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