From e38ebf078d6061e6c9c7947628d976665b7d885e Mon Sep 17 00:00:00 2001 From: tleininger Date: Wed, 30 Sep 2026 14:58:12 +0200 Subject: [PATCH] Render FAQ topics as cards, drop dead scoped CSS --- Elternbeirat.Contracts/Faq.cs | 27 ++++++++ Elternbeirat.Contracts/FaqTopic.cs | 47 +++++++++++++- Elternbeirat.PocketBase/PocketBaseClient.cs | 48 ++++++++++++++ Elternbeirat.Web.Tests/PocketBaseFixture.cs | 7 ++- Elternbeirat.Web/Features/Faqs/FaqList.razor | 29 ++++----- .../Features/Faqs/FaqList.razor.cs | 16 ++--- .../Features/Faqs/FaqList.razor.css | 10 --- .../Features/Faqs/FaqTopicDetail.razor | 30 +++++++++ .../Features/Faqs/FaqTopicDetail.razor.cs | 63 +++++++++++++++++++ Elternbeirat.Web/wwwroot/app.css | 25 +++++++- pb/pb_migrations/1727200000_dev_seed.js | 4 +- 11 files changed, 260 insertions(+), 46 deletions(-) delete mode 100644 Elternbeirat.Web/Features/Faqs/FaqList.razor.css create mode 100644 Elternbeirat.Web/Features/Faqs/FaqTopicDetail.razor create mode 100644 Elternbeirat.Web/Features/Faqs/FaqTopicDetail.razor.cs diff --git a/Elternbeirat.Contracts/Faq.cs b/Elternbeirat.Contracts/Faq.cs index f592cb3..c0dac89 100644 --- a/Elternbeirat.Contracts/Faq.cs +++ b/Elternbeirat.Contracts/Faq.cs @@ -43,6 +43,33 @@ public record Faq [JsonPropertyName("answer")] public required string Answer { get; init; } + /// + /// Gets the sort order of the question within its topic. + /// + /// + /// The sort key; questions with smaller values appear first. + /// + [JsonPropertyName("order")] + public double Order { get; init; } + + /// + /// Gets whether this entry is published and may be shown to visitors. + /// + /// + /// when the entry is public, for a + /// draft that must stay hidden. + /// + /// + /// Collection queries filter on public=true server-side, so entries loaded + /// that way are always public and never need this checked. It matters only for the + /// faqs_via_topic back-relation expand behind : + /// PocketBase does not apply the topic query's public filter to expanded + /// child records, so a draft question would otherwise leak onto the topic page. + /// That getter filters on this to keep drafts hidden. + /// + [JsonPropertyName("public")] + public bool Public { get; init; } + /// /// Gets the expanded relations returned by PocketBase, or /// when the query did not expand any. diff --git a/Elternbeirat.Contracts/FaqTopic.cs b/Elternbeirat.Contracts/FaqTopic.cs index 9f681bb..9f585c8 100644 --- a/Elternbeirat.Contracts/FaqTopic.cs +++ b/Elternbeirat.Contracts/FaqTopic.cs @@ -38,7 +38,7 @@ public record FaqTopic public required string Title { get; init; } /// - /// Gets the URL slug of the topic, e.g. "mensa". + /// Gets the URL slug of the topic, e.g. "cafeteria". /// /// /// A URL path segment: lowercase letters, digits and single hyphens as @@ -70,4 +70,49 @@ public record FaqTopic /// [JsonPropertyName("order")] public double Order { get; init; } + + /// + /// Gets the expanded relations returned by PocketBase, or + /// when the query did not expand any. + /// + [JsonPropertyName("expand")] + public FaqTopicExpand? Expand { get; init; } + + /// + /// Gets the questions of this topic, ordered by , or an + /// empty list when none are linked or the relation was not expanded. + /// + /// + /// Convenience over : questions name their topic, so + /// PocketBase returns them under the back-relation key when the query expands it + /// (faqs_via_topic). The topic detail page reads them from here. They + /// arrive unsorted, so this orders them by for a stable + /// render. Drafts are dropped: PocketBase does not apply the topic query's + /// public filter to the expanded children, so this keeps + /// non-public questions () off the page. + /// + [JsonIgnore] + public IReadOnlyList Faqs => + Expand?.Faqs is { } faqs + ? [.. faqs.Where(faq => faq.Public).OrderBy(faq => faq.Order)] + : []; +} + +/// +/// The relations of a that PocketBase returns under +/// expand when the query asks for them. +/// +/// +/// The questions point at their topic, so PocketBase exposes them as the +/// back-relation faqs_via_topic. reads through +/// this. +/// +public record FaqTopicExpand +{ + /// + /// Gets the expanded questions of the faqs_via_topic back-relation, if it + /// was expanded. + /// + [JsonPropertyName("faqs_via_topic")] + public IReadOnlyList? Faqs { get; init; } } diff --git a/Elternbeirat.PocketBase/PocketBaseClient.cs b/Elternbeirat.PocketBase/PocketBaseClient.cs index fd9507b..841de14 100644 --- a/Elternbeirat.PocketBase/PocketBaseClient.cs +++ b/Elternbeirat.PocketBase/PocketBaseClient.cs @@ -90,6 +90,54 @@ public sealed class PocketBaseClient(HttpClient httpClient) public Task> GetFaqsAsync(CancellationToken token = default) => GetRecordsAsync("faqs", "topic.order,order", token, expand: "topic"); + /// + /// Gets all public FAQ topics, ordered by . + /// + /// + /// Backs the FAQ overview, which lists the topics as links to their own pages. + /// The questions are not loaded here; the topic page loads them per topic via + /// . + /// + /// + /// + /// The public FAQ topics, or an empty list if there are none. + /// + /// + public Task> GetFaqTopicsAsync(CancellationToken token = default) + => GetRecordsAsync("faq_topics", "order", token); + + /// + /// Gets one public FAQ topic by its slug, with its questions expanded, or + /// when no public topic has that slug. + /// + /// + /// Backs the topic's own page at /faqs/{slug}: it loads the single topic + /// and, through the faqs_via_topic back-relation, the questions grouped + /// under it, so the page needs one request. The slug is unique, so at most one + /// record matches. + /// + /// + /// The topic slug from the URL, e.g. "cafeteria". + /// + /// + /// + /// The matching public topic with its questions, or if + /// none matches. + /// + /// + public async Task GetFaqTopicBySlugAsync( + string slug, + CancellationToken token = default) + { + var topics = await GetRecordsAsync( + "faq_topics", + "order", + token, + expand: "faqs_via_topic"); + return topics.FirstOrDefault( + topic => string.Equals(topic.Slug, slug, StringComparison.OrdinalIgnoreCase)); + } + /// /// Gets all public records of a collection in a single request. /// diff --git a/Elternbeirat.Web.Tests/PocketBaseFixture.cs b/Elternbeirat.Web.Tests/PocketBaseFixture.cs index 439f3e2..0253efc 100644 --- a/Elternbeirat.Web.Tests/PocketBaseFixture.cs +++ b/Elternbeirat.Web.Tests/PocketBaseFixture.cs @@ -314,15 +314,16 @@ public sealed class PocketBaseFixture : IAsyncLifetime }); // Faq topics: two, ordered. The topics own the grouping and the order the FAQ - // page shows the groups in (Mensa before Schließfächer). + // page shows the groups in (cafeteria before lockers). Titles stay German for + // visitors; slugs are English, as for every route. var lunchTopicId = await CreateRecordAsync(http, "faq_topics", new { - title = "Mensa und Mittagessen", slug = "mensa", intro = "", + title = "Mensa und Mittagessen", slug = "cafeteria", intro = "", order = 1, @public = true, }); var lockerTopicId = await CreateRecordAsync(http, "faq_topics", new { - title = "Schließfächer", slug = "schliessfach", intro = "", + title = "Schließfächer", slug = "lockers", intro = "", order = 2, @public = true, }); diff --git a/Elternbeirat.Web/Features/Faqs/FaqList.razor b/Elternbeirat.Web/Features/Faqs/FaqList.razor index 21488dc..9bf0c4c 100644 --- a/Elternbeirat.Web/Features/Faqs/FaqList.razor +++ b/Elternbeirat.Web/Features/Faqs/FaqList.razor @@ -8,25 +8,22 @@ {

