style: redesign projects page

This change separates the project logo and details into separate pages and makes the list more visually striking.
This commit is contained in:
Oliver Booth 2023-12-24 12:20:03 +00:00
parent fdce8c3cff
commit cbfdefae71
Signed by: oliverbooth
GPG Key ID: E60B570D1B7557B5
17 changed files with 518 additions and 86 deletions

View File

@ -20,6 +20,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scss", "scss", "{822F528E-3
src\scss\app.scss = src\scss\app.scss
src\scss\prism.vs.scss = src\scss\prism.vs.scss
src\scss\prism.css = src\scss\prism.css
src\scss\ribbon.scss = src\scss\ribbon.scss
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ts", "ts", "{BB9F76AC-292A-4F47-809D-8BBBA6E0A048}"

View File

@ -0,0 +1,12 @@
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace OliverBooth.Data;
internal sealed class StringListConverter : ValueConverter<IReadOnlyList<string>, string>
{
public StringListConverter(char separator = ' ') :
base(v => string.Join(separator, v),
s => s.Split(separator, StringSplitOptions.None))
{
}
}

View File

@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace OliverBooth.Data.Web.Configuration;
/// <summary>
/// Represents the configuration for the <see cref="ProgrammingLanguage" /> entity.
/// </summary>
internal sealed class ProgrammingLanguageConfiguration : IEntityTypeConfiguration<ProgrammingLanguage>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<ProgrammingLanguage> builder)
{
builder.ToTable("ProgrammingLanguage");
builder.HasKey(e => e.Key);
builder.Property(e => e.Key).IsRequired();
builder.Property(e => e.Name).IsRequired();
}
}

View File

@ -21,8 +21,13 @@ internal sealed class ProjectConfiguration : IEntityTypeConfiguration<Project>
builder.Property(e => e.Name).IsRequired();
builder.Property(e => e.HeroUrl).IsRequired();
builder.Property(e => e.Description).IsRequired();
builder.Property(e => e.Details).IsRequired();
builder.Property(e => e.Status).HasConversion<EnumToStringConverter<ProjectStatus>>().IsRequired();
builder.Property(e => e.RemoteUrl);
builder.Property(e => e.RemoteTarget);
builder.Property(e => e.Tagline);
builder.Property(e => e.Languages).HasConversion(
v => string.Join(' ', v),
s => s.Split(' ', StringSplitOptions.None));
}
}

View File

@ -0,0 +1,20 @@
namespace OliverBooth.Data.Web;
/// <summary>
/// Represents a programming language.
/// </summary>
public interface IProgrammingLanguage
{
/// <summary>
/// Gets the unique key for this programming language.
/// </summary>
/// <value>The unique key.</value>
/// <remarks>This is generally the file extension of the language.</remarks>
string Key { get; }
/// <summary>
/// Gets the name of this programming language.
/// </summary>
/// <value>The name.</value>
string Name { get; }
}

View File

@ -11,6 +11,12 @@ public interface IProject
/// <value>The description of the project.</value>
string Description { get; }
/// <summary>
/// Gets the details of the project.
/// </summary>
/// <value>The details.</value>
string Details { get; }
/// <summary>
/// Gets the URL of the hero image.
/// </summary>
@ -23,6 +29,12 @@ public interface IProject
/// <value>The ID of the project.</value>
Guid Id { get; }
/// <summary>
/// Gets the set of languages used for this project.
/// </summary>
/// <value>The languages.</value>
IReadOnlyList<string> Languages { get; }
/// <summary>
/// Gets the name of the project.
/// </summary>
@ -58,4 +70,10 @@ public interface IProject
/// </summary>
/// <value>The status of the project.</value>
ProjectStatus Status { get; }
/// <summary>
/// Gets the tagline of the project.
/// </summary>
/// <value>The tagline.</value>
string? Tagline { get; }
}

View File

