Add home hero with intro and buttons from the body, next-event card and page tiles
This commit is contained in:
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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 "<p>Schön, dass Sie da sind.</p>\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);
|
||||
}
|
||||
@@ -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))];
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user