Generate navigation and render page embeds from PocketBase

This commit is contained in:
tleininger committed 2026-09-23 14:54:10 +02:00
1 parent 6b5d9dda55
commit 3fd3796d73
26 files changed
+544 -114

No files matched your search

@@ -5,14 +5,10 @@
<div class="header-inner">
<a class="brand" href="/">Elternbeirat IGMH</a>
<nav class="main-nav">
<NavLink href="/" Match="NavLinkMatch.All">Start</NavLink>
<NavLink href="/board">Vorstandsteam</NavLink>
<NavLink href="/patrons">Förderverein</NavLink>
<NavLink href="/posts">Beiträge</NavLink>
<NavLink href="/events">Termine</NavLink>
<NavLink href="/faqs">FAQ</NavLink>
<NavLink href="/downloads">Downloads</NavLink>
<NavLink href="/contact">Kontakt</NavLink>
@foreach (var link in _header)
{
<NavLink href="@link.Href" Match="@(link.Href == "/" ? NavLinkMatch.All : NavLinkMatch.Prefix)">@link.Title</NavLink>
}
</nav>
</div>
</header>
@@ -25,9 +21,10 @@
<div class="footer-inner">
<span>Elternbeirat der IGMH</span>
<nav class="footer-nav">
<a href="/contact">Kontakt</a>
<a href="/imprint">Impressum</a>
<a href="/privacy">Datenschutz</a>
@foreach (var link in _footer)
{
<a href="@link.Href">@link.Title</a>
}
</nav>
</div>
</footer>
@@ -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<MainLayout> Logger { get; set; } = default!;
private IReadOnlyList<NavLinkItem> _header = [];
private IReadOnlyList<NavLinkItem> _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);
}
}
}
@@ -0,0 +1,25 @@
<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)
{
<li>
<time datetime="@ev.Start.ToString("s")">@EventFormat.Range(ev)</time>
<span class="event-title">@ev.Title</span>
@if (!string.IsNullOrWhiteSpace(ev.Location))
{
<span class="event-location">@ev.Location</span>
}
</li>
}
</ul>
<p><a href="/events">Alle Termine &rarr;</a></p>
}
</section>
@@ -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<EventsEmbed> Logger { get; set; } = default!;
private IReadOnlyList<Event> _upcoming = [];
protected override async Task OnInitializedAsync()
{
IReadOnlyList<Event> 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();
}
}
@@ -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;
}
@@ -0,0 +1,21 @@
<section class="embed embed-faqs">
<h2>Häufige Fragen</h2>
@if (_faqs.Count == 0)
{
<p>Zurzeit gibt es keine Fragen.</p>
}
else
{
@foreach (var faq in _faqs)
{
<details class="faq-item">
<summary>@faq.Question</summary>
<div class="faq-answer">
@((MarkupString)Markdown.ToHtml(faq.Answer))
</div>
</details>
}
<p><a href="/faqs">Alle Fragen &rarr;</a></p>
}
</section>
@@ -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<FaqsEmbed> Logger { get; set; } = default!;
private IReadOnlyList<Faq> _faqs = [];
protected override async Task OnInitializedAsync()
{
try
{
// Teaser: the first three questions, with a link to the full hub.
var faqs = await PocketBase.GetFaqsAsync();
_faqs = [.. faqs.Take(3)];
}
catch (HttpRequestException ex)
{
// Source unreachable -> show no teaser instead of a 500.
LogLoadFailed(Logger, ex);
}
}
}
@@ -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;
}
@@ -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":
<PostsEmbed />
break;
case "events":
<EventsEmbed />
break;
case "faqs":
<FaqsEmbed />
break;
}
}
@code {
/// <summary>The page's embed keys ("posts", "events", "faqs"), in order.</summary>
[Parameter]
public IReadOnlyList<string> Embed { get; set; } = [];
}
@@ -0,0 +1,23 @@
<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>
</li>
}
</ul>
<p><a href="/posts">Alle Beiträge &rarr;</a></p>
}
</section>
@@ -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<PostsEmbed> Logger { get; set; } = default!;
private IReadOnlyList<Post> _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);
}
}
}
@@ -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;
}
@@ -22,7 +22,7 @@ else
@foreach (var ev in _upcoming)
{
<li>
<time datetime="@ev.Start.ToString("s")">@Format(ev)</time>
<time datetime="@ev.Start.ToString("s")">@EventFormat.Range(ev)</time>
<span class="event-title">@ev.Title</span>
@if (!string.IsNullOrWhiteSpace(ev.Location))
{
@@ -44,7 +44,7 @@ else
@foreach (var ev in _past)
{
<li>
<time datetime="@ev.Start.ToString("s")">@Format(ev)</time>
<time datetime="@ev.Start.ToString("s")">@EventFormat.Range(ev)</time>
<span class="event-title">@ev.Title</span>
@if (!string.IsNullOrWhiteSpace(ev.Location))
{
@@ -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;
}
}
+1 -23
View File
@@ -9,26 +9,4 @@
Antworten auf häufige Fragen und Unterlagen rund um die Elternarbeit.
</p>
<section class="home-news">
<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>
</li>
}
</ul>
<p><a href="/posts">Alle Beiträge &rarr;</a></p>
}
</section>
<PageEmbeds Embed="_embed" />
+9 -10
View File
@@ -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<Home> Logger { get; set; } = default!;
private IReadOnlyList<Post> _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<string> _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);
}
}
@@ -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;
}
@@ -0,0 +1,31 @@
using Elternbeirat.Contracts;
namespace Elternbeirat.Web.Features.Layout;
/// <summary>
/// Builds the header and footer navigation from the public pages. Pages carry a
/// <see cref="Page.Location"/> ("header"/"footer"/"") and an <see cref="Page.Order"/>;
/// pages without a location are sub-pages and never appear in a menu.
/// </summary>
public static class NavBuilder
{
/// <summary>Header links, ordered, built from pages with location "header".</summary>
public static IReadOnlyList<NavLinkItem> Header(IReadOnlyList<Page> pages) =>
Build(pages, "header");
/// <summary>Footer links, ordered, built from pages with location "footer".</summary>
public static IReadOnlyList<NavLinkItem> Footer(IReadOnlyList<Page> pages) =>
Build(pages, "footer");
private static List<NavLinkItem> Build(IReadOnlyList<Page> pages, string location) =>
[.. pages
.Where(page => string.Equals(page.Location, location, StringComparison.Ordinal))
.OrderBy(page => page.Order)
.Select(page => new NavLinkItem(HrefFor(page.Slug), page.Title))];
// 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}";
}
@@ -0,0 +1,6 @@
namespace Elternbeirat.Web.Features.Layout;
/// <summary>A single navigation entry derived from a page record.</summary>
/// <param name="Href">Target URL; the "home" page maps to "/".</param>
/// <param name="Title">Link text shown to the visitor.</param>
public sealed record NavLinkItem(string Href, string Title);
@@ -10,4 +10,5 @@ else
<article>
@Body
</article>
<PageEmbeds Embed="_page.Embed" />
}
+48
View File
@@ -0,0 +1,48 @@
using System.Globalization;
using Elternbeirat.Contracts;
namespace Elternbeirat.Web.Shared;
/// <summary>
/// Formats an <see cref="Event"/>'s date range for display in German. Shared by
/// the full event list and the home-page events embed so both render dates the
/// same way.
/// </summary>
public static class EventFormat
{
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
// An event counts as all-day when its start carries no wall-clock time
// (00:00); PocketBase has no separate flag.
private static bool HasTime(Event ev) => ev.Start.TimeOfDay != TimeSpan.Zero;
/// <summary>
/// Formats the entry's date range: a single day, a date with a time, or a
/// span across days.
/// </summary>
public static string Range(Event ev)
{
var start = HasTime(ev)
? ev.Start.ToString("dddd, d. MMMM yyyy, HH:mm", German) + " Uhr"
: ev.Start.ToString("dddd, d. MMMM yyyy", German);
if (ev.End is not { } end)
{
return start;
}
// Same day: append just the end time. Different days: append the full
// end date.
if (end.Date == ev.Start.Date)
{
return HasTime(ev)
? start + "–" + end.ToString("HH:mm", German) + " Uhr"
: start;
}
var endText = HasTime(ev)
? end.ToString("d. MMMM yyyy, HH:mm", German) + " Uhr"
: end.ToString("d. MMMM yyyy", German);
return start + " – " + endText;
}
}
+2
View File
@@ -8,3 +8,5 @@
@using Microsoft.JSInterop
@using Elternbeirat.Web
@using Elternbeirat.Web.Shared
@using Elternbeirat.Web.Features.Layout
@using Elternbeirat.Web.Features.Embeds