Compare commits

..
2 Commits
Author SHA1 Message Date
tleininger 44f07c8c13 Reduce heading sizes by one step 2026-09-25 09:12:10 +02:00
tleininger 455b603299 Extract date rendering into per-feature Date components 2026-09-25 08:49:44 +02:00
16 changed files with 170 additions and 73 deletions

No files matched your search

@@ -1,12 +1,13 @@
@if (_upcoming.Count > 0) @using Elternbeirat.Web.Features.Events
@if (_upcomingEvents.Count > 0)
{ {
<section class="embed embed-events"> <section class="embed embed-events">
<h2>Kommende Termine</h2> <h2>Kommende Termine</h2>
<ul class="event-list"> <ul class="event-list">
@foreach (var ev in _upcoming) @foreach (var ev in _upcomingEvents)
{ {
<li> <li>
<time datetime="@ev.Start.ToString("s")">@EventFormat.Range(ev)</time> <EventDate Value="ev" />
<span class="event-title">@ev.Title</span> <span class="event-title">@ev.Title</span>
@if (!string.IsNullOrWhiteSpace(ev.Location)) @if (!string.IsNullOrWhiteSpace(ev.Location))
{ {
@@ -25,7 +25,7 @@ public partial class EventsEmbed
[CascadingParameter] [CascadingParameter]
private HttpContext? HttpContext { get; set; } private HttpContext? HttpContext { get; set; }
private IReadOnlyList<Event> _upcoming = []; private IReadOnlyList<Event> _upcomingEvents = [];
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -45,7 +45,7 @@ public partial class EventsEmbed
// Teaser: the soonest upcoming events, earliest first. An event earlier // Teaser: the soonest upcoming events, earliest first. An event earlier
// today still counts as upcoming. // today still counts as upcoming.
var today = DateOnly.FromDateTime(DateTime.Today); var today = DateOnly.FromDateTime(DateTime.Today);
_upcoming = _upcomingEvents =
[ [
.. events .. events
.Where(@event => DateOnly.FromDateTime(@event.Start) >= today) .Where(@event => DateOnly.FromDateTime(@event.Start) >= today)
@@ -1,3 +1,4 @@
@using Elternbeirat.Web.Features.Posts
@if (_latest.Count > 0) @if (_latest.Count > 0)
{ {
<section class="embed embed-posts"> <section class="embed embed-posts">
@@ -7,9 +8,7 @@
{ {
<li> <li>
<a href="@($"/posts/{post.Slug}")">@post.Title</a> <a href="@($"/posts/{post.Slug}")">@post.Title</a>
<time datetime="@post.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)"> <PostDate Value="post.Date" />
@post.Date.ToString("d. MMMM yyyy", Cultures.German)
</time>
</li> </li>
} }
</ul> </ul>
@@ -0,0 +1,10 @@
@*
Renders an event's date as a <time> element: the start in the machine-readable
datetime attribute, the full German range (weekday, clock time, multi-day span)
as visible text via EventDateFormat.
Unlike <PostDate>, this takes the whole Event, not a plain day: the visible
range depends on the event's end and whether it has a clock time, and that
logic already lives in EventDateFormat.
*@
<time datetime="@Value.Start.ToString("s")">@EventDateFormat.Range(Value)</time>
@@ -0,0 +1,18 @@
using Elternbeirat.Contracts;
using Microsoft.AspNetCore.Components;
namespace Elternbeirat.Web.Features.Events;
/// <summary>
/// Renders an event's date range as a <c>&lt;time&gt;</c> element, in both a
/// machine-readable and a visitor-facing form.
/// </summary>
public partial class EventDate
{
/// <summary>
/// The event whose date range to render.
/// </summary>
[Parameter]
[EditorRequired]
public Event Value { get; set; } = null!;
}
@@ -1,6 +1,7 @@
using Elternbeirat.Contracts; using Elternbeirat.Contracts;
using Elternbeirat.Web.Shared;
namespace Elternbeirat.Web.Shared; namespace Elternbeirat.Web.Features.Events;
/// <summary> /// <summary>
/// Formats the date range of an <see cref="Event"/> for display in German. /// Formats the date range of an <see cref="Event"/> for display in German.
@@ -8,8 +9,13 @@ namespace Elternbeirat.Web.Shared;
/// <remarks> /// <remarks>
/// Shared by the full event list and the home-page events embed, so both render /// Shared by the full event list and the home-page events embed, so both render
/// dates the same way. /// dates the same way.
/// <para>
/// This is the event counterpart to
/// <see cref="Posts.PostDateFormat"/>, which formats a plain
/// <see cref="DateOnly"/> day instead of a point-in-time range.
/// </para>
/// </remarks> /// </remarks>
public static class EventFormat public static class EventDateFormat
{ {
/// <summary> /// <summary>
/// Date format for the start, and for a single-day event. /// Date format for the start, and for a single-day event.
@@ -18,17 +18,17 @@ else
<h2>Kommende Termine</h2> <h2>Kommende Termine</h2>
@if (_upcoming.Count == 0) @if (_upcomingEvents.Count == 0)
{ {
<p>Zurzeit sind keine Termine geplant.</p> <p>Zurzeit sind keine Termine geplant.</p>
} }
else else
{ {
<ul class="event-list"> <ul class="event-list">
@foreach (var ev in _upcoming) @foreach (var ev in _upcomingEvents)
{ {
<li> <li>
<time datetime="@ev.Start.ToString("s")">@EventFormat.Range(ev)</time> <EventDate Value="ev" />
<span class="event-title">@ev.Title</span> <span class="event-title">@ev.Title</span>
@if (!string.IsNullOrWhiteSpace(ev.Location)) @if (!string.IsNullOrWhiteSpace(ev.Location))
{ {
@@ -43,14 +43,14 @@ else
</ul> </ul>
} }
@if (_past.Count > 0) @if (_pastEvents.Count > 0)
{ {
<h2>Vergangene Termine</h2> <h2>Vergangene Termine</h2>
<ul class="event-list event-list-past"> <ul class="event-list event-list-past">
@foreach (var ev in _past) @foreach (var ev in _pastEvents)
{ {
<li> <li>
<time datetime="@ev.Start.ToString("s")">@EventFormat.Range(ev)</time> <EventDate Value="ev" />
<span class="event-title">@ev.Title</span> <span class="event-title">@ev.Title</span>
@if (!string.IsNullOrWhiteSpace(ev.Location)) @if (!string.IsNullOrWhiteSpace(ev.Location))
{ {
@@ -23,8 +23,8 @@ public partial class EventList
[CascadingParameter] [CascadingParameter]
private HttpContext? HttpContext { get; set; } private HttpContext? HttpContext { get; set; }
private IReadOnlyList<Event> _upcoming = []; private IReadOnlyList<Event> _upcomingEvents = [];
private IReadOnlyList<Event> _past = []; private IReadOnlyList<Event> _pastEvents = [];
// Set when the content source could not be reached or read. The markup then // Set when the content source could not be reached or read. The markup then
// shows a "temporarily unavailable" note instead of "no events", matching the // shows a "temporarily unavailable" note instead of "no events", matching the
@@ -51,13 +51,13 @@ public partial class EventList
// Split into upcoming and past by date, so an event earlier today still // Split into upcoming and past by date, so an event earlier today still
// counts as upcoming. Upcoming earliest first, past most recent first. // counts as upcoming. Upcoming earliest first, past most recent first.
var today = DateOnly.FromDateTime(DateTime.Today); var today = DateOnly.FromDateTime(DateTime.Today);
_upcoming = _upcomingEvents =
[ [
.. events .. events
.Where(@event => DateOnly.FromDateTime(@event.Start) >= today) .Where(@event => DateOnly.FromDateTime(@event.Start) >= today)
.OrderBy(@event => @event.Start) .OrderBy(@event => @event.Start)
]; ];
_past = _pastEvents =
[ [
.. events .. events
.Where(@event => DateOnly.FromDateTime(@event.Start) < today) .Where(@event => DateOnly.FromDateTime(@event.Start) < today)
@@ -0,0 +1,10 @@
@*
Renders a post's calendar day as a <time> element: the ISO value in the
machine-readable datetime attribute, the German long form as visible text.
A <time> needs the day in both forms, so putting it here keeps every caller
to a single tag instead of repeating the two PostDateFormat calls.
The event counterpart is <EventDate>, which renders a point in time (with an
optional clock time and range) instead of a plain day.
*@
<time datetime="@PostDateFormat.Iso(Value)">@PostDateFormat.Display(Value)</time>
@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Components;
namespace Elternbeirat.Web.Features.Posts;
/// <summary>
/// Renders a post's publication day as a <c>&lt;time&gt;</c> element, in both a
/// machine-readable and a visitor-facing form. The two formats come from
/// <see cref="PostDateFormat"/>.
/// </summary>
public partial class PostDate
{
/// <summary>
/// The post's publication day.
/// </summary>
[Parameter]
[EditorRequired]
public DateOnly Value { get; set; }
}
@@ -0,0 +1,9 @@
/* The date's own look: small and softened. Lives with the component so every
place that renders <PostDate> (list, home embed, detail page) shows the date
the same way. Positioning (margin to neighbours) is NOT set here -- that
depends on the surrounding context and is set by each caller. */
time {
display: block;
color: var(--color-text-soft); /* token, so it adapts in dark mode */
font-size: 0.85rem;
}
@@ -0,0 +1,55 @@
using System.Globalization;
using Elternbeirat.Web.Shared;
namespace Elternbeirat.Web.Features.Posts;
/// <summary>
/// Formats a post's calendar day for display and for a machine-readable
/// <c>datetime</c> attribute.
/// </summary>
/// <remarks>
/// Posts carry a <see cref="DateOnly"/> (a calendar day, no time of day). Their
/// <c>&lt;time&gt;</c> element needs the day twice: once in German for the
/// visitor (<see cref="Display"/>) and once as ISO 8601 for the <c>datetime</c>
/// attribute (<see cref="Iso"/>). Keeping both here means the two never drift
/// apart and the markup no longer repeats the same <c>ToString</c> pair.
/// <para>
/// This is the post counterpart to
/// <see cref="Events.EventDateFormat"/>, which formats a
/// <see cref="System.DateTime"/> range instead.
/// </para>
/// </remarks>
public static class PostDateFormat
{
/// <summary>
/// Display format for a calendar day, e.g. <c>8. Oktober 2026</c>. The
/// weekday is omitted, unlike the event format.
/// </summary>
private const string LongDate = "d. MMMM yyyy";
/// <summary>
/// Formats a calendar day in German for a visitor to read.
/// </summary>
/// <param name="value">
/// The calendar day to format.
/// </param>
/// <returns>
/// The day as e.g. <c>8. Oktober 2026</c>, using German month names.
/// </returns>
public static string Display(DateOnly value)
=> value.ToString(LongDate, Cultures.German);
/// <summary>
/// Formats a calendar day as ISO 8601 (<c>yyyy-MM-dd</c>) for a
/// machine-readable <c>datetime</c> attribute.
/// </summary>
/// <param name="value">
/// The calendar day to format.
/// </param>
/// <returns>
/// The day as e.g. <c>2026-10-08</c>, culture-invariant so the value is
/// stable regardless of the request's culture.
/// </returns>
public static string Iso(DateOnly value)
=> value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
}
@@ -12,10 +12,11 @@ else if (_post is not null)
<a href="/posts">&larr; Alle Beiträge</a> <a href="/posts">&larr; Alle Beiträge</a>
</nav> </nav>
<article> <article>
<time class="post-meta" datetime="@_post.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)"> <div class="post-content">
@_post.Date.ToString("d. MMMM yyyy", Cultures.German) <PostDate Value="_post.Date" />
</time> <h2>@_post.Title</h2>
@Body @Body
</div>
</article> </article>
} }
@* No post and not unavailable: the slug was unknown; NavigationManager.NotFound() @* No post and not unavailable: the slug was unknown; NavigationManager.NotFound()
@@ -1,7 +1,15 @@
.post-meta { /* Push the whole article body down from the "all posts" link above it. Wraps the
display: block; date, heading and body, so scoped CSS applies (the date sits in the PostDate
color: var(--color-text-soft); /* token, so it adapts in dark mode */ child component and would not be reachable on its own). The date's own look
font-size: 0.85rem; comes from PostDate.razor.css. */
margin-top: 1.5rem; /* space from the "all posts" link above */ .post-content {
margin-bottom: 0.25rem; /* keep the body close below */ margin-top: 1.5rem;
}
/* The post title is the first h2 in the content, sitting right under its date, so
drop the large default heading gap above it (h1..h4 carry --space-5 top margin
globally); date and title belong together. Scoped to :first-of-type so h2
subheadings inside the Markdown body keep their normal spacing. */
.post-content h2:first-of-type {
margin-top: var(--space-1);
} }
@@ -19,9 +19,7 @@ else
{ {
<li> <li>
<a href="@($"/posts/{post.Slug}")">@post.Title</a> <a href="@($"/posts/{post.Slug}")">@post.Title</a>
<time datetime="@post.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)"> <PostDate Value="post.Date" />
@post.Date.ToString("d. MMMM yyyy", Cultures.German)
</time>
</li> </li>
} }
</ul> </ul>
+5 -41
View File
@@ -124,16 +124,16 @@ h1, h2, h3, h4 {
} }
h1 { h1 {
font-size: 2rem; font-size: 1.5rem;
margin-top: 0; /* the first heading on a page needs no gap above */ margin-top: 0; /* the first heading on a page needs no gap above */
} }
h2 { h2 {
font-size: 1.5rem; font-size: 1.3rem;
} }
h3 { h3 {
font-size: 1.2rem; font-size: 1.15rem;
} }
/* Comfortable paragraph rhythm and a capped line length for readability. */ /* Comfortable paragraph rhythm and a capped line length for readability. */
@@ -221,11 +221,10 @@ h1:focus {
text-decoration: none; text-decoration: none;
} }
/* The date's look (small, softened) comes from PostDate.razor.css. Here only the
list-specific spacing: nudge the date down from the post's title link above. */
.post-list time { .post-list time {
display: block;
margin-top: var(--space-1); margin-top: var(--space-1);
color: var(--color-text-soft);
font-size: 0.85rem;
} }
/* Events: the date leads in the warm accent colour. */ /* Events: the date leads in the warm accent colour. */
@@ -354,38 +353,3 @@ h1:focus {
.embed-more a:hover span { .embed-more a:hover span {
transform: translateX(3px); transform: translateX(3px);
} }
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid #e50000;
}
.validation-message {
color: #e50000;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.darker-border-checkbox.form-check-input {
border-color: #929292;
}
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
color: var(--bs-secondary-color);
text-align: end;
}
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
}