Compare commits
71
Commits
b1d1953a62
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53cee04396 | ||
|
|
ee69892058 | ||
|
|
a847507b46 | ||
|
|
7d09233ca2 | ||
|
|
8e4333b3ac | ||
|
|
ebb3b387c5 | ||
|
|
7e56e83953 | ||
|
|
ed6aa5d5ec | ||
|
|
ba65b18d29 | ||
|
|
ae362639e3 | ||
|
|
0257b2e30b | ||
|
|
602f258257 | ||
|
|
a5a68ed122 | ||
|
|
efb6fa9109 | ||
|
|
b2dc033de1 | ||
|
|
d328adac5c | ||
|
|
6e5a30607e | ||
|
|
c24ca16a41 | ||
|
|
e51afeb34b | ||
|
|
b737f57ef7 | ||
|
|
d539fde09d | ||
|
|
a641beab16 | ||
|
|
5eb36ad3e9 | ||
|
|
ca0b3c311c | ||
|
|
33fa1e4e93 | ||
|
|
3530167cc3 | ||
|
|
8fb5044062 | ||
|
|
5f28237ef0 | ||
|
|
818f4c508e | ||
|
|
ee43d133e9 | ||
|
|
cf799dd970 | ||
|
|
de319d3b00 | ||
|
|
43e6ed7743 | ||
|
|
4fa17429b5 | ||
|
|
666614a3b2 | ||
|
|
c67c71ea28 | ||
|
|
eacf95e041 | ||
|
|
cb345975cb | ||
|
|
e38ebf078d | ||
|
|
2ea8856d65 | ||
|
|
28944c155a | ||
|
|
a4c8c1f4cb | ||
|
|
ab4a1dd8fc | ||
|
|
13d35f4d27 | ||
|
|
44f07c8c13 | ||
|
|
455b603299 | ||
|
|
d95bf0dcbe | ||
|
|
c7c55c0c6c | ||
|
|
01d25eb04b | ||
|
|
e242eaf79a | ||
|
|
0f7e968c16 | ||
|
|
d06af2891a | ||
|
|
66bc76af1c | ||
|
|
31b114982e | ||
|
|
6d3260db49 | ||
|
|
aef05fb090 | ||
|
|
4b93434679 | ||
|
|
3fd4068762 | ||
|
|
1bdb026669 | ||
|
|
d7e708a717 | ||
|
|
fa49f80d65 | ||
|
|
02654c20d3 | ||
|
|
7337a1cbc0 | ||
|
|
c3da5eb6e1 | ||
|
|
d5372963bb | ||
|
|
47d9841532 | ||
|
|
3fd3796d73 | ||
|
|
6b5d9dda55 | ||
|
|
4136af6417 | ||
|
|
da87716d97 | ||
|
|
c59e7a4f83 |
No files matched your search
@@ -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
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
# Shell-Skripte brauchen Unix-Zeilenenden (LF), sonst scheitert der #!-Aufruf
|
# Shell-Skripte brauchen Unix-Zeilenenden (LF), sonst scheitert der #!-Aufruf
|
||||||
# in bash. Unabhaengig von core.autocrlf auf Windows erzwingen.
|
# in bash. Unabhaengig von core.autocrlf auf Windows erzwingen.
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
|
|
||||||
|
# PocketBase-Migration und -Schema laufen im Linux-Container. LF erzwingen, damit
|
||||||
|
# core.autocrlf=true auf Windows sie nicht bei jeder Beruehrung als geaendert
|
||||||
|
# markiert (staendige "LF will be replaced by CRLF"-Warnung).
|
||||||
|
pb/** text eol=lf
|
||||||
+6
-1
@@ -13,4 +13,9 @@ riderModule.iml
|
|||||||
/backup/
|
/backup/
|
||||||
|
|
||||||
# Secrets (Gitea-Token o.ae.) -- niemals ins Repo. Vorlage: .env.example
|
# Secrets (Gitea-Token o.ae.) -- niemals ins Repo. Vorlage: .env.example
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# PocketBase runtime data (SQLite DB, uploads) -- may hold personal data and is
|
||||||
|
# per-environment state, not source. The dev seed lives in pb/pb_migrations/ and
|
||||||
|
# IS checked in; only the data folder is ignored.
|
||||||
|
/pb/pb_data/
|
||||||
@@ -7,10 +7,11 @@
|
|||||||
<list />
|
<list />
|
||||||
</option>
|
</option>
|
||||||
<option name="commandLineOptions" value="--build" />
|
<option name="commandLineOptions" value="--build" />
|
||||||
<!-- Base compose (loaded first); the dev overlay in sourceFilePath wins. -->
|
<!-- The dev overlay must win over the base, so it is listed here:
|
||||||
|
secondarySourceFiles are merged after sourceFilePath. -->
|
||||||
<option name="secondarySourceFiles">
|
<option name="secondarySourceFiles">
|
||||||
<list>
|
<list>
|
||||||
<option value="$PROJECT_DIR$/compose.yaml" />
|
<option value="$PROJECT_DIR$/compose.dev.yaml" />
|
||||||
</list>
|
</list>
|
||||||
</option>
|
</option>
|
||||||
<option name="services">
|
<option name="services">
|
||||||
@@ -19,12 +20,12 @@
|
|||||||
<option value="eb-web" />
|
<option value="eb-web" />
|
||||||
</list>
|
</list>
|
||||||
</option>
|
</option>
|
||||||
<!-- Dev overlay (primary = last applied): host ports + local web build. -->
|
<!-- Base compose, merged first. -->
|
||||||
<option name="sourceFilePath" value="$PROJECT_DIR$/compose.dev.yaml" />
|
<option name="sourceFilePath" value="$PROJECT_DIR$/compose.yaml" />
|
||||||
<option name="upForceRecreate" value="true" />
|
<option name="upForceRecreate" value="true" />
|
||||||
</settings>
|
</settings>
|
||||||
</deployment>
|
</deployment>
|
||||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
|
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
|
||||||
<method v="2" />
|
<method v="2" />
|
||||||
</configuration>
|
</configuration>
|
||||||
</component>
|
</component>
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
Website des Elternbeirats der IGMH. Blazor Web App mit statischem
|
Website des Elternbeirats der IGMH. Blazor Web App mit statischem
|
||||||
Server-Side-Rendering, .NET 10. Läuft als Container auf Unraid hinter dem Nginx
|
Server-Side-Rendering, .NET 10. Läuft als Container auf Unraid hinter dem Nginx
|
||||||
Proxy Manager.
|
Proxy Manager. Die Inhalte liegen in **PocketBase** (zweiter Container); die App
|
||||||
|
liest sie pro Request über einen typisierten `PocketBaseClient`.
|
||||||
|
|
||||||
Architekturentscheidungen und deren Begründung: `docs/architektur.md`. Dort
|
Architekturentscheidungen und deren Begründung: `docs/architektur.md`. Dort
|
||||||
nachlesen, bevor eine davon in Frage gestellt wird.
|
nachlesen, bevor eine davon in Frage gestellt wird.
|
||||||
@@ -11,7 +12,7 @@ nachlesen, bevor eine davon in Frage gestellt wird.
|
|||||||
|
|
||||||
| Thema | Datei |
|
| Thema | Datei |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Warum SSR statt WASM, warum keine DB (AE-1 bis AE-4) | `docs/architektur.md` |
|
| Warum SSR statt WASM, warum PocketBase als Datenschicht (AE-1 bis AE-4) | `docs/architektur.md` |
|
||||||
| Stack lokal starten, Tests, compose-Overlays | `docs/entwicklung.md` |
|
| Stack lokal starten, Tests, compose-Overlays | `docs/entwicklung.md` |
|
||||||
| Unraid, compose, NPM-Proxy-Host, Registry, Rollback | `docs/deployment.md` |
|
| Unraid, compose, NPM-Proxy-Host, Registry, Rollback | `docs/deployment.md` |
|
||||||
| Inhalte anlegen und ändern (PocketBase-Admin) | `docs/redaktion.md` |
|
| Inhalte anlegen und ändern (PocketBase-Admin) | `docs/redaktion.md` |
|
||||||
@@ -26,28 +27,36 @@ Sub-Issues als Checkliste im Body, alle am Milestone „Elternbeirat-Website".
|
|||||||
## Kommandos
|
## Kommandos
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet run --project Elternbeirat.Web # lokal; Inhalte werden beim Start gelesen (kein Auto-Reload)
|
# Lokaler Stack (App + PocketBase) über das Dev-Overlay, siehe docs/entwicklung.md:
|
||||||
dotnet test # Smoke-Tests: jede Route liefert 200
|
docker compose -f compose.yaml -f compose.dev.yaml up --build # App :5000, PocketBase-Admin :8090/_/
|
||||||
docker compose build && docker compose up -d
|
dotnet test # Smoke-Tests (starten eigene PocketBase) + Unit-Tests
|
||||||
```
|
```
|
||||||
|
|
||||||
Es gibt keine Datenbank, keine Migrationen, kein Seeding. Ein `git clone` ist der
|
Die App liest ihren Inhalt pro Request aus PocketBase; sie hält selbst keinen
|
||||||
vollständige Stand.
|
Inhalt und liest nichts beim Start ein. Kein EF Core, keine Migrationen, kein
|
||||||
|
Seeding im Code. Ein `git clone` ist **nicht** der vollständige Stand — der
|
||||||
|
Inhalt liegt in PocketBase (`pb_data`), das separat gesichert wird (siehe
|
||||||
|
`docs/deployment.md`).
|
||||||
|
|
||||||
## Nicht offensichtlich
|
## Nicht offensichtlich
|
||||||
|
|
||||||
- **Inhalte liegen im Image**, nicht in einem Volume. Jede Textänderung braucht
|
- **Inhalte liegen in PocketBase** (`pb_data`-Volume), nicht im Image. Eine
|
||||||
Commit und Rebuild. Das ist Absicht (`docs/architektur.md`, AE-3) — nicht
|
Textänderung ist ein Eintrag im PocketBase-Admin und sofort live — kein Commit,
|
||||||
„vereinfachen".
|
kein Rebuild (`docs/architektur.md`, AE-2/AE-3). Nur die statischen Assets unter
|
||||||
|
`wwwroot/` (CSS, Fonts, PDFs) sind ins Image gebacken.
|
||||||
- **Kein `UseHttpsRedirection()`, kein `UseHsts()`.** NPM terminiert TLS; beides
|
- **Kein `UseHttpsRedirection()`, kein `UseHsts()`.** NPM terminiert TLS; beides
|
||||||
erzeugt hinter dem Proxy eine Redirect-Schleife. `UseForwardedHeaders` mit
|
erzeugt hinter dem Proxy eine Redirect-Schleife. `UseForwardedHeaders` mit
|
||||||
geleerten `KnownNetworks`/`KnownProxies` ist korrekt so, weil der Container kein
|
geleerten `KnownNetworks`/`KnownProxies` ist korrekt so, weil der Container kein
|
||||||
Port-Mapping hat und nur über NPM erreichbar ist.
|
Port-Mapping hat und nur über NPM erreichbar ist.
|
||||||
- **Das chiseled-Runtime-Image hat keine Shell.** `HEALTHCHECK` mit `curl` oder
|
- **Das chiseled-Runtime-Image hat keine Shell.** `HEALTHCHECK` mit `curl` oder
|
||||||
`sh` schlägt dort fehl.
|
`sh` schlägt dort fehl.
|
||||||
- Neue Seite = Markdown in `Content/pages/`. Neuer Beitrag = Markdown in
|
- Neue Seite / neuer Beitrag / neuer Termin / neue Frage = **Record in der
|
||||||
`Content/posts/`. Neuer Termin = Eintrag in `Content/events.yml`. In allen
|
passenden PocketBase-Collection** (`pages` / `posts` / `events` / `faqs`), nicht
|
||||||
Fällen wird **kein** `.razor` angefasst.
|
eine Datei und **kein** `.razor`. `pages` mit `slug` ist sofort unter `/<slug>`
|
||||||
|
erreichbar; `location`+`order` steuern das Menü, `embed` bettet Listen ein
|
||||||
|
(`docs/redaktion.md`). Eine **neue Art von Liste** (eigene Route/Logik) ist
|
||||||
|
dagegen Code: neue Collection + `PocketBaseClient`-Methode + Komponente
|
||||||
|
(`docs/entwicklung.md`).
|
||||||
- Slugs ohne Umlaute (`ueber-uns`, nicht `über-uns`).
|
- Slugs ohne Umlaute (`ueber-uns`, nicht `über-uns`).
|
||||||
|
|
||||||
## Regeln
|
## Regeln
|
||||||
@@ -57,22 +66,64 @@ vollständige Stand.
|
|||||||
- **Keine externen Ressourcen.** Keine CDN-Skripte, keine Google Fonts, keine
|
- **Keine externen Ressourcen.** Keine CDN-Skripte, keine Google Fonts, keine
|
||||||
Maps- oder Video-Embeds. Schriften werden selbst ausgeliefert. Grund ist
|
Maps- oder Video-Embeds. Schriften werden selbst ausgeliefert. Grund ist
|
||||||
Datenschutz, nicht Geschmack (`docs/recht.md`).
|
Datenschutz, nicht Geschmack (`docs/recht.md`).
|
||||||
- **Keine Datenbank, kein EF Core, kein ORM.** Inhalte sind Dateien.
|
- **Kein EF Core, kein ORM, keine Migrationen.** Inhalte kommen aus PocketBase
|
||||||
- **Inhalte gehören nach `Content/`**, nicht als Markup in Komponenten.
|
über den `PocketBaseClient` (REST), nicht aus einer relationalen DB im Code.
|
||||||
- Kein CMS, kein Login, keine Benutzerkonten.
|
- **Inhalte gehören nach PocketBase**, nicht als Markup in Komponenten und nicht
|
||||||
|
in Dateien im Repo.
|
||||||
|
- Kein eigenes CMS, kein Login und keine Benutzerkonten **in der Website** —
|
||||||
|
gepflegt wird ausschließlich im PocketBase-Admin (Redakteure sind Superuser).
|
||||||
- UI-Texte auf Deutsch, ohne Anglizismen. `Termine`, nicht `Events`.
|
- UI-Texte auf Deutsch, ohne Anglizismen. `Termine`, nicht `Events`.
|
||||||
- Neue NuGet-Abhängigkeiten vorher begründen. Bestand: `Markdig`, `YamlDotNet`.
|
- Neue NuGet-Abhängigkeiten vorher begründen. Genutzt wird nur `Markdig`
|
||||||
|
(Markdown → HTML). `YamlDotNet` stammt aus der abgelösten Datei-Schicht, wird
|
||||||
|
nicht mehr verwendet und soll aus dem `csproj` entfernt werden.
|
||||||
|
|
||||||
## Stil
|
## Stil
|
||||||
|
|
||||||
- Nullable aktiviert, `ImplicitUsings` an, File-scoped Namespaces.
|
- Nullable aktiviert, `ImplicitUsings` an, File-scoped Namespaces.
|
||||||
- Services über DI, als Singleton registriert (Inhalte werden beim Start
|
- Datenzugriff über den typisierten `PocketBaseClient` (registriert via
|
||||||
eingelesen und gecacht).
|
`AddHttpClient`), der pro Request liest — kein Start-Cache, kein Singleton mit
|
||||||
- Öffentliche Typen und Methoden der `Services` bekommen XML-Doc (auf Englisch,
|
Inhalten. Komponenten liegen feature-basiert unter `Features/<Bereich>/`.
|
||||||
leicht verständlich), Razor-Markup nicht.
|
- JSON-Konverter liegen in `Contracts` und werden per `[JsonConverter]` **am Feld**
|
||||||
|
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, Markdown-Inhalte,
|
Besucher liest oder ein Redakteur pflegt: UI-Texte sowie die **Werte** der
|
||||||
Frontmatter-Schlüssel wie `titel:` sowie Slugs/Dateinamen unter `Content/`
|
PocketBase-Records (z. B. `title: Vorstandsteam`, der Markdown-`body`). Die
|
||||||
(z. B. `vorstandsteam`, `/termine`).
|
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.
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="Markdig" Version="0.38.0" />
|
<PackageVersion Include="Markdig" Version="0.38.0" />
|
||||||
<PackageVersion Include="YamlDotNet" Version="16.2.1" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Test-only packages. -->
|
<!-- Test-only packages. -->
|
||||||
|
|||||||
@@ -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->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)));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
@@ -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))));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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("<script>");
|
||||||
|
}
|
||||||
|
|
||||||
|
[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("")] // 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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.Web.Features.Layout;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for the navigation builder. Pure logic, no PocketBase needed.
|
||||||
|
/// </summary>
|
||||||
|
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) =>
|
||||||
|
new() { Id = "test", Slug = slug, Location = location, Order = order, Title = title };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Header_keeps_only_header_pages_ordered_by_order()
|
||||||
|
{
|
||||||
|
var pages = new[]
|
||||||
|
{
|
||||||
|
Page("board", "header", 2, "Vorstandsteam"),
|
||||||
|
Page("home", "header", 1, "Start"),
|
||||||
|
Page("imprint", "footer", 1, "Impressum"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var header = NavBuilder.Header(pages);
|
||||||
|
|
||||||
|
header.Select(link => link.Title).ShouldBe(["Start", "Vorstandsteam"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Footer_keeps_only_footer_pages_ordered_by_order()
|
||||||
|
{
|
||||||
|
var pages = new[]
|
||||||
|
{
|
||||||
|
Page("privacy", "footer", 2, "Datenschutz"),
|
||||||
|
Page("imprint", "footer", 1, "Impressum"),
|
||||||
|
Page("home", "header", 1, "Start"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var footer = NavBuilder.Footer(pages);
|
||||||
|
|
||||||
|
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]
|
||||||
|
public void Home_page_maps_to_root_every_other_slug_to_its_path()
|
||||||
|
{
|
||||||
|
var pages = new[]
|
||||||
|
{
|
||||||
|
Page("home", "header", 1, "Start"),
|
||||||
|
Page("board", "header", 2, "Vorstandsteam"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var header = NavBuilder.Header(pages);
|
||||||
|
|
||||||
|
header[0].Href.ShouldBe("/");
|
||||||
|
header[1].Href.ShouldBe("/board");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ namespace Elternbeirat.Web.Tests;
|
|||||||
/// with a known seed (see <see cref="PocketBaseFixture"/>). Because the data is fixed,
|
/// with a known seed (see <see cref="PocketBaseFixture"/>). Because the data is fixed,
|
||||||
/// the tests assert on exact values, and they need no network to the live instance.
|
/// the tests assert on exact values, and they need no network to the live instance.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase) : IClassFixture<PocketBaseFixture>
|
[Collection(PocketBaseTestGroup.Name)]
|
||||||
|
public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Events_load_sorted_by_start()
|
public async Task Events_load_sorted_by_start()
|
||||||
@@ -16,20 +17,20 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase) : IClass
|
|||||||
|
|
||||||
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();
|
||||||
@@ -45,8 +46,8 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase) : IClass
|
|||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -59,7 +60,21 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase) : IClass
|
|||||||
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]
|
||||||
@@ -69,9 +84,10 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase) : IClass
|
|||||||
|
|
||||||
var pages = await client.GetPagesAsync();
|
var pages = await client.GetPagesAsync();
|
||||||
|
|
||||||
// Three pages seeded, one with public=false; the draft must be filtered out.
|
// The draft (public=false) must be filtered out, the public ones present.
|
||||||
pages.Select(p => p.Slug).ShouldBe(["home", "contact"]);
|
pages.ShouldNotContain(page => page.Slug == "draft");
|
||||||
pages.ShouldNotContain(p => p.Slug == "draft");
|
pages.ShouldContain(page => page.Slug == "home");
|
||||||
|
pages.ShouldContain(page => page.Slug == "contact");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -83,6 +99,8 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase) : IClass
|
|||||||
|
|
||||||
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 sits in the header like every other page (location is required); it
|
||||||
|
// is the single source of the home nav link.
|
||||||
home.Location.ShouldBe("header");
|
home.Location.ShouldBe("header");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +111,10 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase) : IClass
|
|||||||
|
|
||||||
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,8 @@ using System.Diagnostics;
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
using Elternbeirat.PocketBase;
|
using Elternbeirat.PocketBase;
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Tests;
|
namespace Elternbeirat.Web.Tests;
|
||||||
@@ -16,9 +18,16 @@ namespace Elternbeirat.Web.Tests;
|
|||||||
/// (only the <c>eb-pocketbase</c> service, not the web app) by shelling out to
|
/// (only the <c>eb-pocketbase</c> service, not the web app) by shelling out to
|
||||||
/// <c>docker compose</c>, so the image version, superuser env and port stay defined
|
/// <c>docker compose</c>, so the image version, superuser env and port stay defined
|
||||||
/// in one place -- the compose files -- and the tests always exercise the same
|
/// in one place -- the compose files -- and the tests always exercise the same
|
||||||
/// PocketBase the stack runs. The seed data is deliberately fixed here rather than
|
/// PocketBase the stack runs.
|
||||||
/// exported from the real instance, so tests assert against values this file
|
/// </para>
|
||||||
/// controls. Requires Docker with the Compose plugin.
|
/// <para>
|
||||||
|
/// The collection <b>schema</b> is not typed out here: it is imported from
|
||||||
|
/// <c>pb/pb_migrations/collections_schema.json</c>, the same file the dev seed
|
||||||
|
/// migration uses, so the schema is defined once and cannot drift between the two.
|
||||||
|
/// The seeded <b>records</b>, by contrast, are fixed in this file on purpose (not
|
||||||
|
/// the dev seed's data), so tests assert against values this file controls -- e.g.
|
||||||
|
/// a non-public draft page that must never appear, and an event at a known time.
|
||||||
|
/// Requires Docker with the Compose plugin.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PocketBaseFixture : IAsyncLifetime
|
public sealed class PocketBaseFixture : IAsyncLifetime
|
||||||
@@ -49,6 +58,14 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
|||||||
/// <summary>Creates a <see cref="PocketBaseClient"/> pointed at this container.</summary>
|
/// <summary>Creates a <see cref="PocketBaseClient"/> pointed at this container.</summary>
|
||||||
public PocketBaseClient CreateClient() => new(_http);
|
public PocketBaseClient CreateClient() => new(_http);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The base URL the container's PocketBase is reachable at. Set once the
|
||||||
|
/// container is up; used to point the web app's client at this instance in the
|
||||||
|
/// route smoke tests.
|
||||||
|
/// </summary>
|
||||||
|
public Uri BaseUrl => _http.BaseAddress
|
||||||
|
?? throw new InvalidOperationException("PocketBase is not started yet.");
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
// Clear any leftover from an earlier run that was aborted before DisposeAsync
|
// Clear any leftover from an earlier run that was aborted before DisposeAsync
|
||||||
@@ -137,15 +154,15 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Authenticates as superuser, then creates the collections and records. This
|
/// Authenticates as superuser, then imports the shared collection schema and
|
||||||
/// mirrors the shape the client reads, not the full production schema.
|
/// seeds the fixed test records the tests assert against.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task SeedAsync()
|
private async Task SeedAsync()
|
||||||
{
|
{
|
||||||
var token = await AuthenticateAsync(_http);
|
var token = await AuthenticateAsync(_http);
|
||||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(token);
|
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(token);
|
||||||
|
|
||||||
await CreateCollectionsAsync(_http);
|
await ImportCollectionsAsync(_http);
|
||||||
await SeedRecordsAsync(_http);
|
await SeedRecordsAsync(_http);
|
||||||
|
|
||||||
// Drop the superuser token so the tests read as an anonymous visitor would,
|
// Drop the superuser token so the tests read as an anonymous visitor would,
|
||||||
@@ -176,75 +193,124 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task CreateCollectionsAsync(HttpClient http)
|
/// <summary>
|
||||||
|
/// Imports the four content collections from the shared schema file, the same
|
||||||
|
/// one the dev seed migration reads (<c>pb/pb_migrations/collections_schema.json</c>).
|
||||||
|
/// Uses PocketBase's <c>/api/collections/import</c> so the whole schema is
|
||||||
|
/// defined in one place instead of being typed out here.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task ImportCollectionsAsync(HttpClient http)
|
||||||
{
|
{
|
||||||
// listRule/viewRule = "" means publicly readable; the client's
|
// The file maps name -> collection object; the import endpoint wants a
|
||||||
// filter=public=true does the visibility gating on top.
|
// flat list, so unwrap the values. deleteMissing=false leaves anything
|
||||||
await CreateCollectionAsync(http, "pages", new object[]
|
// already present untouched (the import is idempotent).
|
||||||
{
|
var path = Path.Combine(RepoRoot(), "pb", "pb_migrations", "collections_schema.json");
|
||||||
new { name = "title", type = "text", required = true },
|
using var schema = JsonDocument.Parse(await File.ReadAllTextAsync(path));
|
||||||
new { name = "body", type = "editor" },
|
var collections = schema.RootElement.EnumerateObject()
|
||||||
new { name = "location", type = "select", maxSelect = 1, values = new[] { "header", "footer" } },
|
.Select(property => property.Value)
|
||||||
new { name = "order", type = "number" },
|
.ToArray();
|
||||||
new { name = "slug", type = "text", required = true },
|
|
||||||
new { name = "embed", type = "select", maxSelect = 3, values = new[] { "posts", "events", "faqs" } },
|
|
||||||
new { name = "public", type = "bool" },
|
|
||||||
});
|
|
||||||
|
|
||||||
await CreateCollectionAsync(http, "posts", new object[]
|
// Serialize the JsonElement list back to JSON for the request body.
|
||||||
{
|
var body = JsonSerializer.Serialize(new { collections, deleteMissing = false });
|
||||||
new { name = "date", type = "date" },
|
using var content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||||
new { name = "title", type = "text", required = true },
|
var response = await http.PutAsync(new Uri("/api/collections/import", UriKind.Relative), content);
|
||||||
new { name = "body", type = "editor" },
|
|
||||||
new { name = "slug", type = "text", required = true },
|
|
||||||
new { name = "public", type = "bool" },
|
|
||||||
});
|
|
||||||
|
|
||||||
await CreateCollectionAsync(http, "events", new object[]
|
|
||||||
{
|
|
||||||
new { name = "start", type = "date", required = true },
|
|
||||||
new { name = "end", type = "date" },
|
|
||||||
new { name = "title", type = "text", required = true },
|
|
||||||
new { name = "location", type = "text" },
|
|
||||||
new { name = "note", type = "text" },
|
|
||||||
new { name = "public", type = "bool" },
|
|
||||||
});
|
|
||||||
|
|
||||||
await CreateCollectionAsync(http, "faqs", new object[]
|
|
||||||
{
|
|
||||||
new { name = "question", type = "text", required = true },
|
|
||||||
new { name = "answer", type = "editor" },
|
|
||||||
new { name = "topic", type = "select", maxSelect = 1, values = new[] { "mensa", "schliessfach", "elterneuro", "elternarbeit" } },
|
|
||||||
new { name = "public", type = "bool" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task CreateCollectionAsync(HttpClient http, string name, object[] fields)
|
|
||||||
{
|
|
||||||
var response = await http.PostAsJsonAsync("/api/collections", new
|
|
||||||
{
|
|
||||||
name,
|
|
||||||
type = "base",
|
|
||||||
listRule = "",
|
|
||||||
viewRule = "",
|
|
||||||
fields,
|
|
||||||
});
|
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task SeedRecordsAsync(HttpClient http)
|
private static async Task SeedRecordsAsync(HttpClient http)
|
||||||
{
|
{
|
||||||
// Pages: one header page, one footer page. Both public.
|
// Pages that the route smoke tests reach through the navigation, the
|
||||||
|
// footer or the FAQ hub. Every page carries a location ("header" or
|
||||||
|
// "footer") -- the field is required, matching production. All public.
|
||||||
|
// The home page sits in the header at order 1, like production; the brand
|
||||||
|
// 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 = "header",
|
// The body drives the home hero: "# heading" -> h1, the paragraph -> intro,
|
||||||
|
// 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,
|
order = 1, slug = "home", embed = new[] { "posts", "events" }, @public = true,
|
||||||
});
|
});
|
||||||
await CreateRecordAsync(http, "pages", new
|
await CreateRecordAsync(http, "pages", new
|
||||||
|
{
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
await CreateRecordAsync(http, "pages", new
|
||||||
|
{
|
||||||
|
// The body uses a design block, so the smoke tests can follow a ":::"
|
||||||
|
// container through PocketBase and the page into the rendered HTML.
|
||||||
|
title = "Förderverein",
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
// Page record for the FAQ list. Route /faqs is served by FaqList, which reads
|
||||||
|
// 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
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
await CreateRecordAsync(http, "pages", new
|
||||||
|
{
|
||||||
|
title = "Downloads", body = "# Downloads", location = "header",
|
||||||
|
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
|
||||||
|
{
|
||||||
|
title = "Beiträge", body = "Neuigkeiten aus dem Elternbeirat.",
|
||||||
|
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
|
||||||
{
|
{
|
||||||
title = "Kontakt", body = "Mail an uns", location = "footer",
|
title = "Kontakt", body = "Mail an uns", location = "footer",
|
||||||
order = 1, slug = "contact", embed = Array.Empty<string>(), @public = true,
|
order = 1, slug = "contact", embed = Array.Empty<string>(), @public = true,
|
||||||
});
|
});
|
||||||
|
await CreateRecordAsync(http, "pages", new
|
||||||
|
{
|
||||||
|
title = "Impressum", body = "# Impressum", location = "footer",
|
||||||
|
order = 2, slug = "imprint", embed = Array.Empty<string>(), @public = true,
|
||||||
|
});
|
||||||
|
await CreateRecordAsync(http, "pages", new
|
||||||
|
{
|
||||||
|
title = "Datenschutz", body = "# Datenschutz", location = "footer",
|
||||||
|
order = 3, slug = "privacy", embed = Array.Empty<string>(), @public = true,
|
||||||
|
});
|
||||||
// A draft page that must never appear (public=false).
|
// A draft page that must never appear (public=false).
|
||||||
await CreateRecordAsync(http, "pages", new
|
await CreateRecordAsync(http, "pages", new
|
||||||
{
|
{
|
||||||
@@ -252,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
|
||||||
@@ -264,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,14 @@
|
|||||||
|
namespace Elternbeirat.Web.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Groups every test that needs the seeded PocketBase container into one xUnit
|
||||||
|
/// collection, so the single <see cref="PocketBaseFixture"/> (one container, one
|
||||||
|
/// seed) is shared across those classes instead of started once per class. The
|
||||||
|
/// test project runs one PocketBase for the whole run under the fixed project
|
||||||
|
/// name <c>eb-test-stack</c>; sharing avoids two classes fighting over it.
|
||||||
|
/// </summary>
|
||||||
|
[CollectionDefinition(Name)]
|
||||||
|
public sealed class PocketBaseTestGroup : ICollectionFixture<PocketBaseFixture>
|
||||||
|
{
|
||||||
|
public const string Name = "PocketBase";
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
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;
|
||||||
|
|
||||||
@@ -7,39 +10,81 @@ namespace Elternbeirat.Web.Tests;
|
|||||||
/// Smoke tests: every known route must return 200, unknown routes must return
|
/// Smoke tests: every known route must return 200, unknown routes must return
|
||||||
/// 404. This catches broken content files, renamed slugs or routing regressions
|
/// 404. This catches broken content files, renamed slugs or routing regressions
|
||||||
/// before a deploy.
|
/// before a deploy.
|
||||||
|
/// <para>
|
||||||
|
/// The post routes now read from PocketBase, so the tests point the web app at the
|
||||||
|
/// seeded container from <see cref="PocketBaseFixture"/> (shared via the
|
||||||
|
/// collection) and assert against its known slugs. Requires Docker, like the
|
||||||
|
/// client tests.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RouteSmokeTests : IClassFixture<WebApplicationFactory<Program>>
|
[Collection(PocketBaseTestGroup.Name)]
|
||||||
|
public sealed partial class RouteSmokeTests : IDisposable
|
||||||
{
|
{
|
||||||
|
// WithWebHostBuilder returns a new factory that wraps the base one; both are
|
||||||
|
// disposable, so both are held and disposed to avoid leaking either.
|
||||||
|
private readonly WebApplicationFactory<Program> _baseFactory = new();
|
||||||
private readonly WebApplicationFactory<Program> _factory;
|
private readonly WebApplicationFactory<Program> _factory;
|
||||||
|
|
||||||
public RouteSmokeTests(WebApplicationFactory<Program> factory)
|
public RouteSmokeTests(PocketBaseFixture pocketBase)
|
||||||
{
|
{
|
||||||
_factory = factory;
|
// Point the app's PocketBaseClient at the seeded test container instead of
|
||||||
|
// the value from appsettings. UseSetting (not ConfigureAppConfiguration)
|
||||||
|
// because it wins over appsettings.Development.json, which otherwise pins the
|
||||||
|
// client to localhost:8090 while the test container uses a random port.
|
||||||
|
_factory = WithClock(
|
||||||
|
_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()
|
||||||
|
{
|
||||||
|
_factory.Dispose();
|
||||||
|
_baseFactory.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
/// 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 =
|
||||||
[
|
[
|
||||||
"/",
|
"/",
|
||||||
"/vorstandsteam",
|
"/board",
|
||||||
"/foerderverein",
|
"/patrons",
|
||||||
"/faq",
|
"/faqs",
|
||||||
"/faq-mensa",
|
"/faq-lunch",
|
||||||
"/faq-schliessfach",
|
|
||||||
"/faq-elterneuro",
|
|
||||||
"/faq-elternarbeit",
|
|
||||||
"/downloads",
|
"/downloads",
|
||||||
"/kontakt",
|
"/contact",
|
||||||
"/impressum",
|
"/imprint",
|
||||||
"/datenschutz",
|
"/privacy",
|
||||||
"/beitraege",
|
"/posts",
|
||||||
"/beitraege/neuer-vorstand-gewaehlt",
|
"/posts/new-board",
|
||||||
"/beitraege/neue-sporthalle-eroeffnet",
|
"/posts/new-hall",
|
||||||
"/termine",
|
"/events",
|
||||||
"/termine.ics",
|
"/events.ics",
|
||||||
];
|
];
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
@@ -55,7 +100,8 @@ public sealed class RouteSmokeTests : IClassFixture<WebApplicationFactory<Progra
|
|||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("/gibt-es-nicht")]
|
[InlineData("/gibt-es-nicht")]
|
||||||
[InlineData("/beitraege/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();
|
||||||
@@ -64,4 +110,531 @@ public sealed class RouteSmokeTests : IClassFixture<WebApplicationFactory<Progra
|
|||||||
|
|
||||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Faqs_route_renders_the_topic_accordion()
|
||||||
|
{
|
||||||
|
// /faqs is a literal route (FaqList) that must win over the /{Slug}
|
||||||
|
// catch-all content page. It reads the "faqs" page for heading and intro
|
||||||
|
// 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 html = await client.GetStringAsync(new Uri("/faqs", UriKind.Relative));
|
||||||
|
|
||||||
|
html.ShouldContain("FAQs"); // heading from the page title
|
||||||
|
html.ShouldContain("Mensa und Mittagessen"); // German topic heading (a group)
|
||||||
|
html.ShouldContain("Wann gibt es Mittagessen?"); // a question nested in the group
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Layout_navigation_is_built_from_the_pages()
|
||||||
|
{
|
||||||
|
// The header and footer menus are generated from the "pages" records, not
|
||||||
|
// hard-coded. Assert a header link, a footer link, and that the non-public
|
||||||
|
// draft page never leaks into the menu.
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
|
||||||
|
|
||||||
|
html.ShouldContain("href=\"/board\""); // header page
|
||||||
|
html.ShouldContain("href=\"/imprint\""); // footer page
|
||||||
|
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]
|
||||||
|
public async Task Home_renders_the_embeds_its_page_opts_into()
|
||||||
|
{
|
||||||
|
// The home page's embed field is ["posts","events"], so the root route
|
||||||
|
// renders the posts teaser and the events teaser below the intro.
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
|
||||||
|
|
||||||
|
html.ShouldContain("Aktuelle Beiträge"); // posts embed heading
|
||||||
|
html.ShouldContain("Neuer Vorstand"); // a seeded post title
|
||||||
|
html.ShouldContain("Kommende Termine"); // events embed heading
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Content_page_renders_its_body()
|
||||||
|
{
|
||||||
|
// /patrons is a catch-all content page (the /{Slug} route). Assert it renders
|
||||||
|
// its Markdown body, proving the ContentPage path works for an arbitrary slug.
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/patrons", UriKind.Relative));
|
||||||
|
|
||||||
|
html.ShouldContain("Der Förderverein unterstützt die Schule."); // the page body, rendered from Markdown
|
||||||
|
}
|
||||||
|
|
||||||
|
[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><link></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><footer></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><section></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></a></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><ul></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();
|
||||||
}
|
}
|
||||||
@@ -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("<b>Name</b>");
|
||||||
|
}
|
||||||
|
|
||||||
|
[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();
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +1,110 @@
|
|||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
<div class="page">
|
@* Header, main and footer are plain block children of <body>, which is the page's
|
||||||
<header class="site-header">
|
scroll root. The header is position:sticky and, because it is NOT a flex/grid
|
||||||
|
child (a stretched flex item never sticks -- it fills its cell and unsticks
|
||||||
|
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
|
||||||
|
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">
|
||||||
<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
|
||||||
<nav class="main-nav">
|
school small below it. One link, so the whole mark leads home. *@
|
||||||
<NavLink href="/" Match="NavLinkMatch.All">Start</NavLink>
|
<a class="brand" href="/">
|
||||||
<NavLink href="/vorstandsteam">Vorstandsteam</NavLink>
|
<Signet />
|
||||||
<NavLink href="/foerderverein">Förderverein</NavLink>
|
<span class="brand-text">
|
||||||
<NavLink href="/beitraege">Beiträge</NavLink>
|
<span class="brand-name">Elternbeirat</span>
|
||||||
<NavLink href="/termine">Termine</NavLink>
|
<span class="brand-school">IGMH Mannheim</span>
|
||||||
<NavLink href="/faq">FAQ</NavLink>
|
</span>
|
||||||
<NavLink href="/downloads">Downloads</NavLink>
|
</a>
|
||||||
<NavLink href="/kontakt">Kontakt</NavLink>
|
|
||||||
|
@* CSS-only mobile menu: the checkbox holds the open/closed state, the
|
||||||
|
label is the hamburger button, and :checked reveals the nav below (see
|
||||||
|
MainLayout.razor.css). No JavaScript, no @@rendermode -- works under
|
||||||
|
static SSR. The checkbox is only visually hidden on a phone, not
|
||||||
|
removed, so it is what keyboard and screen-reader users operate: Tab
|
||||||
|
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="visually-hidden">Hauptmenü anzeigen</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<nav id="main-nav" class="main-nav" aria-label="Hauptmenü">
|
||||||
|
@* The home link comes from PocketBase like every other entry (the
|
||||||
|
"home" page carries location "header"), so it is rendered by the
|
||||||
|
loop below -- no separate hard-coded home link, which would show
|
||||||
|
up twice next to the PocketBase one. *@
|
||||||
|
@foreach (var link in _header)
|
||||||
|
{
|
||||||
|
<NavLink href="@link.Href" Match="@(link.Href == "/" ? NavLinkMatch.All : NavLinkMatch.Prefix)">@link.Title</NavLink>
|
||||||
|
}
|
||||||
</nav>
|
</nav>
|
||||||
</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 />
|
||||||
<a href="/kontakt">Kontakt</a>
|
<span>Elternbeirat der IGMH</span>
|
||||||
<a href="/impressum">Impressum</a>
|
</div>
|
||||||
<a href="/datenschutz">Datenschutz</a>
|
|
||||||
</nav>
|
@* Both link columns come from PocketBase like the header. An empty list
|
||||||
|
(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>
|
||||||
</div>
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Elternbeirat.PocketBase;
|
||||||
|
using Elternbeirat.Web.Features.Layout;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Components.Layout;
|
||||||
|
|
||||||
|
public partial class MainLayout
|
||||||
|
{
|
||||||
|
[LoggerMessage(
|
||||||
|
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]
|
||||||
|
private PocketBaseClient PocketBase { get; set; } = null!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private ILogger<MainLayout> Logger { get; set; } = null!;
|
||||||
|
|
||||||
|
[CascadingParameter]
|
||||||
|
private HttpContext? HttpContext { get; set; }
|
||||||
|
|
||||||
|
private IReadOnlyList<NavLinkItem> _header = [];
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pages = await PocketBase.GetPagesAsync(HttpContext?.RequestAborted ?? default);
|
||||||
|
_header = NavBuilder.Header(pages);
|
||||||
|
_footer = NavBuilder.Footer(pages);
|
||||||
|
}
|
||||||
|
catch (PocketBaseUnavailableException ex)
|
||||||
|
{
|
||||||
|
// Only the menu fails: render the chrome with an empty menu instead of a
|
||||||
|
// 500, so every page stays reachable. The brand link still leads home.
|
||||||
|
LogSourceUnavailable(Logger, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,99 +19,317 @@
|
|||||||
top: 0.5rem;
|
top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Page frame: header and footer stay put, the content grows and pushes the
|
/* Header: pinned to the top while the page scrolls, so the brand and (on mobile)
|
||||||
footer to the bottom even on short pages. */
|
the hamburger button are always reachable. It is a plain block child of <body>
|
||||||
.page {
|
(NOT a flex item) -- a stretched flex child fills its cell and never sticks.
|
||||||
display: flex;
|
z-index keeps it above the content; the opaque background stops content showing
|
||||||
flex-direction: column;
|
through as it scrolls underneath. */
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.site-header {
|
.site-header {
|
||||||
border-bottom: 1px solid #e0e0e0;
|
/* The brand gradient, blue into green -- the strongest, most visible cue that
|
||||||
background: #fff;
|
carries the school's colours across every page. Built on --color-header, so
|
||||||
|
it stays deep in dark mode too (see app.css). No accent underline any more:
|
||||||
|
the gradient already ends in green, and the active menu item carries the
|
||||||
|
green stroke. */
|
||||||
|
background: var(--gradient-brand);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-inner {
|
.header-inner {
|
||||||
max-width: 60rem;
|
max-width: var(--content-width);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1rem 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: 0.5rem 1.5rem;
|
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: #1f3a5f;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-nav a {
|
/* The hamburger button and its state checkbox are hidden on wide screens; the
|
||||||
color: #333;
|
media query below turns them on for narrow ones. display:none (beating the
|
||||||
text-decoration: none;
|
global .visually-hidden the checkbox also carries) takes the checkbox out of the
|
||||||
padding: 0.2rem 0;
|
tab order here, where there is no menu to open. */
|
||||||
border-bottom: 2px solid transparent;
|
.nav-toggle,
|
||||||
|
.nav-toggle-label {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-nav a:hover {
|
/* The link colours/states for .main-nav a live in the GLOBAL app.css, not here.
|
||||||
color: #1f3a5f;
|
Reason: NavLink renders plain <a> elements that carry no scoped-CSS attribute
|
||||||
}
|
(b-xxxxx), so a scoped rule ".main-nav a" would not match them. See app.css. */
|
||||||
|
|
||||||
/* NavLink adds .active to the link of the current route. */
|
/* Content: at least tall enough that the footer sits at the bottom of the viewport
|
||||||
.main-nav a.active {
|
on short pages. 100dvh - 5rem leaves room for the header with its signet (which
|
||||||
color: #1f3a5f;
|
sits above in normal flow); dvh follows the mobile browser's shrinking toolbar.
|
||||||
border-bottom-color: #1f3a5f;
|
On long pages the content simply grows past this and pushes the footer down
|
||||||
}
|
naturally. */
|
||||||
|
|
||||||
/* Content */
|
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
min-height: calc(100dvh - 5rem);
|
||||||
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 60rem;
|
max-width: var(--content-width);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1.5rem 1.25rem 3rem;
|
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 #e0e0e0;
|
position: relative;
|
||||||
background: #fafafa;
|
margin-top: var(--space-6);
|
||||||
color: #555;
|
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: 60rem;
|
max-width: var(--content-width);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1rem 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-4);
|
||||||
gap: 0.5rem 1.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-nav {
|
.footer-brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.25rem;
|
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: #555;
|
color: var(--color-on-footer-soft);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-nav a:hover {
|
.footer-nav a:hover {
|
||||||
color: #1f3a5f;
|
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
|
||||||
|
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. */
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
/* The brand stays on the left, the hamburger goes to the right, and the nav
|
||||||
|
occupies its own full-width row below both. */
|
||||||
|
.header-inner {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
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. */
|
||||||
|
.nav-toggle-label {
|
||||||
|
display: block;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin: -0.5rem; /* keep the tap target large without shifting layout */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The three-bar icon, drawn with one element plus its ::before/::after. */
|
||||||
|
.nav-toggle-bar,
|
||||||
|
.nav-toggle-bar::before,
|
||||||
|
.nav-toggle-bar::after {
|
||||||
|
display: block;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--color-on-header);
|
||||||
|
content: "";
|
||||||
|
transition: transform var(--transition), background-color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-toggle-bar::before {
|
||||||
|
transform: translateY(-6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-toggle-bar::after {
|
||||||
|
transform: translateY(4px); /* 6px minus the bar's own 2px height */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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 {
|
||||||
|
flex-basis: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: max-height 0.2s ease, visibility 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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
|
||||||
|
instead of running off the screen. */
|
||||||
|
.nav-toggle:checked ~ .main-nav {
|
||||||
|
visibility: visible;
|
||||||
|
transition: max-height 0.2s ease;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile per-link spacing for .main-nav a is in the global app.css too, for
|
||||||
|
the same scoped-attribute reason as above. */
|
||||||
|
|
||||||
|
/* The footer columns stack: one column, brand on top. */
|
||||||
|
.footer-inner {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Calendar entries of the Elternbeirat.
|
|
||||||
#
|
|
||||||
# One entry per event. Required keys: title, start.
|
|
||||||
# start/end:
|
|
||||||
# date only -> "2026-03-15" (all-day)
|
|
||||||
# date with time -> "2026-03-15 19:30"
|
|
||||||
# location and note are optional.
|
|
||||||
|
|
||||||
- title: Elternbeiratssitzung
|
|
||||||
start: "2026-10-08 19:30"
|
|
||||||
location: Lehrerzimmer
|
|
||||||
note: Themen bitte vorab per E-Mail einreichen.
|
|
||||||
|
|
||||||
- title: Herbstbasar
|
|
||||||
start: "2026-11-14 10:00"
|
|
||||||
end: "2026-11-14 16:00"
|
|
||||||
location: Aula
|
|
||||||
|
|
||||||
- title: Weihnachtsferien
|
|
||||||
start: "2026-12-21"
|
|
||||||
end: "2027-01-06"
|
|
||||||
|
|
||||||
- title: Elternsprechtag
|
|
||||||
start: "2026-09-05 15:00"
|
|
||||||
end: "2026-09-05 18:00"
|
|
||||||
location: Klassenräume
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
---
|
|
||||||
title: Datenschutz
|
|
||||||
---
|
|
||||||
|
|
||||||
# Datenschutzerklärung
|
|
||||||
|
|
||||||
*TODO: Rechtlich verbindliche Datenschutzerklärung ergänzen. Erst nach Freigabe
|
|
||||||
finalisieren – siehe `docs/recht.md`.*
|
|
||||||
|
|
||||||
Diese Website wird bewusst ohne externe Ressourcen betrieben: keine CDN-Skripte,
|
|
||||||
keine Google Fonts, keine Karten- oder Video-Einbettungen. Schriften werden von
|
|
||||||
unserem eigenen Server ausgeliefert. Dadurch werden beim Besuch keine Daten an
|
|
||||||
Dritte übertragen.
|
|
||||||
|
|
||||||
## Verantwortliche Stelle
|
|
||||||
|
|
||||||
*TODO: siehe [Impressum](/impressum).*
|
|
||||||
|
|
||||||
## Server-Logdaten
|
|
||||||
|
|
||||||
*TODO: beschreiben, welche Zugriffsdaten der Server (bzw. der vorgelagerte
|
|
||||||
Proxy) protokolliert und wie lange.*
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
---
|
|
||||||
title: Downloads
|
|
||||||
---
|
|
||||||
|
|
||||||
# Downloads
|
|
||||||
|
|
||||||
Hier stellen wir Unterlagen zur Elternarbeit als Datei bereit: Protokolle der
|
|
||||||
Sitzungen, Elterninformationen und die Geschäftsordnung.
|
|
||||||
|
|
||||||
## Unterlagen
|
|
||||||
|
|
||||||
*TODO: Download-Liste ergänzen. Dateien liegen unter `wwwroot/downloads/` und
|
|
||||||
werden hier verlinkt, z. B.:*
|
|
||||||
|
|
||||||
- *Protokoll der Vollversammlung (PDF)*
|
|
||||||
- *Geschäftsordnung des Elternbeirats (PDF)*
|
|
||||||
- *Elterninformation Elternvertreter (PDF)*
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
title: FAQ Elternarbeit
|
|
||||||
---
|
|
||||||
|
|
||||||
# Elternarbeit
|
|
||||||
|
|
||||||
Unterstützung für neu gewählte und erfahrene Elternvertreterinnen und
|
|
||||||
Elternvertreter.
|
|
||||||
|
|
||||||
## Welche Aufgaben habe ich als Elternvertreter?
|
|
||||||
|
|
||||||
*TODO: Aufgaben und Rechte der Klassenelternvertretung beschreiben.*
|
|
||||||
|
|
||||||
## Wie läuft die Zusammenarbeit mit dem Elternbeirat?
|
|
||||||
|
|
||||||
*TODO: Sitzungsrhythmus und Ansprechpartner ergänzen.*
|
|
||||||
|
|
||||||
## Wo finde ich Vorlagen und Unterlagen?
|
|
||||||
|
|
||||||
Unterlagen und Protokolle finden Sie im Bereich [Downloads](/downloads).
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
---
|
|
||||||
title: FAQ Elterneuro
|
|
||||||
---
|
|
||||||
|
|
||||||
# Elterneuro
|
|
||||||
|
|
||||||
Der Elterneuro ist ein freiwilliger Beitrag der Eltern, mit dem der Elternbeirat
|
|
||||||
Projekte an der Schule unterstützt.
|
|
||||||
|
|
||||||
## Wofür wird der Elterneuro verwendet?
|
|
||||||
|
|
||||||
*TODO: konkrete Beispiele für die Verwendung ergänzen.*
|
|
||||||
|
|
||||||
## Wie hoch ist der Beitrag?
|
|
||||||
|
|
||||||
Der Elterneuro ist freiwillig.
|
|
||||||
|
|
||||||
*TODO: übliche Beitragshöhe und Zahlungsweg ergänzen.*
|
|
||||||
|
|
||||||
## An wen kann ich mich bei Fragen wenden?
|
|
||||||
|
|
||||||
Bei Fragen erreichen Sie uns über die [Kontaktseite](/kontakt).
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
---
|
|
||||||
title: FAQ Mensa
|
|
||||||
---
|
|
||||||
|
|
||||||
# Mensa
|
|
||||||
|
|
||||||
Alles rund um das Mittagessen an der IGMH: Anmeldung, Guthaben und Fristen.
|
|
||||||
|
|
||||||
## Wie melde ich mein Kind an?
|
|
||||||
|
|
||||||
Die Essensbestellung läuft über das System i-NET Menue.
|
|
||||||
|
|
||||||
*TODO: Ablauf der Erstanmeldung und Zugangsdaten beschreiben.*
|
|
||||||
|
|
||||||
## Wie lade ich Guthaben auf?
|
|
||||||
|
|
||||||
*TODO: Aufladeweg und Zahlungsarten ergänzen.*
|
|
||||||
|
|
||||||
## Bis wann kann ich bestellen oder stornieren?
|
|
||||||
|
|
||||||
*TODO: Fristen für Bestellung und Stornierung ergänzen.*
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
---
|
|
||||||
title: FAQ Schließfach
|
|
||||||
---
|
|
||||||
|
|
||||||
# Schließfächer
|
|
||||||
|
|
||||||
Informationen zur Anmietung eines Schließfachs an der IGMH.
|
|
||||||
|
|
||||||
## Welche Größen gibt es und was kosten sie?
|
|
||||||
|
|
||||||
*TODO: Verfügbare Größen und Preise ergänzen.*
|
|
||||||
|
|
||||||
## Wie miete ich ein Schließfach an?
|
|
||||||
|
|
||||||
Die Verwaltung läuft über das Serviceportal von AstraDirect.
|
|
||||||
|
|
||||||
*TODO: Ablauf der Anmietung und Link zum Portal ergänzen.*
|
|
||||||
|
|
||||||
## Wie tausche oder kündige ich mein Fach?
|
|
||||||
|
|
||||||
*TODO: Vorgehen für Fachtausch und Kündigung ergänzen.*
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
---
|
|
||||||
title: FAQ
|
|
||||||
---
|
|
||||||
|
|
||||||
# Häufige Fragen
|
|
||||||
|
|
||||||
Hier finden Eltern Antworten auf wiederkehrende Fragen rund um den Schulalltag.
|
|
||||||
Die Themen sind nach Bereichen aufgeteilt:
|
|
||||||
|
|
||||||
- [Mensa](/faq-mensa) – Anmeldung, Guthaben, Fristen
|
|
||||||
- [Schließfächer](/faq-schliessfach) – Größen, Preise, Verwaltung
|
|
||||||
- [Elterneuro](/faq-elterneuro) – freiwilliger Beitrag und Verwendung
|
|
||||||
- [Elternarbeit](/faq-elternarbeit) – Tipps für Elternvertreterinnen und Elternvertreter
|
|
||||||
|
|
||||||
*TODO: Reihenfolge und weitere Themen ergänzen, sobald die Unterseiten stehen.*
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
---
|
|
||||||
title: Förderverein
|
|
||||||
---
|
|
||||||
|
|
||||||
# Förderverein „Freunde der IGMH"
|
|
||||||
|
|
||||||
Der Förderverein „Freunde der IGMH" unterstützt die Schule bei Anschaffungen und
|
|
||||||
Projekten, die aus dem regulären Budget nicht finanziert werden können. Mitglieder
|
|
||||||
sind Eltern, Lehrkräfte, Ehemalige und Förderer der Schule.
|
|
||||||
|
|
||||||
## Was der Verein fördert
|
|
||||||
|
|
||||||
- Ausstattung für Unterricht und Arbeitsgemeinschaften
|
|
||||||
- Musische, sportliche und kulturelle Projekte
|
|
||||||
- Anschaffungen, die allen Schülerinnen und Schülern zugutekommen
|
|
||||||
|
|
||||||
## Mitglied werden
|
|
||||||
|
|
||||||
*TODO: Beitrittsformular bzw. Ansprechpartner und Beitragshöhe ergänzen.*
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
---
|
|
||||||
title: Impressum
|
|
||||||
---
|
|
||||||
|
|
||||||
# Impressum
|
|
||||||
|
|
||||||
*TODO: Rechtlich verbindliches Impressum ergänzen. Erst nach Freigabe mit echten
|
|
||||||
Daten füllen – siehe `docs/recht.md`.*
|
|
||||||
|
|
||||||
## Angaben gemäß § 5 DDG
|
|
||||||
|
|
||||||
*TODO: Name und Anschrift des Diensteanbieters (Elternbeirat / Schule).*
|
|
||||||
|
|
||||||
## Vertreten durch
|
|
||||||
|
|
||||||
*TODO: gesetzlicher Vertreter.*
|
|
||||||
|
|
||||||
## Kontakt
|
|
||||||
|
|
||||||
*TODO: E-Mail-Adresse (siehe [Kontakt](/kontakt)).*
|
|
||||||
|
|
||||||
## Verantwortlich i. S. d. § 18 Abs. 2 MStV
|
|
||||||
|
|
||||||
*TODO: Name und Anschrift der verantwortlichen Person.*
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
---
|
|
||||||
title: Kontakt
|
|
||||||
---
|
|
||||||
|
|
||||||
# Kontakt
|
|
||||||
|
|
||||||
Sie erreichen den Elternbeirat der IGMH per E-Mail:
|
|
||||||
|
|
||||||
[TODO-adresse@example.org](mailto:TODO-adresse@example.org)
|
|
||||||
|
|
||||||
*TODO: Echte Kontakt-E-Mail-Adresse eintragen.*
|
|
||||||
|
|
||||||
## Anschrift
|
|
||||||
|
|
||||||
Elternbeirat der IGMH
|
|
||||||
*TODO: Anschrift der Schule ergänzen.*
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
---
|
|
||||||
title: Vorstandsteam
|
|
||||||
---
|
|
||||||
|
|
||||||
# Das Vorstandsteam
|
|
||||||
|
|
||||||
Der Elternbeirat der IGMH wird von einem ehrenamtlichen Vorstand geleitet.
|
|
||||||
Er vertritt die Elternschaft gegenüber Schule und Schulträger und koordiniert
|
|
||||||
die Arbeit der Klassenelternvertreter.
|
|
||||||
|
|
||||||
## Aufgaben des Vorstands
|
|
||||||
|
|
||||||
- Vertretung der Eltern in der Schulkonferenz
|
|
||||||
- Zusammenarbeit mit Schulleitung und Kollegium
|
|
||||||
- Organisation der Vollversammlungen und Sitzungen
|
|
||||||
- Ansprechpartner für Fragen rund um Mensa, Schließfächer und Förderverein
|
|
||||||
|
|
||||||
*Die namentliche Vorstellung der Vorstandsmitglieder folgt.*
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: Neue Sporthalle feierlich eröffnet
|
|
||||||
date: 2025-10-05
|
|
||||||
---
|
|
||||||
|
|
||||||
# Neue Sporthalle feierlich eröffnet
|
|
||||||
|
|
||||||
Die neue Sporthalle der IGMH ist eröffnet. Schülerinnen, Schüler und Lehrkräfte
|
|
||||||
haben damit deutlich mehr Platz für Sportunterricht und Arbeitsgemeinschaften.
|
|
||||||
|
|
||||||
*TODO: Bericht und Fotos ergänzen.*
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
---
|
|
||||||
title: Neuer Vorstand des Elternbeirats gewählt
|
|
||||||
date: 2025-02-26
|
|
||||||
---
|
|
||||||
|
|
||||||
# Neuer Vorstand des Elternbeirats gewählt
|
|
||||||
|
|
||||||
Bei der Vollversammlung hat der Elternbeirat der IGMH einen neuen Vorstand
|
|
||||||
gewählt. Das Team bedankt sich für das entgegengebrachte Vertrauen und freut
|
|
||||||
sich auf die gemeinsame Arbeit im neuen Schuljahr.
|
|
||||||
|
|
||||||
*TODO: Namen und Ämter des neuen Vorstands ergänzen (nach Freigabe).*
|
|
||||||
@@ -19,13 +19,12 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Markdig" />
|
<PackageReference Include="Markdig" />
|
||||||
<PackageReference Include="YamlDotNet" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Content ships as files inside the image, not in a volume. Copy it to
|
<!-- The typed PocketBaseClient the app reads its content from. Referencing
|
||||||
the output directory so the container can find it. -->
|
PocketBase brings in Elternbeirat.Contracts (the DTOs) transitively. -->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="Content\**\*" CopyToOutputDirectory="PreserveNewest" />
|
<ProjectReference Include="..\Elternbeirat.PocketBase\Elternbeirat.PocketBase.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
@using Elternbeirat.Web.Features.Events
|
||||||
|
@if (_upcomingEvents.Count > 0)
|
||||||
|
{
|
||||||
|
<section class="embed embed-events">
|
||||||
|
<h2>Kommende Termine</h2>
|
||||||
|
<ul class="event-list">
|
||||||
|
@foreach (var ev in _upcomingEvents)
|
||||||
|
{
|
||||||
|
@* A teaser: each card leads to the events page with the full list. *@
|
||||||
|
<li><EventCard Value="ev" Href="/events" /></li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.PocketBase;
|
||||||
|
using Elternbeirat.Web.Features.Events;
|
||||||
|
using Elternbeirat.Web.Shared;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Features.Embeds;
|
||||||
|
|
||||||
|
public partial class EventsEmbed
|
||||||
|
{
|
||||||
|
// Number of upcoming events shown in the teaser; the full list is one link away.
|
||||||
|
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]
|
||||||
|
private PocketBaseClient PocketBase { get; set; } = null!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private ILogger<EventsEmbed> Logger { get; set; } = null!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private TimeProvider Time { get; set; } = null!;
|
||||||
|
|
||||||
|
[CascadingParameter]
|
||||||
|
private HttpContext? HttpContext { get; set; }
|
||||||
|
|
||||||
|
private IReadOnlyList<Event> _upcomingEvents = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
IReadOnlyList<Event> events;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
events = await PocketBase.GetEventsAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (PocketBaseUnavailableException exception)
|
||||||
|
{
|
||||||
|
// Only part of the page fails: omit the teaser, keep the page (and its
|
||||||
|
// 200) intact. A missing teaser is degraded, not broken.
|
||||||
|
LogSourceUnavailable(Logger, exception);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teaser: the soonest upcoming events, earliest first. An event earlier
|
||||||
|
// today still counts as upcoming.
|
||||||
|
_upcomingEvents = [.. UpcomingEvents.From(events, BerlinClock.Today(Time)).Take(TeaserCount)];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/* The card is EventCard and the list grid lives in the global app.css, shared with
|
||||||
|
the full events page. Only the embed's spacing on the home page is specific here. */
|
||||||
|
.embed-events {
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@* Renders the dynamic blocks a page opts into via its "embed" field, in the
|
||||||
|
given order. Unknown keys are ignored so a typo in PocketBase can never break
|
||||||
|
a page. Each block loads its own data. *@
|
||||||
|
|
||||||
|
@foreach (var key in Embed)
|
||||||
|
{
|
||||||
|
switch (key)
|
||||||
|
{
|
||||||
|
case "posts":
|
||||||
|
<PostsEmbed />
|
||||||
|
break;
|
||||||
|
case "events":
|
||||||
|
<EventsEmbed />
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The page's embed keys ("posts", "events"), in order.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public IReadOnlyList<string> Embed { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
@using Elternbeirat.Web.Features.Posts
|
||||||
|
@if (_latest.Count > 0)
|
||||||
|
{
|
||||||
|
<section class="embed embed-posts">
|
||||||
|
<h2>Aktuelle Beiträge</h2>
|
||||||
|
<ul class="post-list">
|
||||||
|
@foreach (var post in _latest)
|
||||||
|
{
|
||||||
|
<li><PostCard Value="post" /></li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.PocketBase;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Features.Embeds;
|
||||||
|
|
||||||
|
public partial class PostsEmbed
|
||||||
|
{
|
||||||
|
// 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; the teaser is omitted.")]
|
||||||
|
private static partial void LogSourceUnavailable(
|
||||||
|
ILogger logger,
|
||||||
|
Exception exception);
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private PocketBaseClient PocketBase { get; set; } = null!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private ILogger<PostsEmbed> Logger { get; set; } = null!;
|
||||||
|
|
||||||
|
[CascadingParameter]
|
||||||
|
private HttpContext? HttpContext { get; set; }
|
||||||
|
|
||||||
|
private IReadOnlyList<Post> _latest = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// GetPostsAsync returns newest first, so the first entries are the latest.
|
||||||
|
var posts = await PocketBase.GetPostsAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
|
||||||
|
_latest = [.. posts.Take(TeaserCount)];
|
||||||
|
}
|
||||||
|
catch (PocketBaseUnavailableException exception)
|
||||||
|
{
|
||||||
|
// Only part of the page fails: omit the teaser, keep the page (and its
|
||||||
|
// 200) intact. A missing teaser is degraded, not broken.
|
||||||
|
LogSourceUnavailable(Logger, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/* The card is PostCard and the list grid lives in the global app.css, shared with
|
||||||
|
the full posts page. Only the embed's spacing on the home page is specific here. */
|
||||||
|
.embed-posts {
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
namespace Elternbeirat.Web.Features.Events;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A single calendar entry (parents' evening, meeting, deadline). Read from
|
|
||||||
/// <c>Content/events.yml</c> at startup. All display text is German because it
|
|
||||||
/// is visitor-facing content.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class Event
|
|
||||||
{
|
|
||||||
/// <summary>Short title shown in the list and the calendar.</summary>
|
|
||||||
public required string Title { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// When the entry starts. A date-only entry (no time) is treated as an
|
|
||||||
/// all-day event; see <see cref="HasTime"/>.
|
|
||||||
/// </summary>
|
|
||||||
public required DateTime Start { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// True when <see cref="Start"/> carries a wall-clock time, false for an
|
|
||||||
/// all-day entry. Drives both the list formatting and the ICS export.
|
|
||||||
/// </summary>
|
|
||||||
public required bool HasTime { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Optional end, for multi-hour or multi-day entries.</summary>
|
|
||||||
public DateTime? End { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Optional location, e.g. "Aula" or "Mensa".</summary>
|
|
||||||
public string? Location { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Optional free-text note shown below the entry.</summary>
|
|
||||||
public string? Note { get; init; }
|
|
||||||
}
|
|
||||||
@@ -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><time></c> element, in both a
|
||||||
|
/// machine-readable and a visitor-facing form.
|
||||||
|
/// </summary>
|
||||||
|
public partial class EventDate
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The event whose date range to render.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
[EditorRequired]
|
||||||
|
public Event Value { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,57 +1,74 @@
|
|||||||
@page "/termine"
|
@page "/events"
|
||||||
@inject EventService EventService
|
|
||||||
|
|
||||||
<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="/termine.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 (EventService.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 EventService.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")">@Format(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 (EventService.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 EventService.Past)
|
<p>Zurzeit sind keine Termine geplant.</p>
|
||||||
{
|
}
|
||||||
<li>
|
else
|
||||||
<time datetime="@ev.Start.ToString("s")">@Format(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,37 +1,81 @@
|
|||||||
using System.Globalization;
|
using Elternbeirat.Contracts;
|
||||||
using Elternbeirat.Web.Services;
|
using Elternbeirat.PocketBase;
|
||||||
|
using Elternbeirat.Web.Shared;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Features.Events;
|
namespace Elternbeirat.Web.Features.Events;
|
||||||
|
|
||||||
public partial class EventList
|
public partial class EventList
|
||||||
{
|
{
|
||||||
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
[LoggerMessage(
|
||||||
|
Level = LogLevel.Error,
|
||||||
|
Message = "Could not load events from PocketBase; content source is unavailable.")]
|
||||||
|
private static partial void LogSourceUnavailable(
|
||||||
|
ILogger logger,
|
||||||
|
Exception exception);
|
||||||
|
|
||||||
// Formats the entry's date range for display: a single day, a date with a
|
[Inject]
|
||||||
// time, or a span across days.
|
private PocketBaseClient PocketBase { get; set; } = null!;
|
||||||
private static string Format(Event ev)
|
|
||||||
|
[Inject]
|
||||||
|
private ILogger<EventList> Logger { get; set; } = null!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
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()
|
||||||
{
|
{
|
||||||
var start = ev.HasTime
|
IReadOnlyList<Event> events;
|
||||||
? ev.Start.ToString("dddd, d. MMMM yyyy, HH:mm", German) + " Uhr"
|
try
|
||||||
: ev.Start.ToString("dddd, d. MMMM yyyy", German);
|
|
||||||
|
|
||||||
if (ev.End is not { } end)
|
|
||||||
{
|
{
|
||||||
return start;
|
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 (PocketBaseUnavailableException exception)
|
||||||
|
{
|
||||||
|
// Empty sections would claim there are no events. The site works, its
|
||||||
|
// data store is down: 503 and an honest note.
|
||||||
|
LogSourceUnavailable(Logger, exception);
|
||||||
|
_unavailable = true;
|
||||||
|
SourceUnavailable.SetStatus(HttpContext);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same day: append just the end time. Different days: append the full
|
// Split into upcoming and past by date, so an event earlier today still
|
||||||
// end date.
|
// counts as upcoming. Upcoming earliest first, past most recent first.
|
||||||
if (end.Date == ev.Start.Date)
|
var today = BerlinClock.Today(Time);
|
||||||
{
|
_upcomingEvents = [.. UpcomingEvents.From(events, today)];
|
||||||
return ev.HasTime
|
_pastEvents =
|
||||||
? start + "–" + end.ToString("HH:mm", German) + " Uhr"
|
[
|
||||||
: start;
|
.. events
|
||||||
}
|
.Where(@event => DateOnly.FromDateTime(@event.Start) < today)
|
||||||
|
.OrderByDescending(@event => @event.Start)
|
||||||
var endText = ev.HasTime
|
];
|
||||||
? end.ToString("d. MMMM yyyy, HH:mm", German) + " Uhr"
|
|
||||||
: end.ToString("d. MMMM yyyy", German);
|
|
||||||
return start + " – " + endText;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,42 +1,85 @@
|
|||||||
.event-list {
|
/* The card grid (.event-list) is shared with the home-page embed and lives in the
|
||||||
list-style: none;
|
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;
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-list li {
|
/* On a phone the day sits above the title: a column beside it would leave the
|
||||||
padding: 0.75rem 0;
|
title too little room. */
|
||||||
border-bottom: 1px solid #eee;
|
.past-events li {
|
||||||
|
display: grid;
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-list time {
|
/* From tablet width on, day and title side by side. The day column has a fixed
|
||||||
display: block;
|
width, so every title starts on the same line; it fits the longest single day,
|
||||||
color: #777;
|
"30. September". A span across months ("30. Januar – 2. Februar") wraps after
|
||||||
font-size: 0.85rem;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-title {
|
/* Tabular digits give every day the same width, so "1." and "10." line up. */
|
||||||
font-size: 1.1rem;
|
.past-event-date {
|
||||||
font-weight: 600;
|
color: var(--color-text-soft);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-location::before {
|
.past-event-title {
|
||||||
content: " · ";
|
color: var(--color-text);
|
||||||
color: #777;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-location {
|
/* The time trails the title, quieter, as an addition rather than part of the name. */
|
||||||
color: #555;
|
.past-event-time {
|
||||||
}
|
margin-left: var(--space-2);
|
||||||
|
color: var(--color-text-soft);
|
||||||
.event-note {
|
font-variant-numeric: tabular-nums;
|
||||||
margin: 0.25rem 0 0;
|
white-space: nowrap;
|
||||||
color: #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-list-past {
|
|
||||||
color: #777;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-list-past .event-title {
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
}
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using Elternbeirat.Web.Services;
|
|
||||||
using YamlDotNet.Serialization;
|
|
||||||
using YamlDotNet.Serialization.NamingConventions;
|
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Features.Events;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads the calendar entries from <c>Content/events.yml</c> once at startup and
|
|
||||||
/// keeps them in memory. Registered as a singleton because the content lives in
|
|
||||||
/// the image and does not change at runtime.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class EventService
|
|
||||||
{
|
|
||||||
private readonly IReadOnlyList<Event> _events;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads and parses <c>Content/events.yml</c> below the application root.
|
|
||||||
/// A missing file yields an empty list rather than an error.
|
|
||||||
/// </summary>
|
|
||||||
public EventService(IWebHostEnvironment environment)
|
|
||||||
{
|
|
||||||
var path = Path.Combine(environment.ContentRootPath, "Content", "events.yml");
|
|
||||||
_events = File.Exists(path)
|
|
||||||
? Parse(File.ReadAllText(path))
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Entries that start today or later, earliest first — the upcoming section.
|
|
||||||
/// "Today" is compared by date, so an entry earlier today still counts as
|
|
||||||
/// upcoming.
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<Event> Upcoming =>
|
|
||||||
_events
|
|
||||||
.Where(e => DateOnly.FromDateTime(e.Start) >= DateOnly.FromDateTime(DateTime.Today))
|
|
||||||
.OrderBy(e => e.Start)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Entries that already passed, most recent first — the past section shown
|
|
||||||
/// below the upcoming ones.
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<Event> Past =>
|
|
||||||
_events
|
|
||||||
.Where(e => DateOnly.FromDateTime(e.Start) < DateOnly.FromDateTime(DateTime.Today))
|
|
||||||
.OrderByDescending(e => e.Start)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
/// <summary>All entries in chronological order, for the ICS export.</summary>
|
|
||||||
public IReadOnlyList<Event> All =>
|
|
||||||
_events.OrderBy(e => e.Start).ToList();
|
|
||||||
|
|
||||||
private static List<Event> Parse(string yaml)
|
|
||||||
{
|
|
||||||
// Keys are lowercase single words (title, start, ...), which the camel
|
|
||||||
// case convention maps onto the PascalCase properties.
|
|
||||||
var deserializer = new DeserializerBuilder()
|
|
||||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
|
||||||
.IgnoreUnmatchedProperties()
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
var raw = deserializer.Deserialize<List<EventEntry>?>(yaml) ?? [];
|
|
||||||
return raw
|
|
||||||
.Where(entry => entry.Title is not null && entry.Start is not null)
|
|
||||||
.Select(ToEvent)
|
|
||||||
.OfType<Event>()
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Event? ToEvent(EventEntry entry)
|
|
||||||
{
|
|
||||||
if (!TryParseWhen(entry.Start!, out var start, out var hasTime))
|
|
||||||
{
|
|
||||||
// A malformed date drops the entry rather than crashing startup.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
DateTime? end = null;
|
|
||||||
if (entry.End is not null && TryParseWhen(entry.End, out var parsedEnd, out _))
|
|
||||||
{
|
|
||||||
end = parsedEnd;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Event
|
|
||||||
{
|
|
||||||
Title = entry.Title!,
|
|
||||||
Start = start,
|
|
||||||
HasTime = hasTime,
|
|
||||||
End = end,
|
|
||||||
Location = entry.Location,
|
|
||||||
Note = entry.Note,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses a value that is either a date (<c>2026-03-15</c>) or a date with a
|
|
||||||
/// time (<c>2026-03-15 19:30</c>). Sets <paramref name="hasTime"/> so the
|
|
||||||
/// caller knows whether to treat the entry as all-day.
|
|
||||||
/// </summary>
|
|
||||||
private static bool TryParseWhen(string value, out DateTime when, out bool hasTime)
|
|
||||||
{
|
|
||||||
var trimmed = value.Trim();
|
|
||||||
|
|
||||||
if (DateOnly.TryParseExact(trimmed, "yyyy-MM-dd",
|
|
||||||
CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateOnly))
|
|
||||||
{
|
|
||||||
when = dateOnly.ToDateTime(TimeOnly.MinValue);
|
|
||||||
hasTime = false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
string[] formats = ["yyyy-MM-dd HH:mm", "yyyy-MM-dd'T'HH:mm"];
|
|
||||||
if (DateTime.TryParseExact(trimmed, formats,
|
|
||||||
CultureInfo.InvariantCulture, DateTimeStyles.None, out when))
|
|
||||||
{
|
|
||||||
hasTime = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
when = default;
|
|
||||||
hasTime = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Raw shape of one YAML entry, before validation. String-typed on purpose so
|
|
||||||
/// we control date parsing and can distinguish date-only from date-and-time.
|
|
||||||
/// </summary>
|
|
||||||
private sealed class EventEntry
|
|
||||||
{
|
|
||||||
public string? Title { get; set; }
|
|
||||||
public string? Start { get; set; }
|
|
||||||
public string? End { get; set; }
|
|
||||||
public string? Location { get; set; }
|
|
||||||
public string? Note { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Elternbeirat.Web.Services;
|
using Elternbeirat.Contracts;
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Features.Events;
|
namespace Elternbeirat.Web.Features.Events;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
@page "/faqs"
|
||||||
|
|
||||||
|
@{
|
||||||
|
// 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";
|
||||||
|
}
|
||||||
|
|
||||||
|
<PageTitle>@title</PageTitle>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (var topic in _topics)
|
||||||
|
{
|
||||||
|
<details class="faq-topic disclosure">
|
||||||
|
<summary>@topic.Title</summary>
|
||||||
|
@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">
|
||||||
|
<summary>@faq.Question</summary>
|
||||||
|
<div class="faq-answer markdown-body">
|
||||||
|
@((MarkupString)Markdown.ToHtml(faq.Answer))
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
}
|
||||||
|
</details>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.PocketBase;
|
||||||
|
using Elternbeirat.Web.Shared;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Features.Faqs;
|
||||||
|
|
||||||
|
public partial class FaqList
|
||||||
|
{
|
||||||
|
[LoggerMessage(
|
||||||
|
Level = LogLevel.Error,
|
||||||
|
Message = "Could not load FAQ topics from PocketBase; content source is unavailable.")]
|
||||||
|
private static partial void LogSourceUnavailable(
|
||||||
|
ILogger logger,
|
||||||
|
Exception exception);
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private PocketBaseClient PocketBase { get; set; } = null!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private ILogger<FaqList> Logger { get; set; } = null!;
|
||||||
|
|
||||||
|
[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()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var token = HttpContext?.RequestAborted ?? CancellationToken.None;
|
||||||
|
var pages = await PocketBase.GetPagesAsync(token);
|
||||||
|
_page = pages.FirstOrDefault(
|
||||||
|
page => string.Equals(page.Slug, "faqs", StringComparison.OrdinalIgnoreCase));
|
||||||
|
_topics = await PocketBase.GetFaqTopicsAsync(token);
|
||||||
|
}
|
||||||
|
catch (PocketBaseUnavailableException exception)
|
||||||
|
{
|
||||||
|
// An empty page would claim there are no topics. The site works, its
|
||||||
|
// data store is down: 503 and an honest note.
|
||||||
|
LogSourceUnavailable(Logger, exception);
|
||||||
|
_unavailable = true;
|
||||||
|
SourceUnavailable.SetStatus(HttpContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/* Same centred 42rem reading column as a content page (ContentPage.razor.css), so
|
||||||
|
every page of the site shares one column under the header. The topic cards and
|
||||||
|
the questions inside them live in the global app.css. */
|
||||||
|
h1,
|
||||||
|
article,
|
||||||
|
p,
|
||||||
|
.faq-topic {
|
||||||
|
box-sizing: border-box; /* padding inside the 42rem, flush with the text */
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,35 +1,82 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
@inject PostService PostService
|
@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>
|
}
|
||||||
|
|
||||||
<section class="home-news">
|
@if (_intro.Links.Count > 0)
|
||||||
<h2>Aktuelle Beiträge</h2>
|
{
|
||||||
|
<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 (_latest.Count == 0)
|
@if (_nextEvent is not null)
|
||||||
{
|
{
|
||||||
<p>Zurzeit gibt es keine Beiträge.</p>
|
@* As in EventCard: the calendar sheet shows the date and is hidden from
|
||||||
}
|
screen readers, who get the full date from <EventDate> instead. Visible
|
||||||
else
|
beside the sheet is only what it cannot show: the time, the end. *@
|
||||||
{
|
var span = EventDateFormat.Span(_nextEvent);
|
||||||
<ul class="post-list">
|
<a class="next-event" href="/events">
|
||||||
@foreach (var post in _latest)
|
<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>
|
<li>
|
||||||
<a href="@($"/beitraege/{post.Slug}")">@post.Title</a>
|
<a class="tile-link" href="@tile.Href">
|
||||||
<time datetime="@post.Date.ToString("yyyy-MM-dd")">
|
<span class="tile-icon"><Icon Name="@tile.Icon" Size="24" /></span>
|
||||||
@post.Date.ToString("d. MMMM yyyy", German)
|
<span class="tile-title">@tile.Title</span>
|
||||||
</time>
|
@if (tile.Description.Length > 0)
|
||||||
|
{
|
||||||
|
<span class="tile-text">@tile.Description</span>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
<p><a href="/beitraege">Alle Beiträge →</a></p>
|
</section>
|
||||||
}
|
}
|
||||||
</section>
|
|
||||||
|
<div class="home-embeds">
|
||||||
|
<PageEmbeds Embed="_embed" />
|
||||||
|
</div>
|
||||||
@@ -1,17 +1,90 @@
|
|||||||
using System.Globalization;
|
using Elternbeirat.Contracts;
|
||||||
using Elternbeirat.Web.Services;
|
using Elternbeirat.PocketBase;
|
||||||
|
using Elternbeirat.Web.Features.Events;
|
||||||
|
using Elternbeirat.Web.Shared;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Features.Home;
|
namespace Elternbeirat.Web.Features.Home;
|
||||||
|
|
||||||
public partial class Home
|
public partial class Home
|
||||||
{
|
{
|
||||||
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
[LoggerMessage(
|
||||||
|
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);
|
||||||
|
|
||||||
private IReadOnlyList<Post> _latest = [];
|
[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);
|
||||||
|
|
||||||
protected override void OnInitialized()
|
[Inject]
|
||||||
|
private PocketBaseClient PocketBase { get; set; } = null!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private ILogger<Home> Logger { get; set; } = null!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
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 Event? _nextEvent;
|
||||||
|
|
||||||
|
protected override Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
// Show the three most recent posts as a teaser on the home page.
|
var token = HttpContext?.RequestAborted ?? CancellationToken.None;
|
||||||
_latest = [.. PostService.All.Take(3)];
|
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
|
||||||
|
{
|
||||||
|
var pages = await PocketBase.GetPagesAsync(token);
|
||||||
|
var home = pages.FirstOrDefault(page => Page.IsHomeSlug(page.Slug));
|
||||||
|
_intro = HomeIntro.Parse(home?.Body);
|
||||||
|
_embed = home?.Embed ?? [];
|
||||||
|
_tiles = HomeTile.From(pages);
|
||||||
|
}
|
||||||
|
catch (PocketBaseUnavailableException exception)
|
||||||
|
{
|
||||||
|
LogPagesUnavailable(Logger, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,29 +1,311 @@
|
|||||||
.intro {
|
/* --- Hero ---------------------------------------------------------------------
|
||||||
font-size: 1.1rem;
|
A full-width band in the brand gradient with a faint dot pattern on top. The
|
||||||
color: #444;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-news {
|
/* The inside follows the page frame again, so the hero text lines up with the
|
||||||
margin-top: 2rem;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-list {
|
@media (min-width: 48rem) {
|
||||||
list-style: none;
|
.hero-inner {
|
||||||
padding: 0;
|
grid-template-columns: minmax(0, 1fr) minmax(16rem, 22rem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-list li {
|
/* On the gradient everything is white: the global heading colour is a blue that
|
||||||
padding: 0.75rem 0;
|
would vanish here. */
|
||||||
border-bottom: 1px solid #eee;
|
/* balance evens out the lines, so a short last word ("IGMH") never wraps alone. */
|
||||||
|
.hero h1 {
|
||||||
|
color: var(--color-on-header);
|
||||||
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-list a {
|
/* The intro is editor Markdown, rendered without scope attributes, so it is reached
|
||||||
font-size: 1.1rem;
|
through ::deep. Slightly larger than body text, as a lead. Links stay white and
|
||||||
text-decoration: none;
|
underlined: the blue link colour would be unreadable on the gradient. */
|
||||||
|
.hero-intro ::deep p {
|
||||||
|
font-size: 1.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-list time {
|
.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;
|
display: block;
|
||||||
color: #777;
|
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-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-weight: 700;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hidden on a phone; the title alone says where the tile leads. */
|
||||||
|
.tile-text {
|
||||||
|
display: none;
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 "<p>Schön, dass Sie da sind.</p>\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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using Elternbeirat.Contracts;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Features.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the header and footer navigation from the public pages. Every page
|
||||||
|
/// carries a required <see cref="Page.Location"/> ("header" or "footer") and an
|
||||||
|
/// <see cref="Page.Order"/>; this splits them into the two menus, each ordered.
|
||||||
|
/// </summary>
|
||||||
|
public static class NavBuilder
|
||||||
|
{
|
||||||
|
/// <summary>Header links, ordered, built from pages with location "header".</summary>
|
||||||
|
public static IReadOnlyList<NavLinkItem> Header(IReadOnlyList<Page> pages) =>
|
||||||
|
ToLinks(PagesAt(pages, "header"));
|
||||||
|
|
||||||
|
/// <summary>Footer links, ordered, built from pages with location "footer".</summary>
|
||||||
|
public static IReadOnlyList<NavLinkItem> Footer(IReadOnlyList<Page> pages) =>
|
||||||
|
ToLinks(PagesAt(pages, "footer"));
|
||||||
|
|
||||||
|
/// <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
|
||||||
|
.Where(page => string.Equals(page.Location, location, StringComparison.Ordinal))
|
||||||
|
.OrderBy(page => page.Order)];
|
||||||
|
|
||||||
|
private static List<NavLinkItem> ToLinks(IEnumerable<Page> pages) =>
|
||||||
|
[.. pages.Select(page => new NavLinkItem(HrefFor(page.Slug), page.Title))];
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Elternbeirat.Web.Features.Layout;
|
||||||
|
|
||||||
|
/// <summary>A single navigation entry derived from a page record.</summary>
|
||||||
|
/// <param name="Href">Target URL; the "home" page maps to "/".</param>
|
||||||
|
/// <param name="Title">Link text shown to the visitor.</param>
|
||||||
|
public sealed record NavLinkItem(string Href, string Title);
|
||||||
@@ -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}"
|
||||||
@inject PageService PageService
|
|
||||||
|
|
||||||
@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>
|
||||||
@((MarkupString)_page.ContentHtml)
|
<article class="markdown-body">@Body</article>
|
||||||
</article>
|
<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. *@
|
||||||
@@ -1,28 +1,67 @@
|
|||||||
using Elternbeirat.Web.Services;
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.PocketBase;
|
||||||
|
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.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;
|
||||||
|
|
||||||
private Page? _page;
|
[Inject]
|
||||||
|
private PocketBaseClient PocketBase { get; set; } = null!;
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
[Inject]
|
||||||
{
|
private ILogger<ContentPage> Logger { get; set; } = null!;
|
||||||
_page = PageService.Find(Slug);
|
|
||||||
|
|
||||||
// Unknown slug -> 404, so UseStatusCodePagesWithReExecute serves the
|
[Inject]
|
||||||
// /not-found page instead of an empty 200 response.
|
private NavigationManager Navigation { get; set; } = null!;
|
||||||
if (_page is null && HttpContext is not null)
|
|
||||||
{
|
|
||||||
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[CascadingParameter]
|
[CascadingParameter]
|
||||||
private HttpContext? HttpContext { get; set; }
|
private HttpContext? HttpContext { get; set; }
|
||||||
|
|
||||||
|
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
|
||||||
|
// MarkupString in the markup.
|
||||||
|
private MarkupString Body => (MarkupString)Markdown.ToHtml(_page?.Body);
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pages = await PocketBase.GetPagesAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
|
||||||
|
_page = pages.FirstOrDefault(
|
||||||
|
page => string.Equals(page.Slug, Slug, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
catch (PocketBaseUnavailableException exception)
|
||||||
|
{
|
||||||
|
// The site works, its data store is down: 503, not 404.
|
||||||
|
LogSourceUnavailable(Logger, Slug, exception);
|
||||||
|
_unavailable = true;
|
||||||
|
SourceUnavailable.SetStatus(HttpContext);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_page is null)
|
||||||
|
{
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
namespace Elternbeirat.Web.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A static content page, read from a Markdown file in <c>Content/pages/</c>.
|
|
||||||
/// The file name (without extension) is the slug.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class Page
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// URL identifier of the page, taken from the file name without umlauts
|
|
||||||
/// (e.g. <c>vorstandsteam</c>). It appears in the route <c>/{slug}</c>.
|
|
||||||
/// </summary>
|
|
||||||
public required string Slug { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Display title from the YAML front matter (<c>titel:</c>). Falls back to
|
|
||||||
/// the slug when no title is set.
|
|
||||||
/// </summary>
|
|
||||||
public required string Title { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The page body rendered from Markdown to HTML (front matter excluded).
|
|
||||||
/// </summary>
|
|
||||||
public required string ContentHtml { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
using Markdig;
|
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads the static content pages from <c>Content/pages/*.md</c>, renders them
|
|
||||||
/// to HTML once at startup and keeps them in memory. Registered as a singleton
|
|
||||||
/// because the content lives in the image and does not change at runtime.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class PageService
|
|
||||||
{
|
|
||||||
private readonly IReadOnlyDictionary<string, Page> _pagesBySlug;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads every Markdown page from the <c>Content/pages</c> directory below
|
|
||||||
/// the application root and caches it.
|
|
||||||
/// </summary>
|
|
||||||
public PageService(IWebHostEnvironment environment)
|
|
||||||
{
|
|
||||||
var pipeline = new MarkdownPipelineBuilder()
|
|
||||||
.UseYamlFrontMatter()
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
var directory = Path.Combine(environment.ContentRootPath, "Content", "pages");
|
|
||||||
var pages = new Dictionary<string, Page>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
if (Directory.Exists(directory))
|
|
||||||
{
|
|
||||||
foreach (var path in Directory.EnumerateFiles(directory, "*.md"))
|
|
||||||
{
|
|
||||||
var page = Read(path, pipeline);
|
|
||||||
pages[page.Slug] = page;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_pagesBySlug = pages;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the page for the given slug, or <c>null</c> if there is none.
|
|
||||||
/// The lookup is case-insensitive.
|
|
||||||
/// </summary>
|
|
||||||
public Page? Find(string slug) =>
|
|
||||||
_pagesBySlug.GetValueOrDefault(slug);
|
|
||||||
|
|
||||||
private static Page Read(string path, MarkdownPipeline pipeline)
|
|
||||||
{
|
|
||||||
var slug = Path.GetFileNameWithoutExtension(path);
|
|
||||||
var source = File.ReadAllText(path);
|
|
||||||
|
|
||||||
var document = Markdown.Parse(source, pipeline);
|
|
||||||
var title = FrontMatter.Read("title", document, source) ?? slug;
|
|
||||||
var html = Markdown.ToHtml(source, pipeline);
|
|
||||||
|
|
||||||
return new Page { Slug = slug, Title = title, ContentHtml = html };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
namespace Elternbeirat.Web.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A news post, read from a Markdown file in <c>Content/posts/</c>. Unlike a
|
|
||||||
/// <see cref="Page"/>, a post has a date and
|
|
||||||
/// is shown in a chronological list. The file name (without extension) is the
|
|
||||||
/// slug.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class Post
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// URL identifier of the post, taken from the file name without umlauts.
|
|
||||||
/// It appears in the route <c>/beitraege/{slug}</c>.
|
|
||||||
/// </summary>
|
|
||||||
public required string Slug { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Display title from the YAML front matter (<c>titel:</c>). Falls back to
|
|
||||||
/// the slug when no title is set.
|
|
||||||
/// </summary>
|
|
||||||
public required string Title { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Publication date from the front matter (<c>datum:</c>). Used to sort the
|
|
||||||
/// list newest first.
|
|
||||||
/// </summary>
|
|
||||||
public required DateOnly Date { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The post body rendered from Markdown to HTML (front matter excluded).
|
|
||||||
/// </summary>
|
|
||||||
public required string ContentHtml { get; init; }
|
|
||||||
}
|
|
||||||
@@ -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><time></c> element, in both a
|
||||||
|
/// machine-readable and a visitor-facing form. The two formats come from
|
||||||
|
/// <see cref="PostDateFormat"/>.
|
||||||
|
/// </summary>
|
||||||
|
public partial class PostDate
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The post's publication day.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
[EditorRequired]
|
||||||
|
public DateOnly Value { get; set; }
|
||||||
|
}
|
||||||
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user