diff --git a/Core/Entities/Note.cs b/Core/Entities/Note.cs index c460b23..1d54a5e 100644 --- a/Core/Entities/Note.cs +++ b/Core/Entities/Note.cs @@ -26,5 +26,11 @@ public class Note [Display(Name = "Last Modified By")] public string? LastModifiedBy { get; set; } + [Display(Name = "Is Pinned")] + public bool IsPinned { get; set; } + + [Display(Name = "Is Deleted")] + public bool IsDeleted { get; set; } + public List NoteHistories { get; } = []; } diff --git a/Data/Configurations/NoteConfiguration.cs b/Data/Configurations/NoteConfiguration.cs index a2ae5a0..216eca1 100644 --- a/Data/Configurations/NoteConfiguration.cs +++ b/Data/Configurations/NoteConfiguration.cs @@ -13,6 +13,8 @@ namespace Data.Configurations // Indexes builder.HasIndex(n => n.Title); builder.HasIndex(n => n.CreatedAt); + builder.HasIndex(n => n.IsPinned); + builder.HasIndex(n => n.IsDeleted); // Constraints builder.Property(n => n.Title) diff --git a/Data/Migrations/20260115185640_AddNotesAndNoteHistory.Designer.cs b/Data/Migrations/20260116235231_AddNoteIsPinnedAndIsDeleted.Designer.cs similarity index 97% rename from Data/Migrations/20260115185640_AddNotesAndNoteHistory.Designer.cs rename to Data/Migrations/20260116235231_AddNoteIsPinnedAndIsDeleted.Designer.cs index a67869a..fafdd3e 100644 --- a/Data/Migrations/20260115185640_AddNotesAndNoteHistory.Designer.cs +++ b/Data/Migrations/20260116235231_AddNoteIsPinnedAndIsDeleted.Designer.cs @@ -11,8 +11,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Data.Migrations { [DbContext(typeof(AppDbContext))] - [Migration("20260115185640_AddNotesAndNoteHistory")] - partial class AddNotesAndNoteHistory + [Migration("20260116235231_AddNoteIsPinnedAndIsDeleted")] + partial class AddNoteIsPinnedAndIsDeleted { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -196,6 +196,12 @@ namespace Data.Migrations .HasMaxLength(255) .HasColumnType("TEXT"); + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsPinned") + .HasColumnType("INTEGER"); + b.Property("LastModifiedBy") .HasMaxLength(255) .HasColumnType("TEXT"); @@ -212,6 +218,10 @@ namespace Data.Migrations b.HasIndex("CreatedAt"); + b.HasIndex("IsDeleted"); + + b.HasIndex("IsPinned"); + b.HasIndex("Title"); b.ToTable("Notes"); diff --git a/Data/Migrations/20260115185640_AddNotesAndNoteHistory.cs b/Data/Migrations/20260116235231_AddNoteIsPinnedAndIsDeleted.cs similarity index 86% rename from Data/Migrations/20260115185640_AddNotesAndNoteHistory.cs rename to Data/Migrations/20260116235231_AddNoteIsPinnedAndIsDeleted.cs index 06d0891..0669181 100644 --- a/Data/Migrations/20260115185640_AddNotesAndNoteHistory.cs +++ b/Data/Migrations/20260116235231_AddNoteIsPinnedAndIsDeleted.cs @@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace Data.Migrations { /// - public partial class AddNotesAndNoteHistory : Migration + public partial class AddNoteIsPinnedAndIsDeleted : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -22,7 +22,9 @@ namespace Data.Migrations CreatedAt = table.Column(type: "TEXT", nullable: false), UpdatedAt = table.Column(type: "TEXT", nullable: false), CreatedBy = table.Column(type: "TEXT", maxLength: 255, nullable: true), - LastModifiedBy = table.Column(type: "TEXT", maxLength: 255, nullable: true) + LastModifiedBy = table.Column(type: "TEXT", maxLength: 255, nullable: true), + IsPinned = table.Column(type: "INTEGER", nullable: false), + IsDeleted = table.Column(type: "INTEGER", nullable: false) }, constraints: table => { @@ -73,6 +75,16 @@ namespace Data.Migrations table: "Notes", column: "CreatedAt"); + migrationBuilder.CreateIndex( + name: "IX_Notes_IsDeleted", + table: "Notes", + column: "IsDeleted"); + + migrationBuilder.CreateIndex( + name: "IX_Notes_IsPinned", + table: "Notes", + column: "IsPinned"); + migrationBuilder.CreateIndex( name: "IX_Notes_Title", table: "Notes", diff --git a/Data/Migrations/AppDbContextModelSnapshot.cs b/Data/Migrations/AppDbContextModelSnapshot.cs index a25de98..2adc966 100644 --- a/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/Data/Migrations/AppDbContextModelSnapshot.cs @@ -193,6 +193,12 @@ namespace Data.Migrations .HasMaxLength(255) .HasColumnType("TEXT"); + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsPinned") + .HasColumnType("INTEGER"); + b.Property("LastModifiedBy") .HasMaxLength(255) .HasColumnType("TEXT"); @@ -209,6 +215,10 @@ namespace Data.Migrations b.HasIndex("CreatedAt"); + b.HasIndex("IsDeleted"); + + b.HasIndex("IsPinned"); + b.HasIndex("Title"); b.ToTable("Notes"); diff --git a/WebApp/Components/Features/Teams/Index.razor b/WebApp/Components/Features/Teams/Index.razor index 5169100..da37039 100644 --- a/WebApp/Components/Features/Teams/Index.razor +++ b/WebApp/Components/Features/Teams/Index.razor @@ -1,4 +1,4 @@ -@using Microsoft.EntityFrameworkCore +@using Microsoft.EntityFrameworkCore @using WebApp.Components.Shared.Components @using WebApp.Models @page "/teams" @@ -19,6 +19,7 @@ OnClick="ToggleRegionalFilter"> @(_showRegionalOnly ? "Showing Regional Only" : "Show Regional Only") + diff --git a/WebApp/Components/Pages/Home.razor b/WebApp/Components/Pages/Home.razor index 82de0cf..fa75aab 100644 --- a/WebApp/Components/Pages/Home.razor +++ b/WebApp/Components/Pages/Home.razor @@ -2,8 +2,14 @@ @attribute [Authorize] @using Microsoft.EntityFrameworkCore @using WebApp.Models +@using Core.Entities +@using WebApp.Services +@using WebApp.Components.Shared.Components @inject IConfiguration Configuration @inject AppDbContext Context +@inject INotesService NotesService +@inject IDialogService DialogService +@implements IAsyncDisposable @Configuration["ChapterSettings:Name"] - TSA Chapter Organizer @@ -26,6 +32,19 @@ +@if (_pinnedNotes.Any()) +{ + + Pinned Notes + + + @foreach (var note in _pinnedNotes) + { + + } + +} + @if (!_hasStudents) { @@ -182,13 +201,54 @@ else private int _teamCount; private int _individualTeamsCount; private int _groupTeamsCount; + private List _pinnedNotes = []; + private CancellationTokenSource? _cancellationTokenSource; + private bool _isDisposed = false; private bool _hasStudents => _studentCount > 0; private bool _hasTeams => _teamCount > 0; + protected override void OnInitialized() + { + _cancellationTokenSource = new CancellationTokenSource(); + } + protected override async Task OnInitializedAsync() { await LoadStatistics(); + await LoadPinnedNotes(); + } + + private async Task HandleNoteChanged() + { + if (!_isDisposed) + { + await LoadPinnedNotes(); + StateHasChanged(); + } + } + + private async Task LoadPinnedNotes() + { + if (_isDisposed) return; + + try + { + var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None; + _pinnedNotes = (await NotesService.GetPinnedNotesAsync()).ToList(); + } + catch (TaskCanceledException) + { + // Component was disposed, ignore + } + catch (JSDisconnectedException) + { + // JS connection lost, ignore + } + catch (Exception) + { + // Error loading pinned notes - ignore + } } private async Task LoadStatistics() @@ -225,4 +285,16 @@ else _individualTeamsCount = contextTeams.Count(e => e.Event.EventFormat == EventFormat.Individual); _groupTeamsCount = contextTeams.Count(e => e.Event.EventFormat == EventFormat.Team); } + + public async ValueTask DisposeAsync() + { + if (!_isDisposed) + { + _isDisposed = true; + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = null; + } + await ValueTask.CompletedTask; + } } diff --git a/WebApp/Components/Pages/NoteHistoryDialog.razor b/WebApp/Components/Pages/NoteHistoryDialog.razor index a0af44f..18cb6ff 100644 --- a/WebApp/Components/Pages/NoteHistoryDialog.razor +++ b/WebApp/Components/Pages/NoteHistoryDialog.razor @@ -97,6 +97,8 @@ "Created" => Color.Success, "Updated" => Color.Info, "Deleted" => Color.Error, + "Soft Deleted" => Color.Error, + "Restored" => Color.Success, _ => Color.Default }; } @@ -110,7 +112,7 @@ var options = new DialogOptions { - MaxWidth = MaxWidth.Large, + MaxWidth = MaxWidth.Medium, FullWidth = true, CloseButton = true }; diff --git a/WebApp/Components/Pages/Notes.razor b/WebApp/Components/Pages/Notes.razor index ed08fae..6ca1973 100644 --- a/WebApp/Components/Pages/Notes.razor +++ b/WebApp/Components/Pages/Notes.razor @@ -7,9 +7,16 @@ @inject INotesService NotesService @inject IDialogService DialogService @inject ISnackbar Snackbar +@inject NavigationManager NavigationManager + + @(_showRemoved ? "Show Active" : "Show Removed") + - @foreach (var note in _notes) + @foreach (var note in _paginatedNotes) { - - + var noteId = note.Id; + var initialExpanded = _selectedNoteId.HasValue && noteId == _selectedNoteId.Value; + if (!_expandedNotes.ContainsKey(noteId)) + { + _expandedNotes[noteId] = initialExpanded; + } + var isExpanded = GetNoteExpanded(noteId); + + + + + + @GetNoteHeaderText(note, isExpanded) + + + @if (!note.Title.StartsWith("@") && !note.IsDeleted && !isExpanded) + { + + } + + + + @if (!string.IsNullOrWhiteSpace(note.Content)) { @@ -51,40 +89,124 @@ Last updated: @note.UpdatedAt.ToString("g") by @(note.LastModifiedBy ?? "Unknown") - - History - - - Edit - - - Delete - + @if (!note.IsDeleted) + { + + History + + + Edit + + + Delete + + } + else + { + + Restore + + } + } + + @if (_totalPages > 1 || _currentPageSize != DefaultPageSize) + { + + + Items per page: + + 10 + 25 + 50 + 100 + + + + @if (_totalPages > 1) + { + + } + + + Showing @_displayStart to @_displayEnd of @_totalNotes + + + } } @code { + // Constants + private const int MaxPreviewLength = 60; + private const int MaxPinnedNotes = 3; + private const int DefaultPageSize = 25; + private List _notes = []; + private List _paginatedNotes = []; private bool _isLoading = true; + private bool _showRemoved = false; + private int _currentPage = 0; + private int _currentPageSize = DefaultPageSize; + private int _pinnedCount = 0; + + private int CurrentPageSize + { + get => _currentPageSize; + set + { + if (_currentPageSize != value) + { + _currentPageSize = value; + _currentPage = 0; // Reset to first page when page size changes + UpdatePagination(); + StateHasChanged(); + } + } + } + private int _totalNotes = 0; + private int _totalPages = 0; + private int _displayStart = 0; + private int _displayEnd = 0; private CancellationTokenSource? _cancellationTokenSource; private bool _isDisposed = false; + private int? _selectedNoteId; + private Dictionary _expandedNotes = new(); + + + private static DialogOptions GetDefaultDialogOptions() => new() + { + MaxWidth = MaxWidth.Medium, + FullWidth = true, + CloseButton = true + }; + + [SupplyParameterFromQuery] + private int? Id { get; set; } protected override void OnInitialized() { @@ -93,9 +215,34 @@ protected override async Task OnInitializedAsync() { + _selectedNoteId = Id; await LoadNotes(); + + // Clear the query parameter after loading + if (Id.HasValue) + { + NavigationManager.NavigateTo("/notes", replace: true); + } } + private bool GetNoteExpanded(int noteId) + { + if (_expandedNotes.ContainsKey(noteId)) + { + return _expandedNotes[noteId]; + } + return _selectedNoteId.HasValue && noteId == _selectedNoteId.Value; + } + + private void OnPanelExpandedChanged(int noteId, bool expanded) + { + if (_isDisposed) return; + + _expandedNotes[noteId] = expanded; + InvokeAsync(StateHasChanged); + } + + private async Task LoadNotes() { if (_isDisposed) return; @@ -104,7 +251,28 @@ try { var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None; - _notes = (await NotesService.GetNotesAsync()).ToList(); + + if (_showRemoved) + { + _notes = (await NotesService.GetDeletedNotesAsync()).ToList(); + } + else + { + _notes = (await NotesService.GetNotesAsync(false)).ToList(); + } + + // Update pinned count cache + _pinnedCount = _notes.Count(n => n.IsPinned && !n.Title.StartsWith("@") && !n.IsDeleted); + + // Clean up expanded state for notes that no longer exist + var existingNoteIds = _notes.Select(n => n.Id).ToHashSet(); + var notesToRemove = _expandedNotes.Keys.Where(id => !existingNoteIds.Contains(id)).ToList(); + foreach (var id in notesToRemove) + { + _expandedNotes.Remove(id); + } + + UpdatePagination(); } catch (TaskCanceledException) { @@ -131,6 +299,69 @@ } } + private string GetNoteHeaderText(Note note, bool isExpanded) + { + // When expanded, only show title (no preview) + if (isExpanded || string.IsNullOrWhiteSpace(note.Content)) + { + return note.Title; + } + + var preview = GetPreview(note.Content); + if (string.IsNullOrWhiteSpace(preview)) + { + return note.Title; + } + + return $"{note.Title} — {preview}"; + } + + private string GetPreview(string? content) + { + return MarkdownHelper.StripMarkdownPreview(content, MaxPreviewLength); + } + + private void UpdatePagination() + { + _totalNotes = _notes.Count; + _totalPages = (int)Math.Ceiling((double)_totalNotes / _currentPageSize); + + // Ensure current page is valid + if (_currentPage >= _totalPages && _totalPages > 0) + { + _currentPage = _totalPages - 1; + } + else if (_currentPage < 0) + { + _currentPage = 0; + } + + // Calculate paginated notes + var startIndex = _currentPage * _currentPageSize; + _paginatedNotes = _notes.Skip(startIndex).Take(_currentPageSize).ToList(); + + // Calculate display range + _displayStart = _totalNotes > 0 ? startIndex + 1 : 0; + _displayEnd = Math.Min(startIndex + _currentPageSize, _totalNotes); + } + + private void OnPageChanged(int newPage) + { + if (_isDisposed) return; + + _currentPage = newPage - 1; // MudPagination is 1-based, we use 0-based + UpdatePagination(); + StateHasChanged(); + } + + private async Task ToggleRemovedFilter() + { + if (_isDisposed) return; + + _showRemoved = !_showRemoved; + await LoadNotes(); + } + private async Task OpenCreateDialog() { if (_isDisposed) return; @@ -141,14 +372,7 @@ ["IsEdit"] = false }; - var options = new DialogOptions - { - MaxWidth = MaxWidth.Large, - FullWidth = true, - CloseButton = true - }; - - var dialog = await DialogService.ShowAsync("Create Note", parameters, options); + var dialog = await DialogService.ShowAsync("Create Note", parameters, GetDefaultDialogOptions()); var result = await dialog.Result; if (!result.Canceled && !_isDisposed) @@ -169,7 +393,7 @@ var options = new DialogOptions { - MaxWidth = MaxWidth.Large, + MaxWidth = MaxWidth.Medium, FullWidth = true, CloseButton = true }; @@ -192,14 +416,61 @@ ["NoteId"] = noteId }; - var options = new DialogOptions - { - MaxWidth = MaxWidth.Large, - FullWidth = true, - CloseButton = true - }; + await DialogService.ShowAsync("Note History", parameters, GetDefaultDialogOptions()); + } - await DialogService.ShowAsync("Note History", parameters, options); + private async Task TogglePin(Note note) + { + if (_isDisposed) return; + + try + { + await NotesService.TogglePinNoteAsync(note.Id); + + if (!_isDisposed) + { + // Reload to get updated state before showing message + await LoadNotes(); + var updatedNote = _notes.FirstOrDefault(n => n.Id == note.Id); + if (updatedNote != null) + { + Snackbar.Add($"Note '{updatedNote.Title}' {(updatedNote.IsPinned ? "pinned" : "unpinned")}", Severity.Success); + } + } + } + catch (TaskCanceledException) + { + // Component was disposed, ignore + } + catch (JSDisconnectedException) + { + // JS connection lost, ignore + } + catch (Exception ex) + { + if (!_isDisposed) + { + Snackbar.Add($"Error toggling pin: {ex.Message}", Severity.Error); + } + } + } + + private bool IsPinDisabled(Note note) + { + // Can always unpin + if (note.IsPinned) + return false; + + // Can't pin page notes + if (note.Title.StartsWith("@")) + return true; + + // Can't pin deleted notes + if (note.IsDeleted) + return true; + + // Check if already at max pinned notes (using cached count) + return _pinnedCount >= MaxPinnedNotes; } private async Task DeleteNote(Note note) @@ -246,6 +517,37 @@ } } + private async Task RestoreNote(Note note) + { + if (_isDisposed) return; + + try + { + await NotesService.RestoreNoteAsync(note.Id); + + if (!_isDisposed) + { + Snackbar.Add($"Note '{note.Title}' restored", Severity.Success); + await LoadNotes(); + } + } + catch (TaskCanceledException) + { + // Component was disposed, ignore + } + catch (JSDisconnectedException) + { + // JS connection lost, ignore + } + catch (Exception ex) + { + if (!_isDisposed) + { + Snackbar.Add($"Error restoring note: {ex.Message}", Severity.Error); + } + } + } + public async ValueTask DisposeAsync() { if (!_isDisposed) diff --git a/WebApp/Components/Shared/Components/NoteCard.razor b/WebApp/Components/Shared/Components/NoteCard.razor new file mode 100644 index 0000000..2d33156 --- /dev/null +++ b/WebApp/Components/Shared/Components/NoteCard.razor @@ -0,0 +1,67 @@ +@namespace WebApp.Components.Shared.Components +@inject IDialogService DialogService + +@* Reuse dialog options pattern - could be extracted if used in more places *@ + + + + +
+ + @Note.Title + @if (Note.IsPinned) + { + + } +
+ + @if (!string.IsNullOrWhiteSpace(PreviewText)) + { + + @PreviewText + + } + else + { + + No content + + } +
+
+
+ +@code { + [Parameter] + public Note Note { get; set; } = null!; + + [Parameter] + public EventCallback OnNoteChanged { get; set; } + + private const int CardPreviewMaxLength = 150; + private string PreviewText => MarkdownHelper.StripMarkdownPreview(Note.Content, CardPreviewMaxLength); + + private async Task OpenNoteDialog() + { + var parameters = new DialogParameters + { + ["NoteId"] = Note.Id + }; + + var options = new DialogOptions + { + MaxWidth = MaxWidth.Medium, + FullWidth = true, + CloseButton = true + }; + + var dialog = await DialogService.ShowAsync("Note", parameters, options); + var result = await dialog.Result; + + // If dialog was closed and result indicates a change (e.g., note deleted or unpinned) + if (result != null && !result.Canceled) + { + await OnNoteChanged.InvokeAsync(); + } + } +} diff --git a/WebApp/Components/Shared/Components/NoteViewDialog.razor b/WebApp/Components/Shared/Components/NoteViewDialog.razor new file mode 100644 index 0000000..81d6623 --- /dev/null +++ b/WebApp/Components/Shared/Components/NoteViewDialog.razor @@ -0,0 +1,144 @@ +@namespace WebApp.Components.Shared.Components +@inject INotesService NotesService +@inject ISnackbar Snackbar +@inject NavigationManager NavigationManager +@implements IAsyncDisposable + + + + @if (_isLoading) + { + + } + else + { + + @_note.Title + @if (!string.IsNullOrWhiteSpace(_note.Content)) + { + +
+ @((MarkupString)MarkdownHelper.ToHtml(_note.Content)) +
+
+ } + else + { + + No content yet. Click Edit to add content. + + } +
+ } +
+ + + + Edit in Notes + + Close + +
+ +@code { + [CascadingParameter] + IMudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] + public int NoteId { get; set; } + + private Note _note = null!; + private bool _isLoading = true; + private CancellationTokenSource? _cancellationTokenSource; + private bool _isDisposed = false; + + protected override void OnInitialized() + { + _cancellationTokenSource = new CancellationTokenSource(); + } + + protected override async Task OnInitializedAsync() + { + await LoadNote(); + } + + private async Task LoadNote() + { + if (_isDisposed) return; + + try + { + var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None; + var note = await NotesService.GetNoteAsync(NoteId); + + if (note != null) + { + _note = new Note + { + Id = note.Id, + Title = note.Title, + Content = note.Content ?? "", + IsPinned = note.IsPinned + }; + } + else + { + if (_isDisposed) return; + Snackbar.Add("Note not found", Severity.Error); + MudDialog.Cancel(); + } + } + catch (TaskCanceledException) + { + // Component was disposed, ignore + } + catch (JSDisconnectedException) + { + // JS connection lost, ignore + } + catch (Exception ex) + { + if (!_isDisposed) + { + Snackbar.Add($"Error loading note: {ex.Message}", Severity.Error); + } + } + finally + { + if (!_isDisposed) + { + _isLoading = false; + StateHasChanged(); + } + } + } + + private void NavigateToNotes() + { + if (_isDisposed) return; + MudDialog.Close(); + NavigationManager.NavigateTo($"/notes?id={_note.Id}"); + } + + private void Cancel() + { + if (_isDisposed) return; + MudDialog.Cancel(); + } + + public async ValueTask DisposeAsync() + { + if (!_isDisposed) + { + _isDisposed = true; + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = null; + } + await ValueTask.CompletedTask; + } +} diff --git a/WebApp/Components/Shared/Components/PageNoteButton.razor b/WebApp/Components/Shared/Components/PageNoteButton.razor new file mode 100644 index 0000000..00bde98 --- /dev/null +++ b/WebApp/Components/Shared/Components/PageNoteButton.razor @@ -0,0 +1,134 @@ +@namespace WebApp.Components.Shared.Components +@inject INotesService NotesService +@inject IDialogService DialogService +@implements IAsyncDisposable + + + @if (!string.IsNullOrEmpty(ButtonText)) + { + @ButtonText + } + + +@code { + [Parameter] + public string PageIdentifier { get; set; } = null!; + + [Parameter] + public string? Icon { get; set; } + + [Parameter] + public Variant Variant { get; set; } = Variant.Outlined; + + [Parameter] + public Size Size { get; set; } = Size.Medium; + + [Parameter] + public string? ButtonText { get; set; } + + [Parameter] + public string? Tooltip { get; set; } + + private string IconValue => Icon ?? Icons.Material.Filled.Note; + private string TooltipText => Tooltip ?? $"Page notes for {PageIdentifier}"; + private bool _hasContent = false; + private Color ButtonColor => _hasContent ? Color.Success : (Variant == Variant.Filled ? Color.Primary : Color.Default); + private CancellationTokenSource? _cancellationTokenSource; + private bool _isDisposed = false; + + protected override void OnInitialized() + { + _cancellationTokenSource = new CancellationTokenSource(); + } + + protected override async Task OnInitializedAsync() + { + await CheckNoteContent(); + } + + private async Task CheckNoteContent() + { + if (_isDisposed) return; + + try + { + var note = await NotesService.GetPageNoteAsync(PageIdentifier); + _hasContent = note != null && !string.IsNullOrWhiteSpace(note.Content); + + if (!_isDisposed) + { + StateHasChanged(); + } + } + catch (TaskCanceledException) + { + // Component was disposed, ignore + } + catch (JSDisconnectedException) + { + // JS connection lost, ignore + } + catch (Exception) + { + // Error checking note - ignore + } + } + + private async Task OpenDialog() + { + if (_isDisposed) return; + + try + { + var parameters = new DialogParameters + { + ["PageIdentifier"] = PageIdentifier + }; + + var options = new DialogOptions + { + MaxWidth = MaxWidth.Medium, + FullWidth = true, + CloseButton = true + }; + + var dialog = await DialogService.ShowAsync($"Page Notes: {PageIdentifier}", parameters, options); + var result = await dialog.Result; + + // Refresh content indicator after dialog closes + if (!result.Canceled && !_isDisposed) + { + await CheckNoteContent(); + } + } + catch (TaskCanceledException) + { + // Component was disposed, ignore + } + catch (JSDisconnectedException) + { + // JS connection lost, ignore + } + catch (Exception) + { + // Error opening dialog - could show snackbar if we had access + } + } + + public async ValueTask DisposeAsync() + { + if (!_isDisposed) + { + _isDisposed = true; + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = null; + } + await ValueTask.CompletedTask; + } +} diff --git a/WebApp/Components/Shared/Components/PageNoteDialog.razor b/WebApp/Components/Shared/Components/PageNoteDialog.razor new file mode 100644 index 0000000..594857c --- /dev/null +++ b/WebApp/Components/Shared/Components/PageNoteDialog.razor @@ -0,0 +1,232 @@ +@namespace WebApp.Components.Shared.Components +@inject INotesService NotesService +@inject ISnackbar Snackbar +@implements IAsyncDisposable + + + + @if (_isLoading) + { + + } + else if (_isEditMode) + { + + Content (Markdown) + + + } + else + { + + @DisplayTitle + @if (!string.IsNullOrWhiteSpace(_note.Content)) + { + +
+ @((MarkupString)MarkdownHelper.ToHtml(_note.Content)) +
+
+ } + else + { + + No content yet. Click Edit to add content. + + } +
+ } +
+ + @if (_isEditMode) + { + Cancel + Save + } + else + { + + + Edit + + Close + } + +
+ +@code { + [CascadingParameter] + IMudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] + public string PageIdentifier { get; set; } = null!; + + private Note _note = null!; + private string _pageNoteTitle = null!; + private string DisplayTitle => $"{PageIdentifier} Note"; + private bool _isLoading = true; + private bool _isEdit = false; + private bool _isEditMode = false; + private CancellationTokenSource? _cancellationTokenSource; + private bool _isDisposed = false; + + protected override void OnInitialized() + { + _cancellationTokenSource = new CancellationTokenSource(); + _pageNoteTitle = $"@{PageIdentifier}"; + _note = new Note + { + Title = _pageNoteTitle, + Content = "" + }; + } + + protected override async Task OnInitializedAsync() + { + await LoadPageNote(); + } + + private async Task LoadPageNote() + { + if (_isDisposed) return; + + try + { + var existingNote = await NotesService.GetPageNoteAsync(PageIdentifier); + + if (existingNote != null) + { + _note = new Note + { + Id = existingNote.Id, + Title = existingNote.Title, + Content = existingNote.Content ?? "" + }; + _isEdit = true; + // If note has no content, open in edit mode + if (string.IsNullOrWhiteSpace(_note.Content)) + { + _isEditMode = true; + } + } + else + { + _note = new Note + { + Title = _pageNoteTitle, + Content = "" + }; + _isEdit = false; + // New note - open in edit mode + _isEditMode = true; + } + } + catch (TaskCanceledException) + { + // Component was disposed, ignore + } + catch (JSDisconnectedException) + { + // JS connection lost, ignore + } + catch (Exception ex) + { + if (!_isDisposed) + { + Snackbar.Add($"Error loading page note: {ex.Message}", Severity.Error); + } + } + finally + { + if (!_isDisposed) + { + _isLoading = false; + StateHasChanged(); + } + } + } + + private async Task Save() + { + if (_isDisposed) return; + + try + { + if (_isEdit) + { + await NotesService.UpdateNoteAsync(_note); + Snackbar.Add("Page note updated successfully", Severity.Success); + } + else + { + await NotesService.CreateNoteAsync(_note); + Snackbar.Add("Page note created successfully", Severity.Success); + } + + if (!_isDisposed) + { + // After saving, switch back to view mode and reload the note + _isEditMode = false; + await LoadPageNote(); + } + } + catch (TaskCanceledException) + { + // Component was disposed, ignore + } + catch (JSDisconnectedException) + { + // JS connection lost, ignore + } + catch (Exception ex) + { + if (!_isDisposed) + { + Snackbar.Add($"Error saving page note: {ex.Message}", Severity.Error); + } + } + } + + private void EnterEditMode() + { + if (_isDisposed) return; + _isEditMode = true; + StateHasChanged(); + } + + private void Cancel() + { + if (_isDisposed) return; + if (_isEditMode) + { + // If in edit mode, cancel goes back to view mode + _isEditMode = false; + // Reload the note to discard changes + _ = LoadPageNote(); + } + else + { + MudDialog.Cancel(); + } + } + + public async ValueTask DisposeAsync() + { + if (!_isDisposed) + { + _isDisposed = true; + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = null; + } + await ValueTask.CompletedTask; + } +} diff --git a/WebApp/Services/INotesService.cs b/WebApp/Services/INotesService.cs index 6c10c64..8834ed4 100644 --- a/WebApp/Services/INotesService.cs +++ b/WebApp/Services/INotesService.cs @@ -5,15 +5,23 @@ namespace WebApp.Services; public interface INotesService { /// - /// Gets all notes. + /// Gets all notes, optionally including deleted notes. /// - Task> GetNotesAsync(); + /// If true, includes soft-deleted notes. Defaults to false. + Task> GetNotesAsync(bool includeDeleted = false); /// /// Gets a single note by ID. /// Task GetNoteAsync(int id); + /// + /// Gets a page-specific note by page identifier. + /// + /// The page identifier (e.g., "Teams", "Registration") + /// The note with title "@{pageIdentifier}" or null if not found + Task GetPageNoteAsync(string pageIdentifier); + /// /// Gets all history entries for a note. /// @@ -33,4 +41,24 @@ public interface INotesService /// Deletes a note and creates a deletion history entry. /// Task DeleteNoteAsync(int id); + + /// + /// Gets up to 3 pinned notes, excluding page notes and deleted notes, ordered by most recently updated. + /// + Task> GetPinnedNotesAsync(); + + /// + /// Toggles the pin status of a note, enforcing the 3-note limit. + /// + Task TogglePinNoteAsync(int noteId); + + /// + /// Gets all soft-deleted notes. + /// + Task> GetDeletedNotesAsync(); + + /// + /// Restores a soft-deleted note. + /// + Task RestoreNoteAsync(int noteId); } diff --git a/WebApp/Services/MarkdownHelper.cs b/WebApp/Services/MarkdownHelper.cs index 5ee8d1b..2aa8555 100644 --- a/WebApp/Services/MarkdownHelper.cs +++ b/WebApp/Services/MarkdownHelper.cs @@ -12,6 +12,22 @@ public static class MarkdownHelper .UseAdvancedExtensions() .Build(); + // Compiled regex patterns for stripping markdown + private static readonly Regex MarkdownLinkRegex = + new(@"\[([^\]]+)\]\([^\)]+\)", RegexOptions.Compiled); + private static readonly Regex BoldRegex = + new(@"\*\*([^\*]+)\*\*", RegexOptions.Compiled); + private static readonly Regex ItalicRegex = + new(@"\*([^\*]+)\*", RegexOptions.Compiled); + private static readonly Regex InlineCodeRegex = + new(@"`([^`]+)`", RegexOptions.Compiled); + private static readonly Regex HeaderRegex = + new(@"#+\s+", RegexOptions.Compiled); + private static readonly Regex NewlineRegex = + new(@"\n+", RegexOptions.Compiled); + private static readonly Regex HtmlTagRegex = + new(@"<[^>]+>", RegexOptions.Compiled); + /// /// Converts markdown text to HTML. /// @@ -45,4 +61,35 @@ public static class MarkdownHelper return html; } + + /// + /// Strips markdown formatting from content and returns plain text for preview. + /// + /// The markdown content to strip. + /// Maximum length of the preview. If 0 or negative, no truncation is performed. + /// Plain text preview with markdown formatting removed. + public static string StripMarkdownPreview(string? content, int maxLength = 0) + { + if (string.IsNullOrWhiteSpace(content)) + return string.Empty; + + // Strip markdown formatting for preview using compiled regex + var plainText = MarkdownLinkRegex.Replace(content, "$1"); // Replace markdown links with link text + plainText = BoldRegex.Replace(plainText, "$1"); // Remove bold + plainText = ItalicRegex.Replace(plainText, "$1"); // Remove italic + plainText = InlineCodeRegex.Replace(plainText, "$1"); // Remove inline code + plainText = HeaderRegex.Replace(plainText, ""); // Remove headers + plainText = NewlineRegex.Replace(plainText, " "); // Replace newlines with spaces + plainText = HtmlTagRegex.Replace(plainText, ""); // Remove HTML tags + + plainText = plainText.Trim(); + + // Truncate if maxLength is specified and content exceeds it + if (maxLength > 0 && plainText.Length > maxLength) + { + return plainText.Substring(0, maxLength - 3) + "..."; + } + + return plainText; + } } diff --git a/WebApp/Services/NotesService.cs b/WebApp/Services/NotesService.cs index a6cd120..0adbff5 100644 --- a/WebApp/Services/NotesService.cs +++ b/WebApp/Services/NotesService.cs @@ -30,11 +30,20 @@ public class NotesService : INotesService return user.FindFirstValue(ClaimTypes.Email) ?? user.Identity?.Name; } - public async Task> GetNotesAsync() + public async Task> GetNotesAsync(bool includeDeleted = false) { - return await _context.Notes + var query = _context.Notes .AsNoTracking() - .OrderByDescending(n => n.UpdatedAt) + .AsQueryable(); + + if (!includeDeleted) + { + query = query.Where(n => !n.IsDeleted); + } + + return await query + .OrderBy(n => n.Title.StartsWith("@") ? 1 : 0) // Non-page notes first (0), page notes last (1) + .ThenByDescending(n => n.UpdatedAt) // Within each group, order by most recently updated .ToListAsync(); } @@ -45,6 +54,15 @@ public class NotesService : INotesService .FirstOrDefaultAsync(n => n.Id == id); } + public async Task GetPageNoteAsync(string pageIdentifier) + { + var pageNoteTitle = $"@{pageIdentifier}"; + return await _context.Notes + .AsNoTracking() + .Where(n => n.Title == pageNoteTitle && !n.IsDeleted) + .FirstOrDefaultAsync(); + } + public async Task> GetNoteHistoryAsync(int noteId) { return await _context.NoteHistories @@ -147,16 +165,134 @@ public class NotesService : INotesService Content = note.Content, ModifiedBy = userEmail, ModifiedAt = now, - ChangeType = "Deleted" + ChangeType = "Soft Deleted" }; _context.NoteHistories.Add(history); - // Delete the note (cascade will handle history, but we want to keep history) - _context.Notes.Remove(note); + // Soft delete - set IsDeleted flag instead of removing + note.IsDeleted = true; + note.UpdatedAt = now; + note.LastModifiedBy = userEmail; await _context.SaveChangesAsync(); - _logger.LogInformation("Note deleted: {NoteId} by {User}", id, userEmail); + _logger.LogInformation("Note soft deleted: {NoteId} by {User}", id, userEmail); + } + + public async Task> GetPinnedNotesAsync() + { + return await _context.Notes + .AsNoTracking() + .Where(n => n.IsPinned && !n.Title.StartsWith("@") && !n.IsDeleted) + .OrderByDescending(n => n.UpdatedAt) + .Take(3) + .ToListAsync(); + } + + public async Task TogglePinNoteAsync(int noteId) + { + var note = await _context.Notes + .FirstOrDefaultAsync(n => n.Id == noteId); + + if (note == null) + { + throw new InvalidOperationException($"Note with ID {noteId} not found."); + } + + // Prevent pinning page notes + if (note.Title.StartsWith("@")) + { + throw new InvalidOperationException("Page notes cannot be pinned."); + } + + var userEmail = GetCurrentUserEmail(); + var now = DateTime.UtcNow; + + // If pinning and already 3 pinned notes exist (excluding this note if it's already pinned) + if (!note.IsPinned) + { + var pinnedCount = await _context.Notes + .CountAsync(n => n.IsPinned && !n.Title.StartsWith("@") && !n.IsDeleted); + + if (pinnedCount >= 3) + { + // Unpin the oldest pinned note + var oldestPinned = await _context.Notes + .Where(n => n.IsPinned && !n.Title.StartsWith("@") && !n.IsDeleted) + .OrderBy(n => n.UpdatedAt) + .FirstOrDefaultAsync(); + + if (oldestPinned != null) + { + oldestPinned.IsPinned = false; + oldestPinned.UpdatedAt = now; + oldestPinned.LastModifiedBy = userEmail; + _logger.LogInformation("Auto-unpinned note {NoteId} (oldest) when pinning note {NewNoteId} by {User}", + oldestPinned.Id, noteId, userEmail); + } + } + } + + // Toggle the pin status + note.IsPinned = !note.IsPinned; + note.UpdatedAt = now; + note.LastModifiedBy = userEmail; + + await _context.SaveChangesAsync(); + + _logger.LogInformation("Note {NoteId} {Action} by {User}", noteId, + note.IsPinned ? "pinned" : "unpinned", userEmail); + } + + public async Task> GetDeletedNotesAsync() + { + return await _context.Notes + .AsNoTracking() + .Where(n => n.IsDeleted) + .OrderBy(n => n.Title.StartsWith("@") ? 1 : 0) // Non-page notes first (0), page notes last (1) + .ThenByDescending(n => n.UpdatedAt) // Within each group, order by most recently updated + .ToListAsync(); + } + + public async Task RestoreNoteAsync(int noteId) + { + var note = await _context.Notes + .FirstOrDefaultAsync(n => n.Id == noteId); + + if (note == null) + { + throw new InvalidOperationException($"Note with ID {noteId} not found."); + } + + if (!note.IsDeleted) + { + throw new InvalidOperationException($"Note with ID {noteId} is not deleted."); + } + + var userEmail = GetCurrentUserEmail(); + var now = DateTime.UtcNow; + + // Create restore history entry + var history = new NoteHistory + { + NoteId = note.Id, + Title = note.Title, + Content = note.Content, + ModifiedBy = userEmail, + ModifiedAt = now, + ChangeType = "Restored" + }; + + _context.NoteHistories.Add(history); + + // Restore the note + note.IsDeleted = false; + note.UpdatedAt = now; + note.LastModifiedBy = userEmail; + + await _context.SaveChangesAsync(); + + _logger.LogInformation("Note restored: {NoteId} by {User}", noteId, userEmail); } }