This commit is contained in:
commit
0b1066c273
@ -44,7 +44,7 @@ internal sealed class BlogPost : IBlogPost
|
||||
public DateTimeOffset? Updated { get; internal set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public BlogPostVisibility Visibility { get; internal set; }
|
||||
public Visibility Visibility { get; internal set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public int? WordPressId { get; set; }
|
||||
|
@ -26,7 +26,7 @@ internal sealed class BlogPostConfiguration : IEntityTypeConfiguration<BlogPost>
|
||||
builder.Property(e => e.DisqusDomain).IsRequired(false);
|
||||
builder.Property(e => e.DisqusIdentifier).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.Tags).IsRequired()
|
||||
.HasConversion(
|
||||
|
@ -85,7 +85,7 @@ public interface IBlogPost
|
||||
/// Gets the visibility of the post.
|
||||
/// </summary>
|
||||
/// <value>The visibility of the post.</value>
|
||||
BlogPostVisibility Visibility { get; }
|
||||
Visibility Visibility { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the WordPress ID of the post.
|
||||
|
@ -1,10 +1,15 @@
|
||||
namespace OliverBooth.Data.Blog;
|
||||
namespace OliverBooth.Data;
|
||||
|
||||
/// <summary>
|
||||
/// An enumeration of the possible visibilities of a blog post.
|
||||
/// </summary>
|
||||
public enum BlogPostVisibility
|
||||
public enum Visibility
|
||||
{
|
||||
/// <summary>
|
||||
/// Used for filtering results. Represents all visibilities.
|
||||
/// </summary>
|
||||
None = -1,
|
||||
|
||||
/// <summary>
|
||||
/// The post is private and only visible to the author, or those with the password.
|
||||
/// </summary>
|
@ -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>>();
|
||||
}
|
||||
}
|
@ -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>>();
|
||||
}
|
||||
}
|
73
OliverBooth/Data/Web/ITutorialArticle.cs
Normal file
73
OliverBooth/Data/Web/ITutorialArticle.cs
Normal 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; }
|
||||
}
|
43
OliverBooth/Data/Web/ITutorialFolder.cs
Normal file
43
OliverBooth/Data/Web/ITutorialFolder.cs
Normal 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; }
|
||||
}
|
101
OliverBooth/Data/Web/TutorialArticle.cs
Normal file
101
OliverBooth/Data/Web/TutorialArticle.cs
Normal 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;
|
||||
}
|
||||
}
|
86
OliverBooth/Data/Web/TutorialFolder.cs
Normal file
86
OliverBooth/Data/Web/TutorialFolder.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
@page "/blog/{year:int}/{month:int}/{day:int}/{slug}"
|
||||
@using Humanizer
|
||||
@using OliverBooth.Data
|
||||
@using OliverBooth.Data.Blog
|
||||
@using OliverBooth.Services
|
||||
@inject IBlogPostService BlogPostService
|
||||
@ -44,13 +45,13 @@
|
||||
|
||||
@switch (post.Visibility)
|
||||
{
|
||||
case BlogPostVisibility.Private:
|
||||
case Visibility.Private:
|
||||
<div class="alert alert-danger" role="alert">
|
||||
This post is private and can only be viewed by those with the password.
|
||||
</div>
|
||||
break;
|
||||
|
||||
case BlogPostVisibility.Unlisted:
|
||||
case Visibility.Unlisted:
|
||||
<div class="alert alert-warning" role="alert">
|
||||
This post is unlisted and can only be viewed by those with the link.
|
||||
</div>
|
||||
|
92
OliverBooth/Pages/Tutorials/Article.cshtml
Normal file
92
OliverBooth/Pages/Tutorials/Article.cshtml
Normal 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>•</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>
|
41
OliverBooth/Pages/Tutorials/Article.cshtml.cs
Normal file
41
OliverBooth/Pages/Tutorials/Article.cshtml.cs
Normal 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();
|
||||
}
|
||||
}
|
@ -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";
|
||||
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, 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>
|
37
OliverBooth/Pages/Tutorials/Index.cshtml.cs
Normal file
37
OliverBooth/Pages/Tutorials/Index.cshtml.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -39,6 +39,7 @@ builder.Services.AddSingleton<IBlogPostService, BlogPostService>();
|
||||
builder.Services.AddSingleton<IBlogUserService, BlogUserService>();
|
||||
builder.Services.AddSingleton<IProjectService, ProjectService>();
|
||||
builder.Services.AddSingleton<IMastodonService, MastodonService>();
|
||||
builder.Services.AddSingleton<ITutorialService, TutorialService>();
|
||||
builder.Services.AddSingleton<IReadingListService, ReadingListService>();
|
||||
builder.Services.AddRazorPages().AddRazorRuntimeCompilation();
|
||||
builder.Services.AddControllersWithViews();
|
||||
|
@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis;
|
||||
using Humanizer;
|
||||
using Markdig;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OliverBooth.Data;
|
||||
using OliverBooth.Data.Blog;
|
||||
|
||||
namespace OliverBooth.Services;
|
||||
@ -44,7 +45,7 @@ internal sealed class BlogPostService : IBlogPostService
|
||||
{
|
||||
using BlogContext context = _dbContextFactory.CreateDbContext();
|
||||
IQueryable<BlogPost> ordered = context.BlogPosts
|
||||
.Where(p => p.Visibility == BlogPostVisibility.Published)
|
||||
.Where(p => p.Visibility == Visibility.Published)
|
||||
.OrderByDescending(post => post.Published);
|
||||
if (limit > -1)
|
||||
{
|
||||
@ -59,7 +60,7 @@ internal sealed class BlogPostService : IBlogPostService
|
||||
{
|
||||
using BlogContext context = _dbContextFactory.CreateDbContext();
|
||||
return context.BlogPosts
|
||||
.Where(p => p.Visibility == BlogPostVisibility.Published)
|
||||
.Where(p => p.Visibility == Visibility.Published)
|
||||
.OrderByDescending(post => post.Published)
|
||||
.Skip(page * pageSize)
|
||||
.Take(pageSize)
|
||||
@ -71,7 +72,7 @@ internal sealed class BlogPostService : IBlogPostService
|
||||
{
|
||||
using BlogContext context = _dbContextFactory.CreateDbContext();
|
||||
return context.BlogPosts
|
||||
.Where(p => p.Visibility == BlogPostVisibility.Published)
|
||||
.Where(p => p.Visibility == Visibility.Published)
|
||||
.OrderBy(post => post.Published)
|
||||
.FirstOrDefault(post => post.Published > blogPost.Published);
|
||||
}
|
||||
@ -81,7 +82,7 @@ internal sealed class BlogPostService : IBlogPostService
|
||||
{
|
||||
using BlogContext context = _dbContextFactory.CreateDbContext();
|
||||
return context.BlogPosts
|
||||
.Where(p => p.Visibility == BlogPostVisibility.Published)
|
||||
.Where(p => p.Visibility == Visibility.Published)
|
||||
.OrderByDescending(post => post.Published)
|
||||
.FirstOrDefault(post => post.Published < blogPost.Published);
|
||||
}
|
||||
|
91
OliverBooth/Services/ITutorialService.cs
Normal file
91
OliverBooth/Services/ITutorialService.cs
Normal 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);
|
||||
}
|
148
OliverBooth/Services/TutorialService.cs
Normal file
148
OliverBooth/Services/TutorialService.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -109,6 +109,11 @@ class UI {
|
||||
public static addLineNumbers(element?: Element) {
|
||||
element = element || document.body;
|
||||
element.querySelectorAll("pre code").forEach((block) => {
|
||||
if (block.className.indexOf("|nolinenumbers") > 0) {
|
||||
block.className = block.className.replaceAll("|nolinenumbers", "");
|
||||
return;
|
||||
}
|
||||
|
||||
let content = block.textContent;
|
||||
if (content.trim().split("\n").length > 1) {
|
||||
block.parentElement.classList.add("line-numbers");
|
||||
|
Loading…
Reference in New Issue
Block a user