diff --git a/OliverBooth/Data/Blog/Author.cs b/OliverBooth/Data/Blog/Author.cs new file mode 100644 index 0000000..9fd09a8 --- /dev/null +++ b/OliverBooth/Data/Blog/Author.cs @@ -0,0 +1,42 @@ +namespace OliverBooth.Data.Blog; + +/// +/// Represents an author of a blog post. +/// +public sealed class Author : IEquatable +{ + /// + /// Gets or sets the email address of the author. + /// + /// The email address. + public string? EmailAddress { get; set; } + + /// + /// Gets the ID of the author. + /// + /// The ID. + public int Id { get; private set; } + + /// + /// Gets or sets the name of the author. + /// + /// The name. + 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; + } +} diff --git a/OliverBooth/Data/Blog/BlogPost.cs b/OliverBooth/Data/Blog/BlogPost.cs new file mode 100644 index 0000000..c8aef68 --- /dev/null +++ b/OliverBooth/Data/Blog/BlogPost.cs @@ -0,0 +1,66 @@ +namespace OliverBooth.Data.Blog; + +/// +/// Represents a blog post. +/// +public sealed class BlogPost : IEquatable +{ + /// + /// Gets the ID of the author. + /// + /// The author ID. + public int AuthorId { get; private set; } + + /// + /// Gets or sets the body of the blog post. + /// + /// The body. + public string Body { get; set; } = string.Empty; + + /// + /// Gets the ID of the blog post. + /// + /// The ID. + public int Id { get; private set; } + + /// + /// Gets or sets the date and time at which the blog post was published. + /// + /// The publish timestamp. + public DateTimeOffset Published { get; set; } + + /// + /// Gets or sets the URL slug of the blog post. + /// + /// The URL slug. + public string Slug { get; set; } = string.Empty; + + /// + /// Gets or sets the title of the blog post. + /// + /// The title. + public string Title { get; set; } = string.Empty; + + /// + /// Gets or sets the legacy WordPress ID of the blog post. + /// + /// The legacy WordPress ID. + 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; + } +} diff --git a/OliverBooth/Data/Blog/Configuration/AuthorConfiguration.cs b/OliverBooth/Data/Blog/Configuration/AuthorConfiguration.cs new file mode 100644 index 0000000..d7f9ca1 --- /dev/null +++ b/OliverBooth/Data/Blog/Configuration/AuthorConfiguration.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace OliverBooth.Data.Blog.Configuration; + +/// +/// Represents the configuration for the entity. +/// +internal sealed class AuthorConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder 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); + } +} diff --git a/OliverBooth/Data/Blog/Configuration/BlogPostConfiguration.cs b/OliverBooth/Data/Blog/Configuration/BlogPostConfiguration.cs new file mode 100644 index 0000000..d2152a2 --- /dev/null +++ b/OliverBooth/Data/Blog/Configuration/BlogPostConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace OliverBooth.Data.Blog.Configuration; + +/// +/// Represents the configuration for the entity. +/// +internal sealed class BlogPostConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder 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(); + } +} diff --git a/OliverBooth/Data/BlogContext.cs b/OliverBooth/Data/BlogContext.cs new file mode 100644 index 0000000..ec8a8f0 --- /dev/null +++ b/OliverBooth/Data/BlogContext.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore; +using OliverBooth.Data.Blog; +using OliverBooth.Data.Blog.Configuration; + +namespace OliverBooth.Data; + +/// +/// Represents a session with the blog database. +/// +public sealed class BlogContext : DbContext +{ + private readonly IConfiguration _configuration; + + public BlogContext(IConfiguration configuration) + { + _configuration = configuration; + } + + /// + /// Gets the set of authors. + /// + /// The set of authors. + public DbSet Authors { get; internal set; } = null!; + + /// + /// Gets the set of blog posts. + /// + /// The set of blog posts. + public DbSet BlogPosts { get; internal set; } = null!; + + /// + 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()); + } +} diff --git a/OliverBooth/Data/WebContext.cs b/OliverBooth/Data/WebContext.cs new file mode 100644 index 0000000..838664c --- /dev/null +++ b/OliverBooth/Data/WebContext.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; + +namespace OliverBooth.Data; + +/// +/// Represents a session with the web database. +/// +public sealed class WebContext : DbContext +{ + private readonly IConfiguration _configuration; + + public WebContext(IConfiguration configuration) + { + _configuration = configuration; + } + + /// + 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) + { + } +} diff --git a/OliverBooth/OliverBooth.csproj b/OliverBooth/OliverBooth.csproj index 8a6f2ac..a567424 100644 --- a/OliverBooth/OliverBooth.csproj +++ b/OliverBooth/OliverBooth.csproj @@ -14,9 +14,13 @@ + + + + diff --git a/OliverBooth/Pages/Blog/Article.cshtml b/OliverBooth/Pages/Blog/Article.cshtml new file mode 100644 index 0000000..0015727 --- /dev/null +++ b/OliverBooth/Pages/Blog/Article.cshtml @@ -0,0 +1,9 @@ +@page "/blog/{year:int}/{month:int}/{slug}" +@model OliverBooth.Pages.Blog.Article + +@if (Model.Post is { } post) +{ +

