Add Note and NoteHistory entities with configurations and service implementation
This commit introduces the Note and NoteHistory entities, along with their respective configurations for Entity Framework Core. The AppDbContext has been updated to include DbSet properties for both entities. A new INotesService interface and its implementation, NotesService, have been created to handle CRUD operations for notes, including history tracking. Additionally, several Blazor components have been added for note management, including dialogs for editing notes and viewing note history. The UI has been enhanced to support markdown content rendering and improved user interaction for note creation and editing. These changes contribute to a comprehensive note-taking feature within the application.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
@@ -7,6 +7,8 @@
|
||||
<base href="/" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
|
||||
<link href="@Assets["_content/MudBlazor/MudBlazor.min.css"]" rel="stylesheet" />
|
||||
<link href="_content/PSC.Blazor.Components.MarkdownEditor/css/easymde.min.css" rel="stylesheet" />
|
||||
<link href="_content/PSC.Blazor.Components.MarkdownEditor/css/markdowneditor.css" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="app.css" />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<HeadOutlet />
|
||||
@@ -17,9 +19,12 @@
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
</AppErrorBoundary>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
|
||||
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/easymde.min.js"></script>
|
||||
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/markdownEditor.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
@using Core.Entities
|
||||
@using WebApp.Services
|
||||
@using PSC.Blazor.Components.MarkdownEditor
|
||||
@using MudBlazor
|
||||
@inject INotesService NotesService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudStack Spacing="3">
|
||||
<MudTextField @bind-Value="_note.Title"
|
||||
Label="Title"
|
||||
Variant="Variant.Outlined"
|
||||
Required="true"
|
||||
RequiredError="Title is required"
|
||||
MaxLength="200" />
|
||||
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Content (Markdown)</MudText>
|
||||
<MarkdownEditor Value="@_note.Content"
|
||||
ValueChanged="@((string? value) => _note.Content = value)"
|
||||
Placeholder="Enter your markdown content here..."
|
||||
AutoSaveEnabled="false"
|
||||
NativeSpellChecker="false" />
|
||||
</MudStack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Save">Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
IMudDialogInstance MudDialog { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public Note Note { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public bool IsEdit { get; set; }
|
||||
|
||||
private Note _note = null!;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_note = new Note
|
||||
{
|
||||
Id = Note.Id,
|
||||
Title = Note.Title ?? "",
|
||||
Content = Note.Content ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_note.Title))
|
||||
{
|
||||
Snackbar.Add("Title is required", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (IsEdit)
|
||||
{
|
||||
await NotesService.UpdateNoteAsync(_note);
|
||||
Snackbar.Add("Note updated successfully", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
await NotesService.CreateNoteAsync(_note);
|
||||
Snackbar.Add("Note created successfully", Severity.Success);
|
||||
}
|
||||
|
||||
MudDialog.Close(DialogResult.Ok(true));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error saving note: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void Cancel()
|
||||
{
|
||||
MudDialog.Cancel();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
@using Core.Entities
|
||||
@using WebApp.Services
|
||||
@using MudBlazor
|
||||
@inject INotesService NotesService
|
||||
@inject IDialogService DialogService
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
@if (_isLoading)
|
||||
{
|
||||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
|
||||
}
|
||||
else if (!_history.Any())
|
||||
{
|
||||
<MudText Typo="Typo.body1" Align="Align.Center" Class="my-4">
|
||||
No history available for this note.
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable Items="@_history" Hover="true" Striped="true" Dense="true">
|
||||
<HeaderContent>
|
||||
<MudTh>Date/Time</MudTh>
|
||||
<MudTh>User</MudTh>
|
||||
<MudTh>Change Type</MudTh>
|
||||
<MudTh>Title</MudTh>
|
||||
<MudTh>Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Date/Time">
|
||||
@context.ModifiedAt.ToString("g")
|
||||
</MudTd>
|
||||
<MudTd DataLabel="User">
|
||||
@(context.ModifiedBy ?? "Unknown")
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Change Type">
|
||||
<MudChip T="string" Size="Size.Small"
|
||||
Color="@GetChangeTypeColor(context.ChangeType)">
|
||||
@context.ChangeType
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Title">
|
||||
@context.Title
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Actions">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Visibility"
|
||||
OnClick="() => ViewVersion(context)"
|
||||
Variant="Variant.Text"
|
||||
Size="Size.Small">
|
||||
View
|
||||
</MudButton>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Close">Close</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
IMudDialogInstance MudDialog { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public int NoteId { get; set; }
|
||||
|
||||
private List<NoteHistory> _history = [];
|
||||
private bool _isLoading = true;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadHistory();
|
||||
}
|
||||
|
||||
private async Task LoadHistory()
|
||||
{
|
||||
try
|
||||
{
|
||||
_history = (await NotesService.GetNoteHistoryAsync(NoteId)).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Error handling - could show snackbar if we had access
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private Color GetChangeTypeColor(string changeType)
|
||||
{
|
||||
return changeType switch
|
||||
{
|
||||
"Created" => Color.Success,
|
||||
"Updated" => Color.Info,
|
||||
"Deleted" => Color.Error,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
|
||||
private async Task ViewVersion(NoteHistory history)
|
||||
{
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
["History"] = history
|
||||
};
|
||||
|
||||
var options = new DialogOptions
|
||||
{
|
||||
MaxWidth = MaxWidth.Large,
|
||||
FullWidth = true,
|
||||
CloseButton = true
|
||||
};
|
||||
|
||||
await DialogService.ShowAsync<NoteVersionViewDialog>("View Version", parameters, options);
|
||||
}
|
||||
|
||||
private void Close()
|
||||
{
|
||||
MudDialog.Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
@using Core.Entities
|
||||
@using WebApp.Services
|
||||
@using MudBlazor
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudStack Spacing="3">
|
||||
<MudText Typo="Typo.h6">@_history.Title</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
Modified: @_history.ModifiedAt.ToString("g") by @(_history.ModifiedBy ?? "Unknown")
|
||||
</MudText>
|
||||
<MudChip T="string" Size="Size.Small" Color="@GetChangeTypeColor(_history.ChangeType)">
|
||||
@_history.ChangeType
|
||||
</MudChip>
|
||||
<MudDivider />
|
||||
@if (!string.IsNullOrWhiteSpace(_history.Content))
|
||||
{
|
||||
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-background-grey);">
|
||||
<div class="markdown-content">
|
||||
@((MarkupString)MarkdownHelper.ToHtml(_history.Content))
|
||||
</div>
|
||||
</MudPaper>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center" Class="my-4">
|
||||
No content in this version.
|
||||
</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Close">Close</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
IMudDialogInstance MudDialog { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public NoteHistory History { get; set; } = null!;
|
||||
|
||||
private NoteHistory _history = null!;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_history = History;
|
||||
}
|
||||
|
||||
private Color GetChangeTypeColor(string changeType)
|
||||
{
|
||||
return changeType switch
|
||||
{
|
||||
"Created" => Color.Success,
|
||||
"Updated" => Color.Info,
|
||||
"Deleted" => Color.Error,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
|
||||
private void Close()
|
||||
{
|
||||
MudDialog.Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
@page "/notes"
|
||||
@attribute [Authorize]
|
||||
@implements IAsyncDisposable
|
||||
@using Core.Entities
|
||||
@using WebApp.Services
|
||||
@using WebApp.Components.Shared.Components
|
||||
@inject INotesService NotesService
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageHeader Title="Notes">
|
||||
<ActionButtons>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="OpenCreateDialog"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary">
|
||||
Create Note
|
||||
</MudButton>
|
||||
</ActionButtons>
|
||||
</PageHeader>
|
||||
|
||||
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||
@if (_isLoading)
|
||||
{
|
||||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
|
||||
}
|
||||
else if (!_notes.Any())
|
||||
{
|
||||
<MudText Typo="Typo.h6" Align="Align.Center" Class="my-8">
|
||||
No notes yet. Create your first note to get started!
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudExpansionPanels MultiExpansion="true">
|
||||
@foreach (var note in _notes)
|
||||
{
|
||||
<MudExpansionPanel Text="@note.Title"
|
||||
Icon="@Icons.Material.Filled.Note">
|
||||
<MudStack Spacing="2">
|
||||
@if (!string.IsNullOrWhiteSpace(note.Content))
|
||||
{
|
||||
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-background-grey);">
|
||||
<div class="markdown-content">
|
||||
@((MarkupString)MarkdownHelper.ToHtml(note.Content))
|
||||
</div>
|
||||
</MudPaper>
|
||||
}
|
||||
<MudStack Row="true" Spacing="2" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
Last updated: @note.UpdatedAt.ToString("g") by @(note.LastModifiedBy ?? "Unknown")
|
||||
</MudText>
|
||||
<MudStack Row="true" Spacing="2">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.History"
|
||||
OnClick="() => OpenHistoryDialog(note.Id)"
|
||||
Variant="Variant.Text"
|
||||
Size="Size.Small">
|
||||
History
|
||||
</MudButton>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Edit"
|
||||
OnClick="() => OpenEditDialog(note)"
|
||||
Variant="Variant.Text"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary">
|
||||
Edit
|
||||
</MudButton>
|
||||
<MudButton StartIcon="@Icons.Material.Outlined.Delete"
|
||||
OnClick="() => DeleteNote(note)"
|
||||
Variant="Variant.Text"
|
||||
Size="Size.Small"
|
||||
Color="Color.Error">
|
||||
Delete
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudExpansionPanel>
|
||||
}
|
||||
</MudExpansionPanels>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
private List<Note> _notes = [];
|
||||
private bool _isLoading = true;
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
private bool _isDisposed = false;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadNotes();
|
||||
}
|
||||
|
||||
private async Task LoadNotes()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
|
||||
_isLoading = true;
|
||||
try
|
||||
{
|
||||
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
_notes = (await NotesService.GetNotesAsync()).ToList();
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// Component was disposed, ignore
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// JS connection lost, ignore
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add($"Error loading notes: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
_isLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenCreateDialog()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
["Note"] = new Note { Title = "", Content = "" },
|
||||
["IsEdit"] = false
|
||||
};
|
||||
|
||||
var options = new DialogOptions
|
||||
{
|
||||
MaxWidth = MaxWidth.Large,
|
||||
FullWidth = true,
|
||||
CloseButton = true
|
||||
};
|
||||
|
||||
var dialog = await DialogService.ShowAsync<NoteEditDialog>("Create Note", parameters, options);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (!result.Canceled && !_isDisposed)
|
||||
{
|
||||
await LoadNotes();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenEditDialog(Note note)
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
["Note"] = note,
|
||||
["IsEdit"] = true
|
||||
};
|
||||
|
||||
var options = new DialogOptions
|
||||
{
|
||||
MaxWidth = MaxWidth.Large,
|
||||
FullWidth = true,
|
||||
CloseButton = true
|
||||
};
|
||||
|
||||
var dialog = await DialogService.ShowAsync<NoteEditDialog>("Edit Note", parameters, options);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (!result.Canceled && !_isDisposed)
|
||||
{
|
||||
await LoadNotes();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenHistoryDialog(int noteId)
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
["NoteId"] = noteId
|
||||
};
|
||||
|
||||
var options = new DialogOptions
|
||||
{
|
||||
MaxWidth = MaxWidth.Large,
|
||||
FullWidth = true,
|
||||
CloseButton = true
|
||||
};
|
||||
|
||||
await DialogService.ShowAsync<NoteHistoryDialog>("Note History", parameters, options);
|
||||
}
|
||||
|
||||
private async Task DeleteNote(Note note)
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
|
||||
try
|
||||
{
|
||||
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
|
||||
var result = await DialogService.ShowMessageBox(
|
||||
"Delete Note",
|
||||
(MarkupString)$"Are you sure you want to delete <b>{note.Title}</b>? This cannot be undone.",
|
||||
yesText: "Yes",
|
||||
noText: "Cancel");
|
||||
|
||||
if (_isDisposed) return;
|
||||
|
||||
if (result == true)
|
||||
{
|
||||
await NotesService.DeleteNoteAsync(note.Id);
|
||||
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add($"Note '{note.Title}' deleted", Severity.Info);
|
||||
await LoadNotes();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// Component was disposed, ignore
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// JS connection lost, ignore
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add($"Error deleting note: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
_isDisposed = true;
|
||||
_cancellationTokenSource?.Cancel();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_cancellationTokenSource = null;
|
||||
}
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
@using WebApp.Models
|
||||
@using WebApp.Models
|
||||
@using WebApp.Authentication
|
||||
@inject IConfiguration Configuration
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
<MudNavLink Href="/events" Icon="@AppIcons.Events">Events</MudNavLink>
|
||||
</MudNavGroup>
|
||||
|
||||
<MudNavGroup Title="Tools" Icon="@Icons.Material.Filled.Build" Expanded="false">
|
||||
<MudNavLink Href="/notes" Icon="@Icons.Material.Filled.Note">Notes</MudNavLink>
|
||||
</MudNavGroup>
|
||||
|
||||
<AuthorizeView Roles="Administrator">
|
||||
<MudDivider Class="my-2"/>
|
||||
<MudNavLink Href="/settings/chapter" Icon="@Icons.Material.Filled.School">Chapter Settings</MudNavLink>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@@ -28,3 +28,5 @@
|
||||
@using Core.Models
|
||||
@using Data
|
||||
@using VisNetwork.Blazor
|
||||
@using PSC.Blazor.Components.MarkdownEditor
|
||||
@using WebApp.Services
|
||||
@@ -193,6 +193,7 @@ builder.Services.AddScoped<Core.Services.IEventOccurrenceParserService>(sp =>
|
||||
});
|
||||
builder.Services.AddScoped<WebApp.Services.FormValidationService>();
|
||||
builder.Services.AddScoped<WebApp.Services.EventDefinitionService>();
|
||||
builder.Services.AddScoped<WebApp.Services.INotesService, WebApp.Services.NotesService>();
|
||||
|
||||
// State container for maintaining state per user connection (Blazor Server)
|
||||
builder.Services.AddScoped<StateContainer>();
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using Core.Entities;
|
||||
|
||||
namespace WebApp.Services;
|
||||
|
||||
public interface INotesService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all notes.
|
||||
/// </summary>
|
||||
Task<IEnumerable<Note>> GetNotesAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single note by ID.
|
||||
/// </summary>
|
||||
Task<Note?> GetNoteAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all history entries for a note.
|
||||
/// </summary>
|
||||
Task<IEnumerable<NoteHistory>> GetNoteHistoryAsync(int noteId);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new note and initial history entry.
|
||||
/// </summary>
|
||||
Task<Note> CreateNoteAsync(Note note);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing note and creates a history entry.
|
||||
/// </summary>
|
||||
Task<Note> UpdateNoteAsync(Note note);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a note and creates a deletion history entry.
|
||||
/// </summary>
|
||||
Task DeleteNoteAsync(int id);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Markdig;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace WebApp.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Helper class for rendering markdown to HTML.
|
||||
/// </summary>
|
||||
public static class MarkdownHelper
|
||||
{
|
||||
private static readonly MarkdownPipeline _pipeline = new MarkdownPipelineBuilder()
|
||||
.UseAdvancedExtensions()
|
||||
.Build();
|
||||
|
||||
/// <summary>
|
||||
/// Converts markdown text to HTML.
|
||||
/// </summary>
|
||||
/// <param name="markdown">The markdown text to convert.</param>
|
||||
/// <returns>HTML string ready for rendering.</returns>
|
||||
public static string ToHtml(string? markdown)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(markdown))
|
||||
return string.Empty;
|
||||
|
||||
var html = Markdown.ToHtml(markdown, _pipeline);
|
||||
|
||||
// Add target="_blank" and rel="noopener noreferrer" to all links
|
||||
html = Regex.Replace(html, @"<a\s+([^>]*?)href=""([^""]*?)""([^>]*?)>",
|
||||
match =>
|
||||
{
|
||||
var beforeHref = match.Groups[1].Value;
|
||||
var href = match.Groups[2].Value;
|
||||
var afterHref = match.Groups[3].Value;
|
||||
|
||||
// Check if target is already present
|
||||
if (Regex.IsMatch(beforeHref + afterHref, @"target\s*=", RegexOptions.IgnoreCase))
|
||||
{
|
||||
return match.Value; // Return unchanged if target already exists
|
||||
}
|
||||
|
||||
// Add target="_blank" and rel="noopener noreferrer"
|
||||
return $"<a {beforeHref}href=\"{href}\" target=\"_blank\" rel=\"noopener noreferrer\"{afterHref}>";
|
||||
},
|
||||
RegexOptions.IgnoreCase);
|
||||
|
||||
return html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using Core.Entities;
|
||||
using Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace WebApp.Services;
|
||||
|
||||
public class NotesService : INotesService
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly ILogger<NotesService> _logger;
|
||||
|
||||
public NotesService(
|
||||
AppDbContext context,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ILogger<NotesService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private string? GetCurrentUserEmail()
|
||||
{
|
||||
var user = _httpContextAccessor.HttpContext?.User;
|
||||
if (user == null)
|
||||
return null;
|
||||
|
||||
return user.FindFirstValue(ClaimTypes.Email) ?? user.Identity?.Name;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Note>> GetNotesAsync()
|
||||
{
|
||||
return await _context.Notes
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(n => n.UpdatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Note?> GetNoteAsync(int id)
|
||||
{
|
||||
return await _context.Notes
|
||||
.Include(n => n.NoteHistories.OrderByDescending(h => h.ModifiedAt))
|
||||
.FirstOrDefaultAsync(n => n.Id == id);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<NoteHistory>> GetNoteHistoryAsync(int noteId)
|
||||
{
|
||||
return await _context.NoteHistories
|
||||
.AsNoTracking()
|
||||
.Where(h => h.NoteId == noteId)
|
||||
.OrderByDescending(h => h.ModifiedAt)
|
||||
.Take(10)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Note> CreateNoteAsync(Note note)
|
||||
{
|
||||
var userEmail = GetCurrentUserEmail();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
note.CreatedAt = now;
|
||||
note.UpdatedAt = now;
|
||||
note.CreatedBy = userEmail;
|
||||
note.LastModifiedBy = userEmail;
|
||||
|
||||
_context.Notes.Add(note);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Create initial history entry
|
||||
var history = new NoteHistory
|
||||
{
|
||||
NoteId = note.Id,
|
||||
Title = note.Title,
|
||||
Content = note.Content,
|
||||
ModifiedBy = userEmail,
|
||||
ModifiedAt = now,
|
||||
ChangeType = "Created"
|
||||
};
|
||||
|
||||
_context.NoteHistories.Add(history);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Note created: {NoteId} by {User}", note.Id, userEmail);
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
public async Task<Note> UpdateNoteAsync(Note note)
|
||||
{
|
||||
var existingNote = await _context.Notes
|
||||
.FirstOrDefaultAsync(n => n.Id == note.Id);
|
||||
|
||||
if (existingNote == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Note with ID {note.Id} not found.");
|
||||
}
|
||||
|
||||
var userEmail = GetCurrentUserEmail();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Create history entry with previous state before updating
|
||||
var history = new NoteHistory
|
||||
{
|
||||
NoteId = existingNote.Id,
|
||||
Title = existingNote.Title,
|
||||
Content = existingNote.Content,
|
||||
ModifiedBy = userEmail,
|
||||
ModifiedAt = now,
|
||||
ChangeType = "Updated"
|
||||
};
|
||||
|
||||
_context.NoteHistories.Add(history);
|
||||
|
||||
// Update the note
|
||||
existingNote.Title = note.Title;
|
||||
existingNote.Content = note.Content;
|
||||
existingNote.UpdatedAt = now;
|
||||
existingNote.LastModifiedBy = userEmail;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Note updated: {NoteId} by {User}", note.Id, userEmail);
|
||||
|
||||
return existingNote;
|
||||
}
|
||||
|
||||
public async Task DeleteNoteAsync(int id)
|
||||
{
|
||||
var note = await _context.Notes
|
||||
.FirstOrDefaultAsync(n => n.Id == id);
|
||||
|
||||
if (note == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Note with ID {id} not found.");
|
||||
}
|
||||
|
||||
var userEmail = GetCurrentUserEmail();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Create deletion history entry
|
||||
var history = new NoteHistory
|
||||
{
|
||||
NoteId = note.Id,
|
||||
Title = note.Title,
|
||||
Content = note.Content,
|
||||
ModifiedBy = userEmail,
|
||||
ModifiedAt = now,
|
||||
ChangeType = "Deleted"
|
||||
};
|
||||
|
||||
_context.NoteHistories.Add(history);
|
||||
|
||||
// Delete the note (cascade will handle history, but we want to keep history)
|
||||
_context.Notes.Remove(note);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Note deleted: {NoteId} by {User}", id, userEmail);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
@@ -33,6 +33,8 @@
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageReference Include="PSC.Blazor.Components.MarkdownEditor" Version="8.0.6" />
|
||||
<PackageReference Include="Markdig" Version="0.37.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -128,4 +128,105 @@
|
||||
body {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Markdown content styling */
|
||||
.markdown-content {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.markdown-content h1,
|
||||
.markdown-content h2,
|
||||
.markdown-content h3,
|
||||
.markdown-content h4,
|
||||
.markdown-content h5,
|
||||
.markdown-content h6 {
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-content h1 {
|
||||
font-size: 2em;
|
||||
border-bottom: 1px solid var(--mud-palette-divider);
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.markdown-content h2 {
|
||||
font-size: 1.5em;
|
||||
border-bottom: 1px solid var(--mud-palette-divider);
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.markdown-content p {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.markdown-content ul,
|
||||
.markdown-content ol {
|
||||
margin: 1em 0;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
.markdown-content li {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.markdown-content a {
|
||||
color: var(--mud-palette-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-content a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
background-color: var(--mud-palette-background-grey);
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
background-color: var(--mud-palette-background-grey);
|
||||
padding: 1em;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.markdown-content pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
border-left: 4px solid var(--mud-palette-primary);
|
||||
padding-left: 1em;
|
||||
margin: 1em 0;
|
||||
color: var(--mud-palette-text-secondary);
|
||||
}
|
||||
|
||||
.markdown-content table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.markdown-content table th,
|
||||
.markdown-content table td {
|
||||
border: 1px solid var(--mud-palette-divider);
|
||||
padding: 0.5em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-content table th {
|
||||
background-color: var(--mud-palette-background-grey);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
Reference in New Issue
Block a user