Compare commits

...
3 Commits
Author SHA1 Message Date
Tom 13d35f4d27 Merge pull request 'Refactor/ui date components' (#34) from refactor/ui-date-components into main
Build and push image / build (push) Successful in 2m14s
Reviewed-on: #34
2026-09-25 09:12:49 +02:00
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">
<h2>Kommende Termine</h2>
<ul class="event-list">
@foreach (var ev in _upcoming)
@foreach (var ev in _upcomingEvents)
{
<li>
<time datetime="@ev.Start.ToString("s")">@EventFormat.Range(ev)</time>
<EventDate Value="ev" />
<span class="event-title">@ev.Title</span>
@if (!string.IsNullOrWhiteSpace(ev.Location))
{
@@ -25,7 +25,7 @@ public partial class EventsEmbed
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private IReadOnlyList<Event> _upcoming = [];
private IReadOnlyList<Event> _upcomingEvents = [];
protected override async Task OnInitializedAsync()
{
@@ -45,7 +45,7 @@ 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);
_upcoming =
_upcomingEvents =
[
.. events
.Where(@event => DateOnly.FromDateTime(@event.Start) >= today)
@@ -1,3 +1,4 @@
@using Elternbeirat.Web.Features.Posts
@if (_latest.Count > 0)
{
<section class="embed embed-posts">
@@ -7,9 +8,7 @@
{
<li>
<a href="@($"/posts/{post.Slug}")">@post.Title</a>
<time datetime="@post.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)">
@post.Date.ToString("d. MMMM yyyy", Cultures.German)
</time>
<PostDate Value="post.Date" />
</li>
}
</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.Web.Shared;
namespace Elternbeirat.Web.Shared;
namespace Elternbeirat.Web.Features.Events;
/// <summary>
/// Formats the date range of an <see cref="Event"/> for display in German.
@@ -8,8 +9,13 @@ namespace Elternbeirat.Web.Shared;
/// <remarks>
/// Shared by the full event list and the home-page events embed, so both render
/// 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>
public static class EventFormat
public static class EventDateFormat
{
/// <summary>
/// Date format for the start, and for a single-day event.
@@ -18,17 +18,17 @@ else
<h2>Kommende Termine</h2>
@if (_upcoming.Count == 0)
@if (_upcomingEvents.Count == 0)
{
<p>Zurzeit sind keine Termine geplant.</p>
}
else
{
<ul class="event-list">
@foreach (var ev in _upcoming)
@foreach (var ev in _upcomingEvents)
{
<li>
<time datetime="@ev.Start.ToString("s")">@EventFormat.Range(ev)</time>
<EventDate Value="ev" />
<span class="event-title">@ev.Title</span>
@if (!string.IsNullOrWhiteSpace(ev.Location))
{
@@ -43,14 +43,14 @@ else
</ul>
}
@if (_past.Count > 0)
@if (_pastEvents.Count > 0)
{
<h2>Vergangene Termine</h2>
<ul class="event-list event-list-past">
@foreach (var ev in _past)
@foreach (var ev in _pastEvents)
{
<li>
<time datetime="@ev.Start.ToString("s")">@EventFormat.Range(ev)</time>
<EventDate Value="ev" />
<span class="event-title">@ev.Title</span>
@if (!string.IsNullOrWhiteSpace(ev.Location))
{
@@ -23,8 +23,8 @@ public partial class EventList
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private IReadOnlyList<Event> _upcoming = [];
private IReadOnlyList<Event> _past = [];
private IReadOnlyList<Event> _upcomingEvents = [];
private IReadOnlyList<Event> _pastEvents = [];
// 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
@@ -51,13 +51,13 @@ 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);
_upcoming =
_upcomingEvents =
[
.. events
.Where(@event => DateOnly.FromDateTime(@event.Start) >= today)
.OrderBy(@event => @event.Start)
];
_past =
_pastEvents =
[
.. events
.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>
</nav>
<article>
<time class="post-meta" datetime="@_post.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)">
@_post.Date.ToString("d. MMMM yyyy", Cultures.German)
</time>
<div class="post-content">
<PostDate Value="_post.Date" />
<h2>@_post.Title</h2>
@Body
</div>
</article>
}
@* No post and not unavailable: the slug was unknown; NavigationManager.NotFound()
@@ -1,7 +1,15 @@
.post-meta {
display: block;
color: var(--color-text-soft); /* token, so it adapts in dark mode */
font-size: 0.85rem;
margin-top: 1.5rem; /* space from the "all posts" link above */
margin-bottom: 0.25rem; /* keep the body close below */
/* Push the whole article body down from the "all posts" link above it. Wraps the
date, heading and body, so scoped CSS applies (the date sits in the PostDate
child component and would not be reachable on its own). The date's own look
comes from PostDate.razor.css. */
.post-content {
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>
<a href="@($"/posts/{post.Slug}")">@post.Title</a>
<time datetime="@post.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)">
@post.Date.ToString("d. MMMM yyyy", Cultures.German)
</time>
<PostDate Value="post.Date" />
</li>
}
</ul>
+5 -41
View File
@@ -124,16 +124,16 @@ h1, h2, h3, h4 {
}
h1 {
font-size: 2rem;
font-size: 1.5rem;
margin-top: 0; /* the first heading on a page needs no gap above */
}
h2 {
font-size: 1.5rem;
font-size: 1.3rem;
}
h3 {
font-size: 1.2rem;
font-size: 1.15rem;
}
/* Comfortable paragraph rhythm and a capped line length for readability. */
@@ -221,11 +221,10 @@ h1:focus {
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 {
display: block;
margin-top: var(--space-1);
color: var(--color-text-soft);
font-size: 0.85rem;
}
/* Events: the date leads in the warm accent colour. */
@@ -354,38 +353,3 @@ h1:focus {
.embed-more a:hover span {
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;
}