From 5c4aaf91df389eddc23a2e07bf90a2b0fb4debd1 Mon Sep 17 00:00:00 2001 From: James Kolpack Date: Thu, 15 Jan 2026 21:47:01 -0500 Subject: [PATCH] Add Note and NoteHistory entities with configurations and service implementation This commit introduces the Note and NoteHistory entities, along with their respective configurations for Entity Framework Core. The AppDbContext has been updated to include DbSet properties for both entities. A new INotesService interface and its implementation, NotesService, have been created to handle CRUD operations for notes, including history tracking. Additionally, several Blazor components have been added for note management, including dialogs for editing notes and viewing note history. The UI has been enhanced to support markdown content rendering and improved user interaction for note creation and editing. These changes contribute to a comprehensive note-taking feature within the application. --- Core/Entities/Note.cs | 30 ++ Core/Entities/NoteHistory.cs | 32 ++ Data/AppDbContext.cs | 4 +- Data/Configurations/NoteConfiguration.cs | 39 ++ .../NoteHistoryConfiguration.cs | 36 ++ ...5185640_AddNotesAndNoteHistory.Designer.cs | 481 ++++++++++++++++++ .../20260115185640_AddNotesAndNoteHistory.cs | 92 ++++ Data/Migrations/AppDbContextModelSnapshot.cs | 93 ++++ WebApp/Components/App.razor | 7 +- WebApp/Components/Pages/NoteEditDialog.razor | 88 ++++ .../Components/Pages/NoteHistoryDialog.razor | 125 +++++ .../Pages/NoteVersionViewDialog.razor | 66 +++ WebApp/Components/Pages/Notes.razor | 260 ++++++++++ WebApp/Components/Shared/Layout/NavMenu.razor | 6 +- WebApp/Components/_Imports.razor | 4 +- WebApp/Program.cs | 1 + WebApp/Services/INotesService.cs | 36 ++ WebApp/Services/MarkdownHelper.cs | 48 ++ WebApp/Services/NotesService.cs | 162 ++++++ WebApp/WebApp.csproj | 4 +- WebApp/wwwroot/app.css | 101 ++++ 21 files changed, 1710 insertions(+), 5 deletions(-) create mode 100644 Core/Entities/Note.cs create mode 100644 Core/Entities/NoteHistory.cs create mode 100644 Data/Configurations/NoteConfiguration.cs create mode 100644 Data/Configurations/NoteHistoryConfiguration.cs create mode 100644 Data/Migrations/20260115185640_AddNotesAndNoteHistory.Designer.cs create mode 100644 Data/Migrations/20260115185640_AddNotesAndNoteHistory.cs create mode 100644 WebApp/Components/Pages/NoteEditDialog.razor create mode 100644 WebApp/Components/Pages/NoteHistoryDialog.razor create mode 100644 WebApp/Components/Pages/NoteVersionViewDialog.razor create mode 100644 WebApp/Components/Pages/Notes.razor create mode 100644 WebApp/Services/INotesService.cs create mode 100644 WebApp/Services/MarkdownHelper.cs create mode 100644 WebApp/Services/NotesService.cs diff --git a/Core/Entities/Note.cs b/Core/Entities/Note.cs new file mode 100644 index 0000000..c460b23 --- /dev/null +++ b/Core/Entities/Note.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; + +namespace Core.Entities; + +public class Note +{ + public int Id { get; set; } + + [Required] + [StringLength(200)] + [Display(Name = "Title")] + public string Title { get; set; } = null!; + + [Display(Name = "Content")] + public string? Content { get; set; } + + [Display(Name = "Created At")] + public DateTime CreatedAt { get; set; } + + [Display(Name = "Updated At")] + public DateTime UpdatedAt { get; set; } + + [Display(Name = "Created By")] + public string? CreatedBy { get; set; } + + [Display(Name = "Last Modified By")] + public string? LastModifiedBy { get; set; } + + public List NoteHistories { get; } = []; +} diff --git a/Core/Entities/NoteHistory.cs b/Core/Entities/NoteHistory.cs new file mode 100644 index 0000000..dd47198 --- /dev/null +++ b/Core/Entities/NoteHistory.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; + +namespace Core.Entities; + +public class NoteHistory +{ + public int Id { get; set; } + + [Required] + [Display(Name = "Note Id")] + public int NoteId { get; set; } + + [StringLength(200)] + [Display(Name = "Title")] + public string Title { get; set; } = null!; + + [Display(Name = "Content")] + public string? Content { get; set; } + + [Display(Name = "Modified By")] + public string? ModifiedBy { get; set; } + + [Display(Name = "Modified At")] + public DateTime ModifiedAt { get; set; } + + [Required] + [StringLength(50)] + [Display(Name = "Change Type")] + public string ChangeType { get; set; } = null!; + + public Note Note { get; set; } = null!; +} diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 640a4b7..0148c7d 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using System.Reflection; using Core.Entities; using Microsoft.EntityFrameworkCore; @@ -12,6 +12,8 @@ namespace Data public DbSet StudentEventRanking { get; set; } public DbSet EventOccurrences { get; set; } public DbSet Careers { get; set; } + public DbSet Notes { get; set; } + public DbSet NoteHistories { get; set; } public AppDbContext() { diff --git a/Data/Configurations/NoteConfiguration.cs b/Data/Configurations/NoteConfiguration.cs new file mode 100644 index 0000000..a2ae5a0 --- /dev/null +++ b/Data/Configurations/NoteConfiguration.cs @@ -0,0 +1,39 @@ +using Core.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Data.Configurations +{ + public class NoteConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(n => n.Id); + + // Indexes + builder.HasIndex(n => n.Title); + builder.HasIndex(n => n.CreatedAt); + + // Constraints + builder.Property(n => n.Title) + .IsRequired() + .HasMaxLength(200); + + builder.Property(n => n.Content) + .HasColumnType("TEXT"); + + builder.Property(n => n.CreatedBy) + .HasMaxLength(255); + + builder.Property(n => n.LastModifiedBy) + .HasMaxLength(255); + + // Relationships + builder.HasMany(n => n.NoteHistories) + .WithOne(h => h.Note) + .HasForeignKey(h => h.NoteId) + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + } + } +} diff --git a/Data/Configurations/NoteHistoryConfiguration.cs b/Data/Configurations/NoteHistoryConfiguration.cs new file mode 100644 index 0000000..d2b0e7a --- /dev/null +++ b/Data/Configurations/NoteHistoryConfiguration.cs @@ -0,0 +1,36 @@ +using Core.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Data.Configurations +{ + public class NoteHistoryConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(h => h.Id); + + // Indexes + builder.HasIndex(h => h.NoteId); + builder.HasIndex(h => h.ModifiedAt); + builder.HasIndex(h => new { h.NoteId, h.ModifiedAt }); + + // Constraints + builder.Property(h => h.Title) + .IsRequired() + .HasMaxLength(200); + + builder.Property(h => h.Content) + .HasColumnType("TEXT"); + + builder.Property(h => h.ModifiedBy) + .HasMaxLength(255); + + builder.Property(h => h.ChangeType) + .IsRequired() + .HasMaxLength(50); + + // Relationship is configured in NoteConfiguration + } + } +} diff --git a/Data/Migrations/20260115185640_AddNotesAndNoteHistory.Designer.cs b/Data/Migrations/20260115185640_AddNotesAndNoteHistory.Designer.cs new file mode 100644 index 0000000..a67869a --- /dev/null +++ b/Data/Migrations/20260115185640_AddNotesAndNoteHistory.Designer.cs @@ -0,0 +1,481 @@ +// +using System; +using Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260115185640_AddNotesAndNoteHistory")] + partial class AddNotesAndNoteHistory + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.8"); + + modelBuilder.Entity("CareerEventDefinition", b => + { + b.Property("EventDefinitionId") + .HasColumnType("INTEGER"); + + b.Property("RelatedCareersId") + .HasColumnType("INTEGER"); + + b.HasKey("EventDefinitionId", "RelatedCareersId"); + + b.HasIndex("RelatedCareersId"); + + b.ToTable("EventDefinitionCareers", (string)null); + }); + + modelBuilder.Entity("Core.Entities.Career", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Careers"); + }); + + modelBuilder.Entity("Core.Entities.EventDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterEligibilityCountRegionals") + .HasColumnType("INTEGER"); + + b.Property("ChapterEligibilityCountState") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Documentation") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Eligibility") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("EventFormat") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LevelOfEffort") + .HasColumnType("INTEGER"); + + b.Property("MaxTeamSize") + .HasColumnType("INTEGER"); + + b.Property("MinTeamSize") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("OnSiteActivity") + .HasColumnType("INTEGER"); + + b.Property("Presubmission") + .HasColumnType("INTEGER"); + + b.Property("SemifinalistActivity") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ShortName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Theme") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EventFormat"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("Core.Entities.EventOccurrence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("EventDefinitionId") + .HasColumnType("INTEGER"); + + b.Property("Location") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SpecialEventType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("Time") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EventDefinitionId"); + + b.HasIndex("SpecialEventType"); + + b.HasIndex("StartTime"); + + b.ToTable("EventOccurrences"); + }); + + modelBuilder.Entity("Core.Entities.Note", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Content") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastModifiedBy") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Title"); + + b.ToTable("Notes"); + }); + + modelBuilder.Entity("Core.Entities.NoteHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChangeType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Content") + .HasColumnType("TEXT"); + + b.Property("ModifiedAt") + .HasColumnType("TEXT"); + + b.Property("ModifiedBy") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("NoteId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ModifiedAt"); + + b.HasIndex("NoteId"); + + b.HasIndex("NoteId", "ModifiedAt"); + + b.ToTable("NoteHistories"); + }); + + modelBuilder.Entity("Core.Entities.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Grade") + .HasColumnType("INTEGER"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NationalId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OfficerRole") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("RegionalId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StateId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TsaYear") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("Grade"); + + b.HasIndex("FirstName", "LastName"); + + b.ToTable("Students"); + }); + + modelBuilder.Entity("Core.Entities.StudentEventRanking", b => + { + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.Property("EventDefinitionId") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.HasKey("StudentId", "EventDefinitionId"); + + b.HasIndex("EventDefinitionId"); + + b.HasIndex("Rank"); + + b.HasIndex("StudentId"); + + b.ToTable("StudentEventRanking"); + }); + + modelBuilder.Entity("Core.Entities.Team", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CaptainId") + .HasColumnType("INTEGER"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CaptainId"); + + b.HasIndex("EventId"); + + b.HasIndex("EventId", "Identifier"); + + b.ToTable("Teams"); + }); + + modelBuilder.Entity("StudentTeam", b => + { + b.Property("StudentsId") + .HasColumnType("INTEGER"); + + b.Property("TeamsId") + .HasColumnType("INTEGER"); + + b.HasKey("StudentsId", "TeamsId"); + + b.HasIndex("TeamsId"); + + b.ToTable("TeamStudents", (string)null); + }); + + modelBuilder.Entity("CareerEventDefinition", b => + { + b.HasOne("Core.Entities.EventDefinition", null) + .WithMany() + .HasForeignKey("EventDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.Career", null) + .WithMany() + .HasForeignKey("RelatedCareersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Core.Entities.EventOccurrence", b => + { + b.HasOne("Core.Entities.EventDefinition", "EventDefinition") + .WithMany() + .HasForeignKey("EventDefinitionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("EventDefinition"); + }); + + modelBuilder.Entity("Core.Entities.NoteHistory", b => + { + b.HasOne("Core.Entities.Note", "Note") + .WithMany("NoteHistories") + .HasForeignKey("NoteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Note"); + }); + + modelBuilder.Entity("Core.Entities.StudentEventRanking", b => + { + b.HasOne("Core.Entities.EventDefinition", "EventDefinition") + .WithMany() + .HasForeignKey("EventDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.Student", "Student") + .WithMany("EventRankings") + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EventDefinition"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Core.Entities.Team", b => + { + b.HasOne("Core.Entities.Student", "Captain") + .WithMany() + .HasForeignKey("CaptainId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Core.Entities.EventDefinition", "Event") + .WithMany() + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Captain"); + + b.Navigation("Event"); + }); + + modelBuilder.Entity("StudentTeam", b => + { + b.HasOne("Core.Entities.Student", null) + .WithMany() + .HasForeignKey("StudentsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.Team", null) + .WithMany() + .HasForeignKey("TeamsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Core.Entities.Note", b => + { + b.Navigation("NoteHistories"); + }); + + modelBuilder.Entity("Core.Entities.Student", b => + { + b.Navigation("EventRankings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/Migrations/20260115185640_AddNotesAndNoteHistory.cs b/Data/Migrations/20260115185640_AddNotesAndNoteHistory.cs new file mode 100644 index 0000000..06d0891 --- /dev/null +++ b/Data/Migrations/20260115185640_AddNotesAndNoteHistory.cs @@ -0,0 +1,92 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.Migrations +{ + /// + public partial class AddNotesAndNoteHistory : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Notes", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Title = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Content = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false), + CreatedBy = table.Column(type: "TEXT", maxLength: 255, nullable: true), + LastModifiedBy = table.Column(type: "TEXT", maxLength: 255, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Notes", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "NoteHistories", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + NoteId = table.Column(type: "INTEGER", nullable: false), + Title = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Content = table.Column(type: "TEXT", nullable: true), + ModifiedBy = table.Column(type: "TEXT", maxLength: 255, nullable: true), + ModifiedAt = table.Column(type: "TEXT", nullable: false), + ChangeType = table.Column(type: "TEXT", maxLength: 50, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_NoteHistories", x => x.Id); + table.ForeignKey( + name: "FK_NoteHistories_Notes_NoteId", + column: x => x.NoteId, + principalTable: "Notes", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_NoteHistories_ModifiedAt", + table: "NoteHistories", + column: "ModifiedAt"); + + migrationBuilder.CreateIndex( + name: "IX_NoteHistories_NoteId", + table: "NoteHistories", + column: "NoteId"); + + migrationBuilder.CreateIndex( + name: "IX_NoteHistories_NoteId_ModifiedAt", + table: "NoteHistories", + columns: new[] { "NoteId", "ModifiedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_Notes_CreatedAt", + table: "Notes", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_Notes_Title", + table: "Notes", + column: "Title"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "NoteHistories"); + + migrationBuilder.DropTable( + name: "Notes"); + } + } +} diff --git a/Data/Migrations/AppDbContextModelSnapshot.cs b/Data/Migrations/AppDbContextModelSnapshot.cs index a141808..a25de98 100644 --- a/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/Data/Migrations/AppDbContextModelSnapshot.cs @@ -177,6 +177,83 @@ namespace Data.Migrations b.ToTable("EventOccurrences"); }); + modelBuilder.Entity("Core.Entities.Note", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Content") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastModifiedBy") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Title"); + + b.ToTable("Notes"); + }); + + modelBuilder.Entity("Core.Entities.NoteHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChangeType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Content") + .HasColumnType("TEXT"); + + b.Property("ModifiedAt") + .HasColumnType("TEXT"); + + b.Property("ModifiedBy") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("NoteId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ModifiedAt"); + + b.HasIndex("NoteId"); + + b.HasIndex("NoteId", "ModifiedAt"); + + b.ToTable("NoteHistories"); + }); + modelBuilder.Entity("Core.Entities.Student", b => { b.Property("Id") @@ -323,6 +400,17 @@ namespace Data.Migrations b.Navigation("EventDefinition"); }); + modelBuilder.Entity("Core.Entities.NoteHistory", b => + { + b.HasOne("Core.Entities.Note", "Note") + .WithMany("NoteHistories") + .HasForeignKey("NoteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Note"); + }); + modelBuilder.Entity("Core.Entities.StudentEventRanking", b => { b.HasOne("Core.Entities.EventDefinition", "EventDefinition") @@ -375,6 +463,11 @@ namespace Data.Migrations .IsRequired(); }); + modelBuilder.Entity("Core.Entities.Note", b => + { + b.Navigation("NoteHistories"); + }); + modelBuilder.Entity("Core.Entities.Student", b => { b.Navigation("EventRankings"); diff --git a/WebApp/Components/App.razor b/WebApp/Components/App.razor index 5870269..2445bcf 100644 --- a/WebApp/Components/App.razor +++ b/WebApp/Components/App.razor @@ -1,4 +1,4 @@ - + @@ -7,6 +7,8 @@ + + @@ -17,9 +19,12 @@ + + + diff --git a/WebApp/Components/Pages/NoteEditDialog.razor b/WebApp/Components/Pages/NoteEditDialog.razor new file mode 100644 index 0000000..7f92ec2 --- /dev/null +++ b/WebApp/Components/Pages/NoteEditDialog.razor @@ -0,0 +1,88 @@ +@using Core.Entities +@using WebApp.Services +@using PSC.Blazor.Components.MarkdownEditor +@using MudBlazor +@inject INotesService NotesService +@inject ISnackbar Snackbar +@inject IDialogService DialogService + + + + + + + Content (Markdown) + + + + + Cancel + Save + + + +@code { + [CascadingParameter] + IMudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] + public Note Note { get; set; } = null!; + + [Parameter] + public bool IsEdit { get; set; } + + private Note _note = null!; + + protected override void OnInitialized() + { + _note = new Note + { + Id = Note.Id, + Title = Note.Title ?? "", + Content = Note.Content ?? "" + }; + } + + private async Task Save() + { + if (string.IsNullOrWhiteSpace(_note.Title)) + { + Snackbar.Add("Title is required", Severity.Warning); + return; + } + + try + { + if (IsEdit) + { + await NotesService.UpdateNoteAsync(_note); + Snackbar.Add("Note updated successfully", Severity.Success); + } + else + { + await NotesService.CreateNoteAsync(_note); + Snackbar.Add("Note created successfully", Severity.Success); + } + + MudDialog.Close(DialogResult.Ok(true)); + } + catch (Exception ex) + { + Snackbar.Add($"Error saving note: {ex.Message}", Severity.Error); + } + } + + private void Cancel() + { + MudDialog.Cancel(); + } +} diff --git a/WebApp/Components/Pages/NoteHistoryDialog.razor b/WebApp/Components/Pages/NoteHistoryDialog.razor new file mode 100644 index 0000000..a0af44f --- /dev/null +++ b/WebApp/Components/Pages/NoteHistoryDialog.razor @@ -0,0 +1,125 @@ +@using Core.Entities +@using WebApp.Services +@using MudBlazor +@inject INotesService NotesService +@inject IDialogService DialogService + + + + @if (_isLoading) + { + + } + else if (!_history.Any()) + { + + No history available for this note. + + } + else + { + + + Date/Time + User + Change Type + Title + Actions + + + + @context.ModifiedAt.ToString("g") + + + @(context.ModifiedBy ?? "Unknown") + + + + @context.ChangeType + + + + @context.Title + + + + View + + + + + } + + + Close + + + +@code { + [CascadingParameter] + IMudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] + public int NoteId { get; set; } + + private List _history = []; + private bool _isLoading = true; + + protected override async Task OnInitializedAsync() + { + await LoadHistory(); + } + + private async Task LoadHistory() + { + try + { + _history = (await NotesService.GetNoteHistoryAsync(NoteId)).ToList(); + } + catch (Exception ex) + { + // Error handling - could show snackbar if we had access + } + finally + { + _isLoading = false; + } + } + + private Color GetChangeTypeColor(string changeType) + { + return changeType switch + { + "Created" => Color.Success, + "Updated" => Color.Info, + "Deleted" => Color.Error, + _ => Color.Default + }; + } + + private async Task ViewVersion(NoteHistory history) + { + var parameters = new DialogParameters + { + ["History"] = history + }; + + var options = new DialogOptions + { + MaxWidth = MaxWidth.Large, + FullWidth = true, + CloseButton = true + }; + + await DialogService.ShowAsync("View Version", parameters, options); + } + + private void Close() + { + MudDialog.Close(); + } +} diff --git a/WebApp/Components/Pages/NoteVersionViewDialog.razor b/WebApp/Components/Pages/NoteVersionViewDialog.razor new file mode 100644 index 0000000..579cb6f --- /dev/null +++ b/WebApp/Components/Pages/NoteVersionViewDialog.razor @@ -0,0 +1,66 @@ +@using Core.Entities +@using WebApp.Services +@using MudBlazor + + + + + @_history.Title + + Modified: @_history.ModifiedAt.ToString("g") by @(_history.ModifiedBy ?? "Unknown") + + + @_history.ChangeType + + + @if (!string.IsNullOrWhiteSpace(_history.Content)) + { + +
+ @((MarkupString)MarkdownHelper.ToHtml(_history.Content)) +
+
+ } + else + { + + No content in this version. + + } +
+
+ + Close + +
+ +@code { + [CascadingParameter] + IMudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] + public NoteHistory History { get; set; } = null!; + + private NoteHistory _history = null!; + + protected override void OnInitialized() + { + _history = History; + } + + private Color GetChangeTypeColor(string changeType) + { + return changeType switch + { + "Created" => Color.Success, + "Updated" => Color.Info, + "Deleted" => Color.Error, + _ => Color.Default + }; + } + + private void Close() + { + MudDialog.Close(); + } +} diff --git a/WebApp/Components/Pages/Notes.razor b/WebApp/Components/Pages/Notes.razor new file mode 100644 index 0000000..ed08fae --- /dev/null +++ b/WebApp/Components/Pages/Notes.razor @@ -0,0 +1,260 @@ +@page "/notes" +@attribute [Authorize] +@implements IAsyncDisposable +@using Core.Entities +@using WebApp.Services +@using WebApp.Components.Shared.Components +@inject INotesService NotesService +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + + + + Create Note + + + + + + @if (_isLoading) + { + + } + else if (!_notes.Any()) + { + + No notes yet. Create your first note to get started! + + } + else + { + + @foreach (var note in _notes) + { + + + @if (!string.IsNullOrWhiteSpace(note.Content)) + { + +
+ @((MarkupString)MarkdownHelper.ToHtml(note.Content)) +
+
+ } + + + Last updated: @note.UpdatedAt.ToString("g") by @(note.LastModifiedBy ?? "Unknown") + + + + History + + + Edit + + + Delete + + + +
+
+ } +
+ } +
+ +@code { + private List _notes = []; + private bool _isLoading = true; + private CancellationTokenSource? _cancellationTokenSource; + private bool _isDisposed = false; + + protected override void OnInitialized() + { + _cancellationTokenSource = new CancellationTokenSource(); + } + + protected override async Task OnInitializedAsync() + { + await LoadNotes(); + } + + private async Task LoadNotes() + { + if (_isDisposed) return; + + _isLoading = true; + try + { + var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None; + _notes = (await NotesService.GetNotesAsync()).ToList(); + } + catch (TaskCanceledException) + { + // Component was disposed, ignore + } + catch (JSDisconnectedException) + { + // JS connection lost, ignore + } + catch (Exception ex) + { + if (!_isDisposed) + { + Snackbar.Add($"Error loading notes: {ex.Message}", Severity.Error); + } + } + finally + { + if (!_isDisposed) + { + _isLoading = false; + StateHasChanged(); + } + } + } + + private async Task OpenCreateDialog() + { + if (_isDisposed) return; + + var parameters = new DialogParameters + { + ["Note"] = new Note { Title = "", Content = "" }, + ["IsEdit"] = false + }; + + var options = new DialogOptions + { + MaxWidth = MaxWidth.Large, + FullWidth = true, + CloseButton = true + }; + + var dialog = await DialogService.ShowAsync("Create Note", parameters, options); + var result = await dialog.Result; + + if (!result.Canceled && !_isDisposed) + { + await LoadNotes(); + } + } + + private async Task OpenEditDialog(Note note) + { + if (_isDisposed) return; + + var parameters = new DialogParameters + { + ["Note"] = note, + ["IsEdit"] = true + }; + + var options = new DialogOptions + { + MaxWidth = MaxWidth.Large, + FullWidth = true, + CloseButton = true + }; + + var dialog = await DialogService.ShowAsync("Edit Note", parameters, options); + var result = await dialog.Result; + + if (!result.Canceled && !_isDisposed) + { + await LoadNotes(); + } + } + + private async Task OpenHistoryDialog(int noteId) + { + if (_isDisposed) return; + + var parameters = new DialogParameters + { + ["NoteId"] = noteId + }; + + var options = new DialogOptions + { + MaxWidth = MaxWidth.Large, + FullWidth = true, + CloseButton = true + }; + + await DialogService.ShowAsync("Note History", parameters, options); + } + + private async Task DeleteNote(Note note) + { + if (_isDisposed) return; + + try + { + var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None; + + var result = await DialogService.ShowMessageBox( + "Delete Note", + (MarkupString)$"Are you sure you want to delete {note.Title}? This cannot be undone.", + yesText: "Yes", + noText: "Cancel"); + + if (_isDisposed) return; + + if (result == true) + { + await NotesService.DeleteNoteAsync(note.Id); + + if (!_isDisposed) + { + Snackbar.Add($"Note '{note.Title}' deleted", Severity.Info); + await LoadNotes(); + } + } + } + catch (TaskCanceledException) + { + // Component was disposed, ignore + } + catch (JSDisconnectedException) + { + // JS connection lost, ignore + } + catch (Exception ex) + { + if (!_isDisposed) + { + Snackbar.Add($"Error deleting note: {ex.Message}", Severity.Error); + } + } + } + + public async ValueTask DisposeAsync() + { + if (!_isDisposed) + { + _isDisposed = true; + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = null; + } + await ValueTask.CompletedTask; + } +} diff --git a/WebApp/Components/Shared/Layout/NavMenu.razor b/WebApp/Components/Shared/Layout/NavMenu.razor index c44a769..6b8e4fc 100644 --- a/WebApp/Components/Shared/Layout/NavMenu.razor +++ b/WebApp/Components/Shared/Layout/NavMenu.razor @@ -1,4 +1,4 @@ -@using WebApp.Models +@using WebApp.Models @using WebApp.Authentication @inject IConfiguration Configuration @@ -28,6 +28,10 @@ Events + + Notes + + Chapter Settings diff --git a/WebApp/Components/_Imports.razor b/WebApp/Components/_Imports.razor index 76a1629..a8f4f86 100644 --- a/WebApp/Components/_Imports.razor +++ b/WebApp/Components/_Imports.razor @@ -1,4 +1,4 @@ -@using System.Net.Http +@using System.Net.Http @using System.Net.Http.Json @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization @@ -28,3 +28,5 @@ @using Core.Models @using Data @using VisNetwork.Blazor +@using PSC.Blazor.Components.MarkdownEditor +@using WebApp.Services \ No newline at end of file diff --git a/WebApp/Program.cs b/WebApp/Program.cs index 932b867..a30f86b 100644 --- a/WebApp/Program.cs +++ b/WebApp/Program.cs @@ -193,6 +193,7 @@ builder.Services.AddScoped(sp => }); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // State container for maintaining state per user connection (Blazor Server) builder.Services.AddScoped(); diff --git a/WebApp/Services/INotesService.cs b/WebApp/Services/INotesService.cs new file mode 100644 index 0000000..6c10c64 --- /dev/null +++ b/WebApp/Services/INotesService.cs @@ -0,0 +1,36 @@ +using Core.Entities; + +namespace WebApp.Services; + +public interface INotesService +{ + /// + /// Gets all notes. + /// + Task> GetNotesAsync(); + + /// + /// Gets a single note by ID. + /// + Task GetNoteAsync(int id); + + /// + /// Gets all history entries for a note. + /// + Task> GetNoteHistoryAsync(int noteId); + + /// + /// Creates a new note and initial history entry. + /// + Task CreateNoteAsync(Note note); + + /// + /// Updates an existing note and creates a history entry. + /// + Task UpdateNoteAsync(Note note); + + /// + /// Deletes a note and creates a deletion history entry. + /// + Task DeleteNoteAsync(int id); +} diff --git a/WebApp/Services/MarkdownHelper.cs b/WebApp/Services/MarkdownHelper.cs new file mode 100644 index 0000000..5ee8d1b --- /dev/null +++ b/WebApp/Services/MarkdownHelper.cs @@ -0,0 +1,48 @@ +using Markdig; +using System.Text.RegularExpressions; + +namespace WebApp.Services; + +/// +/// Helper class for rendering markdown to HTML. +/// +public static class MarkdownHelper +{ + private static readonly MarkdownPipeline _pipeline = new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .Build(); + + /// + /// Converts markdown text to HTML. + /// + /// The markdown text to convert. + /// HTML string ready for rendering. + public static string ToHtml(string? markdown) + { + if (string.IsNullOrWhiteSpace(markdown)) + return string.Empty; + + var html = Markdown.ToHtml(markdown, _pipeline); + + // Add target="_blank" and rel="noopener noreferrer" to all links + html = Regex.Replace(html, @"]*?)href=""([^""]*?)""([^>]*?)>", + match => + { + var beforeHref = match.Groups[1].Value; + var href = match.Groups[2].Value; + var afterHref = match.Groups[3].Value; + + // Check if target is already present + if (Regex.IsMatch(beforeHref + afterHref, @"target\s*=", RegexOptions.IgnoreCase)) + { + return match.Value; // Return unchanged if target already exists + } + + // Add target="_blank" and rel="noopener noreferrer" + return $""; + }, + RegexOptions.IgnoreCase); + + return html; + } +} diff --git a/WebApp/Services/NotesService.cs b/WebApp/Services/NotesService.cs new file mode 100644 index 0000000..a6cd120 --- /dev/null +++ b/WebApp/Services/NotesService.cs @@ -0,0 +1,162 @@ +using Core.Entities; +using Data; +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; + +namespace WebApp.Services; + +public class NotesService : INotesService +{ + private readonly AppDbContext _context; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ILogger _logger; + + public NotesService( + AppDbContext context, + IHttpContextAccessor httpContextAccessor, + ILogger logger) + { + _context = context; + _httpContextAccessor = httpContextAccessor; + _logger = logger; + } + + private string? GetCurrentUserEmail() + { + var user = _httpContextAccessor.HttpContext?.User; + if (user == null) + return null; + + return user.FindFirstValue(ClaimTypes.Email) ?? user.Identity?.Name; + } + + public async Task> GetNotesAsync() + { + return await _context.Notes + .AsNoTracking() + .OrderByDescending(n => n.UpdatedAt) + .ToListAsync(); + } + + public async Task GetNoteAsync(int id) + { + return await _context.Notes + .Include(n => n.NoteHistories.OrderByDescending(h => h.ModifiedAt)) + .FirstOrDefaultAsync(n => n.Id == id); + } + + public async Task> GetNoteHistoryAsync(int noteId) + { + return await _context.NoteHistories + .AsNoTracking() + .Where(h => h.NoteId == noteId) + .OrderByDescending(h => h.ModifiedAt) + .Take(10) + .ToListAsync(); + } + + public async Task CreateNoteAsync(Note note) + { + var userEmail = GetCurrentUserEmail(); + var now = DateTime.UtcNow; + + note.CreatedAt = now; + note.UpdatedAt = now; + note.CreatedBy = userEmail; + note.LastModifiedBy = userEmail; + + _context.Notes.Add(note); + await _context.SaveChangesAsync(); + + // Create initial history entry + var history = new NoteHistory + { + NoteId = note.Id, + Title = note.Title, + Content = note.Content, + ModifiedBy = userEmail, + ModifiedAt = now, + ChangeType = "Created" + }; + + _context.NoteHistories.Add(history); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Note created: {NoteId} by {User}", note.Id, userEmail); + + return note; + } + + public async Task UpdateNoteAsync(Note note) + { + var existingNote = await _context.Notes + .FirstOrDefaultAsync(n => n.Id == note.Id); + + if (existingNote == null) + { + throw new InvalidOperationException($"Note with ID {note.Id} not found."); + } + + var userEmail = GetCurrentUserEmail(); + var now = DateTime.UtcNow; + + // Create history entry with previous state before updating + var history = new NoteHistory + { + NoteId = existingNote.Id, + Title = existingNote.Title, + Content = existingNote.Content, + ModifiedBy = userEmail, + ModifiedAt = now, + ChangeType = "Updated" + }; + + _context.NoteHistories.Add(history); + + // Update the note + existingNote.Title = note.Title; + existingNote.Content = note.Content; + existingNote.UpdatedAt = now; + existingNote.LastModifiedBy = userEmail; + + await _context.SaveChangesAsync(); + + _logger.LogInformation("Note updated: {NoteId} by {User}", note.Id, userEmail); + + return existingNote; + } + + public async Task DeleteNoteAsync(int id) + { + var note = await _context.Notes + .FirstOrDefaultAsync(n => n.Id == id); + + if (note == null) + { + throw new InvalidOperationException($"Note with ID {id} not found."); + } + + var userEmail = GetCurrentUserEmail(); + var now = DateTime.UtcNow; + + // Create deletion history entry + var history = new NoteHistory + { + NoteId = note.Id, + Title = note.Title, + Content = note.Content, + ModifiedBy = userEmail, + ModifiedAt = now, + ChangeType = "Deleted" + }; + + _context.NoteHistories.Add(history); + + // Delete the note (cascade will handle history, but we want to keep history) + _context.Notes.Remove(note); + + await _context.SaveChangesAsync(); + + _logger.LogInformation("Note deleted: {NoteId} by {User}", id, userEmail); + } +} diff --git a/WebApp/WebApp.csproj b/WebApp/WebApp.csproj index 277e852..c3bb11c 100644 --- a/WebApp/WebApp.csproj +++ b/WebApp/WebApp.csproj @@ -1,4 +1,4 @@ - + net9.0 @@ -33,6 +33,8 @@ + + diff --git a/WebApp/wwwroot/app.css b/WebApp/wwwroot/app.css index 31f9bfc..2ac96bc3 100644 --- a/WebApp/wwwroot/app.css +++ b/WebApp/wwwroot/app.css @@ -128,4 +128,105 @@ body { font-size: 0.875rem; } +} + +/* Markdown content styling */ +.markdown-content { + line-height: 1.6; +} + +.markdown-content h1, +.markdown-content h2, +.markdown-content h3, +.markdown-content h4, +.markdown-content h5, +.markdown-content h6 { + margin-top: 1.5em; + margin-bottom: 0.5em; + font-weight: 600; +} + +.markdown-content h1 { + font-size: 2em; + border-bottom: 1px solid var(--mud-palette-divider); + padding-bottom: 0.3em; +} + +.markdown-content h2 { + font-size: 1.5em; + border-bottom: 1px solid var(--mud-palette-divider); + padding-bottom: 0.3em; +} + +.markdown-content p { + margin: 1em 0; +} + +.markdown-content ul, +.markdown-content ol { + margin: 1em 0; + padding-left: 2em; +} + +.markdown-content li { + margin: 0.5em 0; +} + +.markdown-content a { + color: var(--mud-palette-primary); + text-decoration: none; +} + +.markdown-content a:hover { + text-decoration: underline; +} + +.markdown-content code { + background-color: var(--mud-palette-background-grey); + padding: 0.2em 0.4em; + border-radius: 3px; + font-family: 'Courier New', monospace; + font-size: 0.9em; +} + +.markdown-content pre { + background-color: var(--mud-palette-background-grey); + padding: 1em; + border-radius: 4px; + overflow-x: auto; +} + +.markdown-content pre code { + background-color: transparent; + padding: 0; +} + +.markdown-content blockquote { + border-left: 4px solid var(--mud-palette-primary); + padding-left: 1em; + margin: 1em 0; + color: var(--mud-palette-text-secondary); +} + +.markdown-content table { + border-collapse: collapse; + width: 100%; + margin: 1em 0; +} + +.markdown-content table th, +.markdown-content table td { + border: 1px solid var(--mud-palette-divider); + padding: 0.5em; + text-align: left; +} + +.markdown-content table th { + background-color: var(--mud-palette-background-grey); + font-weight: 600; +} + +.markdown-content img { + max-width: 100%; + height: auto; } \ No newline at end of file