Compare commits
62
Commits
7337a1cbc0
...
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 |
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
|
||||
# break the folder convention for no benefit.
|
||||
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
|
||||
# in bash. Unabhaengig von core.autocrlf auf Windows erzwingen.
|
||||
*.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
|
||||
@@ -25,7 +25,7 @@
|
||||
<option name="upForceRecreate" value="true" />
|
||||
</settings>
|
||||
</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" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -83,11 +83,47 @@ Inhalt liegt in PocketBase (`pb_data`), das separat gesichert wird (siehe
|
||||
- Datenzugriff über den typisierten `PocketBaseClient` (registriert via
|
||||
`AddHttpClient`), der pro Request liest — kein Start-Cache, kein Singleton mit
|
||||
Inhalten. Komponenten liegen feature-basiert unter `Features/<Bereich>/`.
|
||||
- Öffentliche Typen und Methoden im `PocketBaseClient` und in `Contracts`
|
||||
bekommen XML-Doc (auf Englisch, 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,
|
||||
Skripte und Doku. Das gilt **auch für Domänenbegriffe**: im Code `Event`, nicht
|
||||
`Termin`; `Post`, nicht `Beitrag`. Deutsch bleibt ausschließlich, was ein
|
||||
Besucher liest oder ein Redakteur pflegt: UI-Texte sowie die **Werte** der
|
||||
PocketBase-Records (z. B. `title: Vorstandsteam`, der Markdown-`body`). Die
|
||||
Feldnamen und Slugs bleiben dagegen englisch (`title`, `slug`, `/board`).
|
||||
|
||||
## Coding Convention
|
||||
|
||||
- **Ausdruckskörper (`=>`) sind Pflicht, wo syntaktisch möglich** — Methoden,
|
||||
Properties, Konstruktoren, Operatoren, lokale Funktionen. Ein `if/else`, das
|
||||
einen Wert liefert, wird zum ternären Ausdruck oder zur `switch`-Expression, kein
|
||||
Block-Körper mit `return`. Ein Block-Körper nur, wo ein Ausdruck sprachlich nicht
|
||||
geht (mehrere Anweisungen ohne Rückgabe, `ref`/`out`, `yield`).
|
||||
- Ternäre und `switch`-Expressions dürfen dafür mehrzeilig umgebrochen werden;
|
||||
Lesbarkeit entsteht durch Einrückung, nicht durch einen Block.
|
||||
- Nullable aktiv nutzen: `?`, `??`, `??=` statt Nullprüfungen im Block. Ein
|
||||
ungültiger `null`-Fall wird als Ausdruck geworfen (`?? throw new …`).
|
||||
- Argumente/Rückgaben früh und knapp validieren, bevorzugt als Ausdruck.
|
||||
|
||||
## Documentation Convention
|
||||
|
||||
Vorbild ist `Elternbeirat.PocketBase/LocalDateTimeConverter.cs` — daran
|
||||
ausrichten.
|
||||
|
||||
- **Jeder öffentliche (`public`/`protected`) Typ und Member bekommt XML-Doc** —
|
||||
nicht nur `PocketBaseClient` und `Contracts`. Interne Helfer, die Teil der
|
||||
fachlichen Erklärung sind (wie `WallClock`), ebenfalls. Razor-Markup nicht.
|
||||
- Voller Umfang, wo zutreffend: `<summary>`, dazu `<param>`, `<returns>`,
|
||||
`<exception>` (jede geworfene Bedingung), `<remarks>` für Kontext/Fallstricke,
|
||||
`<example>` mit `<code>` für nicht offensichtliche Nutzung, `<seealso>` auf
|
||||
verwandte Typen. `<inheritdoc/>` bei Interface-/Basis-Implementierungen.
|
||||
- Code im Text als Markup referenzieren, nicht als Prosa: `<see cref="…"/>`,
|
||||
`<see langword="null"/>`/`<see langword="false"/>`, `<c>…</c>` für Literale.
|
||||
- Einrückung: der Textinhalt steht mit vier Leerzeichen unter dem `///`-Tag
|
||||
(`/// Text`), Tags sauber verschachtelt.
|
||||
- Englisch, leicht verständlich, erklärt **warum**, nicht was der Code ohnehin
|
||||
zeigt.
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Elternbeirat.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// JSON converter that reads and writes a PocketBase date as a calendar date
|
||||
/// (<see cref="DateOnly"/>), keeping the day and dropping the time-of-day.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// PocketBase has no date-only field type: it stores every date as a UTC
|
||||
/// instant with a time-of-day (e.g. an editor's <c>2 Jan 00:00</c> Berlin is
|
||||
/// stored as <c>"2026-01-01 22:00:00.000Z"</c>). The day the editor meant is
|
||||
/// therefore the day <em>in Berlin</em>, not the day of the raw UTC string. This
|
||||
/// converter reuses the same UTC->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;
|
||||
|
||||
/// <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
|
||||
{
|
||||
/// <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")]
|
||||
public string Id { get; init; } = "";
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start of the event with date and time. Stored as UTC by PocketBase but
|
||||
/// read as local time (Europe/Berlin) by convention. An all-day event uses
|
||||
/// 00:00 as the time.
|
||||
/// Gets the start of the event as Europe/Berlin wall-clock time.
|
||||
/// </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")]
|
||||
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")]
|
||||
[JsonConverter(typeof(NullableLocalDateTimeConverter))]
|
||||
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")]
|
||||
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")]
|
||||
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")]
|
||||
public string Note { get; init; } = "";
|
||||
public string Note { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Whether the event is visible to visitors.</summary>
|
||||
[JsonPropertyName("public")]
|
||||
public bool Public { get; init; }
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the event has a wall-clock time, as opposed
|
||||
/// 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;
|
||||
|
||||
/// <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>
|
||||
/// <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
|
||||
{
|
||||
/// <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")]
|
||||
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")]
|
||||
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")]
|
||||
public string Answer { get; init; } = "";
|
||||
public required string Answer { get; init; }
|
||||
|
||||
/// <summary>Topic the question is grouped under, e.g. "mensa".</summary>
|
||||
[JsonPropertyName("topic")]
|
||||
public string Topic { get; init; } = "";
|
||||
/// <summary>
|
||||
/// Gets the sort order of the question within its topic.
|
||||
/// </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")]
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// A content page. 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, and <see cref="Embed"/> lists dynamic blocks (posts,
|
||||
/// events, faqs) rendered below the page body.
|
||||
/// A content page of the site, as stored in PocketBase.
|
||||
/// </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
|
||||
{
|
||||
/// <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")]
|
||||
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")]
|
||||
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")]
|
||||
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")]
|
||||
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")]
|
||||
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")]
|
||||
public string Slug { get; init; } = "";
|
||||
public required string Slug { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Dynamic blocks to render below the body: any of "posts", "events", "faqs".
|
||||
/// Empty for a plain text page.
|
||||
/// Gets the dynamic blocks rendered below the <see cref="Body"/>.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// Any of <c>"posts"</c> and <c>"events"</c>, or an empty list for a plain
|
||||
/// text page.
|
||||
/// </value>
|
||||
[JsonPropertyName("embed")]
|
||||
public IReadOnlyList<string> Embed { get; init; } = [];
|
||||
|
||||
/// <summary>Whether the page is visible to visitors.</summary>
|
||||
[JsonPropertyName("public")]
|
||||
public bool Public { get; init; }
|
||||
/// <summary>
|
||||
/// Tests whether a slug is the home page's. This is the single definition of
|
||||
/// 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;
|
||||
|
||||
/// <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
|
||||
{
|
||||
/// <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")]
|
||||
public string Id { get; init; } = "";
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Publication date. Stored as UTC by PocketBase but read as local time
|
||||
/// (Europe/Berlin) by convention; only the date part is shown.
|
||||
/// Gets the publication date as a calendar day.
|
||||
/// </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")]
|
||||
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")]
|
||||
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")]
|
||||
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")]
|
||||
public string Slug { get; init; } = "";
|
||||
|
||||
/// <summary>Whether the post is visible to visitors.</summary>
|
||||
[JsonPropertyName("public")]
|
||||
public bool Public { get; init; }
|
||||
public required string Slug { 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;
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// Reads published content from a PocketBase instance over its REST API.
|
||||
/// </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,
|
||||
};
|
||||
options.Converters.Add(new LocalDateTimeConverter());
|
||||
options.Converters.Add(new NullableLocalDateTimeConverter());
|
||||
return options;
|
||||
using var response = await httpClient.GetAsync(
|
||||
new Uri("/api/health", UriKind.Relative), token);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
// 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>
|
||||
public Task<IReadOnlyList<Page>> GetPagesAsync(CancellationToken ct = default)
|
||||
=> GetRecordsAsync<Page>("pages", "order", ct);
|
||||
|
||||
/// <summary>Gets all public posts, newest first.</summary>
|
||||
public Task<IReadOnlyList<Post>> GetPostsAsync(CancellationToken ct = default)
|
||||
=> GetRecordsAsync<Post>("posts", "-date", ct);
|
||||
|
||||
/// <summary>Gets all public events, earliest start first.</summary>
|
||||
public Task<IReadOnlyList<Event>> GetEventsAsync(CancellationToken ct = default)
|
||||
=> GetRecordsAsync<Event>("events", "start", ct);
|
||||
|
||||
/// <summary>Gets all public FAQ entries.</summary>
|
||||
public Task<IReadOnlyList<Faq>> GetFaqsAsync(CancellationToken ct = default)
|
||||
=> GetRecordsAsync<Faq>("faqs", "topic", ct);
|
||||
|
||||
private async Task<IReadOnlyList<T>> GetRecordsAsync<T>(string collection, string sort, CancellationToken ct)
|
||||
/// <summary>
|
||||
/// Gets all public records of a collection in a single request.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">
|
||||
/// The record type to deserialize into.
|
||||
/// </typeparam>
|
||||
/// <param name="collection">
|
||||
/// The PocketBase collection name, e.g. <c>"posts"</c>.
|
||||
/// </param>
|
||||
/// <param name="sort">
|
||||
/// The PocketBase sort expression; a leading <c>-</c> sorts descending.
|
||||
/// </param>
|
||||
/// <param name="token">
|
||||
/// A token to cancel the request.
|
||||
/// </param>
|
||||
/// <param name="expand">
|
||||
/// 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
|
||||
// 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")}"
|
||||
+ $"&sort={Uri.EscapeDataString(sort)}";
|
||||
|
||||
var result = await http.GetFromJsonAsync<RecordList<T>>(url, JsonOptions, ct);
|
||||
return result?.Items ?? [];
|
||||
// Add expand only when asked, so callers that need no relations send the
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// The envelope PocketBase wraps a records list response in. Only <see cref="Items"/>
|
||||
/// is used; the paging fields are ignored because content volumes are small and the
|
||||
/// client requests a large page size in a single call.
|
||||
/// The envelope PocketBase wraps a records list response in.
|
||||
/// </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>
|
||||
{
|
||||
/// <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")]
|
||||
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));
|
||||
}
|
||||
@@ -8,8 +8,10 @@ namespace Elternbeirat.Web.Tests;
|
||||
/// </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() { Slug = slug, Location = location, Order = order, Title = title };
|
||||
new() { Id = "test", Slug = slug, Location = location, Order = order, Title = title };
|
||||
|
||||
[Fact]
|
||||
public void Header_keeps_only_header_pages_ordered_by_order()
|
||||
@@ -19,7 +21,6 @@ public class NavBuilderTests
|
||||
Page("board", "header", 2, "Vorstandsteam"),
|
||||
Page("home", "header", 1, "Start"),
|
||||
Page("imprint", "footer", 1, "Impressum"),
|
||||
Page("hidden", "", 1, "Unterseite"),
|
||||
};
|
||||
|
||||
var header = NavBuilder.Header(pages);
|
||||
@@ -42,6 +43,15 @@ public class NavBuilderTests
|
||||
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()
|
||||
{
|
||||
@@ -56,13 +66,4 @@ public class NavBuilderTests
|
||||
header[0].Href.ShouldBe("/");
|
||||
header[1].Href.ShouldBe("/board");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pages_without_a_location_never_appear_in_a_menu()
|
||||
{
|
||||
var pages = new[] { Page("hidden", "", 1, "Unterseite") };
|
||||
|
||||
NavBuilder.Header(pages).ShouldBeEmpty();
|
||||
NavBuilder.Footer(pages).ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -17,20 +17,20 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
|
||||
|
||||
var events = await client.GetEventsAsync();
|
||||
|
||||
// Two seeded events, earliest start first.
|
||||
events.Select(e => e.Title).ShouldBe(["Elternbeiratssitzung", "Herbstbasar"]);
|
||||
events.ShouldAllBe(e => e.Public);
|
||||
// Three seeded events, earliest start first.
|
||||
events.Select(@event => @event.Title).ShouldBe(["Projekttage", "Infoabend Klasse 5", "Elternbeiratssitzung", "Herbstbasar"]);
|
||||
}
|
||||
|
||||
[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 events = await client.GetEventsAsync();
|
||||
|
||||
// The meeting is seeded as 19:30; by the timezone convention the number is
|
||||
// taken at face value, so no shift to 20:30/21:30 happens.
|
||||
// The meeting is seeded as 17:30Z. October is summer time (UTC+2), so the
|
||||
// converter turns it into 19:30 Berlin wall-clock -- the time an editor
|
||||
// entered in the admin form.
|
||||
var meeting = events.FirstOrDefault(e =>
|
||||
e.Title.Contains("Elternbeiratssitzung", StringComparison.Ordinal));
|
||||
meeting.ShouldNotBeNull();
|
||||
@@ -46,8 +46,8 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
|
||||
|
||||
var events = await client.GetEventsAsync();
|
||||
|
||||
var meeting = events.Single(e => e.Title == "Elternbeiratssitzung");
|
||||
var basar = events.Single(e => e.Title == "Herbstbasar");
|
||||
var meeting = events.Single(@event => @event.Title == "Elternbeiratssitzung");
|
||||
var basar = events.Single(@event => @event.Title == "Herbstbasar");
|
||||
meeting.End.ShouldBeNull(); // no end seeded
|
||||
basar.End.ShouldNotBeNull(); // end seeded
|
||||
}
|
||||
@@ -60,7 +60,21 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
|
||||
var posts = await client.GetPostsAsync();
|
||||
|
||||
// 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]
|
||||
@@ -71,9 +85,9 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
|
||||
var pages = await client.GetPagesAsync();
|
||||
|
||||
// The draft (public=false) must be filtered out, the public ones present.
|
||||
pages.ShouldNotContain(p => p.Slug == "draft");
|
||||
pages.ShouldContain(p => p.Slug == "home");
|
||||
pages.ShouldContain(p => p.Slug == "contact");
|
||||
pages.ShouldNotContain(page => page.Slug == "draft");
|
||||
pages.ShouldContain(page => page.Slug == "home");
|
||||
pages.ShouldContain(page => page.Slug == "contact");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -85,6 +99,8 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
|
||||
|
||||
var home = pages.Single(p => p.Slug == "home");
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -95,7 +111,10 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
|
||||
|
||||
var faqs = await client.GetFaqsAsync();
|
||||
|
||||
faqs.Select(f => f.Topic).ShouldBe(["mensa", "schliessfach"], ignoreOrder: true);
|
||||
faqs.ShouldAllBe(f => !string.IsNullOrWhiteSpace(f.Question));
|
||||
// The topic relation is expanded, so each entry carries its topic. The two
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
@@ -220,30 +220,51 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
||||
private static async Task SeedRecordsAsync(HttpClient http)
|
||||
{
|
||||
// Pages that the route smoke tests reach through the navigation, the
|
||||
// footer or the FAQ hub. Header pages carry a location+order; the FAQ
|
||||
// sub-pages are plain content pages without a menu slot. All public.
|
||||
// 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
|
||||
{
|
||||
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,
|
||||
});
|
||||
await CreateRecordAsync(http, "pages", new
|
||||
{
|
||||
title = "Vorstandsteam", body = "# Vorstand", location = "header",
|
||||
// The body uses the "::: team" block like production: one card with the
|
||||
// full convention (bold name, role, italic duties, note) in a large block,
|
||||
// and one item without the convention, which must become a plain card.
|
||||
title = "Vorstandsteam",
|
||||
body = "## Vorsitz\n\n::: team gross\n"
|
||||
+ "- **Jenny Reger-Stilgenbauer** (Vorsitzende) \n *Schulkonferenz, Mensarat* \n Mitglied im Gesamtelternbeirat.\n"
|
||||
+ ":::\n\n## Beisitz\n\n::: team\n"
|
||||
+ "- Özlem Ünal, Beisitz\n"
|
||||
+ ":::",
|
||||
location = "header",
|
||||
order = 2, slug = "board", embed = Array.Empty<string>(), @public = true,
|
||||
});
|
||||
await CreateRecordAsync(http, "pages", new
|
||||
{
|
||||
// Carries an embed so the ContentPage embed path is covered: /patrons
|
||||
// renders its body plus the FAQ teaser.
|
||||
title = "Förderverein", body = "# Förderverein", location = "header",
|
||||
order = 3, slug = "patrons", embed = new[] { "faqs" }, @public = true,
|
||||
// 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,
|
||||
});
|
||||
// Nav placeholder for the FAQ list (route /faqs is served by FaqList,
|
||||
// which shadows this page). Slug matches production's "faqs" nav entry.
|
||||
// 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 = "FAQ", body = "# Häufige Fragen", location = "header",
|
||||
title = "FAQs",
|
||||
body = "Wählen Sie ein Thema, um die passenden Fragen zu sehen.",
|
||||
location = "header",
|
||||
order = 4, slug = "faqs", embed = Array.Empty<string>(), @public = true,
|
||||
});
|
||||
await CreateRecordAsync(http, "pages", new
|
||||
@@ -251,10 +272,29 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
||||
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 = "FAQ Mensa", body = "# Mensa", location = "",
|
||||
order = 0, slug = "faq-lunch", embed = Array.Empty<string>(), @public = true,
|
||||
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
|
||||
{
|
||||
@@ -278,10 +318,13 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
||||
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
|
||||
{
|
||||
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,
|
||||
});
|
||||
await CreateRecordAsync(http, "posts", new
|
||||
@@ -290,40 +333,84 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
||||
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
|
||||
{
|
||||
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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
// 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
|
||||
{
|
||||
question = "Wann gibt es Mittagessen?", answer = "Um 12 Uhr.",
|
||||
topic = "mensa", @public = true,
|
||||
topic = lunchTopicId, order = 1, @public = true,
|
||||
});
|
||||
await CreateRecordAsync(http, "faqs", new
|
||||
{
|
||||
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);
|
||||
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
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("token")]
|
||||
public string Token { get; init; } = "";
|
||||
public string Token { get; init; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Elternbeirat.Web.Shared;
|
||||
|
||||
namespace Elternbeirat.Web.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the health cache behind the maintenance gate. The probe is a fake
|
||||
/// that counts its calls, so no PocketBase is needed.
|
||||
/// </summary>
|
||||
public class PocketBaseHealthCacheTests
|
||||
{
|
||||
private readonly SteppingClock _clock = new(new DateTimeOffset(2026, 10, 1, 10, 0, 0, TimeSpan.Zero));
|
||||
private int _probes;
|
||||
|
||||
private Func<CancellationToken, Task<bool>> Probe(bool healthy) =>
|
||||
_ =>
|
||||
{
|
||||
_probes++;
|
||||
return Task.FromResult(healthy);
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Result_is_reused_within_the_lifetime()
|
||||
{
|
||||
var cache = new PocketBaseHealthCache(_clock);
|
||||
|
||||
await cache.IsHealthyAsync(Probe(healthy: true), CancellationToken.None);
|
||||
_clock.Now += PocketBaseHealthCache.Lifetime - TimeSpan.FromSeconds(1);
|
||||
var healthy = await cache.IsHealthyAsync(Probe(healthy: false), CancellationToken.None);
|
||||
|
||||
healthy.ShouldBeTrue();
|
||||
_probes.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stale_result_is_probed_again()
|
||||
{
|
||||
// The way back after an outage: once the lifetime is over, the new answer
|
||||
// replaces the old one.
|
||||
var cache = new PocketBaseHealthCache(_clock);
|
||||
|
||||
await cache.IsHealthyAsync(Probe(healthy: false), CancellationToken.None);
|
||||
_clock.Now += PocketBaseHealthCache.Lifetime;
|
||||
var healthy = await cache.IsHealthyAsync(Probe(healthy: true), CancellationToken.None);
|
||||
|
||||
healthy.ShouldBeTrue();
|
||||
_probes.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unhealthy_result_is_cached_too()
|
||||
{
|
||||
// While PocketBase is down, not every request should wait for a failing probe.
|
||||
var cache = new PocketBaseHealthCache(_clock);
|
||||
|
||||
await cache.IsHealthyAsync(Probe(healthy: false), CancellationToken.None);
|
||||
var healthy = await cache.IsHealthyAsync(Probe(healthy: true), CancellationToken.None);
|
||||
|
||||
healthy.ShouldBeFalse();
|
||||
_probes.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A clock the test moves forward by hand, to step past the cache lifetime without
|
||||
/// waiting.
|
||||
/// </summary>
|
||||
/// <param name="start">The moment the clock shows at first.</param>
|
||||
private sealed class SteppingClock(DateTimeOffset start) : TimeProvider
|
||||
{
|
||||
public DateTimeOffset Now { get; set; } = start;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => Now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Elternbeirat.Contracts;
|
||||
using Elternbeirat.Web.Features.Posts;
|
||||
|
||||
namespace Elternbeirat.Web.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the older/newer post next to a post. The list comes newest
|
||||
/// first, as from PocketBase; "previous" is the older post. Pure logic, no
|
||||
/// PocketBase needed.
|
||||
/// </summary>
|
||||
public class PostNeighborsTests
|
||||
{
|
||||
// Newest first, like PocketBaseClient.GetPostsAsync.
|
||||
private static readonly Post[] Posts = [Post("march"), Post("february"), Post("january")];
|
||||
|
||||
private static Post Post(string slug) =>
|
||||
new() { Id = slug, Title = slug, Slug = slug };
|
||||
|
||||
[Fact]
|
||||
public void Middle_post_has_the_older_as_previous_and_the_newer_as_next()
|
||||
{
|
||||
var neighbors = PostNeighbors.Of(Posts, "february");
|
||||
|
||||
neighbors.Previous?.Slug.ShouldBe("january");
|
||||
neighbors.Next?.Slug.ShouldBe("march");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Newest_post_has_no_next()
|
||||
{
|
||||
var neighbors = PostNeighbors.Of(Posts, "march");
|
||||
|
||||
neighbors.Previous?.Slug.ShouldBe("february");
|
||||
neighbors.Next.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Oldest_post_has_no_previous()
|
||||
{
|
||||
var neighbors = PostNeighbors.Of(Posts, "january");
|
||||
|
||||
neighbors.Previous.ShouldBeNull();
|
||||
neighbors.Next?.Slug.ShouldBe("february");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_post_has_no_neighbors() =>
|
||||
PostNeighbors.Of([Post("only")], "only").ShouldBe(new PostNeighbors(null, null));
|
||||
|
||||
[Fact]
|
||||
public void Unknown_slug_has_no_neighbors() =>
|
||||
PostNeighbors.Of(Posts, "december").ShouldBe(new PostNeighbors(null, null));
|
||||
|
||||
[Fact]
|
||||
public void Slug_is_compared_case_insensitively() =>
|
||||
PostNeighbors.Of(Posts, "February").Next?.Slug.ShouldBe("march");
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Elternbeirat.Web.Tests;
|
||||
|
||||
@@ -16,7 +18,7 @@ namespace Elternbeirat.Web.Tests;
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection(PocketBaseTestGroup.Name)]
|
||||
public sealed class RouteSmokeTests : IDisposable
|
||||
public sealed partial class RouteSmokeTests : IDisposable
|
||||
{
|
||||
// WithWebHostBuilder returns a new factory that wraps the base one; both are
|
||||
// disposable, so both are held and disposed to avoid leaking either.
|
||||
@@ -29,10 +31,25 @@ public sealed class RouteSmokeTests : IDisposable
|
||||
// the value from appsettings. UseSetting (not ConfigureAppConfiguration)
|
||||
// because it wins over appsettings.Development.json, which otherwise pins the
|
||||
// client to localhost:8090 while the test container uses a random port.
|
||||
_factory = _baseFactory.WithWebHostBuilder(builder =>
|
||||
builder.UseSetting("PocketBase:BaseUrl", pocketBase.BaseUrl.ToString()));
|
||||
_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();
|
||||
@@ -43,7 +60,16 @@ public sealed class RouteSmokeTests : IDisposable
|
||||
/// Every route that a visitor can reach through the navigation, the FAQ hub
|
||||
/// or the news section. The post slugs match the fixture's seed.
|
||||
/// </summary>
|
||||
public static TheoryData<string> KnownRoutes =>
|
||||
public static TheoryData<string> KnownRoutes => [.. Routes];
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="KnownRoutes"/> that render an HTML page, i.e. all but the
|
||||
/// calendar feed.
|
||||
/// </summary>
|
||||
public static TheoryData<string> PageRoutes =>
|
||||
[.. Routes.Where(route => !route.EndsWith(".ics", StringComparison.Ordinal))];
|
||||
|
||||
private static readonly string[] Routes =
|
||||
[
|
||||
"/",
|
||||
"/board",
|
||||
@@ -75,6 +101,7 @@ public sealed class RouteSmokeTests : IDisposable
|
||||
[Theory]
|
||||
[InlineData("/gibt-es-nicht")]
|
||||
[InlineData("/posts/gibt-es-nicht")]
|
||||
[InlineData("/events/gibt-es-nicht.ics")]
|
||||
public async Task Unknown_route_returns_404(string route)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
@@ -85,18 +112,19 @@ public sealed class RouteSmokeTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Faqs_route_renders_the_grouped_list()
|
||||
public async Task Faqs_route_renders_the_topic_accordion()
|
||||
{
|
||||
// /faqs is a literal route (FaqList) that must win over the /{Slug}
|
||||
// catch-all content page. Assert it shows the FAQ list, grouped by topic
|
||||
// from the seeded questions, not a content page body.
|
||||
// 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("Häufige Fragen");
|
||||
html.ShouldContain("Wann gibt es Mittagessen?");
|
||||
html.ShouldContain("Mensa und Mittagessen"); // German topic heading
|
||||
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]
|
||||
@@ -114,6 +142,30 @@ public sealed class RouteSmokeTests : IDisposable
|
||||
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()
|
||||
{
|
||||
@@ -129,16 +181,460 @@ public sealed class RouteSmokeTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_page_renders_the_embed_its_page_opts_into()
|
||||
public async Task Content_page_renders_its_body()
|
||||
{
|
||||
// /patrons is a catch-all content page whose embed field is ["faqs"], so it
|
||||
// shows its body plus the FAQ teaser -- proving the ContentPage embed path.
|
||||
// /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("Förderverein"); // the page body
|
||||
html.ShouldContain("Häufige Fragen"); // faqs embed heading
|
||||
html.ShouldContain("Wann gibt es Mittagessen?"); // a seeded question
|
||||
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,10 +1,53 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="page">
|
||||
<header class="site-header">
|
||||
@* Header, main and footer are plain block children of <body>, which is the page's
|
||||
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">
|
||||
<a class="brand" href="/">Elternbeirat IGMH</a>
|
||||
<nav class="main-nav">
|
||||
@* Two-line lettering next to the signet: the body's name large, the
|
||||
school small below it. One link, so the whole mark leads home. *@
|
||||
<a class="brand" href="/">
|
||||
<Signet />
|
||||
<span class="brand-text">
|
||||
<span class="brand-name">Elternbeirat</span>
|
||||
<span class="brand-school">IGMH Mannheim</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@* CSS-only mobile menu: the 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>
|
||||
@@ -13,19 +56,55 @@
|
||||
</div>
|
||||
</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
|
||||
</main>
|
||||
|
||||
<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">
|
||||
<span>Elternbeirat der IGMH</span>
|
||||
<nav class="footer-nav">
|
||||
@foreach (var link in _footer)
|
||||
{
|
||||
<a href="@link.Href">@link.Title</a>
|
||||
}
|
||||
</nav>
|
||||
<div class="footer-brand">
|
||||
<Signet />
|
||||
<span>Elternbeirat der IGMH</span>
|
||||
</div>
|
||||
|
||||
@* 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>
|
||||
|
||||
<p class="footer-copyright">© @_year Elternbeirat der IGMH</p>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -6,31 +6,45 @@ namespace Elternbeirat.Web.Components.Layout;
|
||||
|
||||
public partial class MainLayout
|
||||
{
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load navigation pages from PocketBase.")]
|
||||
private static partial void LogLoadFailed(ILogger logger, Exception exception);
|
||||
[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; } = default!;
|
||||
private PocketBaseClient PocketBase { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<MainLayout> Logger { get; set; } = default!;
|
||||
private ILogger<MainLayout> Logger { get; set; } = null!;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private IReadOnlyList<NavLinkItem> _header = [];
|
||||
private IReadOnlyList<NavLinkItem> _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();
|
||||
var pages = await PocketBase.GetPagesAsync(HttpContext?.RequestAborted ?? default);
|
||||
_header = NavBuilder.Header(pages);
|
||||
_footer = NavBuilder.Footer(pages);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
catch (PocketBaseUnavailableException ex)
|
||||
{
|
||||
// Source unreachable -> render the chrome with an empty menu instead of
|
||||
// a 500. The brand link still leads home.
|
||||
LogLoadFailed(Logger, 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;
|
||||
}
|
||||
|
||||
/* Page frame: header and footer stay put, the content grows and pushes the
|
||||
footer to the bottom even on short pages. */
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
/* Header: pinned to the top while the page scrolls, so the brand and (on mobile)
|
||||
the hamburger button are always reachable. It is a plain block child of <body>
|
||||
(NOT a flex item) -- a stretched flex child fills its cell and never sticks.
|
||||
z-index keeps it above the content; the opaque background stops content showing
|
||||
through as it scrolls underneath. */
|
||||
.site-header {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background: #fff;
|
||||
/* The brand gradient, blue into green -- the strongest, most visible cue that
|
||||
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 {
|
||||
max-width: 60rem;
|
||||
max-width: var(--content-width);
|
||||
margin: 0 auto;
|
||||
padding: 1rem 1.25rem;
|
||||
padding: var(--space-3) 1.25rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem 1.5rem;
|
||||
align-items: center;
|
||||
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 {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: #1f3a5f;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--color-on-header);
|
||||
text-decoration: none;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 1.25rem;
|
||||
align-items: center;
|
||||
gap: var(--space-1) var(--space-2);
|
||||
}
|
||||
|
||||
.main-nav a {
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
padding: 0.2rem 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
/* The hamburger button and its state checkbox are hidden on wide screens; the
|
||||
media query below turns them on for narrow ones. display:none (beating the
|
||||
global .visually-hidden the checkbox also carries) takes the checkbox out of the
|
||||
tab order here, where there is no menu to open. */
|
||||
.nav-toggle,
|
||||
.nav-toggle-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-nav a:hover {
|
||||
color: #1f3a5f;
|
||||
}
|
||||
/* The link colours/states for .main-nav a live in the GLOBAL app.css, not here.
|
||||
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. */
|
||||
.main-nav a.active {
|
||||
color: #1f3a5f;
|
||||
border-bottom-color: #1f3a5f;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
/* Content: at least tall enough that the footer sits at the bottom of the viewport
|
||||
on short pages. 100dvh - 5rem leaves room for the header with its signet (which
|
||||
sits above in normal flow); dvh follows the mobile browser's shrinking toolbar.
|
||||
On long pages the content simply grows past this and pushes the footer down
|
||||
naturally. */
|
||||
.content {
|
||||
flex: 1;
|
||||
min-height: calc(100dvh - 5rem);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 60rem;
|
||||
max-width: var(--content-width);
|
||||
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 {
|
||||
border-top: 1px solid #e0e0e0;
|
||||
background: #fafafa;
|
||||
color: #555;
|
||||
font-size: 0.9rem;
|
||||
position: relative;
|
||||
margin-top: var(--space-6);
|
||||
background: var(--color-footer);
|
||||
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 {
|
||||
max-width: 60rem;
|
||||
max-width: var(--content-width);
|
||||
margin: 0 auto;
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem 1.5rem;
|
||||
padding: var(--space-5) 1.25rem var(--space-4);
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr 1fr;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.footer-nav {
|
||||
.footer-brand {
|
||||
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 {
|
||||
color: #555;
|
||||
color: var(--color-on-footer-soft);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer-nav a:hover {
|
||||
color: #1f3a5f;
|
||||
color: var(--color-on-footer);
|
||||
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,25 +1,14 @@
|
||||
<section class="embed embed-events">
|
||||
<h2>Kommende Termine</h2>
|
||||
|
||||
@if (_upcoming.Count == 0)
|
||||
{
|
||||
<p>Zurzeit sind keine Termine geplant.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@using Elternbeirat.Web.Features.Events
|
||||
@if (_upcomingEvents.Count > 0)
|
||||
{
|
||||
<section class="embed embed-events">
|
||||
<h2>Kommende Termine</h2>
|
||||
<ul class="event-list">
|
||||
@foreach (var ev in _upcoming)
|
||||
@foreach (var ev in _upcomingEvents)
|
||||
{
|
||||
<li>
|
||||
<time datetime="@ev.Start.ToString("s")">@EventFormat.Range(ev)</time>
|
||||
<span class="event-title">@ev.Title</span>
|
||||
@if (!string.IsNullOrWhiteSpace(ev.Location))
|
||||
{
|
||||
<span class="event-location">@ev.Location</span>
|
||||
}
|
||||
</li>
|
||||
@* A teaser: each card leads to the events page with the full list. *@
|
||||
<li><EventCard Value="ev" Href="/events" /></li>
|
||||
}
|
||||
</ul>
|
||||
<p><a href="/events">Alle Termine →</a></p>
|
||||
}
|
||||
</section>
|
||||
</section>
|
||||
}
|
||||
@@ -1,43 +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
|
||||
{
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load events for an embed from PocketBase.")]
|
||||
private static partial void LogLoadFailed(ILogger logger, Exception exception);
|
||||
// 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; } = default!;
|
||||
private PocketBaseClient PocketBase { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<EventsEmbed> Logger { get; set; } = default!;
|
||||
private ILogger<EventsEmbed> Logger { get; set; } = null!;
|
||||
|
||||
private IReadOnlyList<Event> _upcoming = [];
|
||||
[Inject]
|
||||
private TimeProvider Time { get; set; } = null!;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private IReadOnlyList<Event> _upcomingEvents = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
IReadOnlyList<Event> events;
|
||||
try
|
||||
{
|
||||
events = await PocketBase.GetEventsAsync();
|
||||
events = await PocketBase.GetEventsAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
catch (PocketBaseUnavailableException exception)
|
||||
{
|
||||
// Source unreachable -> show no teaser instead of a 500.
|
||||
LogLoadFailed(Logger, ex);
|
||||
// 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 three soonest upcoming events, earliest first. An event
|
||||
// earlier today still counts as upcoming.
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
_upcoming = events
|
||||
.Where(e => DateOnly.FromDateTime(e.Start) >= today)
|
||||
.OrderBy(e => e.Start)
|
||||
.Take(3)
|
||||
.ToList();
|
||||
// Teaser: the soonest upcoming events, earliest first. An event earlier
|
||||
// today still counts as upcoming.
|
||||
_upcomingEvents = [.. UpcomingEvents.From(events, BerlinClock.Today(Time)).Take(TeaserCount)];
|
||||
}
|
||||
}
|
||||
@@ -1,33 +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: 2rem;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.event-list li {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.event-list time {
|
||||
display: block;
|
||||
color: #777;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.event-location::before {
|
||||
content: " · ";
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.event-location {
|
||||
color: #555;
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<section class="embed embed-faqs">
|
||||
<h2>Häufige Fragen</h2>
|
||||
|
||||
@if (_faqs.Count == 0)
|
||||
{
|
||||
<p>Zurzeit gibt es keine Fragen.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var faq in _faqs)
|
||||
{
|
||||
<details class="faq-item">
|
||||
<summary>@faq.Question</summary>
|
||||
<div class="faq-answer">
|
||||
@((MarkupString)Markdown.ToHtml(faq.Answer))
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
<p><a href="/faqs">Alle Fragen →</a></p>
|
||||
}
|
||||
</section>
|
||||
@@ -1,34 +0,0 @@
|
||||
using Elternbeirat.Contracts;
|
||||
using Elternbeirat.PocketBase;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Elternbeirat.Web.Features.Embeds;
|
||||
|
||||
public partial class FaqsEmbed
|
||||
{
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load FAQs for an embed from PocketBase.")]
|
||||
private static partial void LogLoadFailed(ILogger logger, Exception exception);
|
||||
|
||||
[Inject]
|
||||
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<FaqsEmbed> Logger { get; set; } = default!;
|
||||
|
||||
private IReadOnlyList<Faq> _faqs = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Teaser: the first three questions, with a link to the full hub.
|
||||
var faqs = await PocketBase.GetFaqsAsync();
|
||||
_faqs = [.. faqs.Take(3)];
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
// Source unreachable -> show no teaser instead of a 500.
|
||||
LogLoadFailed(Logger, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
.embed-faqs {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.faq-item {
|
||||
border-bottom: 1px solid #eee;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.faq-item summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.faq-answer {
|
||||
padding: 0.5rem 0 0.25rem;
|
||||
color: #333;
|
||||
}
|
||||
@@ -12,14 +12,11 @@
|
||||
case "events":
|
||||
<EventsEmbed />
|
||||
break;
|
||||
case "faqs":
|
||||
<FaqsEmbed />
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>The page's embed keys ("posts", "events", "faqs"), in order.</summary>
|
||||
/// <summary>The page's embed keys ("posts", "events"), in order.</summary>
|
||||
[Parameter]
|
||||
public IReadOnlyList<string> Embed { get; set; } = [];
|
||||
}
|
||||
@@ -1,23 +1,13 @@
|
||||
<section class="embed embed-posts">
|
||||
<h2>Aktuelle Beiträge</h2>
|
||||
|
||||
@if (_latest.Count == 0)
|
||||
{
|
||||
<p>Zurzeit gibt es keine Beiträge.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@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>
|
||||
<a href="@($"/posts/{post.Slug}")">@post.Title</a>
|
||||
<time datetime="@post.Date.ToString("yyyy-MM-dd")">
|
||||
@post.Date.ToString("d. MMMM yyyy", German)
|
||||
</time>
|
||||
</li>
|
||||
<li><PostCard Value="post" /></li>
|
||||
}
|
||||
</ul>
|
||||
<p><a href="/posts">Alle Beiträge →</a></p>
|
||||
}
|
||||
</section>
|
||||
</section>
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Globalization;
|
||||
using Elternbeirat.Contracts;
|
||||
using Elternbeirat.PocketBase;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
@@ -7,16 +6,24 @@ namespace Elternbeirat.Web.Features.Embeds;
|
||||
|
||||
public partial class PostsEmbed
|
||||
{
|
||||
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
||||
// Number of posts shown in the teaser; the full list is one link away.
|
||||
private const int TeaserCount = 3;
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load posts for an embed from PocketBase.")]
|
||||
private static partial void LogLoadFailed(ILogger logger, Exception exception);
|
||||
[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; } = default!;
|
||||
private PocketBaseClient PocketBase { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<PostsEmbed> Logger { get; set; } = default!;
|
||||
private ILogger<PostsEmbed> Logger { get; set; } = null!;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private IReadOnlyList<Post> _latest = [];
|
||||
|
||||
@@ -24,14 +31,15 @@ public partial class PostsEmbed
|
||||
{
|
||||
try
|
||||
{
|
||||
// Teaser: the three most recent posts, with a link to the full list.
|
||||
var posts = await PocketBase.GetPostsAsync();
|
||||
_latest = [.. posts.Take(3)];
|
||||
// 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 (HttpRequestException ex)
|
||||
catch (PocketBaseUnavailableException exception)
|
||||
{
|
||||
// Source unreachable -> show no teaser instead of a 500.
|
||||
LogLoadFailed(Logger, ex);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +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: 2rem;
|
||||
}
|
||||
|
||||
.post-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.post-list li {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.post-list a {
|
||||
font-size: 1.1rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.post-list time {
|
||||
display: block;
|
||||
color: #777;
|
||||
font-size: 0.85rem;
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
@*
|
||||
A tear-off calendar sheet for an event's start: short month on a green band,
|
||||
the day large, the weekday small. Pure decoration, hidden from screen readers:
|
||||
every caller puts the full date (<EventDate>) next to it.
|
||||
*@
|
||||
<span class="calendar-sheet" aria-hidden="true">
|
||||
<span class="calendar-month">@EventDateFormat.SheetMonth(Value)</span>
|
||||
<span class="calendar-day">@EventDateFormat.SheetDay(Value)</span>
|
||||
<span class="calendar-weekday">@EventDateFormat.SheetWeekday(Value)</span>
|
||||
</span>
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Elternbeirat.Web.Features.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Renders an event's start day as a calendar sheet (month, day, weekday).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One component for every place that shows an event (the next-event card on the
|
||||
/// home page, the events embed and the events page), so the sheets look the same
|
||||
/// everywhere. The texts come from <see cref="EventDateFormat"/>.
|
||||
/// </remarks>
|
||||
/// <seealso cref="EventCard"/>
|
||||
public partial class CalendarSheet
|
||||
{
|
||||
/// <summary>
|
||||
/// The event's start, as Berlin wall-clock time.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
[EditorRequired]
|
||||
public DateTime Value { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/* The calendar sheet: a green band with the month over a large day number and the
|
||||
weekday. Fixed width, so the sheets of a list line up whatever the month. The band
|
||||
has its own token (--color-calendar-band): the plain green accent is too light for
|
||||
small white text, the band's darker green keeps it above AA in both schemes.
|
||||
Positioning (gap to the text beside it) is the caller's job. */
|
||||
.calendar-sheet {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 4rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.calendar-month {
|
||||
padding: 0.15rem 0;
|
||||
background: var(--color-calendar-band);
|
||||
color: var(--color-on-button);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
padding-top: var(--space-1);
|
||||
color: var(--color-heading);
|
||||
font-size: 1.75rem;
|
||||
font-weight: var(--font-weight-heading);
|
||||
}
|
||||
|
||||
.calendar-weekday {
|
||||
padding-bottom: var(--space-1);
|
||||
color: var(--color-text-soft);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
@*
|
||||
One event as a card: calendar sheet on the left, title, time, place and note on
|
||||
the right. With an Href the whole card is that link (home embed); without one it
|
||||
is a plain card (events page) and offers the event as a calendar file instead.
|
||||
*@
|
||||
@if (Href is null)
|
||||
{
|
||||
<div class="event-card">@Details</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a class="event-card event-card-link" href="@Href">@Details</a>
|
||||
}
|
||||
|
||||
@code {
|
||||
// The card's content, the same in both forms. A template, not a child component,
|
||||
// so the wrapper above stays the only difference between the two.
|
||||
private RenderFragment Details => @<text>
|
||||
<CalendarSheet Value="Value.Start" />
|
||||
<span class="event-details">
|
||||
<span class="event-title">@Value.Title</span>
|
||||
@* The sheet is hidden from screen readers; this is the date they hear. *@
|
||||
<span class="visually-hidden"><EventDate Value="Value" /></span>
|
||||
@if (Span.Length > 0)
|
||||
{
|
||||
<span class="event-meta"><Icon Name="clock" Size="16" /> @Span</span>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(Value.Location))
|
||||
{
|
||||
<span class="event-meta"><Icon Name="map-pin" Size="16" /> @Value.Location</span>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(Value.Note))
|
||||
{
|
||||
<span class="event-note">@Value.Note</span>
|
||||
}
|
||||
@* A link inside a link is invalid HTML, so only the plain card offers it. *@
|
||||
@if (Href is null)
|
||||
{
|
||||
<a class="event-ics" href="@IcsHref"><Icon Name="calendar-plus" Size="16" /> In Kalender übernehmen</a>
|
||||
}
|
||||
</span>
|
||||
</text>;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Elternbeirat.Contracts;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Elternbeirat.Web.Features.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Renders one event as a card with a calendar sheet, time, place and note.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Shared by the events page and the home-page events embed, so both show events
|
||||
/// the same way. They differ only in what the card leads to:
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// In the embed the whole card links to the events page
|
||||
/// (<see cref="Href"/> set).
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// On the events page itself a link back to the same page would lead
|
||||
/// nowhere, so the card is no link (<see cref="Href"/> is
|
||||
/// <see langword="null"/>) and offers "In Kalender übernehmen" instead.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
/// <seealso cref="CalendarSheet"/>
|
||||
/// <seealso cref="IcsCalendar"/>
|
||||
public partial class EventCard
|
||||
{
|
||||
/// <summary>
|
||||
/// The event to show.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
[EditorRequired]
|
||||
public Event Value { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Where the card links to, or <see langword="null"/> for a card that is no link
|
||||
/// and offers the event as a calendar file instead.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string? Href { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The time line beside the sheet; empty for a single all-day event.
|
||||
/// </summary>
|
||||
private string Span => EventDateFormat.Span(Value);
|
||||
|
||||
/// <summary>
|
||||
/// The address of this one event as a calendar file.
|
||||
/// </summary>
|
||||
private string IcsHref => $"/events/{Uri.EscapeDataString(Value.Id)}.ics";
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/* The event card: a white surface on the tinted page, sheet left, text right. The
|
||||
card fills its list cell, so the cards of a row are equally tall. */
|
||||
.event-card {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-start;
|
||||
height: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-bg);
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Only the linked card reacts to hover: a card that leads nowhere must not look
|
||||
clickable. Hover = stronger shadow and a blue edge, nothing moves. */
|
||||
.event-card-link {
|
||||
text-decoration: none;
|
||||
transition: box-shadow var(--transition), border-color var(--transition);
|
||||
}
|
||||
|
||||
.event-card-link:hover {
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: var(--shadow-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.event-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
min-width: 0; /* lets a long title wrap instead of widening the card */
|
||||
}
|
||||
|
||||
.event-title {
|
||||
color: var(--color-brand);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Time and place: icon and text on one quiet line each. The icon belongs to Icon's
|
||||
own scope, so it is reached through ::deep. */
|
||||
.event-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
color: var(--color-text-soft);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.event-meta ::deep .icon {
|
||||
color: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
.event-note {
|
||||
margin-top: var(--space-1);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* In the linked card (home embed) the note is only a teaser: cut after two lines,
|
||||
so a long note does not stretch the card next to short ones. The full note is
|
||||
on the events page. */
|
||||
.event-card-link .event-note {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.event-ics {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
align-self: flex-start;
|
||||
margin-top: var(--space-2);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
@*
|
||||
Renders an event's date as a <time> element: the start in the machine-readable
|
||||
datetime attribute, the full German range (weekday, clock time, multi-day span)
|
||||
as visible text via EventDateFormat.
|
||||
|
||||
Unlike <PostDate>, this takes the whole Event, not a plain day: the visible
|
||||
range depends on the event's end and whether it has a clock time, and that
|
||||
logic already lives in EventDateFormat.
|
||||
*@
|
||||
<time datetime="@Value.Start.ToString("s")">@EventDateFormat.Range(Value)</time>
|
||||
@@ -0,0 +1,18 @@
|
||||
using Elternbeirat.Contracts;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Elternbeirat.Web.Features.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Renders an event's date range as a <c><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,56 +1,74 @@
|
||||
@page "/events"
|
||||
|
||||
<PageTitle>Termine</PageTitle>
|
||||
@{
|
||||
// Heading and intro come from the "events" page record; fall back to a sensible
|
||||
// default only if that record is missing, so the page still renders.
|
||||
var title = _page?.Title ?? "Termine";
|
||||
}
|
||||
|
||||
<h1>Termine</h1>
|
||||
<PageTitle>@title</PageTitle>
|
||||
|
||||
<p class="intro">
|
||||
Alle Termine des Elternbeirats auf einen Blick. Sie können den Kalender auch
|
||||
<a href="/events.ics">abonnieren</a> und in Ihrer Kalender-App automatisch
|
||||
aktuell halten.
|
||||
</p>
|
||||
<h1>@title</h1>
|
||||
|
||||
<h2>Kommende Termine</h2>
|
||||
|
||||
@if (_upcoming.Count == 0)
|
||||
@if (_page is not null && !string.IsNullOrWhiteSpace(_page.Body))
|
||||
{
|
||||
<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
|
||||
{
|
||||
<ul class="event-list">
|
||||
@foreach (var ev in _upcoming)
|
||||
{
|
||||
<li>
|
||||
<time datetime="@ev.Start.ToString("s")">@EventFormat.Range(ev)</time>
|
||||
<span class="event-title">@ev.Title</span>
|
||||
@if (!string.IsNullOrWhiteSpace(ev.Location))
|
||||
{
|
||||
<span class="event-location">@ev.Location</span>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(ev.Note))
|
||||
{
|
||||
<p class="event-note">@ev.Note</p>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
@* The page body above is the intro; the page only adds the subscribe button. *@
|
||||
<p class="events-subscribe">
|
||||
<a class="btn btn-secondary" href="/events.ics"><Icon Name="calendar-plus" Size="18" /> Kalender abonnieren</a>
|
||||
</p>
|
||||
|
||||
@if (_past.Count > 0)
|
||||
{
|
||||
<h2>Vergangene Termine</h2>
|
||||
<ul class="event-list event-list-past">
|
||||
@foreach (var ev in _past)
|
||||
{
|
||||
<li>
|
||||
<time datetime="@ev.Start.ToString("s")">@EventFormat.Range(ev)</time>
|
||||
<span class="event-title">@ev.Title</span>
|
||||
@if (!string.IsNullOrWhiteSpace(ev.Location))
|
||||
{
|
||||
<span class="event-location">@ev.Location</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<h2>Kommende Termine</h2>
|
||||
|
||||
@if (_upcomingEvents.Count == 0)
|
||||
{
|
||||
<p>Zurzeit sind keine Termine geplant.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="event-list">
|
||||
@foreach (var ev in _upcomingEvents)
|
||||
{
|
||||
@* No Href: a card linking to this very page would lead nowhere. *@
|
||||
<li><EventCard Value="ev" /></li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@* Past events matter rarely, so they wait folded up and compact: grouped by year,
|
||||
then day, title and (if the event has one) the time per row. *@
|
||||
@if (_pastEvents.Count > 0)
|
||||
{
|
||||
<details class="past-events disclosure">
|
||||
<summary>Vergangene Termine anzeigen (@_pastEvents.Count)</summary>
|
||||
@foreach (var year in PastYears)
|
||||
{
|
||||
<h3 class="past-events-year">@year.Key</h3>
|
||||
<ul>
|
||||
@foreach (var ev in year)
|
||||
{
|
||||
var time = EventDateFormat.PastTime(ev);
|
||||
<li>
|
||||
<span class="past-event-date">@EventDateFormat.PastDays(ev)</span>
|
||||
<span class="past-event-title">
|
||||
@ev.Title
|
||||
@if (time.Length > 0)
|
||||
{
|
||||
<span class="past-event-time">@time</span>
|
||||
}
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</details>
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,81 @@
|
||||
using Elternbeirat.Contracts;
|
||||
using Elternbeirat.PocketBase;
|
||||
using Elternbeirat.Web.Shared;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Elternbeirat.Web.Features.Events;
|
||||
|
||||
public partial class EventList
|
||||
{
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load events from PocketBase.")]
|
||||
private static partial void LogLoadFailed(ILogger logger, Exception exception);
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Error,
|
||||
Message = "Could not load events from PocketBase; content source is unavailable.")]
|
||||
private static partial void LogSourceUnavailable(
|
||||
ILogger logger,
|
||||
Exception exception);
|
||||
|
||||
[Inject]
|
||||
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||
private PocketBaseClient PocketBase { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<EventList> Logger { get; set; } = default!;
|
||||
private ILogger<EventList> Logger { get; set; } = null!;
|
||||
|
||||
private IReadOnlyList<Event> _upcoming = [];
|
||||
private IReadOnlyList<Event> _past = [];
|
||||
[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()
|
||||
{
|
||||
IReadOnlyList<Event> events;
|
||||
try
|
||||
{
|
||||
events = await PocketBase.GetEventsAsync();
|
||||
var token = HttpContext?.RequestAborted ?? CancellationToken.None;
|
||||
var pages = await PocketBase.GetPagesAsync(token);
|
||||
_page = pages.FirstOrDefault(
|
||||
page => string.Equals(page.Slug, "events", StringComparison.OrdinalIgnoreCase));
|
||||
events = await PocketBase.GetEventsAsync(token);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
catch (PocketBaseUnavailableException exception)
|
||||
{
|
||||
// Source unreachable -> show empty sections instead of a 500. The page
|
||||
// stays up; the "keine Termine" note is rendered.
|
||||
LogLoadFailed(Logger, ex);
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Split into upcoming and past by date, so an event earlier today still
|
||||
// counts as upcoming. Upcoming earliest first, past most recent first.
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
_upcoming = events
|
||||
.Where(e => DateOnly.FromDateTime(e.Start) >= today)
|
||||
.OrderBy(e => e.Start)
|
||||
.ToList();
|
||||
_past = events
|
||||
.Where(e => DateOnly.FromDateTime(e.Start) < today)
|
||||
.OrderByDescending(e => e.Start)
|
||||
.ToList();
|
||||
var today = BerlinClock.Today(Time);
|
||||
_upcomingEvents = [.. UpcomingEvents.From(events, today)];
|
||||
_pastEvents =
|
||||
[
|
||||
.. events
|
||||
.Where(@event => DateOnly.FromDateTime(@event.Start) < today)
|
||||
.OrderByDescending(@event => @event.Start)
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,85 @@
|
||||
.event-list {
|
||||
list-style: none;
|
||||
/* The card grid (.event-list) is shared with the home-page embed and lives in the
|
||||
global app.css; the card itself is EventCard. Only the page's own parts are here. */
|
||||
|
||||
/* Same centred 42rem reading column as a content page (ContentPage.razor.css), so
|
||||
every page of the site shares one column under the header. */
|
||||
h1,
|
||||
h2,
|
||||
article,
|
||||
p,
|
||||
.event-list,
|
||||
.past-events {
|
||||
max-width: 42rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* auto, not 0: this rule outweighs the column rule above and would otherwise pull
|
||||
the button back to the left edge of the page. */
|
||||
.events-subscribe {
|
||||
margin: var(--space-4) auto;
|
||||
}
|
||||
|
||||
/* Past events: one folded-up card. Opened, a compact list grouped by year,
|
||||
deliberately quieter than the upcoming cards above. The open/close triangle is
|
||||
the shared .disclosure rule in app.css, the same as on the FAQ topics. */
|
||||
.past-events {
|
||||
box-sizing: border-box; /* padding inside the 42rem, flush with the text */
|
||||
margin-top: var(--space-5);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.past-events-year {
|
||||
margin: var(--space-4) 0 var(--space-1);
|
||||
color: var(--color-text-soft);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.past-events ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.event-list li {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
/* On a phone the day sits above the title: a column beside it would leave the
|
||||
title too little room. */
|
||||
.past-events li {
|
||||
display: grid;
|
||||
padding: var(--space-2) 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.event-list time {
|
||||
display: block;
|
||||
color: #777;
|
||||
font-size: 0.85rem;
|
||||
/* From tablet width on, day and title side by side. The day column has a fixed
|
||||
width, so every title starts on the same line; it fits the longest single day,
|
||||
"30. September". A span across months ("30. Januar – 2. Februar") wraps after
|
||||
the dash inside the column. Same font size for both and baseline alignment, so
|
||||
the two texts sit on one line. */
|
||||
@media (min-width: 40rem) {
|
||||
.past-events li {
|
||||
grid-template-columns: 8rem 1fr;
|
||||
column-gap: var(--space-3);
|
||||
align-items: baseline;
|
||||
}
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
/* Tabular digits give every day the same width, so "1." and "10." line up. */
|
||||
.past-event-date {
|
||||
color: var(--color-text-soft);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.event-location::before {
|
||||
content: " · ";
|
||||
color: #777;
|
||||
.past-event-title {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.event-location {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.event-note {
|
||||
margin: 0.25rem 0 0;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.event-list-past {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.event-list-past .event-title {
|
||||
font-weight: 400;
|
||||
/* The time trails the title, quieter, as an addition rather than part of the name. */
|
||||
.past-event-time {
|
||||
margin-left: var(--space-2);
|
||||
color: var(--color-text-soft);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -45,7 +45,7 @@ public static class IcsCalendar
|
||||
// between requests, so caches and clients do not see spurious changes.
|
||||
AppendLine(sb, "DTSTAMP:20000101T000000Z");
|
||||
|
||||
if (HasTime(ev))
|
||||
if (ev.HasTime)
|
||||
{
|
||||
AppendLine(sb, $"DTSTART:{Local(ev.Start)}");
|
||||
if (ev.End is { } end)
|
||||
@@ -75,12 +75,6 @@ public static class IcsCalendar
|
||||
AppendLine(sb, "END:VEVENT");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An entry counts as all-day when its start has no wall-clock time (00:00).
|
||||
/// PocketBase has no separate flag; the convention is a midnight start.
|
||||
/// </summary>
|
||||
private static bool HasTime(Event ev) => ev.Start.TimeOfDay != TimeSpan.Zero;
|
||||
|
||||
/// <summary>
|
||||
/// A UID that stays the same as long as the entry's title and start do, so a
|
||||
/// re-subscribe updates the event instead of creating a duplicate.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,28 +1,47 @@
|
||||
@page "/faqs"
|
||||
|
||||
<PageTitle>Häufige Fragen</PageTitle>
|
||||
@{
|
||||
// Heading and intro come from the "faqs" page record; fall back to a sensible
|
||||
// default only if that record is missing, so the page still renders.
|
||||
var title = _page?.Title ?? "FAQs";
|
||||
}
|
||||
|
||||
<h1>Häufige Fragen</h1>
|
||||
<PageTitle>@title</PageTitle>
|
||||
|
||||
@if (_groups.Count == 0)
|
||||
<h1>@title</h1>
|
||||
|
||||
@if (_page is not null && !string.IsNullOrWhiteSpace(_page.Body))
|
||||
{
|
||||
<article class="markdown-body">@Body</article>
|
||||
}
|
||||
|
||||
@if (_unavailable)
|
||||
{
|
||||
<p>Die Fragen sind gerade nicht erreichbar. Bitte später erneut versuchen.</p>
|
||||
}
|
||||
else if (_topics.Count == 0)
|
||||
{
|
||||
<p>Zurzeit gibt es keine Fragen.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var group in _groups)
|
||||
@foreach (var topic in _topics)
|
||||
{
|
||||
<section class="faq-topic">
|
||||
<h2>@TopicLabel(group.Key)</h2>
|
||||
@foreach (var faq in group)
|
||||
<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">
|
||||
<div class="faq-answer markdown-body">
|
||||
@((MarkupString)Markdown.ToHtml(faq.Answer))
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
</section>
|
||||
</details>
|
||||
}
|
||||
}
|
||||
@@ -1,48 +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.Warning, Message = "Could not load FAQs from PocketBase.")]
|
||||
private static partial void LogLoadFailed(ILogger logger, Exception exception);
|
||||
|
||||
// German heading per topic. Unknown topics fall back to the raw key so a new
|
||||
// topic in PocketBase still shows up rather than being silently dropped.
|
||||
private static readonly Dictionary<string, string> TopicLabels =
|
||||
new(StringComparer.Ordinal)
|
||||
{
|
||||
["mensa"] = "Mensa und Mittagessen",
|
||||
["schliessfach"] = "Schließfächer",
|
||||
["elterneuro"] = "Eltern-Euro",
|
||||
["elternarbeit"] = "Elternarbeit und Mitmachen",
|
||||
};
|
||||
[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; } = default!;
|
||||
private PocketBaseClient PocketBase { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<FaqList> Logger { get; set; } = default!;
|
||||
private ILogger<FaqList> Logger { get; set; } = null!;
|
||||
|
||||
private IReadOnlyList<IGrouping<string, Faq>> _groups = [];
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
// The "faqs" page record supplies the heading and intro text, so an editor
|
||||
// owns them in PocketBase rather than them being hard-coded here. Null until
|
||||
// loaded, or when no such page exists.
|
||||
private Page? _page;
|
||||
|
||||
private IReadOnlyList<FaqTopic> _topics = [];
|
||||
|
||||
// Set when the content source could not be reached or read. The markup then
|
||||
// shows a "temporarily unavailable" note instead of "no topics", matching
|
||||
// the 503 this sets on the response.
|
||||
private bool _unavailable;
|
||||
|
||||
// The page body rendered from Markdown to HTML, ready to emit as a
|
||||
// MarkupString in the markup.
|
||||
private MarkupString Body => (MarkupString)Markdown.ToHtml(_page?.Body);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var faqs = await PocketBase.GetFaqsAsync();
|
||||
_groups = [.. faqs.GroupBy(faq => faq.Topic)];
|
||||
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 (HttpRequestException ex)
|
||||
catch (PocketBaseUnavailableException exception)
|
||||
{
|
||||
// Source unreachable -> show an empty page instead of a 500. The page
|
||||
// stays up; the "keine Fragen" note is rendered.
|
||||
LogLoadFailed(Logger, ex);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
private static string TopicLabel(string topic) =>
|
||||
TopicLabels.TryGetValue(topic, out var label) ? label : topic;
|
||||
}
|
||||
@@ -1,22 +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 {
|
||||
margin-bottom: 2rem;
|
||||
box-sizing: border-box; /* padding inside the 42rem, flush with the text */
|
||||
max-width: 42rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.faq-topic h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.faq-item {
|
||||
border-bottom: 1px solid #eee;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.faq-item summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.faq-answer {
|
||||
padding: 0.5rem 0 0.25rem;
|
||||
color: #333;
|
||||
/* 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,12 +1,82 @@
|
||||
@page "/"
|
||||
@using Elternbeirat.Web.Features.Events
|
||||
|
||||
<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">
|
||||
Willkommen beim Elternbeirat der IGMH. Hier finden Sie aktuelle Informationen,
|
||||
Antworten auf häufige Fragen und Unterlagen rund um die Elternarbeit.
|
||||
</p>
|
||||
@if (_intro.IntroHtml.Length > 0)
|
||||
{
|
||||
<div class="hero-intro">@((MarkupString)_intro.IntroHtml)</div>
|
||||
}
|
||||
|
||||
<PageEmbeds Embed="_embed" />
|
||||
@if (_intro.Links.Count > 0)
|
||||
{
|
||||
<p class="hero-actions">
|
||||
@for (var i = 0; i < _intro.Links.Count; i++)
|
||||
{
|
||||
var link = _intro.Links[i];
|
||||
<a class="btn @(i == 0 ? "btn-primary" : "btn-secondary")" href="@link.Href">@link.Text</a>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_nextEvent is not null)
|
||||
{
|
||||
@* As in EventCard: the calendar sheet shows the date and is hidden from
|
||||
screen readers, who get the full date from <EventDate> instead. Visible
|
||||
beside the sheet is only what it cannot show: the time, the end. *@
|
||||
var span = EventDateFormat.Span(_nextEvent);
|
||||
<a class="next-event" href="/events">
|
||||
<span class="next-event-label">Nächster Termin</span>
|
||||
<span class="next-event-row">
|
||||
<CalendarSheet Value="_nextEvent.Start" />
|
||||
<span class="next-event-details">
|
||||
<span class="next-event-title">@_nextEvent.Title</span>
|
||||
<span class="visually-hidden"><EventDate Value="_nextEvent" /></span>
|
||||
@if (span.Length > 0)
|
||||
{
|
||||
<span class="next-event-meta"><Icon Name="clock" Size="16" /> @span</span>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(_nextEvent.Location))
|
||||
{
|
||||
<span class="next-event-meta"><Icon Name="map-pin" Size="16" /> @_nextEvent.Location</span>
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (_tiles.Count > 0)
|
||||
{
|
||||
<section class="home-tiles" aria-labelledby="home-tiles-title">
|
||||
<h2 id="home-tiles-title">Bereiche im Überblick</h2>
|
||||
<ul>
|
||||
@foreach (var tile in _tiles)
|
||||
{
|
||||
<li>
|
||||
<a class="tile-link" href="@tile.Href">
|
||||
<span class="tile-icon"><Icon Name="@tile.Icon" Size="24" /></span>
|
||||
<span class="tile-title">@tile.Title</span>
|
||||
@if (tile.Description.Length > 0)
|
||||
{
|
||||
<span class="tile-text">@tile.Description</span>
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
<div class="home-embeds">
|
||||
<PageEmbeds Embed="_embed" />
|
||||
</div>
|
||||
@@ -1,36 +1,90 @@
|
||||
using Elternbeirat.Contracts;
|
||||
using Elternbeirat.PocketBase;
|
||||
using Elternbeirat.Web.Features.Events;
|
||||
using Elternbeirat.Web.Shared;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Elternbeirat.Web.Features.Home;
|
||||
|
||||
public partial class Home
|
||||
{
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load the home page from PocketBase.")]
|
||||
private static partial void LogLoadFailed(ILogger logger, Exception exception);
|
||||
[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);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Could not load events for the home page from PocketBase; the next-event card is omitted.")]
|
||||
private static partial void LogEventsUnavailable(
|
||||
ILogger logger,
|
||||
Exception exception);
|
||||
|
||||
[Inject]
|
||||
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||
private PocketBaseClient PocketBase { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<Home> Logger { get; set; } = default!;
|
||||
private ILogger<Home> Logger { get; set; } = null!;
|
||||
|
||||
// Which dynamic blocks the home page shows is editorial: it comes from the
|
||||
// "home" page's embed field in PocketBase, not from hard-coded markup.
|
||||
[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 async Task OnInitializedAsync()
|
||||
protected override Task OnInitializedAsync()
|
||||
{
|
||||
var token = HttpContext?.RequestAborted ?? CancellationToken.None;
|
||||
return Task.WhenAll(LoadPagesAsync(token), LoadNextEventAsync(token));
|
||||
}
|
||||
|
||||
// Pages and events load side by side and fail on their own: without events the
|
||||
// page loses only the card, without pages only text and tiles. A PocketBase that
|
||||
// is plainly down never gets this far -- the maintenance gate in Program.cs
|
||||
// answers with maintenance.html first. These catches are the second line: the
|
||||
// gate's health result is cached for a few seconds, so PocketBase can fail between
|
||||
// that check and these reads. Without them that would be a 500; with them the
|
||||
// start page stays a 200 rather than a 503 -- degraded, not broken, and rare.
|
||||
private async Task LoadPagesAsync(CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pages = await PocketBase.GetPagesAsync();
|
||||
var home = pages.FirstOrDefault(
|
||||
p => string.Equals(p.Slug, "home", StringComparison.Ordinal));
|
||||
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 (HttpRequestException ex)
|
||||
catch (PocketBaseUnavailableException exception)
|
||||
{
|
||||
// Source unreachable -> render the intro without embeds instead of a 500.
|
||||
LogLoadFailed(Logger, ex);
|
||||
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,4 +1,311 @@
|
||||
.intro {
|
||||
/* --- Hero ---------------------------------------------------------------------
|
||||
A full-width band in the brand gradient with a faint dot pattern on top. The
|
||||
page content sits in a centred column (.content in MainLayout), so the band
|
||||
breaks out of it: 100vw wide, pulled left by the column's offset. body clips the
|
||||
few pixels of scrollbar this adds (see app.css). The negative top margin cancels
|
||||
the column's top padding, so the band meets the header without a strip of page
|
||||
background in between.
|
||||
The pattern is an inline SVG in a data: URL -- no request, nothing from another
|
||||
host. White dots at 12% opacity: visible as texture, too faint to cost the
|
||||
white text any contrast. */
|
||||
.hero {
|
||||
width: 100vw;
|
||||
margin: calc(-1 * var(--space-4)) 0 var(--space-5) calc(50% - 50vw);
|
||||
background:
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Ccircle cx='3' cy='3' r='1.5' fill='%23fff' fill-opacity='.12'/%3E%3C/svg%3E"),
|
||||
var(--gradient-brand);
|
||||
color: var(--color-on-header);
|
||||
}
|
||||
|
||||
/* The inside follows the page frame again, so the hero text lines up with the
|
||||
tiles and lists below. Stacked on a phone; from tablet width the next-event card
|
||||
moves beside the text. */
|
||||
.hero-inner {
|
||||
box-sizing: border-box;
|
||||
max-width: var(--content-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-6) 1.25rem;
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.hero-inner {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(16rem, 22rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* On the gradient everything is white: the global heading colour is a blue that
|
||||
would vanish here. */
|
||||
/* balance evens out the lines, so a short last word ("IGMH") never wraps alone. */
|
||||
.hero h1 {
|
||||
color: var(--color-on-header);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* The intro is editor Markdown, rendered without scope attributes, so it is reached
|
||||
through ::deep. Slightly larger than body text, as a lead. Links stay white and
|
||||
underlined: the blue link colour would be unreadable on the gradient. */
|
||||
.hero-intro ::deep p {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.hero-intro ::deep a,
|
||||
.hero-intro ::deep a:hover {
|
||||
color: var(--color-on-header);
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
margin: var(--space-4) 0 0;
|
||||
}
|
||||
|
||||
/* The hero's buttons, adapted to the dark gradient: the primary turns light (a
|
||||
filled blue would melt into the blue end), the secondary becomes a white outline
|
||||
with the same translucent wash as the header menu on hover. The focus ring turns
|
||||
white for the same reason. */
|
||||
.hero .btn-primary {
|
||||
background: var(--color-button-light);
|
||||
border-color: var(--color-button-light);
|
||||
color: var(--color-on-button-light);
|
||||
}
|
||||
|
||||
.hero .btn-primary:hover {
|
||||
background: var(--color-button-light-hover);
|
||||
border-color: var(--color-button-light-hover);
|
||||
color: var(--color-on-button-light);
|
||||
}
|
||||
|
||||
.hero .btn-secondary {
|
||||
border-color: var(--color-on-header);
|
||||
color: var(--color-on-header);
|
||||
}
|
||||
|
||||
.hero .btn-secondary:hover {
|
||||
background: var(--color-nav-hover);
|
||||
color: var(--color-on-header);
|
||||
}
|
||||
|
||||
.hero .btn:focus-visible {
|
||||
outline-color: var(--color-focus-inverse);
|
||||
}
|
||||
|
||||
/* On a phone the buttons used to wrap onto a row each: two wide blocks stacked,
|
||||
far too heavy for the top of the page. Instead they share one row in equal
|
||||
columns -- the same buttons as on the desktop, just side by side and a little
|
||||
tighter. Short labels ("Termine", "Kontakt") fit on one line; a longer one
|
||||
wraps inside its button (centred) rather than pushing the other off the row. */
|
||||
@media (max-width: 40rem) {
|
||||
.hero-actions {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 1fr;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.hero .btn {
|
||||
justify-content: center;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Next event ---------------------------------------------------------------
|
||||
A card on the gradient: the regular card surface, so its text uses the normal
|
||||
tokens and adapts to dark mode by itself. The whole card is one link to the
|
||||
events page. */
|
||||
.next-event {
|
||||
display: block;
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-bg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
transition: box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.next-event:hover {
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--shadow-hover), var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* The ring sits outside the card, on the gradient, so it is white like the
|
||||
hero's button rings. */
|
||||
.next-event:focus-visible {
|
||||
outline-color: var(--color-focus-inverse);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.next-event-label {
|
||||
display: block;
|
||||
margin-bottom: var(--space-3);
|
||||
color: var(--color-text-soft);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.next-event-row {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.next-event-details {
|
||||
min-width: 0; /* lets a long title wrap instead of widening the card */
|
||||
}
|
||||
|
||||
.next-event-title {
|
||||
display: block;
|
||||
color: var(--color-brand);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Time and place, one line each with their icon, green as on the event cards. The
|
||||
icon belongs to Icon's own scope, so it is reached through ::deep. */
|
||||
.next-event-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-1);
|
||||
color: var(--color-text-soft);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.next-event-meta ::deep .icon {
|
||||
color: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
/* --- Tiles --------------------------------------------------------------------
|
||||
One card per header page. Fixed columns instead of auto-fill: two on a phone,
|
||||
three from tablet width, so the six pages always fill whole rows and never leave
|
||||
a half row behind. On a phone the tiles are compact (icon and title only), the
|
||||
teaser would make six tiles a long scroll. Hover = stronger shadow and a blue
|
||||
edge, nothing moves. */
|
||||
.home-tiles {
|
||||
margin-block: var(--space-5);
|
||||
}
|
||||
|
||||
.home-tiles ul {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* On a phone the icon sits above the title: beside it, a half-width column leaves
|
||||
too little room and titles get cut. */
|
||||
.tile-link {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-bg);
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
transition: box-shadow var(--transition), border-color var(--transition);
|
||||
}
|
||||
|
||||
.tile-link:hover {
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: var(--shadow-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* The icon in a soft round tint. */
|
||||
.tile-icon {
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
/* Long German compounds must wrap rather than overflow the narrow tile. */
|
||||
.tile-title {
|
||||
max-width: 100%;
|
||||
color: var(--color-brand);
|
||||
font-size: 1.1rem;
|
||||
color: #444;
|
||||
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;
|
||||
}
|
||||
@@ -3,29 +3,53 @@ using Elternbeirat.Contracts;
|
||||
namespace Elternbeirat.Web.Features.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the header and footer navigation from the public pages. Pages carry a
|
||||
/// <see cref="Page.Location"/> ("header"/"footer"/"") and an <see cref="Page.Order"/>;
|
||||
/// pages without a location are sub-pages and never appear in a menu.
|
||||
/// 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) =>
|
||||
Build(pages, "header");
|
||||
ToLinks(PagesAt(pages, "header"));
|
||||
|
||||
/// <summary>Footer links, ordered, built from pages with location "footer".</summary>
|
||||
public static IReadOnlyList<NavLinkItem> Footer(IReadOnlyList<Page> pages) =>
|
||||
Build(pages, "footer");
|
||||
ToLinks(PagesAt(pages, "footer"));
|
||||
|
||||
private static List<NavLinkItem> Build(IReadOnlyList<Page> pages, string location) =>
|
||||
/// <summary>
|
||||
/// Returns the pages of the header menu themselves, in menu order.
|
||||
/// </summary>
|
||||
/// <param name="pages">All public pages.</param>
|
||||
/// <returns>The pages with location <c>header</c>, ordered by <see cref="Page.Order"/>.</returns>
|
||||
/// <remarks>
|
||||
/// For callers that need more than a link, such as the tiles on the home page,
|
||||
/// which also show part of each page's body. Using the same selection as
|
||||
/// <see cref="Header"/> keeps tiles and menu in step.
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<Page> HeaderPages(IReadOnlyList<Page> pages) =>
|
||||
PagesAt(pages, "header");
|
||||
|
||||
/// <summary>
|
||||
/// Returns the URL a page is reached at.
|
||||
/// </summary>
|
||||
/// <param name="slug">The page's slug.</param>
|
||||
/// <returns><c>/</c> for the home page, otherwise <c>/{slug}</c>.</returns>
|
||||
/// <remarks>
|
||||
/// The home page owns "/" (served by the Home component); every other slug is
|
||||
/// reached at "/{slug}", where either a literal route or the catch-all content
|
||||
/// page renders it.
|
||||
/// </remarks>
|
||||
public static string HrefFor(string slug) =>
|
||||
Page.IsHomeSlug(slug) ? "/" : $"/{slug}";
|
||||
|
||||
private static List<Page> PagesAt(
|
||||
IReadOnlyList<Page> pages,
|
||||
string location) =>
|
||||
[.. pages
|
||||
.Where(page => string.Equals(page.Location, location, StringComparison.Ordinal))
|
||||
.OrderBy(page => page.Order)
|
||||
.Select(page => new NavLinkItem(HrefFor(page.Slug), page.Title))];
|
||||
.OrderBy(page => page.Order)];
|
||||
|
||||
// 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.
|
||||
private static string HrefFor(string slug) =>
|
||||
string.Equals(slug, "home", StringComparison.Ordinal) ? "/" : $"/{slug}";
|
||||
private static List<NavLinkItem> ToLinks(IEnumerable<Page> pages) =>
|
||||
[.. pages.Select(page => new NavLinkItem(HrefFor(page.Slug), page.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}"
|
||||
|
||||
@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>
|
||||
<article>
|
||||
@Body
|
||||
</article>
|
||||
<h1>@_page.Title</h1>
|
||||
<article class="markdown-body">@Body</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. *@
|
||||
@@ -2,29 +2,41 @@ using Elternbeirat.Contracts;
|
||||
using Elternbeirat.PocketBase;
|
||||
using Elternbeirat.Web.Shared;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Elternbeirat.Web.Features.Pages;
|
||||
|
||||
public partial class ContentPage
|
||||
{
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load page '{slug}' from PocketBase.")]
|
||||
private static partial void LogLoadFailed(ILogger logger, string slug, Exception exception);
|
||||
[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]
|
||||
public string Slug { get; set; } = "";
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
[Inject]
|
||||
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||
private PocketBaseClient PocketBase { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<ContentPage> Logger { get; set; } = default!;
|
||||
private ILogger<ContentPage> Logger { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private NavigationManager Navigation { get; set; } = null!;
|
||||
|
||||
[CascadingParameter]
|
||||
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);
|
||||
@@ -33,24 +45,23 @@ public partial class ContentPage
|
||||
{
|
||||
try
|
||||
{
|
||||
var pages = await PocketBase.GetPagesAsync();
|
||||
var pages = await PocketBase.GetPagesAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
|
||||
_page = pages.FirstOrDefault(
|
||||
p => string.Equals(p.Slug, Slug, StringComparison.OrdinalIgnoreCase));
|
||||
page => string.Equals(page.Slug, Slug, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
catch (PocketBaseUnavailableException exception)
|
||||
{
|
||||
// Source unreachable -> treat as "not found" rather than a 500. The
|
||||
// 404 branch below serves the friendly /not-found page.
|
||||
LogLoadFailed(Logger, Slug, ex);
|
||||
_page = null;
|
||||
// The site works, its data store is down: 503, not 404.
|
||||
LogSourceUnavailable(Logger, Slug, exception);
|
||||
_unavailable = true;
|
||||
SourceUnavailable.SetStatus(HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
// Unknown slug (or unreachable source) -> 404, so
|
||||
// UseStatusCodePagesWithReExecute serves the /not-found page instead of an
|
||||
// empty 200 response.
|
||||
if (_page is null && HttpContext is not null)
|
||||
if (_page is null)
|
||||
{
|
||||
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
// The source answered, but there is no page with this slug: a genuine 404.
|
||||
Navigation.NotFound();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/* A content page is one reading column: its text, boxes and card grids all stop at
|
||||
42rem (app.css). Left-aligned in the 72rem layout that column left a wide empty
|
||||
strip on the right of a desktop screen; centred, the empty space splits evenly and
|
||||
the page sits balanced under the header. Title and body share the column, so they
|
||||
keep one left edge. The children inside keep margin 0 and simply fill it. */
|
||||
h1,
|
||||
article,
|
||||
p {
|
||||
max-width: 42rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
@*
|
||||
One post as a card that links to the post: date, title, a teaser from the body
|
||||
and "Weiterlesen". The arrow is a hint where the card leads; it does not move.
|
||||
*@
|
||||
<a class="@CssClass" href="@Href">
|
||||
<span class="post-date"><Icon Name="calendar" Size="16" /><PostDate Value="Value.Date" /></span>
|
||||
<span class="post-title">@Value.Title</span>
|
||||
@if (Teaser.Length > 0)
|
||||
{
|
||||
<span class="post-teaser">@Teaser</span>
|
||||
}
|
||||
<span class="post-more">Weiterlesen <Icon Name="arrow-right" Size="16" /></span>
|
||||
</a>
|
||||
@@ -0,0 +1,46 @@
|
||||
using Elternbeirat.Contracts;
|
||||
using Elternbeirat.Web.Shared;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Elternbeirat.Web.Features.Posts;
|
||||
|
||||
/// <summary>
|
||||
/// Renders one post as a card linking to the post, with date, title and teaser.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Shared by the posts page and the home-page posts embed, so both show posts the
|
||||
/// same way. The teaser comes from the body (<see cref="Markdown.Teaser"/>): posts
|
||||
/// have no summary field of their own.
|
||||
/// </remarks>
|
||||
/// <seealso cref="PostDate"/>
|
||||
public partial class PostCard
|
||||
{
|
||||
/// <summary>
|
||||
/// The post to show.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
[EditorRequired]
|
||||
public Post Value { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// <see langword="true"/> for the large card that opens the posts page (the
|
||||
/// newest post); <see langword="false"/> for a regular card.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool Featured { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The card's classes; the featured card adds its modifier.
|
||||
/// </summary>
|
||||
private string CssClass => Featured ? "post-card post-card-featured" : "post-card";
|
||||
|
||||
/// <summary>
|
||||
/// The address of the post.
|
||||
/// </summary>
|
||||
private string Href => $"/posts/{Value.Slug}";
|
||||
|
||||
/// <summary>
|
||||
/// The teaser text; empty if the body has no paragraph text.
|
||||
/// </summary>
|
||||
private string Teaser => Markdown.Teaser(Value.Body);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/* The post card: a white surface with a green stripe on the left as the eye's
|
||||
anchor. The stripe is a border, so it follows the rounded corners. The whole card
|
||||
is the link and fills its list cell, so the cards of a row are equally tall.
|
||||
Hover = stronger shadow, nothing moves. */
|
||||
.post-card {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
height: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-left: 4px solid var(--color-accent);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-bg);
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
transition: box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.post-card:hover {
|
||||
box-shadow: var(--shadow-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Icon and date on one line. The icon belongs to Icon's own scope, so it is
|
||||
reached through ::deep. */
|
||||
.post-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.post-date ::deep .icon {
|
||||
color: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
.post-title {
|
||||
color: var(--color-brand);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Cut after three lines, so cards of a row stay close in height whatever the
|
||||
editor wrote. The featured card lifts the limit (see below). */
|
||||
.post-teaser {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
overflow: hidden;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* margin-top: auto pushes the link line to the bottom, so it sits on one level
|
||||
across a row of cards with teasers of different length. */
|
||||
.post-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
margin-top: auto;
|
||||
padding-top: var(--space-1);
|
||||
color: var(--color-brand);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* The newest post on the posts page: more room, a larger title and the full
|
||||
teaser. */
|
||||
.post-card-featured {
|
||||
padding: var(--space-4) var(--space-5);
|
||||
}
|
||||
|
||||
.post-card-featured .post-title {
|
||||
font-size: var(--font-size-h3);
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.post-card-featured .post-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.post-card-featured .post-teaser {
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
@*
|
||||
Renders a post's calendar day as a <time> element: the ISO value in the
|
||||
machine-readable datetime attribute, the German long form as visible text.
|
||||
A <time> needs the day in both forms, so putting it here keeps every caller
|
||||
to a single tag instead of repeating the two PostDateFormat calls.
|
||||
|
||||
The event counterpart is <EventDate>, which renders a point in time (with an
|
||||
optional clock time and range) instead of a plain day.
|
||||
*@
|
||||
<time datetime="@PostDateFormat.Iso(Value)">@PostDateFormat.Display(Value)</time>
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Elternbeirat.Web.Features.Posts;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a post's publication day as a <c><time></c> element, in both a
|
||||
/// machine-readable and a visitor-facing form. The two formats come from
|
||||
/// <see cref="PostDateFormat"/>.
|
||||
/// </summary>
|
||||
public partial class PostDate
|
||||
{
|
||||
/// <summary>
|
||||
/// The post's publication day.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
[EditorRequired]
|
||||
public DateOnly Value { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* The date's own look: small and softened. Lives with the component so every
|
||||
place that renders <PostDate> (list, home embed, detail page) shows the date
|
||||
the same way. Positioning (margin to neighbours) is NOT set here -- that
|
||||
depends on the surrounding context and is set by each caller. */
|
||||
time {
|
||||
display: block;
|
||||
color: var(--color-text-soft); /* token, so it adapts in dark mode */
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Globalization;
|
||||
using Elternbeirat.Web.Shared;
|
||||
|
||||
namespace Elternbeirat.Web.Features.Posts;
|
||||
|
||||
/// <summary>
|
||||
/// Formats a post's calendar day for display and for a machine-readable
|
||||
/// <c>datetime</c> attribute.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Posts carry a <see cref="DateOnly"/> (a calendar day, no time of day). Their
|
||||
/// <c><time></c> element needs the day twice: once in German for the
|
||||
/// visitor (<see cref="Display"/>) and once as ISO 8601 for the <c>datetime</c>
|
||||
/// attribute (<see cref="Iso"/>). Keeping both here means the two never drift
|
||||
/// apart and the markup no longer repeats the same <c>ToString</c> pair.
|
||||
/// <para>
|
||||
/// This is the post counterpart to
|
||||
/// <see cref="Events.EventDateFormat"/>, which formats a
|
||||
/// <see cref="System.DateTime"/> range instead.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class PostDateFormat
|
||||
{
|
||||
/// <summary>
|
||||
/// Display format for a calendar day, e.g. <c>8. Oktober 2026</c>. The
|
||||
/// weekday is omitted, unlike the event format.
|
||||
/// </summary>
|
||||
private const string LongDate = "d. MMMM yyyy";
|
||||
|
||||
/// <summary>
|
||||
/// Formats a calendar day in German for a visitor to read.
|
||||
/// </summary>
|
||||
/// <param name="value">
|
||||
/// The calendar day to format.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// The day as e.g. <c>8. Oktober 2026</c>, using German month names.
|
||||
/// </returns>
|
||||
public static string Display(DateOnly value)
|
||||
=> value.ToString(LongDate, Cultures.German);
|
||||
|
||||
/// <summary>
|
||||
/// Formats a calendar day as ISO 8601 (<c>yyyy-MM-dd</c>) for a
|
||||
/// machine-readable <c>datetime</c> attribute.
|
||||
/// </summary>
|
||||
/// <param name="value">
|
||||
/// The calendar day to format.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// The day as e.g. <c>2026-10-08</c>, culture-invariant so the value is
|
||||
/// stable regardless of the request's culture.
|
||||
/// </returns>
|
||||
public static string Iso(DateOnly value)
|
||||
=> value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -1,19 +1,46 @@
|
||||
@page "/posts/{Slug}"
|
||||
|
||||
@if (_post is null)
|
||||
@if (_unavailable)
|
||||
{
|
||||
<PageTitle>Nicht gefunden</PageTitle>
|
||||
<PageTitle>Vorübergehend nicht erreichbar</PageTitle>
|
||||
<p>Der Beitrag ist gerade nicht erreichbar. Bitte später erneut versuchen.</p>
|
||||
}
|
||||
else
|
||||
else if (_post is not null)
|
||||
{
|
||||
<PageTitle>@_post.Title</PageTitle>
|
||||
<article>
|
||||
<p class="post-meta">
|
||||
<a href="/posts">← Alle Beiträge</a>
|
||||
<time datetime="@_post.Date.ToString("yyyy-MM-dd")">
|
||||
@_post.Date.ToString("d. MMMM yyyy", German)
|
||||
</time>
|
||||
</p>
|
||||
@Body
|
||||
@* A plain link, not a <nav>: one link is no navigation block, and an unlabelled
|
||||
second <nav> next to the main menu would only add noise for screen readers. *@
|
||||
<a class="btn btn-quiet" href="/posts"><Icon Name="arrow-left" Size="18" />Alle Beiträge</a>
|
||||
|
||||
<article class="post">
|
||||
<header class="post-header">
|
||||
<p class="post-meta"><Icon Name="calendar" Size="16" /><PostDate Value="_post.Date" /></p>
|
||||
<h1>@_post.Title</h1>
|
||||
</header>
|
||||
<div class="markdown-body post-body">@Body</div>
|
||||
</article>
|
||||
|
||||
@if (_neighbors.Previous is not null || _neighbors.Next is not null)
|
||||
{
|
||||
@* Older post on the left, newer on the right, like a timeline. A missing side
|
||||
leaves its cell empty, so the remaining link keeps its place. *@
|
||||
<nav class="post-pager" aria-label="Weitere Beiträge">
|
||||
@if (_neighbors.Previous is { } previous)
|
||||
{
|
||||
<a class="post-pager-link post-pager-previous" href="/posts/@previous.Slug" rel="prev">
|
||||
<span class="post-pager-label"><Icon Name="arrow-left" Size="16" />Vorheriger Beitrag</span>
|
||||
<span class="post-pager-title">@previous.Title</span>
|
||||
</a>
|
||||
}
|
||||
@if (_neighbors.Next is { } next)
|
||||
{
|
||||
<a class="post-pager-link post-pager-next" href="/posts/@next.Slug" rel="next">
|
||||
<span class="post-pager-label">Nächster Beitrag<Icon Name="arrow-right" Size="16" /></span>
|
||||
<span class="post-pager-title">@next.Title</span>
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
}
|
||||
}
|
||||
@* No post and not unavailable: the slug was unknown; NavigationManager.NotFound()
|
||||
already ran in OnParametersSetAsync and renders the not-found page. *@
|
||||
@@ -1,33 +1,46 @@
|
||||
using System.Globalization;
|
||||
using Elternbeirat.Contracts;
|
||||
using Elternbeirat.PocketBase;
|
||||
using Elternbeirat.Web.Shared;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Elternbeirat.Web.Features.Posts;
|
||||
|
||||
public partial class PostDetail
|
||||
{
|
||||
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load post '{slug}' from PocketBase.")]
|
||||
private static partial void LogLoadFailed(ILogger logger, string slug, Exception exception);
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Error,
|
||||
Message = "Could not load post '{Slug}' from PocketBase; content source is unavailable.")]
|
||||
private static partial void LogSourceUnavailable(
|
||||
ILogger logger,
|
||||
string slug,
|
||||
Exception exception);
|
||||
|
||||
[Parameter]
|
||||
public string Slug { get; set; } = "";
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
[Inject]
|
||||
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||
private PocketBaseClient PocketBase { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<PostDetail> Logger { get; set; } = default!;
|
||||
private ILogger<PostDetail> Logger { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private NavigationManager Navigation { get; set; } = null!;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private Post? _post;
|
||||
|
||||
// The older and newer post for the "Vorheriger / Nächster Beitrag" links at the
|
||||
// end; both empty until loaded or when the post stands alone.
|
||||
private PostNeighbors _neighbors = new(null, null);
|
||||
|
||||
// Set when the content source could not be reached or read. The markup then
|
||||
// shows a "temporarily unavailable" note instead of a not-found page, matching
|
||||
// the 503 this sets on the response.
|
||||
private bool _unavailable;
|
||||
|
||||
// The post body rendered from Markdown to HTML, ready to emit as a
|
||||
// MarkupString in the markup.
|
||||
private MarkupString Body => (MarkupString)Markdown.ToHtml(_post?.Body);
|
||||
@@ -36,24 +49,24 @@ public partial class PostDetail
|
||||
{
|
||||
try
|
||||
{
|
||||
var posts = await PocketBase.GetPostsAsync();
|
||||
var posts = await PocketBase.GetPostsAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
|
||||
_post = posts.FirstOrDefault(
|
||||
p => string.Equals(p.Slug, Slug, StringComparison.OrdinalIgnoreCase));
|
||||
post => string.Equals(post.Slug, Slug, StringComparison.OrdinalIgnoreCase));
|
||||
_neighbors = PostNeighbors.Of(posts, Slug);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
catch (PocketBaseUnavailableException exception)
|
||||
{
|
||||
// Source unreachable -> treat as "not found" rather than a 500. The
|
||||
// 404 branch below serves the friendly /not-found page.
|
||||
LogLoadFailed(Logger, Slug, ex);
|
||||
_post = null;
|
||||
// The site works, its data store is down: 503, not 404.
|
||||
LogSourceUnavailable(Logger, Slug, exception);
|
||||
_unavailable = true;
|
||||
SourceUnavailable.SetStatus(HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
// Unknown slug (or unreachable source) -> 404, so
|
||||
// UseStatusCodePagesWithReExecute serves the /not-found page instead of an
|
||||
// empty 200 response.
|
||||
if (_post is null && HttpContext is not null)
|
||||
if (_post is null)
|
||||
{
|
||||
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
// The source answered, but there is no post with this slug: a genuine 404.
|
||||
Navigation.NotFound();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,116 @@
|
||||
.post-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
/* The article: the site's centred 42rem reading column (as on a content page,
|
||||
ContentPage.razor.css) with generous leading, so a long post reads like a page,
|
||||
not like a form. Everything inside follows this width -- the global 42rem cap on
|
||||
paragraphs, lists and boxes is lifted here, so nothing stops short of the column
|
||||
(the post's own leading makes the text a touch wider than elsewhere). */
|
||||
.post {
|
||||
max-width: 42rem;
|
||||
margin: var(--space-4) auto 0;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.post-meta time {
|
||||
display: block;
|
||||
color: #777;
|
||||
/* The back link is an inline button, which margin: auto cannot centre. A left
|
||||
margin of half the free space puts it on the column's left edge instead, and
|
||||
max() keeps it at 0 once the screen is narrower than the column. */
|
||||
.btn-quiet {
|
||||
margin-left: max(0px, (100% - 42rem) / 2);
|
||||
}
|
||||
|
||||
.post ::deep :is(p, ul, ol, blockquote, .aufruf, .hinweis) {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Date above the title as a quiet meta line, with the same calendar icon as on the
|
||||
post cards. The title follows directly, so the gap between them stays small
|
||||
(h1 carries no top margin globally). */
|
||||
.post-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
margin: 0 0 var(--space-2);
|
||||
color: var(--color-text-soft);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.post-meta ::deep .icon {
|
||||
color: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
/* The first paragraph as the lead: larger and in the soft tone, so it reads as an
|
||||
introduction. Pure CSS on the editor's Markdown -- if a post starts with a
|
||||
heading or a list instead, there simply is no lead. Same text colour token as
|
||||
the rest, so it keeps AA contrast in both schemes. */
|
||||
.post-body ::deep > p:first-child {
|
||||
color: var(--color-text-soft);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
/* Older/newer post at the end: two cards side by side, older on the left, newer on
|
||||
the right. The grid keeps each in its column even if the other is missing. Hover
|
||||
= border and shadow, nothing moves. */
|
||||
.post-pager {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-3);
|
||||
max-width: 42rem;
|
||||
margin: var(--space-6) auto 0;
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.post-pager-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
transition: box-shadow var(--transition), border-color var(--transition);
|
||||
}
|
||||
|
||||
.post-pager-link:hover {
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: var(--shadow-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.post-pager-next {
|
||||
grid-column: 2;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.post-pager-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
color: var(--color-text-soft);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.post-pager-next .post-pager-label {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.post-pager-title {
|
||||
color: var(--color-brand);
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* On a phone the two cards stack: a half-width card would squeeze a long title
|
||||
into a narrow column. Older first, as in the wide layout. */
|
||||
@media (max-width: 40rem) {
|
||||
.post-pager {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.post-pager-next {
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,36 @@
|
||||
@page "/posts"
|
||||
|
||||
<PageTitle>Beiträge</PageTitle>
|
||||
@{
|
||||
// Heading and intro come from the "posts" page record; fall back to a sensible
|
||||
// default only if that record is missing, so the page still renders.
|
||||
var title = _page?.Title ?? "Beiträge";
|
||||
}
|
||||
|
||||
<h1>Beiträge</h1>
|
||||
<PageTitle>@title</PageTitle>
|
||||
|
||||
@if (_posts.Count == 0)
|
||||
<h1>@title</h1>
|
||||
|
||||
@if (_page is not null && !string.IsNullOrWhiteSpace(_page.Body))
|
||||
{
|
||||
<article class="markdown-body">@Body</article>
|
||||
}
|
||||
|
||||
@if (_unavailable)
|
||||
{
|
||||
<p>Die Beiträge sind gerade nicht erreichbar. Bitte später erneut versuchen.</p>
|
||||
}
|
||||
else if (_posts.Count == 0)
|
||||
{
|
||||
<p>Zurzeit gibt es keine Beiträge.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="post-list">
|
||||
@foreach (var post in _posts)
|
||||
@* The newest post (GetPostsAsync sorts newest first) opens the page as a large
|
||||
card across the full width; the rest follow in the grid. *@
|
||||
<ul class="post-list post-list-grid">
|
||||
@for (var i = 0; i < _posts.Count; i++)
|
||||
{
|
||||
<li>
|
||||
<a href="@($"/posts/{post.Slug}")">@post.Title</a>
|
||||
<time datetime="@post.Date.ToString("yyyy-MM-dd")">
|
||||
@post.Date.ToString("d. MMMM yyyy", German)
|
||||
</time>
|
||||
</li>
|
||||
<li class="@(i == 0 ? "post-list-featured" : null)"><PostCard Value="_posts[i]" Featured="i == 0" /></li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
@@ -1,36 +1,60 @@
|
||||
using System.Globalization;
|
||||
using Elternbeirat.Contracts;
|
||||
using Elternbeirat.PocketBase;
|
||||
using Elternbeirat.Web.Shared;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Elternbeirat.Web.Features.Posts;
|
||||
|
||||
public partial class PostList
|
||||
{
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load posts from PocketBase.")]
|
||||
private static partial void LogLoadFailed(ILogger logger, Exception exception);
|
||||
|
||||
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Error,
|
||||
Message = "Could not load posts from PocketBase; content source is unavailable.")]
|
||||
private static partial void LogSourceUnavailable(
|
||||
ILogger logger,
|
||||
Exception exception);
|
||||
|
||||
[Inject]
|
||||
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||
private PocketBaseClient PocketBase { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<PostList> Logger { get; set; } = default!;
|
||||
private ILogger<PostList> Logger { get; set; } = null!;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
// The "posts" page record supplies the heading and intro text, so an editor owns
|
||||
// them in PocketBase like every other page, instead of them being hard-coded here.
|
||||
// Null until loaded, or when no such page exists.
|
||||
private Page? _page;
|
||||
|
||||
private IReadOnlyList<Post> _posts = [];
|
||||
|
||||
// Set when the content source could not be reached or read. The markup then
|
||||
// shows a "temporarily unavailable" note instead of "no posts", matching the
|
||||
// 503 this sets on the response.
|
||||
private bool _unavailable;
|
||||
|
||||
// The page body rendered from Markdown to HTML, ready to emit as a MarkupString.
|
||||
private MarkupString Body => (MarkupString)Markdown.ToHtml(_page?.Body);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_posts = await PocketBase.GetPostsAsync();
|
||||
var token = HttpContext?.RequestAborted ?? CancellationToken.None;
|
||||
var pages = await PocketBase.GetPagesAsync(token);
|
||||
_page = pages.FirstOrDefault(
|
||||
page => string.Equals(page.Slug, "posts", StringComparison.OrdinalIgnoreCase));
|
||||
_posts = await PocketBase.GetPostsAsync(token);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
catch (PocketBaseUnavailableException exception)
|
||||
{
|
||||
// Source unreachable -> show an empty list instead of a 500. The page
|
||||
// stays up; the "keine Beiträge" note is rendered.
|
||||
LogLoadFailed(Logger, ex);
|
||||
// An empty list would claim there are no posts. The site works, its
|
||||
// data store is down: 503 and an honest note.
|
||||
LogSourceUnavailable(Logger, exception);
|
||||
_unavailable = true;
|
||||
SourceUnavailable.SetStatus(HttpContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,29 @@
|
||||
/* The card is PostCard and the base list grid lives in the global app.css, shared
|
||||
with the home-page embed. Only the page's two-column layout is here: one column
|
||||
on a phone, two from tablet width, with the newest post across both. */
|
||||
@media (min-width: 48rem) {
|
||||
.post-list-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.post-list-featured {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Same centred 42rem reading column as a content page (ContentPage.razor.css), so
|
||||
every page of the site shares one column under the header. Two cards still fit
|
||||
side by side in it. */
|
||||
h1,
|
||||
article,
|
||||
p,
|
||||
.post-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
max-width: 42rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.post-list li {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.post-list a {
|
||||
font-size: 1.1rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.post-list time {
|
||||
display: block;
|
||||
color: #777;
|
||||
font-size: 0.85rem;
|
||||
/* The intro's last paragraph drops its margin (app.css), so the first card would
|
||||
sit right under the text; this gives it the same air as between sections. */
|
||||
article {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Elternbeirat.Contracts;
|
||||
|
||||
namespace Elternbeirat.Web.Features.Posts;
|
||||
|
||||
/// <summary>
|
||||
/// The posts next to a given post in time: the one published before it and the
|
||||
/// one published after it.
|
||||
/// </summary>
|
||||
/// <param name="Previous">
|
||||
/// The older post, or <see langword="null"/> if the post is the oldest.
|
||||
/// </param>
|
||||
/// <param name="Next">
|
||||
/// The newer post, or <see langword="null"/> if the post is the newest.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// "Previous" and "next" follow time, as a reader moving through the news would:
|
||||
/// "Vorheriger Beitrag" is the older one, "Nächster Beitrag" the newer one. The
|
||||
/// list order of the posts page (newest first) is the other way round, which is
|
||||
/// why the direction is pinned down here instead of being left to the markup.
|
||||
/// </remarks>
|
||||
/// <seealso cref="PostDetail"/>
|
||||
public sealed record PostNeighbors(Post? Previous, Post? Next)
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds the neighbours of the post with the given slug.
|
||||
/// </summary>
|
||||
/// <param name="posts">
|
||||
/// All public posts, newest first, as <c>PocketBaseClient.GetPostsAsync</c>
|
||||
/// returns them.
|
||||
/// </param>
|
||||
/// <param name="slug">The slug of the post being shown.</param>
|
||||
/// <returns>
|
||||
/// The older and the newer post. Both are <see langword="null"/> if the slug is
|
||||
/// not in the list, or if it is the only post.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// The slug is compared case-insensitively, like the route lookup in
|
||||
/// <see cref="PostDetail"/>, so a page reached as <c>/posts/New-Board</c> still
|
||||
/// gets its neighbours.
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// // posts = [March, February, January] (newest first)
|
||||
/// var neighbors = PostNeighbors.Of(posts, "february");
|
||||
/// // neighbors.Previous is January, neighbors.Next is March
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static PostNeighbors Of(IReadOnlyList<Post> posts, string slug) =>
|
||||
IndexOf(posts, slug) is var index and >= 0
|
||||
? new PostNeighbors(
|
||||
Previous: index + 1 < posts.Count ? posts[index + 1] : null,
|
||||
Next: index > 0 ? posts[index - 1] : null)
|
||||
: new PostNeighbors(null, null);
|
||||
|
||||
/// <summary>
|
||||
/// The position of the post with <paramref name="slug"/>, or <c>-1</c>.
|
||||
/// </summary>
|
||||
private static int IndexOf(IReadOnlyList<Post> posts, string slug) =>
|
||||
posts
|
||||
.Select((post, index) => (post, index))
|
||||
.FirstOrDefault(
|
||||
entry => string.Equals(entry.post.Slug, slug, StringComparison.OrdinalIgnoreCase),
|
||||
(null!, -1))
|
||||
.index;
|
||||
}
|
||||
+104
-28
@@ -2,61 +2,103 @@ using Elternbeirat.Contracts;
|
||||
using Elternbeirat.PocketBase;
|
||||
using Elternbeirat.Web.Components;
|
||||
using Elternbeirat.Web.Features.Events;
|
||||
using Elternbeirat.Web.Shared;
|
||||
using Microsoft.AspNetCore.Components.Endpoints;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddRazorComponents();
|
||||
|
||||
// "Today" for upcoming events comes from this clock, not DateTime.Today, so the
|
||||
// smoke tests can swap in a fixed date and check the pages before and after an event.
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
|
||||
// The typed client reads content from PocketBase over its REST API. The base URL
|
||||
// comes from configuration: appsettings for local dev, the PocketBase__BaseUrl
|
||||
// env var on the server (see compose.yaml).
|
||||
// comes from config: appsettings for local dev, the PocketBase__BaseUrl env var
|
||||
// on the server (see compose.yaml).
|
||||
var pocketBaseUrl = builder.Configuration["PocketBase:BaseUrl"]
|
||||
?? throw new InvalidOperationException("PocketBase:BaseUrl is not configured.");
|
||||
?? throw new InvalidOperationException("PocketBase:BaseUrl is not configured.");
|
||||
builder.Services.AddHttpClient<PocketBaseClient>(client =>
|
||||
client.BaseAddress = new Uri(pocketBaseUrl));
|
||||
{
|
||||
client.BaseAddress = new Uri(pocketBaseUrl);
|
||||
// Every page waits for PocketBase. The default wait is 100 seconds, so if
|
||||
// PocketBase is down the page would hang that long. 5 seconds fails fast instead.
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
});
|
||||
|
||||
// Shared by all requests, so the maintenance gate below asks PocketBase at most once
|
||||
// every few seconds rather than on every page view.
|
||||
builder.Services.AddSingleton<PocketBaseHealthCache>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// NPM terminates TLS and is the only way to reach the container (no port
|
||||
// mapping in production). Without UseForwardedHeaders the app sees every request
|
||||
// as HTTP and with the proxy IP instead of the client IP.
|
||||
// KnownNetworks/KnownProxies are deliberately empty because only NPM reaches
|
||||
// the container.
|
||||
app.UseForwardedHeaders(new ForwardedHeadersOptions
|
||||
{
|
||||
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
|
||||
KnownIPNetworks = { },
|
||||
KnownProxies = { }
|
||||
});
|
||||
// NPM sits in front of the app and handles HTTPS. Without this line the app would
|
||||
// think every request is plain HTTP and would see NPM's IP, not the visitor's.
|
||||
// KnownNetworks/KnownProxies are left empty on purpose: only NPM reaches the app.
|
||||
app.UseForwardedHeaders(
|
||||
new ForwardedHeadersOptions
|
||||
{
|
||||
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
|
||||
});
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (!app.Environment.IsDevelopment())
|
||||
if (app.Environment.IsDevelopment() is false)
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
// No UseHsts() and no UseHttpsRedirection(): NPM sets HSTS and terminates
|
||||
// TLS. Both here would create a redirect loop behind the proxy.
|
||||
// No UseHsts() and no UseHttpsRedirection() here: NPM already handles HTTPS.
|
||||
// Adding them behind NPM would send the browser into a redirect loop.
|
||||
}
|
||||
|
||||
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
|
||||
|
||||
// Don't add UseStatusCodePagesWithReExecute here. Blazor already shows its own
|
||||
// not-found page. That middleware would catch *every* error code, so when PocketBase
|
||||
// is down (a 503) it would wrongly show "not found" instead of our own message.
|
||||
app.UseAntiforgery();
|
||||
|
||||
// Maintenance gate: while PocketBase is down, every page gets the static
|
||||
// maintenance.html with 503 instead of a half-empty page (the menu comes from
|
||||
// PocketBase too). Routing has already picked the endpoint at this point, so the gate
|
||||
// covers exactly the Razor component pages: /health, the calendar feeds, the static
|
||||
// assets (incl. maintenance.html and its font) are not components and answer for
|
||||
// themselves. A new endpoint is thus left alone unless it is a page. The health
|
||||
// result is cached (PocketBaseHealthCache), so the page may come back a few seconds
|
||||
// after PocketBase does. Retry-After tells well-behaved clients and crawlers when to
|
||||
// come back; no-store keeps the 503 page out of any cache.
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
var isPage = context.GetEndpoint()?.Metadata.GetMetadata<ComponentTypeMetadata>() is not null;
|
||||
var services = context.RequestServices;
|
||||
if (!isPage
|
||||
|| await services.GetRequiredService<PocketBaseHealthCache>().IsHealthyAsync(
|
||||
services.GetRequiredService<PocketBaseClient>().IsHealthyAsync,
|
||||
context.RequestAborted))
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||
context.Response.Headers.RetryAfter = "60";
|
||||
context.Response.Headers.CacheControl = "no-store";
|
||||
context.Response.ContentType = "text/html; charset=utf-8";
|
||||
await context.Response.SendFileAsync(
|
||||
app.Environment.WebRootFileProvider.GetFileInfo("maintenance.html"),
|
||||
context.RequestAborted);
|
||||
});
|
||||
|
||||
app.MapStaticAssets();
|
||||
app.MapRazorComponents<App>();
|
||||
|
||||
// Subscribable calendar feed of all events. A minimal API endpoint rather than
|
||||
// a Razor page because it returns text/calendar, not HTML. Reads from PocketBase
|
||||
// per request; an unreachable source yields an empty calendar, not a 500.
|
||||
app.MapGet("/events.ics", async (PocketBaseClient pocketBase) =>
|
||||
// Calendar feed people can subscribe to. It returns calendar data, not a web page,
|
||||
// so it is a plain endpoint, not a Razor page. If PocketBase is down it returns an
|
||||
// empty calendar rather than an error.
|
||||
app.MapGet("/events.ics", async (PocketBaseClient pocketBase, CancellationToken token) =>
|
||||
{
|
||||
IReadOnlyList<Event> events;
|
||||
try
|
||||
{
|
||||
events = await pocketBase.GetEventsAsync();
|
||||
events = await pocketBase.GetEventsAsync(token);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
catch (PocketBaseUnavailableException)
|
||||
{
|
||||
events = [];
|
||||
}
|
||||
@@ -64,4 +106,38 @@ app.MapGet("/events.ics", async (PocketBaseClient pocketBase) =>
|
||||
return Results.Text(IcsCalendar.Build(events), "text/calendar; charset=utf-8");
|
||||
});
|
||||
|
||||
// One event as a calendar file ("In Kalender übernehmen" on the events page): the
|
||||
// same calendar as the feed, holding just this event. Unlike the feed it does not
|
||||
// fall back to an empty calendar when PocketBase is down -- a file without the event
|
||||
// the visitor clicked would be a silent failure, so it answers 503. An unknown or
|
||||
// non-public id is a 404.
|
||||
app.MapGet("/events/{id}.ics", async (string id, PocketBaseClient pocketBase, CancellationToken token) =>
|
||||
{
|
||||
IReadOnlyList<Event> events;
|
||||
try
|
||||
{
|
||||
events = await pocketBase.GetEventsAsync(token);
|
||||
}
|
||||
catch (PocketBaseUnavailableException)
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
return events.FirstOrDefault(@event => @event.Id == id) is { } match
|
||||
? Results.Text(IcsCalendar.Build([match]), "text/calendar; charset=utf-8")
|
||||
: Results.NotFound();
|
||||
});
|
||||
|
||||
// Status page for an outside monitor (Uptime Kuma) to check. One check tells all
|
||||
// three states apart: no reply = the app is down, 503 = the app runs but PocketBase
|
||||
// is down, 200 = both are fine. It talks to PocketBase directly, not through the
|
||||
// page-rendering code, so it can still report when that code is the thing failing.
|
||||
app.MapGet("/health", async (PocketBaseClient pocketBase, CancellationToken token) =>
|
||||
await pocketBase.IsHealthyAsync(token)
|
||||
? Results.Text("healthy", "text/plain; charset=utf-8")
|
||||
: Results.Text(
|
||||
"PocketBase unreachable",
|
||||
"text/plain; charset=utf-8",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable));
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace Elternbeirat.Web.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Tells the current calendar day as people at the school see it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The container's clock runs in UTC, so <see cref="DateTime.Today"/> would flip to
|
||||
/// the next day at 01:00 or 02:00 Berlin time, and an event shortly after midnight
|
||||
/// would count as past for an hour or two. Event times are Berlin wall-clock
|
||||
/// values (see <c>LocalDateTimeConverter</c>), so "today" has to be a Berlin day
|
||||
/// too.
|
||||
/// <para>
|
||||
/// The time comes from an injected <see cref="TimeProvider"/>, not the system
|
||||
/// clock, so tests can pin "today" and check what a page shows before and after
|
||||
/// an event.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class BerlinClock
|
||||
{
|
||||
/// <summary>
|
||||
/// IANA id; resolves on every platform .NET supports, Windows included.
|
||||
/// </summary>
|
||||
private static readonly TimeZoneInfo Berlin =
|
||||
TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||
|
||||
/// <summary>
|
||||
/// Returns today's date in Berlin.
|
||||
/// </summary>
|
||||
/// <param name="time">The clock to read, usually <see cref="TimeProvider.System"/>.</param>
|
||||
/// <returns>The current Berlin calendar day.</returns>
|
||||
public static DateOnly Today(TimeProvider time) =>
|
||||
DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(time.GetUtcNow(), Berlin).DateTime);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Elternbeirat.Web.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Shared <see cref="CultureInfo"/> instances used to format visitor-facing
|
||||
/// text.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Kept in one place so every component renders month and weekday names the
|
||||
/// same way, rather than each holding its own <c>de-DE</c> lookup. This is only
|
||||
/// for display text; machine-readable output (e.g. an HTML <c>datetime</c>
|
||||
/// attribute) uses <see cref="CultureInfo.InvariantCulture"/> instead.
|
||||
/// </remarks>
|
||||
internal static class Cultures
|
||||
{
|
||||
/// <summary>
|
||||
/// German culture, for the month and weekday names visitors read.
|
||||
/// </summary>
|
||||
public static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
using System.Globalization;
|
||||
using Elternbeirat.Contracts;
|
||||
|
||||
namespace Elternbeirat.Web.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Formats an <see cref="Event"/>'s date range for display in German. Shared by
|
||||
/// the full event list and the home-page events embed so both render dates the
|
||||
/// same way.
|
||||
/// </summary>
|
||||
public static class EventFormat
|
||||
{
|
||||
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
||||
|
||||
// An event counts as all-day when its start carries no wall-clock time
|
||||
// (00:00); PocketBase has no separate flag.
|
||||
private static bool HasTime(Event ev) => ev.Start.TimeOfDay != TimeSpan.Zero;
|
||||
|
||||
/// <summary>
|
||||
/// Formats the entry's date range: a single day, a date with a time, or a
|
||||
/// span across days.
|
||||
/// </summary>
|
||||
public static string Range(Event ev)
|
||||
{
|
||||
var start = HasTime(ev)
|
||||
? ev.Start.ToString("dddd, d. MMMM yyyy, HH:mm", German) + " Uhr"
|
||||
: ev.Start.ToString("dddd, d. MMMM yyyy", German);
|
||||
|
||||
if (ev.End is not { } end)
|
||||
{
|
||||
return start;
|
||||
}
|
||||
|
||||
// Same day: append just the end time. Different days: append the full
|
||||
// end date.
|
||||
if (end.Date == ev.Start.Date)
|
||||
{
|
||||
return HasTime(ev)
|
||||
? start + "–" + end.ToString("HH:mm", German) + " Uhr"
|
||||
: start;
|
||||
}
|
||||
|
||||
var endText = HasTime(ev)
|
||||
? end.ToString("d. MMMM yyyy, HH:mm", German) + " Uhr"
|
||||
: end.ToString("d. MMMM yyyy", German);
|
||||
return start + " – " + endText;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
@*
|
||||
Renders one icon from IconSet as inline SVG. Purely decorative: aria-hidden hides
|
||||
it from screen readers (the text next to it carries the meaning) and
|
||||
focusable="false" keeps old Edge/IE from putting it in the tab order. The stroke
|
||||
is currentColor, so the icon takes the colour of the surrounding text. An unknown
|
||||
name renders nothing at all.
|
||||
*@
|
||||
@if (_markup is not null)
|
||||
{
|
||||
<svg class="icon" width="@Size" height="@Size"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">@_markup</svg>
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Elternbeirat.Web.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a decorative line icon from <see cref="IconSet"/> as inline SVG.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Inline SVG, not an <c><img></c> or icon font: no extra request, nothing
|
||||
/// from a third-party host, and the icon follows the text colour (including dark
|
||||
/// mode) through <c>currentColor</c>. The icon is hidden from assistive technology,
|
||||
/// so it must never be the only carrier of meaning.
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// <Icon Name="map-pin" /> Aula
|
||||
/// <Icon Name="calendar" Size="16" />
|
||||
/// </code>
|
||||
/// </example>
|
||||
/// <seealso cref="IconSet"/>
|
||||
public partial class Icon
|
||||
{
|
||||
/// <summary>
|
||||
/// The inner SVG markup of the current icon, or <see langword="null"/> when the
|
||||
/// name is unknown and nothing should render.
|
||||
/// </summary>
|
||||
private MarkupString? _markup;
|
||||
|
||||
/// <summary>
|
||||
/// The icon name, e.g. <c>calendar</c>; see <see cref="IconSet"/> for the set.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
[EditorRequired]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Width and height in CSS pixels. Defaults to <c>20</c>, which sits well next
|
||||
/// to body text.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public int Size { get; set; } = 20;
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override void OnParametersSet() =>
|
||||
_markup = IconSet.Find(Name) is { } markup ? new MarkupString(markup) : null;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Collections.Frozen;
|
||||
|
||||
namespace Elternbeirat.Web.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// The fixed set of line icons the site can show, keyed by name.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The icons are inlined as SVG markup instead of being loaded from an icon font or
|
||||
/// a CDN: the site loads nothing from third-party hosts (see <c>docs/recht.md</c>),
|
||||
/// and inline SVG needs no extra request and inherits the text colour.
|
||||
/// <para>
|
||||
/// Source: Lucide (<c>lucide.dev</c>), ISC license; the icons derived from
|
||||
/// Feather (<c>calendar</c>, <c>clock</c>, <c>download</c>,
|
||||
/// <c>external-link</c>, <c>help-circle</c>, <c>info</c>, <c>arrow-right</c>,
|
||||
/// <c>arrow-left</c>)
|
||||
/// are MIT, Copyright (c) 2013-present Cole Bemis. Both licenses only ask that
|
||||
/// the notice travels with the copy, which this comment does. Only the inner
|
||||
/// elements are stored; <see cref="Icon"/> supplies the shared
|
||||
/// <c><svg></c> wrapper (24x24 view box, 2px round stroke).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Lucide renamed <c>help-circle</c> to <c>circle-question-mark</c>; the old
|
||||
/// name is kept here because it is the one the design work refers to.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <seealso cref="Icon"/>
|
||||
public static class IconSet
|
||||
{
|
||||
/// <summary>
|
||||
/// Inner SVG markup per icon name. Ordinal keys: names are code identifiers,
|
||||
/// not visitor text, so <c>Calendar</c> is deliberately not <c>calendar</c>.
|
||||
/// </summary>
|
||||
private static readonly FrozenDictionary<string, string> Icons =
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["calendar"] =
|
||||
"""<path d="M8 2v3"/><path d="M16 2v3"/><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/>""",
|
||||
["calendar-plus"] =
|
||||
"""<path d="M16 18h6"/><path d="M16 2v3"/><path d="M19 15v6"/><path d="M21 11.5V5a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2h8.3"/><path d="M3 9h18"/><path d="M8 2v3"/>""",
|
||||
["clock"] =
|
||||
"""<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>""",
|
||||
["map-pin"] =
|
||||
"""<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>""",
|
||||
["file-text"] =
|
||||
"""<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>""",
|
||||
["download"] =
|
||||
"""<path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/>""",
|
||||
["mail"] =
|
||||
"""<path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"/><rect x="2" y="4" width="20" height="16" rx="2"/>""",
|
||||
["users"] =
|
||||
"""<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><path d="M16 3.128a4 4 0 0 1 0 7.744"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><circle cx="9" cy="7" r="4"/>""",
|
||||
["heart-handshake"] =
|
||||
"""<path d="M19.414 14.414C21 12.828 22 11.5 22 9.5a5.5 5.5 0 0 0-9.591-3.676.6.6 0 0 1-.818.001A5.5 5.5 0 0 0 2 9.5c0 2.3 1.5 4 3 5.5l5.535 5.362a2 2 0 0 0 2.879.052 2.12 2.12 0 0 0-.004-3 2.124 2.124 0 1 0 3-3 2.124 2.124 0 0 0 3.004 0 2 2 0 0 0 0-2.828l-1.881-1.882a2.41 2.41 0 0 0-3.409 0l-1.71 1.71a2 2 0 0 1-2.828 0 2 2 0 0 1 0-2.828l2.823-2.762"/>""",
|
||||
["help-circle"] =
|
||||
"""<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>""",
|
||||
["newspaper"] =
|
||||
"""<path d="M15 18h-5"/><path d="M18 14h-8"/><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"/><rect width="8" height="4" x="10" y="6" rx="1"/>""",
|
||||
["arrow-left"] =
|
||||
"""<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>""",
|
||||
["arrow-right"] =
|
||||
"""<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>""",
|
||||
["external-link"] =
|
||||
"""<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>""",
|
||||
["info"] =
|
||||
"""<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>""",
|
||||
}.ToFrozenDictionary(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Looks up the inner SVG markup of an icon.
|
||||
/// </summary>
|
||||
/// <param name="name">
|
||||
/// The icon name, e.g. <c>calendar</c> or <c>map-pin</c>. Case-sensitive.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// The SVG child elements (paths, circles, rects) to place inside an
|
||||
/// <c><svg></c>, or <see langword="null"/> if <paramref name="name"/> is
|
||||
/// <see langword="null"/> or not part of the set.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// An unknown name yields <see langword="null"/> rather than an exception, so a
|
||||
/// typo costs a missing decoration, never a broken page.
|
||||
/// </remarks>
|
||||
public static string? Find(string? name) =>
|
||||
name is null ? null : Icons.GetValueOrDefault(name);
|
||||
}
|
||||
@@ -1,23 +1,355 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Markdig;
|
||||
using Markdig.Extensions.CustomContainers;
|
||||
using Markdig.Renderers.Html;
|
||||
using Markdig.Syntax;
|
||||
using Markdig.Syntax.Inlines;
|
||||
|
||||
namespace Elternbeirat.Web.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Renders Markdown to HTML. Content now comes from PocketBase as raw Markdown
|
||||
/// (the <c>body</c> field), so the pages render it at display time instead of
|
||||
/// reading pre-rendered HTML from files.
|
||||
/// Renders the editors' Markdown (the <c>body</c>, <c>intro</c> and <c>answer</c>
|
||||
/// fields from PocketBase) to the HTML the pages show.
|
||||
/// </summary>
|
||||
public static class Markdown
|
||||
/// <remarks>
|
||||
/// The pipeline is CommonMark plus exactly two extensions, each there for a reason
|
||||
/// an editor can see:
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <b>Custom containers</b> (<c>::: name</c> … <c>:::</c>) turn a block
|
||||
/// into <c><div class="name"></c>. That is how editors use the
|
||||
/// design blocks (<c>kennzahlen</c>, <c>aufruf</c>, <c>kacheln</c>,
|
||||
/// <c>hinweis</c>, <c>team</c>) without writing HTML. An unknown name
|
||||
/// just yields a div without styling, so a typo never breaks a page.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <b>Pipe tables</b>: plain CommonMark has no tables at all, so without
|
||||
/// this the table styles in <c>app.css</c> could never apply.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Raw HTML in the Markdown is <b>escaped</b>, not passed through
|
||||
/// (<see cref="MarkdownPipelineBuilder"/>'s <c>DisableHtml</c>). The result is
|
||||
/// rendered as a <c>MarkupString</c>, so a passed-through <c><script></c>
|
||||
/// would run in the visitor's browser, and the privacy text promises that no
|
||||
/// program code runs there. Editors style pages with the blocks instead.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// After parsing, a paragraph (or list item) that consists of nothing but one
|
||||
/// link gets the class <see cref="LoneLinkClass"/>. Pure CSS cannot tell
|
||||
/// "a link alone on its line" from "a link inside a sentence" (selectors ignore
|
||||
/// the text around an element), but the stylesheet needs exactly that to show a
|
||||
/// lone <c>mailto:</c> link as a button and a lone PDF link as a file card.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The list items of a <c>::: team</c> block become person cards
|
||||
/// (<see cref="TeamMember"/>): the initials avatar and the split into name,
|
||||
/// role and duties need the text itself, which CSS cannot read.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <seealso cref="IconSet"/>
|
||||
/// <seealso cref="TeamMember"/>
|
||||
public static partial class Markdown
|
||||
{
|
||||
private static readonly MarkdownPipeline Pipeline =
|
||||
new MarkdownPipelineBuilder().Build();
|
||||
/// <summary>
|
||||
/// The class marking a paragraph or list item whose only content is one link.
|
||||
/// <c>app.css</c> keys the mail button and the PDF card off it.
|
||||
/// </summary>
|
||||
public const string LoneLinkClass = "lone-link";
|
||||
|
||||
/// <summary>
|
||||
/// Converts a Markdown string to HTML. Returns an empty string for
|
||||
/// <c>null</c> or empty input, so callers can bind the result directly.
|
||||
/// The default teaser length: about two lines on a card, enough to say what a
|
||||
/// post is about without turning the list into a wall of text.
|
||||
/// </summary>
|
||||
public const int TeaserLength = 160;
|
||||
|
||||
private static readonly MarkdownPipeline Pipeline =
|
||||
new MarkdownPipelineBuilder()
|
||||
.UseCustomContainers()
|
||||
.UsePipeTables()
|
||||
.DisableHtml()
|
||||
.Build();
|
||||
|
||||
/// <summary>
|
||||
/// Converts a Markdown string to HTML.
|
||||
/// </summary>
|
||||
/// <param name="markdown">The editor's Markdown; may be <see langword="null"/>.</param>
|
||||
/// <returns>
|
||||
/// The rendered HTML, or an empty string for <see langword="null"/> or empty
|
||||
/// input, so callers can bind the result directly.
|
||||
/// </returns>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// Markdown.ToHtml("::: hinweis\nBitte vormerken.\n:::");
|
||||
/// // <div class="hinweis"><p>Bitte vormerken.</p></div>
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static string ToHtml(string? markdown) =>
|
||||
string.IsNullOrEmpty(markdown)
|
||||
string.IsNullOrEmpty(markdown) ? "" : Render(Parse(markdown));
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the first sentence of a Markdown text as plain text, e.g. as the
|
||||
/// short description on a home page tile.
|
||||
/// </summary>
|
||||
/// <param name="markdown">The editor's Markdown; may be <see langword="null"/>.</param>
|
||||
/// <returns>
|
||||
/// The first sentence of the first top-level paragraph, with all Markdown
|
||||
/// removed, whitespace collapsed and a trailing colon dropped, or an empty
|
||||
/// string when there is no such paragraph (empty body, or only headings, lists
|
||||
/// and blocks).
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Only top-level paragraphs count: a heading repeats the title, and the text in
|
||||
/// a list or a <c>:::</c> block is rarely a sentence that describes the page.
|
||||
/// <para>
|
||||
/// A sentence ends at <c>.</c>, <c>!</c> or <c>?</c> followed by a space or
|
||||
/// the end. A period after a single letter or a number does not count, so
|
||||
/// <c>z. B.</c> and <c>13. November</c> do not cut the sentence short. A
|
||||
/// longer abbreviation such as <c>bzw.</c> still does; that costs the rest
|
||||
/// of a teaser, never the page.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A paragraph that leads into a list often ends in <c>:</c> without a
|
||||
/// period; as a teaser the colon would point at a list that is not there.
|
||||
/// The sentence is returned whole: a long one is cut by the tile's CSS,
|
||||
/// which knows the space it has, not by a character count.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// Markdown.FirstSentence("# Vorstand\n\nWir sind **sieben** Eltern. Mehr unten.");
|
||||
/// // "Wir sind sieben Eltern."
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static string FirstSentence(string? markdown) =>
|
||||
string.IsNullOrWhiteSpace(markdown)
|
||||
? ""
|
||||
: Markdig.Markdown.ToHtml(markdown, Pipeline);
|
||||
: Parse(markdown).OfType<ParagraphBlock>().FirstOrDefault()?.Inline is { } inline
|
||||
? UpToSentenceEnd(PlainText(inline)).TrimEnd(':', ' ')
|
||||
: "";
|
||||
|
||||
/// <summary>
|
||||
/// Builds a short plain-text teaser from a Markdown text, e.g. for a post card.
|
||||
/// </summary>
|
||||
/// <param name="markdown">The editor's Markdown; may be <see langword="null"/>.</param>
|
||||
/// <param name="maxLength">
|
||||
/// The longest teaser, in characters, before the ellipsis. Defaults to
|
||||
/// <see cref="TeaserLength"/>.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// The text of all paragraphs (including those in lists, quotes and <c>:::</c>
|
||||
/// blocks), with all Markdown removed and whitespace collapsed. A longer text is
|
||||
/// cut at the last word boundary within <paramref name="maxLength"/> and ends
|
||||
/// in <c>…</c>. An empty string when there is no paragraph text at all.
|
||||
/// </returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// <paramref name="maxLength"/> is zero or negative.
|
||||
/// </exception>
|
||||
/// <remarks>
|
||||
/// Headings are skipped: on a card the title already heads the teaser. Unlike
|
||||
/// <see cref="FirstSentence"/> the teaser does not stop at the first sentence,
|
||||
/// because a post often opens with a short line ("Liebe Eltern,") that says
|
||||
/// nothing on its own.
|
||||
/// <para>
|
||||
/// Punctuation left dangling at the cut (<c>,</c>, <c>.</c>, <c>:</c> …) is
|
||||
/// dropped, so the teaser never ends in <c>,…</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// Markdown.Teaser("## Rückblick\n\nDer **Basar** war ein voller Erfolg.", 20);
|
||||
/// // "Der Basar war ein…"
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static string Teaser(string? markdown, int maxLength = TeaserLength) =>
|
||||
maxLength <= 0
|
||||
? throw new ArgumentOutOfRangeException(nameof(maxLength), maxLength, "The teaser needs room for at least one character.")
|
||||
: string.IsNullOrWhiteSpace(markdown)
|
||||
? ""
|
||||
: Shorten(
|
||||
string.Join(
|
||||
' ',
|
||||
Parse(markdown)
|
||||
.Descendants<ParagraphBlock>()
|
||||
.Select(paragraph => paragraph.Inline is { } inline ? PlainText(inline) : "")
|
||||
.Where(text => text.Length > 0)),
|
||||
maxLength);
|
||||
|
||||
/// <summary>
|
||||
/// Parses Markdown with the site's pipeline, for callers that take the document
|
||||
/// apart before rendering it (see <see cref="Render"/>).
|
||||
/// </summary>
|
||||
/// <param name="markdown">The editor's Markdown.</param>
|
||||
/// <returns>The parsed document.</returns>
|
||||
internal static MarkdownDocument Parse(string markdown) =>
|
||||
Markdig.Markdown.Parse(markdown, Pipeline);
|
||||
|
||||
/// <summary>
|
||||
/// Renders a document from <see cref="Parse"/> to HTML, building the team cards
|
||||
/// and marking lone links on the way, exactly as <see cref="ToHtml"/> does.
|
||||
/// </summary>
|
||||
/// <param name="document">The parsed document; changed in place.</param>
|
||||
/// <returns>The rendered HTML.</returns>
|
||||
internal static string Render(MarkdownDocument document) =>
|
||||
Markdig.Markdown.ToHtml(MarkLoneLinks(BuildTeamCards(document)), Pipeline);
|
||||
|
||||
/// <summary>
|
||||
/// Flattens inline Markdown to the text a reader sees: emphasis and link
|
||||
/// markup dropped, link text kept, images left out, whitespace collapsed.
|
||||
/// </summary>
|
||||
/// <param name="inlines">
|
||||
/// The inline content, e.g. of a paragraph or heading, or a part of it.
|
||||
/// </param>
|
||||
/// <returns>The visible text, trimmed.</returns>
|
||||
internal static string PlainText(IEnumerable<Inline> inlines) =>
|
||||
Whitespace().Replace(inlines.Aggregate(new StringBuilder(), AppendText).ToString(), " ").Trim();
|
||||
|
||||
/// <summary>
|
||||
/// Whether an inline carries no visible content (whitespace or a line break).
|
||||
/// </summary>
|
||||
/// <param name="inline">The inline to inspect.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> if it can be ignored when looking for links that stand
|
||||
/// alone in a paragraph.
|
||||
/// </returns>
|
||||
internal static bool IsBlank(Inline inline) =>
|
||||
inline switch
|
||||
{
|
||||
LineBreakInline => true,
|
||||
LiteralInline literal => literal.Content.IsEmptyOrWhitespace(),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Appends the visible text of an inline and its children.
|
||||
/// </summary>
|
||||
/// <param name="text">The builder to append to.</param>
|
||||
/// <param name="inline">The inline to flatten.</param>
|
||||
/// <returns>The same <paramref name="text"/>, for chaining.</returns>
|
||||
private static StringBuilder AppendText(StringBuilder text, Inline inline) =>
|
||||
inline switch
|
||||
{
|
||||
LiteralInline literal => text.Append(literal.Content.ToString()),
|
||||
CodeInline code => text.Append(code.Content),
|
||||
HtmlEntityInline entity => text.Append(entity.Transcoded.ToString()),
|
||||
AutolinkInline autolink => text.Append(autolink.Url),
|
||||
LineBreakInline => text.Append(' '),
|
||||
LinkInline { IsImage: true } => text,
|
||||
ContainerInline container => container.Aggregate(text, AppendText),
|
||||
_ => text,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Cuts a text after its first sentence.
|
||||
/// </summary>
|
||||
/// <param name="text">Plain text.</param>
|
||||
/// <returns>
|
||||
/// The first sentence including its end mark, or the whole text if it has none.
|
||||
/// </returns>
|
||||
private static string UpToSentenceEnd(string text) =>
|
||||
SentenceEnd().Match(text) is { Success: true } end ? text[..(end.Index + 1)] : text;
|
||||
|
||||
/// <summary>
|
||||
/// Cuts a text to a word boundary and marks the cut.
|
||||
/// </summary>
|
||||
/// <param name="text">Plain text.</param>
|
||||
/// <param name="maxLength">The longest result before the ellipsis.</param>
|
||||
/// <returns>
|
||||
/// The text unchanged if it fits; otherwise everything up to the last space
|
||||
/// within <paramref name="maxLength"/>, trailing punctuation removed, plus
|
||||
/// <c>…</c>. A single word longer than <paramref name="maxLength"/> is cut hard.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// The space is searched one character past the limit: if the text breaks
|
||||
/// exactly at <paramref name="maxLength"/>, the last whole word still fits.
|
||||
/// </remarks>
|
||||
private static string Shorten(string text, int maxLength) =>
|
||||
text.Length <= maxLength
|
||||
? text
|
||||
: (text[..(maxLength + 1)].LastIndexOf(' ') is var space and > 0 ? text[..space] : text[..maxLength])
|
||||
.TrimEnd(' ', ',', ';', ':', '.', '-', '–') + "…";
|
||||
|
||||
/// <summary>
|
||||
/// Matches the end mark of a sentence: <c>.</c>, <c>!</c> or <c>?</c> before a
|
||||
/// space or the end, unless it follows a lone letter (<c>z.</c>) or a number
|
||||
/// (<c>13.</c>).
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"(?<!\b\p{L}|\b\d+)[.!?](?=\s|$)")]
|
||||
private static partial Regex SentenceEnd();
|
||||
|
||||
/// <summary>
|
||||
/// Matches a run of whitespace, collapsed to one space in plain text.
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"\s+")]
|
||||
private static partial Regex Whitespace();
|
||||
|
||||
/// <summary>
|
||||
/// Turns the list items of every <c>::: team</c> block into person cards.
|
||||
/// </summary>
|
||||
/// <param name="document">The parsed document; changed in place.</param>
|
||||
/// <returns>The same <paramref name="document"/>, for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// Only lists directly in the block count; a nested list inside a card stays a
|
||||
/// list. <c>::: team gross</c> adds the class
|
||||
/// <see cref="TeamMember.LargeModifier"/> to the block, because Markdig only
|
||||
/// turns the block name into a class, not the words after it.
|
||||
/// </remarks>
|
||||
private static MarkdownDocument BuildTeamCards(MarkdownDocument document)
|
||||
{
|
||||
foreach (var team in document.Descendants<CustomContainer>().Where(TeamMember.IsTeam).ToList())
|
||||
{
|
||||
if (TeamMember.IsLarge(team))
|
||||
{
|
||||
team.GetAttributes().AddClass(TeamMember.LargeModifier);
|
||||
}
|
||||
|
||||
foreach (var item in team.OfType<ListBlock>().SelectMany(list => list.OfType<ListItemBlock>()))
|
||||
{
|
||||
TeamMember.ToCard(item);
|
||||
}
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds <see cref="LoneLinkClass"/> to every paragraph that holds nothing but
|
||||
/// one link.
|
||||
/// </summary>
|
||||
/// <param name="document">The parsed document; changed in place.</param>
|
||||
/// <returns>The same <paramref name="document"/>, for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// Inside a list item the class goes on the item, not the paragraph: in a tight
|
||||
/// list Markdig writes no <c><p></c> at all, so a class on the paragraph
|
||||
/// would be silently dropped.
|
||||
/// </remarks>
|
||||
private static MarkdownDocument MarkLoneLinks(MarkdownDocument document)
|
||||
{
|
||||
foreach (var paragraph in document.Descendants<ParagraphBlock>().Where(IsLoneLink))
|
||||
{
|
||||
MarkdownObject target = paragraph.Parent is ListItemBlock { Count: 1 } item ? item : paragraph;
|
||||
target.GetAttributes().AddClass(LoneLinkClass);
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a paragraph's content is exactly one link, ignoring surrounding
|
||||
/// whitespace and line breaks.
|
||||
/// </summary>
|
||||
/// <param name="paragraph">The paragraph to inspect.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> for one link (inline <c>[text](url)</c> or an
|
||||
/// autolink <c><url></c>, but not an image) and nothing else.
|
||||
/// </returns>
|
||||
private static bool IsLoneLink(ParagraphBlock paragraph) =>
|
||||
paragraph.Inline?
|
||||
.Where(inline => !IsBlank(inline))
|
||||
.ToList() is [LinkInline { IsImage: false } or AutolinkInline];
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
namespace Elternbeirat.Web.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Remembers for a few seconds whether PocketBase answered its health check, so the
|
||||
/// maintenance gate does not probe PocketBase on every page request.
|
||||
/// </summary>
|
||||
/// <param name="time">
|
||||
/// The clock that decides when a result is stale; injected so tests can step past
|
||||
/// <see cref="Lifetime"/> without waiting.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// The gate in <c>Program.cs</c> asks this cache before rendering any page and serves
|
||||
/// the static <c>maintenance.html</c> with 503 while the answer is "unhealthy". A
|
||||
/// failed check is kept just as long as a passed one: while PocketBase is down every
|
||||
/// probe would otherwise wait for the connection to fail again.
|
||||
/// <para>
|
||||
/// The price of caching is a short blind spot in both directions: after
|
||||
/// PocketBase fails, pages may still render for up to <see cref="Lifetime"/> (the
|
||||
/// components' own fallbacks cover that), and after it recovers the maintenance
|
||||
/// page may stay up for as long.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Registered as a singleton. No lock: two requests that find the result stale at
|
||||
/// the same moment both probe, and the later answer wins -- harmless, and cheaper
|
||||
/// than making every request wait on one another.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// var healthy = await cache.IsHealthyAsync(pocketBase.IsHealthyAsync, context.RequestAborted);
|
||||
/// </code>
|
||||
/// </example>
|
||||
/// <seealso cref="PocketBase.PocketBaseClient.IsHealthyAsync"/>
|
||||
public sealed class PocketBaseHealthCache(TimeProvider time)
|
||||
{
|
||||
/// <summary>
|
||||
/// How long a health check result is reused before PocketBase is asked again.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan Lifetime = TimeSpan.FromSeconds(10);
|
||||
|
||||
// One reference, swapped as a whole, so a reader never sees the result of one
|
||||
// check paired with the time of another.
|
||||
private volatile Check? _last;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the last known health of PocketBase, probing again once it is older
|
||||
/// than <see cref="Lifetime"/>.
|
||||
/// </summary>
|
||||
/// <param name="probe">
|
||||
/// The actual check, usually <see cref="PocketBase.PocketBaseClient.IsHealthyAsync"/>.
|
||||
/// Passed in rather than injected because the typed client is transient and this
|
||||
/// cache is a singleton.
|
||||
/// </param>
|
||||
/// <param name="token">Cancels the probe, e.g. when the visitor goes away.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> if PocketBase answered its last check (or answers this
|
||||
/// one), otherwise <see langword="false"/>.
|
||||
/// </returns>
|
||||
/// <exception cref="OperationCanceledException">
|
||||
/// <paramref name="token"/> was cancelled during a probe; nothing is cached then.
|
||||
/// </exception>
|
||||
public async Task<bool> IsHealthyAsync(Func<CancellationToken, Task<bool>> probe, CancellationToken token) =>
|
||||
_last is { } last && time.GetUtcNow() - last.At < Lifetime
|
||||
? last.Healthy
|
||||
: (_last = new Check(await probe(token), time.GetUtcNow())).Healthy;
|
||||
|
||||
/// <summary>
|
||||
/// One health check result and when it was taken.
|
||||
/// </summary>
|
||||
/// <param name="Healthy">Whether PocketBase answered.</param>
|
||||
/// <param name="At">When the answer came in.</param>
|
||||
private sealed record Check(bool Healthy, DateTimeOffset At);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace Elternbeirat.Web.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Marks the current response as HTTP 503 when the content source is down.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Several components handle a <see cref="PocketBase.PocketBaseUnavailableException"/>
|
||||
/// the same way: the site works but PocketBase does not, so the response is a
|
||||
/// 503 and the component renders a "temporarily unavailable" note instead of
|
||||
/// empty content that would read as 200 "there is nothing here". This puts the
|
||||
/// status-setting in one place so those components do not each repeat it.
|
||||
/// </remarks>
|
||||
internal static class SourceUnavailable
|
||||
{
|
||||
/// <summary>
|
||||
/// Sets the response status to 503 Service Unavailable, if there is a
|
||||
/// response to set it on.
|
||||
/// </summary>
|
||||
/// <param name="context">
|
||||
/// The current HTTP context, or <see langword="null"/> when the component
|
||||
/// renders outside a request (in which case there is nothing to do).
|
||||
/// </param>
|
||||
public static void SetStatus(HttpContext? context)
|
||||
{
|
||||
if (context is not null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
using System.Globalization;
|
||||
using Markdig.Extensions.CustomContainers;
|
||||
using Markdig.Renderers.Html;
|
||||
using Markdig.Syntax;
|
||||
using Markdig.Syntax.Inlines;
|
||||
|
||||
namespace Elternbeirat.Web.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// One person in a <c>::: team</c> block, read from a list item that follows the
|
||||
/// convention the board page has always used.
|
||||
/// </summary>
|
||||
/// <param name="Name">The person's name: the bold start of the item.</param>
|
||||
/// <param name="Role">
|
||||
/// The role after the name, without its parentheses, or <see langword="null"/>
|
||||
/// when the item has none (most of the "Beisitz" members).
|
||||
/// </param>
|
||||
/// <param name="Duties">
|
||||
/// The responsibilities from the italic second line, split at commas; empty when
|
||||
/// there is no such line.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// The convention, one list item per person:
|
||||
/// <code>
|
||||
/// - **Maik Palm** (Vorsitzender)
|
||||
/// *Schulkonferenz, Mensarat, Homepage*
|
||||
/// Mitglied im Gesamtelternbeirat.
|
||||
/// </code>
|
||||
/// The bold name becomes the card's heading, the role a chip, every duty a small
|
||||
/// tag and any further line the note. Line breaks may be hard (two spaces) or soft:
|
||||
/// editors forget the two spaces, and the card should not depend on them.
|
||||
/// <para>
|
||||
/// The board stays Markdown in the page body on purpose: a dedicated
|
||||
/// collection would be cleaner, but there is no way yet to bring a schema
|
||||
/// change to production (Gitea #39). Reading the convention costs the editors
|
||||
/// nothing they do not already write.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// An item that does not start with a bold name is not an error: it becomes a
|
||||
/// plain card showing its text as written (<see cref="PlainCardClass"/>).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <seealso cref="Markdown"/>
|
||||
public sealed record TeamMember(string Name, string? Role, IReadOnlyList<string> Duties)
|
||||
{
|
||||
/// <summary>
|
||||
/// The block name editors type: <c>::: team</c>.
|
||||
/// </summary>
|
||||
public const string ContainerName = "team";
|
||||
|
||||
/// <summary>
|
||||
/// The modifier for the chair, <c>::: team gross</c>; also the class it adds to
|
||||
/// the block. The cards keep the shared raster; only the role chip is
|
||||
/// highlighted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A word after the block name rather than a block of its own
|
||||
/// (<c>::: team-gross</c>): the block stays <c>team</c>, so a typo in the
|
||||
/// modifier only costs the highlight, never the whole grid. <c>groß</c> is
|
||||
/// accepted too; <c>gross</c> is the documented form because it types the same
|
||||
/// on every keyboard.
|
||||
/// </remarks>
|
||||
public const string LargeModifier = "gross";
|
||||
|
||||
/// <summary>
|
||||
/// The class on every card, i.e. on every list item in a <c>::: team</c> block.
|
||||
/// </summary>
|
||||
public const string CardClass = "team-card";
|
||||
|
||||
/// <summary>
|
||||
/// The extra class on a card whose item does not follow the convention.
|
||||
/// </summary>
|
||||
public const string PlainCardClass = "team-card-plain";
|
||||
|
||||
/// <summary>
|
||||
/// How many avatar colours there are; <c>app.css</c> defines
|
||||
/// <c>--color-avatar-1</c> (brand blue) and <c>--color-avatar-2</c> (brand
|
||||
/// green) to match.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only the two brand colours: more hues made the board look busy and pulled it
|
||||
/// away from the IGMH palette.
|
||||
/// </remarks>
|
||||
public const int ColorCount = 2;
|
||||
|
||||
/// <summary>
|
||||
/// FNV-1a offset basis and prime (32 bit), see <see cref="Fnv"/>.
|
||||
/// </summary>
|
||||
private const uint FnvOffset = 2166136261;
|
||||
|
||||
private const uint FnvPrime = 16777619;
|
||||
|
||||
/// <summary>
|
||||
/// The letters on the avatar, see <see cref="InitialsOf"/>.
|
||||
/// </summary>
|
||||
public string Initials => InitialsOf(Name);
|
||||
|
||||
/// <summary>
|
||||
/// The avatar colour, from <c>1</c> to <see cref="ColorCount"/>, see
|
||||
/// <see cref="ColorOf"/>.
|
||||
/// </summary>
|
||||
public int Color => ColorOf(Name);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the initials shown on a person's avatar.
|
||||
/// </summary>
|
||||
/// <param name="name">The person's name.</param>
|
||||
/// <returns>
|
||||
/// The first letter of the first and of the last word, upper case; one letter
|
||||
/// for a single word; an empty string when the name holds no letter at all.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// A double name counts as one word, so it adds one letter, not two: two
|
||||
/// letters are what fits the circle, and they are what people expect from a
|
||||
/// name like <c>Reger-Stilgenbauer</c>. Letters outside A–Z (<c>Ö</c>,
|
||||
/// <c>Ş</c>) are kept as they are, and leading punctuation is skipped.
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// TeamMember.InitialsOf("Jenny Reger-Stilgenbauer"); // "JR"
|
||||
/// TeamMember.InitialsOf("Özlem Ünal"); // "ÖÜ"
|
||||
/// TeamMember.InitialsOf("Isabell"); // "I"
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static string InitialsOf(string name) =>
|
||||
name.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) switch
|
||||
{
|
||||
[] => "",
|
||||
[var only] => Initial(only),
|
||||
[var first, .., var last] => Initial(first) + Initial(last),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Picks a person's avatar colour from the name.
|
||||
/// </summary>
|
||||
/// <param name="name">The person's name.</param>
|
||||
/// <returns>A number from <c>1</c> to <see cref="ColorCount"/>.</returns>
|
||||
/// <remarks>
|
||||
/// The same name always gets the same colour, on every request and after every
|
||||
/// restart, so a person does not change colour between visits. That is why this
|
||||
/// is a fixed hash (FNV-1a) and not <see cref="string.GetHashCode()"/>, which
|
||||
/// .NET randomises per process. Surrounding whitespace does not count.
|
||||
/// <para>
|
||||
/// FNV-1a alone spreads short names badly, so the MurmurHash3 finaliser
|
||||
/// (<see cref="Mix"/>) stirs all bits first. The colour is then taken from
|
||||
/// the <b>high</b> bits (multiply by <see cref="ColorCount"/>, keep the top
|
||||
/// 32 bits) rather than a remainder, which would read only the low bits:
|
||||
/// with two colours that put eight of the ten board members on the same
|
||||
/// one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static int ColorOf(string name) =>
|
||||
(int)(((ulong)Mix(Fnv(name.Trim())) * ColorCount) >> 32) + 1;
|
||||
|
||||
/// <summary>
|
||||
/// Reads a person from the first paragraph of a list item.
|
||||
/// </summary>
|
||||
/// <param name="paragraph">The paragraph to read; not changed.</param>
|
||||
/// <returns>
|
||||
/// The person and the inlines that form the note (everything after the name
|
||||
/// line and, if present, the duties line; possibly empty), or
|
||||
/// <see langword="null"/> when the paragraph does not start with a bold name.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// The duties line is only recognised when it is nothing but one italic span;
|
||||
/// any other second line is part of the note, so no text is lost.
|
||||
/// </remarks>
|
||||
internal static (TeamMember Member, Inline[] Note)? Read(ParagraphBlock paragraph) =>
|
||||
Lines(paragraph).ToArray() is [var head, .. var rest]
|
||||
&& Content(head) is [EmphasisInline { DelimiterCount: 2 } name, .. var role]
|
||||
&& Markdown.PlainText(name) is { Length: > 0 } nameText
|
||||
? rest is [var second, .. var after] && Content(second) is [EmphasisInline { DelimiterCount: 1 } duties]
|
||||
? (new TeamMember(nameText, RoleOf(role), DutiesOf(duties)), [.. after.SelectMany(line => line)])
|
||||
: (new TeamMember(nameText, RoleOf(role), []), [.. rest.SelectMany(line => line)])
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Turns one list item of a <c>::: team</c> block into a person card.
|
||||
/// </summary>
|
||||
/// <param name="item">The list item; changed in place.</param>
|
||||
/// <remarks>
|
||||
/// The first paragraph is rebuilt from new inlines: raw HTML inlines for the
|
||||
/// tags and literal inlines for the text, so Markdig escapes everything the
|
||||
/// editor wrote. The note keeps its original inlines, so links and emphasis in it
|
||||
/// still render. Any further blocks in the item stay untouched below.
|
||||
/// </remarks>
|
||||
internal static void ToCard(ListItemBlock item)
|
||||
{
|
||||
var attributes = item.GetAttributes();
|
||||
attributes.AddClass(CardClass);
|
||||
|
||||
if (item.FirstOrDefault() is ParagraphBlock { Inline: not null } paragraph && Read(paragraph) is { } card)
|
||||
{
|
||||
foreach (var inline in card.Note)
|
||||
{
|
||||
inline.Remove();
|
||||
}
|
||||
|
||||
paragraph.Inline = card.Member.Head()
|
||||
.Concat(card.Note.Length > 0 ? [Open("span", "team-note"), .. card.Note, Close("span")] : [])
|
||||
.Aggregate(new ContainerInline(), (container, inline) => container.AppendChild(inline));
|
||||
}
|
||||
else
|
||||
{
|
||||
attributes.AddClass(PlainCardClass);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a <c>:::</c> block is a team block.
|
||||
/// </summary>
|
||||
/// <param name="container">The block to inspect.</param>
|
||||
/// <returns><see langword="true"/> for <c>::: team</c>, with or without modifier.</returns>
|
||||
internal static bool IsTeam(CustomContainer container) =>
|
||||
string.Equals(container.Info, ContainerName, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Whether a team block asks for the chair highlight (<c>::: team gross</c>).
|
||||
/// </summary>
|
||||
/// <param name="container">The team block.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> when one of the words after the name is
|
||||
/// <c>gross</c> or <c>groß</c>, in any case.
|
||||
/// </returns>
|
||||
internal static bool IsLarge(CustomContainer container) =>
|
||||
(container.Arguments ?? "")
|
||||
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Any(word => word.Equals(LargeModifier, StringComparison.OrdinalIgnoreCase)
|
||||
|| word.Equals("groß", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// The card's head: avatar, name, role chip and duty tags, as inlines.
|
||||
/// </summary>
|
||||
/// <returns>The inlines, in reading order; missing parts are left out.</returns>
|
||||
/// <remarks>
|
||||
/// The avatar is hidden from screen readers: it only repeats the name next to
|
||||
/// it.
|
||||
/// </remarks>
|
||||
private Inline[] Head() =>
|
||||
[
|
||||
.. Initials is { Length: > 0 } initials
|
||||
? [Open("span", $"team-avatar team-avatar-{Color.ToString(CultureInfo.InvariantCulture)}", " aria-hidden=\"true\""), new LiteralInline(initials), Close("span")]
|
||||
: Array.Empty<Inline>(),
|
||||
.. Element("team-name", Name, "strong"),
|
||||
.. Role is { } role ? Element("team-role", role) : [],
|
||||
.. Duties.Count > 0
|
||||
? [Open("span", "team-duties"), .. Duties.SelectMany(Duty), Close("span")]
|
||||
: Array.Empty<Inline>(),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// The 32-bit FNV-1a hash of a text's UTF-16 code units.
|
||||
/// </summary>
|
||||
/// <param name="text">The text.</param>
|
||||
/// <returns>The hash.</returns>
|
||||
private static uint Fnv(string text) =>
|
||||
unchecked(text.Aggregate(FnvOffset, (hash, letter) => (hash ^ letter) * FnvPrime));
|
||||
|
||||
/// <summary>
|
||||
/// The MurmurHash3 finaliser (<c>fmix32</c>): spreads every input bit over all
|
||||
/// output bits.
|
||||
/// </summary>
|
||||
/// <param name="hash">The hash to mix.</param>
|
||||
/// <returns>The mixed hash.</returns>
|
||||
private static uint Mix(uint hash) =>
|
||||
unchecked(XorShift(XorShift(XorShift(hash, 16) * 0x85EBCA6B, 13) * 0xC2B2AE35, 16));
|
||||
|
||||
/// <summary>
|
||||
/// Folds the high bits of a value into its low bits.
|
||||
/// </summary>
|
||||
/// <param name="value">The value.</param>
|
||||
/// <param name="shift">How far to shift right before the XOR.</param>
|
||||
/// <returns><paramref name="value"/> XOR <paramref name="value"/> shifted right.</returns>
|
||||
private static uint XorShift(uint value, int shift) => value ^ (value >> shift);
|
||||
|
||||
/// <summary>
|
||||
/// One duty tag, led by a space so screen readers keep the tags apart.
|
||||
/// </summary>
|
||||
/// <param name="duty">The duty.</param>
|
||||
/// <returns>The space and the tag.</returns>
|
||||
private static Inline[] Duty(string duty) =>
|
||||
[new LiteralInline(" "), .. Element("team-duty", duty)];
|
||||
|
||||
/// <summary>
|
||||
/// An element with escaped text inside.
|
||||
/// </summary>
|
||||
/// <param name="classes">The value of the class attribute; written as is.</param>
|
||||
/// <param name="text">The text; escaped by Markdig on rendering.</param>
|
||||
/// <param name="tag">The element name.</param>
|
||||
/// <returns>Opening tag, text and closing tag.</returns>
|
||||
private static Inline[] Element(string classes, string text, string tag = "span") =>
|
||||
[Open(tag, classes), new LiteralInline(text), Close(tag)];
|
||||
|
||||
/// <summary>
|
||||
/// An opening tag with a class, as a raw HTML inline.
|
||||
/// </summary>
|
||||
/// <param name="tag">The element name.</param>
|
||||
/// <param name="classes">The value of the class attribute; written as is.</param>
|
||||
/// <param name="attributes">
|
||||
/// Further attributes, written as is after the class, with a leading space.
|
||||
/// </param>
|
||||
/// <returns>The tag.</returns>
|
||||
/// <remarks>
|
||||
/// Only ever called with constants from this class, never with editor text:
|
||||
/// nothing here is escaped.
|
||||
/// </remarks>
|
||||
private static HtmlInline Open(string tag, string classes, string attributes = "") =>
|
||||
new($"<{tag} class=\"{classes}\"{attributes}>");
|
||||
|
||||
/// <summary>
|
||||
/// A closing tag, as a raw HTML inline.
|
||||
/// </summary>
|
||||
/// <param name="tag">The element name.</param>
|
||||
/// <returns>The tag.</returns>
|
||||
private static HtmlInline Close(string tag) => new($"</{tag}>");
|
||||
|
||||
/// <summary>
|
||||
/// The first letter of a word, upper case.
|
||||
/// </summary>
|
||||
/// <param name="word">One word of a name.</param>
|
||||
/// <returns>The letter, or an empty string if the word has none.</returns>
|
||||
private static string Initial(string word) =>
|
||||
word.FirstOrDefault(char.IsLetter) is var letter and not '\0'
|
||||
? letter.ToString().ToUpper(Cultures.German)
|
||||
: "";
|
||||
|
||||
/// <summary>
|
||||
/// The text after the name, without separators and parentheses.
|
||||
/// </summary>
|
||||
/// <param name="inlines">The inlines after the bold name on the first line.</param>
|
||||
/// <returns>The role, or <see langword="null"/> if nothing is left.</returns>
|
||||
private static string? RoleOf(IEnumerable<Inline> inlines) =>
|
||||
Markdown.PlainText(inlines).Trim(' ', ',', ':', ';', '-', '–') is var text
|
||||
&& (text.StartsWith('(') && text.EndsWith(')') ? text[1..^1].Trim() : text) is { Length: > 0 } role
|
||||
? role
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Splits the italic duties line at its commas.
|
||||
/// </summary>
|
||||
/// <param name="duties">The italic span.</param>
|
||||
/// <returns>The duties, trimmed, without empty entries.</returns>
|
||||
private static string[] DutiesOf(EmphasisInline duties) =>
|
||||
Markdown.PlainText(duties).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
/// <summary>
|
||||
/// The inlines of a line that carry content: no whitespace, no line break.
|
||||
/// </summary>
|
||||
/// <param name="line">One line from <see cref="Lines"/>.</param>
|
||||
/// <returns>The content inlines, in order.</returns>
|
||||
private static Inline[] Content(IEnumerable<Inline> line) =>
|
||||
[.. line.Where(inline => !Markdown.IsBlank(inline))];
|
||||
|
||||
/// <summary>
|
||||
/// Splits a paragraph's inlines into lines.
|
||||
/// </summary>
|
||||
/// <param name="paragraph">The paragraph.</param>
|
||||
/// <returns>
|
||||
/// The lines; each keeps the line break that ends it, so the note can be moved
|
||||
/// with its breaks intact.
|
||||
/// </returns>
|
||||
private static IEnumerable<List<Inline>> Lines(ParagraphBlock paragraph)
|
||||
{
|
||||
var line = new List<Inline>();
|
||||
foreach (var inline in paragraph.Inline?.ToList() ?? [])
|
||||
{
|
||||
line.Add(inline);
|
||||
if (inline is LineBreakInline)
|
||||
{
|
||||
yield return line;
|
||||
line = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (line.Count > 0)
|
||||
{
|
||||
yield return line;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
@using System.Globalization
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
|
||||
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user