@ -0,0 +1,72 @@
namespace OliverBooth.Data.Web;
/// <inheritdoc cref="IProgrammingLanguage" />
internal sealed class ProgrammingLanguage : IEquatable<ProgrammingLanguage>, IProgrammingLanguage
{
/// <inheritdoc />
public string Key { get; } = string.Empty;
/// <inheritdoc />
public string Name { get; internal set; } = string.Empty;
/// <summary>
/// Returns a value indicating whether two instances of <see cref="ProgrammingLanguage" /> are equal.
/// </summary>
/// <param name="left">The first instance of <see cref="ProgrammingLanguage" /> to compare.</param>
/// <param name="right">The second instance of <see cref="ProgrammingLanguage" /> 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 ==(ProgrammingLanguage? left, ProgrammingLanguage? right) => Equals(left, right);
/// <summary>
/// Returns a value indicating whether two instances of <see cref="ProgrammingLanguage" /> are not equal.
/// </summary>
/// <param name="left">The first instance of <see cref="ProgrammingLanguage" /> to compare.</param>
/// <param name="right">The second instance of <see cref="ProgrammingLanguage" /> 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 !=(ProgrammingLanguage? left, ProgrammingLanguage? right) => !(left == right);
/// <summary>
/// Returns a value indicating whether this instance of <see cref="ProgrammingLanguage" /> 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(ProgrammingLanguage? other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return Key.Equals(other.Key);
}
/// <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="ProgrammingLanguage" /> and
/// equals the value of this instance; otherwise, <see langword="false" />.
/// </returns>
public override bool Equals(object? obj)
{
return ReferenceEquals(this, obj) || obj is ProgrammingLanguage 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 Key.GetHashCode();
}
}

View File

@ -8,12 +8,18 @@ internal sealed class Project : IEquatable<Project>, IProject
/// <inheritdoc />
public string Description { get; private set; } = string.Empty;
/// <inheritdoc />
public string Details { 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 IReadOnlyList<string> Languages { get; private set; } = ArraySegment<string>.Empty;
/// <inheritdoc />
public string Name { get; private set; } = string.Empty;
@ -32,6 +38,9 @@ internal sealed class Project : IEquatable<Project>, IProject
/// <inheritdoc />
public ProjectStatus Status { get; private set; } = ProjectStatus.Ongoing;
/// <inheritdoc />
public string? Tagline { get; private set; }
/// <summary>
/// Returns a value indicating whether two instances of <see cref="Project" /> are equal.
/// </summary>

View File

@ -31,6 +31,12 @@ internal sealed class WebContext : DbContext
/// <value>The collection of blacklist entries.</value>
public DbSet<BlacklistEntry> ContactBlacklist { get; private set; } = null!;
/// <summary>
/// Gets the collection of programming languages in the database.
/// </summary>
/// <value>The collection of programming languages.</value>
public DbSet<ProgrammingLanguage> ProgrammingLanguages { get; private set; } = null!;
/// <summary>
/// Gets the collection of projects in the database.
/// </summary>
@ -57,10 +63,12 @@ internal sealed class WebContext : DbContext
optionsBuilder.UseMySql(connectionString, serverVersion);
}
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new BlacklistEntryConfiguration());
modelBuilder.ApplyConfiguration(new BookConfiguration());
modelBuilder.ApplyConfiguration(new ProgrammingLanguageConfiguration());
modelBuilder.ApplyConfiguration(new ProjectConfiguration());
modelBuilder.ApplyConfiguration(new TemplateConfiguration());
modelBuilder.ApplyConfiguration(new SiteConfigurationConfiguration());

View File

@ -4,102 +4,63 @@
@inject IProjectService ProjectService
@{
ViewData["Title"] = "Projects";
IEnumerable<IProject> projects = ProjectService.GetProjects(ProjectStatus.Ongoing).OrderBy(p => p.Rank)
.Concat(ProjectService.GetProjects(ProjectStatus.Past).OrderBy(p => p.Rank))
.Concat(ProjectService.GetProjects(ProjectStatus.Hiatus).OrderBy(p => p.Rank));
}
<main class="container">
<h1 class="display-4">Projects</h1>
@foreach (IProject[] chunk in ProjectService.GetProjects(ProjectStatus.Ongoing).OrderBy(p => p.Rank).Chunk(2))
@foreach (IProject[] chunk in projects.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="https://cdn.olivr.me/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>
}
@switch (project.Status)
{
case ProjectStatus.Ongoing:
<div class="project-card">
<div class="ribbon ribbon-top-left green">
<a asp-page="Project" asp-route-slug="@project.Slug">
<span>Ongoing</span>
</a>
</div>
<a asp-page="Project" asp-route-slug="@project.Slug">
<img src="https://cdn.olivr.me/projects/hero/@project.HeroUrl" class="card-img-top" alt="@project.Name">
</a>
}
</div>
</div>
</div>
}
</div>
}
</div>
break;
@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="https://cdn.olivr.me/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>
}
case ProjectStatus.Past:
<div class="border-info project-card">
<div class="ribbon ribbon-top-left blue">
<a asp-page="Project" asp-route-slug="@project.Slug">
<span>Past Work</span>
</a>
</div>
<a asp-page="Project" asp-route-slug="@project.Slug">
<img src="https://cdn.olivr.me/projects/hero/@project.HeroUrl" class="card-img-top" alt="@project.Name">
</a>
}
</div>
</div>
</div>
}
</div>
}
</div>
break;
@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="https://cdn.olivr.me/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>
}
case ProjectStatus.Hiatus:
<div class="border-danger project-card">
<div class="ribbon ribbon-top-left red">
<a asp-page="Project" asp-route-slug="@project.Slug">
<span>On Hiatus</span>
</a>
</div>
<a asp-page="Project" asp-route-slug="@project.Slug">
<img src="https://cdn.olivr.me/projects/hero/@project.HeroUrl" class="card-img-top" alt="@project.Name">
</a>
}
</div>
</div>
</div>
break;
}
</div>
}
</div>

