Add post and event cards, shared calendar sheet and per-event ICS; rework past events and next-event card

This commit is contained in:
tleininger committed 2026-10-01 15:32:01 +02:00
1 parent d328adac5c
commit b2dc033de1
33 files changed
+1150 -206

No files matched your search

@@ -0,0 +1,10 @@
@*
A tear-off calendar sheet for an event's start: short month on a green band,
the day large, the weekday small. Pure decoration, hidden from screen readers:
every caller puts the full date (<EventDate>) next to it.
*@
<span class="calendar-sheet" aria-hidden="true">
<span class="calendar-month">@EventDateFormat.SheetMonth(Value)</span>
<span class="calendar-day">@EventDateFormat.SheetDay(Value)</span>
<span class="calendar-weekday">@EventDateFormat.SheetWeekday(Value)</span>
</span>
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Components;
namespace Elternbeirat.Web.Features.Events;
/// <summary>
/// Renders an event's start day as a calendar sheet (month, day, weekday).
/// </summary>
/// <remarks>
/// One component for every place that shows an event (the next-event card on the
/// home page, the events embed and the events page), so the sheets look the same
/// everywhere. The texts come from <see cref="EventDateFormat"/>.
/// </remarks>
/// <seealso cref="EventCard"/>
public partial class CalendarSheet
{
/// <summary>
/// The event's start, as Berlin wall-clock time.
/// </summary>
[Parameter]
[EditorRequired]
public DateTime Value { get; set; }
}
@@ -0,0 +1,41 @@
/* The calendar sheet: a green band with the month over a large day number and the
weekday. Fixed width, so the sheets of a list line up whatever the month. The band
has its own token (--color-calendar-band): the plain green accent is too light for
small white text, the band's darker green keeps it above AA in both schemes.
Positioning (gap to the text beside it) is the caller's job. */
.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;
line-height: 1.2;
}
.calendar-month {
padding: 0.15rem 0;
background: var(--color-calendar-band);
color: var(--color-on-button);
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.calendar-day {
padding-top: var(--space-1);
color: var(--color-heading);
font-size: 1.75rem;
font-weight: var(--font-weight-heading);
}
.calendar-weekday {
padding-bottom: var(--space-1);
color: var(--color-text-soft);
font-size: 0.8rem;
font-weight: 600;
}
@@ -0,0 +1,43 @@
@*
One event as a card: calendar sheet on the left, title, time, place and note on
the right. With an Href the whole card is that link (home embed); without one it
is a plain card (events page) and offers the event as a calendar file instead.
*@
@if (Href is null)
{
<div class="event-card">@Details</div>
}
else
{
<a class="event-card event-card-link" href="@Href">@Details</a>
}
@code {
// The card's content, the same in both forms. A template, not a child component,
// so the wrapper above stays the only difference between the two.
private RenderFragment Details => @<text>
<CalendarSheet Value="Value.Start" />
<span class="event-details">
<span class="event-title">@Value.Title</span>
@* The sheet is hidden from screen readers; this is the date they hear. *@
<span class="visually-hidden"><EventDate Value="Value" /></span>
@if (Span.Length > 0)
{
<span class="event-meta"><Icon Name="clock" Size="16" /> @Span</span>
}
@if (!string.IsNullOrWhiteSpace(Value.Location))
{
<span class="event-meta"><Icon Name="map-pin" Size="16" /> @Value.Location</span>
}
@if (!string.IsNullOrWhiteSpace(Value.Note))
{
<span class="event-note">@Value.Note</span>
}
@* A link inside a link is invalid HTML, so only the plain card offers it. *@
@if (Href is null)
{
<a class="event-ics" href="@IcsHref"><Icon Name="calendar-plus" Size="16" /> In Kalender übernehmen</a>
}
</span>
</text>;
}
@@ -0,0 +1,55 @@
using Elternbeirat.Contracts;
using Microsoft.AspNetCore.Components;
namespace Elternbeirat.Web.Features.Events;
/// <summary>
/// Renders one event as a card with a calendar sheet, time, place and note.
/// </summary>
/// <remarks>
/// Shared by the events page and the home-page events embed, so both show events
/// the same way. They differ only in what the card leads to:
/// <list type="bullet">
/// <item>
/// <description>
/// In the embed the whole card links to the events page
/// (<see cref="Href"/> set).
/// </description>
/// </item>
/// <item>
/// <description>
/// On the events page itself a link back to the same page would lead
/// nowhere, so the card is no link (<see cref="Href"/> is
/// <see langword="null"/>) and offers "In Kalender übernehmen" instead.
/// </description>
/// </item>
/// </list>
/// </remarks>
/// <seealso cref="CalendarSheet"/>
/// <seealso cref="IcsCalendar"/>
public partial class EventCard
{
/// <summary>
/// The event to show.
/// </summary>
[Parameter]
[EditorRequired]
public Event Value { get; set; } = null!;
/// <summary>
/// Where the card links to, or <see langword="null"/> for a card that is no link
/// and offers the event as a calendar file instead.
/// </summary>
[Parameter]
public string? Href { get; set; }
/// <summary>
/// The time line beside the sheet; empty for a single all-day event.
/// </summary>
private string Span => EventDateFormat.Span(Value);
/// <summary>
/// The address of this one event as a calendar file.
/// </summary>
private string IcsHref => $"/events/{Uri.EscapeDataString(Value.Id)}.ics";
}
@@ -0,0 +1,87 @@
/* The event card: a white surface on the tinted page, sheet left, text right. The
card fills its list cell, so the cards of a row are equally tall. */
.event-card {
box-sizing: border-box;
display: flex;
gap: var(--space-3);
align-items: flex-start;
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);
}
/* Only the linked card reacts to hover: a card that leads nowhere must not look
clickable. Hover = stronger shadow and a blue edge, nothing moves. */
.event-card-link {
text-decoration: none;
transition: box-shadow var(--transition), border-color var(--transition);
}
.event-card-link:hover {
border-color: var(--color-brand);
box-shadow: var(--shadow-hover);
color: var(--color-text);
}
.event-card-link:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 2px;
}
.event-details {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 0; /* lets a long title wrap instead of widening the card */
}
.event-title {
color: var(--color-brand);
font-size: 1.15rem;
font-weight: 700;
line-height: 1.3;
}
/* Time and place: icon and text on one quiet line each. The icon belongs to Icon's
own scope, so it is reached through ::deep. */
.event-meta {
display: flex;
align-items: center;
gap: var(--space-1);
color: var(--color-text-soft);
font-size: 0.9rem;
}
.event-meta ::deep .icon {
color: var(--color-accent-dark);
}
.event-note {
margin-top: var(--space-1);
font-size: 0.95rem;
}
/* In the linked card (home embed) the note is only a teaser: cut after two lines,
so a long note does not stretch the card next to short ones. The full note is
on the events page. */
.event-card-link .event-note {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
.event-ics {
display: inline-flex;
align-items: center;
gap: var(--space-1);
align-self: flex-start;
margin-top: var(--space-2);
font-size: 0.9rem;
font-weight: 600;
}
@@ -7,8 +7,10 @@ namespace Elternbeirat.Web.Features.Events;
/// 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.
/// Shared by the full event list, the home-page events embed and the next-event
/// card, so all render dates the same way: <see cref="Range"/> as the full date,
/// the <c>Sheet…</c> methods and <see cref="Span"/> for the calendar sheet and the
/// time line beside it, <see cref="PastDays"/> and <see cref="PastTime"/> for the list of past events.
/// <para>
/// This is the event counterpart to
/// <see cref="Posts.PostDateFormat"/>, which formats a plain
@@ -27,6 +29,11 @@ public static class EventDateFormat
/// </summary>
private const string ShortDate = "d. MMMM yyyy";
/// <summary>
/// Date format for the list of past events, which is grouped by year.
/// </summary>
private const string DayMonth = "d. MMMM";
/// <summary>
/// Formats the date range of an event.
/// </summary>
@@ -54,13 +61,136 @@ public static class EventDateFormat
(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",
=> $"{start.ToString(LongDate, Cultures.German)}, {Clock(start)}–{Clock(end)} 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>
/// The short month for the top band of a calendar sheet.
/// </summary>
/// <param name="value">The event's start.</param>
/// <returns>The German short month without its period, e.g. <c>Okt</c> or <c>März</c>.</returns>
/// <remarks>
/// Returned in normal case; the sheet's CSS sets it in capitals. The period of the
/// abbreviation (<c>Okt.</c>) is dropped because on a sheet it reads as a stray dot.
/// </remarks>
/// <seealso cref="CalendarSheet"/>
public static string SheetMonth(DateTime value)
=> value.ToString("MMM", Cultures.German).TrimEnd('.');
/// <summary>
/// The day of the month for the large number on a calendar sheet.
/// </summary>
/// <param name="value">The event's start.</param>
/// <returns>The day without a leading zero, e.g. <c>8</c>.</returns>
public static string SheetDay(DateTime value)
=> value.Day.ToString(Cultures.German);
/// <summary>
/// The short weekday for the bottom of a calendar sheet.
/// </summary>
/// <param name="value">The event's start.</param>
/// <returns>The German short weekday without its period, e.g. <c>Do</c>.</returns>
public static string SheetWeekday(DateTime value)
=> value.ToString("ddd", Cultures.German).TrimEnd('.');
/// <summary>
/// The time line beside a calendar sheet: what the sheet itself cannot show.
/// </summary>
/// <param name="event">The event to format.</param>
/// <returns>
/// Depending on <see cref="Event.End"/> and <see cref="Event.HasTime"/>:
/// <list type="bullet">
/// <item><description>No end: <c>19:30 Uhr</c></description></item>
/// <item><description>Same day: <c>19:30–21:00 Uhr</c></description></item>
/// <item><description>Several days: <c>09:00 Uhr bis Sonntag, 11. Oktober 2026, 13:00 Uhr</c></description></item>
/// <item><description>Several days, no time: <c>bis Sonntag, 11. Oktober 2026</c></description></item>
/// <item><description>One day, no time: an empty string; the sheet says it all.</description></item>
/// </list>
/// </returns>
/// <remarks>
/// The sheet shows the start day only, so a multi-day event must name its last
/// day here, with the weekday, as the sheet does for the first one.
/// </remarks>
public static string Span(Event @event)
=> (@event.Start, @event.End, @event.HasTime) switch
{
(var start, { } end, var hasTime) when end.Date != start.Date
=> (hasTime ? Clock(start) + " Uhr " : "") + "bis " + Stamp(end, LongDate, hasTime),
(var start, null, true)
=> $"{Clock(start)} Uhr",
(var start, { } end, true)
=> $"{Clock(start)}–{Clock(end)} Uhr",
_ => "",
};
/// <summary>
/// The day column of a past event in the folded-up list on the events page.
/// </summary>
/// <param name="event">The event to format.</param>
/// <returns>
/// Day and full month, depending on <see cref="Event.End"/>:
/// <list type="bullet">
/// <item><description>One day: <c>13. November</c></description></item>
/// <item><description>Several days in one month: <c>8.–9. Februar</c></description></item>
/// <item><description>Across a month: <c>30. Januar – 2. Februar</c></description></item>
/// <item><description>Across a year: <c>30. Dezember – 2. Januar 2027</c></description></item>
/// </list>
/// </returns>
/// <remarks>
/// The list is grouped by the start year under its own small heading, so the
/// year is only named where the end leaves that year. The weekday is left out
/// to keep the column short; the time follows the title (<see cref="PastTime"/>).
/// </remarks>
public static string PastDays(Event @event)
=> (@event.Start, @event.End) switch
{
(var start, { } end) when end.Year != start.Year
=> $"{start.ToString(DayMonth, Cultures.German)} – {end.ToString(ShortDate, Cultures.German)}",
(var start, { } end) when end.Month != start.Month
=> $"{start.ToString(DayMonth, Cultures.German)} – {end.ToString(DayMonth, Cultures.German)}",
(var start, { } end) when end.Day != start.Day
=> $"{start.Day.ToString(Cultures.German)}.–{end.ToString(DayMonth, Cultures.German)}",
(var start, _)
=> start.ToString(DayMonth, Cultures.German),
};
/// <summary>
/// The time after the title of a past event in the folded-up list.
/// </summary>
/// <param name="event">The event to format.</param>
/// <returns>
/// Depending on <see cref="Event.HasTime"/> and <see cref="Event.End"/>:
/// <list type="bullet">
/// <item><description>No time: an empty string; nothing is shown.</description></item>
/// <item><description>No end: <c>19:00 Uhr</c></description></item>
/// <item><description>Same day: <c>19:00–21:00 Uhr</c></description></item>
/// <item><description>Several days: the start time only, <c>09:00 Uhr</c></description></item>
/// </list>
/// </returns>
/// <remarks>
/// For several days the day column already shows the span; a time range beside
/// it would read as the daily hours, so only the start time is given.
/// </remarks>
public static string PastTime(Event @event)
=> (@event.HasTime, @event.End) switch
{
(false, _) => "",
(true, { } end) when end.Date != @event.Start.Date => $"{Clock(@event.Start)} Uhr",
_ => Span(@event),
};
/// <summary>
/// Formats the clock time of a point in time.
/// </summary>
/// <param name="value">The date and time.</param>
/// <returns>The time as e.g. <c>19:30</c>.</returns>
private static string Clock(DateTime value)
=> value.ToString("HH:mm", Cultures.German);
/// <summary>
/// Formats a single point in time as a date with an optional time.
/// </summary>
@@ -21,10 +21,9 @@
}
else
{
<p class="intro">
Alle Termine des Elternbeirats auf einen Blick. Sie können den Kalender auch
<a href="/events.ics">abonnieren</a> und in Ihrer Kalender-App automatisch
aktuell halten.
@* The page body above is the intro; the page only adds the subscribe button. *@
<p class="events-subscribe">
<a class="btn btn-secondary" href="/events.ics"><Icon Name="calendar-plus" Size="18" /> Kalender abonnieren</a>
</p>
<h2>Kommende Termine</h2>
@@ -38,41 +37,38 @@ else
<ul class="event-list">
@foreach (var ev in _upcomingEvents)
{
<li>
<a class="card-link" href="/events">
<span class="event-title">@ev.Title</span>
<EventDate Value="ev" />
@if (!string.IsNullOrWhiteSpace(ev.Location))
{
<span class="event-location">@ev.Location</span>
}
@if (!string.IsNullOrWhiteSpace(ev.Note))
{
<p class="event-note">@ev.Note</p>
}
</a>
</li>
@* No Href: a card linking to this very page would lead nowhere. *@
<li><EventCard Value="ev" /></li>
}
</ul>
}
@* Past events matter rarely, so they wait folded up and compact: grouped by year,
then day, title and (if the event has one) the time per row. *@
@if (_pastEvents.Count > 0)
{
<h2>Vergangene Termine</h2>
<ul class="event-list event-list-past">
@foreach (var ev in _pastEvents)
<details class="past-events disclosure">
<summary>Vergangene Termine anzeigen (@_pastEvents.Count)</summary>
@foreach (var year in PastYears)
{
<li>
<a class="card-link" href="/events">
<span class="event-title">@ev.Title</span>
<EventDate Value="ev" />
@if (!string.IsNullOrWhiteSpace(ev.Location))
{
<span class="event-location">@ev.Location</span>
}
</a>
</li>
<h3 class="past-events-year">@year.Key</h3>
<ul>
@foreach (var ev in year)
{
var time = EventDateFormat.PastTime(ev);
<li>
<span class="past-event-date">@EventDateFormat.PastDays(ev)</span>
<span class="past-event-title">
@ev.Title
@if (time.Length > 0)
{
<span class="past-event-time">@time</span>
}
</span>
</li>
}
</ul>
}
</ul>
</details>
}
}
@@ -39,6 +39,10 @@ public partial class EventList
// 503 this sets on the response.
private bool _unavailable;
// Past events grouped by year for the small year headings. _pastEvents is most
// recent first, and GroupBy keeps that order, so the newest year comes first.
private IEnumerable<IGrouping<int, Event>> PastYears => _pastEvents.GroupBy(@event => @event.Start.Year);
// The page body rendered from Markdown to HTML, ready to emit as a MarkupString.
private MarkupString Body => (MarkupString)Markdown.ToHtml(_page?.Body);
@@ -1,2 +1,70 @@
/* The .event-list card styles (including past-event styling) are shared with the
home-page embed and live in the global app.css. Nothing page-specific here. */
/* The card grid (.event-list) is shared with the home-page embed and lives in the
global app.css; the card itself is EventCard. Only the page's own parts are here. */
.events-subscribe {
margin: var(--space-4) 0;
}
/* Past events: one folded-up card. Opened, a compact list grouped by year,
deliberately quieter than the upcoming cards above. The open/close triangle is
the shared .disclosure rule in app.css, the same as on the FAQ topics. */
.past-events {
margin-top: var(--space-5);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
}
.past-events-year {
margin: var(--space-4) 0 var(--space-1);
color: var(--color-text-soft);
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.04em;
}
.past-events ul {
margin: 0;
padding: 0;
list-style: none;
}
/* On a phone the day sits above the title: a column beside it would leave the
title too little room. */
.past-events li {
display: grid;
padding: var(--space-2) 0;
border-top: 1px solid var(--color-border);
}
/* From tablet width on, day and title side by side. The day column has a fixed
width, so every title starts on the same line; it fits the longest single day,
"30. September". A span across months ("30. Januar – 2. Februar") wraps after
the dash inside the column. Same font size for both and baseline alignment, so
the two texts sit on one line. */
@media (min-width: 40rem) {
.past-events li {
grid-template-columns: 8rem 1fr;
column-gap: var(--space-3);
align-items: baseline;
}
}
/* Tabular digits give every day the same width, so "1." and "10." line up. */
.past-event-date {
color: var(--color-text-soft);
font-variant-numeric: tabular-nums;
}
.past-event-title {
color: var(--color-text);
}
/* The time trails the title, quieter, as an addition rather than part of the name. */
.past-event-time {
margin-left: var(--space-2);
color: var(--color-text-soft);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}