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

+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);
}