Compare commits

...
58 Commits
Author SHA1 Message Date
Tom 53cee04396 Merge pull request 'Design/blocks layout' (#60) from design/blocks-layout into main
Build and push image / build (push) Successful in 1m58s
Reviewed-on: #60
2026-10-01 19:28:04 +02:00
tleininger ee69892058 fix buttons 2026-10-01 19:06:54 +02:00
tleininger a847507b46 Centre one 42rem reading column on content, post, event and FAQ pages 2026-10-01 18:48:55 +02:00
tleininger 7d09233ca2 Keep mobile menu links as pills with a full-width active stroke 2026-10-01 18:28:06 +02:00
tleiningerandClaude Opus 5.5 8e4333b3ac Keep design blocks in the reading column, rework key figures and tiles, drop focus ring on tabindex=-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:24:34 +02:00
Tom ebb3b387c5 Merge pull request 'Serve static maintenance page with 503 when PocketBase is unreachable' (#59) from feature/maintenance-page into main
Build and push image / build (push) Successful in 1m55s
Reviewed-on: #59
2026-10-01 17:12:25 +02:00
tleininger 7e56e83953 Serve static maintenance page with 503 when PocketBase is unreachable 2026-10-01 17:12:00 +02:00
Tom ed6aa5d5ec Merge pull request 'Rework post page (lead, pager), add skip link, unified focus ring, reduced motion and keyboard-accessible menu' (#58) from design/post-page-a11y into main
Build and push image / build (push) Successful in 1m54s
Reviewed-on: #58
2026-10-01 16:50:07 +02:00
tleininger ba65b18d29 Rework post page (lead, pager), add skip link, unified focus ring, reduced motion and keyboard-accessible menu 2026-10-01 16:48:52 +02:00
Tom ae362639e3 Merge pull request 'Render board members as person cards via ::: team block' (#56) from design/board-team into main
Build and push image / build (push) Successful in 2m1s
Reviewed-on: #56
2026-10-01 16:15:33 +02:00
tleininger 0257b2e30b Stretch team cards to equal row height and enlarge duty tags 2026-10-01 16:11:43 +02:00
tleininger 602f258257 Unify team grid, limit avatars to brand colours, keep duty tags on one line 2026-10-01 16:04:12 +02:00
tleininger a5a68ed122 Add ::: team block rendering board members as person cards with initials avatars 2026-10-01 15:55:28 +02:00
Tom efb6fa9109 Merge pull request 'Add post and event cards, shared calendar sheet and per-event ICS; rework past events and next-event card' (#55) from design/post-event-cards into main
Build and push image / build (push) Successful in 1m57s
Reviewed-on: #55
2026-10-01 15:32:35 +02:00
tleininger b2dc033de1 Add post and event cards, shared calendar sheet and per-event ICS; rework past events and next-event card 2026-10-01 15:32:01 +02:00
Tom d328adac5c Merge pull request 'Design/home hero' (#54) from design/home-hero into main
Build and push image / build (push) Successful in 1m53s
Reviewed-on: #54
2026-10-01 14:45:46 +02:00
tleininger 6e5a30607e Polish home page: clamp tile teasers, balance hero heading, fix embed gap 2026-10-01 14:44:55 +02:00
tleininger c24ca16a41 Add home hero with intro and buttons from the body, next-event card and page tiles 2026-10-01 14:23:55 +02:00
tleininger e51afeb34b Add home hero with intro and buttons from the body, next-event card and page tiles 2026-10-01 14:05:25 +02:00
Tom b737f57ef7 Merge pull request 'Refresh header and footer with signet, gradient, nav pills and three-column footer; fix box-sizing of design boxes' (#53) from design/header-footer into main
Build and push image / build (push) Successful in 1m57s
Reviewed-on: #53
2026-10-01 13:36:58 +02:00
tleininger d539fde09d Refresh header and footer with signet, gradient, nav pills and three-column footer; fix box-sizing of design boxes 2026-10-01 13:36:26 +02:00
Tom a641beab16 Merge pull request 'Style editor Markdown with .markdown-body and add ::: design blocks' (#52) from design/markdown-blocks into main
Build and push image / build (push) Successful in 1m57s
Reviewed-on: #52
2026-10-01 12:54:22 +02:00
tleiningerandClaude Opus 5.5 5eb36ad3e9 Style editor Markdown with .markdown-body and add ::: design blocks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:52:51 +02:00
Tom ca0b3c311c Merge pull request 'Add the design foundation: self-hosted Nunito, type scale, new tokens and an Icon component' (#51) from design/foundation into main
Build and push image / build (push) Successful in 1m43s
Reviewed-on: #51
2026-10-01 12:04:06 +02:00
tleininger 33fa1e4e93 Add the design foundation: self-hosted Nunito, type scale, new tokens and an Icon component 2026-10-01 12:03:39 +02:00
Tom 3530167cc3 Merge pull request 'Align the FAQ docs with the faq_topics collection' (#48) from docs/faq-topics-redaktion into main
Build and push image / build (push) Successful in 2m1s
Reviewed-on: #48
2026-10-01 11:38:46 +02:00
tleininger 8fb5044062 Align the FAQ docs with the faq_topics collection 2026-10-01 11:37:04 +02:00
Tom 5f28237ef0 Merge pull request 'Fix/content page heading' (#36) from fix/content-page-heading into main
Build and push image / build (push) Successful in 2m26s
Reviewed-on: #36
2026-09-30 23:18:42 +02:00
tleininger 818f4c508e Warm up the contact page text and link the e-mail address 2026-09-30 23:02:22 +02:00
tleininger ee43d133e9 Replace placeholder events with real past events and the go-live date 2026-09-30 22:58:39 +02:00
tleininger cf799dd970 Reword the /posts intro to match the actual content mix 2026-09-30 22:47:23 +02:00
tleininger de319d3b00 Replace placeholder posts with the real backup articles 2026-09-30 22:43:47 +02:00
tleininger 43e6ed7743 Unify post and event cards to one style on the home page 2026-09-30 21:52:59 +02:00
tleininger 4fa17429b5 Read heading and intro from the pages record on /posts and /events 2026-09-30 21:33:26 +02:00
tleininger 666614a3b2 Smoke-test /health for the reachable (200) and unreachable (503) cases 2026-09-30 21:07:40 +02:00
tleininger c67c71ea28 Remove hover motion from cards and "more" links 2026-09-30 16:40:33 +02:00
tleininger eacf95e041 Show FAQ topics as an in-page accordion instead of drill-down pages 2026-09-30 16:21:49 +02:00
tleininger cb345975cb Write proper FAQ page intro; drop dead faqs-embed from tests 2026-09-30 15:13:19 +02:00
tleininger e38ebf078d Render FAQ topics as cards, drop dead scoped CSS 2026-09-30 14:58:12 +02:00
tleininger 2ea8856d65 Group FAQs by a topic relation, ordered; drop unused FAQ teaser 2026-09-29 22:11:30 +02:00
tleininger 28944c155a Sort FAQ entries by editor-defined order within each topic 2026-09-29 21:35:53 +02:00
Tom a4c8c1f4cb Merge pull request 'Apply IGMH brand colours, shrink subheadings, show page title as h1' (#35) from fix/content-page-heading into main
Build and push image / build (push) Successful in 1m56s
Reviewed-on: #35
2026-09-25 16:29:15 +02:00
tleiningerandClaude Opus 4.8 ab4a1dd8fc Apply IGMH brand colours, shrink subheadings, show page title as h1
- app.css: adopt the school's own colours (IGMH blue for header/links/headings, IGMH green as accent), light/dark; cooler near-neutral page bg
- app.css: reduce h2/h3 and give h4 its own size for a calmer heading scale
- ContentPage: render the page title as a visible <h1>, not only <PageTitle>

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-25 16:26:13 +02:00
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
Tom d95bf0dcbe Merge pull request 'Fix/pocketbase date timezone' (#33) from fix/pocketbase-date-timezone into main
Build and push image / build (push) Successful in 2m5s
Reviewed-on: #33
2026-09-24 14:50:09 +02:00
tleininger c7c55c0c6c Let editors define FAQ topics freely instead of a hard-coded map 2026-09-24 14:49:15 +02:00
tleininger 01d25eb04b Take the page heading from the Markdown body, not a separate <h1> 2026-09-24 14:26:35 +02:00
tleininger e242eaf79a Serve 503 when PocketBase is unavailable across all content pages 2026-09-24 13:00:14 +02:00
tleininger 0f7e968c16 Harden the content contracts and enforce the slug rule 2026-09-24 12:08:56 +02:00
tleininger d06af2891a Centralize the home slug check on Page, case-insensitively 2026-09-24 10:23:30 +02:00
tleininger 66bc76af1c Define the all-day rule once as Event.HasTime 2026-09-24 10:08:04 +02:00
tleininger 31b114982e Move date converters onto the contracts 2026-09-24 09:20:14 +02:00
tleininger 6d3260db49 Read a post's date as the Berlin calendar day, not the raw UTC day 2026-09-24 09:11:37 +02:00
tleininger aef05fb090 Convert PocketBase dates to Berlin time; document code conventions 2026-09-24 08:53:47 +02:00
Tom 4b93434679 Merge pull request 'Serve the home nav link from PocketBase, not a hard-coded copy' (#32) from fix/duplicate-start-nav-link into main
Build and push image / build (push) Successful in 1m49s
Reviewed-on: #32
2026-09-23 22:32:26 +02:00
tleininger 3fd4068762 Serve the home nav link from PocketBase, not a hard-coded copy 2026-09-23 22:32:01 +02:00
110 changed files with 7853 additions and 989 deletions

No files matched your search

+6
View File
@@ -54,3 +54,9 @@ dotnet_diagnostic.CA1812.severity = none
# layout and is harmless (Blazor routing is not namespace-based). Renaming would # layout and is harmless (Blazor routing is not namespace-based). Renaming would
# break the folder convention for no benefit. # break the folder convention for no benefit.
dotnet_diagnostic.CA1724.severity = none dotnet_diagnostic.CA1724.severity = none
# CA1308 wants ToUpperInvariant over ToLowerInvariant. That advice is for
# normalizing before a comparison, where a few characters do not round-trip. Here
# lowercasing is the goal itself (URL slugs, which must be lowercase), not a step
# before a comparison -- comparisons use StringComparison.OrdinalIgnoreCase instead.
dotnet_diagnostic.CA1308.severity = none
+38 -2
View File
@@ -83,11 +83,47 @@ Inhalt liegt in PocketBase (`pb_data`), das separat gesichert wird (siehe
- Datenzugriff über den typisierten `PocketBaseClient` (registriert via - Datenzugriff über den typisierten `PocketBaseClient` (registriert via
`AddHttpClient`), der pro Request liest — kein Start-Cache, kein Singleton mit `AddHttpClient`), der pro Request liest — kein Start-Cache, kein Singleton mit
Inhalten. Komponenten liegen feature-basiert unter `Features/<Bereich>/`. Inhalten. Komponenten liegen feature-basiert unter `Features/<Bereich>/`.
- Öffentliche Typen und Methoden im `PocketBaseClient` und in `Contracts` - JSON-Konverter liegen in `Contracts` und werden per `[JsonConverter]` **am Feld**
bekommen XML-Doc (auf Englisch, leicht verständlich), Razor-Markup nicht. zugeordnet, nicht global auf den `JsonSerializerOptions` im Client registriert. So
ist am Contract sichtbar, wie ein Feld behandelt wird (z. B. `Event.Start` als
Zeitpunkt, `Post.Date` als Kalendertag), und `Contracts` bekommt keine Referenz
auf `PocketBase` (Zirkel). Datumsfelder: `DateTime`/`DateTime?` = Zeitpunkt
(Europe/Berlin), `DateOnly` = Kalendertag.
- **Code durchgängig auf Englisch** — Typen, Member, Variablen, Kommentare, - **Code durchgängig auf Englisch** — Typen, Member, Variablen, Kommentare,
Skripte und Doku. Das gilt **auch für Domänenbegriffe**: im Code `Event`, nicht Skripte und Doku. Das gilt **auch für Domänenbegriffe**: im Code `Event`, nicht
`Termin`; `Post`, nicht `Beitrag`. Deutsch bleibt ausschließlich, was ein `Termin`; `Post`, nicht `Beitrag`. Deutsch bleibt ausschließlich, was ein
Besucher liest oder ein Redakteur pflegt: UI-Texte sowie die **Werte** der Besucher liest oder ein Redakteur pflegt: UI-Texte sowie die **Werte** der
PocketBase-Records (z. B. `title: Vorstandsteam`, der Markdown-`body`). Die PocketBase-Records (z. B. `title: Vorstandsteam`, der Markdown-`body`). Die
Feldnamen und Slugs bleiben dagegen englisch (`title`, `slug`, `/board`). Feldnamen und Slugs bleiben dagegen englisch (`title`, `slug`, `/board`).
## Coding Convention
- **Ausdruckskörper (`=>`) sind Pflicht, wo syntaktisch möglich** — Methoden,
Properties, Konstruktoren, Operatoren, lokale Funktionen. Ein `if/else`, das
einen Wert liefert, wird zum ternären Ausdruck oder zur `switch`-Expression, kein
Block-Körper mit `return`. Ein Block-Körper nur, wo ein Ausdruck sprachlich nicht
geht (mehrere Anweisungen ohne Rückgabe, `ref`/`out`, `yield`).
- Ternäre und `switch`-Expressions dürfen dafür mehrzeilig umgebrochen werden;
Lesbarkeit entsteht durch Einrückung, nicht durch einen Block.
- Nullable aktiv nutzen: `?`, `??`, `??=` statt Nullprüfungen im Block. Ein
ungültiger `null`-Fall wird als Ausdruck geworfen (`?? throw new …`).
- Argumente/Rückgaben früh und knapp validieren, bevorzugt als Ausdruck.
## Documentation Convention
Vorbild ist `Elternbeirat.PocketBase/LocalDateTimeConverter.cs` — daran
ausrichten.
- **Jeder öffentliche (`public`/`protected`) Typ und Member bekommt XML-Doc** —
nicht nur `PocketBaseClient` und `Contracts`. Interne Helfer, die Teil der
fachlichen Erklärung sind (wie `WallClock`), ebenfalls. Razor-Markup nicht.
- Voller Umfang, wo zutreffend: `<summary>`, dazu `<param>`, `<returns>`,
`<exception>` (jede geworfene Bedingung), `<remarks>` für Kontext/Fallstricke,
`<example>` mit `<code>` für nicht offensichtliche Nutzung, `<seealso>` auf
verwandte Typen. `<inheritdoc/>` bei Interface-/Basis-Implementierungen.
- Code im Text als Markup referenzieren, nicht als Prosa: `<see cref="…"/>`,
`<see langword="null"/>`/`<see langword="false"/>`, `<c>…</c>` für Literale.
- Einrückung: der Textinhalt steht mit vier Leerzeichen unter dem `///`-Tag
(`/// Text`), Tags sauber verschachtelt.
- Englisch, leicht verständlich, erklärt **warum**, nicht was der Code ohnehin
zeigt.
@@ -0,0 +1,47 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Elternbeirat.Contracts;
/// <summary>
/// JSON converter that reads and writes a PocketBase date as a calendar date
/// (<see cref="DateOnly"/>), keeping the day and dropping the time-of-day.
/// </summary>
/// <remarks>
/// PocketBase has no date-only field type: it stores every date as a UTC
/// instant with a time-of-day (e.g. an editor's <c>2 Jan 00:00</c> Berlin is
/// stored as <c>"2026-01-01 22:00:00.000Z"</c>). The day the editor meant is
/// therefore the day <em>in Berlin</em>, not the day of the raw UTC string. This
/// converter reuses the same UTC-&gt;Berlin conversion as
/// <see cref="LocalDateTimeConverter"/> and then keeps only the date, so the day
/// shown is the day that was entered -- with no time-of-day and no off-by-one.
/// </remarks>
/// <example>
/// Apply it to a calendar-day field of a contract:
/// <code>
/// [JsonConverter(typeof(DateOnlyConverter))]
/// public DateOnly Date { get; init; }
/// </code>
/// </example>
/// <seealso cref="WallClock"/>
public sealed class DateOnlyConverter : JsonConverter<DateOnly>
{
/// <inheritdoc/>
/// <exception cref="FormatException">
/// The stored value is not a valid date string.
/// </exception>
public override DateOnly Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
=> WallClock.Parse(reader.GetString()) is { } berlin
? DateOnly.FromDateTime(berlin)
: default;
/// <inheritdoc/>
public override void Write(
Utf8JsonWriter writer,
DateOnly value,
JsonSerializerOptions options)
=> writer.WriteStringValue(WallClock.Format(value.ToDateTime(TimeOnly.MinValue)));
}
+66 -17
View File
@@ -2,38 +2,87 @@ using System.Text.Json.Serialization;
namespace Elternbeirat.Contracts; namespace Elternbeirat.Contracts;
/// <summary>A calendar entry. Sorted by <see cref="Start"/>.</summary> /// <summary>
/// A calendar entry of the Elternbeirat, as stored in PocketBase.
/// </summary>
/// <remarks>
/// Event lists are ordered by <see cref="Start"/>. Unset optional text fields are
/// <see cref="string.Empty"/>, never <see langword="null"/>.
/// </remarks>
public record Event public record Event
{ {
/// <summary>PocketBase record id.</summary> /// <summary>
/// Gets the PocketBase record id.
/// </summary>
/// <remarks>
/// <see langword="required"/>: every stored record has an id. Modeling it as
/// required states which fields a record must carry, independent of the store,
/// so a future data source has to supply them too.
/// </remarks>
[JsonPropertyName("id")] [JsonPropertyName("id")]
public string Id { get; init; } = ""; public required string Id { get; init; }
/// <summary> /// <summary>
/// Start of the event with date and time. Stored as UTC by PocketBase but /// Gets the start of the event as Europe/Berlin wall-clock time.
/// read as local time (Europe/Berlin) by convention. An all-day event uses
/// 00:00 as the time.
/// </summary> /// </summary>
/// <remarks>
/// PocketBase stores the value in UTC; it is converted to Berlin local time on
/// deserialization, with daylight saving applied. An all-day event uses 00:00
/// as the time.
/// <see langword="required"/>: an event without a start has no place on the
/// calendar, and the field is required in PocketBase.
/// </remarks>
[JsonPropertyName("start")] [JsonPropertyName("start")]
public DateTime Start { get; init; } [JsonConverter(typeof(LocalDateTimeConverter))]
public required DateTime Start { get; init; }
/// <summary>Optional end of the event; null when unset.</summary> /// <summary>
/// Gets the optional end of the event as Europe/Berlin wall-clock time.
/// </summary>
/// <value>
/// The end time, or <see langword="null"/> if the event has no end.
/// </value>
[JsonPropertyName("end")] [JsonPropertyName("end")]
[JsonConverter(typeof(NullableLocalDateTimeConverter))]
public DateTime? End { get; init; } public DateTime? End { get; init; }
/// <summary>Event name, e.g. "Elternbeiratssitzung".</summary> /// <summary>
/// Gets the event name, e.g. <c>"Elternbeiratssitzung"</c>.
/// </summary>
/// <remarks>
/// <see langword="required"/>: an event without a name is incomplete, and the
/// field is required in PocketBase.
/// </remarks>
[JsonPropertyName("title")] [JsonPropertyName("title")]
public string Title { get; init; } = ""; public required string Title { get; init; }
/// <summary>Optional location, e.g. "Aula".</summary> /// <summary>
/// Gets the optional location, e.g. <c>"Aula"</c>.
/// </summary>
/// <value>
/// The location, or <see cref="string.Empty"/> if none is set.
/// </value>
[JsonPropertyName("location")] [JsonPropertyName("location")]
public string Location { get; init; } = ""; public string Location { get; init; } = string.Empty;
/// <summary>Optional note, e.g. "Anmeldung erforderlich".</summary> /// <summary>
/// Gets the optional note, e.g. <c>"Anmeldung erforderlich"</c>.
/// </summary>
/// <value>
/// The note, or <see cref="string.Empty"/> if none is set.
/// </value>
[JsonPropertyName("note")] [JsonPropertyName("note")]
public string Note { get; init; } = ""; public string Note { get; init; } = string.Empty;
/// <summary>Whether the event is visible to visitors.</summary> /// <summary>
[JsonPropertyName("public")] /// Gets a value indicating whether the event has a wall-clock time, as opposed
public bool Public { get; init; } /// to being an all-day event.
/// </summary>
/// <remarks>
/// PocketBase has no all-day flag; the convention is that a start of 00:00
/// means all-day. This is the single definition of that rule -- formatting and
/// the calendar feed both read it here instead of re-deriving it.
/// </remarks>
[JsonIgnore]
public bool HasTime => Start.TimeOfDay != TimeSpan.Zero;
} }
+91 -11
View File
@@ -3,27 +3,107 @@ using System.Text.Json.Serialization;
namespace Elternbeirat.Contracts; namespace Elternbeirat.Contracts;
/// <summary> /// <summary>
/// A single question and answer, grouped on the FAQ page by <see cref="Topic"/>. /// A single question and answer of the FAQ, as stored in PocketBase.
/// </summary> /// </summary>
/// <remarks>
/// On the FAQ page, entries are grouped by their <see cref="Topic"/>. The topic is
/// a PocketBase relation; <see cref="Topic"/> is the resolved topic record, present
/// only when the query expanded the relation (see <see cref="FaqExpand"/>).
/// </remarks>
public record Faq public record Faq
{ {
/// <summary>PocketBase record id.</summary> /// <summary>
/// Gets the PocketBase record id.
/// </summary>
/// <remarks>
/// <see langword="required"/>: every stored record has an id. Modeling it as
/// required states which fields a record must carry, independent of the store,
/// so a future data source has to supply them too.
/// </remarks>
[JsonPropertyName("id")] [JsonPropertyName("id")]
public string Id { get; init; } = ""; public required string Id { get; init; }
/// <summary>The question as a parent would phrase it.</summary> /// <summary>
/// Gets the question as a parent would phrase it.
/// </summary>
/// <remarks>
/// <see langword="required"/>: a FAQ entry without a question is incomplete, and
/// the field is required in PocketBase.
/// </remarks>
[JsonPropertyName("question")] [JsonPropertyName("question")]
public string Question { get; init; } = ""; public required string Question { get; init; }
/// <summary>The answer in Markdown.</summary> /// <summary>
/// Gets the answer as Markdown.
/// </summary>
/// <remarks>
/// <see langword="required"/>: a FAQ entry exists to answer its question, so an
/// entry without an answer is incomplete. The field is required in PocketBase.
/// </remarks>
[JsonPropertyName("answer")] [JsonPropertyName("answer")]
public string Answer { get; init; } = ""; public required string Answer { get; init; }
/// <summary>Topic the question is grouped under, e.g. "mensa".</summary> /// <summary>
[JsonPropertyName("topic")] /// Gets the sort order of the question within its topic.
public string Topic { get; init; } = ""; /// </summary>
/// <value>
/// The sort key; questions with smaller values appear first.
/// </value>
[JsonPropertyName("order")]
public double Order { get; init; }
/// <summary>Whether the question is visible to visitors.</summary> /// <summary>
/// Gets whether this entry is published and may be shown to visitors.
/// </summary>
/// <value>
/// <see langword="true"/> when the entry is public, <see langword="false"/> for a
/// draft that must stay hidden.
/// </value>
/// <remarks>
/// Collection queries filter on <c>public=true</c> server-side, so entries loaded
/// that way are always public and never need this checked. It matters only for the
/// <c>faqs_via_topic</c> back-relation expand behind <see cref="FaqTopic.Faqs"/>:
/// PocketBase does not apply the topic query's <c>public</c> filter to expanded
/// child records, so a draft question would otherwise leak onto the topic page.
/// That getter filters on this to keep drafts hidden.
/// </remarks>
[JsonPropertyName("public")] [JsonPropertyName("public")]
public bool Public { get; init; } public bool Public { get; init; }
/// <summary>
/// Gets the expanded relations returned by PocketBase, or <see langword="null"/>
/// when the query did not expand any.
/// </summary>
[JsonPropertyName("expand")]
public FaqExpand? Expand { get; init; }
/// <summary>
/// Gets the resolved topic this entry is grouped under, or <see langword="null"/>
/// when the query did not expand the topic relation.
/// </summary>
/// <remarks>
/// Convenience over <see cref="Expand"/>: the FAQ page reads the grouping
/// heading from here. It is <see langword="null"/> unless the entry was loaded
/// with the topic relation expanded.
/// </remarks>
[JsonIgnore]
public FaqTopic? Topic => Expand?.Topic;
}
/// <summary>
/// The relations of a <see cref="Faq"/> that PocketBase returns under
/// <c>expand</c> when the query asks for them.
/// </summary>
/// <remarks>
/// One property per expandable relation, named after its PocketBase field, so the
/// JSON <c>expand</c> object maps straight onto it. <see cref="Faq.Topic"/> reads
/// through this.
/// </remarks>
public record FaqExpand
{
/// <summary>
/// Gets the expanded topic of the <c>topic</c> relation, if it was expanded.
/// </summary>
[JsonPropertyName("topic")]
public FaqTopic? Topic { get; init; }
} }
+118
View File
@@ -0,0 +1,118 @@
using System.Text.Json.Serialization;
namespace Elternbeirat.Contracts;
/// <summary>
/// A topic the FAQ entries are grouped under, as stored in PocketBase.
/// </summary>
/// <remarks>
/// A <see cref="Faq"/> points at its topic through a relation, so the FAQ page
/// groups the questions by topic and shows the groups in <see cref="Order"/>.
/// Making the topic its own record (rather than a free-text field on each
/// question) means the heading, its order and its optional <see cref="Intro"/>
/// are edited in one place and shared by every question in the group.
/// </remarks>
public record FaqTopic
{
/// <summary>
/// Gets the PocketBase record id.
/// </summary>
/// <remarks>
/// <see langword="required"/>: every stored record has an id. Modeling it as
/// required states which fields a record must carry, independent of the store,
/// so a future data source has to supply them too.
/// </remarks>
[JsonPropertyName("id")]
public required string Id { get; init; }
/// <summary>
/// Gets the heading shown above the group of questions, e.g.
/// <c>"Mensa und Mittagessen"</c>.
/// </summary>
/// <remarks>
/// Free text; may contain umlauts and spaces. For the URL, see <see cref="Slug"/>.
/// <see langword="required"/>: a topic without a heading has nothing to show, and
/// the field is required in PocketBase.
/// </remarks>
[JsonPropertyName("title")]
public required string Title { get; init; }
/// <summary>
/// Gets the URL slug of the topic, e.g. <c>"cafeteria"</c>.
/// </summary>
/// <value>
/// A URL path segment: lowercase letters, digits and single hyphens as
/// separators, no umlauts. PocketBase enforces this on save via a field
/// pattern (<c>^[a-z0-9]+(-[a-z0-9]+)*$</c>), so every stored slug is already
/// canonical and the app can use it verbatim.
/// </value>
/// <remarks>
/// <see langword="required"/>: the slug is required in PocketBase and is the
/// stable identifier of a topic, independent of its display title.
/// </remarks>
[JsonPropertyName("slug")]
public required string Slug { get; init; }
/// <summary>
/// Gets the optional introduction shown below the topic heading, as Markdown.
/// </summary>
/// <value>
/// The intro Markdown, or an empty string when the topic has none.
/// </value>
[JsonPropertyName("intro")]
public string Intro { get; init; } = string.Empty;
/// <summary>
/// Gets the sort order of the topic among the FAQ groups.
/// </summary>
/// <value>
/// The sort key; topics with smaller values appear first.
/// </value>
[JsonPropertyName("order")]
public double Order { get; init; }
/// <summary>
/// Gets the expanded relations returned by PocketBase, or <see langword="null"/>
/// when the query did not expand any.
/// </summary>
[JsonPropertyName("expand")]
public FaqTopicExpand? Expand { get; init; }
/// <summary>
/// Gets the questions of this topic, ordered by <see cref="Faq.Order"/>, or an
/// empty list when none are linked or the relation was not expanded.
/// </summary>
/// <remarks>
/// Convenience over <see cref="Expand"/>: questions name their topic, so
/// PocketBase returns them under the back-relation key when the query expands it
/// (<c>faqs_via_topic</c>). The FAQ page reads them from here. They
/// arrive unsorted, so this orders them by <see cref="Faq.Order"/> for a stable
/// render. Drafts are dropped: PocketBase does not apply the topic query's
/// <c>public</c> filter to the expanded children, so this keeps
/// non-public questions (<see cref="Faq.Public"/>) off the page.
/// </remarks>
[JsonIgnore]
public IReadOnlyList<Faq> Faqs =>
Expand?.Faqs is { } faqs
? [.. faqs.Where(faq => faq.Public).OrderBy(faq => faq.Order)]
: [];
}
/// <summary>
/// The relations of a <see cref="FaqTopic"/> that PocketBase returns under
/// <c>expand</c> when the query asks for them.
/// </summary>
/// <remarks>
/// The questions point at their topic, so PocketBase exposes them as the
/// back-relation <c>faqs_via_topic</c>. <see cref="FaqTopic.Faqs"/> reads through
/// this.
/// </remarks>
public record FaqTopicExpand
{
/// <summary>
/// Gets the expanded questions of the <c>faqs_via_topic</c> back-relation, if it
/// was expanded.
/// </summary>
[JsonPropertyName("faqs_via_topic")]
public IReadOnlyList<Faq>? Faqs { get; init; }
}
@@ -0,0 +1,133 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Elternbeirat.Contracts;
/// <summary>
/// Converts between PocketBase date strings and Europe/Berlin wall-clock time.
/// </summary>
/// <remarks>
/// PocketBase stores every date in UTC and serializes it with a trailing <c>Z</c>
/// (e.g. <c>"2026-10-08 17:30:00.000Z"</c>). The values produced here are local
/// wall-clock numbers: an editor who typed 19:30 in the admin form gets 19:30 back,
/// with daylight saving applied by the time zone. They carry
/// <see cref="DateTimeKind.Unspecified"/> so that no later formatting shifts them again.
/// </remarks>
internal static class WallClock
{
/// <summary>
/// IANA id; resolves on every platform .NET supports, Windows included.
/// </summary>
private static readonly TimeZoneInfo Berlin =
TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
/// <summary>
/// Parses a PocketBase UTC date string into Berlin wall-clock time.
/// </summary>
/// <param name="raw">
/// The raw PocketBase value; may be <see langword="null"/> or empty.
/// </param>
/// <returns>
/// The Berlin wall-clock time with <see cref="DateTimeKind.Unspecified"/>, or
/// <see langword="null"/> if <paramref name="raw"/> is <see langword="null"/> or empty
/// (PocketBase's representation of an unset date).
/// </returns>
/// <exception cref="FormatException">
/// <paramref name="raw"/> is not a valid date string.
/// </exception>
public static DateTime? Parse(string? raw) =>
string.IsNullOrEmpty(raw)
? null
: TimeZoneInfo.ConvertTime(
DateTimeOffset.Parse(raw, CultureInfo.InvariantCulture), Berlin).DateTime;
/// <summary>
/// Formats a Berlin wall-clock time as the UTC string PocketBase stores.
/// Inverse of <see cref="Parse"/>.
/// </summary>
/// <param name="berlin">
/// A Berlin wall-clock time. Its <see cref="DateTime.Kind"/> is ignored, and the
/// value is always interpreted as Berlin local time.
/// </param>
/// <returns>
/// The UTC value in PocketBase format, e.g. <c>"2026-10-08 17:30:00.000Z"</c>.
/// </returns>
/// <exception cref="ArgumentException">
/// <paramref name="berlin"/> does not exist in Berlin, because it falls in the
/// daylight-saving gap in spring.
/// </exception>
public static string Format(DateTime berlin) =>
TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(berlin, DateTimeKind.Unspecified), Berlin)
.ToString("yyyy-MM-dd HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);
}
/// <summary>
/// JSON converter that reads and writes PocketBase dates as Europe/Berlin
/// wall-clock <see cref="DateTime"/> values.
/// </summary>
/// <remarks>
/// An empty string (unset date) is read as <see langword="default"/>(<see cref="DateTime"/>).
/// For optional dates, use <see cref="NullableLocalDateTimeConverter"/> instead.
/// </remarks>
/// <example>
/// Apply it to a required date field of a contract:
/// <code>
/// [JsonConverter(typeof(LocalDateTimeConverter))]
/// public DateTime Start { get; init; }
/// </code>
/// </example>
/// <seealso cref="WallClock"/>
public sealed class LocalDateTimeConverter : JsonConverter<DateTime>
{
/// <inheritdoc/>
public override DateTime Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
=> WallClock.Parse(reader.GetString()) ?? default;
/// <inheritdoc/>
public override void Write(
Utf8JsonWriter writer,
DateTime value,
JsonSerializerOptions options)
=> writer.WriteStringValue(WallClock.Format(value));
}
/// <summary>
/// JSON converter that reads and writes optional PocketBase dates as Europe/Berlin
/// wall-clock <see cref="DateTime"/> values.
/// </summary>
/// <remarks>
/// PocketBase sends an empty string for an unset optional date (e.g. an event
/// without an end). This value, like JSON <c>null</c>, is read as <see langword="null"/>.
/// </remarks>
/// <example>
/// Apply it to an optional date field of a contract:
/// <code>
/// [JsonConverter(typeof(NullableLocalDateTimeConverter))]
/// public DateTime? End { get; init; }
/// </code>
/// </example>
/// <seealso cref="WallClock"/>
public sealed class NullableLocalDateTimeConverter : JsonConverter<DateTime?>
{
/// <inheritdoc/>
public override DateTime? Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
=> WallClock.Parse(reader.GetString());
/// <inheritdoc/>
/// <remarks>
/// Only called for non-null values: <see cref="JsonConverter{T}.HandleNull"/> is
/// <see langword="false"/>, so the serializer writes <see langword="null"/> itself.
/// </remarks>
public override void Write(
Utf8JsonWriter writer,
DateTime? value,
JsonSerializerOptions options)
=> writer.WriteStringValue(WallClock.Format(value ?? throw new ArgumentNullException(nameof(value))));
}
+89 -20
View File
@@ -3,45 +3,114 @@ using System.Text.Json.Serialization;
namespace Elternbeirat.Contracts; namespace Elternbeirat.Contracts;
/// <summary> /// <summary>
/// A content page. Pages also drive the site navigation: <see cref="Location"/> /// A content page of the site, as stored in PocketBase.
/// and <see cref="Order"/> decide where and in which order a page appears in the
/// header or footer menu, and <see cref="Embed"/> lists dynamic blocks (posts,
/// events, faqs) rendered below the page body.
/// </summary> /// </summary>
/// <remarks>
/// Pages also drive the site navigation: <see cref="Location"/> and
/// <see cref="Order"/> decide where and in which order a page appears in the
/// header or footer menu. <see cref="Embed"/> lists dynamic blocks rendered
/// below the page body.
/// </remarks>
public record Page public record Page
{ {
/// <summary>PocketBase record id.</summary> /// <summary>
/// The slug of the home page, which is served at the site root <c>/</c> rather
/// than at <c>/home</c>. Reserved: no other page may use it.
/// </summary>
private const string HomeSlug = "home";
/// <summary>
/// Gets the PocketBase record id.
/// </summary>
/// <remarks>
/// <see langword="required"/>: every stored record has an id. Modeling it as
/// required states which fields a record must carry, independent of the store,
/// so a future data source has to supply them too.
/// </remarks>
[JsonPropertyName("id")] [JsonPropertyName("id")]
public string Id { get; init; } = ""; public required string Id { get; init; }
/// <summary>Heading shown to visitors; may contain umlauts and spaces.</summary> /// <summary>
/// Gets the heading shown to visitors.
/// </summary>
/// <remarks>
/// Free text; may contain umlauts and spaces. For the URL, see <see cref="Slug"/>.
/// <see langword="required"/>: a page without a heading is incomplete, and the
/// field is required in PocketBase.
/// </remarks>
[JsonPropertyName("title")] [JsonPropertyName("title")]
public string Title { get; init; } = ""; public required string Title { get; init; }
/// <summary>Page body in Markdown.</summary> /// <summary>
/// Gets the page body as Markdown.
/// </summary>
[JsonPropertyName("body")] [JsonPropertyName("body")]
public string Body { get; init; } = ""; public string Body { get; init; } = string.Empty;
/// <summary>Where the page appears in the navigation: "header" or "footer".</summary> /// <summary>
/// Gets the navigation menu the page appears in.
/// </summary>
/// <value>
/// Either <c>"header"</c> or <c>"footer"</c>.
/// </value>
/// <remarks>
/// <see langword="required"/>: the navigation is built from this, and the field
/// is required in PocketBase, so every page belongs to one of the two menus.
/// </remarks>
[JsonPropertyName("location")] [JsonPropertyName("location")]
public string Location { get; init; } = ""; public required string Location { get; init; }
/// <summary>Sort order within its navigation location; smaller is earlier.</summary> /// <summary>
/// Gets the sort order within the navigation menu given by <see cref="Location"/>.
/// </summary>
/// <value>
/// The sort key; pages with smaller values appear first.
/// </value>
[JsonPropertyName("order")] [JsonPropertyName("order")]
public double Order { get; init; } public double Order { get; init; }
/// <summary>URL slug (lowercase, no umlauts), e.g. "board" -> /board.</summary> /// <summary>
/// Gets the URL slug of the page, e.g. <c>"board"</c> for <c>/board</c>.
/// </summary>
/// <value>
/// A URL path segment: lowercase letters, digits and single hyphens as
/// separators, no umlauts. PocketBase enforces this on save via a field
/// pattern (<c>^[a-z0-9]+(-[a-z0-9]+)*$</c>), so every stored slug is already
/// canonical and the app can use it verbatim in comparisons and generated URLs.
/// </value>
/// <remarks>
/// <see langword="required"/>: the slug is how a page is addressed, so a record
/// without one is broken. It is a required field in PocketBase, so deserializing
/// one that lacks it should fail loudly rather than yield a page with no URL.
/// </remarks>
[JsonPropertyName("slug")] [JsonPropertyName("slug")]
public string Slug { get; init; } = ""; public required string Slug { get; init; }
/// <summary> /// <summary>
/// Dynamic blocks to render below the body: any of "posts", "events", "faqs". /// Gets the dynamic blocks rendered below the <see cref="Body"/>.
/// Empty for a plain text page.
/// </summary> /// </summary>
/// <value>
/// Any of <c>"posts"</c> and <c>"events"</c>, or an empty list for a plain
/// text page.
/// </value>
[JsonPropertyName("embed")] [JsonPropertyName("embed")]
public IReadOnlyList<string> Embed { get; init; } = []; public IReadOnlyList<string> Embed { get; init; } = [];
/// <summary>Whether the page is visible to visitors.</summary> /// <summary>
[JsonPropertyName("public")] /// Tests whether a slug is the home page's. This is the single definition of
public bool Public { get; init; } /// that comparison, so every caller identifies the home page the same way.
/// </summary>
/// <remarks>
/// An ordinal (case-sensitive) comparison is enough because slugs are always
/// lowercase: PocketBase enforces that with a field pattern (see
/// <see cref="Slug"/>), and this is only ever called with a stored slug.
/// </remarks>
/// <param name="slug">
/// The slug to test, e.g. from <see cref="Slug"/>.
/// </param>
/// <returns>
/// <see langword="true"/> if <paramref name="slug"/> is the home page's slug.
/// </returns>
public static bool IsHomeSlug(string slug) =>
string.Equals(slug, HomeSlug, StringComparison.Ordinal);
} }
+52 -16
View File
@@ -2,33 +2,69 @@ using System.Text.Json.Serialization;
namespace Elternbeirat.Contracts; namespace Elternbeirat.Contracts;
/// <summary>A news post. Sorted by <see cref="Date"/>, newest first.</summary> /// <summary>
/// A news post of the site, as stored in PocketBase.
/// </summary>
/// <remarks>
/// Post lists are ordered by <see cref="Date"/>, newest first.
/// </remarks>
public record Post public record Post
{ {
/// <summary>PocketBase record id.</summary> /// <summary>
/// Gets the PocketBase record id.
/// </summary>
/// <remarks>
/// <see langword="required"/>: every stored record has an id. Modeling it as
/// required states which fields a record must carry, independent of the store,
/// so a future data source has to supply them too.
/// </remarks>
[JsonPropertyName("id")] [JsonPropertyName("id")]
public string Id { get; init; } = ""; public required string Id { get; init; }
/// <summary> /// <summary>
/// Publication date. Stored as UTC by PocketBase but read as local time /// Gets the publication date as a calendar day.
/// (Europe/Berlin) by convention; only the date part is shown.
/// </summary> /// </summary>
/// <remarks>
/// A post's date is a plain day, not a point in time. It is read as the
/// Berlin calendar day: the UTC value is converted to Europe/Berlin and only
/// the day is kept, so the day an editor picked is the day shown -- no
/// time-of-day, no off-by-one at midnight. See <see cref="DateOnlyConverter"/>.
/// </remarks>
[JsonPropertyName("date")] [JsonPropertyName("date")]
public DateTime Date { get; init; } [JsonConverter(typeof(DateOnlyConverter))]
public DateOnly Date { get; init; }
/// <summary>Post heading shown to visitors; may contain umlauts and spaces.</summary> /// <summary>
/// Gets the heading shown to visitors.
/// </summary>
/// <remarks>
/// Free text; may contain umlauts and spaces. For the URL, see <see cref="Slug"/>.
/// <see langword="required"/>: a post without a heading is incomplete, and the
/// field is required in PocketBase.
/// </remarks>
[JsonPropertyName("title")] [JsonPropertyName("title")]
public string Title { get; init; } = ""; public required string Title { get; init; }
/// <summary>Post body in Markdown.</summary> /// <summary>
/// Gets the post body as Markdown.
/// </summary>
[JsonPropertyName("body")] [JsonPropertyName("body")]
public string Body { get; init; } = ""; public string Body { get; init; } = string.Empty;
/// <summary>URL slug (lowercase, no umlauts), e.g. "herbstbasar" -> /posts/herbstbasar.</summary> /// <summary>
/// Gets the URL slug of the post, e.g. <c>"herbstbasar"</c> for <c>/posts/herbstbasar</c>.
/// </summary>
/// <value>
/// A URL path segment: lowercase letters, digits and single hyphens as
/// separators, no umlauts. PocketBase enforces this on save via a field
/// pattern (<c>^[a-z0-9]+(-[a-z0-9]+)*$</c>), so every stored slug is already
/// canonical and the app can use it verbatim in comparisons and generated URLs.
/// </value>
/// <remarks>
/// <see langword="required"/>: the slug is how a post is addressed, so a record
/// without one is broken. It is a required field in PocketBase, so deserializing
/// one that lacks it should fail loudly rather than yield a post with no URL.
/// </remarks>
[JsonPropertyName("slug")] [JsonPropertyName("slug")]
public string Slug { get; init; } = ""; public required string Slug { get; init; }
/// <summary>Whether the post is visible to visitors.</summary>
[JsonPropertyName("public")]
public bool Public { get; init; }
} }
@@ -1,75 +0,0 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Elternbeirat.PocketBase;
/// <summary>
/// Shared parsing of a PocketBase date string as wall-clock time. The trailing
/// "Z" is stripped rather than honoured, so the number is taken at face value and
/// the result carries <see cref="DateTimeKind.Unspecified"/> -- no timezone shift.
/// </summary>
internal static class WallClock
{
public static DateTime Parse(string raw)
{
// Drop a trailing "Z" so DateTime.Parse does not treat the value as UTC
// and convert it to local time (which would shift 19:30 to 20:30/21:30).
var value = raw.EndsWith('Z') ? raw[..^1] : raw;
var parsed = DateTime.Parse(value, CultureInfo.InvariantCulture,
DateTimeStyles.None);
return DateTime.SpecifyKind(parsed, DateTimeKind.Unspecified);
}
}
/// <summary>
/// Reads PocketBase date values as local wall-clock time.
/// <para>
/// PocketBase stores every date in UTC and serializes it with a trailing "Z"
/// (e.g. "2026-10-08 19:30:00.000Z"). By project convention the stored number
/// IS the local time (Europe/Berlin) and the "Z" is ignored -- see the timezone
/// decision in the data model. This converter therefore parses the value and
/// returns it as an <see cref="DateTimeKind.Unspecified"/> instant, so no
/// timezone shift is ever applied when the value is later formatted.
/// </para>
/// </summary>
public sealed class LocalDateTimeConverter : JsonConverter<DateTime>
{
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var raw = reader.GetString();
return string.IsNullOrEmpty(raw)
? default
: WallClock.Parse(raw);
}
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.ToString("yyyy-MM-dd HH:mm:ss.fff'Z'",
CultureInfo.InvariantCulture));
}
/// <summary>
/// Nullable counterpart of <see cref="LocalDateTimeConverter"/>. PocketBase sends
/// an empty string for an unset optional date (e.g. an event without an end);
/// that maps to null.
/// </summary>
public sealed class NullableLocalDateTimeConverter : JsonConverter<DateTime?>
{
public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var raw = reader.GetString();
if (string.IsNullOrEmpty(raw))
return null;
return WallClock.Parse(raw);
}
public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options)
{
if (value is null)
writer.WriteStringValue("");
else
writer.WriteStringValue(value.Value.ToString("yyyy-MM-dd HH:mm:ss.fff'Z'",
CultureInfo.InvariantCulture));
}
}
+191 -36
View File
@@ -5,47 +5,179 @@ using Elternbeirat.Contracts;
namespace Elternbeirat.PocketBase; namespace Elternbeirat.PocketBase;
/// <summary> /// <summary>
/// Reads published content from a PocketBase instance over its REST API. /// Reads published content from a PocketBase instance over its REST API.
/// <para>
/// One method per collection (pages, posts, events, faqs). Each returns only
/// records with <c>public = true</c> and lets PocketBase do the filtering and
/// sorting via query parameters. The <see cref="HttpClient"/> is expected to have
/// its <see cref="HttpClient.BaseAddress"/> set to the PocketBase base URL, so it
/// is registered as a typed client via <c>AddHttpClient</c>.
/// </para>
/// </summary> /// </summary>
public sealed class PocketBaseClient(HttpClient http) /// <remarks>
/// <para>
/// There is one method per collection (pages, posts, events, faqs). Each returns
/// only records with <c>public = true</c> and leaves filtering and sorting to
/// PocketBase via query parameters.
/// </para>
/// <para>
/// Responses are deserialized with the web defaults. Date conversion from UTC
/// to Berlin wall-clock time is declared on the contracts themselves via
/// <c>[JsonConverter]</c> (e.g. <see cref="Event.Start"/>, <see cref="Post.Date"/>),
/// so no serializer options are configured here.
/// </para>
/// <para>
/// Register the class as a typed client via <c>AddHttpClient</c>, with
/// <see cref="HttpClient.BaseAddress"/> set to the PocketBase base URL.
/// </para>
/// </remarks>
/// <param name="httpClient">
/// The HTTP client; its <see cref="HttpClient.BaseAddress"/> must point to PocketBase.
/// </param>
public sealed class PocketBaseClient(HttpClient httpClient)
{ {
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); /// <summary>
/// Gets all public pages, ordered by <see cref="Page.Order"/>.
/// </summary>
/// <param name="token">
/// A token to cancel the request.
/// </param>
/// <returns>
/// The public pages, or an empty list if there are none.
/// </returns>
/// <exception cref="PocketBaseUnavailableException">
/// PocketBase could not be reached, timed out, or returned an unreadable response.
/// </exception>
/// <exception cref="OperationCanceledException">
/// <paramref name="token"/> was canceled by the caller.
/// </exception>
public Task<IReadOnlyList<Page>> GetPagesAsync(CancellationToken token = default)
=> GetRecordsAsync<Page>("pages", "order", token);
private static JsonSerializerOptions CreateJsonOptions() /// <summary>
/// Gets all public posts, ordered by <see cref="Post.Date"/>, newest first.
/// </summary>
/// <inheritdoc cref="GetPagesAsync" path="/param"/>
/// <returns>
/// The public posts, or an empty list if there are none.
/// </returns>
/// <inheritdoc cref="GetPagesAsync" path="/exception"/>
public Task<IReadOnlyList<Post>> GetPostsAsync(CancellationToken token = default)
=> GetRecordsAsync<Post>("posts", "-date", token);
/// <summary>
/// Gets all public events, ordered by <see cref="Event.Start"/>, earliest first.
/// </summary>
/// <inheritdoc cref="GetPagesAsync" path="/param"/>
/// <returns>
/// The public events, or an empty list if there are none.
/// </returns>
/// <inheritdoc cref="GetPagesAsync" path="/exception"/>
public Task<IReadOnlyList<Event>> GetEventsAsync(CancellationToken token = default)
=> GetRecordsAsync<Event>("events", "start", token);
/// <summary>
/// Gets all public FAQ entries, grouped by topic: ordered by the topic's own
/// <c>order</c>, then by the entry's <c>order</c> within that topic. Each entry
/// carries its expanded <see cref="Faq.Topic"/>.
/// </summary>
/// <remarks>
/// Sorting by <c>topic.order</c> first keeps all questions of a topic together
/// and lists the topic groups in the order an editor gave the topics; the
/// entry's own <c>order</c> then arranges the questions within each group. Both
/// keys together make the order total, so the same content renders the same way
/// between requests. The topic relation is expanded so the page can show each
/// group's heading without a second request.
/// </remarks>
/// <inheritdoc cref="GetPagesAsync" path="/param"/>
/// <returns>
/// The public FAQ entries, or an empty list if there are none.
/// </returns>
/// <inheritdoc cref="GetPagesAsync" path="/exception"/>
public Task<IReadOnlyList<Faq>> GetFaqsAsync(CancellationToken token = default)
=> GetRecordsAsync<Faq>("faqs", "topic.order,order", token, expand: "topic");
/// <summary>
/// Gets all public FAQ topics, ordered by <see cref="FaqTopic.Order"/>, each with
/// its questions expanded.
/// </summary>
/// <remarks>
/// Backs the FAQ page, which shows every topic as a collapsible group with its
/// questions nested inside. The <c>faqs_via_topic</c> back-relation is expanded so
/// one request returns the topics and their questions together; drafts among the
/// questions are dropped in <see cref="FaqTopic.Faqs"/>.
/// </remarks>
/// <inheritdoc cref="GetPagesAsync" path="/param"/>
/// <returns>
/// The public FAQ topics with their questions, or an empty list if there are none.
/// </returns>
/// <inheritdoc cref="GetPagesAsync" path="/exception"/>
public Task<IReadOnlyList<FaqTopic>> GetFaqTopicsAsync(CancellationToken token = default)
=> GetRecordsAsync<FaqTopic>("faq_topics", "order", token, expand: "faqs_via_topic");
/// <summary>
/// Checks whether PocketBase answers its health endpoint.
/// </summary>
/// <remarks>
/// Backs the app's own <c>/health</c> endpoint, which an external monitor (Uptime
/// Kuma) polls: the app answering at all proves the app is up, and this probe adds
/// whether the content source behind it is reachable, so one monitor covers both.
/// This calls <c>GET /api/health</c>, PocketBase's own liveness endpoint, which
/// needs no auth and touches no collection.
/// </remarks>
/// <param name="token">
/// A token to cancel the probe.
/// </param>
/// <returns>
/// <see langword="true"/> if PocketBase answered with a success status within the
/// client timeout; <see langword="false"/> if it was unreachable, timed out, or
/// answered with an error status.
/// </returns>
public async Task<bool> IsHealthyAsync(CancellationToken token = default)
{ {
var options = new JsonSerializerOptions try
{ {
PropertyNameCaseInsensitive = true, using var response = await httpClient.GetAsync(
}; new Uri("/api/health", UriKind.Relative), token);
options.Converters.Add(new LocalDateTimeConverter()); return response.IsSuccessStatusCode;
options.Converters.Add(new NullableLocalDateTimeConverter()); }
return options; // Same failure shapes as a record read: unreachable (HttpRequestException) or the
// client's own timeout (a TaskCanceledException wrapping a TimeoutException). A
// plain TaskCanceledException is the caller's cancellation and propagates.
catch (Exception exception) when (
exception is HttpRequestException
or TaskCanceledException { InnerException: TimeoutException })
{
return false;
}
} }
/// <summary>Gets all public pages, ordered for navigation.</summary> /// <summary>
public Task<IReadOnlyList<Page>> GetPagesAsync(CancellationToken ct = default) /// Gets all public records of a collection in a single request.
=> GetRecordsAsync<Page>("pages", "order", ct); /// </summary>
/// <typeparam name="T">
/// <summary>Gets all public posts, newest first.</summary> /// The record type to deserialize into.
public Task<IReadOnlyList<Post>> GetPostsAsync(CancellationToken ct = default) /// </typeparam>
=> GetRecordsAsync<Post>("posts", "-date", ct); /// <param name="collection">
/// The PocketBase collection name, e.g. <c>"posts"</c>.
/// <summary>Gets all public events, earliest start first.</summary> /// </param>
public Task<IReadOnlyList<Event>> GetEventsAsync(CancellationToken ct = default) /// <param name="sort">
=> GetRecordsAsync<Event>("events", "start", ct); /// The PocketBase sort expression; a leading <c>-</c> sorts descending.
/// </param>
/// <summary>Gets all public FAQ entries.</summary> /// <param name="token">
public Task<IReadOnlyList<Faq>> GetFaqsAsync(CancellationToken ct = default) /// A token to cancel the request.
=> GetRecordsAsync<Faq>("faqs", "topic", ct); /// </param>
/// <param name="expand">
private async Task<IReadOnlyList<T>> GetRecordsAsync<T>(string collection, string sort, CancellationToken ct) /// A PocketBase <c>expand</c> expression naming relations to resolve inline
/// (comma-separated, dot-nested), or <see langword="null"/> to expand none.
/// </param>
/// <returns>
/// The public records, or an empty list if there are none.
/// </returns>
/// <exception cref="PocketBaseUnavailableException">
/// PocketBase could not be reached, timed out, or returned an unreadable response.
/// </exception>
/// <exception cref="OperationCanceledException">
/// <paramref name="token"/> was canceled by the caller.
/// </exception>
private async Task<IReadOnlyList<T>> GetRecordsAsync<T>(
string collection,
string sort,
CancellationToken token,
string? expand = null)
{ {
// filter=public=true keeps drafts out; perPage is large enough to fetch // filter=public=true keeps drafts out; perPage is large enough to fetch
// every record in a single page given the small content volume. // every record in a single page given the small content volume.
@@ -53,7 +185,30 @@ public sealed class PocketBaseClient(HttpClient http)
+ $"?perPage=500&filter={Uri.EscapeDataString("public=true")}" + $"?perPage=500&filter={Uri.EscapeDataString("public=true")}"
+ $"&sort={Uri.EscapeDataString(sort)}"; + $"&sort={Uri.EscapeDataString(sort)}";
var result = await http.GetFromJsonAsync<RecordList<T>>(url, JsonOptions, ct); // Add expand only when asked, so callers that need no relations send the
return result?.Items ?? []; // leaner request they did before.
if (expand is not null)
url += $"&expand={Uri.EscapeDataString(expand)}";
try
{
var result = await httpClient.GetFromJsonAsync<RecordList<T>>(url, token);
return result?.Items ?? [];
}
// These three mean PocketBase itself failed: it was unreachable
// (HttpRequestException), the request to it timed out (a TaskCanceledException
// whose inner exception is a TimeoutException, which is how HttpClient surfaces
// its own timeout), or it answered with something we could not read
// (JsonException). A plain TaskCanceledException with no inner TimeoutException
// is the caller's own cancellation via token and is deliberately not caught --
// it propagates as OperationCanceledException so an aborted request stays an
// abort, not a fault.
catch (Exception exception) when (
exception is HttpRequestException
or JsonException
or TaskCanceledException { InnerException: TimeoutException })
{
throw new PocketBaseUnavailableException($"Could not read collection '{collection}'.", exception);
}
} }
} }
@@ -0,0 +1,49 @@
namespace Elternbeirat.PocketBase;
/// <summary>
/// Thrown when PocketBase could not be reached, timed out, or returned a
/// response that could not be read.
/// </summary>
/// <remarks>
/// This is the one failure the content pages need to distinguish: the site is
/// up but its data store is not, which is an HTTP 503, not a 404. Which
/// low-level exceptions mean that (a transport error, a timeout, unreadable
/// JSON) is <see cref="PocketBaseClient"/>'s concern; callers catch only this
/// type and need not know how the client talks to PocketBase.
/// </remarks>
public sealed class PocketBaseUnavailableException : Exception
{
/// <summary>
/// Initializes a new instance with a default message.
/// </summary>
public PocketBaseUnavailableException()
: base("PocketBase is unavailable.")
{
}
/// <summary>
/// Initializes a new instance with the given message.
/// </summary>
/// <param name="message">
/// The message that describes the error.
/// </param>
public PocketBaseUnavailableException(string message)
: base(message)
{
}
/// <summary>
/// Initializes a new instance with the given message and the underlying
/// failure that caused it.
/// </summary>
/// <param name="message">
/// The message that describes the error.
/// </param>
/// <param name="innerException">
/// The transport, timeout, or deserialization exception that was caught.
/// </param>
public PocketBaseUnavailableException(string message, Exception innerException)
: base(message, innerException)
{
}
}
+16 -4
View File
@@ -3,13 +3,25 @@ using System.Text.Json.Serialization;
namespace Elternbeirat.PocketBase; namespace Elternbeirat.PocketBase;
/// <summary> /// <summary>
/// The envelope PocketBase wraps a records list response in. Only <see cref="Items"/> /// The envelope PocketBase wraps a records list response in.
/// is used; the paging fields are ignored because content volumes are small and the
/// client requests a large page size in a single call.
/// </summary> /// </summary>
/// <typeparam name="T">The record type inside <c>items</c>.</typeparam> /// <remarks>
/// Only <see cref="Items"/> is mapped. The paging fields (<c>page</c>,
/// <c>perPage</c>, <c>totalItems</c>, <c>totalPages</c>) are ignored, because
/// content volumes are small and <see cref="PocketBaseClient"/> fetches every
/// record in a single request.
/// </remarks>
/// <typeparam name="T">
/// The record type inside <c>items</c>.
/// </typeparam>
internal sealed record RecordList<T> internal sealed record RecordList<T>
{ {
/// <summary>
/// Gets the records of the requested page.
/// </summary>
/// <value>
/// The records, or an empty list if the collection has no matching records.
/// </value>
[JsonPropertyName("items")] [JsonPropertyName("items")]
public IReadOnlyList<T> Items { get; init; } = []; public IReadOnlyList<T> Items { get; init; } = [];
} }
@@ -0,0 +1,105 @@
using Elternbeirat.Contracts;
using Elternbeirat.Web.Features.Events;
namespace Elternbeirat.Web.Tests;
/// <summary>
/// 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.
/// </summary>
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("");
}
@@ -0,0 +1,13 @@
namespace Elternbeirat.Web.Tests;
/// <summary>
/// A clock that always shows the same moment, so tests can decide what "today" is
/// (e.g. before or after the fixture's events). Hand-written instead of
/// Microsoft.Extensions.TimeProvider.Testing: overriding one method does not justify
/// a new package.
/// </summary>
/// <param name="now">The moment the clock shows.</param>
internal sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
+27
View File
@@ -0,0 +1,27 @@
using Elternbeirat.Web.Features.Layout;
namespace Elternbeirat.Web.Tests;
/// <summary>
/// Unit tests for the footer's copyright year. Pure logic, no PocketBase needed.
/// </summary>
public class FooterYearTests
{
[Fact]
public void New_year_in_Berlin_wins_over_the_UTC_year()
{
// 23:30 UTC on New Year's Eve is already 00:30 in Berlin (winter time, +1),
// so the footer must show the new year although the server clock says the old.
var now = new DateTimeOffset(2026, 12, 31, 23, 30, 0, TimeSpan.Zero);
FooterYear.For(now).ShouldBe(2027);
}
[Fact]
public void Ordinary_day_keeps_its_year()
{
var now = new DateTimeOffset(2026, 10, 1, 12, 0, 0, TimeSpan.Zero);
FooterYear.For(now).ShouldBe(2026);
}
}
+67
View File
@@ -0,0 +1,67 @@
using Elternbeirat.Web.Features.Home;
namespace Elternbeirat.Web.Tests;
/// <summary>
/// Unit tests for taking the home page's body apart into heading, intro and
/// buttons. Pure logic, no PocketBase needed.
/// </summary>
public class HomeIntroTests
{
[Fact]
public void Body_splits_into_title_intro_and_buttons()
{
var intro = HomeIntro.Parse(
"# Willkommen\n\nSchön, dass Sie da sind.\n\n[Kommende Termine](/events)\n[Kontakt aufnehmen](/contact)");
intro.Title.ShouldBe("Willkommen");
intro.IntroHtml.ShouldBe("<p>Schön, dass Sie da sind.</p>\n");
intro.Links.ShouldBe(
[
new HomeLink("/events", "Kommende Termine"),
new HomeLink("/contact", "Kontakt aufnehmen"),
]);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Empty_body_gives_the_fallback(string? markdown) =>
HomeIntro.Parse(markdown).ShouldBe(HomeIntro.Fallback);
[Fact]
public void Body_without_heading_keeps_the_fallback_title_and_all_text()
{
var intro = HomeIntro.Parse("Nur ein Absatz.");
intro.Title.ShouldBe(HomeIntro.FallbackTitle);
intro.IntroHtml.ShouldContain("Nur ein Absatz.");
intro.Links.ShouldBeEmpty();
}
[Fact]
public void Last_paragraph_with_other_text_stays_intro()
{
// "oder" would get lost in a button row, so the paragraph stays text.
var intro = HomeIntro.Parse("# Hallo\n\n[Termine](/events) oder [Kontakt](/contact)");
intro.Links.ShouldBeEmpty();
intro.IntroHtml.ShouldContain("href=\"/events\"");
}
[Fact]
public void Link_paragraph_that_is_not_last_stays_intro()
{
var intro = HomeIntro.Parse("# Hallo\n\n[Termine](/events)\n\nSchlusssatz.");
intro.Links.ShouldBeEmpty();
intro.IntroHtml.ShouldContain("Schlusssatz.");
}
[Fact]
public void Heading_markup_is_reduced_to_its_text()
{
HomeIntro.Parse("# Willkommen beim **Elternbeirat**").Title.ShouldBe("Willkommen beim Elternbeirat");
}
}
+30
View File
@@ -0,0 +1,30 @@
using Elternbeirat.Web.Shared;
namespace Elternbeirat.Web.Tests;
/// <summary>
/// Unit tests for the icon lookup behind the Icon component. Pure logic, no
/// PocketBase needed.
/// </summary>
public class IconSetTests
{
[Theory]
[InlineData("calendar")]
[InlineData("map-pin")]
[InlineData("help-circle")] // kept under its old name although Lucide renamed it
public void Known_name_returns_svg_markup(string name)
{
var markup = IconSet.Find(name);
markup.ShouldNotBeNullOrWhiteSpace();
markup.ShouldStartWith("<"); // SVG child elements, ready to inline
}
[Theory]
[InlineData("does-not-exist")]
[InlineData("Calendar")] // lookup is case-sensitive, names are code identifiers
[InlineData("")]
[InlineData(null)]
public void Unknown_name_returns_null(string? name) =>
IconSet.Find(name).ShouldBeNull();
}
+187
View File
@@ -0,0 +1,187 @@
using Elternbeirat.Web.Shared;
namespace Elternbeirat.Web.Tests;
/// <summary>
/// Unit tests for the Markdown pipeline behind every editor-written body. Pure logic,
/// no PocketBase needed.
/// </summary>
public class MarkdownTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
public void Empty_input_renders_nothing(string? markdown) =>
Markdown.ToHtml(markdown).ShouldBe("");
[Fact]
public void Custom_container_renders_a_div_with_its_name_as_class()
{
var html = Markdown.ToHtml("::: kennzahlen\n- **seit 1975** Gründung\n:::");
html.ShouldContain("<div class=\"kennzahlen\">");
html.ShouldContain("<strong>seit 1975</strong>");
}
[Fact]
public void Unknown_container_renders_its_content_as_normal_text()
{
// A typo in the block name must cost the styling, never the text.
var html = Markdown.ToHtml("::: gibtsnicht\nNormaler Text.\n:::");
html.ShouldContain("<p>Normaler Text.</p>");
html.ShouldNotContain(":::");
}
[Fact]
public void Raw_html_is_escaped_not_passed_through()
{
// The body is rendered as a MarkupString; a passed-through <script> would run
// in the visitor's browser.
var html = Markdown.ToHtml("Hallo <script>alert(1)</script>\n\n<div onclick=\"x()\">Box</div>");
html.ShouldNotContain("<script>");
html.ShouldNotContain("<div onclick");
html.ShouldContain("&lt;script&gt;");
}
[Fact]
public void Pipe_table_renders_a_table()
{
var html = Markdown.ToHtml("| Fach | Raum |\n|---|---|\n| Mathe | A1 |");
html.ShouldContain("<table>");
html.ShouldContain("<td>Mathe</td>");
}
[Theory]
[InlineData("[kontakt@example.org](mailto:kontakt@example.org)")]
[InlineData("<mailto:kontakt@example.org>")] // autolink
[InlineData("[Satzung](/dokumente/satzung.pdf) ")] // trailing whitespace ignored
public void Link_alone_in_its_paragraph_is_marked(string markdown) =>
Markdown.ToHtml(markdown).ShouldStartWith($"<p class=\"{Markdown.LoneLinkClass}\">");
[Theory]
[InlineData("Schreiben Sie an [uns](mailto:kontakt@example.org).")]
[InlineData("[eins](/a) und [zwei](/b)")]
[InlineData("![Foto](/bild.jpg)")] // an image is not a link
[InlineData("Nur Text.")]
public void Paragraph_that_is_not_a_single_link_is_not_marked(string markdown) =>
Markdown.ToHtml(markdown).ShouldNotContain(Markdown.LoneLinkClass);
[Fact]
public void Lone_link_in_a_tight_list_marks_the_list_item()
{
// A tight list renders no <p>, so the class has to sit on the <li>.
var html = Markdown.ToHtml("- [Antrag](/dokumente/antrag.pdf)\n- Text mit [Link](/x)");
html.ShouldContain($"<li class=\"{Markdown.LoneLinkClass}\"><a href=\"/dokumente/antrag.pdf\">");
html.ShouldContain("<li>Text mit");
}
[Fact]
public void First_sentence_skips_the_heading_and_drops_the_markdown()
{
var sentence = Markdown.FirstSentence(
"# Vorstand\n\nWir sind **sieben** [Eltern](/board). Der Rest folgt.");
sentence.ShouldBe("Wir sind sieben Eltern.");
}
[Theory]
[InlineData("Wir helfen z. B. bei Anträgen. Mehr dazu unten.", "Wir helfen z. B. bei Anträgen.")]
[InlineData("Gewählt am 13. November 2025. Danach mehr.", "Gewählt am 13. November 2025.")]
[InlineData("Haben Sie Fragen? Schreiben Sie uns.", "Haben Sie Fragen?")]
[InlineData("Ohne Satzzeichen am Ende", "Ohne Satzzeichen am Ende")]
public void First_sentence_ends_at_a_real_sentence_end(string markdown, string expected) =>
Markdown.FirstSentence(markdown).ShouldBe(expected);
[Fact]
public void First_sentence_joins_soft_line_breaks_with_a_space()
{
// Editors wrap long lines; the wrap must not glue two words together.
Markdown.FirstSentence("Erste Zeile\nzweite Zeile.").ShouldBe("Erste Zeile zweite Zeile.");
}
[Fact]
public void First_sentence_drops_a_colon_that_leads_into_a_list() =>
Markdown.FirstSentence("Formulare zum Herunterladen:\n\n- Antrag")
.ShouldBe("Formulare zum Herunterladen");
[Fact]
public void First_sentence_is_never_cut_short()
{
// Long teasers are clamped by the tile's CSS, not by a character count.
var sentence = string.Join(' ', Enumerable.Repeat("Wort", 60)) + ".";
Markdown.FirstSentence(sentence).ShouldBe(sentence);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("# Nur eine Überschrift")]
[InlineData("- nur\n- eine Liste")]
[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<ArgumentOutOfRangeException>(() => Markdown.Teaser("Text", 0));
}
+12 -11
View File
@@ -8,8 +8,10 @@ namespace Elternbeirat.Web.Tests;
/// </summary> /// </summary>
public class NavBuilderTests public class NavBuilderTests
{ {
// Id is required on the record but irrelevant to navigation, so a fixed
// placeholder keeps these cases focused on slug/location/order/title.
private static Page Page(string slug, string location, double order, string title) => private static Page Page(string slug, string location, double order, string title) =>
new() { Slug = slug, Location = location, Order = order, Title = title }; new() { Id = "test", Slug = slug, Location = location, Order = order, Title = title };
[Fact] [Fact]
public void Header_keeps_only_header_pages_ordered_by_order() public void Header_keeps_only_header_pages_ordered_by_order()
@@ -19,7 +21,6 @@ public class NavBuilderTests
Page("board", "header", 2, "Vorstandsteam"), Page("board", "header", 2, "Vorstandsteam"),
Page("home", "header", 1, "Start"), Page("home", "header", 1, "Start"),
Page("imprint", "footer", 1, "Impressum"), Page("imprint", "footer", 1, "Impressum"),
Page("hidden", "", 1, "Unterseite"),
}; };
var header = NavBuilder.Header(pages); var header = NavBuilder.Header(pages);
@@ -42,6 +43,15 @@ public class NavBuilderTests
footer.Select(link => link.Title).ShouldBe(["Impressum", "Datenschutz"]); footer.Select(link => link.Title).ShouldBe(["Impressum", "Datenschutz"]);
} }
[Fact]
public void No_pages_give_empty_menus()
{
// PocketBase down or nothing published yet: both menus are empty lists, not
// null, so the layout can simply skip a column instead of crashing.
NavBuilder.Header([]).ShouldBeEmpty();
NavBuilder.Footer([]).ShouldBeEmpty();
}
[Fact] [Fact]
public void Home_page_maps_to_root_every_other_slug_to_its_path() public void Home_page_maps_to_root_every_other_slug_to_its_path()
{ {
@@ -56,13 +66,4 @@ public class NavBuilderTests
header[0].Href.ShouldBe("/"); header[0].Href.ShouldBe("/");
header[1].Href.ShouldBe("/board"); header[1].Href.ShouldBe("/board");
} }
[Fact]
public void Pages_without_a_location_never_appear_in_a_menu()
{
var pages = new[] { Page("hidden", "", 1, "Unterseite") };
NavBuilder.Header(pages).ShouldBeEmpty();
NavBuilder.Footer(pages).ShouldBeEmpty();
}
} }
+34 -17
View File
@@ -17,20 +17,20 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
var events = await client.GetEventsAsync(); var events = await client.GetEventsAsync();
// Two seeded events, earliest start first. // Three seeded events, earliest start first.
events.Select(e => e.Title).ShouldBe(["Elternbeiratssitzung", "Herbstbasar"]); events.Select(@event => @event.Title).ShouldBe(["Projekttage", "Infoabend Klasse 5", "Elternbeiratssitzung", "Herbstbasar"]);
events.ShouldAllBe(e => e.Public);
} }
[Fact] [Fact]
public async Task Event_time_is_read_as_wall_clock_not_shifted() public async Task Event_utc_is_converted_to_berlin_wall_clock()
{ {
var client = pocketBase.CreateClient(); var client = pocketBase.CreateClient();
var events = await client.GetEventsAsync(); var events = await client.GetEventsAsync();
// The meeting is seeded as 19:30; by the timezone convention the number is // The meeting is seeded as 17:30Z. October is summer time (UTC+2), so the
// taken at face value, so no shift to 20:30/21:30 happens. // converter turns it into 19:30 Berlin wall-clock -- the time an editor
// entered in the admin form.
var meeting = events.FirstOrDefault(e => var meeting = events.FirstOrDefault(e =>
e.Title.Contains("Elternbeiratssitzung", StringComparison.Ordinal)); e.Title.Contains("Elternbeiratssitzung", StringComparison.Ordinal));
meeting.ShouldNotBeNull(); meeting.ShouldNotBeNull();
@@ -46,8 +46,8 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
var events = await client.GetEventsAsync(); var events = await client.GetEventsAsync();
var meeting = events.Single(e => e.Title == "Elternbeiratssitzung"); var meeting = events.Single(@event => @event.Title == "Elternbeiratssitzung");
var basar = events.Single(e => e.Title == "Herbstbasar"); var basar = events.Single(@event => @event.Title == "Herbstbasar");
meeting.End.ShouldBeNull(); // no end seeded meeting.End.ShouldBeNull(); // no end seeded
basar.End.ShouldNotBeNull(); // end seeded basar.End.ShouldNotBeNull(); // end seeded
} }
@@ -60,7 +60,21 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
var posts = await client.GetPostsAsync(); var posts = await client.GetPostsAsync();
// Sorted by -date: March before January. // Sorted by -date: March before January.
posts.Select(p => p.Title).ShouldBe(["Neuer Vorstand", "Neue Sporthalle"]); posts.Select(post => post.Title).ShouldBe(["Neuer Vorstand", "Neue Sporthalle"]);
}
[Fact]
public async Task Post_date_is_the_berlin_calendar_day()
{
var client = pocketBase.CreateClient();
var posts = await client.GetPostsAsync();
// The post is seeded as 2026-03-01 23:30Z, which is 2 March 00:30 in Berlin.
// The date is the Berlin day the editor picked (2 March), not the raw UTC
// day (1 March) -- the time zone is applied before the day is taken.
var post = posts.Single(p => p.Title == "Neuer Vorstand");
post.Date.ShouldBe(new DateOnly(2026, 3, 2));
} }
[Fact] [Fact]
@@ -71,9 +85,9 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
var pages = await client.GetPagesAsync(); var pages = await client.GetPagesAsync();
// The draft (public=false) must be filtered out, the public ones present. // The draft (public=false) must be filtered out, the public ones present.
pages.ShouldNotContain(p => p.Slug == "draft"); pages.ShouldNotContain(page => page.Slug == "draft");
pages.ShouldContain(p => p.Slug == "home"); pages.ShouldContain(page => page.Slug == "home");
pages.ShouldContain(p => p.Slug == "contact"); pages.ShouldContain(page => page.Slug == "contact");
} }
[Fact] [Fact]
@@ -85,9 +99,9 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
var home = pages.Single(p => p.Slug == "home"); var home = pages.Single(p => p.Slug == "home");
home.Embed.ShouldBe(["posts", "events"], ignoreOrder: true); home.Embed.ShouldBe(["posts", "events"], ignoreOrder: true);
// Home has no menu slot: the brand is the home link, so a "Start" nav // Home sits in the header like every other page (location is required); it
// entry would be redundant. NavBuilder filters it out via this empty value. // is the single source of the home nav link.
home.Location.ShouldBe(""); home.Location.ShouldBe("header");
} }
[Fact] [Fact]
@@ -97,7 +111,10 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
var faqs = await client.GetFaqsAsync(); var faqs = await client.GetFaqsAsync();
faqs.Select(f => f.Topic).ShouldBe(["mensa", "schliessfach"], ignoreOrder: true); // The topic relation is expanded, so each entry carries its topic. The two
faqs.ShouldAllBe(f => !string.IsNullOrWhiteSpace(f.Question)); // seeded topics are ordered (Mensa order 1 before Schließfächer order 2), and
// GetFaqsAsync sorts by topic.order, so the groups come back in that order.
faqs.Select(faq => faq.Topic?.Title).ShouldBe(["Mensa und Mittagessen", "Schließfächer"]);
faqs.ShouldAllBe(faq => !string.IsNullOrWhiteSpace(faq.Question));
} }
} }
+111 -26
View File
@@ -220,32 +220,51 @@ public sealed class PocketBaseFixture : IAsyncLifetime
private static async Task SeedRecordsAsync(HttpClient http) private static async Task SeedRecordsAsync(HttpClient http)
{ {
// Pages that the route smoke tests reach through the navigation, the // Pages that the route smoke tests reach through the navigation, the
// footer or the FAQ hub. Header pages carry a location+order; the FAQ // footer or the FAQ hub. Every page carries a location ("header" or
// sub-pages are plain content pages without a menu slot. All public. // "footer") -- the field is required, matching production. All public.
// The home page has no menu slot (location ""): the site brand is the home // The home page sits in the header at order 1, like production; the brand
// link, so a separate "Start" nav entry would be redundant. // links home too, but the nav entry is the single source of the home link.
await CreateRecordAsync(http, "pages", new await CreateRecordAsync(http, "pages", new
{ {
title = "Startseite", body = "# Willkommen", location = "", // The body drives the home hero: "# heading" -> h1, the paragraph -> intro,
order = 0, slug = "home", embed = new[] { "posts", "events" }, @public = true, // the last paragraph of only links -> the button row.
title = "Start",
body = "# Willkommen\n\nSchön, dass Sie da sind.\n\n[Kommende Termine](/events)\n[Kontakt aufnehmen](/contact)",
location = "header",
order = 1, slug = "home", embed = new[] { "posts", "events" }, @public = true,
}); });
await CreateRecordAsync(http, "pages", new await CreateRecordAsync(http, "pages", new
{ {
title = "Vorstandsteam", body = "# Vorstand", location = "header", // The body uses the "::: team" block like production: one card with the
// full convention (bold name, role, italic duties, note) in a large block,
// and one item without the convention, which must become a plain card.
title = "Vorstandsteam",
body = "## Vorsitz\n\n::: team gross\n"
+ "- **Jenny Reger-Stilgenbauer** (Vorsitzende) \n *Schulkonferenz, Mensarat* \n Mitglied im Gesamtelternbeirat.\n"
+ ":::\n\n## Beisitz\n\n::: team\n"
+ "- Özlem Ünal, Beisitz\n"
+ ":::",
location = "header",
order = 2, slug = "board", embed = Array.Empty<string>(), @public = true, order = 2, slug = "board", embed = Array.Empty<string>(), @public = true,
}); });
await CreateRecordAsync(http, "pages", new await CreateRecordAsync(http, "pages", new
{ {
// Carries an embed so the ContentPage embed path is covered: /patrons // The body uses a design block, so the smoke tests can follow a ":::"
// renders its body plus the FAQ teaser. // container through PocketBase and the page into the rendered HTML.
title = "Förderverein", body = "# Förderverein", location = "header", title = "Förderverein",
order = 3, slug = "patrons", embed = new[] { "faqs" }, @public = true, body = "Der Förderverein unterstützt die Schule.\n\n::: kennzahlen\n- **seit 1975** Gründung\n:::",
location = "header",
order = 3, slug = "patrons", embed = Array.Empty<string>(), @public = true,
}); });
// Nav placeholder for the FAQ list (route /faqs is served by FaqList, // Page record for the FAQ list. Route /faqs is served by FaqList, which reads
// which shadows this page). Slug matches production's "faqs" nav entry. // this record for its heading and intro (title + body) and renders the topic
// cards itself. Slug matches production's "faqs" nav entry. The body carries
// no "# heading": the title already renders as the h1.
await CreateRecordAsync(http, "pages", new await CreateRecordAsync(http, "pages", new
{ {
title = "FAQ", body = "# Häufige Fragen", location = "header", title = "FAQs",
body = "Wählen Sie ein Thema, um die passenden Fragen zu sehen.",
location = "header",
order = 4, slug = "faqs", embed = Array.Empty<string>(), @public = true, order = 4, slug = "faqs", embed = Array.Empty<string>(), @public = true,
}); });
await CreateRecordAsync(http, "pages", new await CreateRecordAsync(http, "pages", new
@@ -253,10 +272,29 @@ public sealed class PocketBaseFixture : IAsyncLifetime
title = "Downloads", body = "# Downloads", location = "header", title = "Downloads", body = "# Downloads", location = "header",
order = 5, slug = "downloads", embed = Array.Empty<string>(), @public = true, order = 5, slug = "downloads", embed = Array.Empty<string>(), @public = true,
}); });
// Page records for the posts and events lists. Routes /posts and /events are
// served by PostList and EventList, which read these records for their heading
// and intro (title + body) and render the list themselves, just like FaqList.
// The body carries no "# heading": the title already renders as the h1.
await CreateRecordAsync(http, "pages", new await CreateRecordAsync(http, "pages", new
{ {
title = "FAQ Mensa", body = "# Mensa", location = "", title = "Beiträge", body = "Neuigkeiten aus dem Elternbeirat.",
order = 0, slug = "faq-lunch", embed = Array.Empty<string>(), @public = true, location = "header",
order = 6, slug = "posts", embed = Array.Empty<string>(), @public = true,
});
await CreateRecordAsync(http, "pages", new
{
title = "Termine", body = "Sitzungen und Veranstaltungen auf einen Blick.",
location = "header",
order = 7, slug = "events", embed = Array.Empty<string>(), @public = true,
});
// A plain content page reached only by its slug (not shown in the header),
// so the catch-all "/{slug}" ContentPage route stays covered. It carries a
// valid location because the field is required; footer keeps it low-key.
await CreateRecordAsync(http, "pages", new
{
title = "FAQ Mensa", body = "# Mensa", location = "footer",
order = 9, slug = "faq-lunch", embed = Array.Empty<string>(), @public = true,
}); });
await CreateRecordAsync(http, "pages", new await CreateRecordAsync(http, "pages", new
{ {
@@ -280,10 +318,13 @@ public sealed class PocketBaseFixture : IAsyncLifetime
order = 9, slug = "draft", embed = Array.Empty<string>(), @public = false, order = 9, slug = "draft", embed = Array.Empty<string>(), @public = false,
}); });
// Posts: newest first once sorted by -date. // Posts: newest first once sorted by -date. The first post is stored as
// 2026-03-01 23:30Z on purpose: that is 2 March 00:30 in Berlin (winter,
// UTC+1). An editor who picked 2 March must get 2 March back -- the date is
// the Berlin day, so it must NOT be read as the raw UTC day (1 March).
await CreateRecordAsync(http, "posts", new await CreateRecordAsync(http, "posts", new
{ {
date = "2026-03-01 00:00:00.000Z", title = "Neuer Vorstand", date = "2026-03-01 23:30:00.000Z", title = "Neuer Vorstand",
body = "Text", slug = "new-board", @public = true, body = "Text", slug = "new-board", @public = true,
}); });
await CreateRecordAsync(http, "posts", new await CreateRecordAsync(http, "posts", new
@@ -292,40 +333,84 @@ public sealed class PocketBaseFixture : IAsyncLifetime
body = "Text", slug = "new-hall", @public = true, body = "Text", slug = "new-hall", @public = true,
}); });
// Events: the meeting carries the wall-clock time the timezone test checks. // Events: PocketBase stores UTC. The meeting is 19:30 Berlin; October is
// summer time (UTC+2), so it is stored as 17:30Z and the timezone test
// expects 19:30 back. The Herbstbasar is 09:00-13:00 Berlin in November
// (winter, UTC+1), stored as 08:00Z-12:00Z.
await CreateRecordAsync(http, "events", new await CreateRecordAsync(http, "events", new
{ {
start = "2026-10-08 19:30:00.000Z", title = "Elternbeiratssitzung", start = "2026-10-08 17:30:00.000Z", title = "Elternbeiratssitzung",
location = "Aula", note = "", @public = true, location = "Aula", note = "", @public = true,
}); });
await CreateRecordAsync(http, "events", new await CreateRecordAsync(http, "events", new
{ {
start = "2026-11-22 09:00:00.000Z", end = "2026-11-22 13:00:00.000Z", start = "2026-11-22 08:00:00.000Z", end = "2026-11-22 12:00:00.000Z",
title = "Herbstbasar", location = "Schulhof", note = "", @public = true, 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,
});
// Faqs: two topics. // 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
// visitors; slugs are English, as for every route.
var lunchTopicId = await CreateRecordAsync(http, "faq_topics", new
{
title = "Mensa und Mittagessen", slug = "cafeteria", intro = "",
order = 1, @public = true,
});
var lockerTopicId = await CreateRecordAsync(http, "faq_topics", new
{
title = "Schließfächer", slug = "lockers", intro = "",
order = 2, @public = true,
});
// Faqs: each points at its topic through the "topic" relation (the created
// topic id), and carries its own order within the topic.
await CreateRecordAsync(http, "faqs", new await CreateRecordAsync(http, "faqs", new
{ {
question = "Wann gibt es Mittagessen?", answer = "Um 12 Uhr.", question = "Wann gibt es Mittagessen?", answer = "Um 12 Uhr.",
topic = "mensa", @public = true, topic = lunchTopicId, order = 1, @public = true,
}); });
await CreateRecordAsync(http, "faqs", new await CreateRecordAsync(http, "faqs", new
{ {
question = "Wie viel kostet ein Schließfach?", answer = "20 Euro.", question = "Wie viel kostet ein Schließfach?", answer = "20 Euro.",
topic = "schliessfach", @public = true, topic = lockerTopicId, order = 1, @public = true,
}); });
} }
private static async Task CreateRecordAsync(HttpClient http, string collection, object record) private static async Task<string> CreateRecordAsync(HttpClient http, string collection, object record)
{ {
var response = await http.PostAsJsonAsync($"/api/collections/{collection}/records", record); var response = await http.PostAsJsonAsync($"/api/collections/{collection}/records", record);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
// Return the created record's id so a caller can seed a relation to it (e.g. a
// faq pointing at its topic). Callers that do not need it just ignore the value.
var created = await response.Content.ReadFromJsonAsync<CreatedRecord>();
return created?.Id ?? throw new InvalidOperationException(
$"PocketBase returned no id for the created '{collection}' record.");
}
private sealed record CreatedRecord
{
[System.Text.Json.Serialization.JsonPropertyName("id")]
public string Id { get; init; } = string.Empty;
} }
private sealed record AuthResponse private sealed record AuthResponse
{ {
[System.Text.Json.Serialization.JsonPropertyName("token")] [System.Text.Json.Serialization.JsonPropertyName("token")]
public string Token { get; init; } = ""; public string Token { get; init; } = string.Empty;
} }
} }
@@ -0,0 +1,73 @@
using Elternbeirat.Web.Shared;
namespace Elternbeirat.Web.Tests;
/// <summary>
/// Unit tests for the health cache behind the maintenance gate. The probe is a fake
/// that counts its calls, so no PocketBase is needed.
/// </summary>
public class PocketBaseHealthCacheTests
{
private readonly SteppingClock _clock = new(new DateTimeOffset(2026, 10, 1, 10, 0, 0, TimeSpan.Zero));
private int _probes;
private Func<CancellationToken, Task<bool>> Probe(bool healthy) =>
_ =>
{
_probes++;
return Task.FromResult(healthy);
};
[Fact]
public async Task Result_is_reused_within_the_lifetime()
{
var cache = new PocketBaseHealthCache(_clock);
await cache.IsHealthyAsync(Probe(healthy: true), CancellationToken.None);
_clock.Now += PocketBaseHealthCache.Lifetime - TimeSpan.FromSeconds(1);
var healthy = await cache.IsHealthyAsync(Probe(healthy: false), CancellationToken.None);
healthy.ShouldBeTrue();
_probes.ShouldBe(1);
}
[Fact]
public async Task Stale_result_is_probed_again()
{
// The way back after an outage: once the lifetime is over, the new answer
// replaces the old one.
var cache = new PocketBaseHealthCache(_clock);
await cache.IsHealthyAsync(Probe(healthy: false), CancellationToken.None);
_clock.Now += PocketBaseHealthCache.Lifetime;
var healthy = await cache.IsHealthyAsync(Probe(healthy: true), CancellationToken.None);
healthy.ShouldBeTrue();
_probes.ShouldBe(2);
}
[Fact]
public async Task Unhealthy_result_is_cached_too()
{
// While PocketBase is down, not every request should wait for a failing probe.
var cache = new PocketBaseHealthCache(_clock);
await cache.IsHealthyAsync(Probe(healthy: false), CancellationToken.None);
var healthy = await cache.IsHealthyAsync(Probe(healthy: true), CancellationToken.None);
healthy.ShouldBeFalse();
_probes.ShouldBe(1);
}
/// <summary>
/// A clock the test moves forward by hand, to step past the cache lifetime without
/// waiting.
/// </summary>
/// <param name="start">The moment the clock shows at first.</param>
private sealed class SteppingClock(DateTimeOffset start) : TimeProvider
{
public DateTimeOffset Now { get; set; } = start;
public override DateTimeOffset GetUtcNow() => Now;
}
}
@@ -0,0 +1,57 @@
using Elternbeirat.Contracts;
using Elternbeirat.Web.Features.Posts;
namespace Elternbeirat.Web.Tests;
/// <summary>
/// Unit tests for the older/newer post next to a post. The list comes newest
/// first, as from PocketBase; "previous" is the older post. Pure logic, no
/// PocketBase needed.
/// </summary>
public class PostNeighborsTests
{
// Newest first, like PocketBaseClient.GetPostsAsync.
private static readonly Post[] Posts = [Post("march"), Post("february"), Post("january")];
private static Post Post(string slug) =>
new() { Id = slug, Title = slug, Slug = slug };
[Fact]
public void Middle_post_has_the_older_as_previous_and_the_newer_as_next()
{
var neighbors = PostNeighbors.Of(Posts, "february");
neighbors.Previous?.Slug.ShouldBe("january");
neighbors.Next?.Slug.ShouldBe("march");
}
[Fact]
public void Newest_post_has_no_next()
{
var neighbors = PostNeighbors.Of(Posts, "march");
neighbors.Previous?.Slug.ShouldBe("february");
neighbors.Next.ShouldBeNull();
}
[Fact]
public void Oldest_post_has_no_previous()
{
var neighbors = PostNeighbors.Of(Posts, "january");
neighbors.Previous.ShouldBeNull();
neighbors.Next?.Slug.ShouldBe("february");
}
[Fact]
public void Only_post_has_no_neighbors() =>
PostNeighbors.Of([Post("only")], "only").ShouldBe(new PostNeighbors(null, null));
[Fact]
public void Unknown_slug_has_no_neighbors() =>
PostNeighbors.Of(Posts, "december").ShouldBe(new PostNeighbors(null, null));
[Fact]
public void Slug_is_compared_case_insensitively() =>
PostNeighbors.Of(Posts, "February").Next?.Slug.ShouldBe("march");
}
+513 -17
View File
@@ -1,6 +1,8 @@
using System.Net; using System.Net;
using Microsoft.AspNetCore.Hosting; using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
namespace Elternbeirat.Web.Tests; namespace Elternbeirat.Web.Tests;
@@ -16,7 +18,7 @@ namespace Elternbeirat.Web.Tests;
/// </para> /// </para>
/// </summary> /// </summary>
[Collection(PocketBaseTestGroup.Name)] [Collection(PocketBaseTestGroup.Name)]
public sealed class RouteSmokeTests : IDisposable public sealed partial class RouteSmokeTests : IDisposable
{ {
// WithWebHostBuilder returns a new factory that wraps the base one; both are // WithWebHostBuilder returns a new factory that wraps the base one; both are
// disposable, so both are held and disposed to avoid leaking either. // disposable, so both are held and disposed to avoid leaking either.
@@ -29,10 +31,25 @@ public sealed class RouteSmokeTests : IDisposable
// the value from appsettings. UseSetting (not ConfigureAppConfiguration) // the value from appsettings. UseSetting (not ConfigureAppConfiguration)
// because it wins over appsettings.Development.json, which otherwise pins the // because it wins over appsettings.Development.json, which otherwise pins the
// client to localhost:8090 while the test container uses a random port. // client to localhost:8090 while the test container uses a random port.
_factory = _baseFactory.WithWebHostBuilder(builder => _factory = WithClock(
builder.UseSetting("PocketBase:BaseUrl", pocketBase.BaseUrl.ToString())); _baseFactory.WithWebHostBuilder(builder =>
builder.UseSetting("PocketBase:BaseUrl", pocketBase.BaseUrl.ToString())),
BeforeFixtureEvents);
} }
/// <summary>
/// "Now" for the default factory: a week before the fixture's first event, so
/// "upcoming" stays true no matter when the tests run.
/// </summary>
private static readonly DateTimeOffset BeforeFixtureEvents = new(2026, 10, 1, 10, 0, 0, TimeSpan.Zero);
/// <summary>
/// Wraps <paramref name="factory"/> so the app's clock shows <paramref name="now"/>.
/// </summary>
private static WebApplicationFactory<Program> WithClock(WebApplicationFactory<Program> factory, DateTimeOffset now) =>
factory.WithWebHostBuilder(builder =>
builder.ConfigureTestServices(services => services.AddSingleton<TimeProvider>(new FixedTimeProvider(now))));
public void Dispose() public void Dispose()
{ {
_factory.Dispose(); _factory.Dispose();
@@ -43,7 +60,16 @@ public sealed class RouteSmokeTests : IDisposable
/// Every route that a visitor can reach through the navigation, the FAQ hub /// Every route that a visitor can reach through the navigation, the FAQ hub
/// or the news section. The post slugs match the fixture's seed. /// or the news section. The post slugs match the fixture's seed.
/// </summary> /// </summary>
public static TheoryData<string> KnownRoutes => public static TheoryData<string> KnownRoutes => [.. Routes];
/// <summary>
/// The <see cref="KnownRoutes"/> that render an HTML page, i.e. all but the
/// calendar feed.
/// </summary>
public static TheoryData<string> PageRoutes =>
[.. Routes.Where(route => !route.EndsWith(".ics", StringComparison.Ordinal))];
private static readonly string[] Routes =
[ [
"/", "/",
"/board", "/board",
@@ -75,6 +101,7 @@ public sealed class RouteSmokeTests : IDisposable
[Theory] [Theory]
[InlineData("/gibt-es-nicht")] [InlineData("/gibt-es-nicht")]
[InlineData("/posts/gibt-es-nicht")] [InlineData("/posts/gibt-es-nicht")]
[InlineData("/events/gibt-es-nicht.ics")]
public async Task Unknown_route_returns_404(string route) public async Task Unknown_route_returns_404(string route)
{ {
var client = _factory.CreateClient(); var client = _factory.CreateClient();
@@ -85,18 +112,19 @@ public sealed class RouteSmokeTests : IDisposable
} }
[Fact] [Fact]
public async Task Faqs_route_renders_the_grouped_list() public async Task Faqs_route_renders_the_topic_accordion()
{ {
// /faqs is a literal route (FaqList) that must win over the /{Slug} // /faqs is a literal route (FaqList) that must win over the /{Slug}
// catch-all content page. Assert it shows the FAQ list, grouped by topic // catch-all content page. It reads the "faqs" page for heading and intro
// from the seeded questions, not a content page body. // and renders each topic as a collapsible group with its questions nested
// inside, so both the topic headings and the questions are in the markup.
var client = _factory.CreateClient(); var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/faqs", UriKind.Relative)); var html = await client.GetStringAsync(new Uri("/faqs", UriKind.Relative));
html.ShouldContain("Häufige Fragen"); html.ShouldContain("FAQs"); // heading from the page title
html.ShouldContain("Wann gibt es Mittagessen?"); html.ShouldContain("Mensa und Mittagessen"); // German topic heading (a group)
html.ShouldContain("Mensa und Mittagessen"); // German topic heading html.ShouldContain("Wann gibt es Mittagessen?"); // a question nested in the group
} }
[Fact] [Fact]
@@ -114,6 +142,30 @@ public sealed class RouteSmokeTests : IDisposable
html.ShouldNotContain("href=\"/draft\""); // public=false, filtered out html.ShouldNotContain("href=\"/draft\""); // public=false, filtered out
} }
[Fact]
public async Task Footer_links_every_public_header_and_footer_page_in_order()
{
// The footer repeats the header pages as "Schnellzugriff" and then lists the
// footer pages, each column in its order field. Only the <footer> is checked,
// so the header menu cannot make the test pass on its own. The expected list
// mirrors the fixture's pages; the draft (public=false) must be missing.
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
var footer = FooterElement().Match(html);
footer.Success.ShouldBeTrue();
var hrefs = Href().Matches(footer.Value).Select(match => match.Groups[1].Value);
hrefs.ShouldBe(
[
"/", "/board", "/patrons", "/faqs", "/downloads", "/posts", "/events",
"/contact", "/imprint", "/privacy", "/faq-lunch",
]);
footer.Value.ShouldContain("Schnellzugriff");
footer.Value.ShouldContain("Weiteres");
}
[Fact] [Fact]
public async Task Home_renders_the_embeds_its_page_opts_into() public async Task Home_renders_the_embeds_its_page_opts_into()
{ {
@@ -129,16 +181,460 @@ public sealed class RouteSmokeTests : IDisposable
} }
[Fact] [Fact]
public async Task Content_page_renders_the_embed_its_page_opts_into() public async Task Content_page_renders_its_body()
{ {
// /patrons is a catch-all content page whose embed field is ["faqs"], so it // /patrons is a catch-all content page (the /{Slug} route). Assert it renders
// shows its body plus the FAQ teaser -- proving the ContentPage embed path. // its Markdown body, proving the ContentPage path works for an arbitrary slug.
var client = _factory.CreateClient(); var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/patrons", UriKind.Relative)); var html = await client.GetStringAsync(new Uri("/patrons", UriKind.Relative));
html.ShouldContain("Förderverein"); // the page body html.ShouldContain("Der Förderverein unterstützt die Schule."); // the page body, rendered from Markdown
html.ShouldContain("Häufige Fragen"); // faqs embed heading
html.ShouldContain("Wann gibt es Mittagessen?"); // a seeded question
} }
[Fact]
public async Task Content_page_body_carries_the_markdown_styles_and_blocks()
{
// The editor-content styles in app.css all hang off .markdown-body, and the
// design blocks off the class a ":::" container renders. Assert both reach
// the markup of a real page, so a renamed wrapper or a pipeline without
// custom containers fails here instead of silently unstyling the site.
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/patrons", UriKind.Relative));
html.ShouldContain("class=\"markdown-body\"");
html.ShouldContain("<div class=\"kennzahlen\">");
}
[Fact]
public async Task Board_renders_team_block_as_person_cards()
{
// The board body in the fixture holds a "::: team gross" block with one card
// following the convention and a "::: team" block with one that does not.
// Assert the cards survive the whole path: PocketBase, page, Markdown, HTML.
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/board", UriKind.Relative));
html.ShouldContain("<div class=\"team gross\">");
html.ShouldContain("<span class=\"team-avatar team-avatar-");
html.ShouldContain("aria-hidden=\"true\">JR</span>");
html.ShouldContain("<strong class=\"team-name\">Jenny Reger-Stilgenbauer</strong>");
html.ShouldContain("<span class=\"team-role\">Vorsitzende</span>");
html.ShouldContain("<span class=\"team-duty\">Schulkonferenz</span>");
html.ShouldContain("<span class=\"team-duty\">Mensarat</span>");
html.ShouldContain("<span class=\"team-note\">Mitglied im Gesamtelternbeirat.</span>");
html.ShouldContain("<li class=\"team-card team-card-plain\">Özlem Ünal, Beisitz</li>");
html.ShouldContain("<h2>Beisitz</h2>"); // the group headings stay normal headings
}
[Fact]
public async Task Posts_and_events_render_their_page_heading_and_body()
{
// /posts and /events are served by PostList and EventList, which -- like
// FaqList -- read their own "posts"/"events" page record for heading and
// intro instead of hard-coding them. Assert both the title (as the h1) and
// the body text from the fixture records reach the markup.
var client = _factory.CreateClient();
var posts = await client.GetStringAsync(new Uri("/posts", UriKind.Relative));
posts.ShouldContain("Beiträge"); // heading from the page title
posts.ShouldContain("Neuigkeiten aus dem Elternbeirat."); // the page body
var events = await client.GetStringAsync(new Uri("/events", UriKind.Relative));
events.ShouldContain("Termine"); // heading from the page title
events.ShouldContain("Sitzungen und Veranstaltungen auf einen Blick."); // the page body
}
[Theory]
[InlineData("/app.css")]
[InlineData("/")]
public async Task No_resource_is_loaded_from_a_foreign_host(string route)
{
// Privacy rule (docs/recht.md): nothing may make the visitor's browser contact
// a third-party host -- no Google Fonts, no CDN. Check the stylesheet and the
// rendered home page for anything that LOADS from an absolute URL (src,
// srcset, <link href>, CSS url() and @import). Plain <a href> links are left
// alone on purpose: an editor may link to another site, and a link loads
// nothing until the visitor clicks it.
var client = _factory.CreateClient();
var body = await client.GetStringAsync(new Uri(route, UriKind.Relative));
ForeignResource().Matches(body).Select(match => match.Value).ShouldBeEmpty();
}
[Fact]
public async Task Site_font_is_self_hosted()
{
// The @font-face must point at our own wwwroot with a relative URL, and the
// file must actually be served -- otherwise every visitor silently falls back
// to the system font.
var client = _factory.CreateClient();
var css = await client.GetStringAsync(new Uri("/app.css", UriKind.Relative));
css.ShouldContain("url(\"fonts/nunito-latin-wght.woff2\")");
var font = await client.GetAsync(new Uri("/fonts/nunito-latin-wght.woff2", UriKind.Relative));
font.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Fact]
public async Task Health_returns_200_when_PocketBase_is_reachable()
{
// The monitor's happy path: the app answers and PocketBase (the seeded
// fixture) is up, so /health is 200. This mostly guards the wiring -- that the
// endpoint exists and reaches the client -- since the fixture keeps PocketBase
// alive; the unreachable case is covered separately below.
var client = _factory.CreateClient();
var response = await client.GetAsync(new Uri("/health", UriKind.Relative));
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Fact]
public async Task Health_returns_503_when_PocketBase_is_unreachable()
{
// The case the monitor exists for: the app is up but PocketBase is not. Point a
// throwaway app at a dead address (nothing listens on port 1, so the probe is
// refused at once, no timeout wait) and assert /health reports 503 rather than
// claiming healthy.
await using var factory = _baseFactory.WithWebHostBuilder(builder =>
builder.UseSetting("PocketBase:BaseUrl", "http://localhost:1"));
var client = factory.CreateClient();
var response = await client.GetAsync(new Uri("/health", UriKind.Relative));
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
(await response.Content.ReadAsStringAsync()).ShouldBe("PocketBase unreachable");
}
[Theory]
[InlineData("/")]
[InlineData("/board")]
[InlineData("/posts/new-board")]
public async Task Page_shows_maintenance_page_when_PocketBase_is_unreachable(string route)
{
// The maintenance gate: with PocketBase down, a page is not rendered half-empty
// but answered with the static maintenance.html, a 503 and a hint when to retry.
await using var factory = UnreachablePocketBase();
var client = factory.CreateClient();
var response = await client.GetAsync(new Uri(route, UriKind.Relative));
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
response.Headers.RetryAfter.ShouldNotBeNull();
(await response.Content.ReadAsStringAsync()).ShouldContain("Die Seite ist gerade nicht erreichbar");
}
[Fact]
public async Task Calendar_feed_stays_an_empty_calendar_when_PocketBase_is_unreachable()
{
// Not a page, so the gate lets it through: subscribed calendar apps keep
// getting a valid (empty) calendar instead of an HTML page.
await using var factory = UnreachablePocketBase();
var client = factory.CreateClient();
var response = await client.GetAsync(new Uri("/events.ics", UriKind.Relative));
response.StatusCode.ShouldBe(HttpStatusCode.OK);
(await response.Content.ReadAsStringAsync()).ShouldContain("BEGIN:VCALENDAR");
}
[Fact]
public async Task Maintenance_page_and_its_font_stay_reachable_when_PocketBase_is_unreachable()
{
// Static files are not gated: the maintenance page loads its self-hosted font
// from /fonts while PocketBase is down.
await using var factory = UnreachablePocketBase();
var client = factory.CreateClient();
var page = await client.GetAsync(new Uri("/maintenance.html", UriKind.Relative));
var font = await client.GetAsync(new Uri("/fonts/nunito-latin-wght.woff2", UriKind.Relative));
page.StatusCode.ShouldBe(HttpStatusCode.OK);
font.StatusCode.ShouldBe(HttpStatusCode.OK);
}
/// <summary>
/// A throwaway app pointed at a dead address: nothing listens on port 1, so every
/// PocketBase call is refused at once, without a timeout wait.
/// </summary>
private WebApplicationFactory<Program> UnreachablePocketBase() =>
_baseFactory.WithWebHostBuilder(builder =>
builder.UseSetting("PocketBase:BaseUrl", "http://localhost:1"));
[Fact]
public async Task Home_hero_takes_title_and_buttons_from_the_body()
{
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
html.ShouldContain(">Willkommen</h1>");
html.ShouldNotContain(">Elternbeirat der IGMH</h1>"); // the fallback title
html.ShouldContain("""<a class="btn btn-primary" href="/events">""");
html.ShouldContain("""<a class="btn btn-secondary" href="/contact">""");
}
[Fact]
public async Task Home_tiles_are_exactly_the_public_header_pages()
{
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
var tiles = HomeTiles().Match(html);
tiles.Success.ShouldBeTrue();
Href().Matches(tiles.Value).Select(match => match.Groups[1].Value)
.ShouldBe(["/board", "/patrons", "/faqs", "/downloads", "/posts", "/events"]);
}
[Fact]
public async Task Home_shows_the_next_event_when_one_is_upcoming()
{
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
html.ShouldContain("Nächster Termin");
html.ShouldContain("Elternbeiratssitzung");
}
[Fact]
public async Task Home_hides_the_next_event_when_none_is_upcoming()
{
// After the fixture's last event nothing is upcoming; the card must vanish
// instead of showing an empty frame.
await using var factory = WithClock(_factory, new DateTimeOffset(2027, 1, 1, 10, 0, 0, TimeSpan.Zero));
var client = factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
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("""<a class="btn btn-secondary" href="/events.ics">""");
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("""<h3 class="past-events-year">2026</h3>""");
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");
}
[Fact]
public async Task Post_page_has_the_title_as_its_only_h1()
{
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/posts/new-board", UriKind.Relative));
Regex.Count(html, "<h1[ >]").ShouldBe(1);
html.ShouldContain(">Neuer Vorstand</h1>");
}
[Fact]
public async Task Newest_post_links_only_to_the_older_one()
{
// The fixture has two posts: new-board (newer) and new-hall (older).
// "Vorheriger Beitrag" is the older post, so the newest post has no "next".
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/posts/new-board", UriKind.Relative));
html.ShouldContain("href=\"/posts/new-hall\" rel=\"prev\"");
html.ShouldNotContain("rel=\"next\"");
}
[Fact]
public async Task Oldest_post_links_only_to_the_newer_one()
{
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/posts/new-hall", UriKind.Relative));
html.ShouldContain("href=\"/posts/new-board\" rel=\"next\"");
html.ShouldNotContain("rel=\"prev\"");
}
[Theory]
[MemberData(nameof(PageRoutes))]
public async Task Every_page_starts_with_a_skip_link_to_its_own_content(string route)
{
// The skip link must carry the page's own path: with <base href="/"> a bare
// "#main" would lead back to the home page. It must also bypass Blazor's
// enhanced navigation, which scrolls but does not move the focus.
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri(route, UriKind.Relative));
html.ShouldContain($"""<a class="skip-link" href="{route}#main" data-enhance-nav="false" """);
html.ShouldContain("Zum Inhalt springen</a>");
html.ShouldContain("""<main id="main" """);
}
[Fact]
public async Task Menu_toggle_is_a_named_checkbox_tied_to_the_menu()
{
// The CSS-only hamburger: the checkbox is what keyboard and screen-reader
// users operate, so it needs a name and must point at the menu it opens.
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
html.ShouldContain("aria-controls=\"main-nav\"");
html.ShouldContain("Hauptmenü anzeigen</span>");
html.ShouldContain("<nav id=\"main-nav\"");
}
/// <summary>
/// Matches a resource reference that points at an absolute URL, with or without
/// scheme (<c>https://</c>, <c>http://</c> or protocol-relative <c>//</c>): the
/// <c>src</c>/<c>srcset</c> attributes, a <c>&lt;link&gt;</c>'s <c>href</c>, CSS
/// <c>url()</c> and <c>@import</c>.
/// </summary>
[GeneratedRegex("""(?:\b(?:src|srcset)\s*=\s*|<link\b[^>]*\bhref\s*=\s*|url\(\s*|@import\s+)["']?\s*(?:https?:)?//""", RegexOptions.IgnoreCase)]
private static partial Regex ForeignResource();
/// <summary>
/// Matches the page's <c>&lt;footer&gt;</c> element, from its opening tag to the
/// closing one. The layout renders exactly one footer and nests no other.
/// </summary>
[GeneratedRegex("""<footer\b.*?</footer>""", RegexOptions.Singleline)]
private static partial Regex FooterElement();
/// <summary>
/// Matches an <c>href</c> attribute and captures its value.
/// </summary>
[GeneratedRegex(@"\bhref=""([^""]*)""")]
private static partial Regex Href();
/// <summary>
/// Matches the home page's tile section, from its opening tag to the closing
/// one. It nests no other <c>&lt;section&gt;</c>.
/// </summary>
[GeneratedRegex("""<section class="home-tiles".*?</section>""", RegexOptions.Singleline)]
private static partial Regex HomeTiles();
/// <summary>
/// Matches the "Nächster Termin" card in the home hero. It holds no other link,
/// so the first closing <c>&lt;/a&gt;</c> ends it.
/// </summary>
[GeneratedRegex("""<a class="next-event".*?</a>""", RegexOptions.Singleline)]
private static partial Regex NextEventCard();
/// <summary>
/// Matches the first text hidden for sighted visitors but read by screen readers.
/// </summary>
[GeneratedRegex("""<span class="visually-hidden".*?</span>""", RegexOptions.Singleline)]
private static partial Regex VisuallyHidden();
/// <summary>
/// Matches the first list of event cards, from its opening tag to the closing one.
/// The cards nest no other <c>&lt;ul&gt;</c>.
/// </summary>
[GeneratedRegex("""<ul class="event-list".*?</ul>""", RegexOptions.Singleline)]
private static partial Regex EventCards();
/// <summary>
/// Matches the folded-up past events on the events page.
/// </summary>
[GeneratedRegex("""<details class="past-events[ "].*?</details>""", RegexOptions.Singleline)]
private static partial Regex PastEvents();
} }
+176
View File
@@ -0,0 +1,176 @@
using Elternbeirat.Web.Shared;
using Markdig.Syntax;
namespace Elternbeirat.Web.Tests;
/// <summary>
/// Unit tests for the person cards of the "::: team" block: initials, avatar colour,
/// reading the list-item convention and the rendered card. Pure logic, no PocketBase
/// needed.
/// </summary>
public class TeamMemberTests
{
[Theory]
[InlineData("Jenny Reger-Stilgenbauer", "JR")] // a double name is one word
[InlineData("Özlem Ünal", "ÖÜ")] // umlauts kept, not folded to O/U
[InlineData("ömer şahin", "ÖŞ")] // lower case is raised
[InlineData("Isabell", "I")] // a single word gives one letter
[InlineData("Anna Maria Müller", "AM")] // first and last word only
[InlineData(" Maik Palm ", "MP")] // extra whitespace ignored
[InlineData("", "")]
[InlineData("123", "")] // no letter, no initials
public void Initials_are_first_and_last_word(string name, string expected) =>
TeamMember.InitialsOf(name).ShouldBe(expected);
[Fact]
public void Color_is_stable_and_in_range()
{
// Pinned values: the colour must not change between restarts or releases, so a
// change to the hash shows up here instead of recolouring the board.
TeamMember.ColorOf("Maik Palm").ShouldBe(TeamMember.ColorOf(" Maik Palm "));
TeamMember.ColorOf("Maik Palm").ShouldBe(2);
TeamMember.ColorOf("Gentiana Erol").ShouldBe(2);
TeamMember.ColorOf("Raul Chira").ShouldBe(1);
new[] { "Maik Palm", "Gentiana Erol", "Raul Chira", "Marion Leininger", "Isabell Vesper", "" }
.ShouldAllBe(name => TeamMember.ColorOf(name) >= 1 && TeamMember.ColorOf(name) <= TeamMember.ColorCount);
}
[Fact]
public void Board_uses_both_colours_about_evenly()
{
// Two colours only make sense if both show up; the real board (ten names)
// must not land mostly on one.
string[] board =
[
"Maik Palm", "Gentiana Erol", "Raul Chira", "Marion Leininger", "Jenny Reger-Stilgenbauer",
"Mariella Ianello", "Isabell Vesper", "Susanne Naumburg", "Rebecca Goldmann", "Michael Weber",
];
board.Count(name => TeamMember.ColorOf(name) == 1).ShouldBeInRange(4, 6);
}
[Fact]
public void Item_with_role_duties_and_note_is_read_in_full()
{
var (member, note) = Read("- **Maik Palm** (Vorsitzender) \n *Schulkonferenz, Mensarat , Homepage* \n Mitglied im Schulbeirat.");
member.Name.ShouldBe("Maik Palm");
member.Role.ShouldBe("Vorsitzender");
member.Duties.ShouldBe(["Schulkonferenz", "Mensarat", "Homepage"]);
Markdown.PlainText(note).ShouldBe("Mitglied im Schulbeirat.");
}
[Fact]
public void Item_without_role_still_reads_the_duties()
{
var (member, note) = Read("- **Jenny Reger-Stilgenbauer** \n *Schulkonferenz, Schaukasten*");
member.Role.ShouldBeNull();
member.Duties.ShouldBe(["Schulkonferenz", "Schaukasten"]);
note.ShouldBeEmpty();
}
[Fact]
public void Item_with_only_a_name_has_no_role_and_no_duties()
{
var (member, note) = Read("- **Isabell Vesper**");
member.Name.ShouldBe("Isabell Vesper");
member.Role.ShouldBeNull();
member.Duties.ShouldBeEmpty();
note.ShouldBeEmpty();
}
[Fact]
public void Soft_line_breaks_count_like_hard_ones()
{
// Editors forget the two trailing spaces; the card must not depend on them.
var (member, _) = Read("- **Raul Chira** (2. Stellvertreter)\n *Mensarat*");
member.Role.ShouldBe("2. Stellvertreter");
member.Duties.ShouldBe(["Mensarat"]);
}
[Fact]
public void Second_line_that_is_not_only_italic_becomes_the_note()
{
var (member, note) = Read("- **Rebecca Goldmann** \n Neu im Team, *herzlich willkommen*.");
member.Duties.ShouldBeEmpty();
Markdown.PlainText(note).ShouldBe("Neu im Team, herzlich willkommen.");
}
[Theory]
[InlineData("- Michael Weber, Beisitz")] // no bold at all
[InlineData("- Beisitz: **Michael Weber**")] // bold, but not at the start
[InlineData("- *Michael Weber*")] // italic, not bold
public void Item_without_convention_is_not_read(string markdown) =>
TeamMember.Read(FirstParagraph(markdown)).ShouldBeNull();
[Fact]
public void Team_block_renders_person_cards()
{
var html = Markdown.ToHtml(
"::: team\n- **Maik Palm** (Vorsitzender) \n *Schulkonferenz, Mensarat* \n Siehe [Kontakt](/contact).\n:::");
html.ShouldContain("<div class=\"team\">");
html.ShouldContain($"<li class=\"{TeamMember.CardClass}\">");
html.ShouldContain("<span class=\"team-avatar team-avatar-2\" aria-hidden=\"true\">MP</span>");
html.ShouldContain("<strong class=\"team-name\">Maik Palm</strong>");
html.ShouldContain("<span class=\"team-role\">Vorsitzender</span>");
html.ShouldContain("<span class=\"team-duty\">Schulkonferenz</span>");
html.ShouldContain("<span class=\"team-duty\">Mensarat</span>");
html.ShouldContain("<span class=\"team-note\">Siehe <a href=\"/contact\">Kontakt</a>.</span>"); // links survive
html.ShouldNotContain("(Vorsitzender)");
}
[Fact]
public void Item_without_convention_renders_as_plain_card_with_its_text()
{
var html = Markdown.ToHtml("::: team\n- Michael Weber, Beisitz\n:::");
html.ShouldContain($"<li class=\"{TeamMember.CardClass} {TeamMember.PlainCardClass}\">Michael Weber, Beisitz</li>");
html.ShouldNotContain("team-avatar");
}
[Fact]
public void Editor_text_in_a_card_is_escaped()
{
var html = Markdown.ToHtml("::: team\n- **<b>Name</b>** (Rolle \"x\")\n:::");
html.ShouldNotContain("<b>");
html.ShouldContain("&lt;b&gt;Name&lt;/b&gt;");
}
[Theory]
[InlineData("::: team gross")]
[InlineData("::: team groß")]
[InlineData("::: team GROSS")]
public void Large_modifier_adds_its_class(string opening) =>
Markdown.ToHtml($"{opening}\n- **Maik Palm**\n:::").ShouldContain("<div class=\"team gross\">");
[Fact]
public void Mistyped_modifier_keeps_the_grid()
{
// A typo in the modifier costs the highlight, not the cards.
var html = Markdown.ToHtml("::: team grss\n- **Maik Palm**\n:::");
html.ShouldContain("<div class=\"team\">");
html.ShouldContain("team-card");
}
[Fact]
public void Lists_outside_a_team_block_are_untouched()
{
var html = Markdown.ToHtml("- **Maik Palm** (Vorsitzender)");
html.ShouldBe("<ul>\n<li><strong>Maik Palm</strong> (Vorsitzender)</li>\n</ul>\n");
}
private static (TeamMember Member, Markdig.Syntax.Inlines.Inline[] Note) Read(string markdown) =>
TeamMember.Read(FirstParagraph(markdown)) ?? throw new ShouldAssertException("The item was not read as a person.");
private static ParagraphBlock FirstParagraph(string markdown) =>
Markdown.Parse(markdown).Descendants<ParagraphBlock>().First();
}
+37
View File
@@ -0,0 +1,37 @@
using Elternbeirat.Web.Features.Home;
using Elternbeirat.Web.Shared;
namespace Elternbeirat.Web.Tests;
/// <summary>
/// Unit tests for the slug-to-icon mapping of the home page tiles. Pure logic, no
/// PocketBase needed.
/// </summary>
public class TileIconsTests
{
[Theory]
[InlineData("board", "users")]
[InlineData("faqs", "help-circle")]
[InlineData("events", "calendar")]
public void Known_slug_gets_its_icon(string slug, string icon) =>
TileIcons.For(slug).ShouldBe(icon);
[Theory]
[InlineData("schulweg")] // a page an editor just created
[InlineData("Board")] // slugs are lowercase; anything else is unknown
[InlineData("")]
public void Unknown_slug_gets_the_default_icon(string slug) =>
TileIcons.For(slug).ShouldBe(TileIcons.Default);
[Theory]
[InlineData("board")]
[InlineData("patrons")]
[InlineData("faqs")]
[InlineData("downloads")]
[InlineData("posts")]
[InlineData("events")]
[InlineData("contact")]
[InlineData("unbekannt")] // the default
public void Every_icon_exists_in_the_icon_set(string slug) =>
IconSet.Find(TileIcons.For(slug)).ShouldNotBeNull(); // a typo would render no icon at all
}
@@ -0,0 +1,45 @@
using Elternbeirat.Contracts;
using Elternbeirat.Web.Features.Events;
using Elternbeirat.Web.Shared;
namespace Elternbeirat.Web.Tests;
/// <summary>
/// Unit tests for the "what is coming up?" rule and the Berlin day behind it. Pure
/// logic, no PocketBase needed.
/// </summary>
public class UpcomingEventsTests
{
private static Event Event(string title, DateTime start) =>
new() { Id = "test", Title = title, Start = start };
[Fact]
public void Keeps_today_and_later_ordered_by_start()
{
var events = new[]
{
Event("Basar", new DateTime(2026, 11, 22, 9, 0, 0)),
Event("Gestern", new DateTime(2026, 9, 30, 19, 30, 0)),
Event("Heute früh", new DateTime(2026, 10, 1, 7, 0, 0)), // already begun, still today
Event("Sitzung", new DateTime(2026, 10, 8, 19, 30, 0)),
};
var upcoming = UpcomingEvents.From(events, new DateOnly(2026, 10, 1));
upcoming.Select(@event => @event.Title).ShouldBe(["Heute früh", "Sitzung", "Basar"]);
}
[Fact]
public void Nothing_upcoming_gives_an_empty_list() =>
UpcomingEvents.From([Event("Vorbei", new DateTime(2026, 1, 1))], new DateOnly(2026, 10, 1))
.ShouldBeEmpty();
[Fact]
public void Berlin_day_starts_before_the_UTC_day()
{
// 22:30 UTC on 30 September is 00:30 on 1 October in Berlin (summer time).
var time = new FixedTimeProvider(new DateTimeOffset(2026, 9, 30, 22, 30, 0, TimeSpan.Zero));
BerlinClock.Today(time).ShouldBe(new DateOnly(2026, 10, 1));
}
}
@@ -6,24 +6,48 @@
immediately), it pins to the top and stays there while the page scrolls. The immediately), it pins to the top and stays there while the page scrolls. The
footer is kept at the bottom on short pages by a min-height on <main>, not by a footer is kept at the bottom on short pages by a min-height on <main>, not by a
flex wrapper -- see MainLayout.razor.css. *@ flex wrapper -- see MainLayout.razor.css. *@
@* Skip link: the first stop for the Tab key, straight past the menu to the content.
The href carries the current path because of <base href="/"> in App.razor: a
bare "#main" would resolve to "/#main" and lead every subpage back home.
data-enhance-nav="false" leaves the jump to the browser: blazor.web.js would
otherwise take the click, only scroll to <main> and keep the focus on the link,
so the next Tab would land in the header menu again. *@
<a class="skip-link" href="@SkipLinkHref" data-enhance-nav="false">Zum Inhalt springen</a>
<header class="site-header"> <header class="site-header">
<div class="header-inner"> <div class="header-inner">
<a class="brand" href="/">Elternbeirat IGMH</a> @* Two-line lettering next to the signet: the body's name large, the
school small below it. One link, so the whole mark leads home. *@
<a class="brand" href="/">
<Signet />
<span class="brand-text">
<span class="brand-name">Elternbeirat</span>
<span class="brand-school">IGMH Mannheim</span>
</span>
</a>
@* CSS-only mobile menu: the hidden checkbox holds the open/closed @* CSS-only mobile menu: the checkbox holds the open/closed state, the
state, the label is the hamburger button, and :checked reveals the label is the hamburger button, and :checked reveals the nav below (see
nav below (see MainLayout.razor.css). No JavaScript, no @@rendermode MainLayout.razor.css). No JavaScript, no @@rendermode -- works under
-- works under static SSR. *@ static SSR. The checkbox is only visually hidden on a phone, not
<input type="checkbox" id="nav-toggle" class="nav-toggle" /> removed, so it is what keyboard and screen-reader users operate: Tab
<label for="nav-toggle" class="nav-toggle-label" aria-label="Menü öffnen und schließen"> reaches it, Space toggles it, and its checked state is the menu's
open/closed state, announced as such ("Hauptmenü anzeigen,
Kontrollkästchen, aktiviert"). Its name is the label's hidden text;
aria-controls ties it to the menu it opens. aria-expanded would need
JavaScript to keep it in sync, the native checked state does not. *@
<input type="checkbox" id="nav-toggle" class="nav-toggle visually-hidden" aria-controls="main-nav" />
<label for="nav-toggle" class="nav-toggle-label">
<span class="nav-toggle-bar"></span> <span class="nav-toggle-bar"></span>
<span class="visually-hidden">Hauptmenü anzeigen</span>
</label> </label>
<nav class="main-nav"> <nav id="main-nav" class="main-nav" aria-label="Hauptmenü">
@* A home link that only shows in the collapsed mobile menu. On wide @* The home link comes from PocketBase like every other entry (the
screens the brand is the home link, so this would be a redundant "home" page carries location "header"), so it is rendered by the
entry -- CSS hides it there (see .nav-home-mobile). *@ loop below -- no separate hard-coded home link, which would show
<NavLink class="nav-home-mobile" href="/" Match="NavLinkMatch.All">Start</NavLink> up twice next to the PocketBase one. *@
@foreach (var link in _header) @foreach (var link in _header)
{ {
<NavLink href="@link.Href" Match="@(link.Href == "/" ? NavLinkMatch.All : NavLinkMatch.Prefix)">@link.Title</NavLink> <NavLink href="@link.Href" Match="@(link.Href == "/" ? NavLinkMatch.All : NavLinkMatch.Prefix)">@link.Title</NavLink>
@@ -32,18 +56,55 @@
</div> </div>
</header> </header>
<main class="content"> @* The skip link's target. tabindex="-1" makes it focusable by the link alone
(not by Tab), so after the jump the next Tab continues inside the content. *@
<main id="main" class="content" tabindex="-1">
@Body @Body
</main> </main>
<footer class="site-footer"> <footer class="site-footer">
@* A soft wave as the transition from the page into the dark footer. Pure
decoration, so hidden from screen readers; it is filled with the footer
colour and stretched to the full width (preserveAspectRatio="none"). *@
<svg class="footer-wave" viewBox="0 0 1440 48" preserveAspectRatio="none" aria-hidden="true" focusable="false">
<path d="M0 24C240 48 480 48 720 28S1200 0 1440 20V48H0z" />
</svg>
<div class="footer-inner"> <div class="footer-inner">
<span>Elternbeirat der IGMH</span> <div class="footer-brand">
<nav class="footer-nav"> <Signet />
@foreach (var link in _footer) <span>Elternbeirat der IGMH</span>
{ </div>
<a href="@link.Href">@link.Title</a>
} @* Both link columns come from PocketBase like the header. An empty list
</nav> (no such pages, or PocketBase down) renders nothing -- no lone
heading above an empty column. *@
@if (_header.Count > 0)
{
<nav class="footer-nav" aria-label="Schnellzugriff">
<h2 class="footer-heading">Schnellzugriff</h2>
<ul>
@foreach (var link in _header)
{
<li><a href="@link.Href">@link.Title</a></li>
}
</ul>
</nav>
}
@if (_footer.Count > 0)
{
<nav class="footer-nav" aria-label="Weiteres">
<h2 class="footer-heading">Weiteres</h2>
<ul>
@foreach (var link in _footer)
{
<li><a href="@link.Href">@link.Title</a></li>
}
</ul>
</nav>
}
</div> </div>
<p class="footer-copyright">© @_year Elternbeirat der IGMH</p>
</footer> </footer>
@@ -6,31 +6,45 @@ namespace Elternbeirat.Web.Components.Layout;
public partial class MainLayout public partial class MainLayout
{ {
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load navigation pages from PocketBase.")] [LoggerMessage(
private static partial void LogLoadFailed(ILogger logger, Exception exception); Level = LogLevel.Warning,
Message = "Could not load navigation pages from PocketBase; the menu is empty.")]
private static partial void LogSourceUnavailable(ILogger logger, Exception exception);
[Inject] [Inject]
private PocketBaseClient PocketBase { get; set; } = default!; private PocketBaseClient PocketBase { get; set; } = null!;
[Inject] [Inject]
private ILogger<MainLayout> Logger { get; set; } = default!; private ILogger<MainLayout> Logger { get; set; } = null!;
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private IReadOnlyList<NavLinkItem> _header = []; private IReadOnlyList<NavLinkItem> _header = [];
private IReadOnlyList<NavLinkItem> _footer = []; private IReadOnlyList<NavLinkItem> _footer = [];
// The skip link's target on this very page: path and query of the request plus
// the fragment. See the comment on the skip link in the markup for why it cannot
// be a bare "#main".
private string SkipLinkHref =>
$"{HttpContext?.Request.PathBase}{HttpContext?.Request.Path}{HttpContext?.Request.QueryString}#main";
// Berlin's year, not the server's (UTC) -- see FooterYear.
private readonly int _year = FooterYear.For(TimeProvider.System.GetUtcNow());
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
try try
{ {
var pages = await PocketBase.GetPagesAsync(); var pages = await PocketBase.GetPagesAsync(HttpContext?.RequestAborted ?? default);
_header = NavBuilder.Header(pages); _header = NavBuilder.Header(pages);
_footer = NavBuilder.Footer(pages); _footer = NavBuilder.Footer(pages);
} }
catch (HttpRequestException ex) catch (PocketBaseUnavailableException ex)
{ {
// Source unreachable -> render the chrome with an empty menu instead of // Only the menu fails: render the chrome with an empty menu instead of a
// a 500. The brand link still leads home. // 500, so every page stays reachable. The brand link still leads home.
LogLoadFailed(Logger, ex); LogSourceUnavailable(Logger, ex);
} }
} }
} }
@@ -25,11 +25,12 @@
z-index keeps it above the content; the opaque background stops content showing z-index keeps it above the content; the opaque background stops content showing
through as it scrolls underneath. */ through as it scrolls underneath. */
.site-header { .site-header {
/* Brand-blue bar with a warm accent underline -- the strongest, most visible /* The brand gradient, blue into green -- the strongest, most visible cue that
cue that carries the friendly tone across every page. Uses its own header carries the school's colours across every page. Built on --color-header, so
token so it stays deep blue in dark mode too (see app.css). */ it stays deep in dark mode too (see app.css). No accent underline any more:
background: var(--color-header); the gradient already ends in green, and the active menu item carries the
border-bottom: 3px solid var(--color-accent); green stroke. */
background: var(--gradient-brand);
box-shadow: var(--shadow); box-shadow: var(--shadow);
position: sticky; position: sticky;
top: 0; top: 0;
@@ -42,30 +43,71 @@
padding: var(--space-3) 1.25rem; padding: var(--space-3) 1.25rem;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: baseline; align-items: center;
gap: var(--space-2) var(--space-4); gap: var(--space-2) var(--space-4);
} }
/* Signet and two-line lettering side by side. The lettering is a column of its
own, so the name and the school line up on the left edge. */
.brand { .brand {
font-size: 1.35rem; display: flex;
font-weight: 700; align-items: center;
color: #fff; gap: var(--space-2);
color: var(--color-on-header);
text-decoration: none; text-decoration: none;
white-space: nowrap; white-space: nowrap;
} }
.brand:hover {
color: var(--color-on-header);
}
/* On the blue gradient the global brand-blue focus ring would vanish; white
stays visible on both ends of it. */
.brand:focus-visible {
outline-color: var(--color-focus-inverse);
}
/* The signet lives in its own component (Signet.razor), so its <svg> carries that
component's scope attribute, not this one's; ::deep reaches it from here. */
.brand ::deep .signet {
flex-shrink: 0;
width: 2.5rem;
height: 2.5rem;
}
.brand-text {
display: flex;
flex-direction: column;
line-height: 1.15;
}
.brand-name {
font-size: 1.35rem;
font-weight: var(--font-weight-heading);
letter-spacing: var(--letter-spacing-heading);
}
.brand-school {
font-size: 0.85rem;
font-weight: 600;
}
/* The links are pills with their own padding (see app.css), so the gap between
them can stay small. */
.main-nav { .main-nav {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.25rem 1.25rem; align-items: center;
gap: var(--space-1) var(--space-2);
} }
/* The hamburger button, its state checkbox, and the mobile-only home link are /* The hamburger button and its state checkbox are hidden on wide screens; the
hidden on wide screens; the media query below turns them on for narrow ones. media query below turns them on for narrow ones. display:none (beating the
(On wide screens the brand is the home link, so a "Start" entry is redundant.) */ global .visually-hidden the checkbox also carries) takes the checkbox out of the
tab order here, where there is no menu to open. */
.nav-toggle, .nav-toggle,
.nav-toggle-label, .nav-toggle-label {
.nav-home-mobile {
display: none; display: none;
} }
@@ -74,11 +116,12 @@
(b-xxxxx), so a scoped rule ".main-nav a" would not match them. See app.css. */ (b-xxxxx), so a scoped rule ".main-nav a" would not match them. See app.css. */
/* Content: at least tall enough that the footer sits at the bottom of the viewport /* Content: at least tall enough that the footer sits at the bottom of the viewport
on short pages. 100dvh - 4rem leaves room for the header (which sits above in on short pages. 100dvh - 5rem leaves room for the header with its signet (which
normal flow); dvh follows the mobile browser's shrinking toolbar. On long pages sits above in normal flow); dvh follows the mobile browser's shrinking toolbar.
the content simply grows past this and pushes the footer down naturally. */ On long pages the content simply grows past this and pushes the footer down
naturally. */
.content { .content {
min-height: calc(100dvh - 4rem); min-height: calc(100dvh - 5rem);
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
max-width: var(--content-width); max-width: var(--content-width);
@@ -86,39 +129,106 @@
padding: var(--space-4) 1.25rem var(--space-6); padding: var(--space-4) 1.25rem var(--space-6);
} }
/* Footer */ /* Footer: a dark brand-blue band with light text, so the page visibly ends here.
Its own tokens (--color-footer, --color-on-footer...) keep it dark in both
colour schemes, like the header. position:relative anchors the wave. */
.site-footer { .site-footer {
border-top: 1px solid var(--color-border); position: relative;
background: var(--color-surface); margin-top: var(--space-6);
color: var(--color-text-soft); background: var(--color-footer);
font-size: 0.9rem; color: var(--color-on-footer-soft);
font-size: 0.95rem;
} }
/* The wave sits just above the footer's top edge (bottom: 100%), drawn over the
page background in the footer colour, so the band seems to rise softly instead
of starting with a hard line. Fixed height, full width: on a phone it simply
flattens out. Overlapped by 1px so no hairline gap shows between wave and band
when the browser rounds. */
.footer-wave {
position: absolute;
bottom: calc(100% - 1px);
left: 0;
display: block;
width: 100%;
height: 2.5rem;
fill: var(--color-footer);
}
/* Three columns: brand, quick links, further pages. The brand gets a bit more room
because it holds the signet. A missing link column simply leaves its cell empty,
so the others keep their place. */
.footer-inner { .footer-inner {
max-width: var(--content-width); max-width: var(--content-width);
margin: 0 auto; margin: 0 auto;
padding: var(--space-3) 1.25rem; padding: var(--space-5) 1.25rem var(--space-4);
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: 1.5fr 1fr 1fr;
justify-content: space-between;
gap: var(--space-2) var(--space-4);
}
.footer-nav {
display: flex;
gap: var(--space-4); gap: var(--space-4);
} }
.footer-brand {
display: flex;
align-items: center;
align-self: start;
gap: var(--space-2);
color: var(--color-on-footer);
font-weight: 700;
}
.footer-brand ::deep .signet {
flex-shrink: 0;
width: 3rem;
height: 3rem;
}
/* Column headings label the lists; they are not a second page heading, so they
stay small and quiet. */
.footer-heading {
margin: 0 0 var(--space-2);
color: var(--color-on-footer);
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.footer-nav ul {
list-style: none;
margin: 0;
padding: 0;
}
.footer-nav li + li {
margin-top: var(--space-1);
}
.footer-nav a { .footer-nav a {
color: var(--color-text-soft); color: var(--color-on-footer-soft);
text-decoration: none; text-decoration: none;
} }
.footer-nav a:hover { .footer-nav a:hover {
color: var(--color-brand); color: var(--color-on-footer);
text-decoration: underline; text-decoration: underline;
} }
/* Same reason as the brand link in the header: a light ring on the dark band. */
.footer-nav a:focus-visible {
outline-color: var(--color-focus-inverse);
}
/* Copyright: a thin rule separates it from the columns. The rule spans the content
width, not the whole window, so it lines up with the columns above. */
.footer-copyright {
box-sizing: border-box;
max-width: var(--content-width);
margin: 0 auto;
padding: var(--space-3) 1.25rem;
border-top: 1px solid var(--color-footer-border);
font-size: 0.85rem;
}
/* Mobile: on narrow screens the nav collapses behind a hamburger button. This is /* Mobile: on narrow screens the nav collapses behind a hamburger button. This is
pure CSS -- the #nav-toggle checkbox holds the open/closed state and its pure CSS -- the #nav-toggle checkbox holds the open/closed state and its
:checked state reveals the nav. No JavaScript, so it works under static SSR. */ :checked state reveals the nav. No JavaScript, so it works under static SSR. */
@@ -131,6 +241,19 @@
justify-content: space-between; justify-content: space-between;
} }
/* The checkbox comes back as a real, focusable control -- only visually hidden
(global .visually-hidden), so Tab stops on it and Space toggles the menu. It
lives under the label, so the focus ring is drawn there instead. */
.nav-toggle {
display: block;
}
.nav-toggle:focus-visible + .nav-toggle-label {
outline: 3px solid var(--color-focus-inverse);
outline-offset: 2px;
border-radius: var(--radius);
}
/* Show the hamburger button. */ /* Show the hamburger button. */
.nav-toggle-label { .nav-toggle-label {
display: block; display: block;
@@ -146,8 +269,9 @@
display: block; display: block;
width: 1.5rem; width: 1.5rem;
height: 2px; height: 2px;
background: #fff; /* on the blue header bar */ background: var(--color-on-header);
content: ""; content: "";
transition: transform var(--transition), background-color var(--transition);
} }
.nav-toggle-bar::before { .nav-toggle-bar::before {
@@ -158,30 +282,54 @@
transform: translateY(4px); /* 6px minus the bar's own 2px height */ transform: translateY(4px); /* 6px minus the bar's own 2px height */
} }
/* The nav takes its own row and is hidden until the checkbox is checked. */ /* Open: the hamburger turns into an X. The middle bar fades out and the outer
two meet in the middle and cross. Only the icon changes shape; nothing around
it moves. The checkbox sits right before the label, hence the + combinator. */
.nav-toggle:checked + .nav-toggle-label .nav-toggle-bar {
background-color: transparent;
}
.nav-toggle:checked + .nav-toggle-label .nav-toggle-bar::before {
transform: rotate(45deg);
}
.nav-toggle:checked + .nav-toggle-label .nav-toggle-bar::after {
transform: translateY(-2px) rotate(-45deg); /* back up by its own height */
}
/* The nav takes its own row and is hidden until the checkbox is checked.
max-height:0 alone would only hide it from the eye: its links would still
be Tab stops and still be read out. visibility:hidden takes them out of
both. Transitioning visibility delays the switch to hidden until the fold
has finished, so closing still animates. Opening uses the transition of the
:checked rule below, which leaves visibility out: the links must be Tab
stops the moment Space is pressed, not 0.2s later. */
.main-nav { .main-nav {
flex-basis: 100%; flex-basis: 100%;
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
max-height: 0; max-height: 0;
overflow: hidden; overflow: hidden;
transition: max-height 0.2s ease; visibility: hidden;
transition: max-height 0.2s ease, visibility 0.2s;
} }
/* Checked state: reveal the stacked links. The max-height caps the open menu /* Checked state: reveal the stacked links. The max-height caps the open menu
to the space below the sticky header; a longer list scrolls inside it to the space below the sticky header; a longer list scrolls inside it
instead of running off the screen. */ instead of running off the screen. */
.nav-toggle:checked ~ .main-nav { .nav-toggle:checked ~ .main-nav {
visibility: visible;
transition: max-height 0.2s ease;
max-height: 70vh; max-height: 70vh;
overflow-y: auto; overflow-y: auto;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
/* The mobile-only home link reappears as the first entry in the menu. */ /* Mobile per-link spacing for .main-nav a is in the global app.css too, for
.nav-home-mobile { the same scoped-attribute reason as above. */
display: block;
}
/* Mobile per-link spacing/separators for .main-nav a are in the global app.css /* The footer columns stack: one column, brand on top. */
too, for the same scoped-attribute reason as above. */ .footer-inner {
grid-template-columns: 1fr;
}
} }
@@ -1,25 +1,14 @@
<section class="embed embed-events"> @using Elternbeirat.Web.Features.Events
<h2>Kommende Termine</h2> @if (_upcomingEvents.Count > 0)
{
@if (_upcoming.Count == 0) <section class="embed embed-events">
{ <h2>Kommende Termine</h2>
<p>Zurzeit sind keine Termine geplant.</p>
}
else
{
<ul class="event-list"> <ul class="event-list">
@foreach (var ev in _upcoming) @foreach (var ev in _upcomingEvents)
{ {
<li> @* A teaser: each card leads to the events page with the full list. *@
<time datetime="@ev.Start.ToString("s")">@EventFormat.Range(ev)</time> <li><EventCard Value="ev" Href="/events" /></li>
<span class="event-title">@ev.Title</span>
@if (!string.IsNullOrWhiteSpace(ev.Location))
{
<span class="event-location">@ev.Location</span>
}
</li>
} }
</ul> </ul>
<p class="embed-more"><a href="/events">Alle Termine <span aria-hidden="true">&rarr;</span></a></p> </section>
} }
</section>
@@ -1,43 +1,54 @@
using Elternbeirat.Contracts; using Elternbeirat.Contracts;
using Elternbeirat.PocketBase; using Elternbeirat.PocketBase;
using Elternbeirat.Web.Features.Events;
using Elternbeirat.Web.Shared;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
namespace Elternbeirat.Web.Features.Embeds; namespace Elternbeirat.Web.Features.Embeds;
public partial class EventsEmbed public partial class EventsEmbed
{ {
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load events for an embed from PocketBase.")] // Number of upcoming events shown in the teaser; the full list is one link away.
private static partial void LogLoadFailed(ILogger logger, Exception exception); private const int TeaserCount = 3;
[LoggerMessage(
Level = LogLevel.Warning,
Message = "Could not load events for an embed from PocketBase; the teaser is omitted.")]
private static partial void LogSourceUnavailable(
ILogger logger,
Exception exception);
[Inject] [Inject]
private PocketBaseClient PocketBase { get; set; } = default!; private PocketBaseClient PocketBase { get; set; } = null!;
[Inject] [Inject]
private ILogger<EventsEmbed> Logger { get; set; } = default!; private ILogger<EventsEmbed> Logger { get; set; } = null!;
private IReadOnlyList<Event> _upcoming = []; [Inject]
private TimeProvider Time { get; set; } = null!;
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private IReadOnlyList<Event> _upcomingEvents = [];
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
IReadOnlyList<Event> events; IReadOnlyList<Event> events;
try try
{ {
events = await PocketBase.GetEventsAsync(); events = await PocketBase.GetEventsAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
} }
catch (HttpRequestException ex) catch (PocketBaseUnavailableException exception)
{ {
// Source unreachable -> show no teaser instead of a 500. // Only part of the page fails: omit the teaser, keep the page (and its
LogLoadFailed(Logger, ex); // 200) intact. A missing teaser is degraded, not broken.
LogSourceUnavailable(Logger, exception);
return; return;
} }
// Teaser: the three soonest upcoming events, earliest first. An event // Teaser: the soonest upcoming events, earliest first. An event earlier
// earlier today still counts as upcoming. // today still counts as upcoming.
var today = DateOnly.FromDateTime(DateTime.Today); _upcomingEvents = [.. UpcomingEvents.From(events, BerlinClock.Today(Time)).Take(TeaserCount)];
_upcoming = events
.Where(e => DateOnly.FromDateTime(e.Start) >= today)
.OrderBy(e => e.Start)
.Take(3)
.ToList();
} }
} }
@@ -1,5 +1,5 @@
/* Card styles for .event-list are shared with the full events page and live in the /* The card is EventCard and the list grid lives in the global app.css, shared with
global app.css. Only the embed's spacing on the home page is specific here. */ the full events page. Only the embed's spacing on the home page is specific here. */
.embed-events { .embed-events {
margin-top: var(--space-5); margin-top: var(--space-5);
} }
@@ -1,21 +0,0 @@
<section class="embed embed-faqs">
<h2>Häufige Fragen</h2>
@if (_faqs.Count == 0)
{
<p>Zurzeit gibt es keine Fragen.</p>
}
else
{
@foreach (var faq in _faqs)
{
<details class="faq-item">
<summary>@faq.Question</summary>
<div class="faq-answer">
@((MarkupString)Markdown.ToHtml(faq.Answer))
</div>
</details>
}
<p class="embed-more"><a href="/faqs">Alle Fragen <span aria-hidden="true">&rarr;</span></a></p>
}
</section>
@@ -1,34 +0,0 @@
using Elternbeirat.Contracts;
using Elternbeirat.PocketBase;
using Microsoft.AspNetCore.Components;
namespace Elternbeirat.Web.Features.Embeds;
public partial class FaqsEmbed
{
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load FAQs for an embed from PocketBase.")]
private static partial void LogLoadFailed(ILogger logger, Exception exception);
[Inject]
private PocketBaseClient PocketBase { get; set; } = default!;
[Inject]
private ILogger<FaqsEmbed> Logger { get; set; } = default!;
private IReadOnlyList<Faq> _faqs = [];
protected override async Task OnInitializedAsync()
{
try
{
// Teaser: the first three questions, with a link to the full hub.
var faqs = await PocketBase.GetFaqsAsync();
_faqs = [.. faqs.Take(3)];
}
catch (HttpRequestException ex)
{
// Source unreachable -> show no teaser instead of a 500.
LogLoadFailed(Logger, ex);
}
}
}
@@ -1,5 +0,0 @@
/* Card styles for .faq-item are shared with the full FAQ page and live in the
global app.css. Only the embed's spacing on the home page is specific here. */
.embed-faqs {
margin-top: var(--space-5);
}
@@ -12,14 +12,11 @@
case "events": case "events":
<EventsEmbed /> <EventsEmbed />
break; break;
case "faqs":
<FaqsEmbed />
break;
} }
} }
@code { @code {
/// <summary>The page's embed keys ("posts", "events", "faqs"), in order.</summary> /// <summary>The page's embed keys ("posts", "events"), in order.</summary>
[Parameter] [Parameter]
public IReadOnlyList<string> Embed { get; set; } = []; public IReadOnlyList<string> Embed { get; set; } = [];
} }
@@ -1,23 +1,13 @@
<section class="embed embed-posts"> @using Elternbeirat.Web.Features.Posts
<h2>Aktuelle Beiträge</h2> @if (_latest.Count > 0)
{
@if (_latest.Count == 0) <section class="embed embed-posts">
{ <h2>Aktuelle Beiträge</h2>
<p>Zurzeit gibt es keine Beiträge.</p>
}
else
{
<ul class="post-list"> <ul class="post-list">
@foreach (var post in _latest) @foreach (var post in _latest)
{ {
<li> <li><PostCard Value="post" /></li>
<a href="@($"/posts/{post.Slug}")">@post.Title</a>
<time datetime="@post.Date.ToString("yyyy-MM-dd")">
@post.Date.ToString("d. MMMM yyyy", German)
</time>
</li>
} }
</ul> </ul>
<p class="embed-more"><a href="/posts">Alle Beiträge <span aria-hidden="true">&rarr;</span></a></p> </section>
} }
</section>
@@ -1,4 +1,3 @@
using System.Globalization;
using Elternbeirat.Contracts; using Elternbeirat.Contracts;
using Elternbeirat.PocketBase; using Elternbeirat.PocketBase;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
@@ -7,16 +6,24 @@ namespace Elternbeirat.Web.Features.Embeds;
public partial class PostsEmbed public partial class PostsEmbed
{ {
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); // Number of posts shown in the teaser; the full list is one link away.
private const int TeaserCount = 3;
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load posts for an embed from PocketBase.")] [LoggerMessage(
private static partial void LogLoadFailed(ILogger logger, Exception exception); Level = LogLevel.Warning,
Message = "Could not load posts for an embed from PocketBase; the teaser is omitted.")]
private static partial void LogSourceUnavailable(
ILogger logger,
Exception exception);
[Inject] [Inject]
private PocketBaseClient PocketBase { get; set; } = default!; private PocketBaseClient PocketBase { get; set; } = null!;
[Inject] [Inject]
private ILogger<PostsEmbed> Logger { get; set; } = default!; private ILogger<PostsEmbed> Logger { get; set; } = null!;
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private IReadOnlyList<Post> _latest = []; private IReadOnlyList<Post> _latest = [];
@@ -24,14 +31,15 @@ public partial class PostsEmbed
{ {
try try
{ {
// Teaser: the three most recent posts, with a link to the full list. // GetPostsAsync returns newest first, so the first entries are the latest.
var posts = await PocketBase.GetPostsAsync(); var posts = await PocketBase.GetPostsAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
_latest = [.. posts.Take(3)]; _latest = [.. posts.Take(TeaserCount)];
} }
catch (HttpRequestException ex) catch (PocketBaseUnavailableException exception)
{ {
// Source unreachable -> show no teaser instead of a 500. // Only part of the page fails: omit the teaser, keep the page (and its
LogLoadFailed(Logger, ex); // 200) intact. A missing teaser is degraded, not broken.
LogSourceUnavailable(Logger, exception);
} }
} }
} }
@@ -1,5 +1,5 @@
/* Card styles for .post-list are shared with the full posts page and live in the /* The card is PostCard and the list grid lives in the global app.css, shared with
global app.css. Only the embed's spacing on the home page is specific here. */ the full posts page. Only the embed's spacing on the home page is specific here. */
.embed-posts { .embed-posts {
margin-top: var(--space-5); margin-top: var(--space-5);
} }
@@ -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,82 @@
/* 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-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;
}
@@ -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!;
}
@@ -0,0 +1,9 @@
/* The date's own look: small and softened, matching <PostDate> so post and event
cards read as one style. Lives with the component so every place that renders
<EventDate> (list, home embed) shows the date the same way. Positioning (margin
to neighbours) is NOT set here -- that is the surrounding list's job. */
time {
display: block;
color: var(--color-text-soft); /* token, so it adapts in dark mode */
font-size: 0.85rem;
}
@@ -0,0 +1,213 @@
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, 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
/// <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>
/// 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>
/// <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)}, {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>
/// <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);
}
@@ -1,56 +1,74 @@
@page "/events" @page "/events"
<PageTitle>Termine</PageTitle> @{
// Heading and intro come from the "events" page record; fall back to a sensible
// default only if that record is missing, so the page still renders.
var title = _page?.Title ?? "Termine";
}
<h1>Termine</h1> <PageTitle>@title</PageTitle>
<p class="intro"> <h1>@title</h1>
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.
</p>
<h2>Kommende Termine</h2> @if (_page is not null && !string.IsNullOrWhiteSpace(_page.Body))
@if (_upcoming.Count == 0)
{ {
<p>Zurzeit sind keine Termine geplant.</p> <article class="markdown-body">@Body</article>
}
@if (_unavailable)
{
<p>Die Termine sind gerade nicht erreichbar. Bitte später erneut versuchen.</p>
} }
else else
{ {
<ul class="event-list"> @* The page body above is the intro; the page only adds the subscribe button. *@
@foreach (var ev in _upcoming) <p class="events-subscribe">
{ <a class="btn btn-secondary" href="/events.ics"><Icon Name="calendar-plus" Size="18" /> Kalender abonnieren</a>
<li> </p>
<time datetime="@ev.Start.ToString("s")">@EventFormat.Range(ev)</time>
<span class="event-title">@ev.Title</span>
@if (!string.IsNullOrWhiteSpace(ev.Location))
{
<span class="event-location">@ev.Location</span>
}
@if (!string.IsNullOrWhiteSpace(ev.Note))
{
<p class="event-note">@ev.Note</p>
}
</li>
}
</ul>
}
@if (_past.Count > 0) <h2>Kommende Termine</h2>
{
<h2>Vergangene Termine</h2> @if (_upcomingEvents.Count == 0)
<ul class="event-list event-list-past"> {
@foreach (var ev in _past) <p>Zurzeit sind keine Termine geplant.</p>
{ }
<li> else
<time datetime="@ev.Start.ToString("s")">@EventFormat.Range(ev)</time> {
<span class="event-title">@ev.Title</span> <ul class="event-list">
@if (!string.IsNullOrWhiteSpace(ev.Location)) @foreach (var ev in _upcomingEvents)
{ {
<span class="event-location">@ev.Location</span> @* No Href: a card linking to this very page would lead nowhere. *@
} <li><EventCard Value="ev" /></li>
</li> }
} </ul>
</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)
{
<details class="past-events disclosure">
<summary>Vergangene Termine anzeigen (@_pastEvents.Count)</summary>
@foreach (var year in PastYears)
{
<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>
}
</details>
}
} }
@@ -1,48 +1,81 @@
using Elternbeirat.Contracts; using Elternbeirat.Contracts;
using Elternbeirat.PocketBase; using Elternbeirat.PocketBase;
using Elternbeirat.Web.Shared;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
namespace Elternbeirat.Web.Features.Events; namespace Elternbeirat.Web.Features.Events;
public partial class EventList public partial class EventList
{ {
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load events from PocketBase.")] [LoggerMessage(
private static partial void LogLoadFailed(ILogger logger, Exception exception); Level = LogLevel.Error,
Message = "Could not load events from PocketBase; content source is unavailable.")]
private static partial void LogSourceUnavailable(
ILogger logger,
Exception exception);
[Inject] [Inject]
private PocketBaseClient PocketBase { get; set; } = default!; private PocketBaseClient PocketBase { get; set; } = null!;
[Inject] [Inject]
private ILogger<EventList> Logger { get; set; } = default!; private ILogger<EventList> Logger { get; set; } = null!;
private IReadOnlyList<Event> _upcoming = []; [Inject]
private IReadOnlyList<Event> _past = []; private TimeProvider Time { get; set; } = null!;
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
// The "events" page record supplies the heading and intro text, so an editor
// owns them in PocketBase like every other page, instead of them being
// hard-coded here. Null until loaded, or when no such page exists.
private Page? _page;
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
// 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);
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
IReadOnlyList<Event> events; IReadOnlyList<Event> events;
try try
{ {
events = await PocketBase.GetEventsAsync(); var token = HttpContext?.RequestAborted ?? CancellationToken.None;
var pages = await PocketBase.GetPagesAsync(token);
_page = pages.FirstOrDefault(
page => string.Equals(page.Slug, "events", StringComparison.OrdinalIgnoreCase));
events = await PocketBase.GetEventsAsync(token);
} }
catch (HttpRequestException ex) catch (PocketBaseUnavailableException exception)
{ {
// Source unreachable -> show empty sections instead of a 500. The page // Empty sections would claim there are no events. The site works, its
// stays up; the "keine Termine" note is rendered. // data store is down: 503 and an honest note.
LogLoadFailed(Logger, ex); LogSourceUnavailable(Logger, exception);
_unavailable = true;
SourceUnavailable.SetStatus(HttpContext);
return; return;
} }
// Split into upcoming and past by date, so an event earlier today still // Split into upcoming and past by date, so an event earlier today still
// counts as upcoming. Upcoming earliest first, past most recent first. // counts as upcoming. Upcoming earliest first, past most recent first.
var today = DateOnly.FromDateTime(DateTime.Today); var today = BerlinClock.Today(Time);
_upcoming = events _upcomingEvents = [.. UpcomingEvents.From(events, today)];
.Where(e => DateOnly.FromDateTime(e.Start) >= today) _pastEvents =
.OrderBy(e => e.Start) [
.ToList(); .. events
_past = events .Where(@event => DateOnly.FromDateTime(@event.Start) < today)
.Where(e => DateOnly.FromDateTime(e.Start) < today) .OrderByDescending(@event => @event.Start)
.OrderByDescending(e => e.Start) ];
.ToList();
} }
} }
@@ -1,2 +1,85 @@
/* The .event-list card styles (including past-event styling) are shared with the /* The card grid (.event-list) is shared with the home-page embed and lives in the
home-page embed and live in the global app.css. Nothing page-specific here. */ global app.css; the card itself is EventCard. Only the page's own parts are here. */
/* Same centred 42rem reading column as a content page (ContentPage.razor.css), so
every page of the site shares one column under the header. */
h1,
h2,
article,
p,
.event-list,
.past-events {
max-width: 42rem;
margin-inline: auto;
}
/* auto, not 0: this rule outweighs the column rule above and would otherwise pull
the button back to the left edge of the page. */
.events-subscribe {
margin: var(--space-4) auto;
}
/* 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 {
box-sizing: border-box; /* padding inside the 42rem, flush with the text */
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;
}
@@ -45,7 +45,7 @@ public static class IcsCalendar
// between requests, so caches and clients do not see spurious changes. // between requests, so caches and clients do not see spurious changes.
AppendLine(sb, "DTSTAMP:20000101T000000Z"); AppendLine(sb, "DTSTAMP:20000101T000000Z");
if (HasTime(ev)) if (ev.HasTime)
{ {
AppendLine(sb, $"DTSTART:{Local(ev.Start)}"); AppendLine(sb, $"DTSTART:{Local(ev.Start)}");
if (ev.End is { } end) if (ev.End is { } end)
@@ -75,12 +75,6 @@ public static class IcsCalendar
AppendLine(sb, "END:VEVENT"); AppendLine(sb, "END:VEVENT");
} }
/// <summary>
/// An entry counts as all-day when its start has no wall-clock time (00:00).
/// PocketBase has no separate flag; the convention is a midnight start.
/// </summary>
private static bool HasTime(Event ev) => ev.Start.TimeOfDay != TimeSpan.Zero;
/// <summary> /// <summary>
/// A UID that stays the same as long as the entry's title and start do, so a /// A UID that stays the same as long as the entry's title and start do, so a
/// re-subscribe updates the event instead of creating a duplicate. /// re-subscribe updates the event instead of creating a duplicate.
@@ -0,0 +1,30 @@
using Elternbeirat.Contracts;
namespace Elternbeirat.Web.Features.Events;
/// <summary>
/// Picks the events that still lie ahead.
/// </summary>
/// <remarks>
/// The events page, the events embed and the "next event" card on the home page
/// all need the same answer to "what is coming up?". Keeping the rule here means a
/// change to it (e.g. hiding an event once it has ended) reaches all three.
/// </remarks>
/// <seealso cref="Shared.BerlinClock"/>
public static class UpcomingEvents
{
/// <summary>
/// Returns the events on or after <paramref name="today"/>, earliest first.
/// </summary>
/// <param name="events">All events, in any order.</param>
/// <param name="today">The current day, from <see cref="Shared.BerlinClock.Today"/>.</param>
/// <returns>The upcoming events, ordered by start.</returns>
/// <remarks>
/// Compared by day, not by the minute: an event earlier today still counts as
/// upcoming, so a visitor who checks during the evening's meeting still finds it.
/// </remarks>
public static IEnumerable<Event> From(IEnumerable<Event> events, DateOnly today) =>
events
.Where(@event => DateOnly.FromDateTime(@event.Start) >= today)
.OrderBy(@event => @event.Start);
}
+28 -9
View File
@@ -1,28 +1,47 @@
@page "/faqs" @page "/faqs"
<PageTitle>Häufige Fragen</PageTitle> @{
// Heading and intro come from the "faqs" page record; fall back to a sensible
// default only if that record is missing, so the page still renders.
var title = _page?.Title ?? "FAQs";
}
<h1>Häufige Fragen</h1> <PageTitle>@title</PageTitle>
@if (_groups.Count == 0) <h1>@title</h1>
@if (_page is not null && !string.IsNullOrWhiteSpace(_page.Body))
{
<article class="markdown-body">@Body</article>
}
@if (_unavailable)
{
<p>Die Fragen sind gerade nicht erreichbar. Bitte später erneut versuchen.</p>
}
else if (_topics.Count == 0)
{ {
<p>Zurzeit gibt es keine Fragen.</p> <p>Zurzeit gibt es keine Fragen.</p>
} }
else else
{ {
@foreach (var group in _groups) @foreach (var topic in _topics)
{ {
<section class="faq-topic"> <details class="faq-topic disclosure">
<h2>@TopicLabel(group.Key)</h2> <summary>@topic.Title</summary>
@foreach (var faq in group) @if (!string.IsNullOrWhiteSpace(topic.Intro))
{
<div class="faq-topic-intro markdown-body">@((MarkupString)Markdown.ToHtml(topic.Intro))</div>
}
@foreach (var faq in topic.Faqs)
{ {
<details class="faq-item"> <details class="faq-item">
<summary>@faq.Question</summary> <summary>@faq.Question</summary>
<div class="faq-answer"> <div class="faq-answer markdown-body">
@((MarkupString)Markdown.ToHtml(faq.Answer)) @((MarkupString)Markdown.ToHtml(faq.Answer))
</div> </div>
</details> </details>
} }
</section> </details>
} }
} }
+38 -25
View File
@@ -1,48 +1,61 @@
using Elternbeirat.Contracts; using Elternbeirat.Contracts;
using Elternbeirat.PocketBase; using Elternbeirat.PocketBase;
using Elternbeirat.Web.Shared;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
namespace Elternbeirat.Web.Features.Faqs; namespace Elternbeirat.Web.Features.Faqs;
public partial class FaqList public partial class FaqList
{ {
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load FAQs from PocketBase.")] [LoggerMessage(
private static partial void LogLoadFailed(ILogger logger, Exception exception); Level = LogLevel.Error,
Message = "Could not load FAQ topics from PocketBase; content source is unavailable.")]
// German heading per topic. Unknown topics fall back to the raw key so a new private static partial void LogSourceUnavailable(
// topic in PocketBase still shows up rather than being silently dropped. ILogger logger,
private static readonly Dictionary<string, string> TopicLabels = Exception exception);
new(StringComparer.Ordinal)
{
["mensa"] = "Mensa und Mittagessen",
["schliessfach"] = "Schließfächer",
["elterneuro"] = "Eltern-Euro",
["elternarbeit"] = "Elternarbeit und Mitmachen",
};
[Inject] [Inject]
private PocketBaseClient PocketBase { get; set; } = default!; private PocketBaseClient PocketBase { get; set; } = null!;
[Inject] [Inject]
private ILogger<FaqList> Logger { get; set; } = default!; private ILogger<FaqList> Logger { get; set; } = null!;
private IReadOnlyList<IGrouping<string, Faq>> _groups = []; [CascadingParameter]
private HttpContext? HttpContext { get; set; }
// The "faqs" page record supplies the heading and intro text, so an editor
// owns them in PocketBase rather than them being hard-coded here. Null until
// loaded, or when no such page exists.
private Page? _page;
private IReadOnlyList<FaqTopic> _topics = [];
// Set when the content source could not be reached or read. The markup then
// shows a "temporarily unavailable" note instead of "no topics", matching
// the 503 this sets on the response.
private bool _unavailable;
// The page body rendered from Markdown to HTML, ready to emit as a
// MarkupString in the markup.
private MarkupString Body => (MarkupString)Markdown.ToHtml(_page?.Body);
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
try try
{ {
var faqs = await PocketBase.GetFaqsAsync(); var token = HttpContext?.RequestAborted ?? CancellationToken.None;
_groups = [.. faqs.GroupBy(faq => faq.Topic)]; var pages = await PocketBase.GetPagesAsync(token);
_page = pages.FirstOrDefault(
page => string.Equals(page.Slug, "faqs", StringComparison.OrdinalIgnoreCase));
_topics = await PocketBase.GetFaqTopicsAsync(token);
} }
catch (HttpRequestException ex) catch (PocketBaseUnavailableException exception)
{ {
// Source unreachable -> show an empty page instead of a 500. The page // An empty page would claim there are no topics. The site works, its
// stays up; the "keine Fragen" note is rendered. // data store is down: 503 and an honest note.
LogLoadFailed(Logger, ex); LogSourceUnavailable(Logger, exception);
_unavailable = true;
SourceUnavailable.SetStatus(HttpContext);
} }
} }
private static string TopicLabel(string topic) =>
TopicLabels.TryGetValue(topic, out var label) ? label : topic;
} }
@@ -1,10 +1,17 @@
/* The .faq-item card styles are shared with the home-page embed and live in the /* Same centred 42rem reading column as a content page (ContentPage.razor.css), so
global app.css. Only the topic grouping is specific to this full page (the every page of the site shares one column under the header. The topic cards and
embed shows a flat list without topic headings). */ the questions inside them live in the global app.css. */
h1,
article,
p,
.faq-topic { .faq-topic {
margin-bottom: var(--space-5); box-sizing: border-box; /* padding inside the 42rem, flush with the text */
max-width: 42rem;
margin-inline: auto;
} }
.faq-topic h2 { /* The intro's last paragraph drops its margin (app.css), so the first card would
margin-bottom: var(--space-3); sit right under the text; this gives it the same air as between sections. */
article {
margin-bottom: var(--space-5);
} }
+76 -6
View File
@@ -1,12 +1,82 @@
@page "/" @page "/"
@using Elternbeirat.Web.Features.Events
<PageTitle>Elternbeirat der IGMH</PageTitle> <PageTitle>Elternbeirat der IGMH</PageTitle>
<h1>Elternbeirat der IGMH</h1> @* The hero spans the full window width (see Home.razor.css). Its text is the "home"
page's body, taken apart by HomeIntro; the card beside it shows the next event. *@
<section class="hero">
<div class="hero-inner">
<div class="hero-text">
<h1>@_intro.Title</h1>
<p class="intro"> @if (_intro.IntroHtml.Length > 0)
Willkommen beim Elternbeirat der IGMH. Hier finden Sie aktuelle Informationen, {
Antworten auf häufige Fragen und Unterlagen rund um die Elternarbeit. <div class="hero-intro">@((MarkupString)_intro.IntroHtml)</div>
</p> }
<PageEmbeds Embed="_embed" /> @if (_intro.Links.Count > 0)
{
<p class="hero-actions">
@for (var i = 0; i < _intro.Links.Count; i++)
{
var link = _intro.Links[i];
<a class="btn @(i == 0 ? "btn-primary" : "btn-secondary")" href="@link.Href">@link.Text</a>
}
</p>
}
</div>
@if (_nextEvent is not null)
{
@* As in EventCard: the calendar sheet shows the date and is hidden from
screen readers, who get the full date from <EventDate> instead. Visible
beside the sheet is only what it cannot show: the time, the end. *@
var span = EventDateFormat.Span(_nextEvent);
<a class="next-event" href="/events">
<span class="next-event-label">Nächster Termin</span>
<span class="next-event-row">
<CalendarSheet Value="_nextEvent.Start" />
<span class="next-event-details">
<span class="next-event-title">@_nextEvent.Title</span>
<span class="visually-hidden"><EventDate Value="_nextEvent" /></span>
@if (span.Length > 0)
{
<span class="next-event-meta"><Icon Name="clock" Size="16" /> @span</span>
}
@if (!string.IsNullOrWhiteSpace(_nextEvent.Location))
{
<span class="next-event-meta"><Icon Name="map-pin" Size="16" /> @_nextEvent.Location</span>
}
</span>
</span>
</a>
}
</div>
</section>
@if (_tiles.Count > 0)
{
<section class="home-tiles" aria-labelledby="home-tiles-title">
<h2 id="home-tiles-title">Bereiche im Überblick</h2>
<ul>
@foreach (var tile in _tiles)
{
<li>
<a class="tile-link" href="@tile.Href">
<span class="tile-icon"><Icon Name="@tile.Icon" Size="24" /></span>
<span class="tile-title">@tile.Title</span>
@if (tile.Description.Length > 0)
{
<span class="tile-text">@tile.Description</span>
}
</a>
</li>
}
</ul>
</section>
}
<div class="home-embeds">
<PageEmbeds Embed="_embed" />
</div>
+67 -13
View File
@@ -1,36 +1,90 @@
using Elternbeirat.Contracts;
using Elternbeirat.PocketBase; using Elternbeirat.PocketBase;
using Elternbeirat.Web.Features.Events;
using Elternbeirat.Web.Shared;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
namespace Elternbeirat.Web.Features.Home; namespace Elternbeirat.Web.Features.Home;
public partial class Home public partial class Home
{ {
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load the home page from PocketBase.")] [LoggerMessage(
private static partial void LogLoadFailed(ILogger logger, Exception exception); Level = LogLevel.Warning,
Message = "Could not load the pages for the home page from PocketBase; showing the static title only.")]
private static partial void LogPagesUnavailable(
ILogger logger,
Exception exception);
[LoggerMessage(
Level = LogLevel.Warning,
Message = "Could not load events for the home page from PocketBase; the next-event card is omitted.")]
private static partial void LogEventsUnavailable(
ILogger logger,
Exception exception);
[Inject] [Inject]
private PocketBaseClient PocketBase { get; set; } = default!; private PocketBaseClient PocketBase { get; set; } = null!;
[Inject] [Inject]
private ILogger<Home> Logger { get; set; } = default!; private ILogger<Home> Logger { get; set; } = null!;
// Which dynamic blocks the home page shows is editorial: it comes from the [Inject]
// "home" page's embed field in PocketBase, not from hard-coded markup. private TimeProvider Time { get; set; } = null!;
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
// Heading, intro and buttons come from the "home" page's body. Until it is loaded
// -- and for good if PocketBase fails during this request -- the static fallback
// heading stands, so the start page always has a title.
private HomeIntro _intro = HomeIntro.Fallback;
// The hero, the tiles and the next-event card are fixed structure of the start
// page; which dynamic blocks follow below is editorial and comes from the "home"
// page's embed field.
private IReadOnlyList<HomeTile> _tiles = [];
private IReadOnlyList<string> _embed = []; private IReadOnlyList<string> _embed = [];
private Event? _nextEvent;
protected override async Task OnInitializedAsync() protected override Task OnInitializedAsync()
{
var token = HttpContext?.RequestAborted ?? CancellationToken.None;
return Task.WhenAll(LoadPagesAsync(token), LoadNextEventAsync(token));
}
// Pages and events load side by side and fail on their own: without events the
// page loses only the card, without pages only text and tiles. A PocketBase that
// is plainly down never gets this far -- the maintenance gate in Program.cs
// answers with maintenance.html first. These catches are the second line: the
// gate's health result is cached for a few seconds, so PocketBase can fail between
// that check and these reads. Without them that would be a 500; with them the
// start page stays a 200 rather than a 503 -- degraded, not broken, and rare.
private async Task LoadPagesAsync(CancellationToken token)
{ {
try try
{ {
var pages = await PocketBase.GetPagesAsync(); var pages = await PocketBase.GetPagesAsync(token);
var home = pages.FirstOrDefault( var home = pages.FirstOrDefault(page => Page.IsHomeSlug(page.Slug));
p => string.Equals(p.Slug, "home", StringComparison.Ordinal)); _intro = HomeIntro.Parse(home?.Body);
_embed = home?.Embed ?? []; _embed = home?.Embed ?? [];
_tiles = HomeTile.From(pages);
} }
catch (HttpRequestException ex) catch (PocketBaseUnavailableException exception)
{ {
// Source unreachable -> render the intro without embeds instead of a 500. LogPagesUnavailable(Logger, exception);
LogLoadFailed(Logger, ex); }
}
private async Task LoadNextEventAsync(CancellationToken token)
{
try
{
var events = await PocketBase.GetEventsAsync(token);
_nextEvent = UpcomingEvents.From(events, BerlinClock.Today(Time)).FirstOrDefault();
}
catch (PocketBaseUnavailableException exception)
{
LogEventsUnavailable(Logger, exception);
} }
} }
} }
+308 -3
View File
@@ -1,6 +1,311 @@
.intro { /* --- Hero ---------------------------------------------------------------------
A full-width band in the brand gradient with a faint dot pattern on top. The
page content sits in a centred column (.content in MainLayout), so the band
breaks out of it: 100vw wide, pulled left by the column's offset. body clips the
few pixels of scrollbar this adds (see app.css). The negative top margin cancels
the column's top padding, so the band meets the header without a strip of page
background in between.
The pattern is an inline SVG in a data: URL -- no request, nothing from another
host. White dots at 12% opacity: visible as texture, too faint to cost the
white text any contrast. */
.hero {
width: 100vw;
margin: calc(-1 * var(--space-4)) 0 var(--space-5) calc(50% - 50vw);
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Ccircle cx='3' cy='3' r='1.5' fill='%23fff' fill-opacity='.12'/%3E%3C/svg%3E"),
var(--gradient-brand);
color: var(--color-on-header);
}
/* The inside follows the page frame again, so the hero text lines up with the
tiles and lists below. Stacked on a phone; from tablet width the next-event card
moves beside the text. */
.hero-inner {
box-sizing: border-box;
max-width: var(--content-width);
margin: 0 auto;
padding: var(--space-6) 1.25rem;
display: grid;
gap: var(--space-5);
align-items: center;
}
@media (min-width: 48rem) {
.hero-inner {
grid-template-columns: minmax(0, 1fr) minmax(16rem, 22rem);
}
}
/* On the gradient everything is white: the global heading colour is a blue that
would vanish here. */
/* balance evens out the lines, so a short last word ("IGMH") never wraps alone. */
.hero h1 {
color: var(--color-on-header);
text-wrap: balance;
}
/* The intro is editor Markdown, rendered without scope attributes, so it is reached
through ::deep. Slightly larger than body text, as a lead. Links stay white and
underlined: the blue link colour would be unreadable on the gradient. */
.hero-intro ::deep p {
font-size: 1.15rem;
}
.hero-intro ::deep a,
.hero-intro ::deep a:hover {
color: var(--color-on-header);
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
margin: var(--space-4) 0 0;
}
/* The hero's buttons, adapted to the dark gradient: the primary turns light (a
filled blue would melt into the blue end), the secondary becomes a white outline
with the same translucent wash as the header menu on hover. The focus ring turns
white for the same reason. */
.hero .btn-primary {
background: var(--color-button-light);
border-color: var(--color-button-light);
color: var(--color-on-button-light);
}
.hero .btn-primary:hover {
background: var(--color-button-light-hover);
border-color: var(--color-button-light-hover);
color: var(--color-on-button-light);
}
.hero .btn-secondary {
border-color: var(--color-on-header);
color: var(--color-on-header);
}
.hero .btn-secondary:hover {
background: var(--color-nav-hover);
color: var(--color-on-header);
}
.hero .btn:focus-visible {
outline-color: var(--color-focus-inverse);
}
/* On a phone the buttons used to wrap onto a row each: two wide blocks stacked,
far too heavy for the top of the page. Instead they share one row in equal
columns -- the same buttons as on the desktop, just side by side and a little
tighter. Short labels ("Termine", "Kontakt") fit on one line; a longer one
wraps inside its button (centred) rather than pushing the other off the row. */
@media (max-width: 40rem) {
.hero-actions {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
gap: var(--space-2);
}
.hero .btn {
justify-content: center;
padding: var(--space-2) var(--space-3);
text-align: center;
}
}
/* --- Next event ---------------------------------------------------------------
A card on the gradient: the regular card surface, so its text uses the normal
tokens and adapts to dark mode by itself. The whole card is one link to the
events page. */
.next-event {
display: block;
padding: var(--space-4);
border-radius: var(--radius);
background: var(--color-bg);
box-shadow: var(--shadow-lg);
color: var(--color-text);
text-decoration: none;
transition: box-shadow var(--transition);
}
.next-event:hover {
color: var(--color-text);
box-shadow: var(--shadow-hover), var(--shadow-lg);
}
/* The ring sits outside the card, on the gradient, so it is white like the
hero's button rings. */
.next-event:focus-visible {
outline-color: var(--color-focus-inverse);
outline-offset: 3px;
}
.next-event-label {
display: block;
margin-bottom: var(--space-3);
color: var(--color-text-soft);
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.next-event-row {
display: flex;
gap: var(--space-3);
align-items: flex-start;
}
.next-event-details {
min-width: 0; /* lets a long title wrap instead of widening the card */
}
.next-event-title {
display: block;
color: var(--color-brand);
font-size: 1.15rem;
font-weight: 700;
line-height: 1.3;
}
/* 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);
margin-top: var(--space-1);
color: var(--color-text-soft);
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
a half row behind. On a phone the tiles are compact (icon and title only), the
teaser would make six tiles a long scroll. Hover = stronger shadow and a blue
edge, nothing moves. */
.home-tiles {
margin-block: var(--space-5);
}
.home-tiles ul {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
margin: 0;
padding: 0;
list-style: none;
}
/* On a phone the icon sits above the title: beside it, a half-width column leaves
too little room and titles get cut. */
.tile-link {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
height: 100%;
text-align: center;
padding: var(--space-3);
border: 1px solid transparent;
border-radius: var(--radius);
background: var(--color-bg);
box-shadow: var(--shadow);
color: var(--color-text);
text-decoration: none;
transition: box-shadow var(--transition), border-color var(--transition);
}
.tile-link:hover {
border-color: var(--color-brand);
box-shadow: var(--shadow-hover);
color: var(--color-text);
}
/* The icon in a soft round tint. */
.tile-icon {
flex-shrink: 0;
display: grid;
place-items: center;
width: 3rem;
height: 3rem;
border-radius: 50%;
background: var(--color-brand-tint);
color: var(--color-brand);
}
/* Long German compounds must wrap rather than overflow the narrow tile. */
.tile-title {
max-width: 100%;
color: var(--color-brand);
font-size: 1.1rem; font-size: 1.1rem;
/* Softer than body text, via the token -- so it adapts in dark mode instead of font-weight: 700;
staying a fixed dark grey that turns unreadable there. */ overflow-wrap: anywhere;
}
/* Hidden on a phone; the title alone says where the tile leads. */
.tile-text {
display: none;
color: var(--color-text-soft); color: var(--color-text-soft);
font-size: 0.9rem;
line-height: 1.45;
}
@media (min-width: 48rem) {
.home-tiles ul {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
/* Icon left, title and teaser right. Both start at the top, so the icon stays
beside the title even when the teaser runs over several lines. The icon spans
both rows here, where the teaser is visible. */
.tile-link {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
column-gap: var(--space-3);
row-gap: 0;
align-content: start;
padding: var(--space-3) var(--space-4);
text-align: start;
}
.tile-icon {
grid-row: span 2;
align-self: start;
}
/* The teaser is a whole sentence; the tile cuts it visually after three lines,
so the tiles of a row keep a similar height whatever the editor wrote. The
-webkit- box is the form every current browser supports for line-clamp. */
.tile-text {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;
overflow: hidden;
}
}
/* --- Embeds -------------------------------------------------------------------
The blocks the home page opts into (posts, events) sit side by side from desktop
width, so the start page shows both without a long scroll. One column below.
In the grid the blocks' own top margin no longer collapses with the margin of
the tiles above, so it is dropped there; otherwise the gap would be doubled. */
@media (min-width: 64rem) {
.home-embeds {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-5);
align-items: start;
}
.home-embeds ::deep .embed {
margin-top: 0;
}
} }
+128
View File
@@ -0,0 +1,128 @@
using Elternbeirat.Web.Shared;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
namespace Elternbeirat.Web.Features.Home;
/// <summary>
/// The head of the home page, taken apart from the <c>home</c> page's Markdown body:
/// a large heading, an intro and a row of buttons.
/// </summary>
/// <param name="Title">The large heading.</param>
/// <param name="IntroHtml">The intro rendered to HTML, or an empty string.</param>
/// <param name="Links">The buttons, in the order the editor wrote them; may be empty.</param>
/// <remarks>
/// Everything visible here is written by editors in PocketBase. The page only gives
/// it the hero layout, so the editor needs no new field and no special syntax:
/// <list type="bullet">
/// <item><description>The first <c># heading</c> becomes the large heading.</description></item>
/// <item>
/// <description>
/// A last paragraph made of nothing but links becomes the button row;
/// the first link is the primary button.
/// </description>
/// </item>
/// <item><description>Everything else is the intro text.</description></item>
/// </list>
/// <para>
/// <see cref="Fallback"/> keeps a static heading when PocketBase cannot be
/// reached, so the start page never renders without a title.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// HomeIntro.Parse("# Willkommen\n\nSchön, dass Sie da sind.\n\n[Termine](/events) [Kontakt](/contact)");
/// // Title "Willkommen", IntroHtml "&lt;p&gt;Schön, dass Sie da sind.&lt;/p&gt;\n", two links
/// </code>
/// </example>
public sealed record HomeIntro(string Title, string IntroHtml, IReadOnlyList<HomeLink> Links)
{
/// <summary>
/// The heading shown when the body has none or PocketBase is not reachable.
/// </summary>
public const string FallbackTitle = "Elternbeirat der IGMH";
/// <summary>
/// The head of the home page without any content from PocketBase: the static
/// heading, no intro, no buttons.
/// </summary>
public static HomeIntro Fallback { get; } = new(FallbackTitle, "", []);
/// <summary>
/// Takes the <c>home</c> page's body apart into heading, intro and buttons.
/// </summary>
/// <param name="markdown">The body; may be <see langword="null"/> or empty.</param>
/// <returns>
/// The parts; <see cref="Fallback"/> for an empty body. Without a level-1
/// heading, the title is <see cref="FallbackTitle"/> and the rest is used as is.
/// </returns>
/// <remarks>
/// Only top-level blocks are looked at: a heading or a link paragraph inside a
/// list or a <c>:::</c> block stays part of the intro. Links in the button row
/// may be separated by spaces or line breaks; any other text in the paragraph
/// (e.g. "oder") keeps it a normal paragraph, because the button row would drop
/// that text.
/// </remarks>
public static HomeIntro Parse(string? markdown)
{
if (string.IsNullOrWhiteSpace(markdown))
{
return Fallback;
}
var document = Markdown.Parse(markdown);
var heading = document.OfType<HeadingBlock>().FirstOrDefault(block => block.Level == 1);
if (heading is not null)
{
document.Remove(heading);
}
var links = document.LastChild is ParagraphBlock last ? LinksOnly(last) : [];
if (links.Count > 0)
{
document.RemoveAt(document.Count - 1);
}
var title = heading?.Inline is { } inline ? Markdown.PlainText(inline) : "";
return new HomeIntro(
string.IsNullOrEmpty(title) ? FallbackTitle : title,
Markdown.Render(document),
links);
}
/// <summary>
/// Reads the links of a paragraph that holds nothing but links.
/// </summary>
/// <param name="paragraph">The paragraph to inspect.</param>
/// <returns>
/// Its links, or an empty list if the paragraph contains anything besides links
/// and whitespace (including an image).
/// </returns>
private static List<HomeLink> LinksOnly(ParagraphBlock paragraph) =>
paragraph.Inline?.Where(inline => !Markdown.IsBlank(inline)).ToList() is { Count: > 0 } inlines
&& inlines.All(inline => inline is LinkInline { IsImage: false } or AutolinkInline)
? [.. inlines.Select(ToLink)]
: [];
/// <summary>
/// Turns a link inline into a button.
/// </summary>
/// <param name="inline">A <see cref="LinkInline"/> or <see cref="AutolinkInline"/>.</param>
/// <returns>The button's target and visible text.</returns>
/// <exception cref="ArgumentException"><paramref name="inline"/> is neither kind of link.</exception>
private static HomeLink ToLink(Inline inline) =>
inline switch
{
LinkInline link => new HomeLink(link.Url ?? "", Markdown.PlainText(link)),
AutolinkInline autolink => new HomeLink(autolink.Url, autolink.Url),
_ => throw new ArgumentException("Not a link.", nameof(inline)),
};
}
/// <summary>
/// A button in the head of the home page.
/// </summary>
/// <param name="Href">The link target as the editor wrote it.</param>
/// <param name="Text">The visible button text.</param>
public sealed record HomeLink(string Href, string Text);
@@ -0,0 +1,39 @@
using Elternbeirat.Contracts;
using Elternbeirat.Web.Features.Layout;
using Elternbeirat.Web.Shared;
namespace Elternbeirat.Web.Features.Home;
/// <summary>
/// One tile on the home page, leading to a section of the site.
/// </summary>
/// <param name="Href">Target URL of the page.</param>
/// <param name="Title">The page's title.</param>
/// <param name="Description">
/// The first sentence of the page's body, or an empty string, in which case the
/// tile shows only the title.
/// </param>
/// <param name="Icon">The icon name, see <see cref="TileIcons"/>.</param>
public sealed record HomeTile(string Href, string Title, string Description, string Icon)
{
/// <summary>
/// Builds one tile per header page except the home page itself.
/// </summary>
/// <param name="pages">All public pages.</param>
/// <returns>The tiles, in menu order.</returns>
/// <remarks>
/// The tiles mirror the header menu (<see cref="NavBuilder.HeaderPages"/>), so a
/// page an editor puts into the header appears here too, without any code. The
/// home page is left out: a tile leading to the page it sits on would be useless.
/// </remarks>
public static IReadOnlyList<HomeTile> From(IReadOnlyList<Page> pages) =>
[
.. NavBuilder.HeaderPages(pages)
.Where(page => !Page.IsHomeSlug(page.Slug))
.Select(page => new HomeTile(
NavBuilder.HrefFor(page.Slug),
page.Title,
Markdown.FirstSentence(page.Body),
TileIcons.For(page.Slug))),
];
}
@@ -0,0 +1,45 @@
using System.Collections.Frozen;
using Elternbeirat.Web.Shared;
namespace Elternbeirat.Web.Features.Home;
/// <summary>
/// Chooses the icon of a home page tile from the page's slug.
/// </summary>
/// <remarks>
/// Pure presentation, so it lives in code: the schema has no icon field, and adding
/// one needs a schema change that cannot reach production yet. A page without an
/// entry here (e.g. one an editor just created) gets <see cref="Default"/>, so a
/// new page always shows up as a complete tile.
/// </remarks>
/// <seealso cref="IconSet"/>
public static class TileIcons
{
/// <summary>
/// The icon for a slug without its own entry: a generic page.
/// </summary>
public const string Default = "file-text";
/// <summary>
/// Icon name per slug. Every value must be a name in <see cref="IconSet"/>.
/// </summary>
private static readonly FrozenDictionary<string, string> Icons =
new Dictionary<string, string>
{
["board"] = "users",
["patrons"] = "heart-handshake",
["faqs"] = "help-circle",
["downloads"] = "download",
["posts"] = "newspaper",
["events"] = "calendar",
["contact"] = "mail",
}.ToFrozenDictionary(StringComparer.Ordinal);
/// <summary>
/// Returns the icon name for a page.
/// </summary>
/// <param name="slug">The page's slug, e.g. <c>board</c>.</param>
/// <returns>An icon name from <see cref="IconSet"/>; <see cref="Default"/> for an unknown slug.</returns>
public static string For(string slug) =>
Icons.GetValueOrDefault(slug, Default);
}
@@ -0,0 +1,35 @@
namespace Elternbeirat.Web.Features.Layout;
/// <summary>
/// Works out the year shown in the footer's copyright line.
/// </summary>
/// <remarks>
/// The container runs on UTC, but visitors live in Germany. Taking the year
/// straight from the server clock would show the old year for the first hour
/// (two in summer time) of every new year, so the instant is converted to
/// Europe/Berlin first.
/// </remarks>
public static class FooterYear
{
/// <summary>
/// IANA id; resolves on every platform .NET supports, Windows included.
/// </summary>
private static readonly TimeZoneInfo Berlin =
TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
/// <summary>
/// Returns the calendar year in Berlin at the given instant.
/// </summary>
/// <param name="now">
/// The current instant, in any offset; usually
/// <see cref="TimeProvider.GetUtcNow"/>.
/// </param>
/// <returns>The year a visitor in Germany would see on their calendar.</returns>
/// <example>
/// <code>
/// FooterYear.For(new DateTimeOffset(2026, 12, 31, 23, 30, 0, TimeSpan.Zero)); // 2027
/// </code>
/// </example>
public static int For(DateTimeOffset now) =>
TimeZoneInfo.ConvertTime(now, Berlin).Year;
}
+37 -13
View File
@@ -3,29 +3,53 @@ using Elternbeirat.Contracts;
namespace Elternbeirat.Web.Features.Layout; namespace Elternbeirat.Web.Features.Layout;
/// <summary> /// <summary>
/// Builds the header and footer navigation from the public pages. Pages carry a /// Builds the header and footer navigation from the public pages. Every page
/// <see cref="Page.Location"/> ("header"/"footer"/"") and an <see cref="Page.Order"/>; /// carries a required <see cref="Page.Location"/> ("header" or "footer") and an
/// pages without a location are sub-pages and never appear in a menu. /// <see cref="Page.Order"/>; this splits them into the two menus, each ordered.
/// </summary> /// </summary>
public static class NavBuilder public static class NavBuilder
{ {
/// <summary>Header links, ordered, built from pages with location "header".</summary> /// <summary>Header links, ordered, built from pages with location "header".</summary>
public static IReadOnlyList<NavLinkItem> Header(IReadOnlyList<Page> pages) => public static IReadOnlyList<NavLinkItem> Header(IReadOnlyList<Page> pages) =>
Build(pages, "header"); ToLinks(PagesAt(pages, "header"));
/// <summary>Footer links, ordered, built from pages with location "footer".</summary> /// <summary>Footer links, ordered, built from pages with location "footer".</summary>
public static IReadOnlyList<NavLinkItem> Footer(IReadOnlyList<Page> pages) => public static IReadOnlyList<NavLinkItem> Footer(IReadOnlyList<Page> pages) =>
Build(pages, "footer"); ToLinks(PagesAt(pages, "footer"));
private static List<NavLinkItem> Build(IReadOnlyList<Page> pages, string location) => /// <summary>
/// Returns the pages of the header menu themselves, in menu order.
/// </summary>
/// <param name="pages">All public pages.</param>
/// <returns>The pages with location <c>header</c>, ordered by <see cref="Page.Order"/>.</returns>
/// <remarks>
/// For callers that need more than a link, such as the tiles on the home page,
/// which also show part of each page's body. Using the same selection as
/// <see cref="Header"/> keeps tiles and menu in step.
/// </remarks>
public static IReadOnlyList<Page> HeaderPages(IReadOnlyList<Page> pages) =>
PagesAt(pages, "header");
/// <summary>
/// Returns the URL a page is reached at.
/// </summary>
/// <param name="slug">The page's slug.</param>
/// <returns><c>/</c> for the home page, otherwise <c>/{slug}</c>.</returns>
/// <remarks>
/// The home page owns "/" (served by the Home component); every other slug is
/// reached at "/{slug}", where either a literal route or the catch-all content
/// page renders it.
/// </remarks>
public static string HrefFor(string slug) =>
Page.IsHomeSlug(slug) ? "/" : $"/{slug}";
private static List<Page> PagesAt(
IReadOnlyList<Page> pages,
string location) =>
[.. pages [.. pages
.Where(page => string.Equals(page.Location, location, StringComparison.Ordinal)) .Where(page => string.Equals(page.Location, location, StringComparison.Ordinal))
.OrderBy(page => page.Order) .OrderBy(page => page.Order)];
.Select(page => new NavLinkItem(HrefFor(page.Slug), page.Title))];
// The home page owns "/" (served by the Home component); every other slug is private static List<NavLinkItem> ToLinks(IEnumerable<Page> pages) =>
// reached at "/{slug}", where either a literal route or the catch-all content [.. pages.Select(page => new NavLinkItem(HrefFor(page.Slug), page.Title))];
// page renders it.
private static string HrefFor(string slug) =>
string.Equals(slug, "home", StringComparison.Ordinal) ? "/" : $"/{slug}";
} }
@@ -0,0 +1,17 @@
@*
The site's own small mark: a speech bubble (the board speaks for the parents)
holding a grown-up and a child. Inline SVG, so there is no extra request and
nothing from a third-party host. Decorative only -- the name next to it carries
the meaning, so it is hidden from screen readers. Colours come from tokens in
Signet.razor.css; the size is set by whoever places it (MainLayout.razor.css).
*@
<svg class="signet" viewBox="0 0 40 40" width="40" height="40" aria-hidden="true" focusable="false">
<path class="signet-bubble"
d="M8 4h24a6 6 0 0 1 6 6v15a6 6 0 0 1-6 6H18l-8 6v-6H8a6 6 0 0 1-6-6V10a6 6 0 0 1 6-6z" />
<g class="signet-figures">
<circle cx="15" cy="12" r="3.5" />
<path d="M9 26c0-5 2.7-8 6-8s6 3 6 8z" />
<circle cx="26" cy="16" r="2.6" />
<path d="M21.5 26c0-3.8 2-6 4.5-6s4.5 2.2 4.5 6z" />
</g>
</svg>
@@ -0,0 +1,11 @@
/* A white bubble with green figures. It sits on the header gradient and on the dark
footer, both of which always carry white text, so --color-on-header is right in
both places. The figures use the darker green so they stay distinct on the white
bubble (the lighter --color-accent would wash out). */
.signet-bubble {
fill: var(--color-on-header);
}
.signet-figures {
fill: var(--color-accent-dark);
}
@@ -1,14 +1,16 @@
@page "/{Slug}" @page "/{Slug}"
@if (_page is null) @if (_unavailable)
{ {
<PageTitle>Nicht gefunden</PageTitle> <PageTitle>Vorübergehend nicht erreichbar</PageTitle>
<p>Die Seite ist gerade nicht erreichbar. Bitte später erneut versuchen.</p>
} }
else else if (_page is not null)
{ {
<PageTitle>@_page.Title</PageTitle> <PageTitle>@_page.Title</PageTitle>
<article> <h1>@_page.Title</h1>
@Body <article class="markdown-body">@Body</article>
</article>
<PageEmbeds Embed="_page.Embed" /> <PageEmbeds Embed="_page.Embed" />
} }
@* No page and not unavailable: the slug was unknown; NavigationManager.NotFound()
already ran in OnParametersSetAsync and renders the not-found page. *@
@@ -2,29 +2,41 @@ using Elternbeirat.Contracts;
using Elternbeirat.PocketBase; using Elternbeirat.PocketBase;
using Elternbeirat.Web.Shared; using Elternbeirat.Web.Shared;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Http;
namespace Elternbeirat.Web.Features.Pages; namespace Elternbeirat.Web.Features.Pages;
public partial class ContentPage public partial class ContentPage
{ {
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load page '{slug}' from PocketBase.")] [LoggerMessage(
private static partial void LogLoadFailed(ILogger logger, string slug, Exception exception); Level = LogLevel.Error,
Message = "Could not load page '{Slug}' from PocketBase; content source is unavailable.")]
private static partial void LogSourceUnavailable(
ILogger logger,
string slug,
Exception exception);
[Parameter] [Parameter]
public string Slug { get; set; } = ""; public string Slug { get; set; } = string.Empty;
[Inject] [Inject]
private PocketBaseClient PocketBase { get; set; } = default!; private PocketBaseClient PocketBase { get; set; } = null!;
[Inject] [Inject]
private ILogger<ContentPage> Logger { get; set; } = default!; private ILogger<ContentPage> Logger { get; set; } = null!;
[Inject]
private NavigationManager Navigation { get; set; } = null!;
[CascadingParameter] [CascadingParameter]
private HttpContext? HttpContext { get; set; } private HttpContext? HttpContext { get; set; }
private Page? _page; private Page? _page;
// Set when the content source could not be reached or read. The markup then
// shows a "temporarily unavailable" note instead of a not-found page, matching
// the 503 this sets on the response.
private bool _unavailable;
// The page body rendered from Markdown to HTML, ready to emit as a // The page body rendered from Markdown to HTML, ready to emit as a
// MarkupString in the markup. // MarkupString in the markup.
private MarkupString Body => (MarkupString)Markdown.ToHtml(_page?.Body); private MarkupString Body => (MarkupString)Markdown.ToHtml(_page?.Body);
@@ -33,24 +45,23 @@ public partial class ContentPage
{ {
try try
{ {
var pages = await PocketBase.GetPagesAsync(); var pages = await PocketBase.GetPagesAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
_page = pages.FirstOrDefault( _page = pages.FirstOrDefault(
p => string.Equals(p.Slug, Slug, StringComparison.OrdinalIgnoreCase)); page => string.Equals(page.Slug, Slug, StringComparison.OrdinalIgnoreCase));
} }
catch (HttpRequestException ex) catch (PocketBaseUnavailableException exception)
{ {
// Source unreachable -> treat as "not found" rather than a 500. The // The site works, its data store is down: 503, not 404.
// 404 branch below serves the friendly /not-found page. LogSourceUnavailable(Logger, Slug, exception);
LogLoadFailed(Logger, Slug, ex); _unavailable = true;
_page = null; SourceUnavailable.SetStatus(HttpContext);
return;
} }
// Unknown slug (or unreachable source) -> 404, so if (_page is null)
// UseStatusCodePagesWithReExecute serves the /not-found page instead of an
// empty 200 response.
if (_page is null && HttpContext is not null)
{ {
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound; // The source answered, but there is no page with this slug: a genuine 404.
Navigation.NotFound();
} }
} }
} }
@@ -0,0 +1,11 @@
/* A content page is one reading column: its text, boxes and card grids all stop at
42rem (app.css). Left-aligned in the 72rem layout that column left a wide empty
strip on the right of a desktop screen; centred, the empty space splits evenly and
the page sits balanced under the header. Title and body share the column, so they
keep one left edge. The children inside keep margin 0 and simply fill it. */
h1,
article,
p {
max-width: 42rem;
margin-inline: auto;
}
@@ -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.
*@
<a class="@CssClass" href="@Href">
<span class="post-date"><Icon Name="calendar" Size="16" /><PostDate Value="Value.Date" /></span>
<span class="post-title">@Value.Title</span>
@if (Teaser.Length > 0)
{
<span class="post-teaser">@Teaser</span>
}
<span class="post-more">Weiterlesen <Icon Name="arrow-right" Size="16" /></span>
</a>
@@ -0,0 +1,46 @@
using Elternbeirat.Contracts;
using Elternbeirat.Web.Shared;
using Microsoft.AspNetCore.Components;
namespace Elternbeirat.Web.Features.Posts;
/// <summary>
/// Renders one post as a card linking to the post, with date, title and teaser.
/// </summary>
/// <remarks>
/// Shared by the posts page and the home-page posts embed, so both show posts the
/// same way. The teaser comes from the body (<see cref="Markdown.Teaser"/>): posts
/// have no summary field of their own.
/// </remarks>
/// <seealso cref="PostDate"/>
public partial class PostCard
{
/// <summary>
/// The post to show.
/// </summary>
[Parameter]
[EditorRequired]
public Post Value { get; set; } = null!;
/// <summary>
/// <see langword="true"/> for the large card that opens the posts page (the
/// newest post); <see langword="false"/> for a regular card.
/// </summary>
[Parameter]
public bool Featured { get; set; }
/// <summary>
/// The card's classes; the featured card adds its modifier.
/// </summary>
private string CssClass => Featured ? "post-card post-card-featured" : "post-card";
/// <summary>
/// The address of the post.
/// </summary>
private string Href => $"/posts/{Value.Slug}";
/// <summary>
/// The teaser text; empty if the body has no paragraph text.
/// </summary>
private string Teaser => Markdown.Teaser(Value.Body);
}
@@ -0,0 +1,88 @@
/* 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);
}
/* 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;
}
@@ -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);
}
@@ -1,19 +1,46 @@
@page "/posts/{Slug}" @page "/posts/{Slug}"
@if (_post is null) @if (_unavailable)
{ {
<PageTitle>Nicht gefunden</PageTitle> <PageTitle>Vorübergehend nicht erreichbar</PageTitle>
<p>Der Beitrag ist gerade nicht erreichbar. Bitte später erneut versuchen.</p>
} }
else else if (_post is not null)
{ {
<PageTitle>@_post.Title</PageTitle> <PageTitle>@_post.Title</PageTitle>
<article> @* A plain link, not a <nav>: one link is no navigation block, and an unlabelled
<p class="post-meta"> second <nav> next to the main menu would only add noise for screen readers. *@
<a href="/posts">&larr; Alle Beiträge</a> <a class="btn btn-quiet" href="/posts"><Icon Name="arrow-left" Size="18" />Alle Beiträge</a>
<time datetime="@_post.Date.ToString("yyyy-MM-dd")">
@_post.Date.ToString("d. MMMM yyyy", German) <article class="post">
</time> <header class="post-header">
</p> <p class="post-meta"><Icon Name="calendar" Size="16" /><PostDate Value="_post.Date" /></p>
@Body <h1>@_post.Title</h1>
</header>
<div class="markdown-body post-body">@Body</div>
</article> </article>
@if (_neighbors.Previous is not null || _neighbors.Next is not null)
{
@* Older post on the left, newer on the right, like a timeline. A missing side
leaves its cell empty, so the remaining link keeps its place. *@
<nav class="post-pager" aria-label="Weitere Beiträge">
@if (_neighbors.Previous is { } previous)
{
<a class="post-pager-link post-pager-previous" href="/posts/@previous.Slug" rel="prev">
<span class="post-pager-label"><Icon Name="arrow-left" Size="16" />Vorheriger Beitrag</span>
<span class="post-pager-title">@previous.Title</span>
</a>
}
@if (_neighbors.Next is { } next)
{
<a class="post-pager-link post-pager-next" href="/posts/@next.Slug" rel="next">
<span class="post-pager-label">Nächster Beitrag<Icon Name="arrow-right" Size="16" /></span>
<span class="post-pager-title">@next.Title</span>
</a>
}
</nav>
}
} }
@* No post and not unavailable: the slug was unknown; NavigationManager.NotFound()
already ran in OnParametersSetAsync and renders the not-found page. *@
@@ -1,33 +1,46 @@
using System.Globalization;
using Elternbeirat.Contracts; using Elternbeirat.Contracts;
using Elternbeirat.PocketBase; using Elternbeirat.PocketBase;
using Elternbeirat.Web.Shared; using Elternbeirat.Web.Shared;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Http;
namespace Elternbeirat.Web.Features.Posts; namespace Elternbeirat.Web.Features.Posts;
public partial class PostDetail public partial class PostDetail
{ {
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); [LoggerMessage(
Level = LogLevel.Error,
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load post '{slug}' from PocketBase.")] Message = "Could not load post '{Slug}' from PocketBase; content source is unavailable.")]
private static partial void LogLoadFailed(ILogger logger, string slug, Exception exception); private static partial void LogSourceUnavailable(
ILogger logger,
string slug,
Exception exception);
[Parameter] [Parameter]
public string Slug { get; set; } = ""; public string Slug { get; set; } = string.Empty;
[Inject] [Inject]
private PocketBaseClient PocketBase { get; set; } = default!; private PocketBaseClient PocketBase { get; set; } = null!;
[Inject] [Inject]
private ILogger<PostDetail> Logger { get; set; } = default!; private ILogger<PostDetail> Logger { get; set; } = null!;
[Inject]
private NavigationManager Navigation { get; set; } = null!;
[CascadingParameter] [CascadingParameter]
private HttpContext? HttpContext { get; set; } private HttpContext? HttpContext { get; set; }
private Post? _post; private Post? _post;
// The older and newer post for the "Vorheriger / Nächster Beitrag" links at the
// end; both empty until loaded or when the post stands alone.
private PostNeighbors _neighbors = new(null, null);
// Set when the content source could not be reached or read. The markup then
// shows a "temporarily unavailable" note instead of a not-found page, matching
// the 503 this sets on the response.
private bool _unavailable;
// The post body rendered from Markdown to HTML, ready to emit as a // The post body rendered from Markdown to HTML, ready to emit as a
// MarkupString in the markup. // MarkupString in the markup.
private MarkupString Body => (MarkupString)Markdown.ToHtml(_post?.Body); private MarkupString Body => (MarkupString)Markdown.ToHtml(_post?.Body);
@@ -36,24 +49,24 @@ public partial class PostDetail
{ {
try try
{ {
var posts = await PocketBase.GetPostsAsync(); var posts = await PocketBase.GetPostsAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
_post = posts.FirstOrDefault( _post = posts.FirstOrDefault(
p => string.Equals(p.Slug, Slug, StringComparison.OrdinalIgnoreCase)); post => string.Equals(post.Slug, Slug, StringComparison.OrdinalIgnoreCase));
_neighbors = PostNeighbors.Of(posts, Slug);
} }
catch (HttpRequestException ex) catch (PocketBaseUnavailableException exception)
{ {
// Source unreachable -> treat as "not found" rather than a 500. The // The site works, its data store is down: 503, not 404.
// 404 branch below serves the friendly /not-found page. LogSourceUnavailable(Logger, Slug, exception);
LogLoadFailed(Logger, Slug, ex); _unavailable = true;
_post = null; SourceUnavailable.SetStatus(HttpContext);
return;
} }
// Unknown slug (or unreachable source) -> 404, so if (_post is null)
// UseStatusCodePagesWithReExecute serves the /not-found page instead of an
// empty 200 response.
if (_post is null && HttpContext is not null)
{ {
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound; // The source answered, but there is no post with this slug: a genuine 404.
Navigation.NotFound();
} }
} }
} }
@@ -1,13 +1,116 @@
.post-meta { /* The article: the site's centred 42rem reading column (as on a content page,
display: flex; ContentPage.razor.css) with generous leading, so a long post reads like a page,
justify-content: space-between; not like a form. Everything inside follows this width -- the global 42rem cap on
align-items: baseline; paragraphs, lists and boxes is lifted here, so nothing stops short of the column
gap: 1rem; (the post's own leading makes the text a touch wider than elsewhere). */
margin-bottom: 1rem; .post {
max-width: 42rem;
margin: var(--space-4) auto 0;
line-height: 1.75;
} }
.post-meta time { /* The back link is an inline button, which margin: auto cannot centre. A left
display: block; margin of half the free space puts it on the column's left edge instead, and
color: var(--color-text-soft); /* token, so it adapts in dark mode */ max() keeps it at 0 once the screen is narrower than the column. */
.btn-quiet {
margin-left: max(0px, (100% - 42rem) / 2);
}
.post ::deep :is(p, ul, ol, blockquote, .aufruf, .hinweis) {
max-width: none;
}
/* Date above the title as a quiet meta line, with the same calendar icon as on the
post cards. The title follows directly, so the gap between them stays small
(h1 carries no top margin globally). */
.post-meta {
display: flex;
align-items: center;
gap: var(--space-1);
margin: 0 0 var(--space-2);
color: var(--color-text-soft);
font-size: 0.95rem;
font-weight: 600;
}
.post-meta ::deep .icon {
color: var(--color-accent-dark);
}
/* The first paragraph as the lead: larger and in the soft tone, so it reads as an
introduction. Pure CSS on the editor's Markdown -- if a post starts with a
heading or a list instead, there simply is no lead. Same text colour token as
the rest, so it keeps AA contrast in both schemes. */
.post-body ::deep > p:first-child {
color: var(--color-text-soft);
font-size: 1.2rem;
line-height: 1.65;
}
/* Older/newer post at the end: two cards side by side, older on the left, newer on
the right. The grid keeps each in its column even if the other is missing. Hover
= border and shadow, nothing moves. */
.post-pager {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
max-width: 42rem;
margin: var(--space-6) auto 0;
padding-top: var(--space-4);
border-top: 1px solid var(--color-border);
}
.post-pager-link {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-bg);
color: var(--color-text);
text-decoration: none;
transition: box-shadow var(--transition), border-color var(--transition);
}
.post-pager-link:hover {
border-color: var(--color-brand);
box-shadow: var(--shadow-hover);
color: var(--color-text);
}
.post-pager-next {
grid-column: 2;
text-align: right;
}
.post-pager-label {
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--color-text-soft);
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 700;
}
.post-pager-next .post-pager-label {
align-self: flex-end;
}
.post-pager-title {
color: var(--color-brand);
font-weight: 700;
line-height: 1.3;
}
/* On a phone the two cards stack: a half-width card would squeeze a long title
into a narrow column. Older first, as in the wide layout. */
@media (max-width: 40rem) {
.post-pager {
grid-template-columns: 1fr;
}
.post-pager-next {
grid-column: 1;
}
} }
+23 -11
View File
@@ -1,24 +1,36 @@
@page "/posts" @page "/posts"
<PageTitle>Beiträge</PageTitle> @{
// Heading and intro come from the "posts" page record; fall back to a sensible
// default only if that record is missing, so the page still renders.
var title = _page?.Title ?? "Beiträge";
}
<h1>Beiträge</h1> <PageTitle>@title</PageTitle>
@if (_posts.Count == 0) <h1>@title</h1>
@if (_page is not null && !string.IsNullOrWhiteSpace(_page.Body))
{
<article class="markdown-body">@Body</article>
}
@if (_unavailable)
{
<p>Die Beiträge sind gerade nicht erreichbar. Bitte später erneut versuchen.</p>
}
else if (_posts.Count == 0)
{ {
<p>Zurzeit gibt es keine Beiträge.</p> <p>Zurzeit gibt es keine Beiträge.</p>
} }
else else
{ {
<ul class="post-list"> @* The newest post (GetPostsAsync sorts newest first) opens the page as a large
@foreach (var post in _posts) card across the full width; the rest follow in the grid. *@
<ul class="post-list post-list-grid">
@for (var i = 0; i < _posts.Count; i++)
{ {
<li> <li class="@(i == 0 ? "post-list-featured" : null)"><PostCard Value="_posts[i]" Featured="i == 0" /></li>
<a href="@($"/posts/{post.Slug}")">@post.Title</a>
<time datetime="@post.Date.ToString("yyyy-MM-dd")">
@post.Date.ToString("d. MMMM yyyy", German)
</time>
</li>
} }
</ul> </ul>
} }
@@ -1,36 +1,60 @@
using System.Globalization;
using Elternbeirat.Contracts; using Elternbeirat.Contracts;
using Elternbeirat.PocketBase; using Elternbeirat.PocketBase;
using Elternbeirat.Web.Shared;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
namespace Elternbeirat.Web.Features.Posts; namespace Elternbeirat.Web.Features.Posts;
public partial class PostList public partial class PostList
{ {
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load posts from PocketBase.")] [LoggerMessage(
private static partial void LogLoadFailed(ILogger logger, Exception exception); Level = LogLevel.Error,
Message = "Could not load posts from PocketBase; content source is unavailable.")]
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); private static partial void LogSourceUnavailable(
ILogger logger,
Exception exception);
[Inject] [Inject]
private PocketBaseClient PocketBase { get; set; } = default!; private PocketBaseClient PocketBase { get; set; } = null!;
[Inject] [Inject]
private ILogger<PostList> Logger { get; set; } = default!; private ILogger<PostList> Logger { get; set; } = null!;
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
// The "posts" page record supplies the heading and intro text, so an editor owns
// them in PocketBase like every other page, instead of them being hard-coded here.
// Null until loaded, or when no such page exists.
private Page? _page;
private IReadOnlyList<Post> _posts = []; private IReadOnlyList<Post> _posts = [];
// Set when the content source could not be reached or read. The markup then
// shows a "temporarily unavailable" note instead of "no posts", matching the
// 503 this sets on the response.
private bool _unavailable;
// The page body rendered from Markdown to HTML, ready to emit as a MarkupString.
private MarkupString Body => (MarkupString)Markdown.ToHtml(_page?.Body);
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
try try
{ {
_posts = await PocketBase.GetPostsAsync(); var token = HttpContext?.RequestAborted ?? CancellationToken.None;
var pages = await PocketBase.GetPagesAsync(token);
_page = pages.FirstOrDefault(
page => string.Equals(page.Slug, "posts", StringComparison.OrdinalIgnoreCase));
_posts = await PocketBase.GetPostsAsync(token);
} }
catch (HttpRequestException ex) catch (PocketBaseUnavailableException exception)
{ {
// Source unreachable -> show an empty list instead of a 500. The page // An empty list would claim there are no posts. The site works, its
// stays up; the "keine Beiträge" note is rendered. // data store is down: 503 and an honest note.
LogLoadFailed(Logger, ex); LogSourceUnavailable(Logger, exception);
_unavailable = true;
SourceUnavailable.SetStatus(HttpContext);
} }
} }
} }
@@ -1,2 +1,29 @@
/* The .post-list card styles are shared with the home-page embed and live in the /* The card is PostCard and the base list grid lives in the global app.css, shared
global app.css. This page needs no extra styling of its own. */ with the home-page embed. Only the page's two-column layout is here: one column
on a phone, two from tablet width, with the newest post across both. */
@media (min-width: 48rem) {
.post-list-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.post-list-featured {
grid-column: 1 / -1;
}
}
/* Same centred 42rem reading column as a content page (ContentPage.razor.css), so
every page of the site shares one column under the header. Two cards still fit
side by side in it. */
h1,
article,
p,
.post-list {
max-width: 42rem;
margin-inline: auto;
}
/* The intro's last paragraph drops its margin (app.css), so the first card would
sit right under the text; this gives it the same air as between sections. */
article {
margin-bottom: var(--space-5);
}
@@ -0,0 +1,65 @@
using Elternbeirat.Contracts;
namespace Elternbeirat.Web.Features.Posts;
/// <summary>
/// The posts next to a given post in time: the one published before it and the
/// one published after it.
/// </summary>
/// <param name="Previous">
/// The older post, or <see langword="null"/> if the post is the oldest.
/// </param>
/// <param name="Next">
/// The newer post, or <see langword="null"/> if the post is the newest.
/// </param>
/// <remarks>
/// "Previous" and "next" follow time, as a reader moving through the news would:
/// "Vorheriger Beitrag" is the older one, "Nächster Beitrag" the newer one. The
/// list order of the posts page (newest first) is the other way round, which is
/// why the direction is pinned down here instead of being left to the markup.
/// </remarks>
/// <seealso cref="PostDetail"/>
public sealed record PostNeighbors(Post? Previous, Post? Next)
{
/// <summary>
/// Finds the neighbours of the post with the given slug.
/// </summary>
/// <param name="posts">
/// All public posts, newest first, as <c>PocketBaseClient.GetPostsAsync</c>
/// returns them.
/// </param>
/// <param name="slug">The slug of the post being shown.</param>
/// <returns>
/// The older and the newer post. Both are <see langword="null"/> if the slug is
/// not in the list, or if it is the only post.
/// </returns>
/// <remarks>
/// The slug is compared case-insensitively, like the route lookup in
/// <see cref="PostDetail"/>, so a page reached as <c>/posts/New-Board</c> still
/// gets its neighbours.
/// </remarks>
/// <example>
/// <code>
/// // posts = [March, February, January] (newest first)
/// var neighbors = PostNeighbors.Of(posts, "february");
/// // neighbors.Previous is January, neighbors.Next is March
/// </code>
/// </example>
public static PostNeighbors Of(IReadOnlyList<Post> posts, string slug) =>
IndexOf(posts, slug) is var index and >= 0
? new PostNeighbors(
Previous: index + 1 < posts.Count ? posts[index + 1] : null,
Next: index > 0 ? posts[index - 1] : null)
: new PostNeighbors(null, null);
/// <summary>
/// The position of the post with <paramref name="slug"/>, or <c>-1</c>.
/// </summary>
private static int IndexOf(IReadOnlyList<Post> posts, string slug) =>
posts
.Select((post, index) => (post, index))
.FirstOrDefault(
entry => string.Equals(entry.post.Slug, slug, StringComparison.OrdinalIgnoreCase),
(null!, -1))
.index;
}
+104 -28
View File
@@ -2,61 +2,103 @@ using Elternbeirat.Contracts;
using Elternbeirat.PocketBase; using Elternbeirat.PocketBase;
using Elternbeirat.Web.Components; using Elternbeirat.Web.Components;
using Elternbeirat.Web.Features.Events; using Elternbeirat.Web.Features.Events;
using Elternbeirat.Web.Shared;
using Microsoft.AspNetCore.Components.Endpoints;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorComponents(); builder.Services.AddRazorComponents();
// "Today" for upcoming events comes from this clock, not DateTime.Today, so the
// smoke tests can swap in a fixed date and check the pages before and after an event.
builder.Services.AddSingleton(TimeProvider.System);
// The typed client reads content from PocketBase over its REST API. The base URL // The typed client reads content from PocketBase over its REST API. The base URL
// comes from configuration: appsettings for local dev, the PocketBase__BaseUrl // comes from config: appsettings for local dev, the PocketBase__BaseUrl env var
// env var on the server (see compose.yaml). // on the server (see compose.yaml).
var pocketBaseUrl = builder.Configuration["PocketBase:BaseUrl"] var pocketBaseUrl = builder.Configuration["PocketBase:BaseUrl"]
?? throw new InvalidOperationException("PocketBase:BaseUrl is not configured."); ?? throw new InvalidOperationException("PocketBase:BaseUrl is not configured.");
builder.Services.AddHttpClient<PocketBaseClient>(client => builder.Services.AddHttpClient<PocketBaseClient>(client =>
client.BaseAddress = new Uri(pocketBaseUrl)); {
client.BaseAddress = new Uri(pocketBaseUrl);
// Every page waits for PocketBase. The default wait is 100 seconds, so if
// PocketBase is down the page would hang that long. 5 seconds fails fast instead.
client.Timeout = TimeSpan.FromSeconds(5);
});
// Shared by all requests, so the maintenance gate below asks PocketBase at most once
// every few seconds rather than on every page view.
builder.Services.AddSingleton<PocketBaseHealthCache>();
var app = builder.Build(); var app = builder.Build();
// NPM terminates TLS and is the only way to reach the container (no port // NPM sits in front of the app and handles HTTPS. Without this line the app would
// mapping in production). Without UseForwardedHeaders the app sees every request // think every request is plain HTTP and would see NPM's IP, not the visitor's.
// as HTTP and with the proxy IP instead of the client IP. // KnownNetworks/KnownProxies are left empty on purpose: only NPM reaches the app.
// KnownNetworks/KnownProxies are deliberately empty because only NPM reaches app.UseForwardedHeaders(
// the container. new ForwardedHeadersOptions
app.UseForwardedHeaders(new ForwardedHeadersOptions {
{ ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, });
KnownIPNetworks = { },
KnownProxies = { }
});
// Configure the HTTP request pipeline. if (app.Environment.IsDevelopment() is false)
if (!app.Environment.IsDevelopment())
{ {
app.UseExceptionHandler("/Error", createScopeForErrors: true); app.UseExceptionHandler("/Error", createScopeForErrors: true);
// No UseHsts() and no UseHttpsRedirection(): NPM sets HSTS and terminates // No UseHsts() and no UseHttpsRedirection() here: NPM already handles HTTPS.
// TLS. Both here would create a redirect loop behind the proxy. // Adding them behind NPM would send the browser into a redirect loop.
} }
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); // Don't add UseStatusCodePagesWithReExecute here. Blazor already shows its own
// not-found page. That middleware would catch *every* error code, so when PocketBase
// is down (a 503) it would wrongly show "not found" instead of our own message.
app.UseAntiforgery(); app.UseAntiforgery();
// Maintenance gate: while PocketBase is down, every page gets the static
// maintenance.html with 503 instead of a half-empty page (the menu comes from
// PocketBase too). Routing has already picked the endpoint at this point, so the gate
// covers exactly the Razor component pages: /health, the calendar feeds, the static
// assets (incl. maintenance.html and its font) are not components and answer for
// themselves. A new endpoint is thus left alone unless it is a page. The health
// result is cached (PocketBaseHealthCache), so the page may come back a few seconds
// after PocketBase does. Retry-After tells well-behaved clients and crawlers when to
// come back; no-store keeps the 503 page out of any cache.
app.Use(async (context, next) =>
{
var isPage = context.GetEndpoint()?.Metadata.GetMetadata<ComponentTypeMetadata>() is not null;
var services = context.RequestServices;
if (!isPage
|| await services.GetRequiredService<PocketBaseHealthCache>().IsHealthyAsync(
services.GetRequiredService<PocketBaseClient>().IsHealthyAsync,
context.RequestAborted))
{
await next(context);
return;
}
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
context.Response.Headers.RetryAfter = "60";
context.Response.Headers.CacheControl = "no-store";
context.Response.ContentType = "text/html; charset=utf-8";
await context.Response.SendFileAsync(
app.Environment.WebRootFileProvider.GetFileInfo("maintenance.html"),
context.RequestAborted);
});
app.MapStaticAssets(); app.MapStaticAssets();
app.MapRazorComponents<App>(); app.MapRazorComponents<App>();
// Subscribable calendar feed of all events. A minimal API endpoint rather than // Calendar feed people can subscribe to. It returns calendar data, not a web page,
// a Razor page because it returns text/calendar, not HTML. Reads from PocketBase // so it is a plain endpoint, not a Razor page. If PocketBase is down it returns an
// per request; an unreachable source yields an empty calendar, not a 500. // empty calendar rather than an error.
app.MapGet("/events.ics", async (PocketBaseClient pocketBase) => app.MapGet("/events.ics", async (PocketBaseClient pocketBase, CancellationToken token) =>
{ {
IReadOnlyList<Event> events; IReadOnlyList<Event> events;
try try
{ {
events = await pocketBase.GetEventsAsync(); events = await pocketBase.GetEventsAsync(token);
} }
catch (HttpRequestException) catch (PocketBaseUnavailableException)
{ {
events = []; events = [];
} }
@@ -64,4 +106,38 @@ app.MapGet("/events.ics", async (PocketBaseClient pocketBase) =>
return Results.Text(IcsCalendar.Build(events), "text/calendar; charset=utf-8"); return Results.Text(IcsCalendar.Build(events), "text/calendar; charset=utf-8");
}); });
// One event as a calendar file ("In Kalender übernehmen" on the events page): the
// same calendar as the feed, holding just this event. Unlike the feed it does not
// fall back to an empty calendar when PocketBase is down -- a file without the event
// the visitor clicked would be a silent failure, so it answers 503. An unknown or
// non-public id is a 404.
app.MapGet("/events/{id}.ics", async (string id, PocketBaseClient pocketBase, CancellationToken token) =>
{
IReadOnlyList<Event> events;
try
{
events = await pocketBase.GetEventsAsync(token);
}
catch (PocketBaseUnavailableException)
{
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}
return events.FirstOrDefault(@event => @event.Id == id) is { } match
? Results.Text(IcsCalendar.Build([match]), "text/calendar; charset=utf-8")
: Results.NotFound();
});
// Status page for an outside monitor (Uptime Kuma) to check. One check tells all
// three states apart: no reply = the app is down, 503 = the app runs but PocketBase
// is down, 200 = both are fine. It talks to PocketBase directly, not through the
// page-rendering code, so it can still report when that code is the thing failing.
app.MapGet("/health", async (PocketBaseClient pocketBase, CancellationToken token) =>
await pocketBase.IsHealthyAsync(token)
? Results.Text("healthy", "text/plain; charset=utf-8")
: Results.Text(
"PocketBase unreachable",
"text/plain; charset=utf-8",
statusCode: StatusCodes.Status503ServiceUnavailable));
app.Run(); app.Run();
+33
View File
@@ -0,0 +1,33 @@
namespace Elternbeirat.Web.Shared;
/// <summary>
/// Tells the current calendar day as people at the school see it.
/// </summary>
/// <remarks>
/// The container's clock runs in UTC, so <see cref="DateTime.Today"/> would flip to
/// the next day at 01:00 or 02:00 Berlin time, and an event shortly after midnight
/// would count as past for an hour or two. Event times are Berlin wall-clock
/// values (see <c>LocalDateTimeConverter</c>), so "today" has to be a Berlin day
/// too.
/// <para>
/// The time comes from an injected <see cref="TimeProvider"/>, not the system
/// clock, so tests can pin "today" and check what a page shows before and after
/// an event.
/// </para>
/// </remarks>
public static class BerlinClock
{
/// <summary>
/// IANA id; resolves on every platform .NET supports, Windows included.
/// </summary>
private static readonly TimeZoneInfo Berlin =
TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
/// <summary>
/// Returns today's date in Berlin.
/// </summary>
/// <param name="time">The clock to read, usually <see cref="TimeProvider.System"/>.</param>
/// <returns>The current Berlin calendar day.</returns>
public static DateOnly Today(TimeProvider time) =>
DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(time.GetUtcNow(), Berlin).DateTime);
}
+21
View File
@@ -0,0 +1,21 @@
using System.Globalization;
namespace Elternbeirat.Web.Shared;
/// <summary>
/// Shared <see cref="CultureInfo"/> instances used to format visitor-facing
/// text.
/// </summary>
/// <remarks>
/// Kept in one place so every component renders month and weekday names the
/// same way, rather than each holding its own <c>de-DE</c> lookup. This is only
/// for display text; machine-readable output (e.g. an HTML <c>datetime</c>
/// attribute) uses <see cref="CultureInfo.InvariantCulture"/> instead.
/// </remarks>
internal static class Cultures
{
/// <summary>
/// German culture, for the month and weekday names visitors read.
/// </summary>
public static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
}
-48
View File
@@ -1,48 +0,0 @@
using System.Globalization;
using Elternbeirat.Contracts;
namespace Elternbeirat.Web.Shared;
/// <summary>
/// Formats an <see cref="Event"/>'s date range for display in German. Shared by
/// the full event list and the home-page events embed so both render dates the
/// same way.
/// </summary>
public static class EventFormat
{
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
// An event counts as all-day when its start carries no wall-clock time
// (00:00); PocketBase has no separate flag.
private static bool HasTime(Event ev) => ev.Start.TimeOfDay != TimeSpan.Zero;
/// <summary>
/// Formats the entry's date range: a single day, a date with a time, or a
/// span across days.
/// </summary>
public static string Range(Event ev)
{
var start = HasTime(ev)
? ev.Start.ToString("dddd, d. MMMM yyyy, HH:mm", German) + " Uhr"
: ev.Start.ToString("dddd, d. MMMM yyyy", German);
if (ev.End is not { } end)
{
return start;
}
// Same day: append just the end time. Different days: append the full
// end date.
if (end.Date == ev.Start.Date)
{
return HasTime(ev)
? start + "–" + end.ToString("HH:mm", German) + " Uhr"
: start;
}
var endText = HasTime(ev)
? end.ToString("d. MMMM yyyy, HH:mm", German) + " Uhr"
: end.ToString("d. MMMM yyyy", German);
return start + " – " + endText;
}
}
+13
View File
@@ -0,0 +1,13 @@
@*
Renders one icon from IconSet as inline SVG. Purely decorative: aria-hidden hides
it from screen readers (the text next to it carries the meaning) and
focusable="false" keeps old Edge/IE from putting it in the tab order. The stroke
is currentColor, so the icon takes the colour of the surrounding text. An unknown
name renders nothing at all.
*@
@if (_markup is not null)
{
<svg class="icon" width="@Size" height="@Size"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">@_markup</svg>
}
+46
View File
@@ -0,0 +1,46 @@
using Microsoft.AspNetCore.Components;
namespace Elternbeirat.Web.Shared;
/// <summary>
/// Renders a decorative line icon from <see cref="IconSet"/> as inline SVG.
/// </summary>
/// <remarks>
/// Inline SVG, not an <c>&lt;img&gt;</c> or icon font: no extra request, nothing
/// from a third-party host, and the icon follows the text colour (including dark
/// mode) through <c>currentColor</c>. The icon is hidden from assistive technology,
/// so it must never be the only carrier of meaning.
/// </remarks>
/// <example>
/// <code>
/// &lt;Icon Name="map-pin" /&gt; Aula
/// &lt;Icon Name="calendar" Size="16" /&gt;
/// </code>
/// </example>
/// <seealso cref="IconSet"/>
public partial class Icon
{
/// <summary>
/// The inner SVG markup of the current icon, or <see langword="null"/> when the
/// name is unknown and nothing should render.
/// </summary>
private MarkupString? _markup;
/// <summary>
/// The icon name, e.g. <c>calendar</c>; see <see cref="IconSet"/> for the set.
/// </summary>
[Parameter]
[EditorRequired]
public string Name { get; set; } = "";
/// <summary>
/// Width and height in CSS pixels. Defaults to <c>20</c>, which sits well next
/// to body text.
/// </summary>
[Parameter]
public int Size { get; set; } = 20;
/// <inheritdoc/>
protected override void OnParametersSet() =>
_markup = IconSet.Find(Name) is { } markup ? new MarkupString(markup) : null;
}
+86
View File
@@ -0,0 +1,86 @@
using System.Collections.Frozen;
namespace Elternbeirat.Web.Shared;
/// <summary>
/// The fixed set of line icons the site can show, keyed by name.
/// </summary>
/// <remarks>
/// The icons are inlined as SVG markup instead of being loaded from an icon font or
/// a CDN: the site loads nothing from third-party hosts (see <c>docs/recht.md</c>),
/// and inline SVG needs no extra request and inherits the text colour.
/// <para>
/// Source: Lucide (<c>lucide.dev</c>), ISC license; the icons derived from
/// Feather (<c>calendar</c>, <c>clock</c>, <c>download</c>,
/// <c>external-link</c>, <c>help-circle</c>, <c>info</c>, <c>arrow-right</c>,
/// <c>arrow-left</c>)
/// are MIT, Copyright (c) 2013-present Cole Bemis. Both licenses only ask that
/// the notice travels with the copy, which this comment does. Only the inner
/// elements are stored; <see cref="Icon"/> supplies the shared
/// <c>&lt;svg&gt;</c> wrapper (24x24 view box, 2px round stroke).
/// </para>
/// <para>
/// Lucide renamed <c>help-circle</c> to <c>circle-question-mark</c>; the old
/// name is kept here because it is the one the design work refers to.
/// </para>
/// </remarks>
/// <seealso cref="Icon"/>
public static class IconSet
{
/// <summary>
/// Inner SVG markup per icon name. Ordinal keys: names are code identifiers,
/// not visitor text, so <c>Calendar</c> is deliberately not <c>calendar</c>.
/// </summary>
private static readonly FrozenDictionary<string, string> Icons =
new Dictionary<string, string>
{
["calendar"] =
"""<path d="M8 2v3"/><path d="M16 2v3"/><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/>""",
["calendar-plus"] =
"""<path d="M16 18h6"/><path d="M16 2v3"/><path d="M19 15v6"/><path d="M21 11.5V5a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2h8.3"/><path d="M3 9h18"/><path d="M8 2v3"/>""",
["clock"] =
"""<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>""",
["map-pin"] =
"""<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>""",
["file-text"] =
"""<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>""",
["download"] =
"""<path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/>""",
["mail"] =
"""<path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"/><rect x="2" y="4" width="20" height="16" rx="2"/>""",
["users"] =
"""<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><path d="M16 3.128a4 4 0 0 1 0 7.744"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><circle cx="9" cy="7" r="4"/>""",
["heart-handshake"] =
"""<path d="M19.414 14.414C21 12.828 22 11.5 22 9.5a5.5 5.5 0 0 0-9.591-3.676.6.6 0 0 1-.818.001A5.5 5.5 0 0 0 2 9.5c0 2.3 1.5 4 3 5.5l5.535 5.362a2 2 0 0 0 2.879.052 2.12 2.12 0 0 0-.004-3 2.124 2.124 0 1 0 3-3 2.124 2.124 0 0 0 3.004 0 2 2 0 0 0 0-2.828l-1.881-1.882a2.41 2.41 0 0 0-3.409 0l-1.71 1.71a2 2 0 0 1-2.828 0 2 2 0 0 1 0-2.828l2.823-2.762"/>""",
["help-circle"] =
"""<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>""",
["newspaper"] =
"""<path d="M15 18h-5"/><path d="M18 14h-8"/><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"/><rect width="8" height="4" x="10" y="6" rx="1"/>""",
["arrow-left"] =
"""<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>""",
["arrow-right"] =
"""<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>""",
["external-link"] =
"""<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>""",
["info"] =
"""<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>""",
}.ToFrozenDictionary(StringComparer.Ordinal);
/// <summary>
/// Looks up the inner SVG markup of an icon.
/// </summary>
/// <param name="name">
/// The icon name, e.g. <c>calendar</c> or <c>map-pin</c>. Case-sensitive.
/// </param>
/// <returns>
/// The SVG child elements (paths, circles, rects) to place inside an
/// <c>&lt;svg&gt;</c>, or <see langword="null"/> if <paramref name="name"/> is
/// <see langword="null"/> or not part of the set.
/// </returns>
/// <remarks>
/// An unknown name yields <see langword="null"/> rather than an exception, so a
/// typo costs a missing decoration, never a broken page.
/// </remarks>
public static string? Find(string? name) =>
name is null ? null : Icons.GetValueOrDefault(name);
}
+342 -10
View File
@@ -1,23 +1,355 @@
using System.Text;
using System.Text.RegularExpressions;
using Markdig; using Markdig;
using Markdig.Extensions.CustomContainers;
using Markdig.Renderers.Html;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
namespace Elternbeirat.Web.Shared; namespace Elternbeirat.Web.Shared;
/// <summary> /// <summary>
/// Renders Markdown to HTML. Content now comes from PocketBase as raw Markdown /// Renders the editors' Markdown (the <c>body</c>, <c>intro</c> and <c>answer</c>
/// (the <c>body</c> field), so the pages render it at display time instead of /// fields from PocketBase) to the HTML the pages show.
/// reading pre-rendered HTML from files.
/// </summary> /// </summary>
public static class Markdown /// <remarks>
/// The pipeline is CommonMark plus exactly two extensions, each there for a reason
/// an editor can see:
/// <list type="bullet">
/// <item>
/// <description>
/// <b>Custom containers</b> (<c>::: name</c> … <c>:::</c>) turn a block
/// into <c>&lt;div class="name"&gt;</c>. That is how editors use the
/// design blocks (<c>kennzahlen</c>, <c>aufruf</c>, <c>kacheln</c>,
/// <c>hinweis</c>, <c>team</c>) without writing HTML. An unknown name
/// just yields a div without styling, so a typo never breaks a page.
/// </description>
/// </item>
/// <item>
/// <description>
/// <b>Pipe tables</b>: plain CommonMark has no tables at all, so without
/// this the table styles in <c>app.css</c> could never apply.
/// </description>
/// </item>
/// </list>
/// <para>
/// Raw HTML in the Markdown is <b>escaped</b>, not passed through
/// (<see cref="MarkdownPipelineBuilder"/>'s <c>DisableHtml</c>). The result is
/// rendered as a <c>MarkupString</c>, so a passed-through <c>&lt;script&gt;</c>
/// would run in the visitor's browser, and the privacy text promises that no
/// program code runs there. Editors style pages with the blocks instead.
/// </para>
/// <para>
/// After parsing, a paragraph (or list item) that consists of nothing but one
/// link gets the class <see cref="LoneLinkClass"/>. Pure CSS cannot tell
/// "a link alone on its line" from "a link inside a sentence" (selectors ignore
/// the text around an element), but the stylesheet needs exactly that to show a
/// lone <c>mailto:</c> link as a button and a lone PDF link as a file card.
/// </para>
/// <para>
/// The list items of a <c>::: team</c> block become person cards
/// (<see cref="TeamMember"/>): the initials avatar and the split into name,
/// role and duties need the text itself, which CSS cannot read.
/// </para>
/// </remarks>
/// <seealso cref="IconSet"/>
/// <seealso cref="TeamMember"/>
public static partial class Markdown
{ {
private static readonly MarkdownPipeline Pipeline = /// <summary>
new MarkdownPipelineBuilder().Build(); /// The class marking a paragraph or list item whose only content is one link.
/// <c>app.css</c> keys the mail button and the PDF card off it.
/// </summary>
public const string LoneLinkClass = "lone-link";
/// <summary> /// <summary>
/// Converts a Markdown string to HTML. Returns an empty string for /// The default teaser length: about two lines on a card, enough to say what a
/// <c>null</c> or empty input, so callers can bind the result directly. /// post is about without turning the list into a wall of text.
/// </summary> /// </summary>
public const int TeaserLength = 160;
private static readonly MarkdownPipeline Pipeline =
new MarkdownPipelineBuilder()
.UseCustomContainers()
.UsePipeTables()
.DisableHtml()
.Build();
/// <summary>
/// Converts a Markdown string to HTML.
/// </summary>
/// <param name="markdown">The editor's Markdown; may be <see langword="null"/>.</param>
/// <returns>
/// The rendered HTML, or an empty string for <see langword="null"/> or empty
/// input, so callers can bind the result directly.
/// </returns>
/// <example>
/// <code>
/// Markdown.ToHtml("::: hinweis\nBitte vormerken.\n:::");
/// // &lt;div class="hinweis"&gt;&lt;p&gt;Bitte vormerken.&lt;/p&gt;&lt;/div&gt;
/// </code>
/// </example>
public static string ToHtml(string? markdown) => public static string ToHtml(string? markdown) =>
string.IsNullOrEmpty(markdown) string.IsNullOrEmpty(markdown) ? "" : Render(Parse(markdown));
/// <summary>
/// Extracts the first sentence of a Markdown text as plain text, e.g. as the
/// short description on a home page tile.
/// </summary>
/// <param name="markdown">The editor's Markdown; may be <see langword="null"/>.</param>
/// <returns>
/// The first sentence of the first top-level paragraph, with all Markdown
/// removed, whitespace collapsed and a trailing colon dropped, or an empty
/// string when there is no such paragraph (empty body, or only headings, lists
/// and blocks).
/// </returns>
/// <remarks>
/// Only top-level paragraphs count: a heading repeats the title, and the text in
/// a list or a <c>:::</c> block is rarely a sentence that describes the page.
/// <para>
/// A sentence ends at <c>.</c>, <c>!</c> or <c>?</c> followed by a space or
/// the end. A period after a single letter or a number does not count, so
/// <c>z. B.</c> and <c>13. November</c> do not cut the sentence short. A
/// longer abbreviation such as <c>bzw.</c> still does; that costs the rest
/// of a teaser, never the page.
/// </para>
/// <para>
/// A paragraph that leads into a list often ends in <c>:</c> without a
/// period; as a teaser the colon would point at a list that is not there.
/// The sentence is returned whole: a long one is cut by the tile's CSS,
/// which knows the space it has, not by a character count.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// Markdown.FirstSentence("# Vorstand\n\nWir sind **sieben** Eltern. Mehr unten.");
/// // "Wir sind sieben Eltern."
/// </code>
/// </example>
public static string FirstSentence(string? markdown) =>
string.IsNullOrWhiteSpace(markdown)
? "" ? ""
: Markdig.Markdown.ToHtml(markdown, Pipeline); : Parse(markdown).OfType<ParagraphBlock>().FirstOrDefault()?.Inline is { } inline
? UpToSentenceEnd(PlainText(inline)).TrimEnd(':', ' ')
: "";
/// <summary>
/// Builds a short plain-text teaser from a Markdown text, e.g. for a post card.
/// </summary>
/// <param name="markdown">The editor's Markdown; may be <see langword="null"/>.</param>
/// <param name="maxLength">
/// The longest teaser, in characters, before the ellipsis. Defaults to
/// <see cref="TeaserLength"/>.
/// </param>
/// <returns>
/// The text of all paragraphs (including those in lists, quotes and <c>:::</c>
/// blocks), with all Markdown removed and whitespace collapsed. A longer text is
/// cut at the last word boundary within <paramref name="maxLength"/> and ends
/// in <c>…</c>. An empty string when there is no paragraph text at all.
/// </returns>
/// <exception cref="ArgumentOutOfRangeException">
/// <paramref name="maxLength"/> is zero or negative.
/// </exception>
/// <remarks>
/// Headings are skipped: on a card the title already heads the teaser. Unlike
/// <see cref="FirstSentence"/> the teaser does not stop at the first sentence,
/// because a post often opens with a short line ("Liebe Eltern,") that says
/// nothing on its own.
/// <para>
/// Punctuation left dangling at the cut (<c>,</c>, <c>.</c>, <c>:</c> …) is
/// dropped, so the teaser never ends in <c>,…</c>.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// Markdown.Teaser("## Rückblick\n\nDer **Basar** war ein voller Erfolg.", 20);
/// // "Der Basar war ein…"
/// </code>
/// </example>
public static string Teaser(string? markdown, int maxLength = TeaserLength) =>
maxLength <= 0
? throw new ArgumentOutOfRangeException(nameof(maxLength), maxLength, "The teaser needs room for at least one character.")
: string.IsNullOrWhiteSpace(markdown)
? ""
: Shorten(
string.Join(
' ',
Parse(markdown)
.Descendants<ParagraphBlock>()
.Select(paragraph => paragraph.Inline is { } inline ? PlainText(inline) : "")
.Where(text => text.Length > 0)),
maxLength);
/// <summary>
/// Parses Markdown with the site's pipeline, for callers that take the document
/// apart before rendering it (see <see cref="Render"/>).
/// </summary>
/// <param name="markdown">The editor's Markdown.</param>
/// <returns>The parsed document.</returns>
internal static MarkdownDocument Parse(string markdown) =>
Markdig.Markdown.Parse(markdown, Pipeline);
/// <summary>
/// Renders a document from <see cref="Parse"/> to HTML, building the team cards
/// and marking lone links on the way, exactly as <see cref="ToHtml"/> does.
/// </summary>
/// <param name="document">The parsed document; changed in place.</param>
/// <returns>The rendered HTML.</returns>
internal static string Render(MarkdownDocument document) =>
Markdig.Markdown.ToHtml(MarkLoneLinks(BuildTeamCards(document)), Pipeline);
/// <summary>
/// Flattens inline Markdown to the text a reader sees: emphasis and link
/// markup dropped, link text kept, images left out, whitespace collapsed.
/// </summary>
/// <param name="inlines">
/// The inline content, e.g. of a paragraph or heading, or a part of it.
/// </param>
/// <returns>The visible text, trimmed.</returns>
internal static string PlainText(IEnumerable<Inline> inlines) =>
Whitespace().Replace(inlines.Aggregate(new StringBuilder(), AppendText).ToString(), " ").Trim();
/// <summary>
/// Whether an inline carries no visible content (whitespace or a line break).
/// </summary>
/// <param name="inline">The inline to inspect.</param>
/// <returns>
/// <see langword="true"/> if it can be ignored when looking for links that stand
/// alone in a paragraph.
/// </returns>
internal static bool IsBlank(Inline inline) =>
inline switch
{
LineBreakInline => true,
LiteralInline literal => literal.Content.IsEmptyOrWhitespace(),
_ => false,
};
/// <summary>
/// Appends the visible text of an inline and its children.
/// </summary>
/// <param name="text">The builder to append to.</param>
/// <param name="inline">The inline to flatten.</param>
/// <returns>The same <paramref name="text"/>, for chaining.</returns>
private static StringBuilder AppendText(StringBuilder text, Inline inline) =>
inline switch
{
LiteralInline literal => text.Append(literal.Content.ToString()),
CodeInline code => text.Append(code.Content),
HtmlEntityInline entity => text.Append(entity.Transcoded.ToString()),
AutolinkInline autolink => text.Append(autolink.Url),
LineBreakInline => text.Append(' '),
LinkInline { IsImage: true } => text,
ContainerInline container => container.Aggregate(text, AppendText),
_ => text,
};
/// <summary>
/// Cuts a text after its first sentence.
/// </summary>
/// <param name="text">Plain text.</param>
/// <returns>
/// The first sentence including its end mark, or the whole text if it has none.
/// </returns>
private static string UpToSentenceEnd(string text) =>
SentenceEnd().Match(text) is { Success: true } end ? text[..(end.Index + 1)] : text;
/// <summary>
/// Cuts a text to a word boundary and marks the cut.
/// </summary>
/// <param name="text">Plain text.</param>
/// <param name="maxLength">The longest result before the ellipsis.</param>
/// <returns>
/// The text unchanged if it fits; otherwise everything up to the last space
/// within <paramref name="maxLength"/>, trailing punctuation removed, plus
/// <c>…</c>. A single word longer than <paramref name="maxLength"/> is cut hard.
/// </returns>
/// <remarks>
/// The space is searched one character past the limit: if the text breaks
/// exactly at <paramref name="maxLength"/>, the last whole word still fits.
/// </remarks>
private static string Shorten(string text, int maxLength) =>
text.Length <= maxLength
? text
: (text[..(maxLength + 1)].LastIndexOf(' ') is var space and > 0 ? text[..space] : text[..maxLength])
.TrimEnd(' ', ',', ';', ':', '.', '-', '–') + "…";
/// <summary>
/// Matches the end mark of a sentence: <c>.</c>, <c>!</c> or <c>?</c> before a
/// space or the end, unless it follows a lone letter (<c>z.</c>) or a number
/// (<c>13.</c>).
/// </summary>
[GeneratedRegex(@"(?<!\b\p{L}|\b\d+)[.!?](?=\s|$)")]
private static partial Regex SentenceEnd();
/// <summary>
/// Matches a run of whitespace, collapsed to one space in plain text.
/// </summary>
[GeneratedRegex(@"\s+")]
private static partial Regex Whitespace();
/// <summary>
/// Turns the list items of every <c>::: team</c> block into person cards.
/// </summary>
/// <param name="document">The parsed document; changed in place.</param>
/// <returns>The same <paramref name="document"/>, for chaining.</returns>
/// <remarks>
/// Only lists directly in the block count; a nested list inside a card stays a
/// list. <c>::: team gross</c> adds the class
/// <see cref="TeamMember.LargeModifier"/> to the block, because Markdig only
/// turns the block name into a class, not the words after it.
/// </remarks>
private static MarkdownDocument BuildTeamCards(MarkdownDocument document)
{
foreach (var team in document.Descendants<CustomContainer>().Where(TeamMember.IsTeam).ToList())
{
if (TeamMember.IsLarge(team))
{
team.GetAttributes().AddClass(TeamMember.LargeModifier);
}
foreach (var item in team.OfType<ListBlock>().SelectMany(list => list.OfType<ListItemBlock>()))
{
TeamMember.ToCard(item);
}
}
return document;
}
/// <summary>
/// Adds <see cref="LoneLinkClass"/> to every paragraph that holds nothing but
/// one link.
/// </summary>
/// <param name="document">The parsed document; changed in place.</param>
/// <returns>The same <paramref name="document"/>, for chaining.</returns>
/// <remarks>
/// Inside a list item the class goes on the item, not the paragraph: in a tight
/// list Markdig writes no <c>&lt;p&gt;</c> at all, so a class on the paragraph
/// would be silently dropped.
/// </remarks>
private static MarkdownDocument MarkLoneLinks(MarkdownDocument document)
{
foreach (var paragraph in document.Descendants<ParagraphBlock>().Where(IsLoneLink))
{
MarkdownObject target = paragraph.Parent is ListItemBlock { Count: 1 } item ? item : paragraph;
target.GetAttributes().AddClass(LoneLinkClass);
}
return document;
}
/// <summary>
/// Whether a paragraph's content is exactly one link, ignoring surrounding
/// whitespace and line breaks.
/// </summary>
/// <param name="paragraph">The paragraph to inspect.</param>
/// <returns>
/// <see langword="true"/> for one link (inline <c>[text](url)</c> or an
/// autolink <c>&lt;url&gt;</c>, but not an image) and nothing else.
/// </returns>
private static bool IsLoneLink(ParagraphBlock paragraph) =>
paragraph.Inline?
.Where(inline => !IsBlank(inline))
.ToList() is [LinkInline { IsImage: false } or AutolinkInline];
} }
@@ -0,0 +1,73 @@
namespace Elternbeirat.Web.Shared;
/// <summary>
/// Remembers for a few seconds whether PocketBase answered its health check, so the
/// maintenance gate does not probe PocketBase on every page request.
/// </summary>
/// <param name="time">
/// The clock that decides when a result is stale; injected so tests can step past
/// <see cref="Lifetime"/> without waiting.
/// </param>
/// <remarks>
/// The gate in <c>Program.cs</c> asks this cache before rendering any page and serves
/// the static <c>maintenance.html</c> with 503 while the answer is "unhealthy". A
/// failed check is kept just as long as a passed one: while PocketBase is down every
/// probe would otherwise wait for the connection to fail again.
/// <para>
/// The price of caching is a short blind spot in both directions: after
/// PocketBase fails, pages may still render for up to <see cref="Lifetime"/> (the
/// components' own fallbacks cover that), and after it recovers the maintenance
/// page may stay up for as long.
/// </para>
/// <para>
/// Registered as a singleton. No lock: two requests that find the result stale at
/// the same moment both probe, and the later answer wins -- harmless, and cheaper
/// than making every request wait on one another.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// var healthy = await cache.IsHealthyAsync(pocketBase.IsHealthyAsync, context.RequestAborted);
/// </code>
/// </example>
/// <seealso cref="PocketBase.PocketBaseClient.IsHealthyAsync"/>
public sealed class PocketBaseHealthCache(TimeProvider time)
{
/// <summary>
/// How long a health check result is reused before PocketBase is asked again.
/// </summary>
public static readonly TimeSpan Lifetime = TimeSpan.FromSeconds(10);
// One reference, swapped as a whole, so a reader never sees the result of one
// check paired with the time of another.
private volatile Check? _last;
/// <summary>
/// Returns the last known health of PocketBase, probing again once it is older
/// than <see cref="Lifetime"/>.
/// </summary>
/// <param name="probe">
/// The actual check, usually <see cref="PocketBase.PocketBaseClient.IsHealthyAsync"/>.
/// Passed in rather than injected because the typed client is transient and this
/// cache is a singleton.
/// </param>
/// <param name="token">Cancels the probe, e.g. when the visitor goes away.</param>
/// <returns>
/// <see langword="true"/> if PocketBase answered its last check (or answers this
/// one), otherwise <see langword="false"/>.
/// </returns>
/// <exception cref="OperationCanceledException">
/// <paramref name="token"/> was cancelled during a probe; nothing is cached then.
/// </exception>
public async Task<bool> IsHealthyAsync(Func<CancellationToken, Task<bool>> probe, CancellationToken token) =>
_last is { } last && time.GetUtcNow() - last.At < Lifetime
? last.Healthy
: (_last = new Check(await probe(token), time.GetUtcNow())).Healthy;
/// <summary>
/// One health check result and when it was taken.
/// </summary>
/// <param name="Healthy">Whether PocketBase answered.</param>
/// <param name="At">When the answer came in.</param>
private sealed record Check(bool Healthy, DateTimeOffset At);
}
@@ -0,0 +1,30 @@
namespace Elternbeirat.Web.Shared;
/// <summary>
/// Marks the current response as HTTP 503 when the content source is down.
/// </summary>
/// <remarks>
/// Several components handle a <see cref="PocketBase.PocketBaseUnavailableException"/>
/// the same way: the site works but PocketBase does not, so the response is a
/// 503 and the component renders a "temporarily unavailable" note instead of
/// empty content that would read as 200 "there is nothing here". This puts the
/// status-setting in one place so those components do not each repeat it.
/// </remarks>
internal static class SourceUnavailable
{
/// <summary>
/// Sets the response status to 503 Service Unavailable, if there is a
/// response to set it on.
/// </summary>
/// <param name="context">
/// The current HTTP context, or <see langword="null"/> when the component
/// renders outside a request (in which case there is nothing to do).
/// </param>
public static void SetStatus(HttpContext? context)
{
if (context is not null)
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
}
}
}
+380
View File
@@ -0,0 +1,380 @@
using System.Globalization;
using Markdig.Extensions.CustomContainers;
using Markdig.Renderers.Html;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
namespace Elternbeirat.Web.Shared;
/// <summary>
/// One person in a <c>::: team</c> block, read from a list item that follows the
/// convention the board page has always used.
/// </summary>
/// <param name="Name">The person's name: the bold start of the item.</param>
/// <param name="Role">
/// The role after the name, without its parentheses, or <see langword="null"/>
/// when the item has none (most of the "Beisitz" members).
/// </param>
/// <param name="Duties">
/// The responsibilities from the italic second line, split at commas; empty when
/// there is no such line.
/// </param>
/// <remarks>
/// The convention, one list item per person:
/// <code>
/// - **Maik Palm** (Vorsitzender)
/// *Schulkonferenz, Mensarat, Homepage*
/// Mitglied im Gesamtelternbeirat.
/// </code>
/// The bold name becomes the card's heading, the role a chip, every duty a small
/// tag and any further line the note. Line breaks may be hard (two spaces) or soft:
/// editors forget the two spaces, and the card should not depend on them.
/// <para>
/// The board stays Markdown in the page body on purpose: a dedicated
/// collection would be cleaner, but there is no way yet to bring a schema
/// change to production (Gitea #39). Reading the convention costs the editors
/// nothing they do not already write.
/// </para>
/// <para>
/// An item that does not start with a bold name is not an error: it becomes a
/// plain card showing its text as written (<see cref="PlainCardClass"/>).
/// </para>
/// </remarks>
/// <seealso cref="Markdown"/>
public sealed record TeamMember(string Name, string? Role, IReadOnlyList<string> Duties)
{
/// <summary>
/// The block name editors type: <c>::: team</c>.
/// </summary>
public const string ContainerName = "team";
/// <summary>
/// The modifier for the chair, <c>::: team gross</c>; also the class it adds to
/// the block. The cards keep the shared raster; only the role chip is
/// highlighted.
/// </summary>
/// <remarks>
/// A word after the block name rather than a block of its own
/// (<c>::: team-gross</c>): the block stays <c>team</c>, so a typo in the
/// modifier only costs the highlight, never the whole grid. <c>groß</c> is
/// accepted too; <c>gross</c> is the documented form because it types the same
/// on every keyboard.
/// </remarks>
public const string LargeModifier = "gross";
/// <summary>
/// The class on every card, i.e. on every list item in a <c>::: team</c> block.
/// </summary>
public const string CardClass = "team-card";
/// <summary>
/// The extra class on a card whose item does not follow the convention.
/// </summary>
public const string PlainCardClass = "team-card-plain";
/// <summary>
/// How many avatar colours there are; <c>app.css</c> defines
/// <c>--color-avatar-1</c> (brand blue) and <c>--color-avatar-2</c> (brand
/// green) to match.
/// </summary>
/// <remarks>
/// Only the two brand colours: more hues made the board look busy and pulled it
/// away from the IGMH palette.
/// </remarks>
public const int ColorCount = 2;
/// <summary>
/// FNV-1a offset basis and prime (32 bit), see <see cref="Fnv"/>.
/// </summary>
private const uint FnvOffset = 2166136261;
private const uint FnvPrime = 16777619;
/// <summary>
/// The letters on the avatar, see <see cref="InitialsOf"/>.
/// </summary>
public string Initials => InitialsOf(Name);
/// <summary>
/// The avatar colour, from <c>1</c> to <see cref="ColorCount"/>, see
/// <see cref="ColorOf"/>.
/// </summary>
public int Color => ColorOf(Name);
/// <summary>
/// Builds the initials shown on a person's avatar.
/// </summary>
/// <param name="name">The person's name.</param>
/// <returns>
/// The first letter of the first and of the last word, upper case; one letter
/// for a single word; an empty string when the name holds no letter at all.
/// </returns>
/// <remarks>
/// A double name counts as one word, so it adds one letter, not two: two
/// letters are what fits the circle, and they are what people expect from a
/// name like <c>Reger-Stilgenbauer</c>. Letters outside A–Z (<c>Ö</c>,
/// <c>Ş</c>) are kept as they are, and leading punctuation is skipped.
/// </remarks>
/// <example>
/// <code>
/// TeamMember.InitialsOf("Jenny Reger-Stilgenbauer"); // "JR"
/// TeamMember.InitialsOf("Özlem Ünal"); // "ÖÜ"
/// TeamMember.InitialsOf("Isabell"); // "I"
/// </code>
/// </example>
public static string InitialsOf(string name) =>
name.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) switch
{
[] => "",
[var only] => Initial(only),
[var first, .., var last] => Initial(first) + Initial(last),
};
/// <summary>
/// Picks a person's avatar colour from the name.
/// </summary>
/// <param name="name">The person's name.</param>
/// <returns>A number from <c>1</c> to <see cref="ColorCount"/>.</returns>
/// <remarks>
/// The same name always gets the same colour, on every request and after every
/// restart, so a person does not change colour between visits. That is why this
/// is a fixed hash (FNV-1a) and not <see cref="string.GetHashCode()"/>, which
/// .NET randomises per process. Surrounding whitespace does not count.
/// <para>
/// FNV-1a alone spreads short names badly, so the MurmurHash3 finaliser
/// (<see cref="Mix"/>) stirs all bits first. The colour is then taken from
/// the <b>high</b> bits (multiply by <see cref="ColorCount"/>, keep the top
/// 32 bits) rather than a remainder, which would read only the low bits:
/// with two colours that put eight of the ten board members on the same
/// one.
/// </para>
/// </remarks>
public static int ColorOf(string name) =>
(int)(((ulong)Mix(Fnv(name.Trim())) * ColorCount) >> 32) + 1;
/// <summary>
/// Reads a person from the first paragraph of a list item.
/// </summary>
/// <param name="paragraph">The paragraph to read; not changed.</param>
/// <returns>
/// The person and the inlines that form the note (everything after the name
/// line and, if present, the duties line; possibly empty), or
/// <see langword="null"/> when the paragraph does not start with a bold name.
/// </returns>
/// <remarks>
/// The duties line is only recognised when it is nothing but one italic span;
/// any other second line is part of the note, so no text is lost.
/// </remarks>
internal static (TeamMember Member, Inline[] Note)? Read(ParagraphBlock paragraph) =>
Lines(paragraph).ToArray() is [var head, .. var rest]
&& Content(head) is [EmphasisInline { DelimiterCount: 2 } name, .. var role]
&& Markdown.PlainText(name) is { Length: > 0 } nameText
? rest is [var second, .. var after] && Content(second) is [EmphasisInline { DelimiterCount: 1 } duties]
? (new TeamMember(nameText, RoleOf(role), DutiesOf(duties)), [.. after.SelectMany(line => line)])
: (new TeamMember(nameText, RoleOf(role), []), [.. rest.SelectMany(line => line)])
: null;
/// <summary>
/// Turns one list item of a <c>::: team</c> block into a person card.
/// </summary>
/// <param name="item">The list item; changed in place.</param>
/// <remarks>
/// The first paragraph is rebuilt from new inlines: raw HTML inlines for the
/// tags and literal inlines for the text, so Markdig escapes everything the
/// editor wrote. The note keeps its original inlines, so links and emphasis in it
/// still render. Any further blocks in the item stay untouched below.
/// </remarks>
internal static void ToCard(ListItemBlock item)
{
var attributes = item.GetAttributes();
attributes.AddClass(CardClass);
if (item.FirstOrDefault() is ParagraphBlock { Inline: not null } paragraph && Read(paragraph) is { } card)
{
foreach (var inline in card.Note)
{
inline.Remove();
}
paragraph.Inline = card.Member.Head()
.Concat(card.Note.Length > 0 ? [Open("span", "team-note"), .. card.Note, Close("span")] : [])
.Aggregate(new ContainerInline(), (container, inline) => container.AppendChild(inline));
}
else
{
attributes.AddClass(PlainCardClass);
}
}
/// <summary>
/// Whether a <c>:::</c> block is a team block.
/// </summary>
/// <param name="container">The block to inspect.</param>
/// <returns><see langword="true"/> for <c>::: team</c>, with or without modifier.</returns>
internal static bool IsTeam(CustomContainer container) =>
string.Equals(container.Info, ContainerName, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Whether a team block asks for the chair highlight (<c>::: team gross</c>).
/// </summary>
/// <param name="container">The team block.</param>
/// <returns>
/// <see langword="true"/> when one of the words after the name is
/// <c>gross</c> or <c>groß</c>, in any case.
/// </returns>
internal static bool IsLarge(CustomContainer container) =>
(container.Arguments ?? "")
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
.Any(word => word.Equals(LargeModifier, StringComparison.OrdinalIgnoreCase)
|| word.Equals("groß", StringComparison.OrdinalIgnoreCase));
/// <summary>
/// The card's head: avatar, name, role chip and duty tags, as inlines.
/// </summary>
/// <returns>The inlines, in reading order; missing parts are left out.</returns>
/// <remarks>
/// The avatar is hidden from screen readers: it only repeats the name next to
/// it.
/// </remarks>
private Inline[] Head() =>
[
.. Initials is { Length: > 0 } initials
? [Open("span", $"team-avatar team-avatar-{Color.ToString(CultureInfo.InvariantCulture)}", " aria-hidden=\"true\""), new LiteralInline(initials), Close("span")]
: Array.Empty<Inline>(),
.. Element("team-name", Name, "strong"),
.. Role is { } role ? Element("team-role", role) : [],
.. Duties.Count > 0
? [Open("span", "team-duties"), .. Duties.SelectMany(Duty), Close("span")]
: Array.Empty<Inline>(),
];
/// <summary>
/// The 32-bit FNV-1a hash of a text's UTF-16 code units.
/// </summary>
/// <param name="text">The text.</param>
/// <returns>The hash.</returns>
private static uint Fnv(string text) =>
unchecked(text.Aggregate(FnvOffset, (hash, letter) => (hash ^ letter) * FnvPrime));
/// <summary>
/// The MurmurHash3 finaliser (<c>fmix32</c>): spreads every input bit over all
/// output bits.
/// </summary>
/// <param name="hash">The hash to mix.</param>
/// <returns>The mixed hash.</returns>
private static uint Mix(uint hash) =>
unchecked(XorShift(XorShift(XorShift(hash, 16) * 0x85EBCA6B, 13) * 0xC2B2AE35, 16));
/// <summary>
/// Folds the high bits of a value into its low bits.
/// </summary>
/// <param name="value">The value.</param>
/// <param name="shift">How far to shift right before the XOR.</param>
/// <returns><paramref name="value"/> XOR <paramref name="value"/> shifted right.</returns>
private static uint XorShift(uint value, int shift) => value ^ (value >> shift);
/// <summary>
/// One duty tag, led by a space so screen readers keep the tags apart.
/// </summary>
/// <param name="duty">The duty.</param>
/// <returns>The space and the tag.</returns>
private static Inline[] Duty(string duty) =>
[new LiteralInline(" "), .. Element("team-duty", duty)];
/// <summary>
/// An element with escaped text inside.
/// </summary>
/// <param name="classes">The value of the class attribute; written as is.</param>
/// <param name="text">The text; escaped by Markdig on rendering.</param>
/// <param name="tag">The element name.</param>
/// <returns>Opening tag, text and closing tag.</returns>
private static Inline[] Element(string classes, string text, string tag = "span") =>
[Open(tag, classes), new LiteralInline(text), Close(tag)];
/// <summary>
/// An opening tag with a class, as a raw HTML inline.
/// </summary>
/// <param name="tag">The element name.</param>
/// <param name="classes">The value of the class attribute; written as is.</param>
/// <param name="attributes">
/// Further attributes, written as is after the class, with a leading space.
/// </param>
/// <returns>The tag.</returns>
/// <remarks>
/// Only ever called with constants from this class, never with editor text:
/// nothing here is escaped.
/// </remarks>
private static HtmlInline Open(string tag, string classes, string attributes = "") =>
new($"<{tag} class=\"{classes}\"{attributes}>");
/// <summary>
/// A closing tag, as a raw HTML inline.
/// </summary>
/// <param name="tag">The element name.</param>
/// <returns>The tag.</returns>
private static HtmlInline Close(string tag) => new($"</{tag}>");
/// <summary>
/// The first letter of a word, upper case.
/// </summary>
/// <param name="word">One word of a name.</param>
/// <returns>The letter, or an empty string if the word has none.</returns>
private static string Initial(string word) =>
word.FirstOrDefault(char.IsLetter) is var letter and not '\0'
? letter.ToString().ToUpper(Cultures.German)
: "";
/// <summary>
/// The text after the name, without separators and parentheses.
/// </summary>
/// <param name="inlines">The inlines after the bold name on the first line.</param>
/// <returns>The role, or <see langword="null"/> if nothing is left.</returns>
private static string? RoleOf(IEnumerable<Inline> inlines) =>
Markdown.PlainText(inlines).Trim(' ', ',', ':', ';', '-', '–') is var text
&& (text.StartsWith('(') && text.EndsWith(')') ? text[1..^1].Trim() : text) is { Length: > 0 } role
? role
: null;
/// <summary>
/// Splits the italic duties line at its commas.
/// </summary>
/// <param name="duties">The italic span.</param>
/// <returns>The duties, trimmed, without empty entries.</returns>
private static string[] DutiesOf(EmphasisInline duties) =>
Markdown.PlainText(duties).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
/// <summary>
/// The inlines of a line that carry content: no whitespace, no line break.
/// </summary>
/// <param name="line">One line from <see cref="Lines"/>.</param>
/// <returns>The content inlines, in order.</returns>
private static Inline[] Content(IEnumerable<Inline> line) =>
[.. line.Where(inline => !Markdown.IsBlank(inline))];
/// <summary>
/// Splits a paragraph's inlines into lines.
/// </summary>
/// <param name="paragraph">The paragraph.</param>
/// <returns>
/// The lines; each keeps the line break that ends it, so the note can be moved
/// with its breaks intact.
/// </returns>
private static IEnumerable<List<Inline>> Lines(ParagraphBlock paragraph)
{
var line = new List<Inline>();
foreach (var inline in paragraph.Inline?.ToList() ?? [])
{
line.Add(inline);
if (inline is LineBreakInline)
{
yield return line;
line = [];
}
}
if (line.Count > 0)
{
yield return line;
}
}
}
+1
View File
@@ -1,3 +1,4 @@
@using System.Globalization
@using System.Net.Http @using System.Net.Http
@using System.Net.Http.Json @using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
File diff suppressed because it is too large. Load diff
+93
View File
@@ -0,0 +1,93 @@
Copyright 2014 The Nunito Project Authors (https://github.com/googlefonts/nunito)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more