483 lines
17 KiB
Plaintext
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;
|
|
}
|
|
}
|