Merge branch 'feature/legacy-comments'

This commit is contained in:
Oliver Booth 2024-05-01 16:47:51 +01:00
commit 16618cc135
Signed by: oliverbooth
GPG Key ID: E60B570D1B7557B5
14 changed files with 367 additions and 2 deletions

View File

@ -25,6 +25,12 @@ internal sealed class BlogContext : DbContext
/// <value>The collection of blog posts.</value> /// <value>The collection of blog posts.</value>
public DbSet<BlogPost> BlogPosts { get; private set; } = null!; public DbSet<BlogPost> BlogPosts { get; private set; } = null!;
/// <summary>
/// Gets the collection of legacy comments in the database.
/// </summary>
/// <value>The collection of legacy comments.</value>
public DbSet<LegacyComment> LegacyComments { get; private set; } = null!;
/// <summary> /// <summary>
/// Gets the collection of users in the database. /// Gets the collection of users in the database.
/// </summary> /// </summary>
@ -43,6 +49,7 @@ internal sealed class BlogContext : DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.ApplyConfiguration(new BlogPostConfiguration()); modelBuilder.ApplyConfiguration(new BlogPostConfiguration());
modelBuilder.ApplyConfiguration(new LegacyCommentConfiguration());
modelBuilder.ApplyConfiguration(new UserConfiguration()); modelBuilder.ApplyConfiguration(new UserConfiguration());
} }
} }

View File

@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace OliverBooth.Data.Blog.Configuration;
internal sealed class LegacyCommentConfiguration : IEntityTypeConfiguration<LegacyComment>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<LegacyComment> builder)
{
builder.ToTable("LegacyComment");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).IsRequired();
builder.Property(e => e.PostId).IsRequired();
builder.Property(e => e.Author).IsRequired().HasMaxLength(50);
builder.Property(e => e.Avatar).IsRequired(false).HasMaxLength(32767);
builder.Property(e => e.Body).IsRequired().HasMaxLength(32767);
builder.Property(e => e.ParentComment).IsRequired(false);
}
}

View File

@ -0,0 +1,54 @@
namespace OliverBooth.Data.Blog;
/// <summary>
/// Represents a comment that was posted on a legacy comment framework.
/// </summary>
public interface ILegacyComment
{
/// <summary>
/// Gets the PNG-encoded avatar of the author.
/// </summary>
/// <value>The author's avatar.</value>
string? Avatar { get; }
/// <summary>
/// Gets the name of the comment's author.
/// </summary>
/// <value>The author's name.</value>
string Author { get; }
/// <summary>
/// Gets the body of the comment.
/// </summary>
/// <value>The comment body.</value>
string Body { get; }
/// <summary>
/// Gets the date and time at which this comment was posted.
/// </summary>
/// <value>The creation timestamp.</value>
DateTimeOffset CreatedAt { get; }
/// <summary>
/// Gets the ID of this comment.
/// </summary>
Guid Id { get; }
/// <summary>
/// Gets the ID of the comment this comment is replying to.
/// </summary>
/// <value>The parent comment ID, or <see langword="null" /> if this comment is not a reply.</value>
Guid? ParentComment { get; }
/// <summary>
/// Gets the ID of the post to which this comment was posted.
/// </summary>
/// <value>The post ID.</value>
Guid PostId { get; }
/// <summary>
/// Gets the avatar URL of the comment's author.
/// </summary>
/// <returns>The avatar URL.</returns>
string GetAvatarUrl();
}

View File

@ -0,0 +1,33 @@
using System.Web;
namespace OliverBooth.Data.Blog;
internal sealed class LegacyComment : ILegacyComment
{
/// <inheritdoc />
public string? Avatar { get; private set; }
/// <inheritdoc />
public string Author { get; private set; } = string.Empty;
/// <inheritdoc />
public string Body { get; private set; } = string.Empty;
/// <inheritdoc />
public DateTimeOffset CreatedAt { get; private set; }
/// <inheritdoc />
public Guid Id { get; private set; }
/// <inheritdoc />
public Guid? ParentComment { get; private set; }
/// <inheritdoc />
public Guid PostId { get; private set; }
/// <inheritdoc />
public string GetAvatarUrl()
{
return Avatar ?? $"https://ui-avatars.com/api/?name={HttpUtility.UrlEncode(Author)}";
}
}

