This commit is contained in:
commit
0b1066c273
@ -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; }
|
||||||
|
@ -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(
|
||||||
|
@ -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.
|
||||||
|
@ -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>
|
@ -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>
|
/// <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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
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";
|
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>
|
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<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();
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
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) {
|
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");
|
||||||
|
Loading…
Reference in New Issue
Block a user