Merge branch 'feature/tutorials'
Some checks failed
.NET / Build & Test (push) Failing after 1m13s

This commit is contained in:
Oliver Booth 2024-04-26 17:24:08 +01:00
commit 0b1066c273
Signed by: oliverbooth
GPG Key ID: E60B570D1B7557B5
21 changed files with 900 additions and 28 deletions

View File

@ -44,7 +44,7 @@ internal sealed class BlogPost : IBlogPost
public DateTimeOffset? Updated { get; internal set; } public DateTimeOffset? Updated { get; internal set; }
/// <inheritdoc /> /// <inheritdoc />
public BlogPostVisibility Visibility { get; internal set; } public Visibility Visibility { get; internal set; }
/// <inheritdoc /> /// <inheritdoc />
public int? WordPressId { get; set; } public int? WordPressId { get; set; }

View File

@ -26,7 +26,7 @@ internal sealed class BlogPostConfiguration : IEntityTypeConfiguration<BlogPost>
builder.Property(e => e.DisqusDomain).IsRequired(false); builder.Property(e => e.DisqusDomain).IsRequired(false);
builder.Property(e => e.DisqusIdentifier).IsRequired(false); builder.Property(e => e.DisqusIdentifier).IsRequired(false);
builder.Property(e => e.DisqusPath).IsRequired(false); builder.Property(e => e.DisqusPath).IsRequired(false);
builder.Property(e => e.Visibility).HasConversion(new EnumToStringConverter<BlogPostVisibility>()).IsRequired(); builder.Property(e => e.Visibility).HasConversion(new EnumToStringConverter<Visibility>()).IsRequired();
builder.Property(e => e.Password).HasMaxLength(255).IsRequired(false); builder.Property(e => e.Password).HasMaxLength(255).IsRequired(false);
builder.Property(e => e.Tags).IsRequired() builder.Property(e => e.Tags).IsRequired()
.HasConversion( .HasConversion(

View File

@ -85,7 +85,7 @@ public interface IBlogPost
/// Gets the visibility of the post. /// Gets the visibility of the post.
/// </summary> /// </summary>
/// <value>The visibility of the post.</value> /// <value>The visibility of the post.</value>
BlogPostVisibility Visibility { get; } Visibility Visibility { get; }
/// <summary> /// <summary>
/// Gets the WordPress ID of the post. /// Gets the WordPress ID of the post.

View File

@ -1,10 +1,15 @@
namespace OliverBooth.Data.Blog; namespace OliverBooth.Data;
/// <summary> /// <summary>
/// An enumeration of the possible visibilities of a blog post. /// An enumeration of the possible visibilities of a blog post.
/// </summary> /// </summary>
public enum BlogPostVisibility public enum Visibility
{ {
/// <summary>
/// Used for filtering results. Represents all visibilities.
/// </summary>
None = -1,
/// <summary> /// <summary>
/// The post is private and only visible to the author, or those with the password. /// The post is private and only visible to the author, or those with the password.
/// </summary> /// </summary>

View File

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace OliverBooth.Data.Web.Configuration;
/// <summary>
/// Represents the configuration for the <see cref="Template" /> entity.
/// </summary>
internal sealed class TutorialArticleConfiguration : IEntityTypeConfiguration<TutorialArticle>
{
public void Configure(EntityTypeBuilder<TutorialArticle> builder)
{
builder.ToTable("TutorialArticle");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).IsRequired();
builder.Property(e => e.Folder).IsRequired();
builder.Property(e => e.Published).IsRequired();
builder.Property(e => e.Updated);
builder.Property(e => e.Slug).IsRequired();
builder.Property(e => e.Title).IsRequired();
builder.Property(e => e.PreviewImageUrl).HasConversion<UriToStringConverter>();
builder.Property(e => e.NextPart);
builder.Property(e => e.PreviousPart);
builder.Property(e => e.Visibility).HasConversion<EnumToStringConverter<Visibility>>();
}
}

View File

@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace OliverBooth.Data.Web.Configuration;
/// <summary>
/// Represents the configuration for the <see cref="Template" /> entity.
/// </summary>
internal sealed class TutorialFolderConfiguration : IEntityTypeConfiguration<TutorialFolder>
{
public void Configure(EntityTypeBuilder<TutorialFolder> builder)
{
builder.ToTable("TutorialFolder");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).IsRequired();
builder.Property(e => e.Parent);
builder.Property(e => e.Slug).HasMaxLength(50).IsRequired();
builder.Property(e => e.Title).HasMaxLength(255).IsRequired();
builder.Property(e => e.PreviewImageUrl).HasConversion<UriToStringConverter>();
builder.Property(e => e.Visibility).HasConversion<EnumToStringConverter<Visibility>>();
}
}

View File

@ -0,0 +1,73 @@
namespace OliverBooth.Data.Web;
/// <summary>
/// Represents a tutorial article.
/// </summary>
public interface ITutorialArticle
{
/// <summary>
/// Gets the body of this article.
/// </summary>
/// <value>The body.</value>
string Body { get; }
/// <summary>
/// Gets the ID of the folder this article is contained within.
/// </summary>
/// <value>The ID of the folder.</value>
int Folder { get; }
/// <summary>
/// Gets the ID of this article.
/// </summary>
/// <value>The ID.</value>
int Id { get; }
/// <summary>
/// Gets the ID of the next article to this one.
/// </summary>
/// <value>The next part ID.</value>
int? NextPart { get; }
/// <summary>
/// Gets the URL of the article's preview image.
/// </summary>
/// <value>The preview image URL.</value>
Uri? PreviewImageUrl { get; }
/// <summary>
/// Gets the ID of the previous article to this one.
/// </summary>
/// <value>The previous part ID.</value>
int? PreviousPart { get; }
/// <summary>
/// Gets the date and time at which this article was published.
/// </summary>
/// <value>The publish timestamp.</value>
DateTimeOffset Published { get; }
/// <summary>
/// Gets the slug of this article.
/// </summary>
/// <value>The slug.</value>
string Slug { get; }
/// <summary>
/// Gets the title of this article.
/// </summary>
/// <value>The title.</value>
string Title { get; }
/// <summary>
/// Gets the date and time at which this article was updated.
/// </summary>
/// <value>The update timestamp, or <see langword="null" /> if this article has not been updated.</value>
DateTimeOffset? Updated { get; }
/// <summary>
/// Gets the visibility of this article.
/// </summary>
/// <value>The visibility of the article.</value>
Visibility Visibility { get; }
}

View File

@ -0,0 +1,43 @@
namespace OliverBooth.Data.Web;
/// <summary>
/// Represents a folder for tutorial articles.
/// </summary>
public interface ITutorialFolder
{
/// <summary>
/// Gets the ID of this folder.
/// </summary>
/// <value>The ID of the folder.</value>
int Id { get; }
/// <summary>
/// Gets the ID of this folder's parent.
/// </summary>
/// <value>The ID of the parent, or <see langword="null" /> if this folder is at the root.</value>
int? Parent { get; }
/// <summary>
/// Gets the URL of the folder's preview image.
/// </summary>
/// <value>The preview image URL.</value>
Uri? PreviewImageUrl { get; }
/// <summary>
/// Gets the slug of this folder.
/// </summary>
/// <value>The slug.</value>
string Slug { get; }
/// <summary>
/// Gets the title of this folder.
/// </summary>
/// <value>The title.</value>
string Title { get; }
/// <summary>
/// Gets the visibility of this article.
/// </summary>
/// <value>The visibility of the article.</value>
Visibility Visibility { get; }
}

View File

@ -0,0 +1,101 @@
namespace OliverBooth.Data.Web;
/// <summary>
/// Represents a tutorial article.
/// </summary>
internal sealed class TutorialArticle : IEquatable<TutorialArticle>, ITutorialArticle
{
/// <inheritdoc />
public string Body { get; private set; } = string.Empty;
/// <inheritdoc />
public int Folder { get; private set; }
/// <inheritdoc />
public int Id { get; private set; }
/// <inheritdoc />
public int? NextPart { get; }
/// <inheritdoc />
public Uri? PreviewImageUrl { get; private set; }
/// <inheritdoc />
public int? PreviousPart { get; private set; }
/// <inheritdoc />
public DateTimeOffset Published { get; private set; }
/// <inheritdoc />
public string Slug { get; private set; } = string.Empty;
/// <inheritdoc />
public string Title { get; private set; } = string.Empty;
/// <inheritdoc />
public DateTimeOffset? Updated { get; private set; }
/// <inheritdoc />
public Visibility Visibility { get; private set; }
/// <summary>
/// Returns a value indicating whether two instances of <see cref="TutorialArticle" /> are equal.
/// </summary>
/// <param name="left">The first instance of <see cref="TutorialArticle" /> to compare.</param>
/// <param name="right">The second instance of <see cref="TutorialArticle" /> to compare.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="left" /> and <paramref name="right" /> are equal; otherwise,
/// <see langword="false" />.
/// </returns>
public static bool operator ==(TutorialArticle? left, TutorialArticle? right) => Equals(left, right);
/// <summary>
/// Returns a value indicating whether two instances of <see cref="TutorialArticle" /> are not equal.
/// </summary>
/// <param name="left">The first instance of <see cref="TutorialArticle" /> to compare.</param>
/// <param name="right">The second instance of <see cref="TutorialArticle" /> to compare.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="left" /> and <paramref name="right" /> are not equal; otherwise,
/// <see langword="false" />.
/// </returns>
public static bool operator !=(TutorialArticle? left, TutorialArticle? right) => !(left == right);
/// <summary>
/// Returns a value indicating whether this instance of <see cref="TutorialArticle" /> is equal to another
/// instance.
/// </summary>
/// <param name="other">An instance to compare with this instance.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="other" /> is equal to this instance; otherwise,
/// <see langword="false" />.
/// </returns>
public bool Equals(TutorialArticle? other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return Id.Equals(other.Id);
}
/// <summary>
/// Returns a value indicating whether this instance is equal to a specified object.
/// </summary>
/// <param name="obj">An object to compare with this instance.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="obj" /> is an instance of <see cref="TutorialArticle" /> and
/// equals the value of this instance; otherwise, <see langword="false" />.
/// </returns>
public override bool Equals(object? obj)
{
return ReferenceEquals(this, obj) || obj is TutorialArticle other && Equals(other);
}
/// <summary>
/// Gets the hash code for this instance.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
// ReSharper disable once NonReadonlyMemberInGetHashCode
return Id;
}
}

View File

@ -0,0 +1,86 @@
namespace OliverBooth.Data.Web;
/// <summary>
/// Represents a folder for tutorial articles.
/// </summary>
internal sealed class TutorialFolder : IEquatable<TutorialFolder>, ITutorialFolder
{
/// <inheritdoc />
public int Id { get; private set; }
/// <inheritdoc />
public int? Parent { get; private set; }
/// <inheritdoc />
public Uri? PreviewImageUrl { get; private set; }
/// <inheritdoc />
public string Slug { get; private set; } = string.Empty;
/// <inheritdoc />
public string Title { get; private set; } = string.Empty;
/// <inheritdoc />
public Visibility Visibility { get; private set; }
/// <summary>
/// Returns a value indicating whether two instances of <see cref="TutorialFolder" /> are equal.
/// </summary>
/// <param name="left">The first instance of <see cref="TutorialFolder" /> to compare.</param>
/// <param name="right">The second instance of <see cref="TutorialFolder" /> to compare.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="left" /> and <paramref name="right" /> are equal; otherwise,
/// <see langword="false" />.
/// </returns>
public static bool operator ==(TutorialFolder? left, TutorialFolder? right) => Equals(left, right);
/// <summary>
/// Returns a value indicating whether two instances of <see cref="TutorialFolder" /> are not equal.
/// </summary>
/// <param name="left">The first instance of <see cref="TutorialFolder" /> to compare.</param>
/// <param name="right">The second instance of <see cref="TutorialFolder" /> to compare.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="left" /> and <paramref name="right" /> are not equal; otherwise,
/// <see langword="false" />.
/// </returns>
public static bool operator !=(TutorialFolder? left, TutorialFolder? right) => !(left == right);
/// <summary>
/// Returns a value indicating whether this instance of <see cref="TutorialFolder" /> is equal to another
/// instance.
/// </summary>
/// <param name="other">An instance to compare with this instance.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="other" /> is equal to this instance; otherwise,
/// <see langword="false" />.
/// </returns>
public bool Equals(TutorialFolder? other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return Id.Equals(other.Id);
}
/// <summary>
/// Returns a value indicating whether this instance is equal to a specified object.
/// </summary>
/// <param name="obj">An object to compare with this instance.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="obj" /> is an instance of <see cref="TutorialFolder" /> and
/// equals the value of this instance; otherwise, <see langword="false" />.
/// </returns>
public override bool Equals(object? obj)
{
return ReferenceEquals(this, obj) || obj is TutorialFolder other && Equals(other);
}
/// <summary>
/// Gets the hash code for this instance.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
// ReSharper disable once NonReadonlyMemberInGetHashCode
return Id;
}
}