Die Fragen sind gerade nicht erreichbar. Bitte später erneut versuchen.

} -else if (_groups.Count == 0) +else if (_topics.Count == 0) {

Zurzeit gibt es keine Fragen.

} else { - @foreach (var group in _groups) - { -
-

@group.Key.Title

- @foreach (var faq in group) - { -
- @faq.Question -
- @((MarkupString)Markdown.ToHtml(faq.Answer)) -
-
- } -
- } +
    + @foreach (var topic in _topics) + { +
  • +

    @topic.Title

    + @if (!string.IsNullOrWhiteSpace(topic.Intro)) + { +
    @((MarkupString)Markdown.ToHtml(topic.Intro))
    + } +
  • + } +
} diff --git a/Elternbeirat.Web/Features/Faqs/FaqList.razor.cs b/Elternbeirat.Web/Features/Faqs/FaqList.razor.cs index 68362fc..e0a24ac 100644 --- a/Elternbeirat.Web/Features/Faqs/FaqList.razor.cs +++ b/Elternbeirat.Web/Features/Faqs/FaqList.razor.cs @@ -9,7 +9,7 @@ public partial class FaqList { [LoggerMessage( Level = LogLevel.Error, - Message = "Could not load FAQs from PocketBase; content source is unavailable.")] + Message = "Could not load FAQ topics from PocketBase; content source is unavailable.")] private static partial void LogSourceUnavailable( ILogger logger, Exception exception); @@ -23,10 +23,10 @@ public partial class FaqList [CascadingParameter] private HttpContext? HttpContext { get; set; } - private IReadOnlyList> _groups = []; + private IReadOnlyList _topics = []; // Set when the content source could not be reached or read. The markup then - // shows a "temporarily unavailable" note instead of "no questions", matching + // shows a "temporarily unavailable" note instead of "no topics", matching // the 503 this sets on the response. private bool _unavailable; @@ -34,17 +34,11 @@ public partial class FaqList { try { - var faqs = await PocketBase.GetFaqsAsync(HttpContext?.RequestAborted ?? CancellationToken.None); - - // GetFaqsAsync sorts by topic.order first, so grouping in encounter order - // keeps the topic groups in the order an editor gave the topics. Entries - // without an expanded topic have no group and are skipped rather than - // grouped under a null heading. - _groups = [.. faqs.Where(faq => faq.Topic is not null).GroupBy(faq => faq.Topic!)]; + _topics = await PocketBase.GetFaqTopicsAsync(HttpContext?.RequestAborted ?? CancellationToken.None); } catch (PocketBaseUnavailableException exception) { - // An empty page would claim there are no questions. The site works, its + // An empty page would claim there are no topics. The site works, its // data store is down: 503 and an honest note. LogSourceUnavailable(Logger, exception); _unavailable = true; diff --git a/Elternbeirat.Web/Features/Faqs/FaqList.razor.css b/Elternbeirat.Web/Features/Faqs/FaqList.razor.css deleted file mode 100644 index 6bb2570..0000000 --- a/Elternbeirat.Web/Features/Faqs/FaqList.razor.css +++ /dev/null @@ -1,10 +0,0 @@ -/* The .faq-item card styles are shared with the home-page embed and live in the - global app.css. Only the topic grouping is specific to this full page (the - embed shows a flat list without topic headings). */ -.faq-topic { - margin-bottom: var(--space-5); -} - -.faq-topic h2 { - margin-bottom: var(--space-3); -} diff --git a/Elternbeirat.Web/Features/Faqs/FaqTopicDetail.razor b/Elternbeirat.Web/Features/Faqs/FaqTopicDetail.razor new file mode 100644 index 0000000..d8bf6a2 --- /dev/null +++ b/Elternbeirat.Web/Features/Faqs/FaqTopicDetail.razor @@ -0,0 +1,30 @@ +@page "/faqs/{Slug}" + +@if (_unavailable) +{ + Vorübergehend nicht erreichbar +

