diff --git a/OliverBooth/Data/Blog/BlogPost.cs b/OliverBooth/Data/Blog/BlogPost.cs
index cfb1110..05ece19 100644
--- a/OliverBooth/Data/Blog/BlogPost.cs
+++ b/OliverBooth/Data/Blog/BlogPost.cs
@@ -44,7 +44,7 @@ internal sealed class BlogPost : IBlogPost
public DateTimeOffset? Updated { get; internal set; }
///
- public BlogPostVisibility Visibility { get; internal set; }
+ public Visibility Visibility { get; internal set; }
///
public int? WordPressId { get; set; }
diff --git a/OliverBooth/Data/Blog/Configuration/BlogPostConfiguration.cs b/OliverBooth/Data/Blog/Configuration/BlogPostConfiguration.cs
index 7c89b2b..607f9d3 100644
--- a/OliverBooth/Data/Blog/Configuration/BlogPostConfiguration.cs
+++ b/OliverBooth/Data/Blog/Configuration/BlogPostConfiguration.cs
@@ -26,7 +26,7 @@ internal sealed class BlogPostConfiguration : IEntityTypeConfiguration
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()).IsRequired();
+ builder.Property(e => e.Visibility).HasConversion(new EnumToStringConverter()).IsRequired();
builder.Property(e => e.Password).HasMaxLength(255).IsRequired(false);
builder.Property(e => e.Tags).IsRequired()
.HasConversion(
diff --git a/OliverBooth/Data/Blog/IBlogPost.cs b/OliverBooth/Data/Blog/IBlogPost.cs
index a8b9818..d6e4e95 100644
--- a/OliverBooth/Data/Blog/IBlogPost.cs
+++ b/OliverBooth/Data/Blog/IBlogPost.cs
@@ -85,7 +85,7 @@ public interface IBlogPost
/// Gets the visibility of the post.
///
/// The visibility of the post.
- BlogPostVisibility Visibility { get; }
+ Visibility Visibility { get; }
///
/// Gets the WordPress ID of the post.
diff --git a/OliverBooth/Data/Blog/BlogPostVisibility.cs b/OliverBooth/Data/Visibility.cs
similarity index 73%
rename from OliverBooth/Data/Blog/BlogPostVisibility.cs
rename to OliverBooth/Data/Visibility.cs
index d8e982a..17b3e87 100644
--- a/OliverBooth/Data/Blog/BlogPostVisibility.cs
+++ b/OliverBooth/Data/Visibility.cs
@@ -1,10 +1,15 @@
-namespace OliverBooth.Data.Blog;
+namespace OliverBooth.Data;
///
/// An enumeration of the possible visibilities of a blog post.
///
-public enum BlogPostVisibility
+public enum Visibility
{
+ ///
+ /// Used for filtering results. Represents all visibilities.
+ ///
+ None = -1,
+
///
/// The post is private and only visible to the author, or those with the password.
///
diff --git a/OliverBooth/Data/Web/Configuration/TutorialArticleConfiguration.cs b/OliverBooth/Data/Web/Configuration/TutorialArticleConfiguration.cs
new file mode 100644
index 0000000..56924fd
--- /dev/null
+++ b/OliverBooth/Data/Web/Configuration/TutorialArticleConfiguration.cs
@@ -0,0 +1,28 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+namespace OliverBooth.Data.Web.Configuration;
+
+///
+/// Represents the configuration for the entity.
+///
+internal sealed class TutorialArticleConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder 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();
+ builder.Property(e => e.NextPart);
+ builder.Property(e => e.PreviousPart);
+ builder.Property(e => e.Visibility).HasConversion>();
+ }
+}
diff --git a/OliverBooth/Data/Web/Configuration/TutorialFolderConfiguration.cs b/OliverBooth/Data/Web/Configuration/TutorialFolderConfiguration.cs
new file mode 100644
index 0000000..af1eef2
--- /dev/null
+++ b/OliverBooth/Data/Web/Configuration/TutorialFolderConfiguration.cs
@@ -0,0 +1,24 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+namespace OliverBooth.Data.Web.Configuration;
+
+///
+/// Represents the configuration for the entity.
+///
+internal sealed class TutorialFolderConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder 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();
+ builder.Property(e => e.Visibility).HasConversion>();
+ }
+}
diff --git a/OliverBooth/Data/Web/ITutorialArticle.cs b/OliverBooth/Data/Web/ITutorialArticle.cs
new file mode 100644
index 0000000..c1be295
--- /dev/null
+++ b/OliverBooth/Data/Web/ITutorialArticle.cs
@@ -0,0 +1,73 @@
+namespace OliverBooth.Data.Web;
+
+///
+/// Represents a tutorial article.
+///
+public interface ITutorialArticle
+{
+ ///
+ /// Gets the body of this article.
+ ///
+ /// The body.
+ string Body { get; }
+
+ ///
+ /// Gets the ID of the folder this article is contained within.
+ ///
+ /// The ID of the folder.
+ int Folder { get; }
+
+ ///
+ /// Gets the ID of this article.
+ ///
+ /// The ID.
+ int Id { get; }
+
+ ///
+ /// Gets the ID of the next article to this one.
+ ///
+ /// The next part ID.
+ int? NextPart { get; }
+
+ ///
+ /// Gets the URL of the article's preview image.
+ ///
+ /// The preview image URL.
+ Uri? PreviewImageUrl { get; }
+
+ ///
+ /// Gets the ID of the previous article to this one.
+ ///
+ /// The previous part ID.
+ int? PreviousPart { get; }
+
+ ///
+ /// Gets the date and time at which this article was published.
+ ///
+ /// The publish timestamp.
+ DateTimeOffset Published { get; }
+
+ ///
+ /// Gets the slug of this article.
+ ///
+ /// The slug.
+ string Slug { get; }
+
+ ///
+ /// Gets the title of this article.
+ ///
+ /// The title.
+ string Title { get; }
+
+ ///
+ /// Gets the date and time at which this article was updated.
+ ///
+ /// The update timestamp, or if this article has not been updated.
+ DateTimeOffset? Updated { get; }
+
+ ///
+ /// Gets the visibility of this article.
+ ///
+ /// The visibility of the article.
+ Visibility Visibility { get; }
+}
diff --git a/OliverBooth/Data/Web/ITutorialFolder.cs b/OliverBooth/Data/Web/ITutorialFolder.cs
new file mode 100644
index 0000000..65a3cd2
--- /dev/null
+++ b/OliverBooth/Data/Web/ITutorialFolder.cs
@@ -0,0 +1,43 @@
+namespace OliverBooth.Data.Web;
+
+///
+/// Represents a folder for tutorial articles.
+///
+public interface ITutorialFolder
+{
+ ///
+ /// Gets the ID of this folder.
+ ///
+ /// The ID of the folder.
+ int Id { get; }
+
+ ///
+ /// Gets the ID of this folder's parent.
+ ///
+ /// The ID of the parent, or if this folder is at the root.
+ int? Parent { get; }
+
+ ///
+ /// Gets the URL of the folder's preview image.
+ ///
+ /// The preview image URL.
+ Uri? PreviewImageUrl { get; }
+
+ ///
+ /// Gets the slug of this folder.
+ ///
+ /// The slug.
+ string Slug { get; }
+
+ ///
+ /// Gets the title of this folder.
+ ///
+ /// The title.
+ string Title { get; }
+
+ ///
+ /// Gets the visibility of this article.
+ ///
+ /// The visibility of the article.
+ Visibility Visibility { get; }
+}
diff --git a/OliverBooth/Data/Web/TutorialArticle.cs b/OliverBooth/Data/Web/TutorialArticle.cs
new file mode 100644
index 0000000..1938e75
--- /dev/null
+++ b/OliverBooth/Data/Web/TutorialArticle.cs
@@ -0,0 +1,101 @@
+namespace OliverBooth.Data.Web;
+
+///
+/// Represents a tutorial article.
+///
+internal sealed class TutorialArticle : IEquatable, ITutorialArticle
+{
+ ///
+ public string Body { get; private set; } = string.Empty;
+
+ ///
+ public int Folder { get; private set; }
+
+ ///
+ public int Id { get; private set; }
+
+ ///
+ public int? NextPart { get; }
+
+ ///
+ public Uri? PreviewImageUrl { get; private set; }
+
+ ///
+ public int? PreviousPart { get; private set; }
+
+ ///
+ public DateTimeOffset Published { get; private set; }
+
+ ///
+ public string Slug { get; private set; } = string.Empty;
+
+ ///
+ public string Title { get; private set; } = string.Empty;
+
+ ///
+ public DateTimeOffset? Updated { get; private set; }
+
+ ///
+ public Visibility Visibility { get; private set; }
+
+ ///
+ /// Returns a value indicating whether two instances of are equal.
+ ///
+ /// The first instance of to compare.
+ /// The second instance of to compare.
+ ///
+ /// if and are equal; otherwise,
+ /// .
+ ///
+ public static bool operator ==(TutorialArticle? left, TutorialArticle? right) => Equals(left, right);
+
+ ///
+ /// Returns a value indicating whether two instances of are not equal.
+ ///
+ /// The first instance of to compare.
+ /// The second instance of to compare.
+ ///
+ /// if and are not equal; otherwise,
+ /// .
+ ///
+ public static bool operator !=(TutorialArticle? left, TutorialArticle? right) => !(left == right);
+
+ ///
+ /// Returns a value indicating whether this instance of is equal to another
+ /// instance.
+ ///
+ /// An instance to compare with this instance.
+ ///
+ /// if is equal to this instance; otherwise,
+ /// .
+ ///
+ public bool Equals(TutorialArticle? other)
+ {
+ if (ReferenceEquals(null, other)) return false;
+ if (ReferenceEquals(this, other)) return true;
+ return Id.Equals(other.Id);
+ }
+
+ ///
+ /// Returns a value indicating whether this instance is equal to a specified object.
+ ///
+ /// An object to compare with this instance.
+ ///
+ /// if is an instance of and
+ /// equals the value of this instance; otherwise, .
+ ///
+ public override bool Equals(object? obj)
+ {
+ return ReferenceEquals(this, obj) || obj is TutorialArticle other && Equals(other);
+ }
+
+ ///
+ /// Gets the hash code for this instance.
+ ///
+ /// The hash code.
+ public override int GetHashCode()
+ {
+ // ReSharper disable once NonReadonlyMemberInGetHashCode
+ return Id;
+ }
+}
diff --git a/OliverBooth/Data/Web/TutorialFolder.cs b/OliverBooth/Data/Web/TutorialFolder.cs
new file mode 100644
index 0000000..26c5bc1
--- /dev/null
+++ b/OliverBooth/Data/Web/TutorialFolder.cs
@@ -0,0 +1,86 @@
+namespace OliverBooth.Data.Web;
+
+///
+/// Represents a folder for tutorial articles.
+///
+internal sealed class TutorialFolder : IEquatable, ITutorialFolder
+{
+ ///
+ public int Id { get; private set; }
+
+ ///
+ public int? Parent { get; private set; }
+
+ ///
+ public Uri? PreviewImageUrl { get; private set; }
+
+ ///
+ public string Slug { get; private set; } = string.Empty;
+
+ ///
+ public string Title { get; private set; } = string.Empty;
+
+ ///
+ public Visibility Visibility { get; private set; }
+
+ ///
+ /// Returns a value indicating whether two instances of are equal.
+ ///
+ /// The first instance of to compare.
+ /// The second instance of to compare.
+ ///
+ /// if and are equal; otherwise,
+ /// .
+ ///
+ public static bool operator ==(TutorialFolder? left, TutorialFolder? right) => Equals(left, right);
+
+ ///
+ /// Returns a value indicating whether two instances of are not equal.
+ ///
+ /// The first instance of to compare.
+ /// The second instance of to compare.
+ ///
+ /// if and are not equal; otherwise,
+ /// .
+ ///
+ public static bool operator !=(TutorialFolder? left, TutorialFolder? right) => !(left == right);
+
+ ///
+ /// Returns a value indicating whether this instance of is equal to another
+ /// instance.
+ ///
+ /// An instance to compare with this instance.
+ ///
+ /// if is equal to this instance; otherwise,
+ /// .
+ ///
+ public bool Equals(TutorialFolder? other)
+ {
+ if (ReferenceEquals(null, other)) return false;
+ if (ReferenceEquals(this, other)) return true;
+ return Id.Equals(other.Id);
+ }
+
+ ///
+ /// Returns a value indicating whether this instance is equal to a specified object.
+ ///
+ /// An object to compare with this instance.
+ ///
+ /// if is an instance of and
+ /// equals the value of this instance; otherwise, .
+ ///
+ public override bool Equals(object? obj)
+ {
+ return ReferenceEquals(this, obj) || obj is TutorialFolder other && Equals(other);
+ }
+
+ ///
+ /// Gets the hash code for this instance.
+ ///
+ /// The hash code.
+ public override int GetHashCode()
+ {
+ // ReSharper disable once NonReadonlyMemberInGetHashCode
+ return Id;
+ }
+}
diff --git a/OliverBooth/Data/Web/WebContext.cs b/OliverBooth/Data/Web/WebContext.cs
index d5b74e4..8d43e43 100644
--- a/OliverBooth/Data/Web/WebContext.cs
+++ b/OliverBooth/Data/Web/WebContext.cs
@@ -55,6 +55,18 @@ internal sealed class WebContext : DbContext
/// The collection of templates.
public DbSet Templates { get; private set; } = null!;
+ ///
+ /// Gets the collection of tutorial articles in the database.
+ ///
+ /// The collection of tutorial articles.
+ public DbSet TutorialArticles { get; private set; } = null!;
+
+ ///
+ /// Gets the collection of tutorial folders in the database.
+ ///
+ /// The collection of tutorial folders.
+ public DbSet TutorialFolders { get; private set; } = null!;
+
///
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());
}
}
diff --git a/OliverBooth/Pages/Blog/Article.cshtml b/OliverBooth/Pages/Blog/Article.cshtml
index 9bb648c..5e32ba8 100644
--- a/OliverBooth/Pages/Blog/Article.cshtml
+++ b/OliverBooth/Pages/Blog/Article.cshtml
@@ -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:
This post is private and can only be viewed by those with the password.
break;
- case BlogPostVisibility.Unlisted:
+ case Visibility.Unlisted:
This post is unlisted and can only be viewed by those with the link.
\ No newline at end of file
diff --git a/OliverBooth/Pages/Tutorials/Article.cshtml.cs b/OliverBooth/Pages/Tutorials/Article.cshtml.cs
new file mode 100644
index 0000000..7f5f6c7
--- /dev/null
+++ b/OliverBooth/Pages/Tutorials/Article.cshtml.cs
@@ -0,0 +1,41 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using OliverBooth.Data.Web;
+using OliverBooth.Services;
+
+namespace OliverBooth.Pages.Tutorials;
+
+///
+/// Represents the page model for the Article page.
+///
+public class Article : PageModel
+{
+ private readonly ITutorialService _tutorialService;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The .
+ public Article(ITutorialService tutorialService)
+ {
+ _tutorialService = tutorialService;
+ }
+
+ ///
+ /// Gets the requested article.
+ ///
+ /// The requested article.
+ 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();
+ }
+}
diff --git a/OliverBooth/Pages/Tutorials/Index.cshtml b/OliverBooth/Pages/Tutorials/Index.cshtml
index f865f49..aaa2346 100644
--- a/OliverBooth/Pages/Tutorials/Index.cshtml
+++ b/OliverBooth/Pages/Tutorials/Index.cshtml
@@ -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;
}
-
Tutorials
-
Coming Soon
-
- 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.
-
-
- However, in the meantime, I do have various blog posts that contain some tutorials and guides. You can find them
- here!
-
-
- 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 contact me.
-
+ @if (currentFolder is not null)
+ {
+
+ }
+
+
@(currentFolder?.Title ?? "Tutorials")
+ @foreach (ITutorialFolder[] folders in TutorialService.GetFolders(currentFolder, Visibility.Published).Chunk(2))
+ {
+