From b2dc033de10652eab7666fd47d072baf2d1d52dc Mon Sep 17 00:00:00 2001 From: tleininger Date: Thu, 1 Oct 2026 15:32:01 +0200 Subject: [PATCH] Add post and event cards, shared calendar sheet and per-event ICS; rework past events and next-event card --- .../EventDateFormatTests.cs | 105 ++++++++++++++ Elternbeirat.Web.Tests/MarkdownTests.cs | 59 ++++++++ .../PocketBaseClientTests.cs | 4 +- Elternbeirat.Web.Tests/PocketBaseFixture.cs | 14 ++ Elternbeirat.Web.Tests/RouteSmokeTests.cs | 129 +++++++++++++++++ .../Features/Embeds/EventsEmbed.razor | 12 +- .../Features/Embeds/EventsEmbed.razor.css | 4 +- .../Features/Embeds/PostsEmbed.razor | 7 +- .../Features/Embeds/PostsEmbed.razor.css | 4 +- .../Features/Events/CalendarSheet.razor | 10 ++ .../Features/Events/CalendarSheet.razor.cs | 22 +++ .../Features/Events/CalendarSheet.razor.css | 41 ++++++ .../Features/Events/EventCard.razor | 43 ++++++ .../Features/Events/EventCard.razor.cs | 55 +++++++ .../Features/Events/EventCard.razor.css | 87 +++++++++++ .../Features/Events/EventDateFormat.cs | 136 +++++++++++++++++- .../Features/Events/EventList.razor | 60 ++++---- .../Features/Events/EventList.razor.cs | 4 + .../Features/Events/EventList.razor.css | 72 +++++++++- Elternbeirat.Web/Features/Faqs/FaqList.razor | 2 +- Elternbeirat.Web/Features/Home/Home.razor | 19 +-- Elternbeirat.Web/Features/Home/Home.razor.cs | 8 -- Elternbeirat.Web/Features/Home/Home.razor.css | 40 +----- .../Features/Posts/PostCard.razor | 13 ++ .../Features/Posts/PostCard.razor.cs | 46 ++++++ .../Features/Posts/PostCard.razor.css | 93 ++++++++++++ .../Features/Posts/PostList.razor | 13 +- .../Features/Posts/PostList.razor.css | 14 +- Elternbeirat.Web/Program.cs | 22 +++ Elternbeirat.Web/Shared/Markdown.cs | 73 ++++++++++ Elternbeirat.Web/wwwroot/app.css | 119 +++++---------- docs/redaktion.md | 24 +++- pb/pb_migrations/1727200000_dev_seed.js | 2 +- 33 files changed, 1150 insertions(+), 206 deletions(-) create mode 100644 Elternbeirat.Web.Tests/EventDateFormatTests.cs create mode 100644 Elternbeirat.Web/Features/Events/CalendarSheet.razor create mode 100644 Elternbeirat.Web/Features/Events/CalendarSheet.razor.cs create mode 100644 Elternbeirat.Web/Features/Events/CalendarSheet.razor.css create mode 100644 Elternbeirat.Web/Features/Events/EventCard.razor create mode 100644 Elternbeirat.Web/Features/Events/EventCard.razor.cs create mode 100644 Elternbeirat.Web/Features/Events/EventCard.razor.css create mode 100644 Elternbeirat.Web/Features/Posts/PostCard.razor create mode 100644 Elternbeirat.Web/Features/Posts/PostCard.razor.cs create mode 100644 Elternbeirat.Web/Features/Posts/PostCard.razor.css diff --git a/Elternbeirat.Web.Tests/EventDateFormatTests.cs b/Elternbeirat.Web.Tests/EventDateFormatTests.cs new file mode 100644 index 0000000..4fbda37 --- /dev/null +++ b/Elternbeirat.Web.Tests/EventDateFormatTests.cs @@ -0,0 +1,105 @@ +using Elternbeirat.Contracts; +using Elternbeirat.Web.Features.Events; + +namespace Elternbeirat.Web.Tests; + +/// +/// Unit tests for the calendar sheet texts and the time line beside it. Pure logic, +/// no PocketBase needed. All times are Berlin wall-clock, as the contract delivers them. +/// +public class EventDateFormatTests +{ + private static Event Event(DateTime start, DateTime? end = null) => + new() { Id = "test", Title = "Termin", Start = start, End = end }; + + [Theory] + [InlineData(1, "Jan")] + [InlineData(3, "März")] + [InlineData(5, "Mai")] + [InlineData(10, "Okt")] + [InlineData(12, "Dez")] + public void Sheet_month_is_the_German_short_month_without_period(int month, string expected) => + EventDateFormat.SheetMonth(new DateTime(2026, month, 1)).ShouldBe(expected); + + [Fact] + public void Sheet_day_has_no_leading_zero() => + EventDateFormat.SheetDay(new DateTime(2026, 10, 8, 19, 30, 0)).ShouldBe("8"); + + [Theory] + [InlineData(8, "Do")] // Thursday, 8 October 2026 + [InlineData(11, "So")] // Sunday + public void Sheet_weekday_is_the_German_short_weekday_without_period(int day, string expected) => + EventDateFormat.SheetWeekday(new DateTime(2026, 10, day)).ShouldBe(expected); + + [Fact] + public void Past_all_day_event_shows_the_day_and_no_time() + { + var allDay = Event(new DateTime(2026, 11, 13)); + + EventDateFormat.PastDays(allDay).ShouldBe("13. November"); + EventDateFormat.PastTime(allDay).ShouldBe(""); + } + + [Fact] + public void Past_timed_event_shows_the_day_and_its_start_time() + { + var timed = Event(new DateTime(2026, 9, 1, 19, 0, 0)); + + EventDateFormat.PastDays(timed).ShouldBe("1. September"); // no leading zero + EventDateFormat.PastTime(timed).ShouldBe("19:00 Uhr"); + } + + [Fact] + public void Past_timed_event_ending_the_same_day_shows_the_time_range() + { + var evening = Event(new DateTime(2026, 11, 13, 19, 0, 0), new DateTime(2026, 11, 13, 21, 0, 0)); + + EventDateFormat.PastDays(evening).ShouldBe("13. November"); + EventDateFormat.PastTime(evening).ShouldBe("19:00–21:00 Uhr"); + } + + [Fact] + public void Past_multi_day_event_in_one_month_names_the_month_once() => + EventDateFormat.PastDays(Event(new DateTime(2026, 2, 8), new DateTime(2026, 2, 9))).ShouldBe("8.–9. Februar"); + + [Fact] + public void Past_multi_day_event_across_a_month_names_both_months() => + EventDateFormat.PastDays(Event(new DateTime(2026, 1, 30), new DateTime(2026, 2, 2))) + .ShouldBe("30. Januar – 2. Februar"); + + [Fact] + public void Past_multi_day_event_across_a_year_names_the_end_year() => + // The list is grouped by the start year, so only the end needs its year. + EventDateFormat.PastDays(Event(new DateTime(2026, 12, 30), new DateTime(2027, 1, 2))) + .ShouldBe("30. Dezember – 2. Januar 2027"); + + [Fact] + public void Past_timed_multi_day_event_shows_only_the_start_time() => + // A range here would read as the daily hours. + EventDateFormat.PastTime(Event(new DateTime(2026, 2, 8, 9, 0, 0), new DateTime(2026, 2, 9, 13, 0, 0))) + .ShouldBe("09:00 Uhr"); + + [Fact] + public void Span_of_a_timed_event_without_end_is_its_start_time() => + EventDateFormat.Span(Event(new DateTime(2026, 10, 8, 19, 30, 0))).ShouldBe("19:30 Uhr"); + + [Fact] + public void Span_of_a_same_day_event_is_the_time_range() => + EventDateFormat.Span(Event(new DateTime(2026, 10, 8, 19, 30, 0), new DateTime(2026, 10, 8, 21, 0, 0))) + .ShouldBe("19:30–21:00 Uhr"); + + [Fact] + public void Span_of_a_multi_day_event_names_the_last_day() => + EventDateFormat.Span(Event(new DateTime(2026, 10, 9, 9, 0, 0), new DateTime(2026, 10, 11, 13, 0, 0))) + .ShouldBe("09:00 Uhr bis Sonntag, 11. Oktober 2026, 13:00 Uhr"); + + [Fact] + public void Span_of_a_multi_day_all_day_event_has_no_times() => + EventDateFormat.Span(Event(new DateTime(2026, 10, 9), new DateTime(2026, 10, 11))) + .ShouldBe("bis Sonntag, 11. Oktober 2026"); + + [Fact] + public void Span_of_a_single_all_day_event_is_empty() => + // The sheet already shows the day; there is nothing left to say. + EventDateFormat.Span(Event(new DateTime(2026, 10, 8))).ShouldBe(""); +} diff --git a/Elternbeirat.Web.Tests/MarkdownTests.cs b/Elternbeirat.Web.Tests/MarkdownTests.cs index 969102a..f92c63c 100644 --- a/Elternbeirat.Web.Tests/MarkdownTests.cs +++ b/Elternbeirat.Web.Tests/MarkdownTests.cs @@ -125,4 +125,63 @@ public class MarkdownTests [InlineData("::: hinweis\nText im Baustein.\n:::")] public void First_sentence_is_empty_without_a_top_level_paragraph(string? markdown) => Markdown.FirstSentence(markdown).ShouldBe(""); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("## Nur eine Überschrift")] + public void Teaser_is_empty_without_paragraph_text(string? markdown) => + Markdown.Teaser(markdown).ShouldBe(""); + + [Fact] + public void Teaser_keeps_a_short_body_whole() => + Markdown.Teaser("Kurzer Text.").ShouldBe("Kurzer Text."); + + [Fact] + public void Teaser_removes_markdown_and_skips_headings() + { + // Emphasis and link markup go, link text stays; the heading would repeat the + // card title, so it is left out. Paragraphs and list items are joined. + var teaser = Markdown.Teaser( + "## Rückblick\n\nDer **Basar** war ein [Erfolg](/posts).\n\n- Danke an alle\n- Bis bald"); + + teaser.ShouldBe("Der Basar war ein Erfolg. Danke an alle Bis bald"); + } + + [Fact] + public void Teaser_cuts_a_long_body_at_a_word_boundary() + { + var teaser = Markdown.Teaser("Der Basar war ein voller Erfolg.", 20); + + // 20 characters end inside "voller"; the cut falls back to the space before it. + teaser.ShouldBe("Der Basar war ein…"); + } + + [Fact] + public void Teaser_keeps_a_word_that_ends_exactly_at_the_limit() => + Markdown.Teaser("Der Basar war ein voller Erfolg.", 17).ShouldBe("Der Basar war ein…"); + + [Fact] + public void Teaser_drops_punctuation_left_at_the_cut() => + Markdown.Teaser("Liebe Eltern, der Basar war schön.", 14).ShouldBe("Liebe Eltern…"); + + [Fact] + public void Teaser_cuts_a_single_overlong_word_hard() => + Markdown.Teaser("Donaudampfschifffahrtsgesellschaft", 10).ShouldBe("Donaudampf…"); + + [Fact] + public void Teaser_defaults_to_160_characters_plus_ellipsis() + { + var body = string.Join(' ', Enumerable.Repeat("Wort", 100)); // 499 characters + + var teaser = Markdown.Teaser(body); + + teaser.Length.ShouldBeLessThanOrEqualTo(Markdown.TeaserLength + 1); + teaser.ShouldEndWith("Wort…"); + } + + [Fact] + public void Teaser_rejects_a_length_of_zero() => + Should.Throw(() => Markdown.Teaser("Text", 0)); } diff --git a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs index f875e29..ee1993d 100644 --- a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs +++ b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs @@ -17,8 +17,8 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase) var events = await client.GetEventsAsync(); - // Two seeded events, earliest start first. - events.Select(@event => @event.Title).ShouldBe(["Elternbeiratssitzung", "Herbstbasar"]); + // Three seeded events, earliest start first. + events.Select(@event => @event.Title).ShouldBe(["Projekttage", "Infoabend Klasse 5", "Elternbeiratssitzung", "Herbstbasar"]); } [Fact] diff --git a/Elternbeirat.Web.Tests/PocketBaseFixture.cs b/Elternbeirat.Web.Tests/PocketBaseFixture.cs index 151241a..f3b9ddb 100644 --- a/Elternbeirat.Web.Tests/PocketBaseFixture.cs +++ b/Elternbeirat.Web.Tests/PocketBaseFixture.cs @@ -338,6 +338,20 @@ public sealed class PocketBaseFixture : IAsyncLifetime start = "2026-11-22 08:00:00.000Z", end = "2026-11-22 12:00:00.000Z", title = "Herbstbasar", location = "Schulhof", note = "", @public = true, }); + // Two past events (before the smoke tests' clock of 1 October 2026), so the + // folded-up "Vergangene Termine" section on /events has something to show: + // one with a time (19:00 Berlin, summer time), and one over several days + // without a time. All-day means 00:00 Berlin, i.e. 22:00Z the day before. + await CreateRecordAsync(http, "events", new + { + start = "2026-09-10 17:00:00.000Z", title = "Infoabend Klasse 5", + location = "Aula", note = "", @public = true, + }); + await CreateRecordAsync(http, "events", new + { + start = "2026-07-19 22:00:00.000Z", end = "2026-07-21 22:00:00.000Z", + title = "Projekttage", location = "", note = "", @public = true, + }); // Faq topics: two, ordered. The topics own the grouping and the order the FAQ // page shows the groups in (cafeteria before lockers). Titles stay German for diff --git a/Elternbeirat.Web.Tests/RouteSmokeTests.cs b/Elternbeirat.Web.Tests/RouteSmokeTests.cs index d99723d..8b86734 100644 --- a/Elternbeirat.Web.Tests/RouteSmokeTests.cs +++ b/Elternbeirat.Web.Tests/RouteSmokeTests.cs @@ -92,6 +92,7 @@ public sealed partial class RouteSmokeTests : IDisposable [Theory] [InlineData("/gibt-es-nicht")] [InlineData("/posts/gibt-es-nicht")] + [InlineData("/events/gibt-es-nicht.ics")] public async Task Unknown_route_returns_404(string route) { var client = _factory.CreateClient(); @@ -328,6 +329,108 @@ public sealed partial class RouteSmokeTests : IDisposable html.ShouldNotContain("Nächster Termin"); } + [Fact] + public async Task Event_cards_on_the_events_page_do_not_link_to_the_events_page() + { + // Events have no page of their own; a card linking to /events led the + // visitor back to where they already were. The navigation does link to + // /events, so only the card list is checked. + var client = _factory.CreateClient(); + + var html = await client.GetStringAsync(new Uri("/events", UriKind.Relative)); + + var list = EventCards().Match(html); + list.Success.ShouldBeTrue(); + Href().Matches(list.Value).Select(match => match.Groups[1].Value).ShouldAllBe(href => href.EndsWith(".ics", StringComparison.Ordinal)); + } + + [Fact] + public async Task Events_page_shows_its_intro_once_and_a_subscribe_button() + { + // The intro is the "events" page body; a hard-coded intro beside it used to + // say the same thing twice. + var client = _factory.CreateClient(); + + var html = await client.GetStringAsync(new Uri("/events", UriKind.Relative)); + + Regex.Count(html, Regex.Escape("Sitzungen und Veranstaltungen auf einen Blick.")).ShouldBe(1); + html.ShouldContain(""""""); + html.ShouldContain("Kalender abonnieren"); + } + + [Fact] + public async Task Events_page_folds_past_events_away() + { + var client = _factory.CreateClient(); + + var html = await client.GetStringAsync(new Uri("/events", UriKind.Relative)); + + var past = PastEvents().Match(html); + past.Success.ShouldBeTrue(); + past.Value.ShouldContain("Vergangene Termine anzeigen (2)"); + past.Value.ShouldContain("Infoabend Klasse 5"); + past.Value.ShouldNotContain("Elternbeiratssitzung"); // upcoming, not past + past.Value.ShouldContain("""

