Compare commits

...

4 Commits

14 changed files with 475 additions and 185 deletions

View File

@ -31,6 +31,33 @@ public class ContactController : Controller
return RedirectToPage("/Contact/Index");
}
[HttpPost("privacy-policy")]
public async Task<IActionResult> HandlePrivacyPolicy()
{
if (!Request.HasFormContentType)
{
return RedirectToPage("/Contact/Privacy");
}
IFormCollection form = Request.Form;
StringValues name = form["name"];
StringValues email = form["email"];
StringValues subject = form["subject"];
StringValues message = form["message"];
StringValues privacyPolicy = form["privacy-policy"];
await using SmtpSender sender = CreateSender();
await sender.WriteEmail
.To("Oliver Booth", _destination.GetValue<string>("PrivacyPolicy"))
.From(name, email)
.Subject($"[{privacyPolicy}] {subject}")
.BodyHtml(message)
.SendAsync();
TempData["Success"] = true;
return RedirectToPage("/Contact/Result");
}
[HttpPost("other")]
public async Task<IActionResult> HandleMiscellaneous()
{

View File

@ -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="Project" /> entity.
/// </summary>
internal sealed class ProjectConfiguration : IEntityTypeConfiguration<Project>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<Project> builder)
{
builder.ToTable("Project");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).IsRequired();
builder.Property(e => e.Rank).IsRequired();
builder.Property(e => e.Slug).IsRequired();
builder.Property(e => e.Name).IsRequired();
builder.Property(e => e.HeroUrl).IsRequired();
builder.Property(e => e.Description).IsRequired();
builder.Property(e => e.Status).HasConversion<EnumToStringConverter<ProjectStatus>>().IsRequired();
builder.Property(e => e.RemoteUrl);
builder.Property(e => e.RemoteTarget);
}
}

View File

@ -0,0 +1,61 @@
namespace OliverBooth.Data.Web;
/// <summary>
/// Represents a project.
/// </summary>
public interface IProject
{
/// <summary>
/// Gets the description of the project.
/// </summary>
/// <value>The description of the project.</value>
string Description { get; }
/// <summary>
/// Gets the URL of the hero image.
/// </summary>
/// <value>The URL of the hero image.</value>
string HeroUrl { get; }
/// <summary>
/// Gets the ID of the project.
/// </summary>
/// <value>The ID of the project.</value>
Guid Id { get; }
/// <summary>
/// Gets the name of the project.
/// </summary>
/// <value>The name of the project.</value>
string Name { get; }
/// <summary>
/// Gets the rank of the project.
/// </summary>
/// <value>The rank of the project.</value>
int Rank { get; }
/// <summary>
/// Gets the host of the project.
/// </summary>
/// <value>The host of the project.</value>
string? RemoteTarget { get; }
/// <summary>
/// Gets the URL of the project.
/// </summary>
/// <value>The URL of the project.</value>
string? RemoteUrl { get; }
/// <summary>
/// Gets the slug of the project.
/// </summary>
/// <value>The slug of the project.</value>
string Slug { get; }
/// <summary>
/// Gets the status of the project.
/// </summary>
/// <value>The status of the project.</value>
ProjectStatus Status { get; }
}

View File