View File

@ -0,0 +1,84 @@
@page "/project/{slug}"
@using Markdig
@using OliverBooth.Data.Web
@using OliverBooth.Services
@model Project
@inject IProjectService ProjectService
@inject MarkdownPipeline MarkdownPipeline
@{
ViewData["Title"] = "Projects";
}
<main class="container">
@if (Model.SelectedProject is not { } project)
{
<h1>Project Not Found</h1>
return;
}
<nav style="--bs-breadcrumb-divider: '>';" aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a asp-page="/Projects/Index">Projects</a>
</li>
<li class="breadcrumb-item active" aria-current="page">@project.Name</li>
</ol>
</nav>
<h1>@project.Name</h1>
@if (!string.IsNullOrWhiteSpace(project.Tagline))
{
<p class="lead">@project.Tagline</p>
}
<p class="text-center">
<img src="https://cdn.olivr.me/projects/hero/@project.HeroUrl" class="img-fluid" alt="@project.Name">
</p>
<table class="table">
<tr>
<th style="width: 20%">Languages</th>
<td>
@foreach (IProgrammingLanguage language in ProjectService.GetProgrammingLanguages(project))
{
<img src="https://cdn.olivr.me/img/assets/p/@(language.Key).svg" alt="@language.Name" title="@language.Name" style="height: 2em">
}
</td>
</tr>
<tr>
<th>Status</th>
<td>
@switch (project.Status)
{
case ProjectStatus.Ongoing:
<span class="badge rounded-pill text-bg-success">In Active Development</span>
break;
case ProjectStatus.Past:
<span class="badge rounded-pill text-bg-primary">Completed / Retired</span>
break;
case ProjectStatus.Hiatus:
<span class="badge rounded-pill text-bg-danger">On Hiatus</span>
break;
}
</td>
</tr>
@if (project.RemoteUrl != null)
{
<tr>
<th>View</th>
<td>
<a href="@project.RemoteUrl">
@(new Uri(project.RemoteUrl).Host) <i class="fa-solid fa-arrow-up-right-from-square"></i>
</a>
</td>
</tr>
}
<tr>
<th>Details</th>
<td class="trim-p">@Html.Raw(Markdown.ToHtml(project.Details, MarkdownPipeline))</td>
</tr>
</table>
</main>

View File

@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
using OliverBooth.Data.Web;
using OliverBooth.Services;
namespace OliverBooth.Pages.Projects;
public class Project : PageModel
{
private readonly IProjectService _projectService;
public Project(IProjectService projectService)
{
_projectService = projectService;
}
public IProject? SelectedProject { get; private set; }
public void OnGet(string slug)
{
if (_projectService.TryGetProject(slug, out IProject? project))
{
SelectedProject = project;
}
}
}

View File

@ -59,6 +59,7 @@
<link rel="stylesheet" href="~/css/prism.min.css" asp-append-version="true">
<link rel="stylesheet" href="~/css/prism.vs.min.css" asp-append-version="true">
<link rel="stylesheet" href="~/css/app.min.css" asp-append-version="true">
<link rel="stylesheet" href="~/css/ribbon.min.css" asp-append-version="true">
</head>
<body>
<header class="container" style="margin-top: 20px;">

View File