2026

"""); + past.Value.ShouldContain("10. September"); // day without weekday or year + past.Value.ShouldContain("19:00 Uhr"); // the time stays, after the title + past.Value.ShouldContain("20.–22. Juli"); // several days as a span + Regex.Count(past.Value, "Uhr").ShouldBe(1); // the all-day Projekttage get no time + } + + [Fact] + public async Task Single_event_calendar_file_holds_exactly_that_event() + { + // The "In Kalender übernehmen" link of a card leads to a calendar with just + // that one event. Take the link from the page, as a visitor would. + var client = _factory.CreateClient(); + var html = await client.GetStringAsync(new Uri("/events", UriKind.Relative)); + var href = Href().Matches(EventCards().Match(html).Value) + .Select(match => match.Groups[1].Value) + .First(); + + var response = await client.GetAsync(new Uri(href, UriKind.Relative)); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + response.Content.Headers.ContentType?.MediaType.ShouldBe("text/calendar"); + var ics = await response.Content.ReadAsStringAsync(); + ics.ShouldStartWith("BEGIN:VCALENDAR"); + Regex.Count(ics, "BEGIN:VEVENT").ShouldBe(1); + ics.ShouldContain("SUMMARY:Elternbeiratssitzung"); // the earliest upcoming card + } + + [Fact] + public async Task Home_event_cards_link_to_the_events_page() + { + // On the home page the cards are a teaser and may lead to the full list. + var client = _factory.CreateClient(); + + var html = await client.GetStringAsync(new Uri("/", UriKind.Relative)); + + var list = EventCards().Match(html); + list.Success.ShouldBeTrue(); + Href().Matches(list.Value).Select(match => match.Groups[1].Value).ShouldAllBe(href => href == "/events"); + html.ShouldContain("class=\"calendar-sheet\""); + } + + [Fact] + public async Task Home_next_event_shows_the_time_and_keeps_the_full_date_for_screen_readers() + { + // The calendar sheet already shows the day, so the written-out date is only + // for screen readers; visible beside the sheet is the time. The meeting is + // 19:30 Berlin on Thursday, 8 October 2026 (fixture, test clock 1 October). + var client = _factory.CreateClient(); + + var html = await client.GetStringAsync(new Uri("/", UriKind.Relative)); + + var card = NextEventCard().Match(html); + card.Success.ShouldBeTrue(); + var hidden = VisuallyHidden().Match(card.Value); + hidden.Value.ShouldContain("Donnerstag, 8. Oktober 2026, 19:30 Uhr"); + var visible = card.Value.Replace(hidden.Value, "", StringComparison.Ordinal); + visible.ShouldContain("19:30 Uhr"); + visible.ShouldNotContain("Oktober 2026"); + } + /// /// Matches a resource reference that points at an absolute URL, with or without /// scheme (https://, http:// or protocol-relative //): the @@ -356,4 +459,30 @@ public sealed partial class RouteSmokeTests : IDisposable /// [GeneratedRegex("""
""", RegexOptions.Singleline)] private static partial Regex HomeTiles(); + + /// + /// Matches the "Nächster Termin" card in the home hero. It holds no other link, + /// so the first closing </a> ends it. + /// + [GeneratedRegex("""""", RegexOptions.Singleline)] + private static partial Regex NextEventCard(); + + /// + /// Matches the first text hidden for sighted visitors but read by screen readers. + /// + [GeneratedRegex("""""", RegexOptions.Singleline)] + private static partial Regex VisuallyHidden(); + + /// + /// Matches the first list of event cards, from its opening tag to the closing one. + /// The cards nest no other <ul>. + /// + [GeneratedRegex("""
diff --git a/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor.css b/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor.css index afcd19b..1a607e0 100644 --- a/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor.css +++ b/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor.css @@ -1,5 +1,5 @@ -/* Card styles for .event-list are shared with the full events page and live in the - global app.css. Only the embed's spacing on the home page is specific here. */ +/* The card is EventCard and the list grid lives in the global app.css, shared with + the full events page. Only the embed's spacing on the home page is specific here. */ .embed-events { margin-top: var(--space-5); } diff --git a/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor b/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor index dbb81d2..9f94963 100644 --- a/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor +++ b/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor @@ -6,12 +6,7 @@ diff --git a/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor.css b/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor.css index 357ec35..de8b1e0 100644 --- a/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor.css +++ b/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor.css @@ -1,5 +1,5 @@ -/* Card styles for .post-list are shared with the full posts page and live in the - global app.css. Only the embed's spacing on the home page is specific here. */ +/* The card is PostCard and the list grid lives in the global app.css, shared with + the full posts page. Only the embed's spacing on the home page is specific here. */ .embed-posts { margin-top: var(--space-5); } diff --git a/Elternbeirat.Web/Features/Events/CalendarSheet.razor b/Elternbeirat.Web/Features/Events/CalendarSheet.razor new file mode 100644 index 0000000..da5e7bb --- /dev/null +++ b/Elternbeirat.Web/Features/Events/CalendarSheet.razor @@ -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 () next to it. +*@ + diff --git a/Elternbeirat.Web/Features/Events/CalendarSheet.razor.cs b/Elternbeirat.Web/Features/Events/CalendarSheet.razor.cs new file mode 100644 index 0000000..70100fc --- /dev/null +++ b/Elternbeirat.Web/Features/Events/CalendarSheet.razor.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Components; + +namespace Elternbeirat.Web.Features.Events; + +/// +/// Renders an event's start day as a calendar sheet (month, day, weekday). +/// +/// +/// 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 . +/// +/// +public partial class CalendarSheet +{ + /// + /// The event's start, as Berlin wall-clock time. + /// + [Parameter] + [EditorRequired] + public DateTime Value { get; set; } +} diff --git a/Elternbeirat.Web/Features/Events/CalendarSheet.razor.css b/Elternbeirat.Web/Features/Events/CalendarSheet.razor.css new file mode 100644 index 0000000..1c1242b --- /dev/null +++ b/Elternbeirat.Web/Features/Events/CalendarSheet.razor.css @@ -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; +} diff --git a/Elternbeirat.Web/Features/Events/EventCard.razor b/Elternbeirat.Web/Features/Events/EventCard.razor new file mode 100644 index 0000000..b07c899 --- /dev/null +++ b/Elternbeirat.Web/Features/Events/EventCard.razor @@ -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) +{ +
@Details
+} +else +{ + @Details +} + +@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 => @ + + + @Value.Title + @* The sheet is hidden from screen readers; this is the date they hear. *@ + + @if (Span.Length > 0) + { + @Span + } + @if (!string.IsNullOrWhiteSpace(Value.Location)) + { + @Value.Location + } + @if (!string.IsNullOrWhiteSpace(Value.Note)) + { + @Value.Note + } + @* A link inside a link is invalid HTML, so only the plain card offers it. *@ + @if (Href is null) + { + In Kalender übernehmen + } + + ; +} diff --git a/Elternbeirat.Web/Features/Events/EventCard.razor.cs b/Elternbeirat.Web/Features/Events/EventCard.razor.cs new file mode 100644 index 0000000..ae25426 --- /dev/null +++ b/Elternbeirat.Web/Features/Events/EventCard.razor.cs @@ -0,0 +1,55 @@ +using Elternbeirat.Contracts; +using Microsoft.AspNetCore.Components; + +namespace Elternbeirat.Web.Features.Events; + +/// +/// Renders one event as a card with a calendar sheet, time, place and note. +/// +/// +/// 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: +/// +/// +/// +/// In the embed the whole card links to the events page +/// ( set). +/// +/// +/// +/// +/// On the events page itself a link back to the same page would lead +/// nowhere, so the card is no link ( is +/// ) and offers "In Kalender übernehmen" instead. +/// +/// +/// +/// +/// +/// +public partial class EventCard +{ + /// + /// The event to show. + /// + [Parameter] + [EditorRequired] + public Event Value { get; set; } = null!; + + /// + /// Where the card links to, or for a card that is no link + /// and offers the event as a calendar file instead. + /// + [Parameter] + public string? Href { get; set; } + + /// + /// The time line beside the sheet; empty for a single all-day event. + /// + private string Span => EventDateFormat.Span(Value); + + /// + /// The address of this one event as a calendar file. + /// + private string IcsHref => $"/events/{Uri.EscapeDataString(Value.Id)}.ics"; +} diff --git a/Elternbeirat.Web/Features/Events/EventCard.razor.css b/Elternbeirat.Web/Features/Events/EventCard.razor.css new file mode 100644 index 0000000..e45c830 --- /dev/null +++ b/Elternbeirat.Web/Features/Events/EventCard.razor.css @@ -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; +} diff --git a/Elternbeirat.Web/Features/Events/EventDateFormat.cs b/Elternbeirat.Web/Features/Events/EventDateFormat.cs index 298c6b3..5c4051d 100644 --- a/Elternbeirat.Web/Features/Events/EventDateFormat.cs +++ b/Elternbeirat.Web/Features/Events/EventDateFormat.cs @@ -7,8 +7,10 @@ namespace Elternbeirat.Web.Features.Events; /// Formats the date range of an for display in German. /// /// -/// 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: as the full date, +/// the Sheet… methods and for the calendar sheet and the +/// time line beside it, and for the list of past events. /// /// This is the event counterpart to /// , which formats a plain @@ -27,6 +29,11 @@ public static class EventDateFormat /// private const string ShortDate = "d. MMMM yyyy"; + /// + /// Date format for the list of past events, which is grouped by year. + /// + private const string DayMonth = "d. MMMM"; + /// /// Formats the date range of an event. /// @@ -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)}", }; + /// + /// The short month for the top band of a calendar sheet. + /// + /// The event's start. + /// The German short month without its period, e.g. Okt or März. + /// + /// Returned in normal case; the sheet's CSS sets it in capitals. The period of the + /// abbreviation (Okt.) is dropped because on a sheet it reads as a stray dot. + /// + /// + public static string SheetMonth(DateTime value) + => value.ToString("MMM", Cultures.German).TrimEnd('.'); + + /// + /// The day of the month for the large number on a calendar sheet. + /// + /// The event's start. + /// The day without a leading zero, e.g. 8. + public static string SheetDay(DateTime value) + => value.Day.ToString(Cultures.German); + + /// + /// The short weekday for the bottom of a calendar sheet. + /// + /// The event's start. + /// The German short weekday without its period, e.g. Do. + public static string SheetWeekday(DateTime value) + => value.ToString("ddd", Cultures.German).TrimEnd('.'); + + /// + /// The time line beside a calendar sheet: what the sheet itself cannot show. + /// + /// The event to format. + /// + /// Depending on and : + /// + /// No end: 19:30 Uhr + /// Same day: 19:30–21:00 Uhr + /// Several days: 09:00 Uhr bis Sonntag, 11. Oktober 2026, 13:00 Uhr + /// Several days, no time: bis Sonntag, 11. Oktober 2026 + /// One day, no time: an empty string; the sheet says it all. + /// + /// + /// + /// 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. + /// + 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", + _ => "", + }; + + /// + /// The day column of a past event in the folded-up list on the events page. + /// + /// The event to format. + /// + /// Day and full month, depending on : + /// + /// One day: 13. November + /// Several days in one month: 8.–9. Februar + /// Across a month: 30. Januar – 2. Februar + /// Across a year: 30. Dezember – 2. Januar 2027 + /// + /// + /// + /// 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 (). + /// + 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), + }; + + /// + /// The time after the title of a past event in the folded-up list. + /// + /// The event to format. + /// + /// Depending on and : + /// + /// No time: an empty string; nothing is shown. + /// No end: 19:00 Uhr + /// Same day: 19:00–21:00 Uhr + /// Several days: the start time only, 09:00 Uhr + /// + /// + /// + /// 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. + /// + 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), + }; + + /// + /// Formats the clock time of a point in time. + /// + /// The date and time. + /// The time as e.g. 19:30. + private static string Clock(DateTime value) + => value.ToString("HH:mm", Cultures.German); + /// /// Formats a single point in time as a date with an optional time. /// diff --git a/Elternbeirat.Web/Features/Events/EventList.razor b/Elternbeirat.Web/Features/Events/EventList.razor index f165d5f..670ab73 100644 --- a/Elternbeirat.Web/Features/Events/EventList.razor +++ b/Elternbeirat.Web/Features/Events/EventList.razor @@ -21,10 +21,9 @@ } else { -

