From cbfdefae718e7cb81a5fa769eb2e344d346cdbff Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Sun, 24 Dec 2023 12:20:03 +0000 Subject: [PATCH] style: redesign projects page This change separates the project logo and details into separate pages and makes the list more visually striking. --- OliverBooth.sln | 1 + OliverBooth/Data/StringListConverter.cs | 12 ++ .../ProgrammingLanguageConfiguration.cs | 20 ++ .../Web/Configuration/ProjectConfiguration.cs | 5 + OliverBooth/Data/Web/IProgrammingLanguage.cs | 20 ++ OliverBooth/Data/Web/IProject.cs | 18 ++ OliverBooth/Data/Web/ProgrammingLanguage.cs | 72 +++++++ OliverBooth/Data/Web/Project.cs | 9 + OliverBooth/Data/Web/WebContext.cs | 8 + OliverBooth/Pages/Projects/Index.cshtml | 123 +++++------- OliverBooth/Pages/Projects/Project.cshtml | 84 +++++++++ OliverBooth/Pages/Projects/Project.cshtml.cs | 25 +++ OliverBooth/Pages/Shared/_Layout.cshtml | 1 + OliverBooth/Services/IProjectService.cs | 7 + OliverBooth/Services/ProjectService.cs | 10 + src/scss/app.scss | 12 +- src/scss/ribbon.scss | 177 ++++++++++++++++++ 17 files changed, 518 insertions(+), 86 deletions(-) create mode 100644 OliverBooth/Data/StringListConverter.cs create mode 100644 OliverBooth/Data/Web/Configuration/ProgrammingLanguageConfiguration.cs create mode 100644 OliverBooth/Data/Web/IProgrammingLanguage.cs create mode 100644 OliverBooth/Data/Web/ProgrammingLanguage.cs create mode 100644 OliverBooth/Pages/Projects/Project.cshtml create mode 100644 OliverBooth/Pages/Projects/Project.cshtml.cs create mode 100644 src/scss/ribbon.scss diff --git a/OliverBooth.sln b/OliverBooth.sln index 777c92b..cb5bf3b 100644 --- a/OliverBooth.sln +++ b/OliverBooth.sln @@ -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}" diff --git a/OliverBooth/Data/StringListConverter.cs b/OliverBooth/Data/StringListConverter.cs new file mode 100644 index 0000000..7d63526 --- /dev/null +++ b/OliverBooth/Data/StringListConverter.cs @@ -0,0 +1,12 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace OliverBooth.Data; + +internal sealed class StringListConverter : ValueConverter, string> +{ + public StringListConverter(char separator = ' ') : + base(v => string.Join(separator, v), + s => s.Split(separator, StringSplitOptions.None)) + { + } +} diff --git a/OliverBooth/Data/Web/Configuration/ProgrammingLanguageConfiguration.cs b/OliverBooth/Data/Web/Configuration/ProgrammingLanguageConfiguration.cs new file mode 100644 index 0000000..521ab7e --- /dev/null +++ b/OliverBooth/Data/Web/Configuration/ProgrammingLanguageConfiguration.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace OliverBooth.Data.Web.Configuration; + +/// +/// Represents the configuration for the entity. +/// +internal sealed class ProgrammingLanguageConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ProgrammingLanguage"); + builder.HasKey(e => e.Key); + + builder.Property(e => e.Key).IsRequired(); + builder.Property(e => e.Name).IsRequired(); + } +} diff --git a/OliverBooth/Data/Web/Configuration/ProjectConfiguration.cs b/OliverBooth/Data/Web/Configuration/ProjectConfiguration.cs index 349b7fb..08ef5d9 100644 --- a/OliverBooth/Data/Web/Configuration/ProjectConfiguration.cs +++ b/OliverBooth/Data/Web/Configuration/ProjectConfiguration.cs @@ -21,8 +21,13 @@ internal sealed class ProjectConfiguration : IEntityTypeConfiguration 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>().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)); } } diff --git a/OliverBooth/Data/Web/IProgrammingLanguage.cs b/OliverBooth/Data/Web/IProgrammingLanguage.cs new file mode 100644 index 0000000..039f31a --- /dev/null +++ b/OliverBooth/Data/Web/IProgrammingLanguage.cs @@ -0,0 +1,20 @@ +namespace OliverBooth.Data.Web; + +/// +/// Represents a programming language. +/// +public interface IProgrammingLanguage +{ + /// + /// Gets the unique key for this programming language. + /// + /// The unique key. + /// This is generally the file extension of the language. + string Key { get; } + + /// + /// Gets the name of this programming language. + /// + /// The name. + string Name { get; } +} diff --git a/OliverBooth/Data/Web/IProject.cs b/OliverBooth/Data/Web/IProject.cs index 290e14b..f2cc0d8 100644 --- a/OliverBooth/Data/Web/IProject.cs +++ b/OliverBooth/Data/Web/IProject.cs @@ -11,6 +11,12 @@ public interface IProject /// The description of the project. string Description { get; } + /// + /// Gets the details of the project. + /// + /// The details. + string Details { get; } + /// /// Gets the URL of the hero image. /// @@ -23,6 +29,12 @@ public interface IProject /// The ID of the project. Guid Id { get; } + /// + /// Gets the set of languages used for this project. + /// + /// The languages. + IReadOnlyList Languages { get; } + /// /// Gets the name of the project. /// @@ -58,4 +70,10 @@ public interface IProject /// /// The status of the project. ProjectStatus Status { get; } + + /// + /// Gets the tagline of the project. + /// + /// The tagline. + string? Tagline { get; } } diff --git a/OliverBooth/Data/Web/ProgrammingLanguage.cs b/OliverBooth/Data/Web/ProgrammingLanguage.cs new file mode 100644 index 0000000..53746d9 --- /dev/null +++ b/OliverBooth/Data/Web/ProgrammingLanguage.cs @@ -0,0 +1,72 @@ +namespace OliverBooth.Data.Web; + +/// +internal sealed class ProgrammingLanguage : IEquatable, IProgrammingLanguage +{ + /// + public string Key { get; } = string.Empty; + + /// + public string Name { get; internal 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 ==(ProgrammingLanguage? left, ProgrammingLanguage? 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 !=(ProgrammingLanguage? left, ProgrammingLanguage? 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(ProgrammingLanguage? other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + return Key.Equals(other.Key); + } + + /// + /// 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 ProgrammingLanguage other && Equals(other); + } + + /// + /// Gets the hash code for this instance. + /// + /// The hash code. + public override int GetHashCode() + { + // ReSharper disable once NonReadonlyMemberInGetHashCode + return Key.GetHashCode(); + } +} diff --git a/OliverBooth/Data/Web/Project.cs b/OliverBooth/Data/Web/Project.cs index b58f06d..05761a7 100644 --- a/OliverBooth/Data/Web/Project.cs +++ b/OliverBooth/Data/Web/Project.cs @@ -8,12 +8,18 @@ internal sealed class Project : IEquatable, IProject /// public string Description { get; private set; } = string.Empty; + /// + public string Details { get; private set; } = string.Empty; + /// public string HeroUrl { get; private set; } = string.Empty; /// public Guid Id { get; private set; } = Guid.NewGuid(); + /// + public IReadOnlyList Languages { get; private set; } = ArraySegment.Empty; + /// public string Name { get; private set; } = string.Empty; @@ -32,6 +38,9 @@ internal sealed class Project : IEquatable, IProject /// public ProjectStatus Status { get; private set; } = ProjectStatus.Ongoing; + /// + public string? Tagline { get; private set; } + /// /// Returns a value indicating whether two instances of are equal. /// diff --git a/OliverBooth/Data/Web/WebContext.cs b/OliverBooth/Data/Web/WebContext.cs index 151b24a..d5b74e4 100644 --- a/OliverBooth/Data/Web/WebContext.cs +++ b/OliverBooth/Data/Web/WebContext.cs @@ -31,6 +31,12 @@ internal sealed class WebContext : DbContext /// The collection of blacklist entries. public DbSet ContactBlacklist { get; private set; } = null!; + /// + /// Gets the collection of programming languages in the database. + /// + /// The collection of programming languages. + public DbSet ProgrammingLanguages { get; private set; } = null!; + /// /// Gets the collection of projects in the database. /// @@ -57,10 +63,12 @@ internal sealed class WebContext : DbContext optionsBuilder.UseMySql(connectionString, serverVersion); } + /// 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()); diff --git a/OliverBooth/Pages/Projects/Index.cshtml b/OliverBooth/Pages/Projects/Index.cshtml index 5db4be2..32706ab 100644 --- a/OliverBooth/Pages/Projects/Index.cshtml +++ b/OliverBooth/Pages/Projects/Index.cshtml @@ -4,102 +4,63 @@ @inject IProjectService ProjectService @{ ViewData["Title"] = "Projects"; + + IEnumerable 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)); }