@ -22,6 +22,13 @@ public interface IProjectService
/// <returns>A read-only list of projects.</returns>
IReadOnlyList<IProject> GetAllProjects();
/// <summary>
/// Gets the programming languages used in the specified project.
/// </summary>
/// <param name="project">The project whose languages to return.</param>
/// <returns>A read only view of the languages.</returns>
IReadOnlyList<IProgrammingLanguage> GetProgrammingLanguages(IProject project);
/// <summary>
/// Gets all projects with the specified status.
/// </summary>

View File

@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using Humanizer;
using Markdig;
using Microsoft.EntityFrameworkCore;
using OliverBooth.Data.Web;
@ -37,6 +38,15 @@ internal sealed class ProjectService : IProjectService
return context.Projects.OrderBy(p => p.Rank).ThenBy(p => p.Name).ToArray();
}
/// <inheritdoc />
public IReadOnlyList<IProgrammingLanguage> GetProgrammingLanguages(IProject project)
{
using WebContext context = _dbContextFactory.CreateDbContext();
return project.Languages
.Select(l => context.ProgrammingLanguages.Find(l) ?? new ProgrammingLanguage { Name = l.Titleize() })
.ToArray();
}
/// <inheritdoc />
public IReadOnlyList<IProject> GetProjects(ProjectStatus status = ProjectStatus.Ongoing)
{

View File

@ -179,11 +179,9 @@ article {
}
.project-card {
transition: all 0.2s ease-in-out;
&:hover {
transform: scale(1.05);
}
position: relative;
background: #000;
box-shadow: 0 0 15px rgba(0, 0, 0, .1);
}
.blog-card {
@ -315,4 +313,8 @@ table.reading-list {
.book-cover {
width: 50px;
vertical-align: middle;
}
td.trim-p p:last-child {
margin-bottom: 0;
}

177
src/scss/ribbon.scss Normal file
View File

@ -0,0 +1,177 @@
.ribbon {
width: 150px;
height: 150px;
overflow: hidden;
position: absolute;
&::before, &::after {
position: absolute;
z-index: -1;
content: '';
display: block;
}
span {
position: absolute;
display: block;
width: 225px;
padding: 15px 0;
box-shadow: 0 5px 10px rgba(0, 0, 0, .1);
font: 700 18px/1 'Lato', sans-serif;
text-shadow: 0 1px 1px rgba(0, 0, 0, .2);
text-transform: uppercase;
text-align: center;
}
&.ribbon-top-left {
top: -10px;
left: -10px;
&::before, &::after {
border-top-color: transparent;
border-left-color: transparent;
}
&::before {
top: 0;
right: 0;
}
&::after {
bottom: 0;
left: 0;
}
span {
right: -25px;
top: 30px;
transform: rotate(-45deg);
}
}
&.ribbon-top-right {
top: -10px;
right: -10px;
&::before, &::after {
border-top-color: transparent;
border-right-color: transparent;
}
&::before {
top: 0;
left: 0;
}
&::after {
bottom: 0;
right: 0;
}
span {
left: -25px;
top: 30px;
transform: rotate(45deg);
}
}
&.ribbon-bottom-left {
bottom: -10px;
left: -10px;
&::before, &::after {
border-bottom-color: transparent;
border-left-color: transparent;
}
&::before {
bottom: 0;
right: 0;
}
&::after {
top: 0;
left: 0;
}
span {
right: -25px;
bottom: 30px;
transform: rotate(225deg);
}
}
&.ribbon-bottom-right {
bottom: -10px;
right: -10px;
&::before, &::after {
border-bottom-color: transparent;
border-right-color: transparent;
}
&::before {
bottom: 0;
left: 0;
}
&::after {
top: 0;
right: 0;
}
span {
left: -25px;
bottom: 30px;
transform: rotate(-225deg);
}
}
&.red {
$bg: #db3434;
$fg: #fff;
&::before, &::after {
border: 5px solid darken($bg, 20%);
}
span {
background-color: $bg;
color: $fg;
}
}
&.green {
$bg: #34db34;
$fg: #fff;
&::before, &::after {
border: 5px solid darken($bg, 20%);
}
span {
background-color: $bg;
color: $fg;
}
}
&.blue {
$bg: #3498db;
$fg: #fff;
&::before, &::after {
border: 5px solid darken($bg, 20%);
}
span {
background-color: $bg;
color: $fg;
}
}
}
.project-card .ribbon {
transform: scale(75%);
left: -26px;
top: -26px;
}