- Alle Termine des Elternbeirats auf einen Blick. Sie können den Kalender auch - abonnieren und in Ihrer Kalender-App automatisch - aktuell halten. + @* The page body above is the intro; the page only adds the subscribe button. *@ +

+ Kalender abonnieren

Kommende Termine

@@ -38,41 +37,38 @@ else } + @* 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) { -

Vergangene Termine

- + } } diff --git a/Elternbeirat.Web/Features/Events/EventList.razor.cs b/Elternbeirat.Web/Features/Events/EventList.razor.cs index 1967cbd..947b059 100644 --- a/Elternbeirat.Web/Features/Events/EventList.razor.cs +++ b/Elternbeirat.Web/Features/Events/EventList.razor.cs @@ -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> 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); diff --git a/Elternbeirat.Web/Features/Events/EventList.razor.css b/Elternbeirat.Web/Features/Events/EventList.razor.css index f95fa43..2f4f073 100644 --- a/Elternbeirat.Web/Features/Events/EventList.razor.css +++ b/Elternbeirat.Web/Features/Events/EventList.razor.css @@ -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; +} diff --git a/Elternbeirat.Web/Features/Faqs/FaqList.razor b/Elternbeirat.Web/Features/Faqs/FaqList.razor index 88eb523..0b5af54 100644 --- a/Elternbeirat.Web/Features/Faqs/FaqList.razor +++ b/Elternbeirat.Web/Features/Faqs/FaqList.razor @@ -27,7 +27,7 @@ else { @foreach (var topic in _topics) { -
+
@topic.Title @if (!string.IsNullOrWhiteSpace(topic.Intro)) { diff --git a/Elternbeirat.Web/Features/Home/Home.razor b/Elternbeirat.Web/Features/Home/Home.razor index c850c93..1f70fbf 100644 --- a/Elternbeirat.Web/Features/Home/Home.razor +++ b/Elternbeirat.Web/Features/Home/Home.razor @@ -29,21 +29,24 @@ @if (_nextEvent is not null) { - @* The calendar sheet repeats the date as decoration, so it is hidden from - screen readers; carries the full date for everyone. *@ + @* As in EventCard: the calendar sheet shows the date and is hidden from + screen readers, who get the full date from instead. Visible + beside the sheet is only what it cannot show: the time, the end. *@ + var span = EventDateFormat.Span(_nextEvent); Nächster Termin - + @_nextEvent.Title - + + @if (span.Length > 0) + { + @span + } @if (!string.IsNullOrWhiteSpace(_nextEvent.Location)) { - @_nextEvent.Location + @_nextEvent.Location } diff --git a/Elternbeirat.Web/Features/Home/Home.razor.cs b/Elternbeirat.Web/Features/Home/Home.razor.cs index 9be56da..38100c5 100644 --- a/Elternbeirat.Web/Features/Home/Home.razor.cs +++ b/Elternbeirat.Web/Features/Home/Home.razor.cs @@ -46,14 +46,6 @@ public partial class Home private IReadOnlyList _embed = []; private Event? _nextEvent; - // Month and day for the calendar sheet. The sheet is decoration (the full date is - // in 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; diff --git a/Elternbeirat.Web/Features/Home/Home.razor.css b/Elternbeirat.Web/Features/Home/Home.razor.css index 336367d..143f199 100644 --- a/Elternbeirat.Web/Features/Home/Home.razor.css +++ b/Elternbeirat.Web/Features/Home/Home.razor.css @@ -136,38 +136,6 @@ 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 */ } @@ -180,7 +148,9 @@ line-height: 1.3; } -.next-event-location { +/* Time and place, one line each with their icon, green as on the event cards. The + icon belongs to Icon's own scope, so it is reached through ::deep. */ +.next-event-meta { display: flex; align-items: center; gap: var(--space-1); @@ -189,6 +159,10 @@ font-size: 0.9rem; } +.next-event-meta ::deep .icon { + color: var(--color-accent-dark); +} + /* --- Tiles -------------------------------------------------------------------- One card per header page. Fixed columns instead of auto-fill: two on a phone, three from tablet width, so the six pages always fill whole rows and never leave diff --git a/Elternbeirat.Web/Features/Posts/PostCard.razor b/Elternbeirat.Web/Features/Posts/PostCard.razor new file mode 100644 index 0000000..61bdcff --- /dev/null +++ b/Elternbeirat.Web/Features/Posts/PostCard.razor @@ -0,0 +1,13 @@ +@* + One post as a card that links to the post: date, title, a teaser from the body + and "Weiterlesen". The arrow is a hint where the card leads; it does not move. +*@ + + + @Value.Title + @if (Teaser.Length > 0) + { + + } + Weiterlesen + diff --git a/Elternbeirat.Web/Features/Posts/PostCard.razor.cs b/Elternbeirat.Web/Features/Posts/PostCard.razor.cs new file mode 100644 index 0000000..6564e31 --- /dev/null +++ b/Elternbeirat.Web/Features/Posts/PostCard.razor.cs @@ -0,0 +1,46 @@ +using Elternbeirat.Contracts; +using Elternbeirat.Web.Shared; +using Microsoft.AspNetCore.Components; + +namespace Elternbeirat.Web.Features.Posts; + +/// +/// Renders one post as a card linking to the post, with date, title and teaser. +/// +/// +/// Shared by the posts page and the home-page posts embed, so both show posts the +/// same way. The teaser comes from the body (): posts +/// have no summary field of their own. +/// +/// +public partial class PostCard +{ + /// + /// The post to show. + /// + [Parameter] + [EditorRequired] + public Post Value { get; set; } = null!; + + /// + /// for the large card that opens the posts page (the + /// newest post); for a regular card. + /// + [Parameter] + public bool Featured { get; set; } + + /// + /// The card's classes; the featured card adds its modifier. + /// + private string CssClass => Featured ? "post-card post-card-featured" : "post-card"; + + /// + /// The address of the post. + /// + private string Href => $"/posts/{Value.Slug}"; + + /// + /// The teaser text; empty if the body has no paragraph text. + /// + private string Teaser => Markdown.Teaser(Value.Body); +} diff --git a/Elternbeirat.Web/Features/Posts/PostCard.razor.css b/Elternbeirat.Web/Features/Posts/PostCard.razor.css new file mode 100644 index 0000000..7ef89d0 --- /dev/null +++ b/Elternbeirat.Web/Features/Posts/PostCard.razor.css @@ -0,0 +1,93 @@ +/* The post card: a white surface with a green stripe on the left as the eye's + anchor. The stripe is a border, so it follows the rounded corners. The whole card + is the link and fills its list cell, so the cards of a row are equally tall. + Hover = stronger shadow, nothing moves. */ +.post-card { + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: var(--space-2); + height: 100%; + padding: var(--space-3) var(--space-4); + border-left: 4px solid var(--color-accent); + border-radius: var(--radius); + background: var(--color-bg); + box-shadow: var(--shadow); + color: var(--color-text); + text-decoration: none; + transition: box-shadow var(--transition); +} + +.post-card:hover { + box-shadow: var(--shadow-hover); + color: var(--color-text); +} + +.post-card:focus-visible { + outline: 3px solid var(--color-focus); + outline-offset: 2px; +} + +/* Icon and date on one line. The icon belongs to Icon's own scope, so it is + reached through ::deep. */ +.post-date { + display: flex; + align-items: center; + gap: var(--space-1); +} + +.post-date ::deep .icon { + color: var(--color-accent-dark); +} + +.post-title { + color: var(--color-brand); + font-size: 1.15rem; + font-weight: 700; + line-height: 1.3; +} + +/* Cut after three lines, so cards of a row stay close in height whatever the + editor wrote. The featured card lifts the limit (see below). */ +.post-teaser { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + overflow: hidden; + font-size: 0.95rem; +} + +/* margin-top: auto pushes the link line to the bottom, so it sits on one level + across a row of cards with teasers of different length. */ +.post-more { + display: inline-flex; + align-items: center; + gap: var(--space-1); + margin-top: auto; + padding-top: var(--space-1); + color: var(--color-brand); + font-size: 0.9rem; + font-weight: 700; +} + +/* The newest post on the posts page: more room, a larger title and the full + teaser. */ +.post-card-featured { + padding: var(--space-4) var(--space-5); +} + +.post-card-featured .post-title { + font-size: var(--font-size-h3); +} + +@media (min-width: 48rem) { + .post-card-featured .post-title { + font-size: 1.5rem; + } +} + +.post-card-featured .post-teaser { + display: block; + font-size: 1rem; +} diff --git a/Elternbeirat.Web/Features/Posts/PostList.razor b/Elternbeirat.Web/Features/Posts/PostList.razor index e7d15ed..1e49431 100644 --- a/Elternbeirat.Web/Features/Posts/PostList.razor +++ b/Elternbeirat.Web/Features/Posts/PostList.razor @@ -25,15 +25,12 @@ else if (_posts.Count == 0) } else { -