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:
2026-01-15 21:47:01 -05:00
parent 5fdda08627
commit 5c4aaf91df
21 changed files with 1710 additions and 5 deletions
+6 -1
View File
@@ -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();
}
}
+260
View File
@@ -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>
+3 -1
View File
@@ -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