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:
@@ -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<NoteHistory> NoteHistories { get; } = [];
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+12
-2
@@ -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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
@@ -196,6 +196,12 @@ namespace Data.Migrations
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("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");
|
||||
+14
-2
@@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
||||
namespace Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddNotesAndNoteHistory : Migration
|
||||
public partial class AddNoteIsPinnedAndIsDeleted : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
@@ -22,7 +22,9 @@ namespace Data.Migrations
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
|
||||
LastModifiedBy = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true)
|
||||
LastModifiedBy = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
|
||||
IsPinned = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
IsDeleted = table.Column<bool>(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",
|
||||
@@ -193,6 +193,12 @@ namespace Data.Migrations
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("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");
|
||||
|
||||
@@ -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")
|
||||
</MudButton>
|
||||
<PageNoteButton PageIdentifier="Teams" />
|
||||
</ActionButtons>
|
||||
</PageHeader>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
<PageTitle>@Configuration["ChapterSettings:Name"] - TSA Chapter Organizer</PageTitle>
|
||||
|
||||
@@ -26,6 +32,19 @@
|
||||
</div>
|
||||
</MudPaper>
|
||||
|
||||
@if (_pinnedNotes.Any())
|
||||
{
|
||||
<MudPaper Elevation="0" Class="mb-4">
|
||||
<MudText Typo="Typo.h4">Pinned Notes</MudText>
|
||||
</MudPaper>
|
||||
<MudGrid>
|
||||
@foreach (var note in _pinnedNotes)
|
||||
{
|
||||
<NoteCard Note="@note" OnNoteChanged="HandleNoteChanged" />
|
||||
}
|
||||
</MudGrid>
|
||||
}
|
||||
|
||||
@if (!_hasStudents)
|
||||
{
|
||||
<!-- Getting Started: No students yet -->
|
||||
@@ -182,13 +201,54 @@ else
|
||||
private int _teamCount;
|
||||
private int _individualTeamsCount;
|
||||
private int _groupTeamsCount;
|
||||
private List<Note> _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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -7,9 +7,16 @@
|
||||
@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"
|
||||
@@ -33,10 +40,41 @@
|
||||
else
|
||||
{
|
||||
<MudExpansionPanels MultiExpansion="true">
|
||||
@foreach (var note in _notes)
|
||||
@foreach (var note in _paginatedNotes)
|
||||
{
|
||||
<MudExpansionPanel Text="@note.Title"
|
||||
Icon="@Icons.Material.Filled.Note">
|
||||
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))">
|
||||
<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))
|
||||
{
|
||||
@@ -51,6 +89,8 @@
|
||||
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"
|
||||
@@ -71,20 +111,102 @@
|
||||
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>
|
||||
|
||||
@if (_totalPages > 1 || _currentPageSize != DefaultPageSize)
|
||||
{
|
||||
<MudStack Row="true" Spacing="2" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mt-4">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudText Typo="Typo.body2">Items per page:</MudText>
|
||||
<MudSelect T="int" @bind-Value="CurrentPageSize" Variant="Variant.Outlined" Dense="true" Style="width: 100px;">
|
||||
<MudSelectItem Value="10">10</MudSelectItem>
|
||||
<MudSelectItem Value="25">25</MudSelectItem>
|
||||
<MudSelectItem Value="50">50</MudSelectItem>
|
||||
<MudSelectItem Value="100">100</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
|
||||
@if (_totalPages > 1)
|
||||
{
|
||||
<MudPagination Count="@_totalPages"
|
||||
Selected="@(_currentPage + 1)"
|
||||
SelectedChanged="OnPageChanged"
|
||||
ShowFirstButton="true"
|
||||
ShowLastButton="true" />
|
||||
}
|
||||
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
Showing @_displayStart to @_displayEnd of @_totalNotes
|
||||
</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
// Constants
|
||||
private const int MaxPreviewLength = 60;
|
||||
private const int MaxPinnedNotes = 3;
|
||||
private const int DefaultPageSize = 25;
|
||||
|
||||
private List<Note> _notes = [];
|
||||
private List<Note> _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<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()
|
||||
{
|
||||
@@ -93,8 +215,33 @@
|
||||
|
||||
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()
|
||||
{
|
||||
@@ -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<NoteEditDialog>("Create Note", parameters, options);
|
||||
var dialog = await DialogService.ShowAsync<NoteEditDialog>("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<NoteHistoryDialog>("Note History", parameters, GetDefaultDialogOptions());
|
||||
}
|
||||
|
||||
await DialogService.ShowAsync<NoteHistoryDialog>("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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,23 @@ namespace WebApp.Services;
|
||||
public interface INotesService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all notes.
|
||||
/// Gets all notes, optionally including deleted notes.
|
||||
/// </summary>
|
||||
Task<IEnumerable<Note>> GetNotesAsync();
|
||||
/// <param name="includeDeleted">If true, includes soft-deleted notes. Defaults to false.</param>
|
||||
Task<IEnumerable<Note>> GetNotesAsync(bool includeDeleted = false);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single note by ID.
|
||||
/// </summary>
|
||||
Task<Note?> GetNoteAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a page-specific note by page identifier.
|
||||
/// </summary>
|
||||
/// <param name="pageIdentifier">The page identifier (e.g., "Teams", "Registration")</param>
|
||||
/// <returns>The note with title "@{pageIdentifier}" or null if not found</returns>
|
||||
Task<Note?> GetPageNoteAsync(string pageIdentifier);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all history entries for a note.
|
||||
/// </summary>
|
||||
@@ -33,4 +41,24 @@ public interface INotesService
|
||||
/// Deletes a note and creates a deletion history entry.
|
||||
/// </summary>
|
||||
Task DeleteNoteAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Gets up to 3 pinned notes, excluding page notes and deleted notes, ordered by most recently updated.
|
||||
/// </summary>
|
||||
Task<IEnumerable<Note>> GetPinnedNotesAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Toggles the pin status of a note, enforcing the 3-note limit.
|
||||
/// </summary>
|
||||
Task TogglePinNoteAsync(int noteId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all soft-deleted notes.
|
||||
/// </summary>
|
||||
Task<IEnumerable<Note>> GetDeletedNotesAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Restores a soft-deleted note.
|
||||
/// </summary>
|
||||
Task RestoreNoteAsync(int noteId);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// Converts markdown text to HTML.
|
||||
/// </summary>
|
||||
@@ -45,4 +61,35 @@ public static class MarkdownHelper
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips markdown formatting from content and returns plain text for preview.
|
||||
/// </summary>
|
||||
/// <param name="content">The markdown content to strip.</param>
|
||||
/// <param name="maxLength">Maximum length of the preview. If 0 or negative, no truncation is performed.</param>
|
||||
/// <returns>Plain text preview with markdown formatting removed.</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,20 @@ public class NotesService : INotesService
|
||||
return user.FindFirstValue(ClaimTypes.Email) ?? user.Identity?.Name;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Note>> GetNotesAsync()
|
||||
public async Task<IEnumerable<Note>> 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<Note?> GetPageNoteAsync(string pageIdentifier)
|
||||
{
|
||||
var pageNoteTitle = $"@{pageIdentifier}";
|
||||
return await _context.Notes
|
||||
.AsNoTracking()
|
||||
.Where(n => n.Title == pageNoteTitle && !n.IsDeleted)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<NoteHistory>> 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<IEnumerable<Note>> 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<IEnumerable<Note>> 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user