View File

@ -24,6 +24,7 @@ internal sealed class TutorialArticleConfiguration : IEntityTypeConfiguration<Tu
builder.Property(e => e.PreviewImageUrl).HasConversion<UriToStringConverter>(); builder.Property(e => e.PreviewImageUrl).HasConversion<UriToStringConverter>();
builder.Property(e => e.NextPart); builder.Property(e => e.NextPart);
builder.Property(e => e.PreviousPart); builder.Property(e => e.PreviousPart);
builder.Property(e => e.RedirectFrom).IsRequired(false);
builder.Property(e => e.Visibility).HasConversion<EnumToStringConverter<Visibility>>(); builder.Property(e => e.Visibility).HasConversion<EnumToStringConverter<Visibility>>();
builder.Property(e => e.EnableComments).IsRequired(); builder.Property(e => e.EnableComments).IsRequired();
} }

View File

@ -67,6 +67,12 @@ public interface ITutorialArticle
/// <value>The publish timestamp.</value> /// <value>The publish timestamp.</value>
DateTimeOffset Published { get; } DateTimeOffset Published { get; }
/// <summary>
/// Gets the ID of the post that was redirected to this article.
/// </summary>
/// <value>The source redirect post ID.</value>
Guid? RedirectFrom { get; }
/// <summary> /// <summary>
/// Gets the slug of this article. /// Gets the slug of this article.
/// </summary> /// </summary>

View File

@ -38,6 +38,9 @@ internal sealed class TutorialArticle : IEquatable<TutorialArticle>, ITutorialAr
/// <inheritdoc /> /// <inheritdoc />
public DateTimeOffset Published { get; private set; } public DateTimeOffset Published { get; private set; }
/// <inheritdoc />
public Guid? RedirectFrom { get; private set; }
/// <inheritdoc /> /// <inheritdoc />
public string Slug { get; private set; } = string.Empty; public string Slug { get; private set; } = string.Empty;

View File

