Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7679a458e0 | ||
|
|
84b31800ad | ||
|
|
b4c11cd0a6 | ||
|
|
e6eb35ee67 | ||
|
|
947d95893f | ||
|
|
8b0451c2ec | ||
|
|
5f2d7b5b31 |
@@ -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; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+12
-2
@@ -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");
|
||||||
+14
-2
@@ -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");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {
|
||||||
@@ -230,3 +253,16 @@
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -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