View File

@ -55,6 +55,18 @@ internal sealed class WebContext : DbContext
/// <value>The collection of templates.</value> /// <value>The collection of templates.</value>
public DbSet<Template> Templates { get; private set; } = null!; public DbSet<Template> Templates { get; private set; } = null!;
/// <summary>
/// Gets the collection of tutorial articles in the database.
/// </summary>
/// <value>The collection of tutorial articles.</value>
public DbSet<TutorialArticle> TutorialArticles { get; private set; } = null!;
/// <summary>
/// Gets the collection of tutorial folders in the database.
/// </summary>
/// <value>The collection of tutorial folders.</value>
public DbSet<TutorialFolder> TutorialFolders { get; private set; } = null!;
/// <inheritdoc /> /// <inheritdoc />
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
@ -71,6 +83,8 @@ internal sealed class WebContext : DbContext
modelBuilder.ApplyConfiguration(new ProgrammingLanguageConfiguration()); modelBuilder.ApplyConfiguration(new ProgrammingLanguageConfiguration());
modelBuilder.ApplyConfiguration(new ProjectConfiguration()); modelBuilder.ApplyConfiguration(new ProjectConfiguration());
modelBuilder.ApplyConfiguration(new TemplateConfiguration()); modelBuilder.ApplyConfiguration(new TemplateConfiguration());
modelBuilder.ApplyConfiguration(new TutorialArticleConfiguration());
modelBuilder.ApplyConfiguration(new TutorialFolderConfiguration());
modelBuilder.ApplyConfiguration(new SiteConfigurationConfiguration()); modelBuilder.ApplyConfiguration(new SiteConfigurationConfiguration());
} }
} }