@ -0,0 +1,95 @@
namespace OliverBooth.Data.Web;
/// <summary>
/// Represents a project.
/// </summary>
internal sealed class Project : IEquatable<Project>, IProject
{
/// <inheritdoc />
public string Description { get; private set; } = string.Empty;
/// <inheritdoc />
public string HeroUrl { get; private set; } = string.Empty;
/// <inheritdoc />
public Guid Id { get; private set; } = Guid.NewGuid();
/// <inheritdoc />
public string Name { get; private set; } = string.Empty;
/// <inheritdoc />
public int Rank { get; private set; }
/// <inheritdoc />
public string? RemoteTarget { get; private set; }
/// <inheritdoc />
public string? RemoteUrl { get; private set; }
/// <inheritdoc />
public string Slug { get; private set; } = string.Empty;
/// <inheritdoc />
public ProjectStatus Status { get; private set; } = ProjectStatus.Ongoing;
/// <summary>
/// Returns a value indicating whether two instances of <see cref="Project" /> are equal.
/// </summary>
/// <param name="left">The first instance of <see cref="Project" /> to compare.</param>
/// <param name="right">The second instance of <see cref="Project" /> 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 ==(Project? left, Project? right) => Equals(left, right);
/// <summary>
/// Returns a value indicating whether two instances of <see cref="Project" /> are not equal.
/// </summary>
/// <param name="left">The first instance of <see cref="Project" /> to compare.</param>
/// <param name="right">The second instance of <see cref="Project" /> 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 !=(Project? left, Project? right) => !(left == right);
/// <summary>
/// Returns a value indicating whether this instance of <see cref="Project" /> 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(Project? 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="Project" /> and equals the
/// value of this instance; otherwise, <see langword="false" />.
/// </returns>
public override bool Equals(object? obj)
{
return ReferenceEquals(this, obj) || obj is Project 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.GetHashCode();
}
}

View File

@ -0,0 +1,18 @@
using System.ComponentModel;
namespace OliverBooth.Data.Web;
/// <summary>
/// Represents the status of a project.
/// </summary>
public enum ProjectStatus
{
[Description("The project is currently being worked on.")]
Ongoing,
[Description("The project is on an indefinite hiatus.")]
Hiatus,
[Description("The project is no longer being worked on.")]
Past
}

View File

@ -19,6 +19,12 @@ internal sealed class WebContext : DbContext
_configuration = configuration;
}
/// <summary>
/// Gets the collection of projects in the database.
/// </summary>
/// <value>The collection of projects.</value>
public DbSet<Project> Projects { get; private set; } = null!;
/// <summary>
/// Gets the set of site configuration items.
/// </summary>
@ -41,6 +47,7 @@ internal sealed class WebContext : DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new ProjectConfiguration());
modelBuilder.ApplyConfiguration(new TemplateConfiguration());
modelBuilder.ApplyConfiguration(new SiteConfigurationConfiguration());
}

View File

@ -18,21 +18,21 @@
Please outline your concerns in the form below, and I will get back to you as soon as possible.
</p>
<form method="post">
<form method="post" asp-controller="Contact" asp-action="HandlePrivacyPolicy">
<input type="hidden" name="contact-type" value="privacy-policy">
<div class="form-group" style="margin-top: 10px;">
<label for="your-name">Your Name</label>
<input type="text" class="form-control" id="your-name" name="your-name" placeholder="Who are you?">
<label for="name">Your Name</label>
<input type="text" class="form-control" id="name" name="name" placeholder="Who are you?">
</div>
<div class="form-group" style="margin-top: 10px;">
<label for="your-email">Your Email Address</label>
<input type="email" class="form-control" id="your-email" name="your-email" placeholder="How can I reach you?">
<label for="email">Your Email Address</label>
<input type="email" class="form-control" id="email" name="email" placeholder="How can I reach you?">
</div>
<div class="form-group" style="margin-top: 10px;">
<label for="your-email">Privacy Policy</label>
<label for="privacy-policy">Privacy Policy</label>
<select name="privacy-policy" class="form-control" id="privacy-policy">
<option value="website" selected="@(Model.Which != "google-play")">This website's policy</option>
<option value="google-play" selected="@(Model.Which == "google-play")">Google Play policy</option>
@ -40,13 +40,13 @@
</div>
<div class="form-group" style="margin-top: 10px;">
<label for="position-title">Subject Matter</label>
<input type="text" class="form-control" id="position-title" name="position-title" placeholder="Describe your concerns here in a few words" maxlength="100">
<label for="subject">Subject</label>
<input type="text" class="form-control" id="subject" name="subject" placeholder="Describe your concerns here in a few words" maxlength="100">
</div>
<div class="form-group" style="margin-top: 10px;">
<label for="position-description">Description</label>
<textarea class="form-control" id="position-description" name="position-description" rows="5" required placeholder="Go into detail about the nature of your concerns."></textarea>
<label for="message">Description</label>
<textarea class="form-control" id="message" name="message" rows="5" required placeholder="Go into detail about the nature of your concerns."></textarea>
</div>
<button type="submit" class="btn btn-primary" style="margin-top: 10px;">Submit</button>

