feat: add preliminary /blog routes
This change also introduces toml file config
This commit is contained in:
parent
ba8e186cb5
commit
bd999f0ed8
42
OliverBooth/Data/Blog/Author.cs
Normal file
42
OliverBooth/Data/Blog/Author.cs
Normal file
@ -0,0 +1,42 @@
|
||||
namespace OliverBooth.Data.Blog;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an author of a blog post.
|
||||
/// </summary>
|
||||
public sealed class Author : IEquatable<Author>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the email address of the author.
|
||||
/// </summary>
|
||||
/// <value>The email address.</value>
|
||||
public string? EmailAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ID of the author.
|
||||
/// </summary>
|
||||
/// <value>The ID.</value>
|
||||
public int Id { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the author.
|
||||
/// </summary>
|
||||
/// <value>The name.</value>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public bool Equals(Author? other)
|
||||
{
|
||||
if (ReferenceEquals(null, other)) return false;
|
||||
if (ReferenceEquals(this, other)) return true;
|
||||
return Id == other.Id;
|
||||
}
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
return ReferenceEquals(this, obj) || obj is Author other && Equals(other);
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return Id;
|
||||
}
|
||||
}
|
66
OliverBooth/Data/Blog/BlogPost.cs
Normal file
66
OliverBooth/Data/Blog/BlogPost.cs
Normal file
@ -0,0 +1,66 @@
|
||||
namespace OliverBooth.Data.Blog;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a blog post.
|
||||
/// </summary>
|
||||
public sealed class BlogPost : IEquatable<BlogPost>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the ID of the author.
|
||||
/// </summary>
|
||||
/// <value>The author ID.</value>
|
||||
public int AuthorId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the body of the blog post.
|
||||
/// </summary>
|
||||
/// <value>The body.</value>
|
||||
public string Body { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ID of the blog post.
|
||||
/// </summary>
|
||||
/// <value>The ID.</value>
|
||||
public int Id { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the date and time at which the blog post was published.
|
||||
/// </summary>
|
||||
/// <value>The publish timestamp.</value>
|
||||
public DateTimeOffset Published { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the URL slug of the blog post.
|
||||
/// </summary>
|
||||
/// <value>The URL slug.</value>
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the title of the blog post.
|
||||
/// </summary>
|
||||
/// <value>The title.</value>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the legacy WordPress ID of the blog post.
|
||||
/// </summary>
|
||||
/// <value>The legacy WordPress ID.</value>
|
||||
public int? WordPressId { get; set; }
|
||||
|
||||
public bool Equals(BlogPost? other)
|
||||
{
|
||||
if (ReferenceEquals(null, other)) return false;
|
||||
if (ReferenceEquals(this, other)) return true;
|
||||
return Id == other.Id;
|
||||
}
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
return ReferenceEquals(this, obj) || obj is BlogPost other && Equals(other);
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return Id;
|
||||
}
|
||||
}
|
21
OliverBooth/Data/Blog/Configuration/AuthorConfiguration.cs
Normal file
21
OliverBooth/Data/Blog/Configuration/AuthorConfiguration.cs
Normal file
@ -0,0 +1,21 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace OliverBooth.Data.Blog.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the configuration for the <see cref="Author" /> entity.
|
||||
/// </summary>
|
||||
internal sealed class AuthorConfiguration : IEntityTypeConfiguration<Author>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Configure(EntityTypeBuilder<Author> builder)
|
||||
{
|
||||
builder.ToTable("Author");
|
||||
builder.HasKey(e => e.Id);
|
||||
|
||||
builder.Property(e => e.Id).ValueGeneratedOnAdd();
|
||||
builder.Property(e => e.Name).HasMaxLength(100).IsRequired();
|
||||
builder.Property(e => e.EmailAddress).HasMaxLength(255).IsRequired(false);
|
||||
}
|
||||
}
|
25
OliverBooth/Data/Blog/Configuration/BlogPostConfiguration.cs
Normal file
25
OliverBooth/Data/Blog/Configuration/BlogPostConfiguration.cs
Normal file
@ -0,0 +1,25 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace OliverBooth.Data.Blog.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the configuration for the <see cref="BlogPost" /> entity.
|
||||
/// </summary>
|
||||
internal sealed class BlogPostConfiguration : IEntityTypeConfiguration<BlogPost>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Configure(EntityTypeBuilder<BlogPost> builder)
|
||||
{
|
||||
builder.ToTable("BlogPost");
|
||||
builder.HasKey(e => e.Id);
|
||||
|
||||
builder.Property(e => e.Id).ValueGeneratedOnAdd();
|
||||
builder.Property(e => e.WordPressId).IsRequired(false);
|
||||
builder.Property(e => e.Slug).HasMaxLength(100).IsRequired();
|
||||
builder.Property(e => e.AuthorId).IsRequired();
|
||||
builder.Property(e => e.Published).IsRequired();
|
||||
builder.Property(e => e.Title).HasMaxLength(255).IsRequired();
|
||||
builder.Property(e => e.Body).IsRequired();
|
||||
}
|
||||
}
|
44
OliverBooth/Data/BlogContext.cs
Normal file
44
OliverBooth/Data/BlogContext.cs
Normal file
@ -0,0 +1,44 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OliverBooth.Data.Blog;
|
||||
using OliverBooth.Data.Blog.Configuration;
|
||||
|
||||
namespace OliverBooth.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a session with the blog database.
|
||||
/// </summary>
|
||||
public sealed class BlogContext : DbContext
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public BlogContext(IConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the set of authors.
|
||||
/// </summary>
|
||||
/// <value>The set of authors.</value>
|
||||
public DbSet<Author> Authors { get; internal set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the set of blog posts.
|
||||
/// </summary>
|
||||
/// <value>The set of blog posts.</value>
|
||||
public DbSet<BlogPost> BlogPosts { get; internal set; } = null!;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
string connectionString = _configuration.GetConnectionString("Blog") ?? string.Empty;
|
||||
ServerVersion serverVersion = ServerVersion.AutoDetect(connectionString);
|
||||
optionsBuilder.UseMySql(connectionString, serverVersion);
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfiguration(new AuthorConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new BlogPostConfiguration());
|
||||
}
|
||||
}
|
28
OliverBooth/Data/WebContext.cs
Normal file
28
OliverBooth/Data/WebContext.cs
Normal file
@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace OliverBooth.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a session with the web database.
|
||||
/// </summary>
|
||||
public sealed class WebContext : DbContext
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public WebContext(IConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
string connectionString = _configuration.GetConnectionString("Web") ?? string.Empty;
|
||||
ServerVersion serverVersion = ServerVersion.AutoDetect(connectionString);
|
||||
optionsBuilder.UseMySql(connectionString, serverVersion);
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
}
|
||||
}
|
@ -14,9 +14,13 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Alexinea.Extensions.Configuration.Toml" Version="7.0.0"/>
|
||||
<PackageReference Include="Humanizer.Core" Version="2.14.1"/>
|
||||
<PackageReference Include="Markdig" Version="0.32.0"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="7.0.9"/>
|
||||
<PackageReference Include="NLog" Version="5.2.3"/>
|
||||
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.3"/>
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="7.0.0"/>
|
||||
<PackageReference Include="X10D" Version="3.2.2"/>
|
||||
<PackageReference Include="X10D.Hosting" Version="3.2.2"/>
|
||||
</ItemGroup>
|
||||
|
9
OliverBooth/Pages/Blog/Article.cshtml
Normal file
9
OliverBooth/Pages/Blog/Article.cshtml
Normal file
@ -0,0 +1,9 @@
|
||||
@page "/blog/{year:int}/{month:int}/{slug}"
|
||||
@model OliverBooth.Pages.Blog.Article
|
||||
|
||||
@if (Model.Post is { } post)
|
||||
{
|
||||
<h1>@post.Title</h1>
|
||||
<p class="text-muted">@post.Published.ToString("MMMM dd, yyyy") • @Model.Author?.Name</p>
|
||||
@Html.Raw(Model.SanitizeContent(post.Body))
|
||||
}
|
50
OliverBooth/Pages/Blog/Article.cshtml.cs
Normal file
50
OliverBooth/Pages/Blog/Article.cshtml.cs
Normal file
@ -0,0 +1,50 @@
|
||||
using Humanizer;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OliverBooth.Data;
|
||||
using OliverBooth.Data.Blog;
|
||||
|
||||
namespace OliverBooth.Pages.Blog;
|
||||
|
||||
public class Article : PageModel
|
||||
{
|
||||
private readonly IDbContextFactory<BlogContext> _dbContextFactory;
|
||||
|
||||
public Article(IDbContextFactory<BlogContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public Author? Author { get; private set; }
|
||||
|
||||
public BlogPost? Post { get; private set; }
|
||||
|
||||
public string SanitizeContent(string content)
|
||||
{
|
||||
content = content.Replace("<more>", string.Empty);
|
||||
|
||||
while (content.Contains("\n\n"))
|
||||
{
|
||||
content = content.Replace("\n\n", "\n");
|
||||
}
|
||||
|
||||
return Markdig.Markdown.ToHtml(content.Trim());
|
||||
}
|
||||
|
||||
public void OnGet(int year, int month, string slug)
|
||||
{
|
||||
using BlogContext context = _dbContextFactory.CreateDbContext();
|
||||
Post = context.BlogPosts.FirstOrDefault(p => p.Published.Year == year &&
|
||||
p.Published.Month == month &&
|
||||
p.Slug == slug);
|
||||
|
||||
if (Post is null)
|
||||
{
|
||||
Response.StatusCode = 404;
|
||||
}
|
||||
else
|
||||
{
|
||||
Author = context.Authors.FirstOrDefault(a => a.Id == Post.AuthorId);
|
||||
}
|
||||
}
|
||||
}
|
16
OliverBooth/Pages/Blog/Index.cshtml
Normal file
16
OliverBooth/Pages/Blog/Index.cshtml
Normal file
@ -0,0 +1,16 @@
|
||||
@page
|
||||
@using OliverBooth.Data.Blog
|
||||
@model OliverBooth.Pages.Blog.Index
|
||||
|
||||
@foreach (BlogPost post in Model.BlogPosts)
|
||||
{
|
||||
<h2>
|
||||
<a asp-page="/blog/article" asp-route-year="@post.Published.Year" asp-route-month="@post.Published.Month.ToString().PadLeft(2, '0')" asp-route-slug="@post.Slug">@post.Title</a>
|
||||
</h2>
|
||||
<p class="text-muted">@post.Published.ToString("MMMM dd, yyyy") • @Model.GetAuthor(post)?.Name</p>
|
||||
<p>@Html.Raw(Model.SanitizeContent(Model.TrimContent(post.Body, out bool trimmed)))</p>
|
||||
if (trimmed)
|
||||
{
|
||||
<p><a asp-page="/blog/article" asp-route-year="@post.Published.Year" asp-route-month="@post.Published.Month.ToString().PadLeft(2, '0')" asp-route-slug="@post.Slug">Read more...</a></p>
|
||||
}
|
||||
}
|
51
OliverBooth/Pages/Blog/Index.cshtml.cs
Normal file
51
OliverBooth/Pages/Blog/Index.cshtml.cs
Normal file
@ -0,0 +1,51 @@
|
||||
using Humanizer;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OliverBooth.Data;
|
||||
using OliverBooth.Data.Blog;
|
||||
|
||||
namespace OliverBooth.Pages.Blog;
|
||||
|
||||
public class Index : PageModel
|
||||
{
|
||||
private readonly IDbContextFactory<BlogContext> _dbContextFactory;
|
||||
|
||||
public Index(IDbContextFactory<BlogContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<BlogPost> BlogPosts { get; private set; } = ArraySegment<BlogPost>.Empty;
|
||||
|
||||
public string SanitizeContent(string content)
|
||||
{
|
||||
content = content.Replace("<more>", string.Empty);
|
||||
|
||||
while (content.Contains("\n\n"))
|
||||
{
|
||||
content = content.Replace("\n\n", "\n");
|
||||
}
|
||||
|
||||
return Markdig.Markdown.ToHtml(content.Trim());
|
||||
}
|
||||
|
||||
public string TrimContent(string content, out bool trimmed)
|
||||
{
|
||||
ReadOnlySpan<char> span = content.AsSpan();
|
||||
int moreIndex = span.IndexOf("<more>", StringComparison.Ordinal);
|
||||
trimmed = moreIndex != -1 || span.Length > 256;
|
||||
return moreIndex != -1 ? span[..moreIndex].Trim().ToString() : content.Truncate(256);
|
||||
}
|
||||
|
||||
public Author? GetAuthor(BlogPost post)
|
||||
{
|
||||
using BlogContext context = _dbContextFactory.CreateDbContext();
|
||||
return context.Authors.FirstOrDefault(a => a.Id == post.AuthorId);
|
||||
}
|
||||
|
||||
public void OnGet()
|
||||
{
|
||||
using BlogContext context = _dbContextFactory.CreateDbContext();
|
||||
BlogPosts = context.BlogPosts.ToArray();
|
||||
}
|
||||
}
|
@ -1,13 +1,17 @@
|
||||
using NLog.Extensions.Logging;
|
||||
using OliverBooth.Data;
|
||||
using OliverBooth.Services;
|
||||
using X10D.Hosting.DependencyInjection;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
builder.Configuration.AddTomlFile("data/config.toml", true, true);
|
||||
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddNLog();
|
||||
builder.Services.AddHostedSingleton<LoggingService>();
|
||||
|
||||
builder.Services.AddDbContextFactory<BlogContext>();
|
||||
builder.Services.AddDbContextFactory<WebContext>();
|
||||
builder.Services.AddRazorPages().AddRazorRuntimeCompilation();
|
||||
builder.Services.AddControllersWithViews();
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
|
Loading…
Reference in New Issue
Block a user