diff --git a/src/LinkDotNet.Blog.Domain/BlogPostTemplate.cs b/src/LinkDotNet.Blog.Domain/BlogPostTemplate.cs new file mode 100644 index 00000000..5883fc25 --- /dev/null +++ b/src/LinkDotNet.Blog.Domain/BlogPostTemplate.cs @@ -0,0 +1,33 @@ +using System; + +namespace LinkDotNet.Blog.Domain; + +public sealed class BlogPostTemplate : Entity +{ + public string Name { get; private set; } = default!; + + public string Title { get; private set; } = default!; + + public string ShortDescription { get; private set; } = default!; + + public string Content { get; private set; } = default!; + + public static BlogPostTemplate Create(string name, string title, string shortDescription, string content) + { + return new BlogPostTemplate + { + Name = name, + Title = title, + ShortDescription = shortDescription, + Content = content + }; + } + + public void Update(string name, string title, string shortDescription, string content) + { + Name = name; + Title = title; + ShortDescription = shortDescription; + Content = content; + } +} diff --git a/src/LinkDotNet.Blog.Infrastructure/Migrations/20260109211327_AddBlogPostTemplate.Designer.cs b/src/LinkDotNet.Blog.Infrastructure/Migrations/20260109211327_AddBlogPostTemplate.Designer.cs new file mode 100644 index 00000000..d7b64691 --- /dev/null +++ b/src/LinkDotNet.Blog.Infrastructure/Migrations/20260109211327_AddBlogPostTemplate.Designer.cs @@ -0,0 +1,277 @@ +// +using System; +using LinkDotNet.Blog.Infrastructure.Persistence.Sql; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace LinkDotNet.Blog.Web.Migrations; + +[DbContext(typeof(BlogDbContext))] +[Migration("20260109211327_AddBlogPostTemplate")] +partial class AddBlogPostTemplate +{ + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.11"); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.BlogPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PreviewImageUrl") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("PreviewImageUrlFallback") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ReadingTimeInMinutes") + .HasColumnType("INTEGER"); + + b.Property("ScheduledPublishDate") + .HasColumnType("TEXT"); + + b.Property("ShortDescription") + .IsRequired() + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsPublished", "UpdatedDate") + .IsDescending(false, true) + .HasDatabaseName("IX_BlogPosts_IsPublished_UpdatedDate"); + + b.ToTable("BlogPosts"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.BlogPostRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("BlogPostId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Clicks") + .HasColumnType("INTEGER"); + + b.Property("DateClicked") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("BlogPostRecords"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.BlogPostTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ShortDescription") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("BlogPostTemplates"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.ProfileInformationEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ProfileInformationEntries"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.ShortCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("MarkdownContent") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ShortCodes"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.SimilarBlogPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("SimilarBlogPostIds") + .IsRequired() + .HasMaxLength(1350) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SimilarBlogPosts"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.Skill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("Capability") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("IconUrl") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ProficiencyLevel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Skills"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.Talk", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Place") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PresentationTitle") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PublishedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Talks"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.UserRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("DateClicked") + .HasColumnType("TEXT"); + + b.Property("UrlClicked") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UserRecords"); + }); +#pragma warning restore 612, 618 + } +} diff --git a/src/LinkDotNet.Blog.Infrastructure/Migrations/20260109211327_AddBlogPostTemplate.cs b/src/LinkDotNet.Blog.Infrastructure/Migrations/20260109211327_AddBlogPostTemplate.cs new file mode 100644 index 00000000..a62d2c5b --- /dev/null +++ b/src/LinkDotNet.Blog.Infrastructure/Migrations/20260109211327_AddBlogPostTemplate.cs @@ -0,0 +1,62 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LinkDotNet.Blog.Web.Migrations; + +/// +public partial class AddBlogPostTemplate : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + + migrationBuilder.AlterColumn( + name: "AuthorName", + table: "BlogPosts", + type: "TEXT", + maxLength: 256, + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(256)", + oldMaxLength: 256, + oldNullable: true); + + migrationBuilder.CreateTable( + name: "BlogPostTemplates", + columns: table => new + { + Id = table.Column(type: "TEXT", unicode: false, nullable: false), + Name = table.Column(type: "TEXT", maxLength: 256, nullable: false), + Title = table.Column(type: "TEXT", maxLength: 256, nullable: false), + ShortDescription = table.Column(type: "TEXT", nullable: false), + Content = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BlogPostTemplates", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + + migrationBuilder.DropTable( + name: "BlogPostTemplates"); + + migrationBuilder.AlterColumn( + name: "AuthorName", + table: "BlogPosts", + type: "TEXT", + maxLength: 256, + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(256)", + oldMaxLength: 256, + oldNullable: true); + } +} \ No newline at end of file diff --git a/src/LinkDotNet.Blog.Infrastructure/Migrations/BlogDbContextModelSnapshot.cs b/src/LinkDotNet.Blog.Infrastructure/Migrations/BlogDbContextModelSnapshot.cs index 722942de..f0ee127e 100644 --- a/src/LinkDotNet.Blog.Infrastructure/Migrations/BlogDbContextModelSnapshot.cs +++ b/src/LinkDotNet.Blog.Infrastructure/Migrations/BlogDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using LinkDotNet.Blog.Infrastructure.Persistence.Sql; using Microsoft.EntityFrameworkCore; @@ -15,7 +15,7 @@ partial class BlogDbContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); + modelBuilder.HasAnnotation("ProductVersion", "9.0.11"); modelBuilder.Entity("LinkDotNet.Blog.Domain.BlogPost", b => { @@ -26,7 +26,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AuthorName") .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); + .HasColumnType("TEXT"); b.Property("Content") .IsRequired() @@ -57,7 +57,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("TEXT"); - b.Property("Tags") + b.PrimitiveCollection("Tags") .IsRequired() .HasMaxLength(2048) .HasColumnType("TEXT"); @@ -102,6 +102,36 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("BlogPostRecords"); }); + modelBuilder.Entity("LinkDotNet.Blog.Domain.BlogPostTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ShortDescription") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("BlogPostTemplates"); + }); + modelBuilder.Entity("LinkDotNet.Blog.Domain.ProfileInformationEntry", b => { b.Property("Id") @@ -150,7 +180,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsUnicode(false) .HasColumnType("TEXT"); - b.Property("SimilarBlogPostIds") + b.PrimitiveCollection("SimilarBlogPostIds") .IsRequired() .HasMaxLength(1350) .HasColumnType("TEXT"); diff --git a/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/BlogDbContext.cs b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/BlogDbContext.cs index f6c338c9..b8b647f6 100644 --- a/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/BlogDbContext.cs +++ b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/BlogDbContext.cs @@ -29,12 +29,15 @@ public BlogDbContext(DbContextOptions options) public DbSet ShortCodes { get; set; } + public DbSet BlogPostTemplates { get; set; } + protected override void OnModelCreating(ModelBuilder modelBuilder) { ArgumentNullException.ThrowIfNull(modelBuilder); modelBuilder.ApplyConfiguration(new BlogPostConfiguration()); modelBuilder.ApplyConfiguration(new BlogPostRecordConfiguration()); + modelBuilder.ApplyConfiguration(new BlogPostTemplateConfiguration()); modelBuilder.ApplyConfiguration(new ProfileInformationEntryConfiguration()); modelBuilder.ApplyConfiguration(new ShortCodeConfiguration()); modelBuilder.ApplyConfiguration(new SimilarBlogPostConfiguration()); diff --git a/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/BlogPostTemplateConfiguration.cs b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/BlogPostTemplateConfiguration.cs new file mode 100644 index 00000000..4114401f --- /dev/null +++ b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/BlogPostTemplateConfiguration.cs @@ -0,0 +1,20 @@ +using LinkDotNet.Blog.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace LinkDotNet.Blog.Infrastructure.Persistence.Sql.Mapping; + +internal sealed class BlogPostTemplateConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(c => c.Id); + builder.Property(c => c.Id) + .IsUnicode(false) + .ValueGeneratedOnAdd(); + builder.Property(x => x.Name).HasMaxLength(256).IsRequired(); + builder.Property(x => x.Title).HasMaxLength(256).IsRequired(); + builder.Property(x => x.Content).IsRequired(); + builder.Property(x => x.ShortDescription).IsRequired(); + } +} diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/AddTemplateDialog.razor b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/AddTemplateDialog.razor new file mode 100644 index 00000000..b0ae707a --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/AddTemplateDialog.razor @@ -0,0 +1,184 @@ +@using LinkDotNet.Blog.Domain +@using LinkDotNet.Blog.Infrastructure +@using LinkDotNet.Blog.Infrastructure.Persistence +@inject IToastService ToastService +@inject IRepository TemplateRepository +@inject IJSRuntime JSRuntime + + + + + + +@code { + [Parameter] + public EventCallback OnSave { get; set; } + + private ModalDialog ModalDialog { get; set; } = default!; + + private string templateName = string.Empty; + private string TemplateName + { + get => templateName; + set + { + if (templateName != value) + { + templateName = value; + FilterTemplates(value); + } + } + } + + private bool isOpen; + private int selectedIndex = -1; + private IPagedList allTemplates = PagedList.Empty; + private List filteredTemplates = []; + + private bool IsOverwriting => allTemplates.Any(t => t.Name.Equals(TemplateName, StringComparison.OrdinalIgnoreCase)); + + public async Task Open() + { + TemplateName = string.Empty; + allTemplates = await TemplateRepository.GetAllAsync(); + filteredTemplates = allTemplates.ToList(); + isOpen = false; + selectedIndex = -1; + ModalDialog.Open(); + StateHasChanged(); + } + + public void Close() + { + ModalDialog.Close(); + } + + private async Task Save() + { + if (string.IsNullOrWhiteSpace(TemplateName)) + { + ToastService.ShowError("Please provide a name for the template."); + return; + } + + await OnSave.InvokeAsync(TemplateName); + Close(); + } + + private void FilterTemplates(string searchTerm) + { + if (string.IsNullOrWhiteSpace(searchTerm)) + { + filteredTemplates = allTemplates.ToList(); + isOpen = true; + } + else + { + filteredTemplates = allTemplates + .Where(t => t.Name.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)) + .ToList(); + isOpen = filteredTemplates.Count > 0; + } + selectedIndex = filteredTemplates.Count > 0 ? 0 : -1; + } + + private void SelectTemplate(BlogPostTemplate template) + { + TemplateName = template.Name; + isOpen = false; + selectedIndex = -1; + } + + private async Task HandleFocusOut(FocusEventArgs args) + { + await Task.Delay(150); + isOpen = false; + await InvokeAsync(StateHasChanged); + } + + private void HandleKeyDown(KeyboardEventArgs args) + { + if (!isOpen && (args.Key == "ArrowDown" || args.Key == "ArrowUp")) + { + isOpen = true; + FilterTemplates(TemplateName); + return; + } + + if (!isOpen) return; + + switch (args.Key) + { + case "ArrowDown": + selectedIndex = Math.Min(selectedIndex + 1, filteredTemplates.Count - 1); + break; + case "ArrowUp": + selectedIndex = Math.Max(selectedIndex - 1, 0); + break; + case "Enter": + if (selectedIndex >= 0 && selectedIndex < filteredTemplates.Count) + { + SelectTemplate(filteredTemplates[selectedIndex]); + } + break; + case "Escape": + isOpen = false; + break; + } + } + + private async Task DeleteTemplate(BlogPostTemplate template) + { + if (await JSRuntime.InvokeAsync("confirm", $"Are you sure you want to delete template '{template.Name}'?")) + { + await TemplateRepository.DeleteAsync(template.Id); + allTemplates = await TemplateRepository.GetAllAsync(); + FilterTemplates(TemplateName); + ToastService.ShowSuccess($"Template {template.Name} deleted"); + StateHasChanged(); + } + } +} diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor index 0220571a..4d42c857 100644 --- a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor +++ b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor @@ -7,6 +7,8 @@ @inject ICacheInvalidator CacheInvalidator @inject IInstantJobRegistry InstantJobRegistry @inject IRepository ShortCodeRepository +@inject IRepository TemplateRepository +@inject IToastService ToastService @inject IOptions AppConfiguration @inject ICurrentUserService CurrentUserService @@ -74,6 +76,32 @@
+
+ + +