Read all content from PocketBase, per request #30

Merged
Tom merged 7 commits from feature/pocketbase-consume into main 2026-09-23 16:02:46 +02:00
26 changed files with 544 additions and 114 deletions
Showing only changes of commit 3fd3796d73 - Show all commits

No files matched your search

+3 -7
View File
@@ -7,12 +7,8 @@
<list />
</option>
<option name="commandLineOptions" value="--build" />
<!-- Dev overlay: applied LAST so it wins (host ports, local web build, and
the pb_data_dev volume replacing the Unraid bind mount). In Rider the
sourceFilePath is merged first and secondarySourceFiles after it, so
the overlay must sit in secondarySourceFiles, not the other way round --
otherwise the base file overrides it and PocketBase mounts the empty
/mnt/user prod path locally. -->
<!-- The dev overlay must win over the base, so it is listed here:
secondarySourceFiles are merged after sourceFilePath. -->
<option name="secondarySourceFiles">
<list>
<option value="$PROJECT_DIR$/compose.dev.yaml" />
@@ -24,7 +20,7 @@
<option value="eb-web" />
</list>
</option>
<!-- Base compose (production-like), merged first. -->
<!-- Base compose, merged first. -->
<option name="sourceFilePath" value="$PROJECT_DIR$/compose.yaml" />
<option name="upForceRecreate" value="true" />
</settings>
+68
View File
@@ -0,0 +1,68 @@
using Elternbeirat.Contracts;
using Elternbeirat.Web.Features.Layout;
namespace Elternbeirat.Web.Tests;
/// <summary>
/// Unit tests for the navigation builder. Pure logic, no PocketBase needed.
/// </summary>
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();
}
}
+3 -1
View File
@@ -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<string>(), @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.
+43
View File
@@ -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
}
}
@@ -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