Add home hero with intro and buttons from the body, next-event card and page tiles

This commit is contained in:
tleininger committed 2026-10-01 14:05:25 +02:00
1 parent b737f57ef7
commit e51afeb34b
23 files changed
+1262 -65

No files matched your search

@@ -1,5 +1,7 @@
using Elternbeirat.Contracts;
using Elternbeirat.PocketBase;
using Elternbeirat.Web.Features.Events;
using Elternbeirat.Web.Shared;
using Microsoft.AspNetCore.Components;
namespace Elternbeirat.Web.Features.Embeds;
@@ -22,6 +24,9 @@ public partial class EventsEmbed
[Inject]
private ILogger<EventsEmbed> Logger { get; set; } = null!;
[Inject]
private TimeProvider Time { get; set; } = null!;
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
@@ -44,13 +49,6 @@ public partial class EventsEmbed
// Teaser: the soonest upcoming events, earliest first. An event earlier
// today still counts as upcoming.
var today = DateOnly.FromDateTime(DateTime.Today);
_upcomingEvents =
[
.. events
.Where(@event => DateOnly.FromDateTime(@event.Start) >= today)
.OrderBy(@event => @event.Start)
.Take(TeaserCount)
];
_upcomingEvents = [.. UpcomingEvents.From(events, BerlinClock.Today(Time)).Take(TeaserCount)];
}
}
@@ -20,6 +20,9 @@ public partial class EventList
[Inject]
private ILogger<EventList> Logger { get; set; } = null!;
[Inject]
private TimeProvider Time { get; set; } = null!;
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
@@ -62,13 +65,8 @@ public partial class EventList
// Split into upcoming and past by date, so an event earlier today still
// counts as upcoming. Upcoming earliest first, past most recent first.
var today = DateOnly.FromDateTime(DateTime.Today);
_upcomingEvents =
[
.. events
.Where(@event => DateOnly.FromDateTime(@event.Start) >= today)
.OrderBy(@event => @event.Start)
];
var today = BerlinClock.Today(Time);
_upcomingEvents = [.. UpcomingEvents.From(events, today)];
_pastEvents =
[
.. events
@@ -0,0 +1,30 @@
using Elternbeirat.Contracts;
namespace Elternbeirat.Web.Features.Events;
/// <summary>
/// Picks the events that still lie ahead.
/// </summary>
/// <remarks>
/// The events page, the events embed and the "next event" card on the home page
/// all need the same answer to "what is coming up?". Keeping the rule here means a
/// change to it (e.g. hiding an event once it has ended) reaches all three.
/// </remarks>
/// <seealso cref="Shared.BerlinClock"/>
public static class UpcomingEvents
{
/// <summary>
/// Returns the events on or after <paramref name="today"/>, earliest first.
/// </summary>
/// <param name="events">All events, in any order.</param>
/// <param name="today">The current day, from <see cref="Shared.BerlinClock.Today"/>.</param>
/// <returns>The upcoming events, ordered by start.</returns>
/// <remarks>
/// Compared by day, not by the minute: an event earlier today still counts as
/// upcoming, so a visitor who checks during the evening's meeting still finds it.
/// </remarks>
public static IEnumerable<Event> From(IEnumerable<Event> events, DateOnly today) =>
events
.Where(@event => DateOnly.FromDateTime(@event.Start) >= today)
.OrderBy(@event => @event.Start);
}
+72 -6
View File
@@ -1,12 +1,78 @@
@page "/"
@using Elternbeirat.Web.Features.Events
<PageTitle>Elternbeirat der IGMH</PageTitle>
<h1>Elternbeirat der IGMH</h1>
@* The hero spans the full window width (see Home.razor.css). Its text is the "home"
page's body, taken apart by HomeIntro; the card beside it shows the next event. *@
<section class="hero">
<div class="hero-inner">
<div class="hero-text">
<h1>@_intro.Title</h1>
<p class="intro">
Willkommen beim Elternbeirat der IGMH. Hier finden Sie aktuelle Informationen,
Antworten auf häufige Fragen und Unterlagen rund um die Elternarbeit.
</p>
@if (_intro.IntroHtml.Length > 0)
{
<div class="hero-intro">@((MarkupString)_intro.IntroHtml)</div>
}
<PageEmbeds Embed="_embed" />
@if (_intro.Links.Count > 0)
{
<p class="hero-actions">
@for (var i = 0; i < _intro.Links.Count; i++)
{
var link = _intro.Links[i];
<a class="btn @(i == 0 ? "btn-primary" : "btn-secondary")" href="@link.Href">@link.Text</a>
}
</p>
}
</div>
@if (_nextEvent is not null)
{
@* The calendar sheet repeats the date as decoration, so it is hidden from
screen readers; <EventDate> carries the full date for everyone. *@
<a class="next-event" href="/events">
<span class="next-event-label">Nächster Termin</span>
<span class="next-event-row">
<span class="calendar-sheet" aria-hidden="true">
<span class="calendar-month">@NextEventMonth</span>
<span class="calendar-day">@NextEventDay</span>
</span>
<span class="next-event-details">
<span class="next-event-title">@_nextEvent.Title</span>
<EventDate Value="_nextEvent" />
@if (!string.IsNullOrWhiteSpace(_nextEvent.Location))
{
<span class="next-event-location"><Icon Name="map-pin" Size="16" /> @_nextEvent.Location</span>
}
</span>
</span>
</a>
}
</div>
</section>
@if (_tiles.Count > 0)
{
<nav class="home-tiles" aria-label="Bereiche der Website">
<ul>
@foreach (var tile in _tiles)
{
<li>
<a class="tile-link" href="@tile.Href">
<span class="tile-icon"><Icon Name="@tile.Icon" Size="24" /></span>
<span class="tile-title">@tile.Title</span>
@if (tile.Description.Length > 0)
{
<span class="tile-text">@tile.Description</span>
}
</a>
</li>
}
</ul>
</nav>
}
<div class="home-embeds">
<PageEmbeds Embed="_embed" />
</div>
+60 -10
View File
@@ -1,5 +1,7 @@
using Elternbeirat.Contracts;
using Elternbeirat.PocketBase;
using Elternbeirat.Web.Features.Events;
using Elternbeirat.Web.Shared;
using Microsoft.AspNetCore.Components;
namespace Elternbeirat.Web.Features.Home;
@@ -8,8 +10,15 @@ public partial class Home
{
[LoggerMessage(
Level = LogLevel.Warning,
Message = "Could not load the home page from PocketBase; embeds are omitted.")]
private static partial void LogSourceUnavailable(
Message = "Could not load the pages for the home page from PocketBase; showing the static title only.")]
private static partial void LogPagesUnavailable(
ILogger logger,
Exception exception);
[LoggerMessage(
Level = LogLevel.Warning,
Message = "Could not load events for the home page from PocketBase; the next-event card is omitted.")]
private static partial void LogEventsUnavailable(
ILogger logger,
Exception exception);
@@ -19,26 +28,67 @@ public partial class Home
[Inject]
private ILogger<Home> Logger { get; set; } = null!;
[Inject]
private TimeProvider Time { get; set; } = null!;
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
// Which dynamic blocks the home page shows is editorial: it comes from the
// "home" page's embed field in PocketBase, not from hard-coded markup.
private IReadOnlyList<string> _embed = [];
// Heading, intro and buttons come from the "home" page's body. Until it is loaded
// -- and for good if PocketBase is down -- the static fallback heading stands, so
// the start page always has a title.
private HomeIntro _intro = HomeIntro.Fallback;
protected override async Task OnInitializedAsync()
// The hero, the tiles and the next-event card are fixed structure of the start
// page; which dynamic blocks follow below is editorial and comes from the "home"
// page's embed field.
private IReadOnlyList<HomeTile> _tiles = [];
private IReadOnlyList<string> _embed = [];
private Event? _nextEvent;
// Month and day for the calendar sheet. The sheet is decoration (the full date is
// in <EventDate> next to it), so the short month drops its trailing period.
private string NextEventMonth =>
_nextEvent?.Start.ToString("MMM", Cultures.German).TrimEnd('.') ?? "";
private string NextEventDay =>
_nextEvent?.Start.Day.ToString(Cultures.German) ?? "";
protected override Task OnInitializedAsync()
{
var token = HttpContext?.RequestAborted ?? CancellationToken.None;
return Task.WhenAll(LoadPagesAsync(token), LoadNextEventAsync(token));
}
// Pages and events load side by side and fail on their own: without events the
// page loses only the card, without pages only text and tiles. Either way the
// start page stays a 200 rather than a 503 -- it is degraded, not broken.
private async Task LoadPagesAsync(CancellationToken token)
{
try
{
var pages = await PocketBase.GetPagesAsync(HttpContext?.RequestAborted ?? default);
var pages = await PocketBase.GetPagesAsync(token);
var home = pages.FirstOrDefault(page => Page.IsHomeSlug(page.Slug));
_intro = HomeIntro.Parse(home?.Body);
_embed = home?.Embed ?? [];
_tiles = HomeTile.From(pages);
}
catch (PocketBaseUnavailableException exception)
{
// Only the embeds fail: render the static intro without them instead of a
// 500. The home page's own text is markup, so it stays intact.
LogSourceUnavailable(Logger, exception);
LogPagesUnavailable(Logger, exception);
}
}
private async Task LoadNextEventAsync(CancellationToken token)
{
try
{
var events = await PocketBase.GetEventsAsync(token);
_nextEvent = UpcomingEvents.From(events, BerlinClock.Today(Time)).FirstOrDefault();
}
catch (PocketBaseUnavailableException exception)
{
LogEventsUnavailable(Logger, exception);
}
}
}
+263 -3
View File
@@ -1,6 +1,266 @@
.intro {
/* --- Hero ---------------------------------------------------------------------
A full-width band in the brand gradient with a faint dot pattern on top. The
page content sits in a centred column (.content in MainLayout), so the band
breaks out of it: 100vw wide, pulled left by the column's offset. body clips the
few pixels of scrollbar this adds (see app.css). The negative top margin cancels
the column's top padding, so the band meets the header without a strip of page
background in between.
The pattern is an inline SVG in a data: URL -- no request, nothing from another
host. White dots at 12% opacity: visible as texture, too faint to cost the
white text any contrast. */
.hero {
width: 100vw;
margin: calc(-1 * var(--space-4)) 0 var(--space-5) calc(50% - 50vw);
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Ccircle cx='3' cy='3' r='1.5' fill='%23fff' fill-opacity='.12'/%3E%3C/svg%3E"),
var(--gradient-brand);
color: var(--color-on-header);
}
/* The inside follows the page frame again, so the hero text lines up with the
tiles and lists below. Stacked on a phone; from tablet width the next-event card
moves beside the text. */
.hero-inner {
box-sizing: border-box;
max-width: var(--content-width);
margin: 0 auto;
padding: var(--space-6) 1.25rem;
display: grid;
gap: var(--space-5);
align-items: center;
}
@media (min-width: 48rem) {
.hero-inner {
grid-template-columns: minmax(0, 1fr) minmax(16rem, 22rem);
}
}
/* On the gradient everything is white: the global heading colour is a blue that
would vanish here. */
.hero h1 {
color: var(--color-on-header);
}
/* The intro is editor Markdown, rendered without scope attributes, so it is reached
through ::deep. Slightly larger than body text, as a lead. Links stay white and
underlined: the blue link colour would be unreadable on the gradient. */
.hero-intro ::deep p {
font-size: 1.15rem;
}
.hero-intro ::deep a,
.hero-intro ::deep a:hover {
color: var(--color-on-header);
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
margin: var(--space-4) 0 0;
}
/* The hero's buttons, adapted to the dark gradient: the primary turns light (a
filled blue would melt into the blue end), the secondary becomes a white outline
with the same translucent wash as the header menu on hover. The focus ring turns
white for the same reason. */
.hero .btn-primary {
background: var(--color-button-light);
border-color: var(--color-button-light);
color: var(--color-on-button-light);
}
.hero .btn-primary:hover {
background: var(--color-button-light-hover);
border-color: var(--color-button-light-hover);
color: var(--color-on-button-light);
}
.hero .btn-secondary {
border-color: var(--color-on-header);
color: var(--color-on-header);
}
.hero .btn-secondary:hover {
background: var(--color-nav-hover);
color: var(--color-on-header);
}
.hero .btn:focus-visible {
outline-color: var(--color-on-header);
}
/* --- Next event ---------------------------------------------------------------
A card on the gradient: the regular card surface, so its text uses the normal
tokens and adapts to dark mode by itself. The whole card is one link to the
events page. */
.next-event {
display: block;
padding: var(--space-4);
border-radius: var(--radius);
background: var(--color-bg);
box-shadow: var(--shadow-lg);
color: var(--color-text);
text-decoration: none;
transition: box-shadow var(--transition);
}
.next-event:hover {
color: var(--color-text);
box-shadow: var(--shadow-hover), var(--shadow-lg);
}
/* The ring sits outside the card, on the gradient, so it is white like the
hero's button rings. */
.next-event:focus-visible {
outline: 3px solid var(--color-on-header);
outline-offset: 3px;
}
.next-event-label {
display: block;
margin-bottom: var(--space-3);
color: var(--color-text-soft);
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.next-event-row {
display: flex;
gap: var(--space-3);
align-items: flex-start;
}
/* The calendar sheet: a blue band with the month over a large day number. The band
uses the button blue, which keeps white text at >= 5.5:1 in both schemes (the
green accent would fall below AA for text this small). */
.calendar-sheet {
flex-shrink: 0;
display: flex;
flex-direction: column;
width: 4rem;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
text-align: center;
}
.calendar-month {
padding: 0.1rem 0;
background: var(--color-button);
color: var(--color-on-button);
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
}
.calendar-day {
padding: var(--space-1) 0;
color: var(--color-heading);
font-size: 1.75rem;
font-weight: var(--font-weight-heading);
line-height: 1.2;
}
.next-event-details {
min-width: 0; /* lets a long title wrap instead of widening the card */
}
.next-event-title {
display: block;
color: var(--color-brand);
font-size: 1.15rem;
font-weight: 700;
line-height: 1.3;
}
.next-event-location {
display: flex;
align-items: center;
gap: var(--space-1);
margin-top: var(--space-1);
color: var(--color-text-soft);
font-size: 0.9rem;
}
/* --- Tiles --------------------------------------------------------------------
One card per header page. auto-fill with a minimum width gives as many columns
as fit (one on a phone) without breakpoints. Hover = stronger shadow and a blue
edge, nothing moves. */
.home-tiles ul {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: var(--space-3);
margin: 0;
padding: 0;
list-style: none;
}
.tile-link {
box-sizing: border-box;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
column-gap: var(--space-3);
align-items: center;
height: 100%;
padding: var(--space-3) var(--space-4);
border: 1px solid transparent;
border-radius: var(--radius);
background: var(--color-bg);
box-shadow: var(--shadow);
color: var(--color-text);
text-decoration: none;
transition: box-shadow var(--transition), border-color var(--transition);
}
.tile-link:hover {
border-color: var(--color-brand);
box-shadow: var(--shadow-hover);
color: var(--color-text);
}
.tile-link:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 2px;
}
/* The icon in a soft round tint; it spans both rows, so title and text line up
beside it whether there is a text or not. */
.tile-icon {
grid-row: span 2;
display: grid;
place-items: center;
width: 3rem;
height: 3rem;
border-radius: 50%;
background: var(--color-brand-tint);
color: var(--color-brand);
}
.tile-title {
color: var(--color-brand);
font-size: 1.1rem;
/* Softer than body text, via the token -- so it adapts in dark mode instead of
staying a fixed dark grey that turns unreadable there. */
font-weight: 700;
}
.tile-text {
color: var(--color-text-soft);
font-size: 0.9rem;
line-height: 1.45;
}
/* --- Embeds -------------------------------------------------------------------
The blocks the home page opts into (posts, events) sit side by side from desktop
width, so the start page shows both without a long scroll. One column below. */
@media (min-width: 64rem) {
.home-embeds {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-5);
align-items: start;
}
}
+128
View File
@@ -0,0 +1,128 @@
using Elternbeirat.Web.Shared;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
namespace Elternbeirat.Web.Features.Home;
/// <summary>
/// The head of the home page, taken apart from the <c>home</c> page's Markdown body:
/// a large heading, an intro and a row of buttons.
/// </summary>
/// <param name="Title">The large heading.</param>
/// <param name="IntroHtml">The intro rendered to HTML, or an empty string.</param>
/// <param name="Links">The buttons, in the order the editor wrote them; may be empty.</param>
/// <remarks>
/// Everything visible here is written by editors in PocketBase. The page only gives
/// it the hero layout, so the editor needs no new field and no special syntax:
/// <list type="bullet">
/// <item><description>The first <c># heading</c> becomes the large heading.</description></item>
/// <item>
/// <description>
/// A last paragraph made of nothing but links becomes the button row;
/// the first link is the primary button.
/// </description>
/// </item>
/// <item><description>Everything else is the intro text.</description></item>
/// </list>
/// <para>
/// <see cref="Fallback"/> keeps a static heading when PocketBase cannot be
/// reached, so the start page never renders without a title.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// HomeIntro.Parse("# Willkommen\n\nSchön, dass Sie da sind.\n\n[Termine](/events) [Kontakt](/contact)");
/// // Title "Willkommen", IntroHtml "&lt;p&gt;Schön, dass Sie da sind.&lt;/p&gt;\n", two links
/// </code>
/// </example>
public sealed record HomeIntro(string Title, string IntroHtml, IReadOnlyList<HomeLink> Links)
{
/// <summary>
/// The heading shown when the body has none or PocketBase is not reachable.
/// </summary>
public const string FallbackTitle = "Elternbeirat der IGMH";
/// <summary>
/// The head of the home page without any content from PocketBase: the static
/// heading, no intro, no buttons.
/// </summary>
public static HomeIntro Fallback { get; } = new(FallbackTitle, "", []);
/// <summary>
/// Takes the <c>home</c> page's body apart into heading, intro and buttons.
/// </summary>
/// <param name="markdown">The body; may be <see langword="null"/> or empty.</param>
/// <returns>
/// The parts; <see cref="Fallback"/> for an empty body. Without a level-1
/// heading, the title is <see cref="FallbackTitle"/> and the rest is used as is.
/// </returns>
/// <remarks>
/// Only top-level blocks are looked at: a heading or a link paragraph inside a
/// list or a <c>:::</c> block stays part of the intro. Links in the button row
/// may be separated by spaces or line breaks; any other text in the paragraph
/// (e.g. "oder") keeps it a normal paragraph, because the button row would drop
/// that text.
/// </remarks>
public static HomeIntro Parse(string? markdown)
{
if (string.IsNullOrWhiteSpace(markdown))
{
return Fallback;
}
var document = Markdown.Parse(markdown);
var heading = document.OfType<HeadingBlock>().FirstOrDefault(block => block.Level == 1);
if (heading is not null)
{
document.Remove(heading);
}
var links = document.LastChild is ParagraphBlock last ? LinksOnly(last) : [];
if (links.Count > 0)
{
document.RemoveAt(document.Count - 1);
}
var title = heading?.Inline is { } inline ? Markdown.PlainText(inline) : "";
return new HomeIntro(
string.IsNullOrEmpty(title) ? FallbackTitle : title,
Markdown.Render(document),
links);
}
/// <summary>
/// Reads the links of a paragraph that holds nothing but links.
/// </summary>
/// <param name="paragraph">The paragraph to inspect.</param>
/// <returns>
/// Its links, or an empty list if the paragraph contains anything besides links
/// and whitespace (including an image).
/// </returns>
private static List<HomeLink> LinksOnly(ParagraphBlock paragraph) =>
paragraph.Inline?.Where(inline => !Markdown.IsBlank(inline)).ToList() is { Count: > 0 } inlines
&& inlines.All(inline => inline is LinkInline { IsImage: false } or AutolinkInline)
? [.. inlines.Select(ToLink)]
: [];
/// <summary>
/// Turns a link inline into a button.
/// </summary>
/// <param name="inline">A <see cref="LinkInline"/> or <see cref="AutolinkInline"/>.</param>
/// <returns>The button's target and visible text.</returns>
/// <exception cref="ArgumentException"><paramref name="inline"/> is neither kind of link.</exception>
private static HomeLink ToLink(Inline inline) =>
inline switch
{
LinkInline link => new HomeLink(link.Url ?? "", Markdown.PlainText(link)),
AutolinkInline autolink => new HomeLink(autolink.Url, autolink.Url),
_ => throw new ArgumentException("Not a link.", nameof(inline)),
};
}
/// <summary>
/// A button in the head of the home page.
/// </summary>
/// <param name="Href">The link target as the editor wrote it.</param>
/// <param name="Text">The visible button text.</param>
public sealed record HomeLink(string Href, string Text);
@@ -0,0 +1,39 @@
using Elternbeirat.Contracts;
using Elternbeirat.Web.Features.Layout;
using Elternbeirat.Web.Shared;
namespace Elternbeirat.Web.Features.Home;
/// <summary>
/// One tile on the home page, leading to a section of the site.
/// </summary>
/// <param name="Href">Target URL of the page.</param>
/// <param name="Title">The page's title.</param>
/// <param name="Description">
/// The first sentence of the page's body, or an empty string, in which case the
/// tile shows only the title.
/// </param>
/// <param name="Icon">The icon name, see <see cref="TileIcons"/>.</param>
public sealed record HomeTile(string Href, string Title, string Description, string Icon)
{
/// <summary>
/// Builds one tile per header page except the home page itself.
/// </summary>
/// <param name="pages">All public pages.</param>
/// <returns>The tiles, in menu order.</returns>
/// <remarks>
/// The tiles mirror the header menu (<see cref="NavBuilder.HeaderPages"/>), so a
/// page an editor puts into the header appears here too, without any code. The
/// home page is left out: a tile leading to the page it sits on would be useless.
/// </remarks>
public static IReadOnlyList<HomeTile> From(IReadOnlyList<Page> pages) =>
[
.. NavBuilder.HeaderPages(pages)
.Where(page => !Page.IsHomeSlug(page.Slug))
.Select(page => new HomeTile(
NavBuilder.HrefFor(page.Slug),
page.Title,
Markdown.FirstSentence(page.Body),
TileIcons.For(page.Slug))),
];
}
@@ -0,0 +1,45 @@
using System.Collections.Frozen;
using Elternbeirat.Web.Shared;
namespace Elternbeirat.Web.Features.Home;
/// <summary>
/// Chooses the icon of a home page tile from the page's slug.
/// </summary>
/// <remarks>
/// Pure presentation, so it lives in code: the schema has no icon field, and adding
/// one needs a schema change that cannot reach production yet. A page without an
/// entry here (e.g. one an editor just created) gets <see cref="Default"/>, so a
/// new page always shows up as a complete tile.
/// </remarks>
/// <seealso cref="IconSet"/>
public static class TileIcons
{
/// <summary>
/// The icon for a slug without its own entry: a generic page.
/// </summary>
public const string Default = "file-text";
/// <summary>
/// Icon name per slug. Every value must be a name in <see cref="IconSet"/>.
/// </summary>
private static readonly FrozenDictionary<string, string> Icons =
new Dictionary<string, string>
{
["board"] = "users",
["patrons"] = "heart-handshake",
["faqs"] = "help-circle",
["downloads"] = "download",
["posts"] = "newspaper",
["events"] = "calendar",
["contact"] = "mail",
}.ToFrozenDictionary(StringComparer.Ordinal);
/// <summary>
/// Returns the icon name for a page.
/// </summary>
/// <param name="slug">The page's slug, e.g. <c>board</c>.</param>
/// <returns>An icon name from <see cref="IconSet"/>; <see cref="Default"/> for an unknown slug.</returns>
public static string For(string slug) =>
Icons.GetValueOrDefault(slug, Default);
}
+32 -10
View File
@@ -11,23 +11,45 @@ public static class NavBuilder
{
/// <summary>Header links, ordered, built from pages with location "header".</summary>
public static IReadOnlyList<NavLinkItem> Header(IReadOnlyList<Page> pages) =>
Build(pages, "header");
ToLinks(PagesAt(pages, "header"));
/// <summary>Footer links, ordered, built from pages with location "footer".</summary>
public static IReadOnlyList<NavLinkItem> Footer(IReadOnlyList<Page> pages) =>
Build(pages, "footer");
ToLinks(PagesAt(pages, "footer"));
private static List<NavLinkItem> Build(
/// <summary>
/// Returns the pages of the header menu themselves, in menu order.
/// </summary>
/// <param name="pages">All public pages.</param>
/// <returns>The pages with location <c>header</c>, ordered by <see cref="Page.Order"/>.</returns>
/// <remarks>
/// For callers that need more than a link, such as the tiles on the home page,
/// which also show part of each page's body. Using the same selection as
/// <see cref="Header"/> keeps tiles and menu in step.
/// </remarks>
public static IReadOnlyList<Page> HeaderPages(IReadOnlyList<Page> pages) =>
PagesAt(pages, "header");
/// <summary>
/// Returns the URL a page is reached at.
/// </summary>
/// <param name="slug">The page's slug.</param>
/// <returns><c>/</c> for the home page, otherwise <c>/{slug}</c>.</returns>
/// <remarks>
/// The home page owns "/" (served by the Home component); every other slug is
/// reached at "/{slug}", where either a literal route or the catch-all content
/// page renders it.
/// </remarks>
public static string HrefFor(string slug) =>
Page.IsHomeSlug(slug) ? "/" : $"/{slug}";
private static List<Page> PagesAt(
IReadOnlyList<Page> pages,
string location) =>
[.. pages
.Where(page => string.Equals(page.Location, location, StringComparison.Ordinal))
.OrderBy(page => page.Order)
.Select(page => new NavLinkItem(HrefFor(page.Slug), page.Title))];
.OrderBy(page => page.Order)];
// The home page owns "/" (served by the Home component); every other slug is
// reached at "/{slug}", where either a literal route or the catch-all content
// page renders it.
private static string HrefFor(string slug) =>
Page.IsHomeSlug(slug) ? "/" : $"/{slug}";
private static List<NavLinkItem> ToLinks(IEnumerable<Page> pages) =>
[.. pages.Select(page => new NavLinkItem(HrefFor(page.Slug), page.Title))];
}
+4
View File
@@ -8,6 +8,10 @@ var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents();
// "Today" for upcoming events comes from this clock, not DateTime.Today, so the
// smoke tests can swap in a fixed date and check the pages before and after an event.
builder.Services.AddSingleton(TimeProvider.System);
// The typed client reads content from PocketBase over its REST API. The base URL
// comes from config: appsettings for local dev, the PocketBase__BaseUrl env var
// on the server (see compose.yaml).
+33
View File
@@ -0,0 +1,33 @@
namespace Elternbeirat.Web.Shared;
/// <summary>
/// Tells the current calendar day as people at the school see it.
/// </summary>
/// <remarks>
/// The container's clock runs in UTC, so <see cref="DateTime.Today"/> would flip to
/// the next day at 01:00 or 02:00 Berlin time, and an event shortly after midnight
/// would count as past for an hour or two. Event times are Berlin wall-clock
/// values (see <c>LocalDateTimeConverter</c>), so "today" has to be a Berlin day
/// too.
/// <para>
/// The time comes from an injected <see cref="TimeProvider"/>, not the system
/// clock, so tests can pin "today" and check what a page shows before and after
/// an event.
/// </para>
/// </remarks>
public static class BerlinClock
{
/// <summary>
/// IANA id; resolves on every platform .NET supports, Windows included.
/// </summary>
private static readonly TimeZoneInfo Berlin =
TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
/// <summary>
/// Returns today's date in Berlin.
/// </summary>
/// <param name="time">The clock to read, usually <see cref="TimeProvider.System"/>.</param>
/// <returns>The current Berlin calendar day.</returns>
public static DateOnly Today(TimeProvider time) =>
DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(time.GetUtcNow(), Berlin).DateTime);
}
+152 -17
View File
@@ -1,3 +1,5 @@
using System.Text;
using System.Text.RegularExpressions;
using Markdig;
using Markdig.Renderers.Html;
using Markdig.Syntax;
@@ -45,7 +47,7 @@ namespace Elternbeirat.Web.Shared;
/// </para>
/// </remarks>
/// <seealso cref="IconSet"/>
public static class Markdown
public static partial class Markdown
{
/// <summary>
/// The class marking a paragraph or list item whose only content is one link.
@@ -75,9 +77,155 @@ public static class Markdown
/// </code>
/// </example>
public static string ToHtml(string? markdown) =>
string.IsNullOrEmpty(markdown)
? ""
: Markdig.Markdown.ToHtml(MarkLoneLinks(Markdig.Markdown.Parse(markdown, Pipeline)), Pipeline);
string.IsNullOrEmpty(markdown) ? "" : Render(Parse(markdown));
/// <summary>
/// Extracts the first sentence of a Markdown text as plain text, e.g. as the
/// short description on a home page tile.
/// </summary>
/// <param name="markdown">The editor's Markdown; may be <see langword="null"/>.</param>
/// <param name="maxLength">
/// The longest sentence kept as is; a longer one is cut at the last word
/// boundary before the limit and ends in <c>…</c>.
/// </param>
/// <returns>
/// The first sentence of the first top-level paragraph, with all Markdown
/// removed and whitespace collapsed, or an empty string when there is no such
/// paragraph (empty body, or only headings, lists and blocks).
/// </returns>
/// <exception cref="ArgumentOutOfRangeException">
/// <paramref name="maxLength"/> is zero or negative.
/// </exception>
/// <remarks>
/// Only top-level paragraphs count: a heading repeats the title, and the text in
/// a list or a <c>:::</c> block is rarely a sentence that describes the page.
/// <para>
/// A sentence ends at <c>.</c>, <c>!</c> or <c>?</c> followed by a space or
/// the end. A period after a single letter or a number does not count, so
/// <c>z. B.</c> and <c>13. November</c> do not cut the sentence short. A
/// longer abbreviation such as <c>bzw.</c> still does; that costs the rest
/// of a teaser, never the page.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// Markdown.FirstSentence("# Vorstand\n\nWir sind **sieben** Eltern. Mehr unten.");
/// // "Wir sind sieben Eltern."
/// </code>
/// </example>
public static string FirstSentence(string? markdown, int maxLength = 140) =>
maxLength <= 0
? throw new ArgumentOutOfRangeException(nameof(maxLength), maxLength, "Must be positive.")
: string.IsNullOrWhiteSpace(markdown)
? ""
: Parse(markdown).OfType<ParagraphBlock>().FirstOrDefault()?.Inline is { } inline
? Shorten(UpToSentenceEnd(PlainText(inline)), maxLength)
: "";
/// <summary>
/// Parses Markdown with the site's pipeline, for callers that take the document
/// apart before rendering it (see <see cref="Render"/>).
/// </summary>
/// <param name="markdown">The editor's Markdown.</param>
/// <returns>The parsed document.</returns>
internal static MarkdownDocument Parse(string markdown) =>
Markdig.Markdown.Parse(markdown, Pipeline);
/// <summary>
/// Renders a document from <see cref="Parse"/> to HTML, marking lone links on
/// the way, exactly as <see cref="ToHtml"/> does.
/// </summary>
/// <param name="document">The parsed document; changed in place.</param>
/// <returns>The rendered HTML.</returns>
internal static string Render(MarkdownDocument document) =>
Markdig.Markdown.ToHtml(MarkLoneLinks(document), Pipeline);
/// <summary>
/// Flattens inline Markdown to the text a reader sees: emphasis and link
/// markup dropped, link text kept, images left out, whitespace collapsed.
/// </summary>
/// <param name="inline">The inline content, e.g. of a paragraph or heading.</param>
/// <returns>The visible text, trimmed.</returns>
internal static string PlainText(ContainerInline inline) =>
Whitespace().Replace(AppendText(new StringBuilder(), inline).ToString(), " ").Trim();
/// <summary>
/// Whether an inline carries no visible content (whitespace or a line break).
/// </summary>
/// <param name="inline">The inline to inspect.</param>
/// <returns>
/// <see langword="true"/> if it can be ignored when looking for links that stand
/// alone in a paragraph.
/// </returns>
internal static bool IsBlank(Inline inline) =>
inline switch
{
LineBreakInline => true,
LiteralInline literal => literal.Content.IsEmptyOrWhitespace(),
_ => false,
};
/// <summary>
/// Appends the visible text of an inline and its children.
/// </summary>
/// <param name="text">The builder to append to.</param>
/// <param name="inline">The inline to flatten.</param>
/// <returns>The same <paramref name="text"/>, for chaining.</returns>
private static StringBuilder AppendText(StringBuilder text, Inline inline) =>
inline switch
{
LiteralInline literal => text.Append(literal.Content.ToString()),
CodeInline code => text.Append(code.Content),
HtmlEntityInline entity => text.Append(entity.Transcoded.ToString()),
AutolinkInline autolink => text.Append(autolink.Url),
LineBreakInline => text.Append(' '),
LinkInline { IsImage: true } => text,
ContainerInline container => container.Aggregate(text, AppendText),
_ => text,
};
/// <summary>
/// Cuts a text after its first sentence.
/// </summary>
/// <param name="text">Plain text.</param>
/// <returns>
/// The first sentence including its end mark, or the whole text if it has none.
/// </returns>
private static string UpToSentenceEnd(string text) =>
SentenceEnd().Match(text) is { Success: true } end ? text[..(end.Index + 1)] : text;
/// <summary>
/// Shortens a text to at most <paramref name="maxLength"/> characters plus an
/// ellipsis, cutting between words.
/// </summary>
/// <param name="text">Plain text.</param>
/// <param name="maxLength">The longest text kept as is.</param>
/// <returns>The text, shortened if needed.</returns>
/// <remarks>
/// The search for a space starts at <paramref name="maxLength"/> itself, so a
/// word that ends exactly at the limit is kept. A single word longer than the
/// limit is cut hard.
/// </remarks>
private static string Shorten(string text, int maxLength) =>
text.Length <= maxLength
? text
: text.LastIndexOf(' ', maxLength) is > 0 and var space
? $"{text[..space].TrimEnd(',', ';', ':', '-', '–')}…"
: $"{text[..maxLength]}…";
/// <summary>
/// Matches the end mark of a sentence: <c>.</c>, <c>!</c> or <c>?</c> before a
/// space or the end, unless it follows a lone letter (<c>z.</c>) or a number
/// (<c>13.</c>).
/// </summary>
[GeneratedRegex(@"(?<!\b\p{L}|\b\d+)[.!?](?=\s|$)")]
private static partial Regex SentenceEnd();
/// <summary>
/// Matches a run of whitespace, collapsed to one space in plain text.
/// </summary>
[GeneratedRegex(@"\s+")]
private static partial Regex Whitespace();
/// <summary>
/// Adds <see cref="LoneLinkClass"/> to every paragraph that holds nothing but
@@ -114,17 +262,4 @@ public static class Markdown
paragraph.Inline?
.Where(inline => !IsBlank(inline))
.ToList() is [LinkInline { IsImage: false } or AutolinkInline];
/// <summary>
/// Whether an inline carries no visible content (whitespace or a line break).
/// </summary>
/// <param name="inline">The inline to inspect.</param>
/// <returns><see langword="true"/> if it can be ignored when looking for a lone link.</returns>
private static bool IsBlank(Inline inline) =>
inline switch
{
LineBreakInline => true,
LiteralInline literal => literal.Content.IsEmptyOrWhitespace(),
_ => false,
};
}
+72
View File
@@ -62,6 +62,18 @@
--color-button-hover: #1f5477;
--color-on-button: #fff;
/* The light button for dark surfaces (the brand gradient of the home hero),
where a filled blue button would melt into the blue end. Dark blue text on
white, 8:1; the hover is a faint blue tint, so the button still reads as one. */
--color-button-light: #fff;
--color-button-light-hover: #eaf3f9;
--color-on-button-light: #1f5477;
/* Keyboard focus ring (:focus-visible). Its own token so it can stay strong on
the light page (dark blue, 8:1 on white) and flip to a light blue on the dark
one; on the gradient the hero switches it to white. */
--color-focus: #1f5477;
/* Headings. Its own token (not --color-brand) so headings and links can differ:
in dark mode headings become a soft white while links keep the brand blue. */
--color-heading: #2b6f9c;
@@ -158,6 +170,13 @@
text, calm on the dark page */
--color-button-hover: #1f5477;
--color-button-light: #e8eef3; /* white eased down, so it does not glare
on the darker gradient */
--color-button-light-hover: #d3dee6;
--color-on-button-light: #1a3d57; /* 9:1 on the eased white */
--color-focus: #a3d5f4; /* light blue: a dark ring would vanish here */
--color-heading: #c9ced6; /* soft white, kept a step brighter than the
body text so headings still lead the page */
@@ -211,6 +230,11 @@ body {
background: var(--color-page);
color: var(--color-text);
font-size: var(--font-size-body);
/* Full-width bands inside the content column (the home hero) reach the window
edges with 100vw, which includes the vertical scrollbar and would add a few
pixels of sideways scroll. clip, not hidden: hidden would make body a scroll
container and stop the sticky header from sticking. */
overflow-x: clip;
}
a {
@@ -273,6 +297,54 @@ h1:focus {
vertical-align: -0.15em;
}
/* Buttons for links that are an action (the hero's "Kommende Termine", "Kontakt
aufnehmen"). Global, because the look must be the same wherever a component puts
one. Primary = filled, secondary = outlined; both carry the same 2px border, so a
primary and a secondary side by side are exactly the same height. Hover changes
colour and shadow only -- nothing moves (hover movement was removed on purpose). */
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border: 2px solid transparent;
border-radius: var(--radius-sm);
font-weight: 700;
line-height: 1.4;
text-decoration: none;
transition: background-color var(--transition), box-shadow var(--transition),
color var(--transition), border-color var(--transition);
}
.btn:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 2px;
}
.btn-primary {
background: var(--color-button);
border-color: var(--color-button);
color: var(--color-on-button);
}
.btn-primary:hover {
background: var(--color-button-hover);
border-color: var(--color-button-hover);
color: var(--color-on-button);
box-shadow: var(--shadow-hover);
}
.btn-secondary {
background: transparent;
border-color: var(--color-button);
color: var(--color-brand);
}
.btn-secondary:hover {
background: var(--color-brand-tint);
color: var(--color-brand);
}
/* Header navigation links. These live here (global), not in MainLayout.razor.css:
NavLink renders plain <a> elements without a scoped-CSS attribute, so a scoped
".main-nav a" rule would not match them -- the links would fall back to the