Das Thema ist gerade nicht erreichbar. Bitte später erneut versuchen.

+} +else if (_topic is not null) +{ + @_topic.Title + +

@_topic.Title

+ @if (!string.IsNullOrWhiteSpace(_topic.Intro)) + { +
@((MarkupString)Markdown.ToHtml(_topic.Intro))
+ } + @foreach (var faq in _topic.Faqs) + { +
+ @faq.Question +
+ @((MarkupString)Markdown.ToHtml(faq.Answer)) +
+
+ } +} +@* No topic and not unavailable: the slug was unknown; NavigationManager.NotFound() + already ran in OnParametersSetAsync and renders the not-found page. *@ diff --git a/Elternbeirat.Web/Features/Faqs/FaqTopicDetail.razor.cs b/Elternbeirat.Web/Features/Faqs/FaqTopicDetail.razor.cs new file mode 100644 index 0000000..a22518b --- /dev/null +++ b/Elternbeirat.Web/Features/Faqs/FaqTopicDetail.razor.cs @@ -0,0 +1,63 @@ +using Elternbeirat.Contracts; +using Elternbeirat.PocketBase; +using Elternbeirat.Web.Shared; +using Microsoft.AspNetCore.Components; + +namespace Elternbeirat.Web.Features.Faqs; + +public partial class FaqTopicDetail +{ + [LoggerMessage( + Level = LogLevel.Error, + Message = "Could not load FAQ topic '{Slug}' from PocketBase; content source is unavailable.")] + private static partial void LogSourceUnavailable( + ILogger logger, + string slug, + Exception exception); + + [Parameter] + public string Slug { get; set; } = string.Empty; + + [Inject] + private PocketBaseClient PocketBase { get; set; } = null!; + + [Inject] + private ILogger Logger { get; set; } = null!; + + [Inject] + private NavigationManager Navigation { get; set; } = null!; + + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private FaqTopic? _topic; + + // 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; + + protected override async Task OnParametersSetAsync() + { + try + { + _topic = await PocketBase.GetFaqTopicBySlugAsync( + Slug, + HttpContext?.RequestAborted ?? CancellationToken.None); + } + catch (PocketBaseUnavailableException exception) + { + // The site works, its data store is down: 503, not 404. + LogSourceUnavailable(Logger, Slug, exception); + _unavailable = true; + SourceUnavailable.SetStatus(HttpContext); + return; + } + + if (_topic is null) + { + // The source answered, but there is no topic with this slug: a genuine 404. + Navigation.NotFound(); + } + } +} diff --git a/Elternbeirat.Web/wwwroot/app.css b/Elternbeirat.Web/wwwroot/app.css index fa622ef..4d159bf 100644 --- a/Elternbeirat.Web/wwwroot/app.css +++ b/Elternbeirat.Web/wwwroot/app.css @@ -206,7 +206,8 @@ h1:focus { lifted off the tinted page background with a soft shadow and a gentle hover. */ .post-list, -.event-list { +.event-list, +.faq-topic-list { list-style: none; padding: 0; margin: 0; @@ -215,7 +216,8 @@ h1:focus { } .post-list li, -.event-list li { +.event-list li, +.faq-topic-list li { background: var(--color-bg); border-radius: var(--radius); box-shadow: var(--shadow); @@ -224,11 +226,28 @@ h1:focus { } .post-list li:hover, -.event-list li:hover { +.event-list li:hover, +.faq-topic-list li:hover { box-shadow: var(--shadow-hover); transform: translateY(-2px); } +/* FAQ topics: each card is a link to the topic's own page, with an optional intro + below the heading. The heading matches the post/event card titles. */ +.faq-topic-list h2 { + margin: 0; + font-size: 1.15rem; +} + +.faq-topic-list h2 a { + text-decoration: none; +} + +.faq-topic-intro { + margin-top: var(--space-2); + color: var(--color-text-soft); +} + /* Posts */ .post-list a { font-size: 1.15rem; diff --git a/pb/pb_migrations/1727200000_dev_seed.js b/pb/pb_migrations/1727200000_dev_seed.js index 0f2d451..fc55f83 100644 --- a/pb/pb_migrations/1727200000_dev_seed.js +++ b/pb/pb_migrations/1727200000_dev_seed.js @@ -29,11 +29,11 @@ migrate((app) => { , // Topics own the grouping and the order of the FAQ page; a question links to // its topic below via the "topic" relation, resolved by slug at seed time. - faq_topics: [{"title":"Elternarbeit","slug":"elternarbeit","intro":"","order":1,"public":true},{"title":"Eltern-Euro","slug":"eltern-euro","intro":"","order":2,"public":true},{"title":"Mensa und Mittagessen","slug":"mensa","intro":"","order":3,"public":true},{"title":"Schließfächer","slug":"schliessfach","intro":"","order":4,"public":true}] + faq_topics: [{"title":"Elternarbeit","slug":"parent-involvement","intro":"","order":1,"public":true},{"title":"Eltern-Euro","slug":"parent-euro","intro":"","order":2,"public":true},{"title":"Mensa und Mittagessen","slug":"cafeteria","intro":"","order":3,"public":true},{"title":"Schließfächer","slug":"lockers","intro":"","order":4,"public":true}] , // "topic" is a slug here, not a relation id: the record loader below resolves it // to the faq_topics id, since the ids are only known once the topics are saved. - 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","order":1},{"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","order":2},{"answer":"Ja, täglich steht mindestens ein vegetarisches Gericht zur Wahl.","public":true,"question":"Gibt es vegetarische Gerichte?","topic":"mensa","order":3},{"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","order":1},{"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","order":2},{"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","order":3},{"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","order":1},{"answer":"Nein. Der Beitrag ist ausdrücklich freiwillig, und niemand wird bei Nichtzahlung benachteiligt.","public":true,"question":"Ist der Beitrag verpflichtend?","topic":"eltern-euro","order":2},{"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","order":1},{"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","order":2}] + 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":"cafeteria","order":1},{"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":"cafeteria","order":2},{"answer":"Ja, täglich steht mindestens ein vegetarisches Gericht zur Wahl.","public":true,"question":"Gibt es vegetarische Gerichte?","topic":"cafeteria","order":3},{"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":"lockers","order":1},{"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":"lockers","order":2},{"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":"lockers","order":3},{"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":"parent-euro","order":1},{"answer":"Nein. Der Beitrag ist ausdrücklich freiwillig, und niemand wird bei Nichtzahlung benachteiligt.","public":true,"question":"Ist der Beitrag verpflichtend?","topic":"parent-euro","order":2},{"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":"parent-involvement","order":1},{"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":"parent-involvement","order":2}] , }