From 3fd3796d73abe714a072071f9cfe4647699b4188 Mon Sep 17 00:00:00 2001
From: tleininger
Date: Wed, 23 Sep 2026 14:54:10 +0200
Subject: [PATCH] Generate navigation and render page embeds from PocketBase
---
.run/DevEnvironment.run.xml | 10 +--
Elternbeirat.Web.Tests/NavBuilderTests.cs | 68 +++++++++++++++++++
Elternbeirat.Web.Tests/PocketBaseFixture.cs | 4 +-
Elternbeirat.Web.Tests/RouteSmokeTests.cs | 43 ++++++++++++
.../Components/Layout/MainLayout.razor | 19 +++---
.../Components/Layout/MainLayout.razor.cs | 36 ++++++++++
.../Features/Embeds/EventsEmbed.razor | 25 +++++++
.../Features/Embeds/EventsEmbed.razor.cs | 43 ++++++++++++
.../Features/Embeds/EventsEmbed.razor.css | 33 +++++++++
.../Features/Embeds/FaqsEmbed.razor | 21 ++++++
.../Features/Embeds/FaqsEmbed.razor.cs | 34 ++++++++++
.../Features/Embeds/FaqsEmbed.razor.css | 18 +++++
.../Features/Embeds/PageEmbeds.razor | 25 +++++++
.../Features/Embeds/PostsEmbed.razor | 23 +++++++
.../Features/Embeds/PostsEmbed.razor.cs | 37 ++++++++++
.../Features/Embeds/PostsEmbed.razor.css | 24 +++++++
.../Features/Events/EventList.razor | 4 +-
.../Features/Events/EventList.razor.cs | 35 ----------
Elternbeirat.Web/Features/Home/Home.razor | 24 +------
Elternbeirat.Web/Features/Home/Home.razor.cs | 19 +++---
Elternbeirat.Web/Features/Home/Home.razor.css | 25 -------
.../Features/Layout/NavBuilder.cs | 31 +++++++++
.../Features/Layout/NavLinkItem.cs | 6 ++
.../Features/Pages/ContentPage.razor | 1 +
Elternbeirat.Web/Shared/EventFormat.cs | 48 +++++++++++++
Elternbeirat.Web/_Imports.razor | 2 +
26 files changed, 544 insertions(+), 114 deletions(-)
create mode 100644 Elternbeirat.Web.Tests/NavBuilderTests.cs
create mode 100644 Elternbeirat.Web/Components/Layout/MainLayout.razor.cs
create mode 100644 Elternbeirat.Web/Features/Embeds/EventsEmbed.razor
create mode 100644 Elternbeirat.Web/Features/Embeds/EventsEmbed.razor.cs
create mode 100644 Elternbeirat.Web/Features/Embeds/EventsEmbed.razor.css
create mode 100644 Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor
create mode 100644 Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor.cs
create mode 100644 Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor.css
create mode 100644 Elternbeirat.Web/Features/Embeds/PageEmbeds.razor
create mode 100644 Elternbeirat.Web/Features/Embeds/PostsEmbed.razor
create mode 100644 Elternbeirat.Web/Features/Embeds/PostsEmbed.razor.cs
create mode 100644 Elternbeirat.Web/Features/Embeds/PostsEmbed.razor.css
create mode 100644 Elternbeirat.Web/Features/Layout/NavBuilder.cs
create mode 100644 Elternbeirat.Web/Features/Layout/NavLinkItem.cs
create mode 100644 Elternbeirat.Web/Shared/EventFormat.cs
diff --git a/.run/DevEnvironment.run.xml b/.run/DevEnvironment.run.xml
index 71551fb..8f98fd7 100644
--- a/.run/DevEnvironment.run.xml
+++ b/.run/DevEnvironment.run.xml
@@ -7,12 +7,8 @@
-
+
@@ -24,7 +20,7 @@
-
+
diff --git a/Elternbeirat.Web.Tests/NavBuilderTests.cs b/Elternbeirat.Web.Tests/NavBuilderTests.cs
new file mode 100644
index 0000000..dcaf642
--- /dev/null
+++ b/Elternbeirat.Web.Tests/NavBuilderTests.cs
@@ -0,0 +1,68 @@
+using Elternbeirat.Contracts;
+using Elternbeirat.Web.Features.Layout;
+
+namespace Elternbeirat.Web.Tests;
+
+///
+/// Unit tests for the navigation builder. Pure logic, no PocketBase needed.
+///
+public class NavBuilderTests
+{
+ private static Page Page(string slug, string location, double order, string title) =>
+ new() { Slug = slug, Location = location, Order = order, Title = title };
+
+ [Fact]
+ public void Header_keeps_only_header_pages_ordered_by_order()
+ {
+ var pages = new[]
+ {
+ Page("board", "header", 2, "Vorstandsteam"),
+ Page("home", "header", 1, "Start"),
+ Page("imprint", "footer", 1, "Impressum"),
+ Page("hidden", "", 1, "Unterseite"),
+ };
+
+ var header = NavBuilder.Header(pages);
+
+ header.Select(link => link.Title).ShouldBe(["Start", "Vorstandsteam"]);
+ }
+
+ [Fact]
+ public void Footer_keeps_only_footer_pages_ordered_by_order()
+ {
+ var pages = new[]
+ {
+ Page("privacy", "footer", 2, "Datenschutz"),
+ Page("imprint", "footer", 1, "Impressum"),
+ Page("home", "header", 1, "Start"),
+ };
+
+ var footer = NavBuilder.Footer(pages);
+
+ footer.Select(link => link.Title).ShouldBe(["Impressum", "Datenschutz"]);
+ }
+
+ [Fact]
+ public void Home_page_maps_to_root_every_other_slug_to_its_path()
+ {
+ var pages = new[]
+ {
+ Page("home", "header", 1, "Start"),
+ Page("board", "header", 2, "Vorstandsteam"),
+ };
+
+ var header = NavBuilder.Header(pages);
+
+ header[0].Href.ShouldBe("/");
+ header[1].Href.ShouldBe("/board");
+ }
+
+ [Fact]
+ public void Pages_without_a_location_never_appear_in_a_menu()
+ {
+ var pages = new[] { Page("hidden", "", 1, "Unterseite") };
+
+ NavBuilder.Header(pages).ShouldBeEmpty();
+ NavBuilder.Footer(pages).ShouldBeEmpty();
+ }
+}
diff --git a/Elternbeirat.Web.Tests/PocketBaseFixture.cs b/Elternbeirat.Web.Tests/PocketBaseFixture.cs
index d95ad8b..1160e7b 100644
--- a/Elternbeirat.Web.Tests/PocketBaseFixture.cs
+++ b/Elternbeirat.Web.Tests/PocketBaseFixture.cs
@@ -257,8 +257,10 @@ public sealed class PocketBaseFixture : IAsyncLifetime
});
await CreateRecordAsync(http, "pages", new
{
+ // Carries an embed so the ContentPage embed path is covered: /patrons
+ // renders its body plus the FAQ teaser.
title = "Förderverein", body = "# Förderverein", location = "header",
- order = 3, slug = "patrons", embed = Array.Empty(), @public = true,
+ order = 3, slug = "patrons", embed = new[] { "faqs" }, @public = true,
});
// Nav placeholder for the FAQ list (route /faqs is served by FaqList,
// which shadows this page). Slug matches production's "faqs" nav entry.
diff --git a/Elternbeirat.Web.Tests/RouteSmokeTests.cs b/Elternbeirat.Web.Tests/RouteSmokeTests.cs
index a805f4e..c5fd147 100644
--- a/Elternbeirat.Web.Tests/RouteSmokeTests.cs
+++ b/Elternbeirat.Web.Tests/RouteSmokeTests.cs
@@ -98,4 +98,47 @@ public sealed class RouteSmokeTests : IDisposable
html.ShouldContain("Wann gibt es Mittagessen?");
html.ShouldContain("Mensa und Mittagessen"); // German topic heading
}
+
+ [Fact]
+ public async Task Layout_navigation_is_built_from_the_pages()
+ {
+ // The header and footer menus are generated from the "pages" records, not
+ // hard-coded. Assert a header link, a footer link, and that the non-public
+ // draft page never leaks into the menu.
+ var client = _factory.CreateClient();
+
+ var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
+
+ html.ShouldContain("href=\"/board\""); // header page
+ html.ShouldContain("href=\"/imprint\""); // footer page
+ html.ShouldNotContain("href=\"/draft\""); // public=false, filtered out
+ }
+
+ [Fact]
+ public async Task Home_renders_the_embeds_its_page_opts_into()
+ {
+ // The home page's embed field is ["posts","events"], so the root route
+ // renders the posts teaser and the events teaser below the intro.
+ var client = _factory.CreateClient();
+
+ var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
+
+ html.ShouldContain("Aktuelle Beiträge"); // posts embed heading
+ html.ShouldContain("Neuer Vorstand"); // a seeded post title
+ html.ShouldContain("Kommende Termine"); // events embed heading
+ }
+
+ [Fact]
+ public async Task Content_page_renders_the_embed_its_page_opts_into()
+ {
+ // /patrons is a catch-all content page whose embed field is ["faqs"], so it
+ // shows its body plus the FAQ teaser -- proving the ContentPage embed path.
+ var client = _factory.CreateClient();
+
+ var html = await client.GetStringAsync(new Uri("/patrons", UriKind.Relative));
+
+ html.ShouldContain("Förderverein"); // the page body
+ html.ShouldContain("Häufige Fragen"); // faqs embed heading
+ html.ShouldContain("Wann gibt es Mittagessen?"); // a seeded question
+ }
}
diff --git a/Elternbeirat.Web/Components/Layout/MainLayout.razor b/Elternbeirat.Web/Components/Layout/MainLayout.razor
index 4d2d293..175f8a5 100644
--- a/Elternbeirat.Web/Components/Layout/MainLayout.razor
+++ b/Elternbeirat.Web/Components/Layout/MainLayout.razor
@@ -5,14 +5,10 @@
@@ -25,9 +21,10 @@
diff --git a/Elternbeirat.Web/Components/Layout/MainLayout.razor.cs b/Elternbeirat.Web/Components/Layout/MainLayout.razor.cs
new file mode 100644
index 0000000..7edd795
--- /dev/null
+++ b/Elternbeirat.Web/Components/Layout/MainLayout.razor.cs
@@ -0,0 +1,36 @@
+using Elternbeirat.PocketBase;
+using Elternbeirat.Web.Features.Layout;
+using Microsoft.AspNetCore.Components;
+
+namespace Elternbeirat.Web.Components.Layout;
+
+public partial class MainLayout
+{
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load navigation pages from PocketBase.")]
+ private static partial void LogLoadFailed(ILogger logger, Exception exception);
+
+ [Inject]
+ private PocketBaseClient PocketBase { get; set; } = default!;
+
+ [Inject]
+ private ILogger Logger { get; set; } = default!;
+
+ private IReadOnlyList _header = [];
+ private IReadOnlyList _footer = [];
+
+ protected override async Task OnInitializedAsync()
+ {
+ try
+ {
+ var pages = await PocketBase.GetPagesAsync();
+ _header = NavBuilder.Header(pages);
+ _footer = NavBuilder.Footer(pages);
+ }
+ catch (HttpRequestException ex)
+ {
+ // Source unreachable -> render the chrome with an empty menu instead of
+ // a 500. The brand link still leads home.
+ LogLoadFailed(Logger, ex);
+ }
+ }
+}
diff --git a/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor b/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor
new file mode 100644
index 0000000..cae3aa3
--- /dev/null
+++ b/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor
@@ -0,0 +1,25 @@
+
+ Kommende Termine
+
+ @if (_upcoming.Count == 0)
+ {
+ Zurzeit sind keine Termine geplant.
+ }
+ else
+ {
+
+ @foreach (var ev in _upcoming)
+ {
+
+ @EventFormat.Range(ev)
+ @ev.Title
+ @if (!string.IsNullOrWhiteSpace(ev.Location))
+ {
+ @ev.Location
+ }
+
+ }
+
+ Alle Termine →
+ }
+
diff --git a/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor.cs b/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor.cs
new file mode 100644
index 0000000..5c42bcd
--- /dev/null
+++ b/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor.cs
@@ -0,0 +1,43 @@
+using Elternbeirat.Contracts;
+using Elternbeirat.PocketBase;
+using Microsoft.AspNetCore.Components;
+
+namespace Elternbeirat.Web.Features.Embeds;
+
+public partial class EventsEmbed
+{
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load events for an embed from PocketBase.")]
+ private static partial void LogLoadFailed(ILogger logger, Exception exception);
+
+ [Inject]
+ private PocketBaseClient PocketBase { get; set; } = default!;
+
+ [Inject]
+ private ILogger Logger { get; set; } = default!;
+
+ private IReadOnlyList _upcoming = [];
+
+ protected override async Task OnInitializedAsync()
+ {
+ IReadOnlyList events;
+ try
+ {
+ events = await PocketBase.GetEventsAsync();
+ }
+ catch (HttpRequestException ex)
+ {
+ // Source unreachable -> show no teaser instead of a 500.
+ LogLoadFailed(Logger, ex);
+ return;
+ }
+
+ // Teaser: the three soonest upcoming events, earliest first. An event
+ // earlier today still counts as upcoming.
+ var today = DateOnly.FromDateTime(DateTime.Today);
+ _upcoming = events
+ .Where(e => DateOnly.FromDateTime(e.Start) >= today)
+ .OrderBy(e => e.Start)
+ .Take(3)
+ .ToList();
+ }
+}
diff --git a/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor.css b/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor.css
new file mode 100644
index 0000000..9c51138
--- /dev/null
+++ b/Elternbeirat.Web/Features/Embeds/EventsEmbed.razor.css
@@ -0,0 +1,33 @@
+.embed-events {
+ margin-top: 2rem;
+}
+
+.event-list {
+ list-style: none;
+ padding: 0;
+}
+
+.event-list li {
+ padding: 0.75rem 0;
+ border-bottom: 1px solid #eee;
+}
+
+.event-list time {
+ display: block;
+ color: #777;
+ font-size: 0.85rem;
+}
+
+.event-title {
+ font-size: 1.1rem;
+ font-weight: 600;
+}
+
+.event-location::before {
+ content: " · ";
+ color: #777;
+}
+
+.event-location {
+ color: #555;
+}
diff --git a/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor b/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor
new file mode 100644
index 0000000..9460bdb
--- /dev/null
+++ b/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor
@@ -0,0 +1,21 @@
+
+ Häufige Fragen
+
+ @if (_faqs.Count == 0)
+ {
+ Zurzeit gibt es keine Fragen.
+ }
+ else
+ {
+ @foreach (var faq in _faqs)
+ {
+
+ @faq.Question
+
+ @((MarkupString)Markdown.ToHtml(faq.Answer))
+
+
+ }
+ Alle Fragen →
+ }
+
diff --git a/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor.cs b/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor.cs
new file mode 100644
index 0000000..5a90562
--- /dev/null
+++ b/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor.cs
@@ -0,0 +1,34 @@
+using Elternbeirat.Contracts;
+using Elternbeirat.PocketBase;
+using Microsoft.AspNetCore.Components;
+
+namespace Elternbeirat.Web.Features.Embeds;
+
+public partial class FaqsEmbed
+{
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load FAQs for an embed from PocketBase.")]
+ private static partial void LogLoadFailed(ILogger logger, Exception exception);
+
+ [Inject]
+ private PocketBaseClient PocketBase { get; set; } = default!;
+
+ [Inject]
+ private ILogger Logger { get; set; } = default!;
+
+ private IReadOnlyList _faqs = [];
+
+ protected override async Task OnInitializedAsync()
+ {
+ try
+ {
+ // Teaser: the first three questions, with a link to the full hub.
+ var faqs = await PocketBase.GetFaqsAsync();
+ _faqs = [.. faqs.Take(3)];
+ }
+ catch (HttpRequestException ex)
+ {
+ // Source unreachable -> show no teaser instead of a 500.
+ LogLoadFailed(Logger, ex);
+ }
+ }
+}
diff --git a/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor.css b/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor.css
new file mode 100644
index 0000000..1022fd7
--- /dev/null
+++ b/Elternbeirat.Web/Features/Embeds/FaqsEmbed.razor.css
@@ -0,0 +1,18 @@
+.embed-faqs {
+ margin-top: 2rem;
+}
+
+.faq-item {
+ border-bottom: 1px solid #eee;
+ padding: 0.5rem 0;
+}
+
+.faq-item summary {
+ cursor: pointer;
+ font-weight: 600;
+}
+
+.faq-answer {
+ padding: 0.5rem 0 0.25rem;
+ color: #333;
+}
diff --git a/Elternbeirat.Web/Features/Embeds/PageEmbeds.razor b/Elternbeirat.Web/Features/Embeds/PageEmbeds.razor
new file mode 100644
index 0000000..60f3263
--- /dev/null
+++ b/Elternbeirat.Web/Features/Embeds/PageEmbeds.razor
@@ -0,0 +1,25 @@
+@* Renders the dynamic blocks a page opts into via its "embed" field, in the
+ given order. Unknown keys are ignored so a typo in PocketBase can never break
+ a page. Each block loads its own data. *@
+
+@foreach (var key in Embed)
+{
+ switch (key)
+ {
+ case "posts":
+
+ break;
+ case "events":
+
+ break;
+ case "faqs":
+
+ break;
+ }
+}
+
+@code {
+ /// The page's embed keys ("posts", "events", "faqs"), in order.
+ [Parameter]
+ public IReadOnlyList Embed { get; set; } = [];
+}
diff --git a/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor b/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor
new file mode 100644
index 0000000..c4de3dc
--- /dev/null
+++ b/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor
@@ -0,0 +1,23 @@
+
+ Aktuelle Beiträge
+
+ @if (_latest.Count == 0)
+ {
+ Zurzeit gibt es keine Beiträge.
+ }
+ else
+ {
+
+ @foreach (var post in _latest)
+ {
+
+ @post.Title
+
+ @post.Date.ToString("d. MMMM yyyy", German)
+
+
+ }
+
+ Alle Beiträge →
+ }
+
diff --git a/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor.cs b/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor.cs
new file mode 100644
index 0000000..6468fc2
--- /dev/null
+++ b/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor.cs
@@ -0,0 +1,37 @@
+using System.Globalization;
+using Elternbeirat.Contracts;
+using Elternbeirat.PocketBase;
+using Microsoft.AspNetCore.Components;
+
+namespace Elternbeirat.Web.Features.Embeds;
+
+public partial class PostsEmbed
+{
+ private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load posts for an embed from PocketBase.")]
+ private static partial void LogLoadFailed(ILogger logger, Exception exception);
+
+ [Inject]
+ private PocketBaseClient PocketBase { get; set; } = default!;
+
+ [Inject]
+ private ILogger Logger { get; set; } = default!;
+
+ private IReadOnlyList _latest = [];
+
+ protected override async Task OnInitializedAsync()
+ {
+ try
+ {
+ // Teaser: the three most recent posts, with a link to the full list.
+ var posts = await PocketBase.GetPostsAsync();
+ _latest = [.. posts.Take(3)];
+ }
+ catch (HttpRequestException ex)
+ {
+ // Source unreachable -> show no teaser instead of a 500.
+ LogLoadFailed(Logger, ex);
+ }
+ }
+}
diff --git a/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor.css b/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor.css
new file mode 100644
index 0000000..9f4fa3b
--- /dev/null
+++ b/Elternbeirat.Web/Features/Embeds/PostsEmbed.razor.css
@@ -0,0 +1,24 @@
+.embed-posts {
+ margin-top: 2rem;
+}
+
+.post-list {
+ list-style: none;
+ padding: 0;
+}
+
+.post-list li {
+ padding: 0.75rem 0;
+ border-bottom: 1px solid #eee;
+}
+
+.post-list a {
+ font-size: 1.1rem;
+ text-decoration: none;
+}
+
+.post-list time {
+ display: block;
+ color: #777;
+ font-size: 0.85rem;
+}
diff --git a/Elternbeirat.Web/Features/Events/EventList.razor b/Elternbeirat.Web/Features/Events/EventList.razor
index def16d1..bf6af2e 100644
--- a/Elternbeirat.Web/Features/Events/EventList.razor
+++ b/Elternbeirat.Web/Features/Events/EventList.razor
@@ -22,7 +22,7 @@ else
@foreach (var ev in _upcoming)
{
- @Format(ev)
+ @EventFormat.Range(ev)
@ev.Title
@if (!string.IsNullOrWhiteSpace(ev.Location))
{
@@ -44,7 +44,7 @@ else
@foreach (var ev in _past)
{
- @Format(ev)
+ @EventFormat.Range(ev)
@ev.Title
@if (!string.IsNullOrWhiteSpace(ev.Location))
{
diff --git a/Elternbeirat.Web/Features/Events/EventList.razor.cs b/Elternbeirat.Web/Features/Events/EventList.razor.cs
index e4d066c..67bf41f 100644
--- a/Elternbeirat.Web/Features/Events/EventList.razor.cs
+++ b/Elternbeirat.Web/Features/Events/EventList.razor.cs
@@ -1,4 +1,3 @@
-using System.Globalization;
using Elternbeirat.Contracts;
using Elternbeirat.PocketBase;
using Microsoft.AspNetCore.Components;
@@ -7,8 +6,6 @@ namespace Elternbeirat.Web.Features.Events;
public partial class EventList
{
- private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
-
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load events from PocketBase.")]
private static partial void LogLoadFailed(ILogger logger, Exception exception);
@@ -48,36 +45,4 @@ public partial class EventList
.OrderByDescending(e => e.Start)
.ToList();
}
-
- // 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;
-
- // Formats the entry's date range for display: a single day, a date with a
- // time, or a span across days.
- private static string Format(Event ev)
- {
- var start = HasTime(ev)
- ? ev.Start.ToString("dddd, d. MMMM yyyy, HH:mm", German) + " Uhr"
- : ev.Start.ToString("dddd, d. MMMM yyyy", German);
-
- if (ev.End is not { } end)
- {
- return start;
- }
-
- // Same day: append just the end time. Different days: append the full
- // end date.
- if (end.Date == ev.Start.Date)
- {
- return HasTime(ev)
- ? start + "–" + end.ToString("HH:mm", German) + " Uhr"
- : start;
- }
-
- var endText = HasTime(ev)
- ? end.ToString("d. MMMM yyyy, HH:mm", German) + " Uhr"
- : end.ToString("d. MMMM yyyy", German);
- return start + " – " + endText;
- }
}
diff --git a/Elternbeirat.Web/Features/Home/Home.razor b/Elternbeirat.Web/Features/Home/Home.razor
index 3cc5e19..318c1a8 100644
--- a/Elternbeirat.Web/Features/Home/Home.razor
+++ b/Elternbeirat.Web/Features/Home/Home.razor
@@ -9,26 +9,4 @@
Antworten auf häufige Fragen und Unterlagen rund um die Elternarbeit.
-
- Aktuelle Beiträge
-
- @if (_latest.Count == 0)
- {
- Zurzeit gibt es keine Beiträge.
- }
- else
- {
-
- @foreach (var post in _latest)
- {
-
- @post.Title
-
- @post.Date.ToString("d. MMMM yyyy", German)
-
-
- }
-
- Alle Beiträge →
- }
-
+
diff --git a/Elternbeirat.Web/Features/Home/Home.razor.cs b/Elternbeirat.Web/Features/Home/Home.razor.cs
index dfc36f9..e5739c1 100644
--- a/Elternbeirat.Web/Features/Home/Home.razor.cs
+++ b/Elternbeirat.Web/Features/Home/Home.razor.cs
@@ -1,5 +1,3 @@
-using System.Globalization;
-using Elternbeirat.Contracts;
using Elternbeirat.PocketBase;
using Microsoft.AspNetCore.Components;
@@ -7,9 +5,7 @@ namespace Elternbeirat.Web.Features.Home;
public partial class Home
{
- private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
-
- [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load posts for the home teaser from PocketBase.")]
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load the home page from PocketBase.")]
private static partial void LogLoadFailed(ILogger logger, Exception exception);
[Inject]
@@ -18,19 +14,22 @@ public partial class Home
[Inject]
private ILogger Logger { get; set; } = default!;
- private IReadOnlyList _latest = [];
+ // 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.
+ private IReadOnlyList _embed = [];
protected override async Task OnInitializedAsync()
{
try
{
- // Show the three most recent posts as a teaser on the home page.
- var posts = await PocketBase.GetPostsAsync();
- _latest = [.. posts.Take(3)];
+ var pages = await PocketBase.GetPagesAsync();
+ var home = pages.FirstOrDefault(
+ p => string.Equals(p.Slug, "home", StringComparison.Ordinal));
+ _embed = home?.Embed ?? [];
}
catch (HttpRequestException ex)
{
- // Source unreachable -> show no teaser instead of a 500.
+ // Source unreachable -> render the intro without embeds instead of a 500.
LogLoadFailed(Logger, ex);
}
}
diff --git a/Elternbeirat.Web/Features/Home/Home.razor.css b/Elternbeirat.Web/Features/Home/Home.razor.css
index 016ae11..8a7e962 100644
--- a/Elternbeirat.Web/Features/Home/Home.razor.css
+++ b/Elternbeirat.Web/Features/Home/Home.razor.css
@@ -2,28 +2,3 @@
font-size: 1.1rem;
color: #444;
}
-
-.home-news {
- margin-top: 2rem;
-}
-
-.post-list {
- list-style: none;
- padding: 0;
-}
-
-.post-list li {
- padding: 0.75rem 0;
- border-bottom: 1px solid #eee;
-}
-
-.post-list a {
- font-size: 1.1rem;
- text-decoration: none;
-}
-
-.post-list time {
- display: block;
- color: #777;
- font-size: 0.85rem;
-}
diff --git a/Elternbeirat.Web/Features/Layout/NavBuilder.cs b/Elternbeirat.Web/Features/Layout/NavBuilder.cs
new file mode 100644
index 0000000..08414fc
--- /dev/null
+++ b/Elternbeirat.Web/Features/Layout/NavBuilder.cs
@@ -0,0 +1,31 @@
+using Elternbeirat.Contracts;
+
+namespace Elternbeirat.Web.Features.Layout;
+
+///
+/// Builds the header and footer navigation from the public pages. Pages carry a
+/// ("header"/"footer"/"") and an ;
+/// pages without a location are sub-pages and never appear in a menu.
+///
+public static class NavBuilder
+{
+ /// Header links, ordered, built from pages with location "header".
+ public static IReadOnlyList Header(IReadOnlyList pages) =>
+ Build(pages, "header");
+
+ /// Footer links, ordered, built from pages with location "footer".
+ public static IReadOnlyList Footer(IReadOnlyList pages) =>
+ Build(pages, "footer");
+
+ private static List Build(IReadOnlyList pages, string location) =>
+ [.. pages
+ .Where(page => string.Equals(page.Location, location, StringComparison.Ordinal))
+ .OrderBy(page => page.Order)
+ .Select(page => new NavLinkItem(HrefFor(page.Slug), page.Title))];
+
+ // The home page owns "/" (served by the Home component); every other slug is
+ // reached at "/{slug}", where either a literal route or the catch-all content
+ // page renders it.
+ private static string HrefFor(string slug) =>
+ string.Equals(slug, "home", StringComparison.Ordinal) ? "/" : $"/{slug}";
+}
diff --git a/Elternbeirat.Web/Features/Layout/NavLinkItem.cs b/Elternbeirat.Web/Features/Layout/NavLinkItem.cs
new file mode 100644
index 0000000..4ec5090
--- /dev/null
+++ b/Elternbeirat.Web/Features/Layout/NavLinkItem.cs
@@ -0,0 +1,6 @@
+namespace Elternbeirat.Web.Features.Layout;
+
+/// A single navigation entry derived from a page record.
+/// Target URL; the "home" page maps to "/".
+/// Link text shown to the visitor.
+public sealed record NavLinkItem(string Href, string Title);
diff --git a/Elternbeirat.Web/Features/Pages/ContentPage.razor b/Elternbeirat.Web/Features/Pages/ContentPage.razor
index 63adb59..69f6617 100644
--- a/Elternbeirat.Web/Features/Pages/ContentPage.razor
+++ b/Elternbeirat.Web/Features/Pages/ContentPage.razor
@@ -10,4 +10,5 @@ else
@Body
+
}
diff --git a/Elternbeirat.Web/Shared/EventFormat.cs b/Elternbeirat.Web/Shared/EventFormat.cs
new file mode 100644
index 0000000..274e8ea
--- /dev/null
+++ b/Elternbeirat.Web/Shared/EventFormat.cs
@@ -0,0 +1,48 @@
+using System.Globalization;
+using Elternbeirat.Contracts;
+
+namespace Elternbeirat.Web.Shared;
+
+///
+/// Formats an '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.
+///
+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;
+
+ ///
+ /// Formats the entry's date range: a single day, a date with a time, or a
+ /// span across days.
+ ///
+ public static string Range(Event ev)
+ {
+ var start = HasTime(ev)
+ ? ev.Start.ToString("dddd, d. MMMM yyyy, HH:mm", German) + " Uhr"
+ : ev.Start.ToString("dddd, d. MMMM yyyy", German);
+
+ if (ev.End is not { } end)
+ {
+ return start;
+ }
+
+ // Same day: append just the end time. Different days: append the full
+ // end date.
+ if (end.Date == ev.Start.Date)
+ {
+ return HasTime(ev)
+ ? start + "–" + end.ToString("HH:mm", German) + " Uhr"
+ : start;
+ }
+
+ var endText = HasTime(ev)
+ ? end.ToString("d. MMMM yyyy, HH:mm", German) + " Uhr"
+ : end.ToString("d. MMMM yyyy", German);
+ return start + " – " + endText;
+ }
+}
diff --git a/Elternbeirat.Web/_Imports.razor b/Elternbeirat.Web/_Imports.razor
index cf7ad57..403085e 100644
--- a/Elternbeirat.Web/_Imports.razor
+++ b/Elternbeirat.Web/_Imports.razor
@@ -8,3 +8,5 @@
@using Microsoft.JSInterop
@using Elternbeirat.Web
@using Elternbeirat.Web.Shared
+@using Elternbeirat.Web.Features.Layout
+@using Elternbeirat.Web.Features.Embeds