View File

@ -1,190 +1,105 @@
@page
@using OliverBooth.Data.Web
@using OliverBooth.Services
@inject IProjectService ProjectService
@{
ViewData["Title"] = "Projects";
}
<h1 class="display-4">Projects</h1>
<div class="card-group row">
<div class="col-xs-1 col-md-6 col-lg-6 d-flex align-items-stretch">
<div class="card border-success project-card">
<div class="card-header text-bg-success">In Active Development</div>
<img src="~/img/projects/hero/x10d-1280x640.png" class="card-img-top" alt="X10D">
<div class="card-body">
<h5 class="card-title">X10D</h5>
<p class="card-text">A <a href="https://nuget.org/packages/x10d">NuGet</a> offering dozens of extension methods for countless .NET types.</p>
<a href="https://github.com/oliverbooth/X10D" class="btn btn-primary">View on GitHub</a>
@foreach (IProject[] chunk in ProjectService.GetProjects(ProjectStatus.Ongoing).OrderBy(p => p.Rank).Chunk(2))
{
<div class="card-group row" style="margin-top: 20px;">
@foreach (IProject project in chunk)
{
<div class="col-xs-1 col-md-6 col-lg-6 d-flex align-items-stretch">
<div class="card border-success project-card">
<div class="card-header text-bg-success">In Active Development</div>
<img src="~/img/projects/hero/@project.HeroUrl" class="card-img-top" alt="@project.Name">
<div class="card-body">
<h5 class="card-title">@project.Name</h5>
<p class="card-text">@Html.Raw(ProjectService.GetDescription(project))</p>
@if (!string.IsNullOrWhiteSpace(project.RemoteUrl))
{
<a href="@project.RemoteUrl" class="btn btn-primary">
@if (string.IsNullOrWhiteSpace(project.RemoteTarget))
{
<span>View website</span>
}
else
{
<span>View on @project.RemoteTarget</span>
}
</a>
}
</div>
</div>
</div>
</div>
}
</div>
<div class="col-xs-1 col-md-6 col-lg-6 d-flex align-items-stretch">
<div class="card border-success project-card">
<div class="card-header text-bg-success">In Active Development</div>
<img src="~/img/projects/hero/brackeysbot-1280x640.png" class="card-img-top" alt="BrackeysBot">
<div class="card-body">
<h5 class="card-title">BrackeysBot</h5>
<p class="card-text">A collection of self-contained Discord bots that power the <a href="https://discord.gg/brackeys">Brackeys Community</a> Discord server.</p>
<a href="https://github.com/BrackeysBot" class="btn btn-primary">View on GitHub</a>
</div>
</div>
</div>
</div>
<div class="card-group row" style="margin-top: 20px;">
<div class="col-xs-1 col-md-6 col-lg-6 d-flex align-items-stretch">
<div class="card border-success project-card">
<div class="card-header text-bg-success">In Active Development</div>
<img src="~/img/projects/hero/none-1280x640.png" class="card-img-top" alt="Project KW">
<div class="card-body">
<h5 class="card-title">Project KW</h5>
<p class="card-text">A spiritual successor to the PlayStation cult classic Kula World (aka Roll Away).</p>
<p>No further information is available for this project at this time.</p>
</div>
</div>
</div>
</div>
}
<div class="card-group row" style="margin-top: 20px;">
<div class="col-xs-1 col-md-6 col-lg-6 d-flex align-items-stretch">
<div class="card border-info project-card">
<div class="card-header text-bg-info">Past Work</div>
<img src="~/img/projects/hero/fiveoclock-1280x640.png" class="card-img-top" alt="It's 5 O'Clock Somewhere">
<div class="card-body">
<h5 class="card-title">It's 5 O'Clock Somewhere</h5>
<p class="card-text">An alcoholic's best friend: an Android app that tells you where in the world it's socially acceptable to drink.</p>
<a href="https://play.google.com/store/apps/details?id=me.olivr.FiveOClockSomewhere" class="btn btn-primary">View on Play Store</a>
@foreach (IProject[] chunk in ProjectService.GetProjects(ProjectStatus.Past).Chunk(2))
{
<div class="card-group row" style="margin-top: 20px;">
@foreach (IProject project in chunk)
{
<div class="col-xs-1 col-md-6 col-lg-6 d-flex align-items-stretch">
<div class="card border-info project-card">
<div class="card-header text-bg-info">Past Work</div>
<img src="~/img/projects/hero/@project.HeroUrl" class="card-img-top" alt="@project.Name">
<div class="card-body">
<h5 class="card-title">@project.Name</h5>
<p class="card-text">@Html.Raw(ProjectService.GetDescription(project))</p>
@if (!string.IsNullOrWhiteSpace(project.RemoteUrl))
{
<a href="@project.RemoteUrl" class="btn btn-primary">
@if (string.IsNullOrWhiteSpace(project.RemoteTarget))
{
<span>View website</span>
}
else
{
<span>View on @project.RemoteTarget</span>
}
</a>
}
</div>
</div>
</div>
</div>
}
</div>
<div class="col-xs-1 col-md-6 col-lg-6 d-flex align-items-stretch">
<div class="card border-info project-card">
<div class="card-header text-bg-info">Past Work</div>
<img src="~/img/projects/hero/birthday-1280x640.png" class="card-img-top" alt="Is It My Birthday?">
<div class="card-body">
<h5 class="card-title">Is It My Birthday?</h5>
<p class="card-text">An Android app to tell you whether or not today is your birthday.</p>
<a href="https://play.google.com/store/apps/details?id=me.olivr.isitmybirthday" class="btn btn-primary">View on Play Store</a>
</div>
</div>
</div>
</div>
<div class="card-group row" style="margin-top: 20px;">
<div class="col-xs-1 col-md-6 col-lg-6 d-flex align-items-stretch">
<div class="card border-info project-card">
<div class="card-header text-bg-info">Past Work</div>
<img src="~/img/projects/hero/candyjam-1280x640.png" class="card-img-top" alt="Scrolling Candy Apple Saga">
<div class="card-body">
<h5 class="card-title">Scrolling Candy Apple Saga</h5>
<p class="card-text">My submission to the 2013 <a href="https://itch.io/jam/candyjam">Candy Jam</a>. Made with Unity.</p>
<a href="https://oliverbooth.itch.io/scrolling-candy-apple-saga" class="btn btn-primary">View on itch.io</a>
</div>
</div>
</div>
<div class="col-xs-1 col-md-6 col-lg-6 d-flex align-items-stretch">
<div class="card border-light project-card">
<div class="card-header text-bg-light">Retired</div>
<img src="~/img/projects/hero/sampdotnet-1280x640.png" class="card-img-top" alt="SAMP.NET">
<div class="card-body">
<h5 class="card-title">SAMP.NET</h5>
<p class="card-text">A .NET wrapper for the Pawn SAMP API, made during my time in college.</p>
<a href="https://github.com/oliverbooth/SAMP.NET" class="btn btn-primary">View on GitHub</a>
</div>
</div>
</div>
</div>
<div class="card-group row" style="margin-top: 20px;">
<div class="col-xs-1 col-md-6 col-lg-6 d-flex align-items-stretch">
<div class="card border-light project-card">
<div class="card-header text-bg-light">Retired</div>
<img src="~/img/projects/hero/none-1280x640.png" class="card-img-top" alt="SAMP.NET">
<div class="card-body">
<h5 class="card-title">HaloMCCForgePatcher</h5>
<p class="card-text">A tool to enable the Forge menu item in Halo: Master Chief Collection before it was officially released.</p>
<a href="https://github.com/oliverbooth/HaloMCCForgePatcher" class="btn btn-primary">View on GitHub</a>
</div>
</div>
</div>
<div class="col-xs-1 col-md-6 col-lg-6 d-flex align-items-stretch">
<div class="card border-light project-card">
<div class="card-header text-bg-light">Retired</div>
<img src="~/img/projects/hero/unitydocs-1280x640.png" class="card-img-top" alt="Unity API Docs">
<div class="card-body">
<h5 class="card-title">Unity API Docs</h5>
<p class="card-text">A complete rewrite and redesign of the Unity API docs, inspired by the C# and .NET documentation on Microsoft Learn.</p>
<p>This project was cancelled following Unity's pricing change announcement.</p>
</div>
</div>
</div>
</div>
}
<div class="card-group row" style="margin-top: 20px">
<div class="col-xs-1 col-md-6 col-lg-6 d-flex align-items-stretch">
<div class="card border-dark project-card">
<div class="card-header text-bg-dark">On Hiatus</div>
<img src="~/img/projects/hero/olive-1280x640.png" class="card-img-top" alt="Olive">
<div class="card-body">
<h5 class="card-title">Olive</h5>
<p class="card-text">A game engine written in C#, powered by MonoGame.</p>
<a href="https://github.com/olive-engine/olive" class="btn btn-primary">View on GitHub</a>
@foreach (IProject[] chunk in ProjectService.GetProjects(ProjectStatus.Hiatus).Chunk(2))
{
<div class="card-group row" style="margin-top: 20px;">
@foreach (IProject project in chunk)
{
<div class="col-xs-1 col-md-6 col-lg-6 d-flex align-items-stretch">
<div class="card border-dark project-card">
<div class="card-header text-bg-dark">On Hiatus</div>
<img src="~/img/projects/hero/@project.HeroUrl" class="card-img-top" alt="@project.Name">
<div class="card-body">
<h5 class="card-title">@project.Name</h5>
<p class="card-text">@Html.Raw(ProjectService.GetDescription(project))</p>
@if (!string.IsNullOrWhiteSpace(project.RemoteUrl))
{
<a href="@project.RemoteUrl" class="btn btn-primary">
@if (string.IsNullOrWhiteSpace(project.RemoteTarget))
{
<span>View website</span>
}
else
{
<span>View on @project.RemoteTarget</span>
}
</a>
}
</div>
</div>
</div>
</div>
}
</div>
<div class="col-xs-1 col-md-6 col-lg-6 d-flex align-items-stretch">
<div class="card border-dark project-card">
<div class="card-header text-bg-dark">On Hiatus</div>
<img src="~/img/projects/hero/none-1280x640.png" class="card-img-top" alt="TCP.NET">
<div class="card-body">
<h5 class="card-title">TCP.NET</h5>
<p class="card-text">A TCP library for .NET with support for RSA/AES encryption.</p>
<a href="https://github.com/oliverbooth/TcpDotNet" class="btn btn-primary">View on GitHub</a>
</div>
</div>
</div>
</div>
<div class="card-group row" style="margin-top: 20px;">
<div class="col-xs-1 col-md-6 col-lg-6 d-flex align-items-stretch">
<div class="card border-dark project-card">
<div class="card-header text-bg-dark">On Hiatus</div>
<img src="~/img/projects/hero/none-1280x640.png" class="card-img-top" alt="MelonSharp">
<div class="card-body">
<h5 class="card-title">MelonSharp</h5>
<p class="card-text">A vanilla Minecraft server written in C#.</p>
<p>No further information is available for this project at this time.</p>
</div>
</div>
</div>
<div class="col-xs-1 col-md-6 col-lg-6 d-flex align-items-stretch">
<div class="card border-dark project-card">
<div class="card-header text-bg-dark">On Hiatus</div>
<img src="~/img/projects/hero/syntaxgen-1280x640.png" class="card-img-top" alt="SyntaxGen.NET">
<div class="card-body">
<h5 class="card-title">SyntaxGen.NET</h5>
<p class="card-text">A NuGet to be used for documentation tools that can generate the declaration syntax for .NET types and their members.</p>
<a href="https://github.com/oliverbooth/dotnet-syntaxgen" class="btn btn-primary">View on GitHub</a>
</div>
</div>
</div>
</div>
<div class="card-group row" style="margin-top: 20px;">
<div class="col-xs-1 col-md-6 col-lg-6 d-flex align-items-stretch">
<div class="card border-dark project-card">
<div class="card-header text-bg-dark">On Hiatus</div>
<img src="~/img/projects/hero/mutation-1280x640.png" class="card-img-top" alt="Mutation">
<div class="card-body">
<h5 class="card-title">Mutation</h5>
<p class="card-text">A restoration of the classic AW RPG world, modernised for <a href="https://www.virtualparadise.org/">Virtual Paradise</a>.</p>
<a href="https://mutation3d.net" class="btn btn-primary">View website</a>
</div>
</div>
</div>
<div class="col-xs-1 col-md-6 col-lg-6 d-flex align-items-stretch">
<div class="card border-dark project-card">
<div class="card-header text-bg-dark">On Hiatus</div>
<img src="~/img/projects/hero/none-1280x640.png" class="card-img-top" alt="Olive">
<div class="card-body">
<h5 class="card-title">Astraeos</h5>
<p class="card-text">An open-universe sandbox game.</p>
<p>No further information is available for this project at this time.</p>
</div>
</div>
</div>
</div>
}

