Compare commits
7
Commits
5c4aaf91df
...
7679a458e0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7679a458e0 | ||
|
|
84b31800ad | ||
|
|
b4c11cd0a6 | ||
|
|
e6eb35ee67 | ||
|
|
947d95893f | ||
|
|
8b0451c2ec | ||
|
|
5f2d7b5b31 |
@@ -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");
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<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/markdownEditor.js"></script>
|
||||
<script src="js/markdownTablePaste.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -12,10 +12,15 @@
|
||||
|
||||
<PageHeader Title="Event Calendar" Description="View competition schedules and event occurrences" Icon="@AppIcons.EventCalendar">
|
||||
<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">
|
||||
<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>
|
||||
<PageNoteButton PageIdentifier="Event Calendar" />
|
||||
</ActionButtons>
|
||||
</PageHeader>
|
||||
|
||||
|
||||
@@ -19,10 +19,14 @@
|
||||
BackButtonUrl="@(ReturnUrl ?? "/events")">
|
||||
<ActionButtons>
|
||||
<div class="no-print">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Print"
|
||||
OnClick="PrintPage"
|
||||
Variant="Variant.Outlined">Print</MudButton>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="@($"/events/edit?id={eventdefinition.Id}&returnUrl={ReturnUrl ?? "/events"}")" Variant="Variant.Outlined">Edit</MudButton>
|
||||
<MudTooltip Text="Print">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Print"
|
||||
OnClick="PrintPage"
|
||||
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>
|
||||
</ActionButtons>
|
||||
</PageHeader>
|
||||
|
||||
@@ -10,9 +10,15 @@
|
||||
|
||||
<PageHeader Title="Events">
|
||||
<ActionButtons>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="events/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="events/printout" Variant="Variant.Outlined">Printable Descriptions</MudButton>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.AccountTree" Href="events/career-mapping" Variant="Variant.Outlined">Career Mapping</MudButton>
|
||||
<MudTooltip Text="Create New">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="events/create" Variant="Variant.Filled" Color="Color.Primary">Create New</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>
|
||||
</PageHeader>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@page "/meeting-schedule"
|
||||
@page "/meeting-schedule"
|
||||
@attribute [Authorize]
|
||||
@using System.Text
|
||||
@using Core.Calculation
|
||||
@@ -10,7 +10,11 @@
|
||||
@inject ClipboardService ClipboardService
|
||||
@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">
|
||||
<MudGrid>
|
||||
|
||||
@@ -18,10 +18,14 @@
|
||||
BackButtonUrl="@(ReturnUrl ?? "/students")">
|
||||
<ActionButtons>
|
||||
<div class="no-print">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Print"
|
||||
OnClick="PrintPage"
|
||||
Variant="Variant.Outlined">Print</MudButton>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="@($"/students/edit?id={student.Id}&returnUrl={ReturnUrl ?? "/students"}")" Variant="Variant.Outlined">Edit</MudButton>
|
||||
<MudTooltip Text="Print">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Print"
|
||||
OnClick="PrintPage"
|
||||
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>
|
||||
</ActionButtons>
|
||||
</PageHeader>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@page "/students/event-ranking"
|
||||
@page "/students/event-ranking"
|
||||
@attribute [Authorize]
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Models
|
||||
@@ -9,7 +9,11 @@
|
||||
|
||||
<PageHeader
|
||||
Title="Student Event Ranks"
|
||||
Icon="@AppIcons.EventRank" />
|
||||
Icon="@AppIcons.EventRank">
|
||||
<ActionButtons>
|
||||
<PageNoteButton PageIdentifier="Event Ranking" />
|
||||
</ActionButtons>
|
||||
</PageHeader>
|
||||
|
||||
@if (_students == null)
|
||||
{
|
||||
|
||||
@@ -22,12 +22,14 @@ else
|
||||
ShowBackButton="true"
|
||||
BackButtonUrl="/students/event-ranking">
|
||||
<ActionButtons>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Save"
|
||||
OnClick="Save"
|
||||
Color="Color.Primary"
|
||||
Variant="Variant.Filled">
|
||||
Save Rankings
|
||||
</MudButton>
|
||||
<MudTooltip Text="Save Rankings">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Save"
|
||||
OnClick="Save"
|
||||
Color="Color.Primary"
|
||||
Variant="Variant.Filled">
|
||||
Save Rankings
|
||||
</MudButton>
|
||||
</MudTooltip>
|
||||
</ActionButtons>
|
||||
</PageHeader>
|
||||
|
||||
|
||||
@@ -10,9 +10,15 @@
|
||||
|
||||
<PageHeader Title="Students">
|
||||
<ActionButtons>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="students/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
|
||||
<MudButton StartIcon="@AppIcons.EventRank" Href="students/event-ranking" Variant="Variant.Outlined">Event Rankings</MudButton>
|
||||
<MudButton StartIcon="@AppIcons.Registration" Href="students/teams" Variant="Variant.Outlined">Registration</MudButton>
|
||||
<MudTooltip Text="Create New">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="students/create" Variant="Variant.Filled" Color="Color.Primary">Create New</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>
|
||||
</PageHeader>
|
||||
|
||||
|
||||
@@ -11,12 +11,15 @@
|
||||
|
||||
<PageHeader Title="Registration" Icon="@AppIcons.Registration">
|
||||
<ActionButtons>
|
||||
<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 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="Registration" />
|
||||
</ActionButtons>
|
||||
</PageHeader>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@page "/teams/assignment"
|
||||
@page "/teams/assignment"
|
||||
@attribute [Authorize]
|
||||
@using Core.Calculation
|
||||
@using Core.Validation
|
||||
@@ -15,9 +15,12 @@
|
||||
Title="Assignment"
|
||||
Description="Optimized team assignments based on the student event rankings">
|
||||
<ActionButtons>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="students/event-ranking" Variant="Variant.Outlined">
|
||||
Edit Student Event Rankings
|
||||
</MudButton>
|
||||
<MudTooltip Text="Edit Student Event Rankings">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="students/event-ranking" Variant="Variant.Outlined">
|
||||
Edit Student Event Rankings
|
||||
</MudButton>
|
||||
</MudTooltip>
|
||||
<PageNoteButton PageIdentifier="Team Assignment" />
|
||||
</ActionButtons>
|
||||
</PageHeader>
|
||||
|
||||
|
||||
@@ -19,12 +19,16 @@
|
||||
BackButtonUrl="@(ReturnUrl ?? "/teams")">
|
||||
<ActionButtons>
|
||||
<div class="no-print">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Print"
|
||||
OnClick="PrintPage"
|
||||
Variant="Variant.Outlined">Print</MudButton>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Edit"
|
||||
Href="@($"/teams/edit?id={Team.Id}&returnUrl={ReturnUrl ?? "/teams"}")"
|
||||
Variant="Variant.Outlined">Edit</MudButton>
|
||||
<MudTooltip Text="Print">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Print"
|
||||
OnClick="PrintPage"
|
||||
Variant="Variant.Outlined">Print</MudButton>
|
||||
</MudTooltip>
|
||||
<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>
|
||||
</ActionButtons>
|
||||
</PageHeader>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Components.Shared.Components
|
||||
@using WebApp.Models
|
||||
@page "/teams"
|
||||
@@ -10,15 +10,24 @@
|
||||
|
||||
<PageHeader Title="Teams">
|
||||
<ActionButtons>
|
||||
<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/printout" Variant="Variant.Outlined">Printout</MudButton>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="teams/handout" Variant="Variant.Outlined">Handout</MudButton>
|
||||
<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 Text="Create New">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="teams/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Printout">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="teams/printout" Variant="Variant.Outlined">Printout</MudButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Handout">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="teams/handout" Variant="Variant.Outlined">Handout</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>
|
||||
</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,18 @@
|
||||
</div>
|
||||
</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)
|
||||
{
|
||||
<!-- Getting Started: No students yet -->
|
||||
@@ -182,13 +200,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 +284,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
@inject INotesService NotesService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject MarkdownTablePasteService MarkdownTablePasteService
|
||||
|
||||
<MudDialog>
|
||||
<MudDialog Class="@MarkdownHelper.GetNoteColorClass(_note.Id)">
|
||||
<DialogContent>
|
||||
<MudStack Spacing="3">
|
||||
<MudTextField @bind-Value="_note.Title"
|
||||
@@ -14,7 +15,8 @@
|
||||
Variant="Variant.Outlined"
|
||||
Required="true"
|
||||
RequiredError="Title is required"
|
||||
MaxLength="200" />
|
||||
MaxLength="200"
|
||||
ReadOnly="@IsPageNote" />
|
||||
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Content (Markdown)</MudText>
|
||||
<MarkdownEditor Value="@_note.Content"
|
||||
@@ -41,6 +43,9 @@
|
||||
public bool IsEdit { get; set; }
|
||||
|
||||
private Note _note = null!;
|
||||
private bool _pasteMarkdownInitialized = false;
|
||||
|
||||
private bool IsPageNote => Note?.Title?.StartsWith("@") ?? false;
|
||||
|
||||
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()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_note.Title))
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
@inject INotesService NotesService
|
||||
@inject IDialogService DialogService
|
||||
|
||||
<MudDialog>
|
||||
<MudDialog Class="@MarkdownHelper.GetNoteColorClass(NoteId)">
|
||||
<DialogContent>
|
||||
@if (_isLoading)
|
||||
{
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@using WebApp.Services
|
||||
@using MudBlazor
|
||||
|
||||
<MudDialog>
|
||||
<MudDialog Class="@MarkdownHelper.GetNoteColorClass(_history.NoteId)">
|
||||
<DialogContent>
|
||||
<MudStack Spacing="3">
|
||||
<MudText Typo="Typo.h6">@_history.Title</MudText>
|
||||
|
||||
@@ -7,15 +7,26 @@
|
||||
@inject INotesService NotesService
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<PageHeader Title="Notes">
|
||||
<ActionButtons>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="OpenCreateDialog"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary">
|
||||
Create Note
|
||||
</MudButton>
|
||||
<MudTooltip Text="@(_showRemoved ? "Show Active" : "Show Removed")">
|
||||
<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>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Create Note">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="OpenCreateDialog"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary">
|
||||
Create Note
|
||||
</MudButton>
|
||||
</MudTooltip>
|
||||
</ActionButtons>
|
||||
</PageHeader>
|
||||
|
||||
@@ -32,59 +43,129 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudExpansionPanels MultiExpansion="true">
|
||||
@foreach (var note in _notes)
|
||||
{
|
||||
<MudExpansionPanel Text="@note.Title"
|
||||
Icon="@Icons.Material.Filled.Note">
|
||||
<MudStack Spacing="2">
|
||||
@if (!string.IsNullOrWhiteSpace(note.Content))
|
||||
<ClientSidePagination T="Note" Items="@_notes" DefaultPageSize="@DefaultPageSize">
|
||||
<ChildContent Context="paginatedNotes">
|
||||
<MudExpansionPanels MultiExpansion="true">
|
||||
@foreach (var note in paginatedNotes)
|
||||
{
|
||||
var noteId = note.Id;
|
||||
var initialExpanded = _selectedNoteId.HasValue && noteId == _selectedNoteId.Value;
|
||||
if (!_expandedNotes.ContainsKey(noteId))
|
||||
{
|
||||
<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>
|
||||
_expandedNotes[noteId] = initialExpanded;
|
||||
}
|
||||
<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">
|
||||
<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>
|
||||
var isExpanded = GetNoteExpanded(noteId);
|
||||
<MudExpansionPanel @key="@($"note-{noteId}")"
|
||||
Icon="@Icons.Material.Filled.Note"
|
||||
IsExpanded="@isExpanded"
|
||||
ExpandedChanged="@((bool expanded) => OnPanelExpandedChanged(noteId, expanded))"
|
||||
Class="@MarkdownHelper.GetNoteColorClass(noteId)">
|
||||
<TitleContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="flex-grow-1">
|
||||
<MudText Typo="Typo.body1" Class="flex-grow-1" Style="min-width: 0;">
|
||||
<span style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block;">
|
||||
@GetNoteHeaderText(note, isExpanded)
|
||||
</span>
|
||||
</MudText>
|
||||
@if (!note.Title.StartsWith("@") && !note.IsDeleted && !isExpanded)
|
||||
{
|
||||
<MudButton StartIcon="@(note.IsPinned ? Icons.Material.Filled.PushPin : Icons.Material.Outlined.PushPin)"
|
||||
OnClick="() => TogglePin(note)"
|
||||
OnClick:StopPropagation="true"
|
||||
Variant="Variant.Text"
|
||||
Size="Size.Small"
|
||||
Color="@(note.IsPinned ? Color.Primary : Color.Default)"
|
||||
Disabled="@(IsPinDisabled(note))"
|
||||
Title="@(note.IsPinned ? "Unpin note" : "Pin note")"
|
||||
Class="flex-shrink-0" />
|
||||
}
|
||||
</MudStack>
|
||||
</TitleContent>
|
||||
<ChildContent>
|
||||
<MudStack Spacing="2">
|
||||
@if (!string.IsNullOrWhiteSpace(note.Content))
|
||||
{
|
||||
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-background-grey);">
|
||||
<div class="markdown-content">
|
||||
@((MarkupString)MarkdownHelper.ToHtml(note.Content))
|
||||
</div>
|
||||
</MudPaper>
|
||||
}
|
||||
<MudStack Row="true" Spacing="2" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
Last updated: @note.UpdatedAt.ToString("g") by @(note.LastModifiedBy ?? "Unknown")
|
||||
</MudText>
|
||||
<MudStack Row="true" Spacing="2">
|
||||
@if (!note.IsDeleted)
|
||||
{
|
||||
<MudButton StartIcon="@Icons.Material.Filled.History"
|
||||
OnClick="() => OpenHistoryDialog(note.Id)"
|
||||
Variant="Variant.Text"
|
||||
Size="Size.Small">
|
||||
History
|
||||
</MudButton>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Edit"
|
||||
OnClick="() => OpenEditDialog(note)"
|
||||
Variant="Variant.Text"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary">
|
||||
Edit
|
||||
</MudButton>
|
||||
<MudButton StartIcon="@Icons.Material.Outlined.Delete"
|
||||
OnClick="() => DeleteNote(note)"
|
||||
Variant="Variant.Text"
|
||||
Size="Size.Small"
|
||||
Color="Color.Error">
|
||||
Delete
|
||||
</MudButton>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Restore"
|
||||
OnClick="() => RestoreNote(note)"
|
||||
Variant="Variant.Text"
|
||||
Size="Size.Small"
|
||||
Color="Color.Success">
|
||||
Restore
|
||||
</MudButton>
|
||||
}
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudExpansionPanel>
|
||||
}
|
||||
</MudExpansionPanels>
|
||||
</ChildContent>
|
||||
</MudExpansionPanel>
|
||||
}
|
||||
</MudExpansionPanels>
|
||||
</ChildContent>
|
||||
</ClientSidePagination>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
// Constants
|
||||
private const int MaxPreviewLength = 60;
|
||||
private const int MaxPinnedNotes = 3;
|
||||
private const int DefaultPageSize = 25;
|
||||
|
||||
private List<Note> _notes = [];
|
||||
private bool _isLoading = true;
|
||||
private bool _showRemoved = false;
|
||||
private int _pinnedCount = 0;
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
private bool _isDisposed = false;
|
||||
private int? _selectedNoteId;
|
||||
private Dictionary<int, bool> _expandedNotes = new();
|
||||
|
||||
|
||||
private static DialogOptions GetDefaultDialogOptions() => new()
|
||||
{
|
||||
MaxWidth = MaxWidth.Medium,
|
||||
FullWidth = true,
|
||||
CloseButton = true
|
||||
};
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private int? Id { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
@@ -93,9 +174,34 @@
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_selectedNoteId = Id;
|
||||
await LoadNotes();
|
||||
|
||||
// Clear the query parameter after loading
|
||||
if (Id.HasValue)
|
||||
{
|
||||
NavigationManager.NavigateTo("/notes", replace: true);
|
||||
}
|
||||
}
|
||||
|
||||
private bool GetNoteExpanded(int noteId)
|
||||
{
|
||||
if (_expandedNotes.ContainsKey(noteId))
|
||||
{
|
||||
return _expandedNotes[noteId];
|
||||
}
|
||||
return _selectedNoteId.HasValue && noteId == _selectedNoteId.Value;
|
||||
}
|
||||
|
||||
private void OnPanelExpandedChanged(int noteId, bool expanded)
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
|
||||
_expandedNotes[noteId] = expanded;
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
|
||||
private async Task LoadNotes()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
@@ -104,7 +210,26 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
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()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
@@ -141,14 +296,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 +317,7 @@
|
||||
|
||||
var options = new DialogOptions
|
||||
{
|
||||
MaxWidth = MaxWidth.Large,
|
||||
MaxWidth = MaxWidth.Medium,
|
||||
FullWidth = true,
|
||||
CloseButton = true
|
||||
};
|
||||
@@ -192,14 +340,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)
|
||||
@@ -212,7 +407,7 @@
|
||||
|
||||
var result = await DialogService.ShowMessageBox(
|
||||
"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",
|
||||
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()
|
||||
{
|
||||
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
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
|
||||
<MudStack Row="true" Spacing="0" AlignItems="AlignItems.Center"
|
||||
Style="position: relative; display: inline-flex;"
|
||||
Class="@WrapperClass"
|
||||
@onmouseenter="@(() => _isHovered = true)"
|
||||
@onmouseleave="@(() => _isHovered = false)">
|
||||
@onmouseleave="@(() => _isHovered = false)"
|
||||
@ontouchstart="@(() => _isTouched = true)"
|
||||
@ontouchend="@(() => { /* Prevent mouse events on touch */ })"
|
||||
@onclick="@HandleClick">
|
||||
<MudChip T="string"
|
||||
Size="@Size"
|
||||
Color="@Color"
|
||||
@@ -13,9 +17,22 @@
|
||||
Class="@Class">
|
||||
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
|
||||
@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>
|
||||
</MudChip>
|
||||
@@ -46,5 +63,18 @@
|
||||
[Parameter]
|
||||
public string? WrapperClass { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool AlwaysShowControls { get; set; } = 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;
|
||||
}
|
||||
}
|
||||
@@ -194,6 +194,7 @@ builder.Services.AddScoped<Core.Services.IEventOccurrenceParserService>(sp =>
|
||||
builder.Services.AddScoped<WebApp.Services.FormValidationService>();
|
||||
builder.Services.AddScoped<WebApp.Services.EventDefinitionService>();
|
||||
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)
|
||||
builder.Services.AddScoped<StateContainer>();
|
||||
|
||||
@@ -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,47 @@ 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;
|
||||
}
|
||||
|
||||
/// <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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+37
-1
@@ -61,8 +61,31 @@
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.page-header-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
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 {
|
||||
@@ -230,3 +253,16 @@
|
||||
max-width: 100%;
|
||||
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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user