feat: add tutorials page

This commit is contained in:
Oliver Booth 2024-02-20 20:36:23 +00:00
parent f0aa1c0ae9
commit 9e0410f100
Signed by: oliverbooth
GPG Key ID: E60B570D1B7557B5
14 changed files with 834 additions and 17 deletions

View File

@ -0,0 +1,27 @@
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);
}
}

View File

@ -0,0 +1,23 @@
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).IsRequired();
builder.Property(e => e.Title).IsRequired();
builder.Property(e => e.PreviewImageUrl).HasConversion<UriToStringConverter>();
}
}

View File

@ -0,0 +1,67 @@
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; }
}

View File

@ -0,0 +1,37 @@
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; }
}

View File

@ -0,0 +1,98 @@
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; }
/// <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,83 @@
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;
/// <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>
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 />
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
@ -71,6 +83,8 @@ internal sealed class WebContext : DbContext
modelBuilder.ApplyConfiguration(new ProgrammingLanguageConfiguration());
modelBuilder.ApplyConfiguration(new ProjectConfiguration());
modelBuilder.ApplyConfiguration(new TemplateConfiguration());
modelBuilder.ApplyConfiguration(new TutorialArticleConfiguration());
modelBuilder.ApplyConfiguration(new TutorialFolderConfiguration());
modelBuilder.ApplyConfiguration(new SiteConfigurationConfiguration());
}
}

View File

@ -0,0 +1,88 @@
@page "/tutorial/{**slug}"
@using Humanizer
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using OliverBooth.Data.Blog
@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))
{
<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))
{
<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,98 @@
@page "/tutorials"
@page "/tutorials/{**slug}"
@using System.Text
@using OliverBooth.Data.Web
@using OliverBooth.Services
@model Index
@inject ITutorialService TutorialService
@{
ViewData["Title"] = "Tutorials";
ITutorialFolder? currentFolder = Model.CurrentFolder;
}
<main class="container">
<h1 class="display-4">Tutorials</h1>
<p class="lead">Coming Soon</p>
<p>
Due to Unity's poor corporate decision-making, I'm left in a position where I find it infeasible to write Unity
tutorials. I plan to write tutorials for things like Unreal and MonoGame as I learn them, and C# tutorials are
still on the table for sure. But tutorials take a lot of time and effort, so unfortunately it may be a while
before I can get around to publishing them.
</p>
<p>
However, in the meantime, I do have various blog posts that contain some tutorials and guides. You can find them
<a asp-page="/Blog/Index">here</a>!
</p>
<p>
I'm sorry for the inconvenience, but I hope you understand my position. Watch this space! New tutorials will be
coming. If you have any questions or requests, please feel free to <a asp-page="/Contact/Index">contact me</a>.
</p>
@if (currentFolder is not null)
{
<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 = currentFolder.Id;
while (parentId is not null)
{
ITutorialFolder thisFolder = TutorialService.GetFolder(parentId.Value)!;
<li class="breadcrumb-item active" aria-current="page">@thisFolder.Title</li>
parentId = thisFolder.Parent;
}
}
</ol>
</nav>
}
<h1 class="display-4">@(currentFolder?.Title ?? "Tutorials")</h1>
@foreach (ITutorialFolder[] folders in TutorialService.GetFolders(currentFolder).Chunk(2))
{
<div class="card-group row" style="margin-top: 20px;">
@foreach (ITutorialFolder folder in folders)
{
<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).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>

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

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

View File

@ -0,0 +1,87 @@
using System.Diagnostics.CodeAnalysis;
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>
/// <returns>A read-only view of the articles in the folder.</returns>
IReadOnlyCollection<ITutorialArticle> GetArticles(ITutorialFolder folder);
/// <summary>
/// Gets the tutorial folders within a specified folder.
/// </summary>
/// <returns>A read-only view of the subfolders in the folder.</returns>
IReadOnlyCollection<ITutorialFolder> GetFolders(ITutorialFolder? parent = null);
/// <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,139 @@
using System.Diagnostics.CodeAnalysis;
using Cysharp.Text;
using Markdig;
using Microsoft.EntityFrameworkCore;
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)
{
if (folder is null) throw new ArgumentNullException(nameof(folder));
using WebContext context = _dbContextFactory.CreateDbContext();
return context.TutorialArticles.Where(a => a.Folder == folder.Id).ToArray();
}
/// <inheritdoc />
public IReadOnlyCollection<ITutorialFolder> GetFolders(ITutorialFolder? parent = null)
{
using WebContext context = _dbContextFactory.CreateDbContext();
if (parent is null) return context.TutorialFolders.Where(f => f.Parent == null).ToArray();
return context.TutorialFolders.Where(a => a.Parent == parent.Id).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;
}
}