Compare commits

...
7 Commits
Author SHA1 Message Date
poprhythm 7679a458e0 Refactor InteractiveChip component for improved control visibility on touch devices
This commit enhances the InteractiveChip component by adjusting the visibility logic for control content based on device type. Controls are now displayed on touch devices when the AlwaysShowControls parameter is true or when the chip is touched, while hover-based visibility is maintained for desktop devices. These changes improve user interaction and accessibility across different platforms, contributing to a more responsive UI.
2026-01-17 11:42:28 -05:00
poprhythm 84b31800ad Add MarkdownTablePasteService and integrate with NoteEditDialog and PageNoteDialog components
This commit introduces the MarkdownTablePasteService to facilitate markdown table pasting functionality. The service is registered in Program.cs and injected into NoteEditDialog and PageNoteDialog components. Additionally, OnAfterRenderAsync lifecycle methods are implemented in both dialog components to initialize the service after the editor is rendered, enhancing the user experience for note editing. This change supports improved markdown handling within the application.
2026-01-17 11:33:57 -05:00
poprhythm b4c11cd0a6 Enhance UI with MudTooltip for action buttons across various components
This commit adds MudTooltip components to action buttons in the Calendar, Events, Students, and Teams features, improving user experience by providing contextual information on button actions. The changes ensure that users receive helpful hints when hovering over buttons, enhancing accessibility and usability throughout the application.
2026-01-17 10:41:46 -05:00
poprhythm e6eb35ee67 Add PageNoteButton to various components for enhanced note management 2026-01-17 10:25:38 -05:00
poprhythm 947d95893f Refactor Home and Notes components to improve note display and pagination
This commit enhances the Home.razor and Notes.razor components by restructuring the note display logic. The Home component now correctly wraps the pinned notes section in a MudPaper component, ensuring consistent styling. The Notes component has been updated to utilize ClientSidePagination for better performance and user experience, replacing the previous pagination logic. This change simplifies the code and improves the overall maintainability of the note management interface.
2026-01-17 09:34:12 -05:00
poprhythm 8b0451c2ec 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.
2026-01-16 23:12:18 -05:00
poprhythm 5f2d7b5b31 Enhance InteractiveChip component with touch support and control visibility
This commit updates the InteractiveChip component to improve user interaction on touch devices. It introduces touch event handling to toggle control visibility, ensuring that controls are always displayed on mobile devices while allowing hover-based visibility on desktop. The AlwaysShowControls parameter has been added to manage control display behavior, enhancing the overall usability of the component. These changes contribute to a more responsive and accessible user interface.
2026-01-15 22:47:55 -05:00
36 changed files with 1848 additions and 140 deletions
+6
View File
@@ -26,5 +26,11 @@ public class Note
[Display(Name = "Last Modified By")] [Display(Name = "Last Modified By")]
public string? LastModifiedBy { get; set; } 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; } = []; public List<NoteHistory> NoteHistories { get; } = [];
} }
+2
View File
@@ -13,6 +13,8 @@ namespace Data.Configurations
// Indexes // Indexes
builder.HasIndex(n => n.Title); builder.HasIndex(n => n.Title);
builder.HasIndex(n => n.CreatedAt); builder.HasIndex(n => n.CreatedAt);
builder.HasIndex(n => n.IsPinned);
builder.HasIndex(n => n.IsDeleted);
// Constraints // Constraints
builder.Property(n => n.Title) builder.Property(n => n.Title)
@@ -11,8 +11,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Data.Migrations namespace Data.Migrations
{ {
[DbContext(typeof(AppDbContext))] [DbContext(typeof(AppDbContext))]
[Migration("20260115185640_AddNotesAndNoteHistory")] [Migration("20260116235231_AddNoteIsPinnedAndIsDeleted")]
partial class AddNotesAndNoteHistory partial class AddNoteIsPinnedAndIsDeleted
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -196,6 +196,12 @@ namespace Data.Migrations
.HasMaxLength(255) .HasMaxLength(255)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<bool>("IsPinned")
.HasColumnType("INTEGER");
b.Property<string>("LastModifiedBy") b.Property<string>("LastModifiedBy")
.HasMaxLength(255) .HasMaxLength(255)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -212,6 +218,10 @@ namespace Data.Migrations
b.HasIndex("CreatedAt"); b.HasIndex("CreatedAt");
b.HasIndex("IsDeleted");
b.HasIndex("IsPinned");
b.HasIndex("Title"); b.HasIndex("Title");
b.ToTable("Notes"); b.ToTable("Notes");
@@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Data.Migrations namespace Data.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class AddNotesAndNoteHistory : Migration public partial class AddNoteIsPinnedAndIsDeleted : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
@@ -22,7 +22,9 @@ namespace Data.Migrations
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false), CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAt = 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), 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 => constraints: table =>
{ {
@@ -73,6 +75,16 @@ namespace Data.Migrations
table: "Notes", table: "Notes",
column: "CreatedAt"); column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_Notes_IsDeleted",
table: "Notes",
column: "IsDeleted");
migrationBuilder.CreateIndex(
name: "IX_Notes_IsPinned",
table: "Notes",
column: "IsPinned");
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_Notes_Title", name: "IX_Notes_Title",
table: "Notes", table: "Notes",
@@ -193,6 +193,12 @@ namespace Data.Migrations
.HasMaxLength(255) .HasMaxLength(255)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<bool>("IsPinned")
.HasColumnType("INTEGER");
b.Property<string>("LastModifiedBy") b.Property<string>("LastModifiedBy")
.HasMaxLength(255) .HasMaxLength(255)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -209,6 +215,10 @@ namespace Data.Migrations
b.HasIndex("CreatedAt"); b.HasIndex("CreatedAt");
b.HasIndex("IsDeleted");
b.HasIndex("IsPinned");
b.HasIndex("Title"); b.HasIndex("Title");
b.ToTable("Notes"); b.ToTable("Notes");
+1
View File
@@ -25,6 +25,7 @@
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/easymde.min.js"></script> <script src="_content/PSC.Blazor.Components.MarkdownEditor/js/easymde.min.js"></script>
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/markdownEditor.js"></script> <script src="_content/PSC.Blazor.Components.MarkdownEditor/js/markdownEditor.js"></script>
<script src="js/markdownTablePaste.js"></script>
</body> </body>
</html> </html>
@@ -12,10 +12,15 @@
<PageHeader Title="Event Calendar" Description="View competition schedules and event occurrences" Icon="@AppIcons.EventCalendar"> <PageHeader Title="Event Calendar" Description="View competition schedules and event occurrences" Icon="@AppIcons.EventCalendar">
<ActionButtons> <ActionButtons>
<MudButton StartIcon="@Icons.Material.Filled.ImportExport" Href="calendar/event-occurrences/import" Variant="Variant.Filled" Color="Color.Primary">Import</MudButton> <MudTooltip Text="Import">
<MudButton StartIcon="@Icons.Material.Filled.ImportExport" Href="calendar/event-occurrences/import" Variant="Variant.Filled" Color="Color.Primary">Import</MudButton>
</MudTooltip>
<AuthorizeView Roles="@AuthRoles.Administrator"> <AuthorizeView Roles="@AuthRoles.Administrator">
<MudButton StartIcon="@Icons.Material.Filled.AdminPanelSettings" Href="calendar/admin" Variant="Variant.Outlined" Color="Color.Default">Admin</MudButton> <MudTooltip Text="Admin">
<MudButton StartIcon="@Icons.Material.Filled.AdminPanelSettings" Href="calendar/admin" Variant="Variant.Outlined" Color="Color.Default">Admin</MudButton>
</MudTooltip>
</AuthorizeView> </AuthorizeView>
<PageNoteButton PageIdentifier="Event Calendar" />
</ActionButtons> </ActionButtons>
</PageHeader> </PageHeader>
@@ -19,10 +19,14 @@
BackButtonUrl="@(ReturnUrl ?? "/events")"> BackButtonUrl="@(ReturnUrl ?? "/events")">
<ActionButtons> <ActionButtons>
<div class="no-print"> <div class="no-print">
<MudButton StartIcon="@Icons.Material.Filled.Print" <MudTooltip Text="Print">
OnClick="PrintPage" <MudButton StartIcon="@Icons.Material.Filled.Print"
Variant="Variant.Outlined">Print</MudButton> OnClick="PrintPage"
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="@($"/events/edit?id={eventdefinition.Id}&returnUrl={ReturnUrl ?? "/events"}")" Variant="Variant.Outlined">Edit</MudButton> Variant="Variant.Outlined">Print</MudButton>
</MudTooltip>
<MudTooltip Text="Edit">
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="@($"/events/edit?id={eventdefinition.Id}&returnUrl={ReturnUrl ?? "/events"}")" Variant="Variant.Outlined">Edit</MudButton>
</MudTooltip>
</div> </div>
</ActionButtons> </ActionButtons>
</PageHeader> </PageHeader>
@@ -10,9 +10,15 @@
<PageHeader Title="Events"> <PageHeader Title="Events">
<ActionButtons> <ActionButtons>
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="events/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton> <MudTooltip Text="Create New">
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="events/printout" Variant="Variant.Outlined">Printable Descriptions</MudButton> <MudButton StartIcon="@Icons.Material.Filled.Create" Href="events/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
<MudButton StartIcon="@Icons.Material.Filled.AccountTree" Href="events/career-mapping" Variant="Variant.Outlined">Career Mapping</MudButton> </MudTooltip>
<MudTooltip Text="Printable Descriptions">
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="events/printout" Variant="Variant.Outlined">Printable Descriptions</MudButton>
</MudTooltip>
<MudTooltip Text="Career Mapping">
<MudButton StartIcon="@Icons.Material.Filled.AccountTree" Href="events/career-mapping" Variant="Variant.Outlined">Career Mapping</MudButton>
</MudTooltip>
</ActionButtons> </ActionButtons>
</PageHeader> </PageHeader>
@@ -1,4 +1,4 @@
@page "/meeting-schedule" @page "/meeting-schedule"
@attribute [Authorize] @attribute [Authorize]
@using System.Text @using System.Text
@using Core.Calculation @using Core.Calculation
@@ -10,7 +10,11 @@
@inject ClipboardService ClipboardService @inject ClipboardService ClipboardService
@inject LocalStorageService LocalStorage @inject LocalStorageService LocalStorage
<PageHeader Title="@($"{Configuration["ChapterSettings:Shortname"]} TSA Schedule {Configuration["ChapterSettings:CompetitionYear"]}")" /> <PageHeader Title="@($"{Configuration["ChapterSettings:Shortname"]} TSA Schedule {Configuration["ChapterSettings:CompetitionYear"]}")">
<ActionButtons>
<PageNoteButton PageIdentifier="Meeting Schedule" />
</ActionButtons>
</PageHeader>
<MudPaper Elevation="2" Class="pa-3 pa-md-6 mt-4"> <MudPaper Elevation="2" Class="pa-3 pa-md-6 mt-4">
<MudGrid> <MudGrid>
@@ -18,10 +18,14 @@
BackButtonUrl="@(ReturnUrl ?? "/students")"> BackButtonUrl="@(ReturnUrl ?? "/students")">
<ActionButtons> <ActionButtons>
<div class="no-print"> <div class="no-print">
<MudButton StartIcon="@Icons.Material.Filled.Print" <MudTooltip Text="Print">
OnClick="PrintPage" <MudButton StartIcon="@Icons.Material.Filled.Print"
Variant="Variant.Outlined">Print</MudButton> OnClick="PrintPage"
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="@($"/students/edit?id={student.Id}&returnUrl={ReturnUrl ?? "/students"}")" Variant="Variant.Outlined">Edit</MudButton> Variant="Variant.Outlined">Print</MudButton>
</MudTooltip>
<MudTooltip Text="Edit">
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="@($"/students/edit?id={student.Id}&returnUrl={ReturnUrl ?? "/students"}")" Variant="Variant.Outlined">Edit</MudButton>
</MudTooltip>
</div> </div>
</ActionButtons> </ActionButtons>
</PageHeader> </PageHeader>
@@ -1,4 +1,4 @@
@page "/students/event-ranking" @page "/students/event-ranking"
@attribute [Authorize] @attribute [Authorize]
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using WebApp.Models @using WebApp.Models
@@ -9,7 +9,11 @@
<PageHeader <PageHeader
Title="Student Event Ranks" Title="Student Event Ranks"
Icon="@AppIcons.EventRank" /> Icon="@AppIcons.EventRank">
<ActionButtons>
<PageNoteButton PageIdentifier="Event Ranking" />
</ActionButtons>
</PageHeader>
@if (_students == null) @if (_students == null)
{ {
@@ -22,12 +22,14 @@ else
ShowBackButton="true" ShowBackButton="true"
BackButtonUrl="/students/event-ranking"> BackButtonUrl="/students/event-ranking">
<ActionButtons> <ActionButtons>
<MudButton StartIcon="@Icons.Material.Filled.Save" <MudTooltip Text="Save Rankings">
OnClick="Save" <MudButton StartIcon="@Icons.Material.Filled.Save"
Color="Color.Primary" OnClick="Save"
Variant="Variant.Filled"> Color="Color.Primary"
Save Rankings Variant="Variant.Filled">
</MudButton> Save Rankings
</MudButton>
</MudTooltip>
</ActionButtons> </ActionButtons>
</PageHeader> </PageHeader>
@@ -10,9 +10,15 @@
<PageHeader Title="Students"> <PageHeader Title="Students">
<ActionButtons> <ActionButtons>
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="students/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton> <MudTooltip Text="Create New">
<MudButton StartIcon="@AppIcons.EventRank" Href="students/event-ranking" Variant="Variant.Outlined">Event Rankings</MudButton> <MudButton StartIcon="@Icons.Material.Filled.Create" Href="students/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
<MudButton StartIcon="@AppIcons.Registration" Href="students/teams" Variant="Variant.Outlined">Registration</MudButton> </MudTooltip>
<MudTooltip Text="Event Rankings">
<MudButton StartIcon="@AppIcons.EventRank" Href="students/event-ranking" Variant="Variant.Outlined">Event Rankings</MudButton>
</MudTooltip>
<MudTooltip Text="Registration">
<MudButton StartIcon="@AppIcons.Registration" Href="students/teams" Variant="Variant.Outlined">Registration</MudButton>
</MudTooltip>
</ActionButtons> </ActionButtons>
</PageHeader> </PageHeader>
@@ -11,12 +11,15 @@
<PageHeader Title="Registration" Icon="@AppIcons.Registration"> <PageHeader Title="Registration" Icon="@AppIcons.Registration">
<ActionButtons> <ActionButtons>
<MudButton StartIcon="@Icons.Material.Filled.FilterAlt" <MudTooltip Text="@(_showRegionalOnly ? "Showing Regional Only" : "Show Regional Only")">
Variant="@(_showRegionalOnly ? Variant.Filled : Variant.Outlined)" <MudButton StartIcon="@Icons.Material.Filled.FilterAlt"
Color="Color.Primary" Variant="@(_showRegionalOnly ? Variant.Filled : Variant.Outlined)"
OnClick="ToggleRegionalFilter"> Color="Color.Primary"
@(_showRegionalOnly ? "Showing Regional Only" : "Show Regional Only") OnClick="ToggleRegionalFilter">
</MudButton> @(_showRegionalOnly ? "Showing Regional Only" : "Show Regional Only")
</MudButton>
</MudTooltip>
<PageNoteButton PageIdentifier="Registration" />
</ActionButtons> </ActionButtons>
</PageHeader> </PageHeader>
@@ -1,4 +1,4 @@
@page "/teams/assignment" @page "/teams/assignment"
@attribute [Authorize] @attribute [Authorize]
@using Core.Calculation @using Core.Calculation
@using Core.Validation @using Core.Validation
@@ -15,9 +15,12 @@
Title="Assignment" Title="Assignment"
Description="Optimized team assignments based on the student event rankings"> Description="Optimized team assignments based on the student event rankings">
<ActionButtons> <ActionButtons>
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="students/event-ranking" Variant="Variant.Outlined"> <MudTooltip Text="Edit Student Event Rankings">
Edit Student Event Rankings <MudButton StartIcon="@Icons.Material.Filled.Edit" Href="students/event-ranking" Variant="Variant.Outlined">
</MudButton> Edit Student Event Rankings
</MudButton>
</MudTooltip>
<PageNoteButton PageIdentifier="Team Assignment" />
</ActionButtons> </ActionButtons>
</PageHeader> </PageHeader>
+10 -6
View File
@@ -19,12 +19,16 @@
BackButtonUrl="@(ReturnUrl ?? "/teams")"> BackButtonUrl="@(ReturnUrl ?? "/teams")">
<ActionButtons> <ActionButtons>
<div class="no-print"> <div class="no-print">
<MudButton StartIcon="@Icons.Material.Filled.Print" <MudTooltip Text="Print">
OnClick="PrintPage" <MudButton StartIcon="@Icons.Material.Filled.Print"
Variant="Variant.Outlined">Print</MudButton> OnClick="PrintPage"
<MudButton StartIcon="@Icons.Material.Filled.Edit" Variant="Variant.Outlined">Print</MudButton>
Href="@($"/teams/edit?id={Team.Id}&returnUrl={ReturnUrl ?? "/teams"}")" </MudTooltip>
Variant="Variant.Outlined">Edit</MudButton> <MudTooltip Text="Edit">
<MudButton StartIcon="@Icons.Material.Filled.Edit"
Href="@($"/teams/edit?id={Team.Id}&returnUrl={ReturnUrl ?? "/teams"}")"
Variant="Variant.Outlined">Edit</MudButton>
</MudTooltip>
</div> </div>
</ActionButtons> </ActionButtons>
</PageHeader> </PageHeader>
+19 -10
View File
@@ -1,4 +1,4 @@
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using WebApp.Components.Shared.Components @using WebApp.Components.Shared.Components
@using WebApp.Models @using WebApp.Models
@page "/teams" @page "/teams"
@@ -10,15 +10,24 @@
<PageHeader Title="Teams"> <PageHeader Title="Teams">
<ActionButtons> <ActionButtons>
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="teams/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton> <MudTooltip Text="Create New">
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="teams/printout" Variant="Variant.Outlined">Printout</MudButton> <MudButton StartIcon="@Icons.Material.Filled.Create" Href="teams/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="teams/handout" Variant="Variant.Outlined">Handout</MudButton> </MudTooltip>
<MudButton StartIcon="@Icons.Material.Filled.FilterAlt" <MudTooltip Text="Printout">
Variant="@(_showRegionalOnly ? Variant.Filled : Variant.Outlined)" <MudButton StartIcon="@Icons.Material.Filled.Print" Href="teams/printout" Variant="Variant.Outlined">Printout</MudButton>
Color="Color.Primary" </MudTooltip>
OnClick="ToggleRegionalFilter"> <MudTooltip Text="Handout">
@(_showRegionalOnly ? "Showing Regional Only" : "Show Regional Only") <MudButton StartIcon="@Icons.Material.Filled.Print" Href="teams/handout" Variant="Variant.Outlined">Handout</MudButton>
</MudButton> </MudTooltip>
<MudTooltip Text="@(_showRegionalOnly ? "Showing Regional Only" : "Show Regional Only")">
<MudButton StartIcon="@Icons.Material.Filled.FilterAlt"
Variant="@(_showRegionalOnly ? Variant.Filled : Variant.Outlined)"
Color="Color.Primary"
OnClick="ToggleRegionalFilter">
@(_showRegionalOnly ? "Showing Regional Only" : "Show Regional Only")
</MudButton>
</MudTooltip>
<PageNoteButton PageIdentifier="Teams" />
</ActionButtons> </ActionButtons>
</PageHeader> </PageHeader>
+71
View File
@@ -2,8 +2,14 @@
@attribute [Authorize] @attribute [Authorize]
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using WebApp.Models @using WebApp.Models
@using Core.Entities
@using WebApp.Services
@using WebApp.Components.Shared.Components
@inject IConfiguration Configuration @inject IConfiguration Configuration
@inject AppDbContext Context @inject AppDbContext Context
@inject INotesService NotesService
@inject IDialogService DialogService
@implements IAsyncDisposable
<PageTitle>@Configuration["ChapterSettings:Name"] - TSA Chapter Organizer</PageTitle> <PageTitle>@Configuration["ChapterSettings:Name"] - TSA Chapter Organizer</PageTitle>
@@ -26,6 +32,18 @@
</div> </div>
</MudPaper> </MudPaper>
@if (_pinnedNotes.Any())
{
<MudPaper Elevation="0" Class="mb-4">
<MudGrid>
@foreach (var note in _pinnedNotes)
{
<NoteCard Note="@note" OnNoteChanged="HandleNoteChanged" />
}
</MudGrid>
</MudPaper>
}
@if (!_hasStudents) @if (!_hasStudents)
{ {
<!-- Getting Started: No students yet --> <!-- Getting Started: No students yet -->
@@ -182,13 +200,54 @@ else
private int _teamCount; private int _teamCount;
private int _individualTeamsCount; private int _individualTeamsCount;
private int _groupTeamsCount; private int _groupTeamsCount;
private List<Note> _pinnedNotes = [];
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed = false;
private bool _hasStudents => _studentCount > 0; private bool _hasStudents => _studentCount > 0;
private bool _hasTeams => _teamCount > 0; private bool _hasTeams => _teamCount > 0;
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
}
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await LoadStatistics(); 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() private async Task LoadStatistics()
@@ -225,4 +284,16 @@ else
_individualTeamsCount = contextTeams.Count(e => e.Event.EventFormat == EventFormat.Individual); _individualTeamsCount = contextTeams.Count(e => e.Event.EventFormat == EventFormat.Individual);
_groupTeamsCount = contextTeams.Count(e => e.Event.EventFormat == EventFormat.Team); _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;
}
} }
+18 -2
View File
@@ -5,8 +5,9 @@
@inject INotesService NotesService @inject INotesService NotesService
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IDialogService DialogService @inject IDialogService DialogService
@inject MarkdownTablePasteService MarkdownTablePasteService
<MudDialog> <MudDialog Class="@MarkdownHelper.GetNoteColorClass(_note.Id)">
<DialogContent> <DialogContent>
<MudStack Spacing="3"> <MudStack Spacing="3">
<MudTextField @bind-Value="_note.Title" <MudTextField @bind-Value="_note.Title"
@@ -14,7 +15,8 @@
Variant="Variant.Outlined" Variant="Variant.Outlined"
Required="true" Required="true"
RequiredError="Title is required" RequiredError="Title is required"
MaxLength="200" /> MaxLength="200"
ReadOnly="@IsPageNote" />
<MudText Typo="Typo.subtitle2" Class="mb-2">Content (Markdown)</MudText> <MudText Typo="Typo.subtitle2" Class="mb-2">Content (Markdown)</MudText>
<MarkdownEditor Value="@_note.Content" <MarkdownEditor Value="@_note.Content"
@@ -41,6 +43,9 @@
public bool IsEdit { get; set; } public bool IsEdit { get; set; }
private Note _note = null!; private Note _note = null!;
private bool _pasteMarkdownInitialized = false;
private bool IsPageNote => Note?.Title?.StartsWith("@") ?? false;
protected override void OnInitialized() protected override void OnInitialized()
{ {
@@ -52,6 +57,17 @@
}; };
} }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && !_pasteMarkdownInitialized)
{
// Initialize paste-markdown after EasyMDE has rendered
await Task.Delay(150); // Wait for EasyMDE to initialize
await MarkdownTablePasteService.InitializeAsync();
_pasteMarkdownInitialized = true;
}
}
private async Task Save() private async Task Save()
{ {
if (string.IsNullOrWhiteSpace(_note.Title)) if (string.IsNullOrWhiteSpace(_note.Title))
@@ -4,7 +4,7 @@
@inject INotesService NotesService @inject INotesService NotesService
@inject IDialogService DialogService @inject IDialogService DialogService
<MudDialog> <MudDialog Class="@MarkdownHelper.GetNoteColorClass(NoteId)">
<DialogContent> <DialogContent>
@if (_isLoading) @if (_isLoading)
{ {
@@ -97,6 +97,8 @@
"Created" => Color.Success, "Created" => Color.Success,
"Updated" => Color.Info, "Updated" => Color.Info,
"Deleted" => Color.Error, "Deleted" => Color.Error,
"Soft Deleted" => Color.Error,
"Restored" => Color.Success,
_ => Color.Default _ => Color.Default
}; };
} }
@@ -110,7 +112,7 @@
var options = new DialogOptions var options = new DialogOptions
{ {
MaxWidth = MaxWidth.Large, MaxWidth = MaxWidth.Medium,
FullWidth = true, FullWidth = true,
CloseButton = true CloseButton = true
}; };
@@ -2,7 +2,7 @@
@using WebApp.Services @using WebApp.Services
@using MudBlazor @using MudBlazor
<MudDialog> <MudDialog Class="@MarkdownHelper.GetNoteColorClass(_history.NoteId)">
<DialogContent> <DialogContent>
<MudStack Spacing="3"> <MudStack Spacing="3">
<MudText Typo="Typo.h6">@_history.Title</MudText> <MudText Typo="Typo.h6">@_history.Title</MudText>
+292 -66
View File
@@ -7,15 +7,26 @@
@inject INotesService NotesService @inject INotesService NotesService
@inject IDialogService DialogService @inject IDialogService DialogService
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject NavigationManager NavigationManager
<PageHeader Title="Notes"> <PageHeader Title="Notes">
<ActionButtons> <ActionButtons>
<MudButton StartIcon="@Icons.Material.Filled.Add" <MudTooltip Text="@(_showRemoved ? "Show Active" : "Show Removed")">
OnClick="OpenCreateDialog" <MudButton StartIcon="@(_showRemoved ? Icons.Material.Filled.List : Icons.Material.Filled.DeleteOutline)"
Variant="Variant.Filled" OnClick="ToggleRemovedFilter"
Color="Color.Primary"> Variant="Variant.Outlined"
Create Note Color="@(_showRemoved ? Color.Warning : Color.Default)">
</MudButton> @(_showRemoved ? "Show Active" : "Show Removed")
</MudButton>
</MudTooltip>
<MudTooltip Text="Create Note">
<MudButton StartIcon="@Icons.Material.Filled.Add"
OnClick="OpenCreateDialog"
Variant="Variant.Filled"
Color="Color.Primary">
Create Note
</MudButton>
</MudTooltip>
</ActionButtons> </ActionButtons>
</PageHeader> </PageHeader>
@@ -32,59 +43,129 @@
} }
else else
{ {
<MudExpansionPanels MultiExpansion="true"> <ClientSidePagination T="Note" Items="@_notes" DefaultPageSize="@DefaultPageSize">
@foreach (var note in _notes) <ChildContent Context="paginatedNotes">
{ <MudExpansionPanels MultiExpansion="true">
<MudExpansionPanel Text="@note.Title" @foreach (var note in paginatedNotes)
Icon="@Icons.Material.Filled.Note"> {
<MudStack Spacing="2"> var noteId = note.Id;
@if (!string.IsNullOrWhiteSpace(note.Content)) var initialExpanded = _selectedNoteId.HasValue && noteId == _selectedNoteId.Value;
if (!_expandedNotes.ContainsKey(noteId))
{ {
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-background-grey);"> _expandedNotes[noteId] = initialExpanded;
<div class="markdown-content">
@((MarkupString)MarkdownHelper.ToHtml(note.Content))
</div>
</MudPaper>
} }
<MudStack Row="true" Spacing="2" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center"> var isExpanded = GetNoteExpanded(noteId);
<MudText Typo="Typo.body2" Color="Color.Secondary"> <MudExpansionPanel @key="@($"note-{noteId}")"
Last updated: @note.UpdatedAt.ToString("g") by @(note.LastModifiedBy ?? "Unknown") Icon="@Icons.Material.Filled.Note"
</MudText> IsExpanded="@isExpanded"
<MudStack Row="true" Spacing="2"> ExpandedChanged="@((bool expanded) => OnPanelExpandedChanged(noteId, expanded))"
<MudButton StartIcon="@Icons.Material.Filled.History" Class="@MarkdownHelper.GetNoteColorClass(noteId)">
OnClick="() => OpenHistoryDialog(note.Id)" <TitleContent>
Variant="Variant.Text" <MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="flex-grow-1">
Size="Size.Small"> <MudText Typo="Typo.body1" Class="flex-grow-1" Style="min-width: 0;">
History <span style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block;">
</MudButton> @GetNoteHeaderText(note, isExpanded)
<MudButton StartIcon="@Icons.Material.Filled.Edit" </span>
OnClick="() => OpenEditDialog(note)" </MudText>
Variant="Variant.Text" @if (!note.Title.StartsWith("@") && !note.IsDeleted && !isExpanded)
Size="Size.Small" {
Color="Color.Primary"> <MudButton StartIcon="@(note.IsPinned ? Icons.Material.Filled.PushPin : Icons.Material.Outlined.PushPin)"
Edit OnClick="() => TogglePin(note)"
</MudButton> OnClick:StopPropagation="true"
<MudButton StartIcon="@Icons.Material.Outlined.Delete" Variant="Variant.Text"
OnClick="() => DeleteNote(note)" Size="Size.Small"
Variant="Variant.Text" Color="@(note.IsPinned ? Color.Primary : Color.Default)"
Size="Size.Small" Disabled="@(IsPinDisabled(note))"
Color="Color.Error"> Title="@(note.IsPinned ? "Unpin note" : "Pin note")"
Delete Class="flex-shrink-0" />
</MudButton> }
</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> </MudStack>
</MudStack> </ChildContent>
</MudStack> </MudExpansionPanel>
</MudExpansionPanel> }
} </MudExpansionPanels>
</MudExpansionPanels> </ChildContent>
</ClientSidePagination>
} }
</MudPaper> </MudPaper>
@code { @code {
// Constants
private const int MaxPreviewLength = 60;
private const int MaxPinnedNotes = 3;
private const int DefaultPageSize = 25;
private List<Note> _notes = []; private List<Note> _notes = [];
private bool _isLoading = true; private bool _isLoading = true;
private bool _showRemoved = false;
private int _pinnedCount = 0;
private CancellationTokenSource? _cancellationTokenSource; private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed = false; 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() protected override void OnInitialized()
{ {
@@ -93,9 +174,34 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
_selectedNoteId = Id;
await LoadNotes(); 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() private async Task LoadNotes()
{ {
if (_isDisposed) return; if (_isDisposed) return;
@@ -104,7 +210,26 @@
try try
{ {
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None; 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);
}
} }
catch (TaskCanceledException) catch (TaskCanceledException)
{ {
@@ -131,6 +256,36 @@
} }
} }
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() private async Task OpenCreateDialog()
{ {
if (_isDisposed) return; if (_isDisposed) return;
@@ -141,14 +296,7 @@
["IsEdit"] = false ["IsEdit"] = false
}; };
var options = new DialogOptions var dialog = await DialogService.ShowAsync<NoteEditDialog>("Create Note", parameters, GetDefaultDialogOptions());
{
MaxWidth = MaxWidth.Large,
FullWidth = true,
CloseButton = true
};
var dialog = await DialogService.ShowAsync<NoteEditDialog>("Create Note", parameters, options);
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Canceled && !_isDisposed) if (!result.Canceled && !_isDisposed)
@@ -169,7 +317,7 @@
var options = new DialogOptions var options = new DialogOptions
{ {
MaxWidth = MaxWidth.Large, MaxWidth = MaxWidth.Medium,
FullWidth = true, FullWidth = true,
CloseButton = true CloseButton = true
}; };
@@ -192,14 +340,61 @@
["NoteId"] = noteId ["NoteId"] = noteId
}; };
var options = new DialogOptions await DialogService.ShowAsync<NoteHistoryDialog>("Note History", parameters, GetDefaultDialogOptions());
{ }
MaxWidth = MaxWidth.Large,
FullWidth = true,
CloseButton = true
};
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) private async Task DeleteNote(Note note)
@@ -212,7 +407,7 @@
var result = await DialogService.ShowMessageBox( var result = await DialogService.ShowMessageBox(
"Delete Note", "Delete Note",
(MarkupString)$"Are you sure you want to delete <b>{note.Title}</b>? This cannot be undone.", (MarkupString)$"Are you sure you want to delete <b>{note.Title}</b>? You can restore it later from the 'Show Removed' view.",
yesText: "Yes", yesText: "Yes",
noText: "Cancel"); noText: "Cancel");
@@ -246,6 +441,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() public async ValueTask DisposeAsync()
{ {
if (!_isDisposed) if (!_isDisposed)
@@ -0,0 +1,124 @@
@namespace WebApp.Components.Shared.Components
@typeparam T
@using MudBlazor
@ChildContent(_paginatedItems)
@if (_totalPages > 1 || _currentPageSize != DefaultPageSize)
{
<MudStack Row="true" Spacing="2" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mt-4">
@if (ShowPageSizeSelector)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudText Typo="Typo.body2">Items per page:</MudText>
<MudSelect T="int" Value="@_currentPageSize" ValueChanged="OnPageSizeChanged" Variant="Variant.Outlined" Dense="true" Style="width: 100px;">
@foreach (var option in PageSizeOptions)
{
<MudSelectItem Value="@option">@option</MudSelectItem>
}
</MudSelect>
</MudStack>
}
@if (_totalPages > 1)
{
<MudPagination Count="@_totalPages"
Selected="@(_currentPage + 1)"
SelectedChanged="OnPageChanged"
ShowFirstButton="true"
ShowLastButton="true" />
}
@if (ShowItemCount)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">
Showing @_displayStart to @_displayEnd of @_totalItems
</MudText>
}
</MudStack>
}
@code {
private List<T> _paginatedItems = new();
private int _currentPage = 0;
private int _currentPageSize;
private int _totalItems = 0;
private int _totalPages = 0;
private int _displayStart = 0;
private int _displayEnd = 0;
private IEnumerable<T>? _previousItems;
[Parameter] public IEnumerable<T> Items { get; set; } = Enumerable.Empty<T>();
[Parameter] public int DefaultPageSize { get; set; } = 25;
[Parameter] public int[] PageSizeOptions { get; set; } = new[] { 10, 25, 50, 100 };
[Parameter] public bool ShowPageSizeSelector { get; set; } = true;
[Parameter] public bool ShowItemCount { get; set; } = true;
[Parameter] public RenderFragment<IEnumerable<T>> ChildContent { get; set; } = null!;
public IEnumerable<T> PaginatedItems => _paginatedItems;
public int CurrentPage => _currentPage;
public int CurrentPageSize => _currentPageSize;
protected override void OnInitialized()
{
_currentPageSize = DefaultPageSize;
}
protected override void OnParametersSet()
{
// Check if items collection has changed (reference or count)
var itemsChanged = _previousItems != Items ||
(_previousItems?.Count() ?? 0) != (Items?.Count() ?? 0);
if (itemsChanged)
{
_previousItems = Items?.ToList();
_currentPage = 0; // Reset to first page when items change
}
UpdatePagination();
}
private void OnPageSizeChanged(int newPageSize)
{
if (_currentPageSize != newPageSize)
{
_currentPageSize = newPageSize;
_currentPage = 0; // Reset to first page when page size changes
UpdatePagination();
StateHasChanged();
}
}
private void UpdatePagination()
{
var itemsList = Items?.ToList() ?? new List<T>();
_totalItems = itemsList.Count;
_totalPages = _totalItems > 0 ? (int)Math.Ceiling((double)_totalItems / _currentPageSize) : 0;
// Ensure current page is valid
if (_totalPages > 0 && _currentPage >= _totalPages)
{
_currentPage = _totalPages - 1;
}
else if (_currentPage < 0)
{
_currentPage = 0;
}
// Calculate paginated items
var startIndex = _currentPage * _currentPageSize;
_paginatedItems = itemsList.Skip(startIndex).Take(_currentPageSize).ToList();
// Calculate display range
_displayStart = _totalItems > 0 ? startIndex + 1 : 0;
_displayEnd = Math.Min(startIndex + _currentPageSize, _totalItems);
}
private void OnPageChanged(int newPage)
{
_currentPage = newPage - 1; // MudPagination is 1-based, we use 0-based
UpdatePagination();
StateHasChanged();
}
}
@@ -1,10 +1,14 @@
@namespace WebApp.Components.Shared.Components @namespace WebApp.Components.Shared.Components
@using Microsoft.AspNetCore.Components.Web
<MudStack Row="true" Spacing="0" AlignItems="AlignItems.Center" <MudStack Row="true" Spacing="0" AlignItems="AlignItems.Center"
Style="position: relative; display: inline-flex;" Style="position: relative; display: inline-flex;"
Class="@WrapperClass" Class="@WrapperClass"
@onmouseenter="@(() => _isHovered = true)" @onmouseenter="@(() => _isHovered = true)"
@onmouseleave="@(() => _isHovered = false)"> @onmouseleave="@(() => _isHovered = false)"
@ontouchstart="@(() => _isTouched = true)"
@ontouchend="@(() => { /* Prevent mouse events on touch */ })"
@onclick="@HandleClick">
<MudChip T="string" <MudChip T="string"
Size="@Size" Size="@Size"
Color="@Color" Color="@Color"
@@ -13,9 +17,22 @@
Class="@Class"> Class="@Class">
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center"> <MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
@ChildContent @ChildContent
@if (_isHovered && ControlContent != null) @if (ControlContent != null)
{ {
@ControlContent @* Show on touch for mobile/touch devices (below Md) *@
<MudHidden Breakpoint="Breakpoint.MdAndUp">
@if (_isTouched || AlwaysShowControls)
{
@ControlContent
}
</MudHidden>
@* Show on hover for desktop devices (MdAndUp) *@
<MudHidden Breakpoint="Breakpoint.MdAndUp" Invert="true">
@if (_isHovered || AlwaysShowControls)
{
@ControlContent
}
</MudHidden>
} }
</MudStack> </MudStack>
</MudChip> </MudChip>
@@ -46,5 +63,18 @@
[Parameter] [Parameter]
public string? WrapperClass { get; set; } public string? WrapperClass { get; set; }
[Parameter]
public bool AlwaysShowControls { get; set; } = false;
private bool _isHovered = false; private bool _isHovered = false;
private bool _isTouched = false;
private void HandleClick(MouseEventArgs e)
{
// On touch devices, toggle controls on tap (for devices with both touch and mouse)
if (_isTouched)
{
_isTouched = !_isTouched;
}
}
} }
@@ -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 {MarkdownHelper.GetNoteColorClass(Note.Id)}".Trim())" 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 Class="@MarkdownHelper.GetNoteColorClass(NoteId)">
<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,138 @@
@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"
Class="@MarkdownHelper.GetNoteColorClass(_noteId)">
@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 int _noteId = 0;
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);
_noteId = note?.Id ?? 0;
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;
// Always refresh content indicator after dialog closes
// (content may have been saved even if dialog was closed with Cancel)
if (!_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,265 @@
@namespace WebApp.Components.Shared.Components
@inject INotesService NotesService
@inject ISnackbar Snackbar
@inject MarkdownTablePasteService MarkdownTablePasteService
@implements IAsyncDisposable
<MudDialog Class="@MarkdownHelper.GetNoteColorClass(_note.Id)">
<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;
private bool _pasteMarkdownInitialized = false;
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
_pageNoteTitle = $"@{PageIdentifier}";
_note = new Note
{
Title = _pageNoteTitle,
Content = ""
};
}
protected override async Task OnInitializedAsync()
{
await LoadPageNote();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && _isEditMode && !_pasteMarkdownInitialized)
{
// Initialize paste-markdown after first render if already in edit mode
await Task.Delay(150); // Wait for EasyMDE to initialize
if (!_isDisposed)
{
await MarkdownTablePasteService.InitializeAsync();
_pasteMarkdownInitialized = true;
}
}
}
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
{
// CreateNoteAsync returns the note with the ID populated
var createdNote = await NotesService.CreateNoteAsync(_note);
// Update _note with the returned note to get the ID immediately
_note = createdNote;
_isEdit = true; // Mark as existing note now
Snackbar.Add("Page note created successfully", Severity.Success);
}
if (!_isDisposed)
{
// After saving, switch back to view mode and reload the note to ensure we have latest data
_isEditMode = false;
await LoadPageNote();
// Ensure dialog re-renders with updated color class after note ID is set
StateHasChanged();
}
}
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 async Task EnterEditMode()
{
if (_isDisposed) return;
_isEditMode = true;
StateHasChanged();
// Initialize paste-markdown after editor is rendered
if (!_pasteMarkdownInitialized)
{
await Task.Delay(150); // Wait for EasyMDE to initialize
if (!_isDisposed)
{
await MarkdownTablePasteService.InitializeAsync();
_pasteMarkdownInitialized = true;
}
}
}
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;
}
}
+1
View File
@@ -194,6 +194,7 @@ builder.Services.AddScoped<Core.Services.IEventOccurrenceParserService>(sp =>
builder.Services.AddScoped<WebApp.Services.FormValidationService>(); builder.Services.AddScoped<WebApp.Services.FormValidationService>();
builder.Services.AddScoped<WebApp.Services.EventDefinitionService>(); builder.Services.AddScoped<WebApp.Services.EventDefinitionService>();
builder.Services.AddScoped<WebApp.Services.INotesService, WebApp.Services.NotesService>(); builder.Services.AddScoped<WebApp.Services.INotesService, WebApp.Services.NotesService>();
builder.Services.AddScoped<WebApp.Services.MarkdownTablePasteService>();
// State container for maintaining state per user connection (Blazor Server) // State container for maintaining state per user connection (Blazor Server)
builder.Services.AddScoped<StateContainer>(); builder.Services.AddScoped<StateContainer>();
+30 -2
View File
@@ -5,15 +5,23 @@ namespace WebApp.Services;
public interface INotesService public interface INotesService
{ {
/// <summary> /// <summary>
/// Gets all notes. /// Gets all notes, optionally including deleted notes.
/// </summary> /// </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> /// <summary>
/// Gets a single note by ID. /// Gets a single note by ID.
/// </summary> /// </summary>
Task<Note?> GetNoteAsync(int id); 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> /// <summary>
/// Gets all history entries for a note. /// Gets all history entries for a note.
/// </summary> /// </summary>
@@ -33,4 +41,24 @@ public interface INotesService
/// Deletes a note and creates a deletion history entry. /// Deletes a note and creates a deletion history entry.
/// </summary> /// </summary>
Task DeleteNoteAsync(int id); 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);
} }
+59
View File
@@ -12,6 +12,22 @@ public static class MarkdownHelper
.UseAdvancedExtensions() .UseAdvancedExtensions()
.Build(); .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> /// <summary>
/// Converts markdown text to HTML. /// Converts markdown text to HTML.
/// </summary> /// </summary>
@@ -45,4 +61,47 @@ public static class MarkdownHelper
return html; 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;
}
/// <summary>
/// Gets the CSS class name for a note's color based on its ID.
/// Uses modulo 3 to cycle through 3 pastel color classes.
/// </summary>
/// <param name="noteId">The note ID.</param>
/// <returns>CSS class name (e.g., "note-color-0", "note-color-1", "note-color-2"), or empty string if noteId is 0.</returns>
public static string GetNoteColorClass(int noteId)
{
if (noteId == 0) return string.Empty;
return $"note-color-{noteId % 3}";
}
} }
@@ -0,0 +1,41 @@
using Microsoft.JSInterop;
namespace WebApp.Services;
/// <summary>
/// Service for initializing paste-markdown functionality in MarkdownEditor components.
/// Enables automatic conversion of pasted spreadsheet tables (Google Sheets, Excel) to Markdown tables.
/// </summary>
public class MarkdownTablePasteService
{
private readonly IJSRuntime _jsRuntime;
private readonly ILogger<MarkdownTablePasteService> _logger;
public MarkdownTablePasteService(IJSRuntime jsRuntime, ILogger<MarkdownTablePasteService> logger)
{
_jsRuntime = jsRuntime;
_logger = logger;
}
/// <summary>
/// Initializes paste-markdown for a MarkdownEditor instance.
/// Should be called after the editor has been rendered and EasyMDE has initialized.
/// </summary>
/// <param name="editorId">Optional ID of the editor wrapper element. If null, will attempt to find the most recent editor.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InitializeAsync(string? editorId = null)
{
try
{
await _jsRuntime.InvokeVoidAsync("markdownTablePaste.initialize", editorId);
}
catch (JSException ex)
{
_logger.LogWarning(ex, "Failed to initialize paste-markdown for editor {EditorId}", editorId ?? "unknown");
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error initializing paste-markdown for editor {EditorId}", editorId ?? "unknown");
}
}
}
+143 -7
View File
@@ -30,11 +30,20 @@ public class NotesService : INotesService
return user.FindFirstValue(ClaimTypes.Email) ?? user.Identity?.Name; 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() .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(); .ToListAsync();
} }
@@ -45,6 +54,15 @@ public class NotesService : INotesService
.FirstOrDefaultAsync(n => n.Id == id); .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) public async Task<IEnumerable<NoteHistory>> GetNoteHistoryAsync(int noteId)
{ {
return await _context.NoteHistories return await _context.NoteHistories
@@ -147,16 +165,134 @@ public class NotesService : INotesService
Content = note.Content, Content = note.Content,
ModifiedBy = userEmail, ModifiedBy = userEmail,
ModifiedAt = now, ModifiedAt = now,
ChangeType = "Deleted" ChangeType = "Soft Deleted"
}; };
_context.NoteHistories.Add(history); _context.NoteHistories.Add(history);
// Delete the note (cascade will handle history, but we want to keep history) // Soft delete - set IsDeleted flag instead of removing
_context.Notes.Remove(note); note.IsDeleted = true;
note.UpdatedAt = now;
note.LastModifiedBy = userEmail;
await _context.SaveChangesAsync(); 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);
} }
} }
+37 -1
View File
@@ -61,8 +61,31 @@
@media (max-width: 600px) { @media (max-width: 600px) {
.page-header-actions { .page-header-actions {
width: 100%; justify-content: flex-end;
margin-top: 0.5rem; margin-top: 0.5rem;
margin-left: auto;
}
/* Hide button text on small screens by setting font-size to 0 */
.page-header-actions .mud-button {
min-width: auto;
padding-left: 12px;
padding-right: 12px;
font-size: 0;
}
/* Restore icon size - MudBlazor uses material-icons font */
.page-header-actions .mud-button .mud-icon-root {
font-size: 1.5rem !important;
display: inline-flex !important;
}
.page-header-actions .mud-button .mud-icon-root .mud-icon,
.page-header-actions .mud-button .mud-icon-root .material-icons {
font-size: 1.5rem !important;
width: 1.5rem !important;
height: 1.5rem !important;
line-height: 1.5rem !important;
} }
.page-header > div > div:first-of-type { .page-header > div > div:first-of-type {
@@ -229,4 +252,17 @@
.markdown-content img { .markdown-content img {
max-width: 100%; max-width: 100%;
height: auto; height: auto;
}
/* Note color classes - pastel background colors */
.note-color-0 {
background-color: #e3f2fd;
}
.note-color-1 {
background-color: #fff9e6;
}
.note-color-2 {
background-color: #f3e5f5;
} }
+229
View File
@@ -0,0 +1,229 @@
// Integration for converting pasted spreadsheet tables to Markdown tables in EasyMDE/CodeMirror
// Handles both HTML tables (from Google Sheets) and tab-separated values
window.markdownTablePaste = {
/**
* Initializes paste handler for a MarkdownEditor instance.
* Finds the textarea or CodeMirror instance created by EasyMDE and attaches paste event handler.
* @param {string} editorId - The ID of the editor wrapper element, or null to find by class
*/
initialize: function(editorId) {
let attempts = 0;
const maxAttempts = 5;
const tryInitialize = function() {
attempts++;
let textarea = null;
let codeMirror = null;
// Try to find the textarea element
if (editorId) {
const editorElement = document.getElementById(editorId);
if (editorElement) {
textarea = editorElement.querySelector('textarea');
}
} else {
// Find the most recently created EasyMDE textarea (last one in DOM)
const containers = document.querySelectorAll('.EasyMDEContainer');
if (containers.length > 0) {
const lastContainer = containers[containers.length - 1];
textarea = lastContainer.querySelector('textarea');
}
// Fallback: try to find any textarea near an editor-toolbar
if (!textarea) {
const toolbars = document.querySelectorAll('.editor-toolbar');
if (toolbars.length > 0) {
const lastToolbar = toolbars[toolbars.length - 1];
const nextSibling = lastToolbar.nextElementSibling;
if (nextSibling && nextSibling.tagName === 'TEXTAREA') {
textarea = nextSibling;
}
}
}
// Another fallback: find any textarea that's a child of a container with editor classes
if (!textarea) {
const allTextareas = document.querySelectorAll('textarea');
for (let ta of allTextareas) {
const parent = ta.parentElement;
if (parent && (
parent.classList.contains('EasyMDEContainer') ||
parent.classList.contains('editor') ||
parent.querySelector('.editor-toolbar')
)) {
textarea = ta;
break;
}
}
}
}
// If we found a textarea, try to get the CodeMirror instance from EasyMDE
if (textarea) {
if (window.EasyMDE) {
const easyMDEInstances = window.EasyMDE.instances || [];
for (let i = 0; i < easyMDEInstances.length; i++) {
const instance = easyMDEInstances[i];
if (instance && instance.codemirror) {
const cmTextarea = instance.codemirror.getTextArea();
if (cmTextarea === textarea) {
codeMirror = instance.codemirror;
break;
}
}
}
// Alternative: try to get CodeMirror from the textarea's parent
if (!codeMirror && textarea.parentElement) {
const parent = textarea.parentElement;
if (parent._easyMDEInstance && parent._easyMDEInstance.codemirror) {
codeMirror = parent._easyMDEInstance.codemirror;
}
}
}
const target = codeMirror || textarea;
if (target) {
// Add paste handler
if (codeMirror) {
codeMirror.on('paste', function(cm, event) {
handleManualPaste(event, cm);
});
} else if (textarea) {
textarea.addEventListener('paste', function(event) {
handleManualPaste(event, textarea);
}, true); // Use capture phase to intercept early
}
return; // Success - we're done
}
}
// If we didn't find the textarea, retry
if (attempts < maxAttempts) {
setTimeout(tryInitialize, attempts * 100);
}
};
// Start with initial delay to allow EasyMDE to initialize
setTimeout(tryInitialize, 100);
}
};
/**
* Handler for converting pasted spreadsheet tables to Markdown tables.
* Handles both HTML tables (from Google Sheets) and tab-separated values.
*/
function handleManualPaste(event, target) {
const clipboardData = event.clipboardData || window.clipboardData;
if (!clipboardData) return;
const html = clipboardData.getData('text/html');
const text = clipboardData.getData('text/plain');
let markdownTable = null;
// Check if HTML contains a table
if (html && html.includes('<table') && html.includes('</table>')) {
markdownTable = convertHtmlTableToMarkdown(html);
}
// Check if it's tab-separated text (spreadsheet cells)
else if (text && text.includes('\t') && text.includes('\n')) {
const rows = text.trim().split(/\r?\n/).filter(row => row.trim().length > 0);
if (rows.length < 1) return;
const cells = rows.map(row => row.split('\t').map(cell => cell.trim()));
const maxCols = Math.max(...cells.map(row => row.length));
// Pad rows to have same number of columns
const paddedCells = cells.map(row => {
while (row.length < maxCols) row.push('');
return row;
});
// Escape pipe characters in cells
const escapeCell = (cell) => cell.replace(/\|/g, '\\|');
// Build Markdown table
const header = paddedCells[0];
const body = paddedCells.slice(1);
const headerRow = '| ' + header.map(escapeCell).join(' | ') + ' |';
const separatorRow = '| ' + header.map(() => '---').join(' | ') + ' |';
const bodyRows = body.map(row => '| ' + row.map(escapeCell).join(' | ') + ' |');
markdownTable = [headerRow, separatorRow, ...bodyRows].join('\n') + '\n';
}
// If we have a table to insert, do it
if (markdownTable) {
event.preventDefault();
event.stopPropagation();
// Insert into editor
if (target.getDoc && target.getDoc().replaceRange) {
// CodeMirror
const doc = target.getDoc();
const cursor = doc.getCursor();
doc.replaceRange(markdownTable, cursor);
} else if (target.setRangeText) {
// Textarea with setRangeText
const start = target.selectionStart;
const end = target.selectionEnd;
target.setRangeText(markdownTable, start, end, 'end');
target.dispatchEvent(new Event('input', { bubbles: true }));
} else {
// Fallback: insert at cursor
const start = target.selectionStart || 0;
const end = target.selectionEnd || 0;
const before = target.value.substring(0, start);
const after = target.value.substring(end);
target.value = before + markdownTable + after;
target.selectionStart = target.selectionEnd = before.length + markdownTable.length;
target.dispatchEvent(new Event('input', { bubbles: true }));
}
}
}
/**
* Converts an HTML table to Markdown table format
*/
function convertHtmlTableToMarkdown(html) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
const table = tempDiv.querySelector('table');
if (!table) return null;
const rows = table.querySelectorAll('tr');
if (rows.length === 0) return null;
const escapeCell = (cell) => {
const text = cell.textContent || cell.innerText || '';
return text.trim().replace(/\|/g, '\\|').replace(/\n/g, ' ');
};
const markdownRows = [];
// Process header row (first row)
const headerRow = rows[0];
const headerCells = headerRow.querySelectorAll('th, td');
const headerText = Array.from(headerCells).map(escapeCell);
markdownRows.push('| ' + headerText.join(' | ') + ' |');
// Add separator
markdownRows.push('| ' + headerText.map(() => '---').join(' | ') + ' |');
// Process data rows
for (let i = 1; i < rows.length; i++) {
const row = rows[i];
const cells = row.querySelectorAll('td, th');
const cellText = Array.from(cells).map(escapeCell);
markdownRows.push('| ' + cellText.join(' | ') + ' |');
}
return markdownRows.join('\n') + '\n';
}