View File

@ -34,6 +34,7 @@ builder.Services.AddDbContextFactory<WebContext>();
builder.Services.AddSingleton<ITemplateService, TemplateService>();
builder.Services.AddSingleton<IBlogPostService, BlogPostService>();
builder.Services.AddSingleton<IBlogUserService, BlogUserService>();
builder.Services.AddSingleton<IProjectService, ProjectService>();
builder.Services.AddRazorPages().AddRazorRuntimeCompilation();
builder.Services.AddControllersWithViews();
builder.Services.AddRouting(options => options.LowercaseUrls = true);

View File

@ -0,0 +1,57 @@
using System.Diagnostics.CodeAnalysis;
using OliverBooth.Data.Web;
namespace OliverBooth.Services;
/// <summary>
/// Represents a service for interacting with projects.
/// </summary>
public interface IProjectService
{
/// <summary>
/// Gets the description of the specified project.
/// </summary>
/// <param name="project">The project whose description to get.</param>
/// <returns>The description of the specified project.</returns>
/// <exception cref="ArgumentNullException"><paramref name="project" /> is <see langword="null" />.</exception>
string GetDescription(IProject project);
/// <summary>
/// Gets all projects.
/// </summary>
/// <returns>A read-only list of projects.</returns>
IReadOnlyList<IProject> GetAllProjects();
/// <summary>
/// Gets all projects with the specified status.
/// </summary>
/// <param name="status">The status of the projects to get.</param>
/// <returns>A read-only list of projects with the specified status.</returns>
IReadOnlyList<IProject> GetProjects(ProjectStatus status = ProjectStatus.Ongoing);
/// <summary>
/// Attempts to find a project with the specified ID.
/// </summary>
/// <param name="id">The ID of the project.</param>
/// <param name="project">
/// When this method returns, contains the project associated with the specified ID, if the project is found;
/// otherwise, <see langword="null" />.
/// </param>
/// <returns>
/// <see langword="true" /> if a project with the specified ID is found; otherwise, <see langword="false" />.
/// </returns>
bool TryGetProject(Guid id, [NotNullWhen(true)] out IProject? project);
/// <summary>
/// Attempts to find a project with the specified slug.
/// </summary>
/// <param name="slug">The slug of the project.</param>
/// <param name="project">
/// When this method returns, contains the project associated with the specified slug, if the project is found;
/// otherwise, <see langword="null" />.
/// </param>
/// <returns>
/// <see langword="true" /> if a project with the specified slug is found; otherwise, <see langword="false" />.
/// </returns>
bool TryGetProject(string slug, [NotNullWhen(true)] out IProject? project);
}