Projects

- @foreach (IProject[] chunk in ProjectService.GetProjects(ProjectStatus.Ongoing).OrderBy(p => p.Rank).Chunk(2)) + @foreach (IProject[] chunk in projects.Chunk(2)) {
@foreach (IProject project in chunk) {
-
-
In Active Development
- @project.Name - -
- } -
- } +
+ break; - @foreach (IProject[] chunk in ProjectService.GetProjects(ProjectStatus.Past).Chunk(2)) - { -
- @foreach (IProject project in chunk) - { -
-
-
Past Work
- @project.Name -
-
@project.Name
-

@Html.Raw(ProjectService.GetDescription(project))

- @if (!string.IsNullOrWhiteSpace(project.RemoteUrl)) - { - - @if (string.IsNullOrWhiteSpace(project.RemoteTarget)) - { - View website - } - else - { - View on @project.RemoteTarget - } + case ProjectStatus.Past: + -
-
- } -
- } +
+ break; - @foreach (IProject[] chunk in ProjectService.GetProjects(ProjectStatus.Hiatus).Chunk(2)) - { -
- @foreach (IProject project in chunk) - { -
-
-
On Hiatus
- @project.Name -
-
@project.Name
-

@Html.Raw(ProjectService.GetDescription(project))

- @if (!string.IsNullOrWhiteSpace(project.RemoteUrl)) - { - - @if (string.IsNullOrWhiteSpace(project.RemoteTarget)) - { - View website - } - else - { - View on @project.RemoteTarget - } + case ProjectStatus.Hiatus: + -
+
+ break; + }
}
diff --git a/OliverBooth/Pages/Projects/Project.cshtml b/OliverBooth/Pages/Projects/Project.cshtml new file mode 100644 index 0000000..ae90f97 --- /dev/null +++ b/OliverBooth/Pages/Projects/Project.cshtml @@ -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"; +} + +
+ @if (Model.SelectedProject is not { } project) + { +

Project Not Found

+ return; + } + + + +

@project.Name

+ @if (!string.IsNullOrWhiteSpace(project.Tagline)) + { +

@project.Tagline

+ } + +

+ @project.Name +

+ + + + + + + + + + + @if (project.RemoteUrl != null) + { + + + + + } + + + + +
Languages + @foreach (IProgrammingLanguage language in ProjectService.GetProgrammingLanguages(project)) + { + @language.Name + } +
Status + @switch (project.Status) + { + case ProjectStatus.Ongoing: + In Active Development + break; + + case ProjectStatus.Past: + Completed / Retired + break; + + case ProjectStatus.Hiatus: + On Hiatus + break; + } +
View + + @(new Uri(project.RemoteUrl).Host) + +
Details@Html.Raw(Markdown.ToHtml(project.Details, MarkdownPipeline))
+
\ No newline at end of file diff --git a/OliverBooth/Pages/Projects/Project.cshtml.cs b/OliverBooth/Pages/Projects/Project.cshtml.cs new file mode 100644 index 0000000..87782ec --- /dev/null +++ b/OliverBooth/Pages/Projects/Project.cshtml.cs @@ -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; + } + } +} \ No newline at end of file diff --git a/OliverBooth/Pages/Shared/_Layout.cshtml b/OliverBooth/Pages/Shared/_Layout.cshtml index 7e5c21b..ea191b4 100644 --- a/OliverBooth/Pages/Shared/_Layout.cshtml +++ b/OliverBooth/Pages/Shared/_Layout.cshtml @@ -59,6 +59,7 @@ +
diff --git a/OliverBooth/Services/IProjectService.cs b/OliverBooth/Services/IProjectService.cs index e891bf3..16ae771 100644 --- a/OliverBooth/Services/IProjectService.cs +++ b/OliverBooth/Services/IProjectService.cs @@ -22,6 +22,13 @@ public interface IProjectService /// A read-only list of projects. IReadOnlyList GetAllProjects(); + /// + /// Gets the programming languages used in the specified project. + /// + /// The project whose languages to return. + /// A read only view of the languages. + IReadOnlyList GetProgrammingLanguages(IProject project); + /// /// Gets all projects with the specified status. /// diff --git a/OliverBooth/Services/ProjectService.cs b/OliverBooth/Services/ProjectService.cs index 9500feb..6f75ca5 100644 --- a/OliverBooth/Services/ProjectService.cs +++ b/OliverBooth/Services/ProjectService.cs @@ -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(); } + /// + public IReadOnlyList GetProgrammingLanguages(IProject project) + { + using WebContext context = _dbContextFactory.CreateDbContext(); + return project.Languages + .Select(l => context.ProgrammingLanguages.Find(l) ?? new ProgrammingLanguage { Name = l.Titleize() }) + .ToArray(); + } + /// public IReadOnlyList GetProjects(ProjectStatus status = ProjectStatus.Ongoing) { diff --git a/src/scss/app.scss b/src/scss/app.scss index 6ff4aef..d6d73d1 100644 --- a/src/scss/app.scss +++ b/src/scss/app.scss @@ -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; } \ No newline at end of file diff --git a/src/scss/ribbon.scss b/src/scss/ribbon.scss new file mode 100644 index 0000000..c2cc489 --- /dev/null +++ b/src/scss/ribbon.scss @@ -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; +} \ No newline at end of file