diff --git a/OliverBooth/Data/Web/Configuration/TutorialArticleConfiguration.cs b/OliverBooth/Data/Web/Configuration/TutorialArticleConfiguration.cs
new file mode 100644
index 0000000..17f578c
--- /dev/null
+++ b/OliverBooth/Data/Web/Configuration/TutorialArticleConfiguration.cs
@@ -0,0 +1,27 @@
+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);
+ }
+}
diff --git a/OliverBooth/Data/Web/Configuration/TutorialFolderConfiguration.cs b/OliverBooth/Data/Web/Configuration/TutorialFolderConfiguration.cs
new file mode 100644
index 0000000..a925f63
--- /dev/null
+++ b/OliverBooth/Data/Web/Configuration/TutorialFolderConfiguration.cs
@@ -0,0 +1,23 @@
+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).IsRequired();
+ builder.Property(e => e.Title).IsRequired();
+ builder.Property(e => e.PreviewImageUrl).HasConversion();
+ }
+}
diff --git a/OliverBooth/Data/Web/ITutorialArticle.cs b/OliverBooth/Data/Web/ITutorialArticle.cs
new file mode 100644
index 0000000..a52ac3d
--- /dev/null
+++ b/OliverBooth/Data/Web/ITutorialArticle.cs
@@ -0,0 +1,67 @@
+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; }
+}
diff --git a/OliverBooth/Data/Web/ITutorialFolder.cs b/OliverBooth/Data/Web/ITutorialFolder.cs
new file mode 100644
index 0000000..7195c68
--- /dev/null
+++ b/OliverBooth/Data/Web/ITutorialFolder.cs
@@ -0,0 +1,37 @@
+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; }
+}
\ No newline at end of file
diff --git a/OliverBooth/Data/Web/TutorialArticle.cs b/OliverBooth/Data/Web/TutorialArticle.cs
new file mode 100644
index 0000000..0389473
--- /dev/null
+++ b/OliverBooth/Data/Web/TutorialArticle.cs
@@ -0,0 +1,98 @@
+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; }
+
+ ///
+ /// 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..372b960
--- /dev/null
+++ b/OliverBooth/Data/Web/TutorialFolder.cs
@@ -0,0 +1,83 @@
+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;
+
+ ///
+ /// 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;
+ }
+}
\ No newline at end of file
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/Tutorials/Article.cshtml b/OliverBooth/Pages/Tutorials/Article.cshtml
new file mode 100644
index 0000000..c5dd15c
--- /dev/null
+++ b/OliverBooth/Pages/Tutorials/Article.cshtml
@@ -0,0 +1,88 @@
+@page "/tutorial/{**slug}"
+@using Humanizer
+@using Microsoft.AspNetCore.Mvc.TagHelpers
+@using OliverBooth.Data.Blog
+@using OliverBooth.Data.Web
+@using OliverBooth.Services
+@inject ITutorialService TutorialService
+@model Article
+
+@if (Model.CurrentArticle is not { } article)
+{
+ return;
+}
+
+@{
+ ViewData["Post"] = article;
+ ViewData["Title"] = article.Title;
+ DateTimeOffset published = article.Published;
+}
+
+
+
+
\ 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..ab72749 100644
--- a/OliverBooth/Pages/Tutorials/Index.cshtml
+++ b/OliverBooth/Pages/Tutorials/Index.cshtml
@@ -1,23 +1,98 @@
-@page "/tutorials"
+@page "/tutorials/{**slug}"
+@using System.Text
+@using OliverBooth.Data.Web
+@using OliverBooth.Services
+@model Index
+@inject ITutorialService TutorialService
@{
ViewData["Title"] = "Tutorials";
+ ITutorialFolder? currentFolder = Model.CurrentFolder;
}
-
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).Chunk(2))
+ {
+
+ @foreach (ITutorialFolder folder in folders)
+ {
+