@page "/notes" @attribute [Authorize] @implements IAsyncDisposable @using Core.Entities @using Core.Services @using WebApp.Services @using WebApp.Components.Shared.Components @inject INotesService NotesService @inject INoteNamingService NoteNamingService @inject IDialogService DialogService @inject ISnackbar Snackbar @inject NavigationManager NavigationManager @(_showRemoved ? "Show Active" : "Show Removed") Create Note @if (_isLoading) { } else if (!_notes.Any()) { No notes yet. Create your first note to get started! } else { @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 (!NoteNamingService.IsPageNote(note.Title) && !note.IsDeleted && !isExpanded) { } @if (!string.IsNullOrWhiteSpace(note.Content)) { @((MarkupString)MarkdownHelper.ToHtml(note.Content)) } Last updated: @note.UpdatedAt.ToString("g") by @(note.LastModifiedBy ?? "Unknown") @if (!note.IsDeleted) { History Edit Delete } else { Restore } } } @code { // Constants private const int MaxPreviewLength = 60; private const int MaxPinnedNotes = 3; private const int DefaultPageSize = 25; private List _notes = []; private bool _isLoading = true; private bool _showRemoved = false; private int _pinnedCount = 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() { _cancellationTokenSource = new CancellationTokenSource(); } 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; _isLoading = true; try { var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None; 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); } } 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 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 async Task ToggleRemovedFilter() { if (_isDisposed) return; _showRemoved = !_showRemoved; await LoadNotes(); } private async Task OpenCreateDialog() { if (_isDisposed) return; var parameters = new DialogParameters { ["Note"] = new Note { Title = "", Content = "" }, ["IsEdit"] = false }; var dialog = await DialogService.ShowAsync("Create Note", parameters, GetDefaultDialogOptions()); 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.Medium, FullWidth = true, CloseButton = true }; var dialog = await DialogService.ShowAsync("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 }; await DialogService.ShowAsync("Note History", parameters, GetDefaultDialogOptions()); } 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 (NoteNamingService.IsPageNote(note.Title)) 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) { 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 {note.Title}? You can restore it later from the 'Show Removed' view.", 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); } } } 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) { _isDisposed = true; _cancellationTokenSource?.Cancel(); _cancellationTokenSource?.Dispose(); _cancellationTokenSource = null; } await ValueTask.CompletedTask; } }