View File

@ -1,5 +1,6 @@
@page "/blog/{year:int}/{month:int}/{day:int}/{slug}" @page "/blog/{year:int}/{month:int}/{day:int}/{slug}"
@using Humanizer @using Humanizer
@using OliverBooth.Data
@using OliverBooth.Data.Blog @using OliverBooth.Data.Blog
@using OliverBooth.Services @using OliverBooth.Services
@inject IBlogPostService BlogPostService @inject IBlogPostService BlogPostService
@ -44,13 +45,13 @@
@switch (post.Visibility) @switch (post.Visibility)
{ {
case BlogPostVisibility.Private: case Visibility.Private:
<div class="alert alert-danger" role="alert"> <div class="alert alert-danger" role="alert">
This post is private and can only be viewed by those with the password. This post is private and can only be viewed by those with the password.
</div> </div>
break; break;
case BlogPostVisibility.Unlisted: case Visibility.Unlisted:
<div class="alert alert-warning" role="alert"> <div class="alert alert-warning" role="alert">
This post is unlisted and can only be viewed by those with the link. This post is unlisted and can only be viewed by those with the link.
</div> </div>

View File

@ -0,0 +1,92 @@
@page "/tutorial/{**slug}"
@using Humanizer
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using OliverBooth.Data
@using OliverBooth.Data.Web
@using OliverBooth.Services
@inject ITutorialService TutorialService
@model Article
@if (Model.CurrentArticle is not { } article)
{
return;
}
@{
ViewData["Post"] = article;
ViewData["Title"] = article.Title;
DateTimeOffset published = article.Published;
}
<nav style="--bs-breadcrumb-divider: '>';" aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a asp-page="/Tutorials/Index" asp-route-slug="">Tutorials</a>
</li>
@{
int? parentId = Model.CurrentArticle.Folder;
while (parentId is not null)
{
ITutorialFolder thisFolder = TutorialService.GetFolder(parentId.Value)!;
<li class="breadcrumb-item">
<a asp-page="/Tutorials/Index" asp-route-slug="@TutorialService.GetFullSlug(thisFolder)">
@thisFolder.Title
</a>
</li>
parentId = thisFolder.Parent;
}
}
<li class="breadcrumb-item actove" aria-current="page">@Model.CurrentArticle.Title</li>
</ol>
</nav>
<h1>@article.Title</h1>
<p class="text-muted">
<abbr data-bs-toggle="tooltip" data-bs-title="@published.ToString("dddd, d MMMM yyyy HH:mm")">
Published @published.Humanize()
</abbr>
@if (article.Updated is { } updated)
{
<span>&bull;</span>
<abbr data-bs-toggle="tooltip" data-bs-title="@updated.ToString("dddd, d MMMM yyyy HH:mm")">
Updated @updated.Humanize()
</abbr>
}
</p>
<hr>
<article>
@Html.Raw(TutorialService.RenderArticle(article))
</article>
<hr>
<div class="row">
<div class="col-sm-12 col-md-6">
@if (article.PreviousPart is { } previousPartId &&
TutorialService.TryGetArticle(previousPartId, out ITutorialArticle? previousPart) &&
previousPart.Visibility == Visibility.Published)
{
<small>Previous Part</small>
<p class="lead">
<a asp-page="Article" asp-route-slug="@TutorialService.GetFullSlug(previousPart)">
@previousPart.Title
</a>
</p>
}
</div>
<div class="col-sm-12 col-md-6" style="text-align: right;">
@if (article.NextPart is { } nextPartId &&
TutorialService.TryGetArticle(nextPartId, out ITutorialArticle? nextPart) &&
nextPart.Visibility == Visibility.Published)
{
<small>Next Part</small>
<p class="lead">
<a asp-page="Article" asp-route-slug="@TutorialService.GetFullSlug(nextPart)">
@nextPart.Title
</a>
</p>
}
</div>
</div>

View File

@ -0,0 +1,41 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using OliverBooth.Data.Web;
using OliverBooth.Services;
namespace OliverBooth.Pages.Tutorials;
/// <summary>
/// Represents the page model for the <c>Article</c> page.
/// </summary>
public class Article : PageModel
{
private readonly ITutorialService _tutorialService;
/// <summary>
/// Initializes a new instance of the <see cref="Article" /> class.
/// </summary>
/// <param name="tutorialService">The <see cref="ITutorialService" />.</param>
public Article(ITutorialService tutorialService)
{
_tutorialService = tutorialService;
}
/// <summary>
/// Gets the requested article.
/// </summary>
/// <value>The requested article.</value>
public ITutorialArticle CurrentArticle { get; private set; } = null!;
public IActionResult OnGet(string slug)
{
if (!_tutorialService.TryGetArticle(slug, out ITutorialArticle? article))
{
Response.StatusCode = 404;
return NotFound();
}
CurrentArticle = article;
return Page();
}
}

View File

@ -1,23 +1,104 @@
@page "/tutorials" @page "/tutorials/{**slug}"
@using System.Text
@using OliverBooth.Data
@using OliverBooth.Data.Web
@using OliverBooth.Services
@model Index
@inject ITutorialService TutorialService
@{ @{
ViewData["Title"] = "Tutorials"; ViewData["Title"] = "Tutorials";
ITutorialFolder? currentFolder = Model.CurrentFolder;
} }
<main class="container"> <main class="container">
<h1 class="display-4">Tutorials</h1> @if (currentFolder is not null)
<p class="lead">Coming Soon</p> {
<p> <nav style="--bs-breadcrumb-divider: '>';" aria-label="breadcrumb">
Due to Unity's poor corporate decision-making, I'm left in a position where I find it infeasible to write Unity <ol class="breadcrumb">
tutorials. I plan to write tutorials for things like Unreal and MonoGame as I learn them, and C# tutorials are <li class="breadcrumb-item">
still on the table for sure. But tutorials take a lot of time and effort, so unfortunately it may be a while <a asp-page="/Tutorials/Index" asp-route-slug="">Tutorials</a>
before I can get around to publishing them. </li>
</p> @{
<p> int? parentId = currentFolder.Id;
However, in the meantime, I do have various blog posts that contain some tutorials and guides. You can find them while (parentId is not null)
<a asp-page="/Blog/Index">here</a>! {
</p> ITutorialFolder thisFolder = TutorialService.GetFolder(parentId.Value)!;
<p> <li class="breadcrumb-item active" aria-current="page">@thisFolder.Title</li>
I'm sorry for the inconvenience, but I hope you understand my position. Watch this space! New tutorials will be parentId = thisFolder.Parent;
coming. If you have any questions or requests, please feel free to <a asp-page="/Contact/Index">contact me</a>. }
</p> }
</ol>
</nav>
}
<h1 class="display-4">@(currentFolder?.Title ?? "Tutorials")</h1>
@foreach (ITutorialFolder[] folders in TutorialService.GetFolders(currentFolder, Visibility.Published).Chunk(2))
{
<div class="card-group row" style="margin-top: 20px;">
@foreach (ITutorialFolder folder in folders)
{
if (folder.Visibility != Visibility.Published)
{
continue;
}
<div class="col-xs-1 col-md-6 col-lg-6 d-flex align-items-stretch">
<div class="project-card">
<div class="project-image">
<a asp-page="Index" asp-route-slug="@folder.Slug">
<img src="@folder.PreviewImageUrl" class="card-img-top" alt="@folder.Title">
</a>
<a asp-page="Index" asp-route-slug="@folder.Slug">
<p class="project-title">@folder.Title</p>
</a>
</div>
</div>
</div>
}
</div>
}
@if (currentFolder is not null)
{
foreach (ITutorialArticle[] articles in TutorialService.GetArticles(currentFolder, Visibility.Published).Chunk(2))
{
<div class="card-group row" style="margin-top: 20px;">
@foreach (ITutorialArticle article in articles)
{
var slugBuilder = new StringBuilder();
ITutorialFolder? folder = TutorialService.GetFolder(article.Folder);
if (folder is not null)
{
slugBuilder.Append(folder.Slug);
slugBuilder.Append('/');
}
while (folder?.Parent is { } parentId)
{
folder = TutorialService.GetFolder(parentId);
if (folder is not null)
{
slugBuilder.Append(folder.Slug);
slugBuilder.Append('/');
}
}
string slug = slugBuilder + article.Slug;
<div class="col-xs-1 col-md-6 col-lg-6 d-flex align-items-stretch">
<div class="project-card">
<div class="project-image">
<a asp-page="Article" asp-route-slug="@slug">
<img src="@article.PreviewImageUrl" class="card-img-top" alt="@article.Title">
</a>
<a asp-page="Article" asp-route-slug="@slug">
<p class="project-title">@article.Title</p>
</a>
</div>
</div>
</div>
}
</div>
}
}
</main> </main>

View File

@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using OliverBooth.Data.Web;
using OliverBooth.Services;
namespace OliverBooth.Pages.Tutorials;
public class Index : PageModel
{
private readonly ITutorialService _tutorialService;
/// <summary>
/// Initializes a new instance of the <see cref="Index" /> class.
/// </summary>
/// <param name="tutorialService">The tutorial service.</param>
public Index(ITutorialService tutorialService)
{
_tutorialService = tutorialService;
}
public ITutorialFolder? CurrentFolder { get; private set; }
public void OnGet([FromRoute(Name = "slug")] string? slug)
{
if (slug is null) return;
string[] tokens = slug.Split('/');
ITutorialFolder? folder = null;
foreach (string token in tokens)
{
folder = _tutorialService.GetFolder(token, folder);
}
CurrentFolder = folder;
}
}

View File

@ -39,6 +39,7 @@ builder.Services.AddSingleton<IBlogPostService, BlogPostService>();
builder.Services.AddSingleton<IBlogUserService, BlogUserService>(); builder.Services.AddSingleton<IBlogUserService, BlogUserService>();
builder.Services.AddSingleton<IProjectService, ProjectService>(); builder.Services.AddSingleton<IProjectService, ProjectService>();
builder.Services.AddSingleton<IMastodonService, MastodonService>(); builder.Services.AddSingleton<IMastodonService, MastodonService>();
builder.Services.AddSingleton<ITutorialService, TutorialService>();
builder.Services.AddSingleton<IReadingListService, ReadingListService>(); builder.Services.AddSingleton<IReadingListService, ReadingListService>();
builder.Services.AddRazorPages().AddRazorRuntimeCompilation(); builder.Services.AddRazorPages().AddRazorRuntimeCompilation();
builder.Services.AddControllersWithViews(); builder.Services.AddControllersWithViews();

View File

@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis;
using Humanizer; using Humanizer;
using Markdig; using Markdig;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using OliverBooth.Data;
using OliverBooth.Data.Blog; using OliverBooth.Data.Blog;
namespace OliverBooth.Services; namespace OliverBooth.Services;
@ -44,7 +45,7 @@ internal sealed class BlogPostService : IBlogPostService
{ {
using BlogContext context = _dbContextFactory.CreateDbContext(); using BlogContext context = _dbContextFactory.CreateDbContext();
IQueryable<BlogPost> ordered = context.BlogPosts IQueryable<BlogPost> ordered = context.BlogPosts
.Where(p => p.Visibility == BlogPostVisibility.Published) .Where(p => p.Visibility == Visibility.Published)
.OrderByDescending(post => post.Published); .OrderByDescending(post => post.Published);
if (limit > -1) if (limit > -1)
{ {
@ -59,7 +60,7 @@ internal sealed class BlogPostService : IBlogPostService
{ {
using BlogContext context = _dbContextFactory.CreateDbContext(); using BlogContext context = _dbContextFactory.CreateDbContext();
return context.BlogPosts return context.BlogPosts
.Where(p => p.Visibility == BlogPostVisibility.Published) .Where(p => p.Visibility == Visibility.Published)
.OrderByDescending(post => post.Published) .OrderByDescending(post => post.Published)
.Skip(page * pageSize) .Skip(page * pageSize)
.Take(pageSize) .Take(pageSize)
@ -71,7 +72,7 @@ internal sealed class BlogPostService : IBlogPostService
{ {
using BlogContext context = _dbContextFactory.CreateDbContext(); using BlogContext context = _dbContextFactory.CreateDbContext();
return context.BlogPosts return context.BlogPosts
.Where(p => p.Visibility == BlogPostVisibility.Published) .Where(p => p.Visibility == Visibility.Published)
.OrderBy(post => post.Published) .OrderBy(post => post.Published)
.FirstOrDefault(post => post.Published > blogPost.Published); .FirstOrDefault(post => post.Published > blogPost.Published);
} }
@ -81,7 +82,7 @@ internal sealed class BlogPostService : IBlogPostService
{ {
using BlogContext context = _dbContextFactory.CreateDbContext(); using BlogContext context = _dbContextFactory.CreateDbContext();
return context.BlogPosts return context.BlogPosts
.Where(p => p.Visibility == BlogPostVisibility.Published) .Where(p => p.Visibility == Visibility.Published)
.OrderByDescending(post => post.Published) .OrderByDescending(post => post.Published)
.FirstOrDefault(post => post.Published < blogPost.Published); .FirstOrDefault(post => post.Published < blogPost.Published);
} }

View File

@ -0,0 +1,91 @@
using System.Diagnostics.CodeAnalysis;
using OliverBooth.Data;
using OliverBooth.Data.Web;
namespace OliverBooth.Services;
/// <summary>
/// Represents a service which can retrieve tutorial articles.
/// </summary>
public interface ITutorialService
{
/// <summary>
/// Gets the articles within a tutorial folder.
/// </summary>
/// <param name="folder">The folder whose articles to retrieve.</param>
/// <param name="visibility">The visibility to filter by. -1 does not filter.</param>
/// <returns>A read-only view of the articles in the folder.</returns>
IReadOnlyCollection<ITutorialArticle> GetArticles(ITutorialFolder folder, Visibility visibility = Visibility.None);
/// <summary>
/// Gets the tutorial folders within a specified folder.
/// </summary>
/// <param name="parent">The parent folder.</param>
/// <param name="visibility">The visibility to filter by. -1 does not filter.</param>
/// <returns>A read-only view of the subfolders in the folder.</returns>
IReadOnlyCollection<ITutorialFolder> GetFolders(ITutorialFolder? parent = null, Visibility visibility = Visibility.None);
/// <summary>
/// Gets a folder by its ID.
/// </summary>
/// <param name="id">The ID of the folder to get</param>
/// <param name="folder">
/// When this method returns, contains the folder whose ID is equal to the ID specified, or
/// <see langword="null" /> if no such folder was found.
/// </param>
/// <returns><see langword="true" /></returns>
ITutorialFolder? GetFolder(int id);
/// <summary>
/// Gets a folder by its slug.
/// </summary>
/// <param name="slug">The slug of the folder.</param>
/// <param name="parent">The parent folder.</param>
/// <returns>The folder.</returns>
ITutorialFolder? GetFolder(string? slug, ITutorialFolder? parent = null);
/// <summary>
/// Gets the full slug of the specified folder.
/// </summary>
/// <param name="folder">The folder whose slug to return.</param>
/// <returns>The full slug of the folder.</returns>
/// <exception cref="ArgumentNullException"><paramref name="folder" /> is <see langword="null" />.</exception>
string GetFullSlug(ITutorialFolder folder);
/// <summary>
/// Gets the full slug of the specified article.
/// </summary>
/// <param name="article">The article whose slug to return.</param>
/// <returns>The full slug of the article.</returns>
/// <exception cref="ArgumentNullException"><paramref name="article" /> is <see langword="null" />.</exception>
string GetFullSlug(ITutorialArticle article);
/// <summary>
/// Renders the body of the specified article.
/// </summary>
/// <param name="article">The article to render.</param>
/// <returns>The rendered HTML of the article.</returns>
string RenderArticle(ITutorialArticle article);
/// <summary>
/// Attempts to find an article by its ID.
/// </summary>
/// <param name="id">The ID of the article.</param>
/// <param name="article">
/// When this method returns, contains the article whose ID matches the specified <paramref name="id" />, or
/// <see langword="null" /> if no such article was found.
/// </param>
/// <returns><see langword="true" /> if a matching article was found; otherwise, <see langword="false" />.</returns>
bool TryGetArticle(int id, [NotNullWhen(true)] out ITutorialArticle? article);
/// <summary>
/// Attempts to find an article by its slug.
/// </summary>
/// <param name="slug">The slug of the article.</param>
/// <param name="article">
/// When this method returns, contains the article whose slug matches the specified <paramref name="slug" />, or
/// <see langword="null" /> if no such article was found.
/// </param>
/// <returns><see langword="true" /> if a matching article was found; otherwise, <see langword="false" />.</returns>
bool TryGetArticle(string slug, [NotNullWhen(true)] out ITutorialArticle? article);
}

View File

@ -0,0 +1,148 @@
using System.Diagnostics.CodeAnalysis;
using Cysharp.Text;
using Markdig;
using Microsoft.EntityFrameworkCore;
using OliverBooth.Data;
using OliverBooth.Data.Web;
namespace OliverBooth.Services;
internal sealed class TutorialService : ITutorialService
{
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="markdownPipeline">The <see cref="MarkdownPipeline" />.</param>
public TutorialService(IDbContextFactory<WebContext> dbContextFactory, MarkdownPipeline markdownPipeline)
{
_dbContextFactory = dbContextFactory;
_markdownPipeline = markdownPipeline;
}
/// <inheritdoc />
public IReadOnlyCollection<ITutorialArticle> GetArticles(ITutorialFolder folder,
Visibility visibility = Visibility.None)
{
if (folder is null) throw new ArgumentNullException(nameof(folder));
using WebContext context = _dbContextFactory.CreateDbContext();
IQueryable<TutorialArticle> articles = context.TutorialArticles.Where(a => a.Folder == folder.Id);
if (visibility != Visibility.None) articles = articles.Where(a => a.Visibility == visibility);
return articles.ToArray();
}
/// <inheritdoc />
public IReadOnlyCollection<ITutorialFolder> GetFolders(ITutorialFolder? parent = null,
Visibility visibility = Visibility.None)
{
using WebContext context = _dbContextFactory.CreateDbContext();
IQueryable<TutorialFolder> folders = context.TutorialFolders;
folders = parent is null ? folders.Where(f => f.Parent == null) : folders.Where(f => f.Parent == parent.Id);
if (visibility != Visibility.None) folders = folders.Where(a => a.Visibility == visibility);
return folders.ToArray();
}
/// <inheritdoc />
public ITutorialFolder? GetFolder(int id)
{
using WebContext context = _dbContextFactory.CreateDbContext();
return context.TutorialFolders.FirstOrDefault(f => f.Id == id);
}
/// <inheritdoc />
[return: NotNullIfNotNull(nameof(slug))]
public ITutorialFolder? GetFolder(string? slug, ITutorialFolder? parent = null)
{
using WebContext context = _dbContextFactory.CreateDbContext();
return parent is null
? context.TutorialFolders.FirstOrDefault(a => a.Slug == slug)
: context.TutorialFolders.FirstOrDefault(a => a.Slug == slug && a.Parent == parent.Id);
}
/// <inheritdoc />
public string GetFullSlug(ITutorialFolder folder)
{
if (folder is null) throw new ArgumentNullException(nameof(folder));
var folderStack = new Stack<ITutorialFolder>();
folderStack.Push(folder);
while (folder.Parent is { } parentId)
{
ITutorialFolder? current = GetFolder(parentId);
if (current is null) break;
folderStack.Push(current);
}
using var builder = ZString.CreateUtf8StringBuilder();
while (folderStack.Count > 0)
{
builder.Append(folderStack.Pop().Slug);
if (folderStack.Count > 0)
builder.Append('/');
}
return builder.ToString();
}
/// <inheritdoc />
public string GetFullSlug(ITutorialArticle article)
{
if (article is null) throw new ArgumentNullException(nameof(article));
ITutorialFolder? folder = GetFolder(article.Folder);
if (folder is null) return article.Slug;
return $"{GetFullSlug(folder)}/{article.Slug}";
}
/// <inheritdoc />
public string RenderArticle(ITutorialArticle article)
{
return Markdig.Markdown.ToHtml(article.Body, _markdownPipeline);
}
/// <inheritdoc />
public bool TryGetArticle(int id, [NotNullWhen(true)] out ITutorialArticle? article)
{
using WebContext context = _dbContextFactory.CreateDbContext();
article = context.TutorialArticles.FirstOrDefault(a => a.Id == id);
return article is not null;
}
/// <inheritdoc />
public bool TryGetArticle(string slug, [NotNullWhen(true)] out ITutorialArticle? article)
{
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
if (slug is null)
{
article = null;
return false;
}
string[] tokens = slug.Split('/');
ITutorialFolder? folder = null;
for (var index = 0; index < tokens.Length - 1; index++)
{
folder = GetFolder(tokens[index], folder);
}
if (folder is null)
{
article = null;
return false;
}
using WebContext context = _dbContextFactory.CreateDbContext();
slug = tokens[^1];
article = context.TutorialArticles.FirstOrDefault(a => a.Slug == slug && a.Folder == folder.Id);
return article is not null;
}
}

View File

@ -109,6 +109,11 @@ class UI {
public static addLineNumbers(element?: Element) { public static addLineNumbers(element?: Element) {
element = element || document.body; element = element || document.body;
element.querySelectorAll("pre code").forEach((block) => { element.querySelectorAll("pre code").forEach((block) => {
if (block.className.indexOf("|nolinenumbers") > 0) {
block.className = block.className.replaceAll("|nolinenumbers", "");
return;
}
let content = block.textContent; let content = block.textContent;
if (content.trim().split("\n").length > 1) { if (content.trim().split("\n").length > 1) {
block.parentElement.classList.add("line-numbers"); block.parentElement.classList.add("line-numbers");