Extract date rendering into per-feature Date components
This commit is contained in:
1 parent
d95bf0dcbe
commit
455b603299
16 files changed
+168
-71
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><time></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!;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Elternbeirat.Contracts;
|
||||
using Elternbeirat.Web.Shared;
|
||||
|
||||
namespace Elternbeirat.Web.Features.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Formats the date range of an <see cref="Event"/> for display in German.
|
||||
/// </summary>
|
||||
/// <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 EventDateFormat
|
||||
{
|
||||
/// <summary>
|
||||
/// Date format for the start, and for a single-day event.
|
||||
/// </summary>
|
||||
private const string LongDate = "dddd, d. MMMM yyyy";
|
||||
|
||||
/// <summary>
|
||||
/// Date format for the end of a multi-day event; the weekday is omitted.
|
||||
/// </summary>
|
||||
private const string ShortDate = "d. MMMM yyyy";
|
||||
|
||||
/// <summary>
|
||||
/// Formats the date range of an event.
|
||||
/// </summary>
|
||||
/// <param name="event">
|
||||
/// The event to format.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// The formatted range, depending on <see cref="Event.End"/> and <see cref="Event.HasTime"/>:
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <description>No end: <c>Donnerstag, 8. Oktober 2026, 19:30 Uhr</c></description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>Same day: <c>Donnerstag, 8. Oktober 2026, 19:30–21:00 Uhr</c></description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>Several days: <c>Freitag, 9. Oktober 2026 – 11. Oktober 2026</c></description>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// Without a time, the time parts are omitted.
|
||||
/// </returns>
|
||||
public static string Range(Event @event)
|
||||
=> (@event.Start, @event.End, @event.HasTime) switch
|
||||
{
|
||||
(var start, null, var hasTime)
|
||||
=> Stamp(start, LongDate, hasTime),
|
||||
(var start, { } end, true) when end.Date == start.Date
|
||||
=> $"{start.ToString(LongDate, Cultures.German)}, {start.ToString("HH:mm", Cultures.German)}–{end.ToString("HH:mm", Cultures.German)} Uhr",
|
||||
(var start, { } end, false) when end.Date == start.Date
|
||||
=> Stamp(start, LongDate, withTime: false),
|
||||
(var start, { } end, var hasTime)
|
||||
=> $"{Stamp(start, LongDate, hasTime)} – {Stamp(end, ShortDate, hasTime)}",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Formats a single point in time as a date with an optional time.
|
||||
/// </summary>
|
||||
/// <param name="value">
|
||||
/// The date and time to format.
|
||||
/// </param>
|
||||
/// <param name="dateFormat">
|
||||
/// The date format, e.g. <see cref="LongDate"/>.
|
||||
/// </param>
|
||||
/// <param name="withTime">
|
||||
/// <see langword="true"/> to append the time, e.g. <c>, 19:30 Uhr</c>.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// The formatted date, with the time if requested.
|
||||
/// </returns>
|
||||
private static string Stamp(DateTime value, string dateFormat, bool withTime)
|
||||
=> withTime
|
||||
? value.ToString(dateFormat + ", HH:mm", Cultures.German) + " Uhr"
|
||||
: value.ToString(dateFormat, Cultures.German);
|
||||
}
|
||||
@@ -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><time></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><time></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">← 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>
|
||||
@Body
|
||||
<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>
|
||||
|
||||
Reference in new issue
Block a user