@post.Title

+

@post.Published.ToString("MMMM dd, yyyy") • @Model.Author?.Name

+ @Html.Raw(Model.SanitizeContent(post.Body)) +} diff --git a/OliverBooth/Pages/Blog/Article.cshtml.cs b/OliverBooth/Pages/Blog/Article.cshtml.cs new file mode 100644 index 0000000..6040487 --- /dev/null +++ b/OliverBooth/Pages/Blog/Article.cshtml.cs @@ -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 _dbContextFactory; + + public Article(IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + } + + public Author? Author { get; private set; } + + public BlogPost? Post { get; private set; } + + public string SanitizeContent(string content) + { + content = content.Replace("", 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); + } + } +} diff --git a/OliverBooth/Pages/Blog/Index.cshtml b/OliverBooth/Pages/Blog/Index.cshtml new file mode 100644 index 0000000..b17bfed --- /dev/null +++ b/OliverBooth/Pages/Blog/Index.cshtml @@ -0,0 +1,16 @@ +@page +@using OliverBooth.Data.Blog +@model OliverBooth.Pages.Blog.Index + +@foreach (BlogPost post in Model.BlogPosts) +{ +

+ @post.Title +

+

@post.Published.ToString("MMMM dd, yyyy") • @Model.GetAuthor(post)?.Name

+

@Html.Raw(Model.SanitizeContent(Model.TrimContent(post.Body, out bool trimmed)))

+ if (trimmed) + { +

Read more...

+ } +} \ No newline at end of file diff --git a/OliverBooth/Pages/Blog/Index.cshtml.cs b/OliverBooth/Pages/Blog/Index.cshtml.cs new file mode 100644 index 0000000..58c6d83 --- /dev/null +++ b/OliverBooth/Pages/Blog/Index.cshtml.cs @@ -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 _dbContextFactory; + + public Index(IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + } + + public IReadOnlyCollection BlogPosts { get; private set; } = ArraySegment.Empty; + + public string SanitizeContent(string content) + { + content = content.Replace("", 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 span = content.AsSpan(); + int moreIndex = span.IndexOf("", 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(); + } +} diff --git a/OliverBooth/Program.cs b/OliverBooth/Program.cs index b120cc1..2e3ec7d 100644 --- a/OliverBooth/Program.cs +++ b/OliverBooth/Program.cs @@ -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(); +builder.Services.AddDbContextFactory(); +builder.Services.AddDbContextFactory(); builder.Services.AddRazorPages().AddRazorRuntimeCompilation(); builder.Services.AddControllersWithViews(); builder.Services.AddRouting(options => options.LowercaseUrls = true);