View File

@ -0,0 +1,65 @@
using System.Diagnostics.CodeAnalysis;
using Markdig;
using Markdig.Parsers;
using Markdig.Renderers.Html;
using Markdig.Syntax;
using Microsoft.EntityFrameworkCore;
using OliverBooth.Data.Web;
namespace OliverBooth.Services;
/// <summary>
/// Represents a service for interacting with projects.
/// </summary>
internal sealed class ProjectService : IProjectService
{
private readonly IDbContextFactory<WebContext> _dbContextFactory;
private readonly MarkdownPipeline _markdownPipeline;
/// <summary>
/// Initializes a new instance of the <see cref="ProjectService" /> class.
/// </summary>
/// <param name="dbContextFactory">The database context factory.</param>
/// <param name="markdownPipeline">The Markdown pipeline.</param>
public ProjectService(IDbContextFactory<WebContext> dbContextFactory, MarkdownPipeline markdownPipeline)
{
_dbContextFactory = dbContextFactory;
_markdownPipeline = markdownPipeline;
}
/// <inheritdoc />
public string GetDescription(IProject project)
{
return Markdig.Markdown.ToHtml(project.Description, _markdownPipeline);
}
/// <inheritdoc />
public IReadOnlyList<IProject> GetAllProjects()
{
using WebContext context = _dbContextFactory.CreateDbContext();
return context.Projects.OrderBy(p => p.Rank).ThenBy(p => p.Name).ToArray();
}
/// <inheritdoc />
public IReadOnlyList<IProject> GetProjects(ProjectStatus status = ProjectStatus.Ongoing)
{
using WebContext context = _dbContextFactory.CreateDbContext();
return context.Projects.Where(p => p.Status == status).OrderBy(p => p.Rank).ThenBy(p => p.Name).ToArray();
}
/// <inheritdoc />
public bool TryGetProject(Guid id, [NotNullWhen(true)] out IProject? project)
{
using WebContext context = _dbContextFactory.CreateDbContext();
project = context.Projects.Find(id);
return project is not null;
}
/// <inheritdoc />
public bool TryGetProject(string slug, [NotNullWhen(true)] out IProject? project)
{
using WebContext context = _dbContextFactory.CreateDbContext();
project = context.Projects.FirstOrDefault(p => p.Slug == slug);
return project is not null;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

View File

@ -174,6 +174,14 @@ article {
}
}
.project-card {
transition: all 0.2s ease-in-out;
&:hover {
transform: scale(1.05);
}
}
.blog-card {
transition: all 0.2s ease-in-out;

View File

@ -79,6 +79,7 @@ class UI {
UI.renderSpoilers(element);
UI.renderTeX(element);
UI.renderTimestamps(element);
UI.updateProjectCards(element);
}
/**
@ -224,6 +225,13 @@ class UI {
block.innerHTML = content;
});
}
private static updateProjectCards(element?: Element) {
element = element || document.body;
element.querySelectorAll(".project-card .card-body p").forEach((p: HTMLParagraphElement) => {
p.classList.add("card-text");
});
}
}
export default UI;