Merge branch 'feature/legacy-comments'
This commit is contained in:
commit
16618cc135
@ -25,6 +25,12 @@ internal sealed class BlogContext : DbContext
|
||||
/// <value>The collection of blog posts.</value>
|
||||
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>
|
||||
/// Gets the collection of users in the database.
|
||||
/// </summary>
|
||||
@ -43,6 +49,7 @@ internal sealed class BlogContext : DbContext
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfiguration(new BlogPostConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new LegacyCommentConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new UserConfiguration());
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
54
OliverBooth/Data/Blog/ILegacyComment.cs
Normal file
54
OliverBooth/Data/Blog/ILegacyComment.cs
Normal 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();
|
||||
}
|
33
OliverBooth/Data/Blog/LegacyComment.cs
Normal file
33
OliverBooth/Data/Blog/LegacyComment.cs
Normal 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)}";
|
||||
}
|
||||
}
|
@ -24,6 +24,7 @@ internal sealed class TutorialArticleConfiguration : IEntityTypeConfiguration<Tu
|
||||
builder.Property(e => e.PreviewImageUrl).HasConversion<UriToStringConverter>();
|
||||
builder.Property(e => e.NextPart);
|
||||
builder.Property(e => e.PreviousPart);
|
||||
builder.Property(e => e.RedirectFrom).IsRequired(false);
|
||||
builder.Property(e => e.Visibility).HasConversion<EnumToStringConverter<Visibility>>();
|
||||
builder.Property(e => e.EnableComments).IsRequired();
|
||||
}
|
||||
|
@ -67,6 +67,12 @@ public interface ITutorialArticle
|
||||
/// <value>The publish timestamp.</value>
|
||||
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>
|
||||
/// Gets the slug of this article.
|
||||
/// </summary>
|
||||
|
@ -38,6 +38,9 @@ internal sealed class TutorialArticle : IEquatable<TutorialArticle>, ITutorialAr
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset Published { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid? RedirectFrom { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Slug { get; private set; } = string.Empty;
|
||||
|
||||
|
@ -1,9 +1,11 @@
|
||||
@page "/blog/{year:int}/{month:int}/{day:int}/{slug}"
|
||||
@using Humanizer
|
||||
@using Markdig
|
||||
@using OliverBooth.Data
|
||||
@using OliverBooth.Data.Blog
|
||||
@using OliverBooth.Services
|
||||
@inject IBlogPostService BlogPostService
|
||||
@inject MarkdownPipeline MarkdownPipeline
|
||||
@model Article
|
||||
|
||||
@if (Model.ShowPasswordPrompt)
|
||||
@ -145,6 +147,57 @@
|
||||
async>
|
||||
</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 •
|
||||
|
||||
<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
|
||||
{
|
||||
|
@ -1,10 +1,13 @@
|
||||
@page "/tutorial/{**slug}"
|
||||
@using Humanizer
|
||||
@using Markdig
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@using OliverBooth.Data
|
||||
@using OliverBooth.Data.Blog
|
||||
@using OliverBooth.Data.Web
|
||||
@using OliverBooth.Services
|
||||
@inject ITutorialService TutorialService
|
||||
@inject MarkdownPipeline MarkdownPipeline
|
||||
@model Article
|
||||
|
||||
@if (Model.CurrentArticle is not { } article)
|
||||
@ -117,6 +120,57 @@
|
||||
async>
|
||||
</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 •
|
||||
|
||||
<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
|
||||
{
|
||||
|
@ -67,6 +67,27 @@ internal sealed class BlogPostService : IBlogPostService
|
||||
.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 />
|
||||
public IBlogPost? GetNextPost(IBlogPost blogPost)
|
||||
{
|
||||
|
@ -34,6 +34,27 @@ public interface IBlogPostService
|
||||
/// <returns>A collection of blog posts.</returns>
|
||||
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>
|
||||
/// Returns the next blog post from the specified blog post.
|
||||
/// </summary>
|
||||
|
@ -1,5 +1,6 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using OliverBooth.Data;
|
||||
using OliverBooth.Data.Blog;
|
||||
using OliverBooth.Data.Web;
|
||||
|
||||
namespace OliverBooth.Services;
|
||||
@ -60,6 +61,27 @@ public interface ITutorialService
|
||||
/// <exception cref="ArgumentNullException"><paramref name="article" /> is <see langword="null" />.</exception>
|
||||
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>
|
||||
/// Renders the body of the specified article.
|
||||
/// </summary>
|
||||
|
@ -4,24 +4,30 @@ using Humanizer;
|
||||
using Markdig;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OliverBooth.Data;
|
||||
using OliverBooth.Data.Blog;
|
||||
using OliverBooth.Data.Web;
|
||||
|
||||
namespace OliverBooth.Services;
|
||||
|
||||
internal sealed class TutorialService : ITutorialService
|
||||
{
|
||||
private readonly IDbContextFactory<BlogContext> _blogContextFactory;
|
||||
private readonly IDbContextFactory<WebContext> _dbContextFactory;
|
||||
private readonly MarkdownPipeline _markdownPipeline;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TutorialService" /> class.
|
||||
/// </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>
|
||||
public TutorialService(IDbContextFactory<WebContext> dbContextFactory, MarkdownPipeline markdownPipeline)
|
||||
public TutorialService(IDbContextFactory<WebContext> dbContextFactory,
|
||||
IDbContextFactory<BlogContext> blogContextFactory,
|
||||
MarkdownPipeline markdownPipeline)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_markdownPipeline = markdownPipeline;
|
||||
_blogContextFactory = blogContextFactory;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@ -103,6 +109,37 @@ internal sealed class TutorialService : ITutorialService
|
||||
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 />
|
||||
public string RenderArticle(ITutorialArticle article)
|
||||
{
|
||||
|
@ -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 {
|
||||
background-color: desaturate(darken(#6364FF, 50%), 50%);
|
||||
margin-bottom: 50px;
|
||||
|
Loading…
Reference in New Issue
Block a user