Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7c55c0c6c | ||
|
|
01d25eb04b | ||
|
|
e242eaf79a | ||
|
|
0f7e968c16 | ||
|
|
d06af2891a | ||
|
|
66bc76af1c | ||
|
|
31b114982e | ||
|
|
6d3260db49 | ||
|
|
aef05fb090 |
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
|
||||
@@ -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,56 @@ 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 <see cref="Topic"/>.
|
||||
/// </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>
|
||||
/// <summary>
|
||||
/// Gets the topic the question is grouped under, e.g.
|
||||
/// <c>"Mensa und Mittagessen"</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see langword="required"/>: the FAQ page groups entries by topic, so an entry
|
||||
/// without one has no place. The field is required in PocketBase.
|
||||
///
|
||||
/// This is the heading shown on the page verbatim, not a slug: editors add a
|
||||
/// new group by typing its heading here, no code change. Entries with the same
|
||||
/// topic text land in one group.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("topic")]
|
||||
public string Topic { get; init; } = "";
|
||||
|
||||
/// <summary>Whether the question is visible to visitors.</summary>
|
||||
[JsonPropertyName("public")]
|
||||
public bool Public { get; init; }
|
||||
public required string Topic { 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>, <c>"events"</c> and <c>"faqs"</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));
|
||||
}
|
||||
}
|
||||
@@ -6,46 +6,115 @@ 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>
|
||||
/// </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()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
options.Converters.Add(new LocalDateTimeConverter());
|
||||
options.Converters.Add(new NullableLocalDateTimeConverter());
|
||||
return options;
|
||||
}
|
||||
/// <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 pages, ordered for navigation.</summary>
|
||||
public Task<IReadOnlyList<Page>> GetPagesAsync(CancellationToken ct = default)
|
||||
=> GetRecordsAsync<Page>("pages", "order", ct);
|
||||
/// <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 posts, newest first.</summary>
|
||||
public Task<IReadOnlyList<Post>> GetPostsAsync(CancellationToken ct = default)
|
||||
=> GetRecordsAsync<Post>("posts", "-date", ct);
|
||||
/// <summary>
|
||||
/// Gets all public FAQ entries, ordered by <see cref="Faq.Topic"/>, then by
|
||||
/// <see cref="Faq.Question"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The second sort key makes the order total: sorting by topic alone leaves
|
||||
/// entries within a topic in an undefined order, so the same content could
|
||||
/// render differently between requests. Adding the question as a tie-breaker
|
||||
/// keeps the FAQ list and any teaser drawn from it stable.
|
||||
/// </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,question", token);
|
||||
|
||||
/// <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>
|
||||
/// <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)
|
||||
{
|
||||
// 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 +122,25 @@ 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);
|
||||
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; } = [];
|
||||
}
|
||||
@@ -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,18 +19,18 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
|
||||
|
||||
// Two seeded events, earliest start first.
|
||||
events.Select(e => e.Title).ShouldBe(["Elternbeiratssitzung", "Herbstbasar"]);
|
||||
events.ShouldAllBe(e => e.Public);
|
||||
}
|
||||
|
||||
[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();
|
||||
@@ -63,6 +63,20 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
|
||||
posts.Select(p => p.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]
|
||||
public async Task Pages_exclude_non_public_records()
|
||||
{
|
||||
@@ -97,7 +111,7 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
|
||||
|
||||
var faqs = await client.GetFaqsAsync();
|
||||
|
||||
faqs.Select(f => f.Topic).ShouldBe(["mensa", "schliessfach"], ignoreOrder: true);
|
||||
faqs.Select(f => f.Topic).ShouldBe(["Mensa und Mittagessen", "Schließfächer"], ignoreOrder: true);
|
||||
faqs.ShouldAllBe(f => !string.IsNullOrWhiteSpace(f.Question));
|
||||
}
|
||||
}
|
||||
@@ -283,10 +283,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
|
||||
@@ -295,15 +298,18 @@ 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,
|
||||
});
|
||||
|
||||
@@ -311,12 +317,12 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
||||
await CreateRecordAsync(http, "faqs", new
|
||||
{
|
||||
question = "Wann gibt es Mittagessen?", answer = "Um 12 Uhr.",
|
||||
topic = "mensa", @public = true,
|
||||
topic = "Mensa und Mittagessen", @public = true,
|
||||
});
|
||||
await CreateRecordAsync(http, "faqs", new
|
||||
{
|
||||
question = "Wie viel kostet ein Schließfach?", answer = "20 Euro.",
|
||||
topic = "schliessfach", @public = true,
|
||||
topic = "Schließfächer", @public = true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -329,6 +335,6 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
||||
private sealed record AuthResponse
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("token")]
|
||||
public string Token { get; init; } = "";
|
||||
public string Token { get; init; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,19 @@ 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 = [];
|
||||
@@ -22,15 +27,15 @@ public partial class MainLayout
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
@if (_upcoming.Count > 0)
|
||||
{
|
||||
<section class="embed embed-events">
|
||||
<h2>Kommende Termine</h2>
|
||||
|
||||
@if (_upcoming.Count == 0)
|
||||
{
|
||||
<p>Zurzeit sind keine Termine geplant.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="event-list">
|
||||
@foreach (var ev in _upcoming)
|
||||
{
|
||||
@@ -21,5 +16,5 @@
|
||||
}
|
||||
</ul>
|
||||
<p class="embed-more"><a href="/events">Alle Termine <span aria-hidden="true">→</span></a></p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@@ -6,14 +6,24 @@ 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!;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private IReadOnlyList<Event> _upcoming = [];
|
||||
|
||||
@@ -22,22 +32,25 @@ public partial class EventsEmbed
|
||||
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.
|
||||
// Teaser: the 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();
|
||||
_upcoming =
|
||||
[
|
||||
.. events
|
||||
.Where(@event => DateOnly.FromDateTime(@event.Start) >= today)
|
||||
.OrderBy(@event => @event.Start)
|
||||
.Take(TeaserCount)
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
@if (_faqs.Count > 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">
|
||||
@@ -17,5 +12,5 @@
|
||||
</details>
|
||||
}
|
||||
<p class="embed-more"><a href="/faqs">Alle Fragen <span aria-hidden="true">→</span></a></p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@@ -6,14 +6,24 @@ 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);
|
||||
// Number of questions shown in the teaser; the full hub is one link away.
|
||||
private const int TeaserCount = 3;
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Could not load FAQs 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<FaqsEmbed> Logger { get; set; } = default!;
|
||||
private ILogger<FaqsEmbed> Logger { get; set; } = null!;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private IReadOnlyList<Faq> _faqs = [];
|
||||
|
||||
@@ -21,14 +31,16 @@ public partial class FaqsEmbed
|
||||
{
|
||||
try
|
||||
{
|
||||
// Teaser: the first three questions, with a link to the full hub.
|
||||
var faqs = await PocketBase.GetFaqsAsync();
|
||||
_faqs = [.. faqs.Take(3)];
|
||||
// GetFaqsAsync is ordered by topic, then question, so the first entries
|
||||
// the teaser takes are stable across requests.
|
||||
var faqs = await PocketBase.GetFaqsAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
|
||||
_faqs = [.. faqs.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,23 +1,18 @@
|
||||
@if (_latest.Count > 0)
|
||||
{
|
||||
<section class="embed embed-posts">
|
||||
<h2>Aktuelle Beiträge</h2>
|
||||
|
||||
@if (_latest.Count == 0)
|
||||
{
|
||||
<p>Zurzeit gibt es keine Beiträge.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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 datetime="@post.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)">
|
||||
@post.Date.ToString("d. MMMM yyyy", Cultures.German)
|
||||
</time>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<p class="embed-more"><a href="/posts">Alle Beiträge <span aria-hidden="true">→</span></a></p>
|
||||
}
|
||||
</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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,12 @@
|
||||
|
||||
<h1>Termine</h1>
|
||||
|
||||
@if (_unavailable)
|
||||
{
|
||||
<p>Die Termine sind gerade nicht erreichbar. Bitte später erneut versuchen.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="intro">
|
||||
Alle Termine des Elternbeirats auf einen Blick. Sie können den Kalender auch
|
||||
<a href="/events.ics">abonnieren</a> und in Ihrer Kalender-App automatisch
|
||||
@@ -54,3 +60,4 @@ else
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,67 @@
|
||||
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!;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private IReadOnlyList<Event> _upcoming = [];
|
||||
private IReadOnlyList<Event> _past = [];
|
||||
|
||||
// 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;
|
||||
|
||||
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 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();
|
||||
_upcoming =
|
||||
[
|
||||
.. events
|
||||
.Where(@event => DateOnly.FromDateTime(@event.Start) >= today)
|
||||
.OrderBy(@event => @event.Start)
|
||||
];
|
||||
_past =
|
||||
[
|
||||
.. events
|
||||
.Where(@event => DateOnly.FromDateTime(@event.Start) < today)
|
||||
.OrderByDescending(@event => @event.Start)
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
|
||||
<h1>Häufige Fragen</h1>
|
||||
|
||||
@if (_groups.Count == 0)
|
||||
@if (_unavailable)
|
||||
{
|
||||
<p>Die Fragen sind gerade nicht erreichbar. Bitte später erneut versuchen.</p>
|
||||
}
|
||||
else if (_groups.Count == 0)
|
||||
{
|
||||
<p>Zurzeit gibt es keine Fragen.</p>
|
||||
}
|
||||
@@ -13,7 +17,7 @@ else
|
||||
@foreach (var group in _groups)
|
||||
{
|
||||
<section class="faq-topic">
|
||||
<h2>@TopicLabel(group.Key)</h2>
|
||||
<h2>@group.Key</h2>
|
||||
@foreach (var faq in group)
|
||||
{
|
||||
<details class="faq-item">
|
||||
|
||||
@@ -1,48 +1,49 @@
|
||||
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 FAQs 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!;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private IReadOnlyList<IGrouping<string, Faq>> _groups = [];
|
||||
|
||||
// Set when the content source could not be reached or read. The markup then
|
||||
// shows a "temporarily unavailable" note instead of "no questions", matching
|
||||
// the 503 this sets on the response.
|
||||
private bool _unavailable;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var faqs = await PocketBase.GetFaqsAsync();
|
||||
var faqs = await PocketBase.GetFaqsAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
|
||||
_groups = [.. faqs.GroupBy(faq => faq.Topic)];
|
||||
}
|
||||
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 questions. 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,3 +1,4 @@
|
||||
using Elternbeirat.Contracts;
|
||||
using Elternbeirat.PocketBase;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
@@ -5,14 +6,21 @@ 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 home page from PocketBase; embeds are 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<Home> Logger { get; set; } = default!;
|
||||
private ILogger<Home> Logger { get; set; } = null!;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
// 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.
|
||||
@@ -22,15 +30,15 @@ public partial class Home
|
||||
{
|
||||
try
|
||||
{
|
||||
var pages = await PocketBase.GetPagesAsync();
|
||||
var home = pages.FirstOrDefault(
|
||||
p => string.Equals(p.Slug, "home", StringComparison.Ordinal));
|
||||
var pages = await PocketBase.GetPagesAsync(HttpContext?.RequestAborted ?? default);
|
||||
var home = pages.FirstOrDefault(page => Page.IsHomeSlug(page.Slug));
|
||||
_embed = home?.Embed ?? [];
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
catch (PocketBaseUnavailableException exception)
|
||||
{
|
||||
// Source unreachable -> render the intro without embeds instead of a 500.
|
||||
LogLoadFailed(Logger, ex);
|
||||
// Only the embeds fail: render the static intro without them instead of a
|
||||
// 500. The home page's own text is markup, so it stays intact.
|
||||
LogSourceUnavailable(Logger, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,9 @@ public static class NavBuilder
|
||||
public static IReadOnlyList<NavLinkItem> Footer(IReadOnlyList<Page> pages) =>
|
||||
Build(pages, "footer");
|
||||
|
||||
private static List<NavLinkItem> Build(IReadOnlyList<Page> pages, string location) =>
|
||||
private static List<NavLinkItem> Build(
|
||||
IReadOnlyList<Page> pages,
|
||||
string location) =>
|
||||
[.. pages
|
||||
.Where(page => string.Equals(page.Location, location, StringComparison.Ordinal))
|
||||
.OrderBy(page => page.Order)
|
||||
@@ -27,5 +29,5 @@ public static class NavBuilder
|
||||
// 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}";
|
||||
Page.IsHomeSlug(slug) ? "/" : $"/{slug}";
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
@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>
|
||||
<article>@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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
@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">
|
||||
<nav>
|
||||
<a href="/posts">← Alle Beiträge</a>
|
||||
<time datetime="@_post.Date.ToString("yyyy-MM-dd")">
|
||||
@_post.Date.ToString("d. MMMM yyyy", German)
|
||||
</nav>
|
||||
<article>
|
||||
<time class="post-meta" datetime="@_post.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)">
|
||||
@_post.Date.ToString("d. MMMM yyyy", Cultures.German)
|
||||
</time>
|
||||
</p>
|
||||
@Body
|
||||
</article>
|
||||
}
|
||||
@* No post and not unavailable: the slug was unknown; NavigationManager.NotFound()
|
||||
already ran in OnParametersSetAsync and renders the not-found page. *@
|
||||
@@ -1,33 +1,42 @@
|
||||
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;
|
||||
|
||||
// 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 +45,23 @@ 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));
|
||||
}
|
||||
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,7 @@
|
||||
.post-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.post-meta time {
|
||||
display: block;
|
||||
color: var(--color-text-soft); /* token, so it adapts in dark mode */
|
||||
font-size: 0.85rem;
|
||||
margin-top: 1.5rem; /* space from the "all posts" link above */
|
||||
margin-bottom: 0.25rem; /* keep the body close below */
|
||||
}
|
||||
@@ -4,7 +4,11 @@
|
||||
|
||||
<h1>Beiträge</h1>
|
||||
|
||||
@if (_posts.Count == 0)
|
||||
@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>
|
||||
}
|
||||
@@ -15,8 +19,8 @@ else
|
||||
{
|
||||
<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 datetime="@post.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)">
|
||||
@post.Date.ToString("d. MMMM yyyy", Cultures.German)
|
||||
</time>
|
||||
</li>
|
||||
}
|
||||
|
||||
@@ -1,36 +1,48 @@
|
||||
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; }
|
||||
|
||||
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;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_posts = await PocketBase.GetPostsAsync();
|
||||
_posts = await PocketBase.GetPostsAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-10
@@ -24,23 +24,26 @@ var app = builder.Build();
|
||||
// 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
|
||||
app.UseForwardedHeaders(
|
||||
new ForwardedHeadersOptions
|
||||
{
|
||||
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
|
||||
KnownIPNetworks = { },
|
||||
KnownProxies = { }
|
||||
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.
|
||||
}
|
||||
|
||||
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
|
||||
|
||||
// No UseStatusCodePagesWithReExecute: the Router renders the NotFoundPage
|
||||
// (Pages.NotFound) in process for both an unmatched route and an explicit
|
||||
// NavigationManager.NotFound() from a component (see Routes.razor). Re-executing
|
||||
// every error code to /not-found would also turn a 503 (content source down) into
|
||||
// a "not found" page; leaving it out lets those components keep their own
|
||||
// "temporarily unavailable" markup and 503 status.
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.MapStaticAssets();
|
||||
@@ -49,14 +52,14 @@ 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) =>
|
||||
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 = [];
|
||||
}
|
||||
|
||||
@@ -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 +1,77 @@
|
||||
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.
|
||||
/// Formats the date range of an <see cref="Event"/> for display in German.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Shared by the full event list and the home-page events embed, so both render
|
||||
/// dates the same way.
|
||||
/// </remarks>
|
||||
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>
|
||||
/// Date format for the start, and for a single-day event.
|
||||
/// </summary>
|
||||
private const string LongDate = "dddd, d. MMMM yyyy";
|
||||
|
||||
/// <summary>
|
||||
/// Formats the entry's date range: a single day, a date with a time, or a
|
||||
/// span across days.
|
||||
/// Date format for the end of a multi-day event; the weekday is omitted.
|
||||
/// </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);
|
||||
private const string ShortDate = "d. MMMM yyyy";
|
||||
|
||||
if (ev.End is not { } end)
|
||||
/// <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
|
||||
{
|
||||
return start;
|
||||
}
|
||||
(var start, null, var hasTime)
|
||||
=> Stamp(start, LongDate, hasTime),
|
||||
(var start, { } end, true) when end.Date == start.Date
|
||||
=> $"{start.ToString(LongDate, Cultures.German)}, {start.ToString("HH:mm", Cultures.German)}–{end.ToString("HH:mm", Cultures.German)} Uhr",
|
||||
(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)}",
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
/// <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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
@using System.Globalization
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
|
||||
+44
-13
@@ -87,8 +87,9 @@ sofort live, ohne Code und ohne Rebuild:
|
||||
5. Optional `location` (`header`/`footer`) **und** `order` setzen, damit die Seite
|
||||
ins Menü kommt; optional `embed` für eingebettete Blöcke (siehe unten).
|
||||
|
||||
- **Slug bleibt englisch und ohne Umlaute.** `imprint`, `privacy`, `contact`,
|
||||
`board`, `patrons`. Der `title` darf deutsch mit Umlauten sein (`Förderverein`).
|
||||
- **Slug bleibt englisch und ohne Umlaute** — `imprint`, `privacy`, `contact`,
|
||||
`board`, `patrons`; erzwungen per Pattern (siehe *Slug-Regel wird erzwungen*
|
||||
unten). Der `title` darf deutsch mit Umlauten sein (`Förderverein`).
|
||||
- **Reservierte Slugs.** `posts`, `events`, `faqs` und `/` (die Startseite `home`)
|
||||
gehören den festen Listen-/Sonderansichten. Eine `pages`-Seite mit einem dieser
|
||||
Slugs wird von der jeweiligen Ansicht verdeckt und **nicht** angezeigt; in der
|
||||
@@ -105,6 +106,26 @@ sofort live, ohne Code und ohne Rebuild:
|
||||
> Diese Slugs nicht ändern — sonst laufen die rechtlich verlinkten Adressen ins
|
||||
> Leere (404).
|
||||
|
||||
### Slug-Regel wird erzwungen
|
||||
|
||||
Ein Slug muss ein sauberes URL-Segment sein: **Kleinbuchstaben, Ziffern und
|
||||
einzelne Bindestriche als Trenner, keine Umlaute** (`ueber-uns`, nicht `Über uns`).
|
||||
Das ist keine Bitte, sondern eine Feldregel: PocketBase weist einen Slug ab, der
|
||||
sie verletzt, und zeigt beim Speichern einen Fehler. Gibst du `Overview` ein,
|
||||
kommst du erst weiter, wenn du `overview` daraus machst. Die Website verlässt sich
|
||||
darauf — sie repariert einen Slug nicht mehr nachträglich.
|
||||
|
||||
Die Regel steckt als **Pattern** am Feld `slug`. So ist sie gesetzt (einmalig,
|
||||
pro Collection mit einem Slug — `pages` **und** `posts`):
|
||||
|
||||
1. Collection öffnen → *Edit collection* (oben rechts) → Feld `slug`.
|
||||
2. Am Feld auf das **Zahnrad** (Feldoptionen) → **Pattern**.
|
||||
3. Als Pattern eintragen: `^[a-z0-9]+(-[a-z0-9]+)*$`
|
||||
4. **Save** (Feld) und **Save** (Collection).
|
||||
|
||||
Zum Prüfen: `board`, `ueber-uns`, `faq-lunch` werden angenommen; `Overview`,
|
||||
`über-uns`, `my page`, `-x` und `a--b` werden abgewiesen.
|
||||
|
||||
---
|
||||
|
||||
## Beiträge (`posts`)
|
||||
@@ -113,8 +134,12 @@ Ein Beitrag hat `date`, `title`, `body`, `slug` und `public`. Er erscheint unter
|
||||
`/posts` (neueste zuerst) und unter `/posts/<slug>`; die neuesten werden auch auf
|
||||
der Startseite als Vorschau angeteasert.
|
||||
|
||||
- `date` steuert Sortierung und angezeigtes Datum.
|
||||
- `slug` ist englisch, klein, ohne Umlaute (z. B. `new-sports-hall-opened`).
|
||||
- `date` steuert Sortierung und angezeigtes Datum. Anders als bei Terminen zählt
|
||||
hier nur der **Tag** — eine Uhrzeit wird nie angezeigt, und die UTC-Verschiebung
|
||||
aus dem Termine-Hinweis spielt keine Rolle. Trage einfach das Datum ein.
|
||||
- `slug` ist englisch, klein, ohne Umlaute (z. B. `new-sports-hall-opened`) —
|
||||
dieselbe erzwungene Feldregel wie bei Seiten (siehe *Seiten → Slug-Regel wird
|
||||
erzwungen*).
|
||||
|
||||
---
|
||||
|
||||
@@ -128,20 +153,26 @@ Die Übersicht `/events` trennt automatisch in kommende und vergangene Termine;
|
||||
die Reihenfolge der Records spielt keine Rolle. Besucher können `/events.ics` in
|
||||
ihrer Kalender-App abonnieren — der Feed entsteht aus denselben Records.
|
||||
|
||||
> **Uhrzeit = Ortszeit.** Das Admin-Formular rechnet Datumsfelder in die
|
||||
> Browser-Zeitzone um und zeigt eine gespeicherte `19:30` je nach Sommer-/Winter-
|
||||
> zeit als 20:30/21:30 an. Das ist **kein** Fehler, nur zwei Bezugssysteme: der
|
||||
> gespeicherte Zahlenwert **ist** die Ortszeit (Europe/Berlin), die App zeigt ihn
|
||||
> unverändert. Trage die Uhrzeit ein, die auf der Seite stehen soll.
|
||||
> **Trage einfach die Ortszeit ein.** Gib die Uhrzeit ein, die auf der Seite
|
||||
> stehen soll (Europe/Berlin) — z. B. `08:00`. PocketBase speichert intern in UTC
|
||||
> und zeigt dir nach dem Speichern deshalb einen um 1–2 Stunden früheren Wert an
|
||||
> (aus `08:00` wird im Sommer `06:00`). Das ist **kein** Fehler: Die Website
|
||||
> rechnet beim Anzeigen automatisch nach Ortszeit zurück und zeigt wieder `08:00`,
|
||||
> Sommer- und Winterzeit inklusive. Du musst dich um UTC nicht kümmern.
|
||||
|
||||
---
|
||||
|
||||
## Häufige Fragen (`faqs`)
|
||||
|
||||
Eine Frage hat `question`, `answer` (Markdown), `topic` (eines von
|
||||
`mensa`/`schliessfach`/`elterneuro`/`elternarbeit`) und `public`. Die App baut
|
||||
die FAQ-Seite generiert: sie gruppiert die Fragen nach `topic`. Der Rahmentext
|
||||
oben auf `/faqs` ist eine eigene Seite in `pages` (Slug `faqs`).
|
||||
Eine Frage hat `question`, `answer` (Markdown), `topic` und `public`. Die App
|
||||
baut die FAQ-Seite generiert: sie gruppiert die Fragen nach `topic` und zeigt den
|
||||
`topic`-Text unverändert als Gruppen-Überschrift. `topic` ist also **keine feste
|
||||
Auswahl**, sondern der Überschriftentext selbst (z. B. `Mensa und Mittagessen`) —
|
||||
eine neue Gruppe entsteht einfach, indem eine Frage einen neuen `topic`-Text
|
||||
bekommt; Fragen mit gleichem `topic` landen in derselben Gruppe. Auf Groß-/
|
||||
Kleinschreibung und Leerzeichen achten, sonst entstehen versehentlich zwei
|
||||
Gruppen. Der Rahmentext oben auf `/faqs` ist eine eigene Seite in `pages`
|
||||
(Slug `faqs`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ migrate((app) => {
|
||||
,
|
||||
events: [{"end":"","location":"Aula","note":"Gäste sind willkommen. Um Anmeldung im Sekretariat wird gebeten.","public":true,"start":"2026-10-08 19:30:00.000Z","title":"Elternbeiratssitzung"},{"end":"2026-10-20 18:30:00.000Z","location":"Klassenräume","note":"Terminvergabe über das Schulportal.","public":true,"start":"2026-10-20 16:00:00.000Z","title":"Elternsprechtag"},{"end":"2026-11-22 13:00:00.000Z","location":"Schulhof","note":"Kuchenspenden willkommen.","public":true,"start":"2026-11-22 09:00:00.000Z","title":"Herbstbasar"},{"end":"2027-01-06 00:00:00.000Z","location":"","note":"","public":true,"start":"2026-12-19 00:00:00.000Z","title":"Weihnachtsferien"},{"end":"","location":"Mensa","note":"Kennenlernen bei Butterbrezeln und Kaffee.","public":true,"start":"2026-09-10 18:00:00.000Z","title":"Willkommensabend der neuen Fünftklässler"}]
|
||||
,
|
||||
faqs: [{"answer":"Die Anmeldung läuft über das Bestellsystem der Schule. Die Zugangsdaten erhalten Sie zu Schuljahresbeginn über die Klassenleitung.","public":true,"question":"Wie melde ich mein Kind zum Mittagessen an?","topic":"mensa"},{"answer":"Ein Mittagessen kostet aktuell **4,20 €**. Für Familien mit Anspruch auf Leistungen nach dem Bildungs- und Teilhabepaket ist das Essen kostenfrei – sprechen Sie das Sekretariat an.","public":true,"question":"Was kostet ein Mittagessen?","topic":"mensa"},{"answer":"Ja, täglich steht mindestens ein vegetarisches Gericht zur Wahl.","public":true,"question":"Gibt es vegetarische Gerichte?","topic":"mensa"},{"answer":"Schließfächer werden über einen externen Anbieter gemietet. Das Anmeldeformular liegt bei den [Downloads](/downloads).","public":true,"question":"Wie bekommt mein Kind ein Schließfach?","topic":"schliessfach"},{"answer":"Die Miete beträgt rund 15 € pro Schuljahr zuzüglich einer Kaution, die bei Rückgabe erstattet wird.","public":true,"question":"Was kostet die Miete?","topic":"schliessfach"},{"answer":"Wenden Sie sich an den Anbieter; die Kontaktdaten stehen auf dem Mietvertrag. Bei Fragen hilft auch der [Elternbeirat](/contact).","public":true,"question":"Der Schlüssel ist verloren – was nun?","topic":"schliessfach"},{"answer":"Der Eltern-Euro ist ein **freiwilliger** Beitrag, mit dem kleinere Anschaffungen für die Klassen unkompliziert finanziert werden – etwa Bastelmaterial oder Ausflüge.","public":true,"question":"Was ist der Eltern-Euro?","topic":"elterneuro"},{"answer":"Nein. Der Beitrag ist ausdrücklich freiwillig, und niemand wird bei Nichtzahlung benachteiligt.","public":true,"question":"Ist der Beitrag verpflichtend?","topic":"elterneuro"},{"answer":"Ob Schulfest, Basar oder eine helfende Hand bei Ausflügen – über jede Unterstützung freuen wir uns. Melden Sie sich einfach über die [Kontaktseite](/contact).","public":true,"question":"Wie kann ich mich engagieren?","topic":"elternarbeit"},{"answer":"Nein. Viele Aufgaben erledigen Eltern, die einfach mithelfen möchten, ganz ohne Amt.","public":true,"question":"Muss ich in den Elternbeirat gewählt sein, um zu helfen?","topic":"elternarbeit"}]
|
||||
faqs: [{"answer":"Die Anmeldung läuft über das Bestellsystem der Schule. Die Zugangsdaten erhalten Sie zu Schuljahresbeginn über die Klassenleitung.","public":true,"question":"Wie melde ich mein Kind zum Mittagessen an?","topic":"Mensa und Mittagessen"},{"answer":"Ein Mittagessen kostet aktuell **4,20 €**. Für Familien mit Anspruch auf Leistungen nach dem Bildungs- und Teilhabepaket ist das Essen kostenfrei – sprechen Sie das Sekretariat an.","public":true,"question":"Was kostet ein Mittagessen?","topic":"Mensa und Mittagessen"},{"answer":"Ja, täglich steht mindestens ein vegetarisches Gericht zur Wahl.","public":true,"question":"Gibt es vegetarische Gerichte?","topic":"Mensa und Mittagessen"},{"answer":"Schließfächer werden über einen externen Anbieter gemietet. Das Anmeldeformular liegt bei den [Downloads](/downloads).","public":true,"question":"Wie bekommt mein Kind ein Schließfach?","topic":"Schließfächer"},{"answer":"Die Miete beträgt rund 15 € pro Schuljahr zuzüglich einer Kaution, die bei Rückgabe erstattet wird.","public":true,"question":"Was kostet die Miete?","topic":"Schließfächer"},{"answer":"Wenden Sie sich an den Anbieter; die Kontaktdaten stehen auf dem Mietvertrag. Bei Fragen hilft auch der [Elternbeirat](/contact).","public":true,"question":"Der Schlüssel ist verloren – was nun?","topic":"Schließfächer"},{"answer":"Der Eltern-Euro ist ein **freiwilliger** Beitrag, mit dem kleinere Anschaffungen für die Klassen unkompliziert finanziert werden – etwa Bastelmaterial oder Ausflüge.","public":true,"question":"Was ist der Eltern-Euro?","topic":"Eltern-Euro"},{"answer":"Nein. Der Beitrag ist ausdrücklich freiwillig, und niemand wird bei Nichtzahlung benachteiligt.","public":true,"question":"Ist der Beitrag verpflichtend?","topic":"Eltern-Euro"},{"answer":"Ob Schulfest, Basar oder eine helfende Hand bei Ausflügen – über jede Unterstützung freuen wir uns. Melden Sie sich einfach über die [Kontaktseite](/contact).","public":true,"question":"Wie kann ich mich engagieren?","topic":"Elternarbeit und Mitmachen"},{"answer":"Nein. Viele Aufgaben erledigen Eltern, die einfach mithelfen möchten, ganz ohne Amt.","public":true,"question":"Muss ich in den Elternbeirat gewählt sein, um zu helfen?","topic":"Elternarbeit und Mitmachen"}]
|
||||
,
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "slug",
|
||||
"pattern": "",
|
||||
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
@@ -298,7 +298,7 @@
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "slug",
|
||||
"pattern": "",
|
||||
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
@@ -388,26 +388,24 @@
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "select2638274075",
|
||||
"maxSelect": 1,
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "topic",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"mensa",
|
||||
"schliessfach",
|
||||
"elterneuro",
|
||||
"elternarbeit"
|
||||
]
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
|
||||
Reference in new issue
Block a user