Files
chapter-organizer/WebApp/Components/Pages/Notes.razor
T

483 lines
17 KiB
Plaintext

@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
@inject NavigationManager NavigationManager
<PageHeader Title="Notes">
<ActionButtons>
<MudButton StartIcon="@(_showRemoved ? Icons.Material.Filled.List : Icons.Material.Filled.DeleteOutline)"
OnClick="ToggleRemovedFilter"
Variant="Variant.Outlined"
Color="@(_showRemoved ? Color.Warning : Color.Default)">
@(_showRemoved ? "Show Active" : "Show Removed")
</MudButton>
<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
{
<ClientSidePagination T="Note" Items="@_notes" DefaultPageSize="@DefaultPageSize">
<ChildContent Context="paginatedNotes">
<MudExpansionPanels MultiExpansion="true">
@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);
<MudExpansionPanel @key="@($"note-{noteId}")"
Icon="@Icons.Material.Filled.Note"
IsExpanded="@isExpanded"
ExpandedChanged="@((bool expanded) => OnPanelExpandedChanged(noteId, expanded))"
Class="@MarkdownHelper.GetNoteColorClass(noteId)">
<TitleContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="flex-grow-1">
<MudText Typo="Typo.body1" Class="flex-grow-1" Style="min-width: 0;">
<span style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block;">
@GetNoteHeaderText(note, isExpanded)
</span>
</MudText>
@if (!note.Title.StartsWith("@") && !note.IsDeleted && !isExpanded)
{
<MudButton StartIcon="@(note.IsPinned ? Icons.Material.Filled.PushPin : Icons.Material.Outlined.PushPin)"
OnClick="() => TogglePin(note)"
OnClick:StopPropagation="true"
Variant="Variant.Text"
Size="Size.Small"
Color="@(note.IsPinned ? Color.Primary : Color.Default)"
Disabled="@(IsPinDisabled(note))"
Title="@(note.IsPinned ? "Unpin note" : "Pin note")"
Class="flex-shrink-0" />
}
</MudStack>
</TitleContent>
<ChildContent>
<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">
@if (!note.IsDeleted)
{
<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>
}
else
{
<MudButton StartIcon="@Icons.Material.Filled.Restore"
OnClick="() => RestoreNote(note)"
Variant="Variant.Text"
Size="Size.Small"
Color="Color.Success">
Restore
</MudButton>
}
</MudStack>
</MudStack>
</MudStack>
</ChildContent>
</MudExpansionPanel>
}
</MudExpansionPanels>
</ChildContent>
</ClientSidePagination>
}
</MudPaper>
@code {
// Constants
private const int MaxPreviewLength = 60;
private const int MaxPinnedNotes = 3;
private const int DefaultPageSize = 25;
private List<Note> _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<int, bool> _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<NoteEditDialog>("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<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
};
await DialogService.ShowAsync<NoteHistoryDialog>("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 (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)
{
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>? 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;
}
}