Add IsPinned and IsDeleted properties to Note entity with corresponding database configurations and migrations

This commit enhances the Note entity by introducing two new properties: IsPinned and IsDeleted, allowing for better management of note visibility and status. The NoteConfiguration class has been updated to include indexes for these properties, improving query performance. Additionally, new migrations have been created to reflect these changes in the database schema. The UI components have been updated to support pinning and restoring notes, enhancing user interaction and functionality within the note management system.
This commit is contained in:
2026-01-16 23:12:18 -05:00
parent 5f2d7b5b31
commit 8b0451c2ec
16 changed files with 1261 additions and 56 deletions
@@ -0,0 +1,67 @@
@namespace WebApp.Components.Shared.Components
@inject IDialogService DialogService
@* Reuse dialog options pattern - could be extracted if used in more places *@
<MudItem xs="12" sm="6" md="4">
<MudCard Elevation="4" Class="pa-4" Style="cursor: pointer; height: 100%;" @onclick="OpenNoteDialog">
<MudCardContent>
<div class="d-flex align-center mb-2">
<MudIcon Icon="@Icons.Material.Filled.Note" Size="Size.Medium" Class="mr-2" />
<MudText Typo="Typo.h6" Class="flex-grow-1">@Note.Title</MudText>
@if (Note.IsPinned)
{
<MudIcon Icon="@Icons.Material.Filled.PushPin" Size="Size.Small" Color="Color.Primary" />
}
</div>
<MudDivider Class="mb-3" />
@if (!string.IsNullOrWhiteSpace(PreviewText))
{
<MudText Typo="Typo.body2" Style="max-height: 100px; overflow: hidden; text-overflow: ellipsis;">
@PreviewText
</MudText>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center" Class="my-4">
No content
</MudText>
}
</MudCardContent>
</MudCard>
</MudItem>
@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<NoteViewDialog>("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();
}
}
}
@@ -0,0 +1,144 @@
@namespace WebApp.Components.Shared.Components
@inject INotesService NotesService
@inject ISnackbar Snackbar
@inject NavigationManager NavigationManager
@implements IAsyncDisposable
<MudDialog>
<DialogContent>
@if (_isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
}
else
{
<MudStack Spacing="3">
<MudText Typo="Typo.h6">@_note.Title</MudText>
@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>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center" Class="my-4">
No content yet. Click Edit to add content.
</MudText>
}
</MudStack>
}
</DialogContent>
<DialogActions>
<MudSpacer />
<MudButton StartIcon="@Icons.Material.Filled.Edit"
Color="Color.Primary"
Variant="Variant.Filled"
OnClick="NavigateToNotes"
Disabled="@_isLoading">
Edit in Notes
</MudButton>
<MudButton OnClick="Cancel">Close</MudButton>
</DialogActions>
</MudDialog>
@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;
}
}
@@ -0,0 +1,134 @@
@namespace WebApp.Components.Shared.Components
@inject INotesService NotesService
@inject IDialogService DialogService
@implements IAsyncDisposable
<MudButton StartIcon="@IconValue"
OnClick="OpenDialog"
Variant="@Variant"
Size="@Size"
Color="@ButtonColor"
Tooltip="@TooltipText">
@if (!string.IsNullOrEmpty(ButtonText))
{
@ButtonText
}
</MudButton>
@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<PageNoteDialog>($"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;
}
}
@@ -0,0 +1,232 @@
@namespace WebApp.Components.Shared.Components
@inject INotesService NotesService
@inject ISnackbar Snackbar
@implements IAsyncDisposable
<MudDialog>
<DialogContent>
@if (_isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
}
else if (_isEditMode)
{
<MudStack Spacing="3">
<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>
}
else
{
<MudStack Spacing="3">
<MudText Typo="Typo.h6">@DisplayTitle</MudText>
@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>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center" Class="my-4">
No content yet. Click Edit to add content.
</MudText>
}
</MudStack>
}
</DialogContent>
<DialogActions>
@if (_isEditMode)
{
<MudButton OnClick="Cancel" Disabled="@_isLoading">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Save" Disabled="@_isLoading">Save</MudButton>
}
else
{
<MudSpacer />
<MudButton StartIcon="@Icons.Material.Filled.Edit"
Color="Color.Primary"
Variant="Variant.Filled"
OnClick="EnterEditMode"
Disabled="@_isLoading">
Edit
</MudButton>
<MudButton OnClick="Cancel">Close</MudButton>
}
</DialogActions>
</MudDialog>
@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;
}
}