From 450147f08f31cf9167c4e897585287b18c28b200 Mon Sep 17 00:00:00 2001 From: digitaldirk <22691956+digitaldirk@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:25:56 -0700 Subject: [PATCH] Add blog series feature. New admin editing page, series selection when editing blog posts, and series display for readers. --- src/LinkDotNet.Blog.Domain/BlogPost.cs | 32 +- src/LinkDotNet.Blog.Domain/Series.cs | 8 + .../Persistence/MongoDB/Repository.cs | 19 +- .../Persistence/RavenDb/Repository.cs | 11 +- .../Persistence/Sql/BlogDbContext.cs | 3 + .../Sql/Mapping/BlogPostConfiguration.cs | 7 + .../Sql/Mapping/SeriesConfiguration.cs | 17 + .../Persistence/Sql/Repository.cs | 19 +- .../Components/CreateNewBlogPost.razor | 184 ++++++++++ .../Components/CreateNewModel.cs | 20 +- .../Features/Admin/Series/SeriesPage.razor | 321 ++++++++++++++++++ .../Home/Components/AccessControl.razor | 1 + .../Components/SeriesNavigation.razor | 44 +++ .../ShowBlogPost/ShowBlogPostPage.razor | 4 + 14 files changed, 681 insertions(+), 9 deletions(-) create mode 100644 src/LinkDotNet.Blog.Domain/Series.cs create mode 100644 src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/SeriesConfiguration.cs create mode 100644 src/LinkDotNet.Blog.Web/Features/Admin/Series/SeriesPage.razor create mode 100644 src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/SeriesNavigation.razor diff --git a/src/LinkDotNet.Blog.Domain/BlogPost.cs b/src/LinkDotNet.Blog.Domain/BlogPost.cs index 9d0319f1..b223ad59 100644 --- a/src/LinkDotNet.Blog.Domain/BlogPost.cs +++ b/src/LinkDotNet.Blog.Domain/BlogPost.cs @@ -30,6 +30,8 @@ public sealed partial class BlogPost : Entity public int Likes { get; set; } + public int Order { get; set; } + public bool IsScheduled => ScheduledPublishDate is not null; public string TagsAsString => string.Join(",", Tags); @@ -40,6 +42,10 @@ public sealed partial class BlogPost : Entity public string? AuthorName { get; private set; } + public string? SeriesId { get; private set; } + + public Series? Series { get; private set; } + private string GenerateSlug() { if (string.IsNullOrWhiteSpace(Title)) @@ -95,7 +101,9 @@ public static BlogPost Create( DateTime? scheduledPublishDate = null, IEnumerable? tags = null, string? previewImageUrlFallback = null, - string? authorName = null) + string? authorName = null, + string? seriesId = null, + int order = 0) { if (scheduledPublishDate is not null && isPublished) { @@ -116,7 +124,9 @@ public static BlogPost Create( IsPublished = isPublished, Tags = tags?.Select(t => t.Trim()).ToImmutableArray() ?? [], ReadingTimeInMinutes = ReadingTimeCalculator.CalculateReadingTime(content), - AuthorName = authorName + AuthorName = authorName, + SeriesId = seriesId, + Order = order }; return blogPost; @@ -128,6 +138,22 @@ public void Publish() IsPublished = true; } + public void RemoveFromSeries() + { + SeriesId = null; + Series = null; + Order = 0; + } + + public void AssignToSeries(string seriesId, int order) + { + ArgumentException.ThrowIfNullOrEmpty(seriesId); + + SeriesId = seriesId; + Series = null; + Order = order; + } + public void Update(BlogPost from) { ArgumentNullException.ThrowIfNull(from); @@ -148,5 +174,7 @@ public void Update(BlogPost from) Tags = from.Tags; ReadingTimeInMinutes = from.ReadingTimeInMinutes; AuthorName = from.AuthorName; + SeriesId = from.SeriesId; + Order = from.Order; } } diff --git a/src/LinkDotNet.Blog.Domain/Series.cs b/src/LinkDotNet.Blog.Domain/Series.cs new file mode 100644 index 00000000..bdd030cb --- /dev/null +++ b/src/LinkDotNet.Blog.Domain/Series.cs @@ -0,0 +1,8 @@ +using System; + +namespace LinkDotNet.Blog.Domain; + +public sealed class Series : Entity +{ + public string Name { get; set; } = default!; +} diff --git a/src/LinkDotNet.Blog.Infrastructure/Persistence/MongoDB/Repository.cs b/src/LinkDotNet.Blog.Infrastructure/Persistence/MongoDB/Repository.cs index 5b1d8ed0..b4d20cb4 100644 --- a/src/LinkDotNet.Blog.Infrastructure/Persistence/MongoDB/Repository.cs +++ b/src/LinkDotNet.Blog.Infrastructure/Persistence/MongoDB/Repository.cs @@ -40,7 +40,20 @@ public async ValueTask PerformHealthCheckAsync() { var filter = Builders.Filter.Eq(e => e.Id, id); using var result = await Collection.FindAsync(filter); - return await result.FirstOrDefaultAsync(); + var entity = await result.FirstOrDefaultAsync(); + + if (entity is BlogPost { SeriesId: not null } blogPost) + { + var seriesCollection = database.GetCollection(nameof(Series)); + var seriesFilter = Builders.Filter.Eq(s => s.Id, blogPost.SeriesId); + var seriesResult = await seriesCollection.FindAsync(seriesFilter); + var series = await seriesResult.FirstOrDefaultAsync(); + + var seriesProperty = typeof(BlogPost).GetProperty(nameof(BlogPost.Series)); + seriesProperty?.SetValue(blogPost, series); + } + + return entity; } public async ValueTask> GetAllAsync(Expression>? filter = null, @@ -105,9 +118,9 @@ public async ValueTask StoreBulkAsync(IReadOnlyCollection records) { ArgumentNullException.ThrowIfNull(records); - if (records.Count != 0) + foreach (var record in records) { - await Collection.InsertManyAsync(records); + await StoreAsync(record); } } } diff --git a/src/LinkDotNet.Blog.Infrastructure/Persistence/RavenDb/Repository.cs b/src/LinkDotNet.Blog.Infrastructure/Persistence/RavenDb/Repository.cs index d7285b00..78dde0c9 100644 --- a/src/LinkDotNet.Blog.Infrastructure/Persistence/RavenDb/Repository.cs +++ b/src/LinkDotNet.Blog.Infrastructure/Persistence/RavenDb/Repository.cs @@ -36,7 +36,16 @@ public async ValueTask PerformHealthCheckAsync() public async ValueTask GetByIdAsync(string id) { using var session = documentStore.OpenAsyncSession(); - return await session.LoadAsync(id); + var entity = await session.LoadAsync(id); + + if (entity is BlogPost { SeriesId: not null } blogPost) + { + var series = await session.LoadAsync(blogPost.SeriesId); + var seriesProperty = typeof(BlogPost).GetProperty(nameof(BlogPost.Series)); + seriesProperty?.SetValue(blogPost, series); + } + + return entity; } public ValueTask> GetAllAsync(Expression>? filter = null, diff --git a/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/BlogDbContext.cs b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/BlogDbContext.cs index 887fa85a..e30140b9 100644 --- a/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/BlogDbContext.cs +++ b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/BlogDbContext.cs @@ -15,6 +15,8 @@ public BlogDbContext(DbContextOptions options) public DbSet BlogPosts { get; set; } + public DbSet Series { get; set; } + public DbSet ProfileInformationEntries { get; set; } public DbSet Skills { get; set; } @@ -38,6 +40,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) ArgumentNullException.ThrowIfNull(modelBuilder); modelBuilder.ApplyConfiguration(new BlogPostConfiguration()); + modelBuilder.ApplyConfiguration(new SeriesConfiguration()); modelBuilder.ApplyConfiguration(new BlogPostRecordConfiguration()); modelBuilder.ApplyConfiguration(new BlogPostTemplateConfiguration()); modelBuilder.ApplyConfiguration(new BlogPostVersionConfiguration()); diff --git a/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/BlogPostConfiguration.cs b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/BlogPostConfiguration.cs index bdba5a05..e3587aae 100644 --- a/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/BlogPostConfiguration.cs +++ b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/BlogPostConfiguration.cs @@ -19,9 +19,16 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.ShortDescription).IsRequired(); builder.Property(x => x.Likes).IsRequired(); builder.Property(x => x.IsPublished).IsRequired(); + builder.Property(x => x.Order).IsRequired(); builder.Property(x => x.Tags).HasMaxLength(2048); builder.Property(x => x.AuthorName).HasMaxLength(256).IsRequired(false); + builder.Property(x => x.SeriesId).IsUnicode(false).IsRequired(false); + + builder.HasOne(x => x.Series) + .WithMany() + .HasForeignKey(x => x.SeriesId) + .OnDelete(DeleteBehavior.SetNull); builder.HasIndex(x => new { x.IsPublished, x.UpdatedDate }) .HasDatabaseName("IX_BlogPosts_IsPublished_UpdatedDate") diff --git a/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/SeriesConfiguration.cs b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/SeriesConfiguration.cs new file mode 100644 index 00000000..b392e429 --- /dev/null +++ b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/SeriesConfiguration.cs @@ -0,0 +1,17 @@ +using LinkDotNet.Blog.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace LinkDotNet.Blog.Infrastructure.Persistence.Sql.Mapping; + +internal sealed class SeriesConfiguration : 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(); + } +} diff --git a/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Repository.cs b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Repository.cs index e48cf32e..9e686096 100644 --- a/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Repository.cs +++ b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Repository.cs @@ -39,7 +39,14 @@ public async ValueTask PerformHealthCheckAsync() public async ValueTask GetByIdAsync(string id) { await using var blogDbContext = await dbContextFactory.CreateDbContextAsync(); - return await blogDbContext.Set().FirstOrDefaultAsync(b => b.Id == id); + var query = blogDbContext.Set().AsQueryable(); + + if (typeof(TEntity) == typeof(BlogPost)) + { + query = query.Include("Series"); + } + + return await query.FirstOrDefaultAsync(b => b.Id == id); } public ValueTask> GetAllAsync(Expression>? filter = null, @@ -150,7 +157,15 @@ async Task StoreBulkAsyncInBatchesAsync() var count = 0; foreach (var record in records) { - await blogDbContext.Set().AddAsync(record); + if (string.IsNullOrEmpty(record.Id)) + { + await blogDbContext.Set().AddAsync(record); + } + else + { + blogDbContext.Entry(record).State = EntityState.Modified; + } + if (++count % 1000 == 0) { LogBatch(count); 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 d0ac546b..98ecd113 100644 --- a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor +++ b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor @@ -9,6 +9,8 @@ @inject IInstantJobRegistry InstantJobRegistry @inject IRepository ShortCodeRepository @inject IRepository TemplateRepository +@inject IRepository SeriesRepository +@inject IRepository BlogPostRepository @inject IToastService ToastService @inject IOptions AppConfiguration @inject ICurrentUserService CurrentUserService @@ -150,6 +152,77 @@ +
+
+
Blog Series
+
+
+
+
+ + +
+ + + @foreach (var s in blogSeries) + { + + } + + @if (showAddSeries) + { +
+ + +
+ } +
+ @if (!string.IsNullOrEmpty(model.SeriesId) && postsInSeries.Any()) + { +
+
Series Order:
+
+ @for (var i = 0; i < postsInSeries.Count; i++) + { + var post = postsInSeries[i]; + var displayOrder = i + 1; +
+
+ @post.Title +
+ @displayOrder +
+ } +
+ + Drag posts to reorder. + +
+ } + + Assigning a post to a series will display it as part of that series to readers. + +
+
+
Tags
@@ -331,9 +404,16 @@ private bool canSubmit = true; private IPagedList shortCodes = PagedList.Empty; private IPagedList blogPostTemplates = PagedList.Empty; + private IReadOnlyList blogSeries = []; private IReadOnlyList availableTags = []; private IReadOnlyList originalTags = []; + private List postsInSeries = []; + private BlogPost? draggedPost; + + private bool showAddSeries; + private string newSeriesTitle = string.Empty; + private bool IsScheduled => model.ScheduledPublishDate.HasValue; private string? authorName; @@ -342,6 +422,7 @@ { shortCodes = await ShortCodeRepository.GetAllAsync(); blogPostTemplates = await TemplateRepository.GetAllAsync(); + blogSeries = (await SeriesRepository.GetAllAsync(orderBy: s => s.Name, descending: false)).ToList(); await LoadAvailableTagsAsync(); if (AppConfiguration.Value.UseMultiAuthorMode) @@ -364,6 +445,109 @@ { versionHistory = await BlogPostVersionService.GetVersionHistoryAsync(BlogPostId); } + + if (!string.IsNullOrEmpty(model.SeriesId)) + { + await LoadPostsForSeries(); + } + } + + private async Task LoadPostsForSeries() + { + if (string.IsNullOrEmpty(model.SeriesId)) + { + postsInSeries = []; + return; + } + + var result = await BlogPostRepository.GetAllAsync( + filter: b => b.SeriesId == model.SeriesId, + orderBy: b => b.Order, + descending: false + ); + postsInSeries = result.ToList(); + + // For a post that isn't part of the series yet (new post, or an existing post being + // assigned to a different series), place it at the end instead of the beginning. + var isInSeries = !string.IsNullOrEmpty(BlogPostId) + && postsInSeries.Any(p => p.Id == BlogPostId); + if (!isInSeries) + { + model.Order = postsInSeries.Count + 1; + } + } + + private void OnDragStart(BlogPost post) + { + draggedPost = post; + } + + private async Task OnDrop(BlogPost targetPost) + { + if (draggedPost == null || draggedPost.Id == targetPost.Id) + { + return; + } + + var draggedIndex = postsInSeries.IndexOf(draggedPost); + var targetIndex = postsInSeries.IndexOf(targetPost); + + postsInSeries.RemoveAt(draggedIndex); + postsInSeries.Insert(targetIndex, draggedPost); + + // Update orders + for (int i = 0; i < postsInSeries.Count; i++) + { + postsInSeries[i].Order = i + 1; + } + + await BlogPostRepository.StoreBulkAsync(postsInSeries); + ToastService.ShowSuccess("Series order updated"); + draggedPost = null; + StateHasChanged(); + } + + private void ToggleAddSeries() + { + showAddSeries = !showAddSeries; + newSeriesTitle = string.Empty; + } + + private async Task OnNewSeriesKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") + { + await AddSeriesAsync(); + } + } + + private async Task AddSeriesAsync() + { + var title = newSeriesTitle?.Trim(); + if (string.IsNullOrWhiteSpace(title)) + { + return; + } + + var existing = blogSeries.FirstOrDefault(s => string.Equals(s.Name, title, StringComparison.OrdinalIgnoreCase)); + if (existing is not null) + { + model.SeriesId = existing.Id; + await LoadPostsForSeries(); + ToastService.ShowInfo("Series already exists; selected existing one"); + } + else + { + var newSeries = new Series { Name = title }; + await SeriesRepository.StoreAsync(newSeries); + blogSeries = (await SeriesRepository.GetAllAsync(orderBy: s => s.Name, descending: false)).ToList(); + model.SeriesId = newSeries.Id; + await LoadPostsForSeries(); + ToastService.ShowSuccess("Series created"); + } + + showAddSeries = false; + newSeriesTitle = string.Empty; } private string GetStatusText() diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewModel.cs b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewModel.cs index c26eed2f..328d82b6 100644 --- a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewModel.cs +++ b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewModel.cs @@ -10,6 +10,8 @@ public sealed class CreateNewModel private string id = default!; private DateTime? scheduledPublishDate; private string? authorName; + private string? seriesId; + private int order; [Required] [MaxLength(256)] @@ -89,6 +91,18 @@ public string? AuthorName set => SetProperty(out authorName, value); } + public string? SeriesId + { + get => seriesId; + set => SetProperty(out seriesId, value); + } + + public int Order + { + get => order; + set => SetProperty(out order, value); + } + public bool IsDirty { get; private set; } public static CreateNewModel FromBlogPost(BlogPost blogPost) @@ -108,6 +122,8 @@ public static CreateNewModel FromBlogPost(BlogPost blogPost) PreviewImageUrlFallback = blogPost.PreviewImageUrlFallback ?? string.Empty, scheduledPublishDate = blogPost.ScheduledPublishDate?.ToUniversalTime(), authorName = blogPost.AuthorName, + seriesId = blogPost.SeriesId, + order = blogPost.Order, IsDirty = false, }; } @@ -131,7 +147,9 @@ public BlogPost ToBlogPost() scheduledPublishDate, tagList, PreviewImageUrlFallback, - AuthorName); + AuthorName, + SeriesId, + Order); blogPost.Id = id; return blogPost; } diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/Series/SeriesPage.razor b/src/LinkDotNet.Blog.Web/Features/Admin/Series/SeriesPage.razor new file mode 100644 index 00000000..b316e565 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/Admin/Series/SeriesPage.razor @@ -0,0 +1,321 @@ +@page "/series" +@using System.ComponentModel.DataAnnotations +@using LinkDotNet.Blog.Domain +@using LinkDotNet.Blog.Infrastructure.Persistence +@using Microsoft.AspNetCore.Authorization + +@attribute [Authorize] +@inject IRepository SeriesRepository +@inject IRepository BlogPostRepository +@inject IToastService ToastService + +Blog Series + +
+

Blog Series

+ + + +
+ + + +
+
+ + @if (!string.IsNullOrEmpty(model.Id)) + { + + } +
+
+
+ @if (series.Count > 0) + { +
+ + @foreach (var s in series) + { +
+

+ +

+
+
+
+
Blog Posts in this Series:
+
+ @if (postsInSeries.TryGetValue(s.Id, out var posts) && posts.Any()) + { + @foreach (var post in posts) + { +
+
+ @post.Title + @if (!post.IsPublished) + { + Draft + } +
+ @post.Order +
+ } + } + else + { +
No posts assigned to this series.
+ } +
+ Drag and drop to reorder posts. +
+
+
Add Blog Posts to this Series:
+ + @if (searchResults.TryGetValue(s.Id, out var results) && !string.IsNullOrWhiteSpace(GetSearchTerm(s.Id))) + { +
+ @if (results.Count == 0) + { +
No matching posts found.
+ } + else + { + @foreach (var post in results) + { +
+
+ @post.Title + @if (!post.IsPublished) + { + Draft + } + @if (!string.IsNullOrEmpty(post.SeriesId) && post.SeriesId != s.Id) + { + In another series + } +
+ +
+ } + } +
+ } +
+
+
+ + +
+
+
+
+ } +
+ } else + { +

No series exist.

+ } +
+ +@code { + private Model model = new(); + private List series = []; + private string? expandedSeriesId; + private Dictionary> postsInSeries = new(); + private BlogPost? draggedPost; + private Dictionary searchTerms = new(); + private Dictionary> searchResults = new(); + + protected override async Task OnInitializedAsync() + { + series = (await SeriesRepository.GetAllAsync(orderBy: s => s.Name, descending: false)).ToList(); + } + + private async Task ToggleAccordion(string seriesId) + { + expandedSeriesId = expandedSeriesId == seriesId ? null : seriesId; + if (expandedSeriesId != null && !postsInSeries.ContainsKey(expandedSeriesId)) + { + await LoadPostsForSeries(expandedSeriesId); + } + } + + private async Task LoadPostsForSeries(string seriesId) + { + var result = await BlogPostRepository.GetAllAsync( + filter: b => b.SeriesId == seriesId, + orderBy: b => b.Order, + descending: false + ); + postsInSeries[seriesId] = result.ToList(); + } + + private void OnDragStart(BlogPost post) + { + draggedPost = post; + } + + private async Task OnDrop(BlogPost targetPost, string seriesId) + { + if (draggedPost == null || draggedPost.Id == targetPost.Id || draggedPost.SeriesId != seriesId) + { + return; + } + + var posts = postsInSeries[seriesId]; + var draggedIndex = posts.IndexOf(draggedPost); + var targetIndex = posts.IndexOf(targetPost); + + posts.RemoveAt(draggedIndex); + posts.Insert(targetIndex, draggedPost); + + // Update order + for (int i = 0; i < posts.Count; i++) + { + posts[i].Order = i + 1; + } + + await BlogPostRepository.StoreBulkAsync(posts); + ToastService.ShowSuccess("Order updated"); + draggedPost = null; + StateHasChanged(); + } + + private async Task SaveSeries() + { + if (!string.IsNullOrEmpty(model.Id)) + { + var existing = series.FirstOrDefault(s => s.Id == model.Id); + if (existing != null) + { + existing.Name = model.Name; + await SeriesRepository.StoreAsync(existing); + + // Refresh cached blog posts that assigned to this series so updated name appears on blog posts + var assignedPosts = (await BlogPostRepository.GetAllAsync(filter: b => b.SeriesId == existing.Id)).ToList(); + foreach (var post in assignedPosts) + { + await BlogPostRepository.StoreAsync(post); + } + + ToastService.ShowSuccess("Series updated"); + } + } + else + { + var newSeries = new Series { Name = model.Name }; + await SeriesRepository.StoreAsync(newSeries); + series.Add(newSeries); + series = series.OrderBy(s => s.Name).ToList(); + ToastService.ShowSuccess("Series created"); + } + + model = new Model(); + } + + private async Task DeleteSeries(string id) + { + var assignedPosts = (await BlogPostRepository.GetAllAsync(filter: b => b.SeriesId == id)).ToList(); + foreach (var post in assignedPosts) + { + post.RemoveFromSeries(); + await BlogPostRepository.StoreAsync(post); + } + + await SeriesRepository.DeleteAsync(id); + series.RemoveAll(s => s.Id == id); + postsInSeries.Remove(id); + if (expandedSeriesId == id) + { + expandedSeriesId = null; + } + ToastService.ShowSuccess("Series deleted"); + } + + private void EditSeries(Series s) + { + model.Id = s.Id; + model.Name = s.Name; + } + + private void CancelEdit() + { + model = new Model(); + } + + private string GetSearchTerm(string seriesId) => + searchTerms.TryGetValue(seriesId, out var term) ? term : string.Empty; + + private async Task OnSearchInput(string seriesId, string term) + { + searchTerms[seriesId] = term; + + var trimmed = term?.Trim() ?? string.Empty; + if (string.IsNullOrEmpty(trimmed)) + { + searchResults.Remove(seriesId); + return; + } + + var lowered = trimmed.ToLowerInvariant(); + var matches = await BlogPostRepository.GetAllAsync( + filter: b => b.SeriesId != seriesId && b.Title.ToLower().Contains(lowered), + orderBy: b => b.Title, + descending: false, + page: 1, + pageSize: 20); + searchResults[seriesId] = matches.ToList(); + } + + private async Task AddPostToSeries(BlogPost post, string seriesId) + { + if (!postsInSeries.ContainsKey(seriesId)) + { + await LoadPostsForSeries(seriesId); + } + + var posts = postsInSeries[seriesId]; + post.AssignToSeries(seriesId, posts.Count + 1); + await BlogPostRepository.StoreAsync(post); + + posts.Add(post); + postsInSeries[seriesId] = posts; + + if (searchResults.TryGetValue(seriesId, out var results)) + { + results.RemoveAll(p => p.Id == post.Id); + } + + ToastService.ShowSuccess($"Added \"{post.Title}\" to the series"); + StateHasChanged(); + } + + private sealed class Model + { + public string Id { get; set; } = string.Empty; + + [Required] + [MaxLength(256)] + public string Name { get; set; } = string.Empty; + } +} diff --git a/src/LinkDotNet.Blog.Web/Features/Home/Components/AccessControl.razor b/src/LinkDotNet.Blog.Web/Features/Home/Components/AccessControl.razor index 1c9044cf..096c59ba 100644 --- a/src/LinkDotNet.Blog.Web/Features/Home/Components/AccessControl.razor +++ b/src/LinkDotNet.Blog.Web/Features/Home/Components/AccessControl.razor @@ -14,6 +14,7 @@
  • Create new
  • Show drafts
  • Show settings
  • +
  • Show blog series
  • Dashboard
  • diff --git a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/SeriesNavigation.razor b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/SeriesNavigation.razor new file mode 100644 index 00000000..6aefcfc8 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/Components/SeriesNavigation.razor @@ -0,0 +1,44 @@ +@using LinkDotNet.Blog.Domain +@using LinkDotNet.Blog.Infrastructure.Persistence +@inject IRepository BlogPostRepository + +@if (Series != null) +{ +
    +
    +
    Series: @Series.Name
    +
    + @for (var i = 0; i < postsInSeries.Count; i++) + { + var post = postsInSeries[i]; + + @(i + 1). @post.Title + + } +
    +
    +
    +} + +@code { + [Parameter, EditorRequired] + public Series? Series { get; set; } + + [Parameter, EditorRequired] + public required string CurrentBlogPostId { get; set; } + + private List postsInSeries = []; + + protected override async Task OnParametersSetAsync() + { + if (Series != null) + { + var result = await BlogPostRepository.GetAllAsync( + filter: b => b.SeriesId == Series.Id && b.IsPublished, + orderBy: b => b.Order, + descending: false + ); + postsInSeries = result.ToList(); + } + } +} diff --git a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor index 0a58ce55..ad9c16ed 100644 --- a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor +++ b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor @@ -90,6 +90,10 @@ else if (BlogPost is not null)
    @(EnrichWithShortCodes(BlogPost.Content))
    + +
    + +