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.
This commit is contained in:
2026-01-15 21:47:01 -05:00
parent 5fdda08627
commit 5c4aaf91df
21 changed files with 1710 additions and 5 deletions
+30
View File
@@ -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<NoteHistory> NoteHistories { get; } = [];
}
+32
View File
@@ -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!;
}
+3 -1
View File
@@ -1,4 +1,4 @@
using System.Reflection; using System.Reflection;
using Core.Entities; using Core.Entities;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -12,6 +12,8 @@ namespace Data
public DbSet<StudentEventRanking> StudentEventRanking { get; set; } public DbSet<StudentEventRanking> StudentEventRanking { get; set; }
public DbSet<EventOccurrence> EventOccurrences { get; set; } public DbSet<EventOccurrence> EventOccurrences { get; set; }
public DbSet<Career> Careers { get; set; } public DbSet<Career> Careers { get; set; }
public DbSet<Note> Notes { get; set; }
public DbSet<NoteHistory> NoteHistories { get; set; }
public AppDbContext() public AppDbContext()
{ {
+39
View File
@@ -0,0 +1,39 @@
using Core.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Data.Configurations
{
public class NoteConfiguration : IEntityTypeConfiguration<Note>
{
public void Configure(EntityTypeBuilder<Note> 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);
}
}
}
@@ -0,0 +1,36 @@
using Core.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Data.Configurations
{
public class NoteHistoryConfiguration : IEntityTypeConfiguration<NoteHistory>
{
public void Configure(EntityTypeBuilder<NoteHistory> 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
}
}
}
@@ -0,0 +1,481 @@
// <auto-generated />
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
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.8");
modelBuilder.Entity("CareerEventDefinition", b =>
{
b.Property<int>("EventDefinitionId")
.HasColumnType("INTEGER");
b.Property<int>("RelatedCareersId")
.HasColumnType("INTEGER");
b.HasKey("EventDefinitionId", "RelatedCareersId");
b.HasIndex("RelatedCareersId");
b.ToTable("EventDefinitionCareers", (string)null);
});
modelBuilder.Entity("Core.Entities.Career", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Careers");
});
modelBuilder.Entity("Core.Entities.EventDefinition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChapterEligibilityCountRegionals")
.HasColumnType("INTEGER");
b.Property<int>("ChapterEligibilityCountState")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("Documentation")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Eligibility")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("EventFormat")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int?>("LevelOfEffort")
.HasColumnType("INTEGER");
b.Property<int>("MaxTeamSize")
.HasColumnType("INTEGER");
b.Property<int>("MinTeamSize")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<bool>("OnSiteActivity")
.HasColumnType("INTEGER");
b.Property<bool>("Presubmission")
.HasColumnType("INTEGER");
b.Property<string>("SemifinalistActivity")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("ShortName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Date")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("EndTime")
.HasColumnType("TEXT");
b.Property<int?>("EventDefinitionId")
.HasColumnType("INTEGER");
b.Property<string>("Location")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SpecialEventType")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<DateTime>("StartTime")
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Content")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedBy")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("LastModifiedBy")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("Title");
b.ToTable("Notes");
});
modelBuilder.Entity("Core.Entities.NoteHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ChangeType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("Content")
.HasColumnType("TEXT");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("TEXT");
b.Property<string>("ModifiedBy")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<int>("NoteId")
.HasColumnType("INTEGER");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Email")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("FirstName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("Grade")
.HasColumnType("INTEGER");
b.Property<string>("LastName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("NationalId")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("OfficerRole")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("RegionalId")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("StateId")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int>("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<int>("StudentId")
.HasColumnType("INTEGER");
b.Property<int>("EventDefinitionId")
.HasColumnType("INTEGER");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("CaptainId")
.HasColumnType("INTEGER");
b.Property<int>("EventId")
.HasColumnType("INTEGER");
b.Property<string>("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<int>("StudentsId")
.HasColumnType("INTEGER");
b.Property<int>("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
}
}
}
@@ -0,0 +1,92 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.Migrations
{
/// <inheritdoc />
public partial class AddNotesAndNoteHistory : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Notes",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Content = table.Column<string>(type: "TEXT", nullable: true),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
LastModifiedBy = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Notes", x => x.Id);
});
migrationBuilder.CreateTable(
name: "NoteHistories",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
NoteId = table.Column<int>(type: "INTEGER", nullable: false),
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Content = table.Column<string>(type: "TEXT", nullable: true),
ModifiedBy = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
ModifiedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
ChangeType = table.Column<string>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "NoteHistories");
migrationBuilder.DropTable(
name: "Notes");
}
}
}
@@ -177,6 +177,83 @@ namespace Data.Migrations
b.ToTable("EventOccurrences"); b.ToTable("EventOccurrences");
}); });
modelBuilder.Entity("Core.Entities.Note", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Content")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedBy")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("LastModifiedBy")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("Title");
b.ToTable("Notes");
});
modelBuilder.Entity("Core.Entities.NoteHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ChangeType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("Content")
.HasColumnType("TEXT");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("TEXT");
b.Property<string>("ModifiedBy")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<int>("NoteId")
.HasColumnType("INTEGER");
b.Property<string>("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 => modelBuilder.Entity("Core.Entities.Student", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -323,6 +400,17 @@ namespace Data.Migrations
b.Navigation("EventDefinition"); 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 => modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
{ {
b.HasOne("Core.Entities.EventDefinition", "EventDefinition") b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
@@ -375,6 +463,11 @@ namespace Data.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("Core.Entities.Note", b =>
{
b.Navigation("NoteHistories");
});
modelBuilder.Entity("Core.Entities.Student", b => modelBuilder.Entity("Core.Entities.Student", b =>
{ {
b.Navigation("EventRankings"); b.Navigation("EventRankings");
+6 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -7,6 +7,8 @@
<base href="/" /> <base href="/" />
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
<link href="@Assets["_content/MudBlazor/MudBlazor.min.css"]" rel="stylesheet" /> <link href="@Assets["_content/MudBlazor/MudBlazor.min.css"]" rel="stylesheet" />
<link href="_content/PSC.Blazor.Components.MarkdownEditor/css/easymde.min.css" rel="stylesheet" />
<link href="_content/PSC.Blazor.Components.MarkdownEditor/css/markdowneditor.css" rel="stylesheet" />
<link rel="stylesheet" href="app.css" /> <link rel="stylesheet" href="app.css" />
<link rel="icon" type="image/png" href="favicon.png" /> <link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet /> <HeadOutlet />
@@ -17,9 +19,12 @@
<Routes @rendermode="InteractiveServer" /> <Routes @rendermode="InteractiveServer" />
</AppErrorBoundary> </AppErrorBoundary>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="_framework/blazor.web.js"></script> <script src="_framework/blazor.web.js"></script>
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script> <script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
<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/markdownEditor.js"></script>
</body> </body>
</html> </html>
@@ -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
<MudDialog>
<DialogContent>
<MudStack Spacing="3">
<MudTextField @bind-Value="_note.Title"
Label="Title"
Variant="Variant.Outlined"
Required="true"
RequiredError="Title is required"
MaxLength="200" />
<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>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Save">Save</MudButton>
</DialogActions>
</MudDialog>
@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();
}
}
@@ -0,0 +1,125 @@
@using Core.Entities
@using WebApp.Services
@using MudBlazor
@inject INotesService NotesService
@inject IDialogService DialogService
<MudDialog>
<DialogContent>
@if (_isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
}
else if (!_history.Any())
{
<MudText Typo="Typo.body1" Align="Align.Center" Class="my-4">
No history available for this note.
</MudText>
}
else
{
<MudTable Items="@_history" Hover="true" Striped="true" Dense="true">
<HeaderContent>
<MudTh>Date/Time</MudTh>
<MudTh>User</MudTh>
<MudTh>Change Type</MudTh>
<MudTh>Title</MudTh>
<MudTh>Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Date/Time">
@context.ModifiedAt.ToString("g")
</MudTd>
<MudTd DataLabel="User">
@(context.ModifiedBy ?? "Unknown")
</MudTd>
<MudTd DataLabel="Change Type">
<MudChip T="string" Size="Size.Small"
Color="@GetChangeTypeColor(context.ChangeType)">
@context.ChangeType
</MudChip>
</MudTd>
<MudTd DataLabel="Title">
@context.Title
</MudTd>
<MudTd DataLabel="Actions">
<MudButton StartIcon="@Icons.Material.Filled.Visibility"
OnClick="() => ViewVersion(context)"
Variant="Variant.Text"
Size="Size.Small">
View
</MudButton>
</MudTd>
</RowTemplate>
</MudTable>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="Close">Close</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public int NoteId { get; set; }
private List<NoteHistory> _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<NoteVersionViewDialog>("View Version", parameters, options);
}
private void Close()
{
MudDialog.Close();
}
}
@@ -0,0 +1,66 @@
@using Core.Entities
@using WebApp.Services
@using MudBlazor
<MudDialog>
<DialogContent>
<MudStack Spacing="3">
<MudText Typo="Typo.h6">@_history.Title</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">
Modified: @_history.ModifiedAt.ToString("g") by @(_history.ModifiedBy ?? "Unknown")
</MudText>
<MudChip T="string" Size="Size.Small" Color="@GetChangeTypeColor(_history.ChangeType)">
@_history.ChangeType
</MudChip>
<MudDivider />
@if (!string.IsNullOrWhiteSpace(_history.Content))
{
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-background-grey);">
<div class="markdown-content">
@((MarkupString)MarkdownHelper.ToHtml(_history.Content))
</div>
</MudPaper>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center" Class="my-4">
No content in this version.
</MudText>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Close">Close</MudButton>
</DialogActions>
</MudDialog>
@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();
}
}
+260
View File
@@ -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
<PageHeader Title="Notes">
<ActionButtons>
<MudButton StartIcon="@Icons.Material.Filled.Add"
OnClick="OpenCreateDialog"
Variant="Variant.Filled"
Color="Color.Primary">
Create Note
</MudButton>
</ActionButtons>
</PageHeader>
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
@if (_isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
}
else if (!_notes.Any())
{
<MudText Typo="Typo.h6" Align="Align.Center" Class="my-8">
No notes yet. Create your first note to get started!
</MudText>
}
else
{
<MudExpansionPanels MultiExpansion="true">
@foreach (var note in _notes)
{
<MudExpansionPanel Text="@note.Title"
Icon="@Icons.Material.Filled.Note">
<MudStack Spacing="2">
@if (!string.IsNullOrWhiteSpace(note.Content))
{
<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">
<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>
</MudStack>
</MudStack>
</MudStack>
</MudExpansionPanel>
}
</MudExpansionPanels>
}
</MudPaper>
@code {
private List<Note> _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<NoteEditDialog>("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<NoteEditDialog>("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<NoteHistoryDialog>("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 <b>{note.Title}</b>? 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;
}
}
@@ -1,4 +1,4 @@
@using WebApp.Models @using WebApp.Models
@using WebApp.Authentication @using WebApp.Authentication
@inject IConfiguration Configuration @inject IConfiguration Configuration
@@ -28,6 +28,10 @@
<MudNavLink Href="/events" Icon="@AppIcons.Events">Events</MudNavLink> <MudNavLink Href="/events" Icon="@AppIcons.Events">Events</MudNavLink>
</MudNavGroup> </MudNavGroup>
<MudNavGroup Title="Tools" Icon="@Icons.Material.Filled.Build" Expanded="false">
<MudNavLink Href="/notes" Icon="@Icons.Material.Filled.Note">Notes</MudNavLink>
</MudNavGroup>
<AuthorizeView Roles="Administrator"> <AuthorizeView Roles="Administrator">
<MudDivider Class="my-2"/> <MudDivider Class="my-2"/>
<MudNavLink Href="/settings/chapter" Icon="@Icons.Material.Filled.School">Chapter Settings</MudNavLink> <MudNavLink Href="/settings/chapter" Icon="@Icons.Material.Filled.School">Chapter Settings</MudNavLink>
+3 -1
View File
@@ -1,4 +1,4 @@
@using System.Net.Http @using System.Net.Http
@using System.Net.Http.Json @using System.Net.Http.Json
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Authorization
@@ -28,3 +28,5 @@
@using Core.Models @using Core.Models
@using Data @using Data
@using VisNetwork.Blazor @using VisNetwork.Blazor
@using PSC.Blazor.Components.MarkdownEditor
@using WebApp.Services
+1
View File
@@ -193,6 +193,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>();
// 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>();
+36
View File
@@ -0,0 +1,36 @@
using Core.Entities;
namespace WebApp.Services;
public interface INotesService
{
/// <summary>
/// Gets all notes.
/// </summary>
Task<IEnumerable<Note>> GetNotesAsync();
/// <summary>
/// Gets a single note by ID.
/// </summary>
Task<Note?> GetNoteAsync(int id);
/// <summary>
/// Gets all history entries for a note.
/// </summary>
Task<IEnumerable<NoteHistory>> GetNoteHistoryAsync(int noteId);
/// <summary>
/// Creates a new note and initial history entry.
/// </summary>
Task<Note> CreateNoteAsync(Note note);
/// <summary>
/// Updates an existing note and creates a history entry.
/// </summary>
Task<Note> UpdateNoteAsync(Note note);
/// <summary>
/// Deletes a note and creates a deletion history entry.
/// </summary>
Task DeleteNoteAsync(int id);
}
+48
View File
@@ -0,0 +1,48 @@
using Markdig;
using System.Text.RegularExpressions;
namespace WebApp.Services;
/// <summary>
/// Helper class for rendering markdown to HTML.
/// </summary>
public static class MarkdownHelper
{
private static readonly MarkdownPipeline _pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.Build();
/// <summary>
/// Converts markdown text to HTML.
/// </summary>
/// <param name="markdown">The markdown text to convert.</param>
/// <returns>HTML string ready for rendering.</returns>
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, @"<a\s+([^>]*?)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 $"<a {beforeHref}href=\"{href}\" target=\"_blank\" rel=\"noopener noreferrer\"{afterHref}>";
},
RegexOptions.IgnoreCase);
return html;
}
}
+162
View File
@@ -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<NotesService> _logger;
public NotesService(
AppDbContext context,
IHttpContextAccessor httpContextAccessor,
ILogger<NotesService> 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<IEnumerable<Note>> GetNotesAsync()
{
return await _context.Notes
.AsNoTracking()
.OrderByDescending(n => n.UpdatedAt)
.ToListAsync();
}
public async Task<Note?> GetNoteAsync(int id)
{
return await _context.Notes
.Include(n => n.NoteHistories.OrderByDescending(h => h.ModifiedAt))
.FirstOrDefaultAsync(n => n.Id == id);
}
public async Task<IEnumerable<NoteHistory>> GetNoteHistoryAsync(int noteId)
{
return await _context.NoteHistories
.AsNoTracking()
.Where(h => h.NoteId == noteId)
.OrderByDescending(h => h.ModifiedAt)
.Take(10)
.ToListAsync();
}
public async Task<Note> 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<Note> 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);
}
}
+3 -1
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
@@ -33,6 +33,8 @@
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="PSC.Blazor.Components.MarkdownEditor" Version="8.0.6" />
<PackageReference Include="Markdig" Version="0.37.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+101
View File
@@ -128,4 +128,105 @@
body { body {
font-size: 0.875rem; 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;
} }