@ -1,9 +1,11 @@
@page "/blog/{year:int}/{month:int}/{day:int}/{slug}" @page "/blog/{year:int}/{month:int}/{day:int}/{slug}"
@using Humanizer @using Humanizer
@using Markdig
@using OliverBooth.Data @using OliverBooth.Data
@using OliverBooth.Data.Blog @using OliverBooth.Data.Blog
@using OliverBooth.Services @using OliverBooth.Services
@inject IBlogPostService BlogPostService @inject IBlogPostService BlogPostService
@inject MarkdownPipeline MarkdownPipeline
@model Article @model Article
@if (Model.ShowPasswordPrompt) @if (Model.ShowPasswordPrompt)
@ -145,6 +147,57 @@
async> async>
</script> </script>
} }
int commentCount = BlogPostService.GetLegacyCommentCount(post);
if (commentCount > 0)
{
<hr>
var nestLevelMap = new Dictionary<ILegacyComment, int>();
IReadOnlyList<ILegacyComment> legacyComments = BlogPostService.GetLegacyComments(post);
var commentStack = new Stack<ILegacyComment>(legacyComments.OrderByDescending(c => c.CreatedAt));
<p class="text-center">
<strong>@("legacy comment".ToQuantity(commentCount))</strong>
</p>
<p class="text-center">
<sub>Legacy comments are comments that were posted using a commenting system that I no longer use. This exists for posterity.</sub>
</p>
while (commentStack.Count > 0)
{
ILegacyComment comment = commentStack.Pop();
foreach (ILegacyComment reply in BlogPostService.GetLegacyReplies(comment).OrderByDescending(c => c.CreatedAt))
{
if (nestLevelMap.TryGetValue(comment, out int currentLevel))
{
nestLevelMap[reply] = currentLevel + 1;
}
else
{
nestLevelMap[reply] = 1;
}
commentStack.Push(reply);
}
int padding = 0;
if (nestLevelMap.TryGetValue(comment, out int nestLevel))
{
padding = 50 * nestLevel;
}
<div class="legacy-comment" style="margin-left: @(padding)px;">
<img class="blog-author-icon" src="@comment.GetAvatarUrl()" alt="@comment.Author">
@comment.Author &bull;
<abbr class="text-muted" data-bs-toggle="tooltip" data-bs-title="@comment.CreatedAt.ToString("dddd, d MMMM yyyy HH:mm")">
@comment.CreatedAt.Humanize()
</abbr>
<div class="comment">@Html.Raw(Markdown.ToHtml(comment.Body, MarkdownPipeline))</div>
</div>
}
}
} }
else else
{ {

View File

@ -1,10 +1,13 @@
@page "/tutorial/{**slug}" @page "/tutorial/{**slug}"
@using Humanizer @using Humanizer
@using Markdig
@using Microsoft.AspNetCore.Mvc.TagHelpers @using Microsoft.AspNetCore.Mvc.TagHelpers
@using OliverBooth.Data @using OliverBooth.Data
@using OliverBooth.Data.Blog
@using OliverBooth.Data.Web @using OliverBooth.Data.Web
@using OliverBooth.Services @using OliverBooth.Services
@inject ITutorialService TutorialService @inject ITutorialService TutorialService
@inject MarkdownPipeline MarkdownPipeline
@model Article @model Article
@if (Model.CurrentArticle is not { } article) @if (Model.CurrentArticle is not { } article)
@ -117,6 +120,57 @@
async> async>
</script> </script>
} }
int commentCount = TutorialService.GetLegacyCommentCount(article);
if (commentCount > 0)
{
<hr>
var nestLevelMap = new Dictionary<ILegacyComment, int>();
IReadOnlyList<ILegacyComment> legacyComments = TutorialService.GetLegacyComments(article);
var commentStack = new Stack<ILegacyComment>(legacyComments.OrderByDescending(c => c.CreatedAt));
<p class="text-center">
<strong>@("legacy comment".ToQuantity(commentCount))</strong>
</p>
<p class="text-center">
<sub>Legacy comments are comments that were posted using a commenting system that I no longer use. This exists for posterity.</sub>
</p>
while (commentStack.Count > 0)
{
ILegacyComment comment = commentStack.Pop();
foreach (ILegacyComment reply in TutorialService.GetLegacyReplies(comment).OrderByDescending(c => c.CreatedAt))
{
if (nestLevelMap.TryGetValue(comment, out int currentLevel))
{
nestLevelMap[reply] = currentLevel + 1;
}
else
{
nestLevelMap[reply] = 1;
}
commentStack.Push(reply);
}
int padding = 0;
if (nestLevelMap.TryGetValue(comment, out int nestLevel))
{
padding = 50 * nestLevel;
}
<div class="legacy-comment" style="margin-left: @(padding)px;">
<img class="blog-author-icon" src="@comment.GetAvatarUrl()" alt="@comment.Author">
@comment.Author &bull;
<abbr class="text-muted" data-bs-toggle="tooltip" data-bs-title="@comment.CreatedAt.ToString("dddd, d MMMM yyyy HH:mm")">
@comment.CreatedAt.Humanize()
</abbr>
<div class="comment">@Html.Raw(Markdown.ToHtml(comment.Body, MarkdownPipeline))</div>
</div>
}
}
} }
else else
{ {

View File

@ -67,6 +67,27 @@ internal sealed class BlogPostService : IBlogPostService
.ToArray().Select(CacheAuthor).ToArray(); .ToArray().Select(CacheAuthor).ToArray();
} }
/// <inheritdoc />
public int GetLegacyCommentCount(IBlogPost post)
{
using BlogContext context = _dbContextFactory.CreateDbContext();
return context.LegacyComments.Count(c => c.PostId == post.Id);
}
/// <inheritdoc />
public IReadOnlyList<ILegacyComment> GetLegacyComments(IBlogPost post)
{
using BlogContext context = _dbContextFactory.CreateDbContext();
return context.LegacyComments.Where(c => c.PostId == post.Id && c.ParentComment == null).ToArray();
}
/// <inheritdoc />
public IReadOnlyList<ILegacyComment> GetLegacyReplies(ILegacyComment comment)
{
using BlogContext context = _dbContextFactory.CreateDbContext();
return context.LegacyComments.Where(c => c.ParentComment == comment.Id).ToArray();
}
/// <inheritdoc /> /// <inheritdoc />
public IBlogPost? GetNextPost(IBlogPost blogPost) public IBlogPost? GetNextPost(IBlogPost blogPost)
{ {

View File

@ -34,6 +34,27 @@ public interface IBlogPostService
/// <returns>A collection of blog posts.</returns> /// <returns>A collection of blog posts.</returns>
IReadOnlyList<IBlogPost> GetBlogPosts(int page, int pageSize = 10); IReadOnlyList<IBlogPost> GetBlogPosts(int page, int pageSize = 10);
/// <summary>
/// Returns the number of legacy comments for the specified post.
/// </summary>
/// <param name="post">The post whose legacy comments to count.</param>
/// <returns>The total number of legacy comments.</returns>
int GetLegacyCommentCount(IBlogPost post);
/// <summary>
/// Returns the collection of legacy comments for the specified post.
/// </summary>
/// <param name="post">The post whose legacy comments to retrieve.</param>
/// <returns>A read-only view of the legacy comments.</returns>
IReadOnlyList<ILegacyComment> GetLegacyComments(IBlogPost post);
/// <summary>
/// Returns the collection of replies to the specified legacy comment.
/// </summary>
/// <param name="comment">The comment whose replies to retrieve.</param>
/// <returns>A read-only view of the replies.</returns>
IReadOnlyList<ILegacyComment> GetLegacyReplies(ILegacyComment comment);
/// <summary> /// <summary>
/// Returns the next blog post from the specified blog post. /// Returns the next blog post from the specified blog post.
/// </summary> /// </summary>

View File

@ -1,5 +1,6 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using OliverBooth.Data; using OliverBooth.Data;
using OliverBooth.Data.Blog;
using OliverBooth.Data.Web; using OliverBooth.Data.Web;
namespace OliverBooth.Services; namespace OliverBooth.Services;
@ -60,6 +61,27 @@ public interface ITutorialService
/// <exception cref="ArgumentNullException"><paramref name="article" /> is <see langword="null" />.</exception> /// <exception cref="ArgumentNullException"><paramref name="article" /> is <see langword="null" />.</exception>
string GetFullSlug(ITutorialArticle article); string GetFullSlug(ITutorialArticle article);
/// <summary>
/// Returns the number of legacy comments for the specified article.
/// </summary>
/// <param name="article">The article whose legacy comments to count.</param>
/// <returns>The total number of legacy comments.</returns>
int GetLegacyCommentCount(ITutorialArticle article);
/// <summary>
/// Returns the collection of legacy comments for the specified article.
/// </summary>
/// <param name="article">The article whose legacy comments to retrieve.</param>
/// <returns>A read-only view of the legacy comments.</returns>
IReadOnlyList<ILegacyComment> GetLegacyComments(ITutorialArticle article);
/// <summary>
/// Returns the collection of replies to the specified legacy comment.
/// </summary>
/// <param name="comment">The comment whose replies to retrieve.</param>
/// <returns>A read-only view of the replies.</returns>
IReadOnlyList<ILegacyComment> GetLegacyReplies(ILegacyComment comment);
/// <summary> /// <summary>
/// Renders the body of the specified article. /// Renders the body of the specified article.
/// </summary> /// </summary>

View File

@ -4,24 +4,30 @@ using Humanizer;
using Markdig; using Markdig;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using OliverBooth.Data; using OliverBooth.Data;
using OliverBooth.Data.Blog;
using OliverBooth.Data.Web; using OliverBooth.Data.Web;
namespace OliverBooth.Services; namespace OliverBooth.Services;
internal sealed class TutorialService : ITutorialService internal sealed class TutorialService : ITutorialService
{ {
private readonly IDbContextFactory<BlogContext> _blogContextFactory;
private readonly IDbContextFactory<WebContext> _dbContextFactory; private readonly IDbContextFactory<WebContext> _dbContextFactory;
private readonly MarkdownPipeline _markdownPipeline; private readonly MarkdownPipeline _markdownPipeline;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="TutorialService" /> class. /// Initializes a new instance of the <see cref="TutorialService" /> class.
/// </summary> /// </summary>
/// <param name="dbContextFactory">The <see cref="IDbContextFactory{TContext}" />.</param> /// <param name="dbContextFactory">The <see cref="WebContext" /> factory.</param>
/// <param name="blogContextFactory">The <see cref="BlogContext" /> factory.</param>
/// <param name="markdownPipeline">The <see cref="MarkdownPipeline" />.</param> /// <param name="markdownPipeline">The <see cref="MarkdownPipeline" />.</param>
public TutorialService(IDbContextFactory<WebContext> dbContextFactory, MarkdownPipeline markdownPipeline) public TutorialService(IDbContextFactory<WebContext> dbContextFactory,
IDbContextFactory<BlogContext> blogContextFactory,
MarkdownPipeline markdownPipeline)
{ {
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_markdownPipeline = markdownPipeline; _markdownPipeline = markdownPipeline;
_blogContextFactory = blogContextFactory;
} }
/// <inheritdoc /> /// <inheritdoc />
@ -103,6 +109,37 @@ internal sealed class TutorialService : ITutorialService
return $"{GetFullSlug(folder)}/{article.Slug}"; return $"{GetFullSlug(folder)}/{article.Slug}";
} }
/// <inheritdoc />
public int GetLegacyCommentCount(ITutorialArticle article)
{
if (article.RedirectFrom is not { } postId)
{
return 0;
}
using BlogContext context = _blogContextFactory.CreateDbContext();
return context.LegacyComments.Count(c => c.PostId == postId);
}
/// <inheritdoc />
public IReadOnlyList<ILegacyComment> GetLegacyComments(ITutorialArticle article)
{
if (article.RedirectFrom is not { } postId)
{
return ArraySegment<ILegacyComment>.Empty;
}
using BlogContext context = _blogContextFactory.CreateDbContext();
return context.LegacyComments.Where(c => c.PostId == postId && c.ParentComment == null).ToArray();
}
/// <inheritdoc />
public IReadOnlyList<ILegacyComment> GetLegacyReplies(ILegacyComment comment)
{
using BlogContext context = _blogContextFactory.CreateDbContext();
return context.LegacyComments.Where(c => c.ParentComment == comment.Id).ToArray();
}
/// <inheritdoc /> /// <inheritdoc />
public string RenderArticle(ITutorialArticle article) public string RenderArticle(ITutorialArticle article)
{ {

View File

@ -429,6 +429,38 @@ td.trim-p p:last-child {
} }
} }
.legacy-comment {
font-size: 14px !important;
.blog-author-icon {
height: 28px;
}
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
.comment {
font-size: 14px !important;
margin-left: 30px;
background: #1d1d1d;
padding: 10px;
border-radius: 5px;
p:last-child {
margin-bottom: 0;
}
blockquote.blockquote {
font-size: 14px !important;
border-left: 3px solid #687a86;
padding-left: 15px;
}
}
}
.mastodon-update-card.card { .mastodon-update-card.card {
background-color: desaturate(darken(#6364FF, 50%), 50%); background-color: desaturate(darken(#6364FF, 50%), 50%);
margin-bottom: 50px; margin-bottom: 50px;