From da87716d97a32cb6424f0f79f415e3783b5af60e Mon Sep 17 00:00:00 2001 From: tleininger Date: Wed, 23 Sep 2026 13:09:22 +0200 Subject: [PATCH 1/7] Consume PocketBase for posts and events, with English routes --- .../PocketBaseClientTests.cs | 3 +- Elternbeirat.Web.Tests/PocketBaseFixture.cs | 8 + Elternbeirat.Web.Tests/PocketBaseTestGroup.cs | 14 ++ Elternbeirat.Web.Tests/RouteSmokeTests.cs | 42 ++++-- .../Components/Layout/MainLayout.razor | 4 +- Elternbeirat.Web/Elternbeirat.Web.csproj | 6 + Elternbeirat.Web/Features/Events/Event.cs | 33 ----- .../Features/Events/EventList.razor | 13 +- .../Features/Events/EventList.razor.cs | 54 ++++++- .../Features/Events/EventService.cs | 138 ------------------ .../Features/Events/IcsCalendar.cs | 10 +- Elternbeirat.Web/Features/Home/Home.razor | 4 +- .../Features/Posts/PostDetail.razor | 7 +- .../Features/Posts/PostDetail.razor.cs | 46 ++++-- .../Features/Posts/PostList.razor | 9 +- .../Features/Posts/PostList.razor.cs | 28 ++++ Elternbeirat.Web/Program.cs | 30 +++- Elternbeirat.Web/Shared/Markdown.cs | 23 +++ Elternbeirat.Web/_Imports.razor | 1 + Elternbeirat.Web/appsettings.Development.json | 3 + Elternbeirat.Web/appsettings.json | 7 +- compose.yaml | 20 ++- 22 files changed, 278 insertions(+), 225 deletions(-) create mode 100644 Elternbeirat.Web.Tests/PocketBaseTestGroup.cs delete mode 100644 Elternbeirat.Web/Features/Events/Event.cs delete mode 100644 Elternbeirat.Web/Features/Events/EventService.cs create mode 100644 Elternbeirat.Web/Shared/Markdown.cs diff --git a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs index b90033b..f5b1a87 100644 --- a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs +++ b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs @@ -7,7 +7,8 @@ namespace Elternbeirat.Web.Tests; /// with a known seed (see ). Because the data is fixed, /// the tests assert on exact values, and they need no network to the live instance. /// -public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase) : IClassFixture +[Collection(PocketBaseTestGroup.Name)] +public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase) { [Fact] public async Task Events_load_sorted_by_start() diff --git a/Elternbeirat.Web.Tests/PocketBaseFixture.cs b/Elternbeirat.Web.Tests/PocketBaseFixture.cs index 1c333ef..d6b3fe4 100644 --- a/Elternbeirat.Web.Tests/PocketBaseFixture.cs +++ b/Elternbeirat.Web.Tests/PocketBaseFixture.cs @@ -49,6 +49,14 @@ public sealed class PocketBaseFixture : IAsyncLifetime /// Creates a pointed at this container. public PocketBaseClient CreateClient() => new(_http); + /// + /// The base URL the container's PocketBase is reachable at. Set once the + /// container is up; used to point the web app's client at this instance in the + /// route smoke tests. + /// + public Uri BaseUrl => _http.BaseAddress + ?? throw new InvalidOperationException("PocketBase is not started yet."); + public async Task InitializeAsync() { // Clear any leftover from an earlier run that was aborted before DisposeAsync diff --git a/Elternbeirat.Web.Tests/PocketBaseTestGroup.cs b/Elternbeirat.Web.Tests/PocketBaseTestGroup.cs new file mode 100644 index 0000000..bccb367 --- /dev/null +++ b/Elternbeirat.Web.Tests/PocketBaseTestGroup.cs @@ -0,0 +1,14 @@ +namespace Elternbeirat.Web.Tests; + +/// +/// Groups every test that needs the seeded PocketBase container into one xUnit +/// collection, so the single (one container, one +/// seed) is shared across those classes instead of started once per class. The +/// test project runs one PocketBase for the whole run under the fixed project +/// name eb-test-stack; sharing avoids two classes fighting over it. +/// +[CollectionDefinition(Name)] +public sealed class PocketBaseTestGroup : ICollectionFixture +{ + public const string Name = "PocketBase"; +} diff --git a/Elternbeirat.Web.Tests/RouteSmokeTests.cs b/Elternbeirat.Web.Tests/RouteSmokeTests.cs index 919306d..f69f239 100644 --- a/Elternbeirat.Web.Tests/RouteSmokeTests.cs +++ b/Elternbeirat.Web.Tests/RouteSmokeTests.cs @@ -1,4 +1,5 @@ using System.Net; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; namespace Elternbeirat.Web.Tests; @@ -7,19 +8,40 @@ namespace Elternbeirat.Web.Tests; /// Smoke tests: every known route must return 200, unknown routes must return /// 404. This catches broken content files, renamed slugs or routing regressions /// before a deploy. +/// +/// The post routes now read from PocketBase, so the tests point the web app at the +/// seeded container from (shared via the +/// collection) and assert against its known slugs. Requires Docker, like the +/// client tests. +/// /// -public sealed class RouteSmokeTests : IClassFixture> +[Collection(PocketBaseTestGroup.Name)] +public sealed class RouteSmokeTests : IDisposable { + // WithWebHostBuilder returns a new factory that wraps the base one; both are + // disposable, so both are held and disposed to avoid leaking either. + private readonly WebApplicationFactory _baseFactory = new(); private readonly WebApplicationFactory _factory; - public RouteSmokeTests(WebApplicationFactory factory) + public RouteSmokeTests(PocketBaseFixture pocketBase) { - _factory = factory; + // Point the app's PocketBaseClient at the seeded test container instead of + // the value from appsettings. UseSetting (not ConfigureAppConfiguration) + // because it wins over appsettings.Development.json, which otherwise pins the + // client to localhost:8090 while the test container uses a random port. + _factory = _baseFactory.WithWebHostBuilder(builder => + builder.UseSetting("PocketBase:BaseUrl", pocketBase.BaseUrl.ToString())); + } + + public void Dispose() + { + _factory.Dispose(); + _baseFactory.Dispose(); } /// /// Every route that a visitor can reach through the navigation, the FAQ hub - /// or the news section. + /// or the news section. The post slugs match the fixture's seed. /// public static TheoryData KnownRoutes => [ @@ -35,11 +57,11 @@ public sealed class RouteSmokeTests : IClassFixtureStart Vorstandsteam Förderverein - Beiträge - Termine + Beiträge + Termine FAQ Downloads Kontakt diff --git a/Elternbeirat.Web/Elternbeirat.Web.csproj b/Elternbeirat.Web/Elternbeirat.Web.csproj index f05d606..70c00f9 100644 --- a/Elternbeirat.Web/Elternbeirat.Web.csproj +++ b/Elternbeirat.Web/Elternbeirat.Web.csproj @@ -22,6 +22,12 @@ + + + + + diff --git a/Elternbeirat.Web/Features/Events/Event.cs b/Elternbeirat.Web/Features/Events/Event.cs deleted file mode 100644 index fe94c93..0000000 --- a/Elternbeirat.Web/Features/Events/Event.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace Elternbeirat.Web.Features.Events; - -/// -/// A single calendar entry (parents' evening, meeting, deadline). Read from -/// Content/events.yml at startup. All display text is German because it -/// is visitor-facing content. -/// -public sealed class Event -{ - /// Short title shown in the list and the calendar. - public required string Title { get; init; } - - /// - /// When the entry starts. A date-only entry (no time) is treated as an - /// all-day event; see . - /// - public required DateTime Start { get; init; } - - /// - /// True when carries a wall-clock time, false for an - /// all-day entry. Drives both the list formatting and the ICS export. - /// - public required bool HasTime { get; init; } - - /// Optional end, for multi-hour or multi-day entries. - public DateTime? End { get; init; } - - /// Optional location, e.g. "Aula" or "Mensa". - public string? Location { get; init; } - - /// Optional free-text note shown below the entry. - public string? Note { get; init; } -} diff --git a/Elternbeirat.Web/Features/Events/EventList.razor b/Elternbeirat.Web/Features/Events/EventList.razor index 5d71ca1..def16d1 100644 --- a/Elternbeirat.Web/Features/Events/EventList.razor +++ b/Elternbeirat.Web/Features/Events/EventList.razor @@ -1,5 +1,4 @@ -@page "/termine" -@inject EventService EventService +@page "/events" Termine @@ -7,20 +6,20 @@

Alle Termine des Elternbeirats auf einen Blick. Sie können den Kalender auch - abonnieren und in Ihrer Kalender-App automatisch + abonnieren und in Ihrer Kalender-App automatisch aktuell halten.

Kommende Termine

-@if (EventService.Upcoming.Count == 0) +@if (_upcoming.Count == 0) {

Zurzeit sind keine Termine geplant.

} else {
    - @foreach (var ev in EventService.Upcoming) + @foreach (var ev in _upcoming) {
  • @@ -38,11 +37,11 @@ else
} -@if (EventService.Past.Count > 0) +@if (_past.Count > 0) {

Vergangene Termine

    - @foreach (var ev in EventService.Past) + @foreach (var ev in _past) {
  • diff --git a/Elternbeirat.Web/Features/Events/EventList.razor.cs b/Elternbeirat.Web/Features/Events/EventList.razor.cs index a17a7bd..e4d066c 100644 --- a/Elternbeirat.Web/Features/Events/EventList.razor.cs +++ b/Elternbeirat.Web/Features/Events/EventList.razor.cs @@ -1,5 +1,7 @@ using System.Globalization; -using Elternbeirat.Web.Services; +using Elternbeirat.Contracts; +using Elternbeirat.PocketBase; +using Microsoft.AspNetCore.Components; namespace Elternbeirat.Web.Features.Events; @@ -7,11 +9,55 @@ 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); + + [Inject] + private PocketBaseClient PocketBase { get; set; } = default!; + + [Inject] + private ILogger Logger { get; set; } = default!; + + private IReadOnlyList _upcoming = []; + private IReadOnlyList _past = []; + + protected override async Task OnInitializedAsync() + { + IReadOnlyList events; + try + { + events = await PocketBase.GetEventsAsync(); + } + catch (HttpRequestException ex) + { + // Source unreachable -> show empty sections instead of a 500. The page + // stays up; the "keine Termine" note is rendered. + LogLoadFailed(Logger, ex); + 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(); + } + + // 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 = ev.HasTime + var start = HasTime(ev) ? ev.Start.ToString("dddd, d. MMMM yyyy, HH:mm", German) + " Uhr" : ev.Start.ToString("dddd, d. MMMM yyyy", German); @@ -24,12 +70,12 @@ public partial class EventList // end date. if (end.Date == ev.Start.Date) { - return ev.HasTime + return HasTime(ev) ? start + "–" + end.ToString("HH:mm", German) + " Uhr" : start; } - var endText = ev.HasTime + 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/Events/EventService.cs b/Elternbeirat.Web/Features/Events/EventService.cs deleted file mode 100644 index e813ce8..0000000 --- a/Elternbeirat.Web/Features/Events/EventService.cs +++ /dev/null @@ -1,138 +0,0 @@ -using System.Globalization; -using Elternbeirat.Web.Services; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; - -namespace Elternbeirat.Web.Features.Events; - -/// -/// Reads the calendar entries from Content/events.yml once at startup and -/// keeps them in memory. Registered as a singleton because the content lives in -/// the image and does not change at runtime. -/// -public sealed class EventService -{ - private readonly IReadOnlyList _events; - - /// - /// Reads and parses Content/events.yml below the application root. - /// A missing file yields an empty list rather than an error. - /// - public EventService(IWebHostEnvironment environment) - { - var path = Path.Combine(environment.ContentRootPath, "Content", "events.yml"); - _events = File.Exists(path) - ? Parse(File.ReadAllText(path)) - : []; - } - - /// - /// Entries that start today or later, earliest first — the upcoming section. - /// "Today" is compared by date, so an entry earlier today still counts as - /// upcoming. - /// - public IReadOnlyList Upcoming => - _events - .Where(e => DateOnly.FromDateTime(e.Start) >= DateOnly.FromDateTime(DateTime.Today)) - .OrderBy(e => e.Start) - .ToList(); - - /// - /// Entries that already passed, most recent first — the past section shown - /// below the upcoming ones. - /// - public IReadOnlyList Past => - _events - .Where(e => DateOnly.FromDateTime(e.Start) < DateOnly.FromDateTime(DateTime.Today)) - .OrderByDescending(e => e.Start) - .ToList(); - - /// All entries in chronological order, for the ICS export. - public IReadOnlyList All => - _events.OrderBy(e => e.Start).ToList(); - - private static List Parse(string yaml) - { - // Keys are lowercase single words (title, start, ...), which the camel - // case convention maps onto the PascalCase properties. - var deserializer = new DeserializerBuilder() - .WithNamingConvention(CamelCaseNamingConvention.Instance) - .IgnoreUnmatchedProperties() - .Build(); - - var raw = deserializer.Deserialize?>(yaml) ?? []; - return raw - .Where(entry => entry.Title is not null && entry.Start is not null) - .Select(ToEvent) - .OfType() - .ToList(); - } - - private static Event? ToEvent(EventEntry entry) - { - if (!TryParseWhen(entry.Start!, out var start, out var hasTime)) - { - // A malformed date drops the entry rather than crashing startup. - return null; - } - - DateTime? end = null; - if (entry.End is not null && TryParseWhen(entry.End, out var parsedEnd, out _)) - { - end = parsedEnd; - } - - return new Event - { - Title = entry.Title!, - Start = start, - HasTime = hasTime, - End = end, - Location = entry.Location, - Note = entry.Note, - }; - } - - /// - /// Parses a value that is either a date (2026-03-15) or a date with a - /// time (2026-03-15 19:30). Sets so the - /// caller knows whether to treat the entry as all-day. - /// - private static bool TryParseWhen(string value, out DateTime when, out bool hasTime) - { - var trimmed = value.Trim(); - - if (DateOnly.TryParseExact(trimmed, "yyyy-MM-dd", - CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateOnly)) - { - when = dateOnly.ToDateTime(TimeOnly.MinValue); - hasTime = false; - return true; - } - - string[] formats = ["yyyy-MM-dd HH:mm", "yyyy-MM-dd'T'HH:mm"]; - if (DateTime.TryParseExact(trimmed, formats, - CultureInfo.InvariantCulture, DateTimeStyles.None, out when)) - { - hasTime = true; - return true; - } - - when = default; - hasTime = false; - return false; - } - - /// - /// Raw shape of one YAML entry, before validation. String-typed on purpose so - /// we control date parsing and can distinguish date-only from date-and-time. - /// - private sealed class EventEntry - { - public string? Title { get; set; } - public string? Start { get; set; } - public string? End { get; set; } - public string? Location { get; set; } - public string? Note { get; set; } - } -} diff --git a/Elternbeirat.Web/Features/Events/IcsCalendar.cs b/Elternbeirat.Web/Features/Events/IcsCalendar.cs index e3d3b03..2d85388 100644 --- a/Elternbeirat.Web/Features/Events/IcsCalendar.cs +++ b/Elternbeirat.Web/Features/Events/IcsCalendar.cs @@ -1,7 +1,7 @@ using System.Globalization; using System.Security.Cryptography; using System.Text; -using Elternbeirat.Web.Services; +using Elternbeirat.Contracts; namespace Elternbeirat.Web.Features.Events; @@ -45,7 +45,7 @@ public static class IcsCalendar // between requests, so caches and clients do not see spurious changes. AppendLine(sb, "DTSTAMP:20000101T000000Z"); - if (ev.HasTime) + if (HasTime(ev)) { AppendLine(sb, $"DTSTART:{Local(ev.Start)}"); if (ev.End is { } end) @@ -75,6 +75,12 @@ public static class IcsCalendar AppendLine(sb, "END:VEVENT"); } + /// + /// 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. + /// + private static bool HasTime(Event ev) => ev.Start.TimeOfDay != TimeSpan.Zero; + /// /// 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. diff --git a/Elternbeirat.Web/Features/Home/Home.razor b/Elternbeirat.Web/Features/Home/Home.razor index b811c0e..48118f4 100644 --- a/Elternbeirat.Web/Features/Home/Home.razor +++ b/Elternbeirat.Web/Features/Home/Home.razor @@ -23,13 +23,13 @@ @foreach (var post in _latest) {
  • - @post.Title + @post.Title
  • }
-

Alle Beiträge →

+

Alle Beiträge →

} diff --git a/Elternbeirat.Web/Features/Posts/PostDetail.razor b/Elternbeirat.Web/Features/Posts/PostDetail.razor index ef31e63..718f4c2 100644 --- a/Elternbeirat.Web/Features/Posts/PostDetail.razor +++ b/Elternbeirat.Web/Features/Posts/PostDetail.razor @@ -1,5 +1,4 @@ -@page "/beitraege/{Slug}" -@inject PostService PostService +@page "/posts/{Slug}" @if (_post is null) { @@ -10,11 +9,11 @@ else @_post.Title } diff --git a/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs b/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs index e2d1e99..39e1df1 100644 --- a/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs +++ b/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs @@ -1,5 +1,7 @@ using System.Globalization; -using Elternbeirat.Web.Services; +using Elternbeirat.Contracts; +using Elternbeirat.PocketBase; +using Elternbeirat.Web.Shared; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Http; @@ -9,23 +11,49 @@ 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); + [Parameter] public string Slug { get; set; } = ""; + [Inject] + private PocketBaseClient PocketBase { get; set; } = default!; + + [Inject] + private ILogger Logger { get; set; } = default!; + + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + private Post? _post; - protected override void OnParametersSet() - { - _post = PostService.Find(Slug); + // 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); - // Unknown slug -> 404, so UseStatusCodePagesWithReExecute serves the - // /not-found page instead of an empty 200 response. + protected override async Task OnParametersSetAsync() + { + try + { + var posts = await PocketBase.GetPostsAsync(); + _post = posts.FirstOrDefault( + p => string.Equals(p.Slug, Slug, StringComparison.OrdinalIgnoreCase)); + } + catch (HttpRequestException ex) + { + // 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; + } + + // 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) { HttpContext.Response.StatusCode = StatusCodes.Status404NotFound; } } - - [CascadingParameter] - private HttpContext? HttpContext { get; set; } } diff --git a/Elternbeirat.Web/Features/Posts/PostList.razor b/Elternbeirat.Web/Features/Posts/PostList.razor index 0301a89..c294cc4 100644 --- a/Elternbeirat.Web/Features/Posts/PostList.razor +++ b/Elternbeirat.Web/Features/Posts/PostList.razor @@ -1,21 +1,20 @@ -@page "/beitraege" -@inject PostService PostService +@page "/posts" Beiträge

Beiträge

-@if (PostService.All.Count == 0) +@if (_posts.Count == 0) {

Zurzeit gibt es keine Beiträge.

} else {
    - @foreach (var post in PostService.All) + @foreach (var post in _posts) {
  • - @post.Title + @post.Title diff --git a/Elternbeirat.Web/Features/Posts/PostList.razor.cs b/Elternbeirat.Web/Features/Posts/PostList.razor.cs index f582b7e..38d358b 100644 --- a/Elternbeirat.Web/Features/Posts/PostList.razor.cs +++ b/Elternbeirat.Web/Features/Posts/PostList.razor.cs @@ -1,8 +1,36 @@ using System.Globalization; +using Elternbeirat.Contracts; +using Elternbeirat.PocketBase; +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"); + + [Inject] + private PocketBaseClient PocketBase { get; set; } = default!; + + [Inject] + private ILogger Logger { get; set; } = default!; + + private IReadOnlyList _posts = []; + + protected override async Task OnInitializedAsync() + { + try + { + _posts = await PocketBase.GetPostsAsync(); + } + catch (HttpRequestException ex) + { + // Source unreachable -> show an empty list instead of a 500. The page + // stays up; the "keine Beiträge" note is rendered. + LogLoadFailed(Logger, ex); + } + } } diff --git a/Elternbeirat.Web/Program.cs b/Elternbeirat.Web/Program.cs index ed34eb9..3ea5781 100644 --- a/Elternbeirat.Web/Program.cs +++ b/Elternbeirat.Web/Program.cs @@ -1,3 +1,5 @@ +using Elternbeirat.Contracts; +using Elternbeirat.PocketBase; using Elternbeirat.Web.Components; using Elternbeirat.Web.Features.Events; using Elternbeirat.Web.Services; @@ -8,10 +10,17 @@ var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddRazorComponents(); +// The typed client reads content from PocketBase over its REST API. The base URL +// comes from configuration: appsettings for local dev, the PocketBase__BaseUrl +// env var on the server (see compose.yaml). +var pocketBaseUrl = builder.Configuration["PocketBase:BaseUrl"] + ?? throw new InvalidOperationException("PocketBase:BaseUrl is not configured."); +builder.Services.AddHttpClient(client => + client.BaseAddress = new Uri(pocketBaseUrl)); + // Content is read once at startup and cached -> singletons. builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); var app = builder.Build(); @@ -43,8 +52,21 @@ app.MapStaticAssets(); app.MapRazorComponents(); // Subscribable calendar feed of all events. A minimal API endpoint rather than -// a Razor page because it returns text/calendar, not HTML. -app.MapGet("/termine.ics", (EventService events) => - Results.Text(IcsCalendar.Build(events.All), "text/calendar; charset=utf-8")); +// 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) => +{ + IReadOnlyList events; + try + { + events = await pocketBase.GetEventsAsync(); + } + catch (HttpRequestException) + { + events = []; + } + + return Results.Text(IcsCalendar.Build(events), "text/calendar; charset=utf-8"); +}); app.Run(); diff --git a/Elternbeirat.Web/Shared/Markdown.cs b/Elternbeirat.Web/Shared/Markdown.cs new file mode 100644 index 0000000..cc4309a --- /dev/null +++ b/Elternbeirat.Web/Shared/Markdown.cs @@ -0,0 +1,23 @@ +using Markdig; + +namespace Elternbeirat.Web.Shared; + +/// +/// Renders Markdown to HTML. Content now comes from PocketBase as raw Markdown +/// (the body field), so the pages render it at display time instead of +/// reading pre-rendered HTML from files. +/// +public static class Markdown +{ + private static readonly MarkdownPipeline Pipeline = + new MarkdownPipelineBuilder().Build(); + + /// + /// Converts a Markdown string to HTML. Returns an empty string for + /// null or empty input, so callers can bind the result directly. + /// + public static string ToHtml(string? markdown) => + string.IsNullOrEmpty(markdown) + ? "" + : Markdig.Markdown.ToHtml(markdown, Pipeline); +} diff --git a/Elternbeirat.Web/_Imports.razor b/Elternbeirat.Web/_Imports.razor index 57b29e7..ac14ee8 100644 --- a/Elternbeirat.Web/_Imports.razor +++ b/Elternbeirat.Web/_Imports.razor @@ -8,3 +8,4 @@ @using Microsoft.JSInterop @using Elternbeirat.Web @using Elternbeirat.Web.Services +@using Elternbeirat.Web.Shared diff --git a/Elternbeirat.Web/appsettings.Development.json b/Elternbeirat.Web/appsettings.Development.json index 0c208ae..b561eb0 100644 --- a/Elternbeirat.Web/appsettings.Development.json +++ b/Elternbeirat.Web/appsettings.Development.json @@ -4,5 +4,8 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "PocketBase": { + "BaseUrl": "http://localhost:8090" } } diff --git a/Elternbeirat.Web/appsettings.json b/Elternbeirat.Web/appsettings.json index 10f68b8..4ddd54e 100644 --- a/Elternbeirat.Web/appsettings.json +++ b/Elternbeirat.Web/appsettings.json @@ -5,5 +5,10 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "PocketBase": { + // The PocketBase service name on the compose network. compose.yaml overrides + // this via the env var PocketBase__BaseUrl; local dev uses appsettings.Development.json. + "BaseUrl": "http://eb-pocketbase:8090" + } } diff --git a/compose.yaml b/compose.yaml index f996039..b207665 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,6 +1,10 @@ -# Base compose: production-like. Both services, no host port mappings (NPM is the -# only path to the web app; the editors reach PocketBase through an NPM subdomain). -# Only PocketBase's data is a volume; everything else lives inside the images. +# Base compose: production-like. Both services; only PocketBase's data is a volume, +# everything else lives inside the images. +# +# The two `ports:` blocks below are TEMPORARY. The target state is NPM in front: +# NPM terminates TLS and is the only path to the web app, and the editors reach +# PocketBase through an NPM subdomain -- then both port mappings go away (#9). While +# NPM is not set up yet, the ports are the only way onto the site and the admin UI. # # For local development use the dev overlay on top, which adds host ports and builds # the web image from source: @@ -25,6 +29,11 @@ services: volumes: # The only state that must survive a restart or redeploy. - /mnt/user/appdata/pocketbase/pb_data:/pb_data + ports: + # TEMPORARY: exposes the admin UI on the LAN (http://:8090/_/) while + # NPM is not in front yet. Remove once the editors reach PocketBase through an + # NPM subdomain (#9) -- until then the admin has no other way in. + - "8090:8090" healthcheck: # The image ships this endpoint; the web app waits for it to pass. test: ["CMD", "wget", "--spider", "-q", "http://localhost:8090/api/health"] @@ -44,3 +53,8 @@ services: TZ: Europe/Berlin # The web app reads content from PocketBase over the compose network. PocketBase__BaseUrl: http://eb-pocketbase:8090 + ports: + # TEMPORARY: reach the site on the LAN (http://:5000) while NPM is not + # in front yet. Replace with the external npm network once NPM terminates TLS + # and is the only path in (#9). + - "5000:8080" From 4136af6417a63e7e4df1b1325d1282fbc104d2d0 Mon Sep 17 00:00:00 2001 From: tleininger Date: Wed, 23 Sep 2026 13:30:48 +0200 Subject: [PATCH 2/7] Serve pages and home from PocketBase, drop the file-based content layer --- .../PocketBaseClientTests.cs | 5 +- Elternbeirat.Web.Tests/PocketBaseFixture.cs | 45 +++++++++- Elternbeirat.Web.Tests/RouteSmokeTests.cs | 13 ++- .../Components/Layout/MainLayout.razor | 12 +-- Elternbeirat.Web/Features/Home/Home.razor | 1 - Elternbeirat.Web/Features/Home/Home.razor.cs | 28 +++++- .../Features/Pages/ContentPage.razor | 3 +- .../Features/Pages/ContentPage.razor.cs | 46 ++++++++-- Elternbeirat.Web/Features/Pages/Page.cs | 25 ------ .../Features/Pages/PageService.cs | 57 ------------ Elternbeirat.Web/Features/Posts/Post.cs | 33 ------- .../Features/Posts/PostService.cs | 86 ------------------- Elternbeirat.Web/Program.cs | 5 -- Elternbeirat.Web/Shared/FrontMatter.cs | 42 --------- Elternbeirat.Web/_Imports.razor | 1 - 15 files changed, 117 insertions(+), 285 deletions(-) delete mode 100644 Elternbeirat.Web/Features/Pages/Page.cs delete mode 100644 Elternbeirat.Web/Features/Pages/PageService.cs delete mode 100644 Elternbeirat.Web/Features/Posts/Post.cs delete mode 100644 Elternbeirat.Web/Features/Posts/PostService.cs delete mode 100644 Elternbeirat.Web/Shared/FrontMatter.cs diff --git a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs index f5b1a87..03ddc45 100644 --- a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs +++ b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs @@ -70,9 +70,10 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase) var pages = await client.GetPagesAsync(); - // Three pages seeded, one with public=false; the draft must be filtered out. - pages.Select(p => p.Slug).ShouldBe(["home", "contact"]); + // The draft (public=false) must be filtered out, the public ones present. pages.ShouldNotContain(p => p.Slug == "draft"); + pages.ShouldContain(p => p.Slug == "home"); + pages.ShouldContain(p => p.Slug == "contact"); } [Fact] diff --git a/Elternbeirat.Web.Tests/PocketBaseFixture.cs b/Elternbeirat.Web.Tests/PocketBaseFixture.cs index d6b3fe4..7953818 100644 --- a/Elternbeirat.Web.Tests/PocketBaseFixture.cs +++ b/Elternbeirat.Web.Tests/PocketBaseFixture.cs @@ -191,7 +191,7 @@ public sealed class PocketBaseFixture : IAsyncLifetime await CreateCollectionAsync(http, "pages", new object[] { new { name = "title", type = "text", required = true }, - new { name = "body", type = "editor" }, + new { name = "body", type = "text" }, new { name = "location", type = "select", maxSelect = 1, values = new[] { "header", "footer" } }, new { name = "order", type = "number" }, new { name = "slug", type = "text", required = true }, @@ -203,7 +203,7 @@ public sealed class PocketBaseFixture : IAsyncLifetime { new { name = "date", type = "date" }, new { name = "title", type = "text", required = true }, - new { name = "body", type = "editor" }, + new { name = "body", type = "text" }, new { name = "slug", type = "text", required = true }, new { name = "public", type = "bool" }, }); @@ -221,7 +221,7 @@ public sealed class PocketBaseFixture : IAsyncLifetime await CreateCollectionAsync(http, "faqs", new object[] { new { name = "question", type = "text", required = true }, - new { name = "answer", type = "editor" }, + new { name = "answer", type = "text" }, new { name = "topic", type = "select", maxSelect = 1, values = new[] { "mensa", "schliessfach", "elterneuro", "elternarbeit" } }, new { name = "public", type = "bool" }, }); @@ -242,17 +242,54 @@ public sealed class PocketBaseFixture : IAsyncLifetime private static async Task SeedRecordsAsync(HttpClient http) { - // Pages: one header page, one footer page. Both public. + // Pages that the route smoke tests reach through the navigation, the + // footer or the FAQ hub. Header pages carry a location+order; the FAQ + // sub-pages are plain content pages without a menu slot. All public. await CreateRecordAsync(http, "pages", new { title = "Startseite", body = "# Willkommen", location = "header", order = 1, slug = "home", embed = new[] { "posts", "events" }, @public = true, }); await CreateRecordAsync(http, "pages", new + { + title = "Vorstandsteam", body = "# Vorstand", location = "header", + order = 2, slug = "board", embed = Array.Empty(), @public = true, + }); + await CreateRecordAsync(http, "pages", new + { + title = "Förderverein", body = "# Förderverein", location = "header", + order = 3, slug = "patrons", embed = Array.Empty(), @public = true, + }); + await CreateRecordAsync(http, "pages", new + { + title = "FAQ", body = "# Häufige Fragen", location = "header", + order = 4, slug = "faq", embed = Array.Empty(), @public = true, + }); + await CreateRecordAsync(http, "pages", new + { + title = "Downloads", body = "# Downloads", location = "header", + order = 5, slug = "downloads", embed = Array.Empty(), @public = true, + }); + await CreateRecordAsync(http, "pages", new + { + title = "FAQ Mensa", body = "# Mensa", location = "", + order = 0, slug = "faq-mensa", embed = Array.Empty(), @public = true, + }); + await CreateRecordAsync(http, "pages", new { title = "Kontakt", body = "Mail an uns", location = "footer", order = 1, slug = "contact", embed = Array.Empty(), @public = true, }); + await CreateRecordAsync(http, "pages", new + { + title = "Impressum", body = "# Impressum", location = "footer", + order = 2, slug = "imprint", embed = Array.Empty(), @public = true, + }); + await CreateRecordAsync(http, "pages", new + { + title = "Datenschutz", body = "# Datenschutz", location = "footer", + order = 3, slug = "privacy", embed = Array.Empty(), @public = true, + }); // A draft page that must never appear (public=false). await CreateRecordAsync(http, "pages", new { diff --git a/Elternbeirat.Web.Tests/RouteSmokeTests.cs b/Elternbeirat.Web.Tests/RouteSmokeTests.cs index f69f239..5f8a73d 100644 --- a/Elternbeirat.Web.Tests/RouteSmokeTests.cs +++ b/Elternbeirat.Web.Tests/RouteSmokeTests.cs @@ -46,17 +46,14 @@ public sealed class RouteSmokeTests : IDisposable public static TheoryData KnownRoutes => [ "/", - "/vorstandsteam", - "/foerderverein", + "/board", + "/patrons", "/faq", "/faq-mensa", - "/faq-schliessfach", - "/faq-elterneuro", - "/faq-elternarbeit", "/downloads", - "/kontakt", - "/impressum", - "/datenschutz", + "/contact", + "/imprint", + "/privacy", "/posts", "/posts/new-board", "/posts/new-hall", diff --git a/Elternbeirat.Web/Components/Layout/MainLayout.razor b/Elternbeirat.Web/Components/Layout/MainLayout.razor index 6d8f5ac..56d549d 100644 --- a/Elternbeirat.Web/Components/Layout/MainLayout.razor +++ b/Elternbeirat.Web/Components/Layout/MainLayout.razor @@ -6,13 +6,13 @@ Elternbeirat IGMH @@ -25,9 +25,9 @@ diff --git a/Elternbeirat.Web/Features/Home/Home.razor b/Elternbeirat.Web/Features/Home/Home.razor index 48118f4..3cc5e19 100644 --- a/Elternbeirat.Web/Features/Home/Home.razor +++ b/Elternbeirat.Web/Features/Home/Home.razor @@ -1,5 +1,4 @@ @page "/" -@inject PostService PostService Elternbeirat der IGMH diff --git a/Elternbeirat.Web/Features/Home/Home.razor.cs b/Elternbeirat.Web/Features/Home/Home.razor.cs index 8ad1eef..dfc36f9 100644 --- a/Elternbeirat.Web/Features/Home/Home.razor.cs +++ b/Elternbeirat.Web/Features/Home/Home.razor.cs @@ -1,5 +1,7 @@ using System.Globalization; -using Elternbeirat.Web.Services; +using Elternbeirat.Contracts; +using Elternbeirat.PocketBase; +using Microsoft.AspNetCore.Components; namespace Elternbeirat.Web.Features.Home; @@ -7,11 +9,29 @@ 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.")] + 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 void OnInitialized() + protected override async Task OnInitializedAsync() { - // Show the three most recent posts as a teaser on the home page. - _latest = [.. PostService.All.Take(3)]; + try + { + // Show the three most recent posts as a teaser on the home page. + 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/Pages/ContentPage.razor b/Elternbeirat.Web/Features/Pages/ContentPage.razor index dd09e47..63adb59 100644 --- a/Elternbeirat.Web/Features/Pages/ContentPage.razor +++ b/Elternbeirat.Web/Features/Pages/ContentPage.razor @@ -1,5 +1,4 @@ @page "/{Slug}" -@inject PageService PageService @if (_page is null) { @@ -9,6 +8,6 @@ else { @_page.Title
    - @((MarkupString)_page.ContentHtml) + @Body
    } diff --git a/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs b/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs index 0d37292..ed809fc 100644 --- a/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs +++ b/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs @@ -1,4 +1,6 @@ -using Elternbeirat.Web.Services; +using Elternbeirat.Contracts; +using Elternbeirat.PocketBase; +using Elternbeirat.Web.Shared; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Http; @@ -6,23 +8,49 @@ 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); + [Parameter] public string Slug { get; set; } = ""; + [Inject] + private PocketBaseClient PocketBase { get; set; } = default!; + + [Inject] + private ILogger Logger { get; set; } = default!; + + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + private Page? _page; - protected override void OnParametersSet() - { - _page = PageService.Find(Slug); + // 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); - // Unknown slug -> 404, so UseStatusCodePagesWithReExecute serves the - // /not-found page instead of an empty 200 response. + protected override async Task OnParametersSetAsync() + { + try + { + var pages = await PocketBase.GetPagesAsync(); + _page = pages.FirstOrDefault( + p => string.Equals(p.Slug, Slug, StringComparison.OrdinalIgnoreCase)); + } + catch (HttpRequestException ex) + { + // 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; + } + + // 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) { HttpContext.Response.StatusCode = StatusCodes.Status404NotFound; } } - - [CascadingParameter] - private HttpContext? HttpContext { get; set; } } diff --git a/Elternbeirat.Web/Features/Pages/Page.cs b/Elternbeirat.Web/Features/Pages/Page.cs deleted file mode 100644 index 4b0f451..0000000 --- a/Elternbeirat.Web/Features/Pages/Page.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace Elternbeirat.Web.Services; - -/// -/// A static content page, read from a Markdown file in Content/pages/. -/// The file name (without extension) is the slug. -/// -public sealed class Page -{ - /// - /// URL identifier of the page, taken from the file name without umlauts - /// (e.g. vorstandsteam). It appears in the route /{slug}. - /// - public required string Slug { get; init; } - - /// - /// Display title from the YAML front matter (titel:). Falls back to - /// the slug when no title is set. - /// - public required string Title { get; init; } - - /// - /// The page body rendered from Markdown to HTML (front matter excluded). - /// - public required string ContentHtml { get; init; } -} diff --git a/Elternbeirat.Web/Features/Pages/PageService.cs b/Elternbeirat.Web/Features/Pages/PageService.cs deleted file mode 100644 index d191887..0000000 --- a/Elternbeirat.Web/Features/Pages/PageService.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Markdig; - -namespace Elternbeirat.Web.Services; - -/// -/// Reads the static content pages from Content/pages/*.md, renders them -/// to HTML once at startup and keeps them in memory. Registered as a singleton -/// because the content lives in the image and does not change at runtime. -/// -public sealed class PageService -{ - private readonly IReadOnlyDictionary _pagesBySlug; - - /// - /// Reads every Markdown page from the Content/pages directory below - /// the application root and caches it. - /// - public PageService(IWebHostEnvironment environment) - { - var pipeline = new MarkdownPipelineBuilder() - .UseYamlFrontMatter() - .Build(); - - var directory = Path.Combine(environment.ContentRootPath, "Content", "pages"); - var pages = new Dictionary(StringComparer.OrdinalIgnoreCase); - - if (Directory.Exists(directory)) - { - foreach (var path in Directory.EnumerateFiles(directory, "*.md")) - { - var page = Read(path, pipeline); - pages[page.Slug] = page; - } - } - - _pagesBySlug = pages; - } - - /// - /// Returns the page for the given slug, or null if there is none. - /// The lookup is case-insensitive. - /// - public Page? Find(string slug) => - _pagesBySlug.GetValueOrDefault(slug); - - private static Page Read(string path, MarkdownPipeline pipeline) - { - var slug = Path.GetFileNameWithoutExtension(path); - var source = File.ReadAllText(path); - - var document = Markdown.Parse(source, pipeline); - var title = FrontMatter.Read("title", document, source) ?? slug; - var html = Markdown.ToHtml(source, pipeline); - - return new Page { Slug = slug, Title = title, ContentHtml = html }; - } -} diff --git a/Elternbeirat.Web/Features/Posts/Post.cs b/Elternbeirat.Web/Features/Posts/Post.cs deleted file mode 100644 index 8631cd4..0000000 --- a/Elternbeirat.Web/Features/Posts/Post.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace Elternbeirat.Web.Services; - -/// -/// A news post, read from a Markdown file in Content/posts/. Unlike a -/// , a post has a date and -/// is shown in a chronological list. The file name (without extension) is the -/// slug. -/// -public sealed class Post -{ - /// - /// URL identifier of the post, taken from the file name without umlauts. - /// It appears in the route /beitraege/{slug}. - /// - public required string Slug { get; init; } - - /// - /// Display title from the YAML front matter (titel:). Falls back to - /// the slug when no title is set. - /// - public required string Title { get; init; } - - /// - /// Publication date from the front matter (datum:). Used to sort the - /// list newest first. - /// - public required DateOnly Date { get; init; } - - /// - /// The post body rendered from Markdown to HTML (front matter excluded). - /// - public required string ContentHtml { get; init; } -} diff --git a/Elternbeirat.Web/Features/Posts/PostService.cs b/Elternbeirat.Web/Features/Posts/PostService.cs deleted file mode 100644 index 996d8da..0000000 --- a/Elternbeirat.Web/Features/Posts/PostService.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System.Globalization; -using Markdig; - -namespace Elternbeirat.Web.Services; - -/// -/// Reads the news posts from Content/posts/*.md, renders them to HTML -/// once at startup and keeps them in memory, sorted newest first. Registered as -/// a singleton because the content lives in the image and does not change at -/// runtime. -/// -public sealed class PostService -{ - private readonly IReadOnlyList _posts; - private readonly IReadOnlyDictionary _postsBySlug; - - /// - /// Reads every Markdown post from the Content/posts directory - /// below the application root and caches it. - /// - public PostService(IWebHostEnvironment environment) - { - var pipeline = new MarkdownPipelineBuilder() - .UseYamlFrontMatter() - .Build(); - - var directory = Path.Combine(environment.ContentRootPath, "Content", "posts"); - var posts = new List(); - - if (Directory.Exists(directory)) - { - foreach (var path in Directory.EnumerateFiles(directory, "*.md")) - { - posts.Add(Read(path, pipeline)); - } - } - - // Newest first for the list; a stable slug order breaks date ties. - posts.Sort((a, b) => - { - var byDate = b.Date.CompareTo(a.Date); - return byDate != 0 ? byDate : string.CompareOrdinal(a.Slug, b.Slug); - }); - - _posts = posts; - _postsBySlug = posts.ToDictionary(p => p.Slug, StringComparer.OrdinalIgnoreCase); - } - - /// - /// All posts, newest first. Used for the overview list. - /// - public IReadOnlyList All => _posts; - - /// - /// Returns the post for the given slug, or null if there is none. - /// The lookup is case-insensitive. - /// - public Post? Find(string slug) => - _postsBySlug.GetValueOrDefault(slug); - - private static Post Read(string path, MarkdownPipeline pipeline) - { - var slug = Path.GetFileNameWithoutExtension(path); - var source = File.ReadAllText(path); - - var document = Markdown.Parse(source, pipeline); - var title = FrontMatter.Read("title", document, source) ?? slug; - var date = ParseDate(FrontMatter.Read("date", document, source)); - var html = Markdown.ToHtml(source, pipeline); - - return new Post { Slug = slug, Title = title, Date = date, ContentHtml = html }; - } - - /// - /// Parses the datum: value as an ISO date (yyyy-MM-dd). Falls back to - /// when the value is missing or malformed, - /// so a single bad file does not crash startup — it just sorts last. - /// - private static DateOnly ParseDate(string? value) - { - return DateOnly.TryParse(value, CultureInfo.InvariantCulture, - DateTimeStyles.None, out var date) - ? date - : DateOnly.MinValue; - } -} diff --git a/Elternbeirat.Web/Program.cs b/Elternbeirat.Web/Program.cs index 3ea5781..49ac70e 100644 --- a/Elternbeirat.Web/Program.cs +++ b/Elternbeirat.Web/Program.cs @@ -2,7 +2,6 @@ using Elternbeirat.Contracts; using Elternbeirat.PocketBase; using Elternbeirat.Web.Components; using Elternbeirat.Web.Features.Events; -using Elternbeirat.Web.Services; using Microsoft.AspNetCore.HttpOverrides; var builder = WebApplication.CreateBuilder(args); @@ -18,10 +17,6 @@ var pocketBaseUrl = builder.Configuration["PocketBase:BaseUrl"] builder.Services.AddHttpClient(client => client.BaseAddress = new Uri(pocketBaseUrl)); -// Content is read once at startup and cached -> singletons. -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - var app = builder.Build(); // NPM terminates TLS and is the only way to reach the container (no port diff --git a/Elternbeirat.Web/Shared/FrontMatter.cs b/Elternbeirat.Web/Shared/FrontMatter.cs deleted file mode 100644 index 950ddab..0000000 --- a/Elternbeirat.Web/Shared/FrontMatter.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Markdig; -using Markdig.Extensions.Yaml; -using Markdig.Syntax; - -namespace Elternbeirat.Web.Services; - -/// -/// Reads simple key: value pairs from a Markdown file's YAML front -/// matter. Kept deliberately minimal (one value per line, no nesting) — this is -/// enough for titles and dates. YamlDotNet will take over once we need -/// structured metadata. Front-matter keys stay German because the Markdown -/// files are edited by German-speaking editors. -/// -public static class FrontMatter -{ - /// - /// Returns the value of the given front-matter key, or null if the - /// file has no front matter or the key is missing. The key match is - /// case-insensitive; surrounding quotes on the value are removed. - /// - public static string? Read(string key, MarkdownDocument document, string source) - { - var block = document.Descendants().FirstOrDefault(); - if (block is null) - { - return null; - } - - var prefix = key + ":"; - var frontMatter = source.Substring(block.Span.Start, block.Span.Length); - foreach (var line in frontMatter.Split('\n')) - { - var trimmed = line.Trim(); - if (trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - { - return trimmed[prefix.Length..].Trim().Trim('"'); - } - } - - return null; - } -} diff --git a/Elternbeirat.Web/_Imports.razor b/Elternbeirat.Web/_Imports.razor index ac14ee8..cf7ad57 100644 --- a/Elternbeirat.Web/_Imports.razor +++ b/Elternbeirat.Web/_Imports.razor @@ -7,5 +7,4 @@ @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop @using Elternbeirat.Web -@using Elternbeirat.Web.Services @using Elternbeirat.Web.Shared From 6b5d9dda55e8a3abc24e93caad40db12c2684cd4 Mon Sep 17 00:00:00 2001 From: tleininger Date: Wed, 23 Sep 2026 14:15:16 +0200 Subject: [PATCH 3/7] Serve FAQs from PocketBase on a grouped /faqs page --- .run/DevEnvironment.run.xml | 13 +++-- Elternbeirat.Web.Tests/PocketBaseFixture.cs | 4 +- Elternbeirat.Web.Tests/RouteSmokeTests.cs | 17 ++++++- .../Components/Layout/MainLayout.razor | 2 +- Elternbeirat.Web/Features/Faqs/FaqList.razor | 28 +++++++++++ .../Features/Faqs/FaqList.razor.cs | 48 +++++++++++++++++++ .../Features/Faqs/FaqList.razor.css | 22 +++++++++ 7 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 Elternbeirat.Web/Features/Faqs/FaqList.razor create mode 100644 Elternbeirat.Web/Features/Faqs/FaqList.razor.cs create mode 100644 Elternbeirat.Web/Features/Faqs/FaqList.razor.css diff --git a/.run/DevEnvironment.run.xml b/.run/DevEnvironment.run.xml index 713b9be..71551fb 100644 --- a/.run/DevEnvironment.run.xml +++ b/.run/DevEnvironment.run.xml @@ -7,10 +7,15 @@ - - - +
  • - + @ev.Title @if (!string.IsNullOrWhiteSpace(ev.Location)) { @@ -44,7 +44,7 @@ else @foreach (var ev in _past) {
  • - + @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 - -
    • - } -
    -

    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 From 47d984153229077a338189f2afe79153acb29a8f Mon Sep 17 00:00:00 2001 From: tleininger Date: Wed, 23 Sep 2026 15:30:53 +0200 Subject: [PATCH 5/7] Remove the leftover file content layer and align the docs --- CLAUDE.md | 63 +++++++++++------- Directory.Packages.props | 1 - Elternbeirat.Web/Content/events.yml | 26 -------- Elternbeirat.Web/Content/pages/datenschutz.md | 22 ------- Elternbeirat.Web/Content/pages/downloads.md | 17 ----- .../Content/pages/faq-elternarbeit.md | 20 ------ .../Content/pages/faq-elterneuro.md | 22 ------- Elternbeirat.Web/Content/pages/faq-mensa.md | 21 ------ .../Content/pages/faq-schliessfach.md | 21 ------ Elternbeirat.Web/Content/pages/faq.md | 15 ----- .../Content/pages/foerderverein.md | 19 ------ Elternbeirat.Web/Content/pages/impressum.md | 24 ------- Elternbeirat.Web/Content/pages/kontakt.md | 16 ----- .../Content/pages/vorstandsteam.md | 18 ------ .../posts/neue-sporthalle-eroeffnet.md | 11 ---- .../Content/posts/neuer-vorstand-gewaehlt.md | 12 ---- Elternbeirat.Web/Elternbeirat.Web.csproj | 7 -- docs/architektur.md | 64 ++++++++++++------- docs/entwicklung.md | 29 +++++++++ docs/recht.md | 7 +- docs/redaktion.md | 46 +++++++++++-- 21 files changed, 154 insertions(+), 327 deletions(-) delete mode 100644 Elternbeirat.Web/Content/events.yml delete mode 100644 Elternbeirat.Web/Content/pages/datenschutz.md delete mode 100644 Elternbeirat.Web/Content/pages/downloads.md delete mode 100644 Elternbeirat.Web/Content/pages/faq-elternarbeit.md delete mode 100644 Elternbeirat.Web/Content/pages/faq-elterneuro.md delete mode 100644 Elternbeirat.Web/Content/pages/faq-mensa.md delete mode 100644 Elternbeirat.Web/Content/pages/faq-schliessfach.md delete mode 100644 Elternbeirat.Web/Content/pages/faq.md delete mode 100644 Elternbeirat.Web/Content/pages/foerderverein.md delete mode 100644 Elternbeirat.Web/Content/pages/impressum.md delete mode 100644 Elternbeirat.Web/Content/pages/kontakt.md delete mode 100644 Elternbeirat.Web/Content/pages/vorstandsteam.md delete mode 100644 Elternbeirat.Web/Content/posts/neue-sporthalle-eroeffnet.md delete mode 100644 Elternbeirat.Web/Content/posts/neuer-vorstand-gewaehlt.md diff --git a/CLAUDE.md b/CLAUDE.md index 570d8d8..be11c9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,8 @@ Website des Elternbeirats der IGMH. Blazor Web App mit statischem Server-Side-Rendering, .NET 10. Läuft als Container auf Unraid hinter dem Nginx -Proxy Manager. +Proxy Manager. Die Inhalte liegen in **PocketBase** (zweiter Container); die App +liest sie pro Request über einen typisierten `PocketBaseClient`. Architekturentscheidungen und deren Begründung: `docs/architektur.md`. Dort nachlesen, bevor eine davon in Frage gestellt wird. @@ -11,7 +12,7 @@ nachlesen, bevor eine davon in Frage gestellt wird. | Thema | Datei | |---|---| -| Warum SSR statt WASM, warum keine DB (AE-1 bis AE-4) | `docs/architektur.md` | +| Warum SSR statt WASM, warum PocketBase als Datenschicht (AE-1 bis AE-4) | `docs/architektur.md` | | Stack lokal starten, Tests, compose-Overlays | `docs/entwicklung.md` | | Unraid, compose, NPM-Proxy-Host, Registry, Rollback | `docs/deployment.md` | | Inhalte anlegen und ändern (PocketBase-Admin) | `docs/redaktion.md` | @@ -26,28 +27,36 @@ Sub-Issues als Checkliste im Body, alle am Milestone „Elternbeirat-Website". ## Kommandos ```bash -dotnet run --project Elternbeirat.Web # lokal; Inhalte werden beim Start gelesen (kein Auto-Reload) -dotnet test # Smoke-Tests: jede Route liefert 200 -docker compose build && docker compose up -d +# Lokaler Stack (App + PocketBase) über das Dev-Overlay, siehe docs/entwicklung.md: +docker compose -f compose.yaml -f compose.dev.yaml up --build # App :5000, PocketBase-Admin :8090/_/ +dotnet test # Smoke-Tests (starten eigene PocketBase) + Unit-Tests ``` -Es gibt keine Datenbank, keine Migrationen, kein Seeding. Ein `git clone` ist der -vollständige Stand. +Die App liest ihren Inhalt pro Request aus PocketBase; sie hält selbst keinen +Inhalt und liest nichts beim Start ein. Kein EF Core, keine Migrationen, kein +Seeding im Code. Ein `git clone` ist **nicht** der vollständige Stand — der +Inhalt liegt in PocketBase (`pb_data`), das separat gesichert wird (siehe +`docs/deployment.md`). ## Nicht offensichtlich -- **Inhalte liegen im Image**, nicht in einem Volume. Jede Textänderung braucht - Commit und Rebuild. Das ist Absicht (`docs/architektur.md`, AE-3) — nicht - „vereinfachen". +- **Inhalte liegen in PocketBase** (`pb_data`-Volume), nicht im Image. Eine + Textänderung ist ein Eintrag im PocketBase-Admin und sofort live — kein Commit, + kein Rebuild (`docs/architektur.md`, AE-2/AE-3). Nur die statischen Assets unter + `wwwroot/` (CSS, Fonts, PDFs) sind ins Image gebacken. - **Kein `UseHttpsRedirection()`, kein `UseHsts()`.** NPM terminiert TLS; beides erzeugt hinter dem Proxy eine Redirect-Schleife. `UseForwardedHeaders` mit geleerten `KnownNetworks`/`KnownProxies` ist korrekt so, weil der Container kein Port-Mapping hat und nur über NPM erreichbar ist. - **Das chiseled-Runtime-Image hat keine Shell.** `HEALTHCHECK` mit `curl` oder `sh` schlägt dort fehl. -- Neue Seite = Markdown in `Content/pages/`. Neuer Beitrag = Markdown in - `Content/posts/`. Neuer Termin = Eintrag in `Content/events.yml`. In allen - Fällen wird **kein** `.razor` angefasst. +- Neue Seite / neuer Beitrag / neuer Termin / neue Frage = **Record in der + passenden PocketBase-Collection** (`pages` / `posts` / `events` / `faqs`), nicht + eine Datei und **kein** `.razor`. `pages` mit `slug` ist sofort unter `/` + erreichbar; `location`+`order` steuern das Menü, `embed` bettet Listen ein + (`docs/redaktion.md`). Eine **neue Art von Liste** (eigene Route/Logik) ist + dagegen Code: neue Collection + `PocketBaseClient`-Methode + Komponente + (`docs/entwicklung.md`). - Slugs ohne Umlaute (`ueber-uns`, nicht `über-uns`). ## Regeln @@ -57,22 +66,28 @@ vollständige Stand. - **Keine externen Ressourcen.** Keine CDN-Skripte, keine Google Fonts, keine Maps- oder Video-Embeds. Schriften werden selbst ausgeliefert. Grund ist Datenschutz, nicht Geschmack (`docs/recht.md`). -- **Keine Datenbank, kein EF Core, kein ORM.** Inhalte sind Dateien. -- **Inhalte gehören nach `Content/`**, nicht als Markup in Komponenten. -- Kein CMS, kein Login, keine Benutzerkonten. +- **Kein EF Core, kein ORM, keine Migrationen.** Inhalte kommen aus PocketBase + über den `PocketBaseClient` (REST), nicht aus einer relationalen DB im Code. +- **Inhalte gehören nach PocketBase**, nicht als Markup in Komponenten und nicht + in Dateien im Repo. +- Kein eigenes CMS, kein Login und keine Benutzerkonten **in der Website** — + gepflegt wird ausschließlich im PocketBase-Admin (Redakteure sind Superuser). - UI-Texte auf Deutsch, ohne Anglizismen. `Termine`, nicht `Events`. -- Neue NuGet-Abhängigkeiten vorher begründen. Bestand: `Markdig`, `YamlDotNet`. +- Neue NuGet-Abhängigkeiten vorher begründen. Genutzt wird nur `Markdig` + (Markdown → HTML). `YamlDotNet` stammt aus der abgelösten Datei-Schicht, wird + nicht mehr verwendet und soll aus dem `csproj` entfernt werden. ## Stil - Nullable aktiviert, `ImplicitUsings` an, File-scoped Namespaces. -- Services über DI, als Singleton registriert (Inhalte werden beim Start - eingelesen und gecacht). -- Öffentliche Typen und Methoden der `Services` bekommen XML-Doc (auf Englisch, - leicht verständlich), Razor-Markup nicht. +- 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//`. +- Öffentliche Typen und Methoden im `PocketBaseClient` und in `Contracts` + bekommen XML-Doc (auf Englisch, leicht verständlich), Razor-Markup nicht. - **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, Markdown-Inhalte, - Frontmatter-Schlüssel wie `titel:` sowie Slugs/Dateinamen unter `Content/` - (z. B. `vorstandsteam`, `/termine`). + 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`). diff --git a/Directory.Packages.props b/Directory.Packages.props index 3dbf02a..35b56c0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,7 +9,6 @@ - diff --git a/Elternbeirat.Web/Content/events.yml b/Elternbeirat.Web/Content/events.yml deleted file mode 100644 index 5d8f418..0000000 --- a/Elternbeirat.Web/Content/events.yml +++ /dev/null @@ -1,26 +0,0 @@ -# Calendar entries of the Elternbeirat. -# -# One entry per event. Required keys: title, start. -# start/end: -# date only -> "2026-03-15" (all-day) -# date with time -> "2026-03-15 19:30" -# location and note are optional. - -- title: Elternbeiratssitzung - start: "2026-10-08 19:30" - location: Lehrerzimmer - note: Themen bitte vorab per E-Mail einreichen. - -- title: Herbstbasar - start: "2026-11-14 10:00" - end: "2026-11-14 16:00" - location: Aula - -- title: Weihnachtsferien - start: "2026-12-21" - end: "2027-01-06" - -- title: Elternsprechtag - start: "2026-09-05 15:00" - end: "2026-09-05 18:00" - location: Klassenräume diff --git a/Elternbeirat.Web/Content/pages/datenschutz.md b/Elternbeirat.Web/Content/pages/datenschutz.md deleted file mode 100644 index 813951e..0000000 --- a/Elternbeirat.Web/Content/pages/datenschutz.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: Datenschutz ---- - -# Datenschutzerklärung - -*TODO: Rechtlich verbindliche Datenschutzerklärung ergänzen. Erst nach Freigabe -finalisieren – siehe `docs/recht.md`.* - -Diese Website wird bewusst ohne externe Ressourcen betrieben: keine CDN-Skripte, -keine Google Fonts, keine Karten- oder Video-Einbettungen. Schriften werden von -unserem eigenen Server ausgeliefert. Dadurch werden beim Besuch keine Daten an -Dritte übertragen. - -## Verantwortliche Stelle - -*TODO: siehe [Impressum](/impressum).* - -## Server-Logdaten - -*TODO: beschreiben, welche Zugriffsdaten der Server (bzw. der vorgelagerte -Proxy) protokolliert und wie lange.* diff --git a/Elternbeirat.Web/Content/pages/downloads.md b/Elternbeirat.Web/Content/pages/downloads.md deleted file mode 100644 index d4ed098..0000000 --- a/Elternbeirat.Web/Content/pages/downloads.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: Downloads ---- - -# Downloads - -Hier stellen wir Unterlagen zur Elternarbeit als Datei bereit: Protokolle der -Sitzungen, Elterninformationen und die Geschäftsordnung. - -## Unterlagen - -*TODO: Download-Liste ergänzen. Dateien liegen unter `wwwroot/downloads/` und -werden hier verlinkt, z. B.:* - -- *Protokoll der Vollversammlung (PDF)* -- *Geschäftsordnung des Elternbeirats (PDF)* -- *Elterninformation Elternvertreter (PDF)* diff --git a/Elternbeirat.Web/Content/pages/faq-elternarbeit.md b/Elternbeirat.Web/Content/pages/faq-elternarbeit.md deleted file mode 100644 index e26735b..0000000 --- a/Elternbeirat.Web/Content/pages/faq-elternarbeit.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: FAQ Elternarbeit ---- - -# Elternarbeit - -Unterstützung für neu gewählte und erfahrene Elternvertreterinnen und -Elternvertreter. - -## Welche Aufgaben habe ich als Elternvertreter? - -*TODO: Aufgaben und Rechte der Klassenelternvertretung beschreiben.* - -## Wie läuft die Zusammenarbeit mit dem Elternbeirat? - -*TODO: Sitzungsrhythmus und Ansprechpartner ergänzen.* - -## Wo finde ich Vorlagen und Unterlagen? - -Unterlagen und Protokolle finden Sie im Bereich [Downloads](/downloads). diff --git a/Elternbeirat.Web/Content/pages/faq-elterneuro.md b/Elternbeirat.Web/Content/pages/faq-elterneuro.md deleted file mode 100644 index 3465b3d..0000000 --- a/Elternbeirat.Web/Content/pages/faq-elterneuro.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: FAQ Elterneuro ---- - -# Elterneuro - -Der Elterneuro ist ein freiwilliger Beitrag der Eltern, mit dem der Elternbeirat -Projekte an der Schule unterstützt. - -## Wofür wird der Elterneuro verwendet? - -*TODO: konkrete Beispiele für die Verwendung ergänzen.* - -## Wie hoch ist der Beitrag? - -Der Elterneuro ist freiwillig. - -*TODO: übliche Beitragshöhe und Zahlungsweg ergänzen.* - -## An wen kann ich mich bei Fragen wenden? - -Bei Fragen erreichen Sie uns über die [Kontaktseite](/kontakt). diff --git a/Elternbeirat.Web/Content/pages/faq-mensa.md b/Elternbeirat.Web/Content/pages/faq-mensa.md deleted file mode 100644 index 3d5f91c..0000000 --- a/Elternbeirat.Web/Content/pages/faq-mensa.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -title: FAQ Mensa ---- - -# Mensa - -Alles rund um das Mittagessen an der IGMH: Anmeldung, Guthaben und Fristen. - -## Wie melde ich mein Kind an? - -Die Essensbestellung läuft über das System i-NET Menue. - -*TODO: Ablauf der Erstanmeldung und Zugangsdaten beschreiben.* - -## Wie lade ich Guthaben auf? - -*TODO: Aufladeweg und Zahlungsarten ergänzen.* - -## Bis wann kann ich bestellen oder stornieren? - -*TODO: Fristen für Bestellung und Stornierung ergänzen.* diff --git a/Elternbeirat.Web/Content/pages/faq-schliessfach.md b/Elternbeirat.Web/Content/pages/faq-schliessfach.md deleted file mode 100644 index 7ab707d..0000000 --- a/Elternbeirat.Web/Content/pages/faq-schliessfach.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -title: FAQ Schließfach ---- - -# Schließfächer - -Informationen zur Anmietung eines Schließfachs an der IGMH. - -## Welche Größen gibt es und was kosten sie? - -*TODO: Verfügbare Größen und Preise ergänzen.* - -## Wie miete ich ein Schließfach an? - -Die Verwaltung läuft über das Serviceportal von AstraDirect. - -*TODO: Ablauf der Anmietung und Link zum Portal ergänzen.* - -## Wie tausche oder kündige ich mein Fach? - -*TODO: Vorgehen für Fachtausch und Kündigung ergänzen.* diff --git a/Elternbeirat.Web/Content/pages/faq.md b/Elternbeirat.Web/Content/pages/faq.md deleted file mode 100644 index c88fabf..0000000 --- a/Elternbeirat.Web/Content/pages/faq.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -title: FAQ ---- - -# Häufige Fragen - -Hier finden Eltern Antworten auf wiederkehrende Fragen rund um den Schulalltag. -Die Themen sind nach Bereichen aufgeteilt: - -- [Mensa](/faq-mensa) – Anmeldung, Guthaben, Fristen -- [Schließfächer](/faq-schliessfach) – Größen, Preise, Verwaltung -- [Elterneuro](/faq-elterneuro) – freiwilliger Beitrag und Verwendung -- [Elternarbeit](/faq-elternarbeit) – Tipps für Elternvertreterinnen und Elternvertreter - -*TODO: Reihenfolge und weitere Themen ergänzen, sobald die Unterseiten stehen.* diff --git a/Elternbeirat.Web/Content/pages/foerderverein.md b/Elternbeirat.Web/Content/pages/foerderverein.md deleted file mode 100644 index ecb6bfe..0000000 --- a/Elternbeirat.Web/Content/pages/foerderverein.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -title: Förderverein ---- - -# Förderverein „Freunde der IGMH" - -Der Förderverein „Freunde der IGMH" unterstützt die Schule bei Anschaffungen und -Projekten, die aus dem regulären Budget nicht finanziert werden können. Mitglieder -sind Eltern, Lehrkräfte, Ehemalige und Förderer der Schule. - -## Was der Verein fördert - -- Ausstattung für Unterricht und Arbeitsgemeinschaften -- Musische, sportliche und kulturelle Projekte -- Anschaffungen, die allen Schülerinnen und Schülern zugutekommen - -## Mitglied werden - -*TODO: Beitrittsformular bzw. Ansprechpartner und Beitragshöhe ergänzen.* diff --git a/Elternbeirat.Web/Content/pages/impressum.md b/Elternbeirat.Web/Content/pages/impressum.md deleted file mode 100644 index 3de2511..0000000 --- a/Elternbeirat.Web/Content/pages/impressum.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -title: Impressum ---- - -# Impressum - -*TODO: Rechtlich verbindliches Impressum ergänzen. Erst nach Freigabe mit echten -Daten füllen – siehe `docs/recht.md`.* - -## Angaben gemäß § 5 DDG - -*TODO: Name und Anschrift des Diensteanbieters (Elternbeirat / Schule).* - -## Vertreten durch - -*TODO: gesetzlicher Vertreter.* - -## Kontakt - -*TODO: E-Mail-Adresse (siehe [Kontakt](/kontakt)).* - -## Verantwortlich i. S. d. § 18 Abs. 2 MStV - -*TODO: Name und Anschrift der verantwortlichen Person.* diff --git a/Elternbeirat.Web/Content/pages/kontakt.md b/Elternbeirat.Web/Content/pages/kontakt.md deleted file mode 100644 index 5e2ccfb..0000000 --- a/Elternbeirat.Web/Content/pages/kontakt.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -title: Kontakt ---- - -# Kontakt - -Sie erreichen den Elternbeirat der IGMH per E-Mail: - -[TODO-adresse@example.org](mailto:TODO-adresse@example.org) - -*TODO: Echte Kontakt-E-Mail-Adresse eintragen.* - -## Anschrift - -Elternbeirat der IGMH -*TODO: Anschrift der Schule ergänzen.* diff --git a/Elternbeirat.Web/Content/pages/vorstandsteam.md b/Elternbeirat.Web/Content/pages/vorstandsteam.md deleted file mode 100644 index df13a9d..0000000 --- a/Elternbeirat.Web/Content/pages/vorstandsteam.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: Vorstandsteam ---- - -# Das Vorstandsteam - -Der Elternbeirat der IGMH wird von einem ehrenamtlichen Vorstand geleitet. -Er vertritt die Elternschaft gegenüber Schule und Schulträger und koordiniert -die Arbeit der Klassenelternvertreter. - -## Aufgaben des Vorstands - -- Vertretung der Eltern in der Schulkonferenz -- Zusammenarbeit mit Schulleitung und Kollegium -- Organisation der Vollversammlungen und Sitzungen -- Ansprechpartner für Fragen rund um Mensa, Schließfächer und Förderverein - -*Die namentliche Vorstellung der Vorstandsmitglieder folgt.* diff --git a/Elternbeirat.Web/Content/posts/neue-sporthalle-eroeffnet.md b/Elternbeirat.Web/Content/posts/neue-sporthalle-eroeffnet.md deleted file mode 100644 index f59270e..0000000 --- a/Elternbeirat.Web/Content/posts/neue-sporthalle-eroeffnet.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: Neue Sporthalle feierlich eröffnet -date: 2025-10-05 ---- - -# Neue Sporthalle feierlich eröffnet - -Die neue Sporthalle der IGMH ist eröffnet. Schülerinnen, Schüler und Lehrkräfte -haben damit deutlich mehr Platz für Sportunterricht und Arbeitsgemeinschaften. - -*TODO: Bericht und Fotos ergänzen.* diff --git a/Elternbeirat.Web/Content/posts/neuer-vorstand-gewaehlt.md b/Elternbeirat.Web/Content/posts/neuer-vorstand-gewaehlt.md deleted file mode 100644 index 8cb545c..0000000 --- a/Elternbeirat.Web/Content/posts/neuer-vorstand-gewaehlt.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: Neuer Vorstand des Elternbeirats gewählt -date: 2025-02-26 ---- - -# Neuer Vorstand des Elternbeirats gewählt - -Bei der Vollversammlung hat der Elternbeirat der IGMH einen neuen Vorstand -gewählt. Das Team bedankt sich für das entgegengebrachte Vertrauen und freut -sich auf die gemeinsame Arbeit im neuen Schuljahr. - -*TODO: Namen und Ämter des neuen Vorstands ergänzen (nach Freigabe).* diff --git a/Elternbeirat.Web/Elternbeirat.Web.csproj b/Elternbeirat.Web/Elternbeirat.Web.csproj index 70c00f9..a245ec5 100644 --- a/Elternbeirat.Web/Elternbeirat.Web.csproj +++ b/Elternbeirat.Web/Elternbeirat.Web.csproj @@ -19,7 +19,6 @@ - - - - - diff --git a/docs/architektur.md b/docs/architektur.md index a6f481c..f16b13a 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -2,12 +2,13 @@ Why the site is built the way it is. Read this before questioning one of these decisions — each records the reasoning, not just the choice. Operational how-to -lives in `docs/deployment.md`; open work lives in `tasks/`. +lives in `docs/deployment.md`; open work lives in the Gitea issues (milestone +„Elternbeirat-Website"). -> **Note on scope.** These decisions describe stage 1: a static information site -> with content as files. The move to editor-based content maintenance -> (`tasks/001-redaktion-ohne-entwickler.md`) deliberately revisits AE-2 and AE-3 — -> when that lands, update this file. +> **Note on scope.** The site started as a static information site with content +> as files (stage 1). Editorial maintenance without a developer then replaced +> that file layer with PocketBase, which revised **AE-2** and **AE-3** below — +> each now records both the original decision and why it changed. ## AE-1: Blazor with static server-side rendering, not WebAssembly @@ -24,30 +25,49 @@ costs around 60 MB of RAM in the container. (`@rendermode InteractiveServer` on exactly that one component), without changing the architecture. Never global. -## AE-2: Content as files in the repo, not in a database +## AE-2: Content in PocketBase, read per request (revised) -**Decision:** Page content as Markdown with YAML frontmatter, events as structured -YAML (`Content/events.yml`), documents as PDF under `wwwroot`. +**Decision:** All visible content lives in **PocketBase** (collections `pages`, +`posts`, `events`, `faqs`); the app reads it over the REST API per request via a +typed `PocketBaseClient`. Documents remain PDFs under `wwwroot`. -**Rationale:** No DB backup, no migration schema, no consistency problems between -files and database. Changes are commits and are therefore traceable and reversible. -For a site with ~10 subpages and a few events per year, anything else is overhead. +**Original decision (stage 1):** Content as files in the repo — Markdown with YAML +frontmatter, events as `Content/events.yml`. The rationale then was: no DB backup, +no migration schema, no file/DB drift, and "backup = `git clone`". For a handful of +pages that was the leanest option. -**Consequence:** Text changes require a commit and a redeploy. With an expected five -changes a year that is acceptable — and the reason the editor expansion -(`tasks/001`) is treated as a separate stage. +**Why it changed:** The site needs to be maintainable by the board **without a +developer and without a redeploy**. Files meant every text fix was a commit and a +rebuild. PocketBase keeps the "small, self-hostable, one binary" spirit while +letting an editor change content live in its admin UI. It has **no** migration +schema of the EF/ORM kind — the collections are its data — so the objection that +weighed against "a database" in stage 1 does not apply here. -## AE-3: Content is baked into the image, not mounted as a volume +**Consequence:** Editing is now a task in the PocketBase admin, not a commit (see +`redaktion.md`). The app holds no content of its own; if PocketBase is unreachable a +page degrades to empty rather than failing. "Backup = `git clone`" no longer covers +the content — the backup is now `pb_data` (see AE-3 and `deployment.md`). -**Decision:** `Content/` and `wwwroot/` are part of the image. +## AE-3: Content lives in PocketBase's `pb_data`, not baked into the image (revised) -**Rationale:** Mounted content from the appdata share could be edited directly on the -NAS, but that is exactly when the repo and the live state drift apart — and the -"backup = `git clone`" property from AE-2 would be worthless. +**Decision:** The editable content lives in PocketBase's `pb_data`, a persistent +volume separate from the app image. Only the static assets under `wwwroot/` (CSS, +fonts, PDFs) are baked into the image. -**Consequence:** No quick fix on the live system. Typos are corrected properly via a -commit. Note: the content is read once at startup and cached — there is **no** -`FileSystemWatcher` (a change needs a restart / `dotnet watch`). +**Original decision (stage 1):** `Content/` and `wwwroot/` were both baked into the +image, deliberately **not** a mounted volume, so the live state could never drift +from the repo and "backup = `git clone`" held. + +**Why it changed:** Editing content live (AE-2) is only possible if that content +survives a redeploy — so it must be a volume, not part of the image. The drift +concern is answered differently now: the content simply has one home (PocketBase), +and the repo no longer claims to hold it. The app image stays stateless and can be +rebuilt and redeployed at any time without touching the data. + +**Consequence:** The backup is a copy of `pb_data` (plus a restore test), not a +`git clone` (set up in issue #9, see `deployment.md`). The app reads content per +request over HTTP, so there is nothing cached at startup and no `FileSystemWatcher` +— an editorial change is live immediately, no restart needed. ## AE-4: TLS and certificates exclusively in the Nginx Proxy Manager diff --git a/docs/entwicklung.md b/docs/entwicklung.md index ac2eab2..f4783db 100644 --- a/docs/entwicklung.md +++ b/docs/entwicklung.md @@ -100,3 +100,32 @@ The app reads its content from PocketBase over the compose network inside the compose network, independent of the container name). Editing content means editing records in the PocketBase admin UI at , not editing files. See `redaktion.md`. + +--- + +## Adding a new content type + +Editors can add **records** to the existing collections and add plain text +`pages`, but a **new kind of list** — its own collection rendered on its own +route with its own sorting or grouping — is a development task, not editorial. +It takes three steps, all small because the plumbing is shared: + +1. **Collection + DTO.** Create the collection in the PocketBase admin (give it a + `public` bool and open List/View rules, like the others), then add a matching + record type in `Elternbeirat.Contracts` with `[JsonPropertyName(...)]` on each + field — mirror `Page`/`Post`. +2. **Client method.** Add one method to `PocketBaseClient`. The shared + `GetRecordsAsync(collection, sort, ct)` already does the `public=true` + filter, the sort and the deserialization, so a new type is a one-liner: + `public Task> GetMinutesAsync(CancellationToken ct = default)` + `=> GetRecordsAsync("minutes", "-date", ct);` +3. **Feature component.** Add a component under `Features//` with its own + `@page "/…"` route that injects `PocketBaseClient`, calls the new method and + renders the result. Literal routes win over the `/{Slug}` catch-all, so pick a + slug that no `pages` record needs. Follow an existing list (`PostList`, + `EventList`, `FaqList`) for the load-in-`OnInitializedAsync`, log-and-degrade + pattern, and add a route smoke test plus a fixture seed. + +Text pages need none of this — they are all served generically by +`ContentPage` (`@page "/{Slug}"`), which is why a new `pages` record is live +without a rebuild. diff --git a/docs/recht.md b/docs/recht.md index bc0efac..784f5fe 100644 --- a/docs/recht.md +++ b/docs/recht.md @@ -1,9 +1,10 @@ # Legal — imprint, privacy, photos Not legal advice — but the points where school sites regularly get flagged. The -actual imprint and privacy texts live in `Content/pages/impressum.md` and -`Content/pages/datenschutz.md`; filling them in with released, real data is tracked -in `tasks/003-echte-inhalte-vor-go-live.md`. +actual imprint and privacy texts are `pages` records in PocketBase (slugs +`imprint` and `privacy`), edited in the admin UI — see `redaktion.md`. Filling +them in with released, real data before go-live is tracked as a Gitea issue +(milestone „Elternbeirat-Website"). ## Imprint (§ 5 DDG) diff --git a/docs/redaktion.md b/docs/redaktion.md index 655c8f9..13d09f3 100644 --- a/docs/redaktion.md +++ b/docs/redaktion.md @@ -5,11 +5,9 @@ Wie die sichtbaren Inhalte der Website gepflegt werden. Der Inhalt liegt in über die Weboberfläche bearbeitet. Keine Dateien, kein Commit, kein Rebuild: eine Änderung im Admin ist sofort live. -> **Übergang (Stand #8).** Der Inhalt ist bereits vollständig in PocketBase; die -> Blazor-App wird gerade darauf umgestellt, ihn von dort zu lesen (Issue #8). -> Solange das läuft, kann die ausgelieferte Seite noch aus den alten Dateien -> unter `Content/` stammen. Sobald #8 durch ist, ist PocketBase die einzige -> Quelle und dieser Abschnitt der einzige Pflegeweg. +> **Stand.** Die Blazor-App liest ihren gesamten Inhalt aus PocketBase (Issue #8); +> die alte Datei-Schicht unter `Content/` gibt es nicht mehr. PocketBase ist damit +> die einzige Quelle und dieser Abschnitt der einzige Pflegeweg. > **Sprachkonvention.** Feldnamen und Slugs sind **englisch** (`title`, `slug`, > `board`, `posts`). Der Text, den ein Besucher liest, bleibt **deutsch** — also @@ -49,16 +47,52 @@ Jede Collection hat als **letztes Feld `public`** (ja/nein). Nur Records mit `public = true` erscheinen auf der Website — so lässt sich ein Entwurf anlegen, ohne dass er schon sichtbar ist. +### Was redaktionell geht — und was Code braucht + +Redaktion arbeitet mit **Daten**, nicht mit Struktur. Konkret: + +| Was du willst | Geht redaktionell? | Wie | +|---|---|---| +| Beitrag / Termin / Frage hinzufügen | **Ja** | Neuer Record in `posts` / `events` / `faqs` — erscheint sofort in der jeweiligen Liste | +| Neue Textseite | **Ja** | Neuer Record in `pages` (siehe unten) | +| Eine Seite mit einer Liste anteasern | **Ja** | Feld `embed` der Seite (`posts`/`events`/`faqs`) | +| **Neue Art von Liste** (eigene Seite mit eigener Sortierung/Gruppierung und eigener Adresse, z. B. „Protokolle") | **Nein** | Braucht Entwicklung: neue Collection **plus** Code | + +Das heißt: **Bestehende Listen lassen sich beliebig erweitern**, aber eine *neue* +Listenart entsteht nicht durch Anlegen einer Collection allein. Die drei Listen +(`posts`, `events`, `faqs`) sind fest verdrahtet, weil jede eine eigene +Darstellung hat — Datum und Sortierung, Kalender/`.ics`, Themen-Gruppierung. Eine +weitere solche Ansicht anzulegen ist ein **Entwickler-Task**: eine neue Collection +in PocketBase, eine Lesemethode im `PocketBaseClient` und eine eigene Komponente +mit ihrer Route (siehe `docs/entwicklung.md`). Das ist Absicht: Inhalte sind +Daten, Struktur und Darstellung sind Code. + --- ## Seiten (`pages`) Eine Seite hat `title` (Überschrift für Menschen, Umlaute erlaubt), `slug` (die URL, klein und **ohne Umlaute**: `board`, nicht `über-uns`) und `body` (der -Text, als Editor-Feld). +Text, als **Markdown**). + +**Eine neue Seite anlegen** — Record in `pages`, dann ist die Adresse `/` +sofort live, ohne Code und ohne Rebuild: + +1. `title` setzen (Pflicht). +2. `slug` setzen (Pflicht), englisch und ohne Umlaute — z. B. `slug = schulweg` + ergibt `/schulweg`. +3. `body` als Markdown schreiben (optional; darf leer bleiben). +4. **`public` anhaken.** Ohne Haken ist die Seite ein Entwurf und erscheint auf + der Website als „nicht gefunden" (404) — das ist der häufigste Stolperstein. +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`). +- **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 + Produktion dienen solche Records nur als Menü-Platzhalter. - **Menü:** Ob eine Seite ins Menü kommt, steht an der Seite selbst — die Felder `location` (`header` oder `footer`) und `order` (Reihenfolge, ab 1). Die App baut Kopf- und Fußnavigation daraus; nichts wird im Markup angefasst. From d5372963bbc6159990752851a359c9cf178a2741 Mon Sep 17 00:00:00 2001 From: tleininger Date: Wed, 23 Sep 2026 15:43:44 +0200 Subject: [PATCH 6/7] Seed the dev PocketBase with example content on a fresh volume --- .gitignore | 7 +++- compose.dev.yaml | 6 +++ pb/pb_migrations/1727200000_dev_seed.js | 53 +++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 pb/pb_migrations/1727200000_dev_seed.js diff --git a/.gitignore b/.gitignore index daf55a7..dfc7484 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,9 @@ riderModule.iml /backup/ # Secrets (Gitea-Token o.ae.) -- niemals ins Repo. Vorlage: .env.example -.env \ No newline at end of file +.env + +# PocketBase runtime data (SQLite DB, uploads) -- may hold personal data and is +# per-environment state, not source. The dev seed lives in pb/pb_migrations/ and +# IS checked in; only the data folder is ignored. +/pb/pb_data/ \ No newline at end of file diff --git a/compose.dev.yaml b/compose.dev.yaml index 3527a24..c5afe1f 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -24,6 +24,12 @@ services: # tag clears the inherited bind mount so only this volume applies. - !reset null - pb_data_dev:/pb_data + # Dev-only seed: PocketBase runs the JS migrations in this folder on start. + # On a fresh pb_data_dev volume it creates the content collections and fills + # them with example records (see pb/pb_migrations). Guarded to be a no-op on + # a populated volume. Deliberately NOT in the base file -- production is + # seeded by hand, never from this repo. + - ./pb/pb_migrations:/pb_migrations eb-web: # Build from local source instead of pulling the registry image, so local code diff --git a/pb/pb_migrations/1727200000_dev_seed.js b/pb/pb_migrations/1727200000_dev_seed.js new file mode 100644 index 0000000..5212000 --- /dev/null +++ b/pb/pb_migrations/1727200000_dev_seed.js @@ -0,0 +1,53 @@ +/// + +// Dev-only seed: creates the four content collections and fills them with example +// records, so a fresh local stack shows a populated site on the first +// `docker compose up`. Mounted into the container ONLY by compose.dev.yaml (bind +// `./pb/pb_migrations:/pb_migrations`), so it never runs against production. +// +// Two guarded phases, both idempotent: +// 1. Schema -- a collection is created only if it does not exist yet. +// 2. Records -- a collection is filled only while it is still empty. +// So editing content locally, or pointing this at a populated volume, is safe: +// existing collections and existing data are left untouched. +// +// Generated from the local dev database. To refresh the seed, re-export and +// regenerate this file rather than hand-editing the schema blocks. +migrate((app) => { + const schema = {"posts":{"id":"pbc_1125843985","listRule":"","viewRule":"","createRule":null,"updateRule":null,"deleteRule":null,"name":"posts","type":"base","fields":[{"autogeneratePattern":"[a-z0-9]{15}","help":"","hidden":false,"id":"text3208210256","max":15,"min":15,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"},{"help":"","hidden":false,"id":"date2862495610","max":"","min":"","name":"date","presentable":false,"required":false,"system":false,"type":"date"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text724990059","max":0,"min":0,"name":"title","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text3685223346","max":0,"min":0,"name":"body","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text2560465762","max":0,"min":0,"name":"slug","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"","hidden":false,"id":"bool1001664029","name":"public","presentable":false,"required":false,"system":false,"type":"bool"}],"indexes":[],"created":"2026-09-23 10:38:12.035Z","updated":"2026-09-23 11:25:30.698Z","system":false},"events":{"id":"pbc_1687431684","listRule":"","viewRule":"","createRule":null,"updateRule":null,"deleteRule":null,"name":"events","type":"base","fields":[{"autogeneratePattern":"[a-z0-9]{15}","help":"","hidden":false,"id":"text3208210256","max":15,"min":15,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"},{"help":"","hidden":false,"id":"date2675529103","max":"","min":"","name":"start","presentable":false,"required":true,"system":false,"type":"date"},{"help":"","hidden":false,"id":"date16528305","max":"","min":"","name":"end","presentable":false,"required":false,"system":false,"type":"date"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text724990059","max":0,"min":0,"name":"title","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text1587448267","max":0,"min":0,"name":"location","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text3485334036","max":0,"min":0,"name":"note","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"},{"help":"","hidden":false,"id":"bool1001664029","name":"public","presentable":false,"required":false,"system":false,"type":"bool"}],"indexes":[],"created":"2026-09-23 10:38:12.110Z","updated":"2026-09-23 10:38:12.110Z","system":false},"pages":{"id":"pbc_3945946014","listRule":"","viewRule":"","createRule":null,"updateRule":null,"deleteRule":null,"name":"pages","type":"base","fields":[{"autogeneratePattern":"[a-z0-9]{15}","help":"","hidden":false,"id":"text3208210256","max":15,"min":15,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text724990059","max":0,"min":0,"name":"title","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text3685223346","max":0,"min":0,"name":"body","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"},{"help":"","hidden":false,"id":"select1587448267","maxSelect":1,"name":"location","presentable":false,"required":false,"system":false,"type":"select","values":["header","footer"]},{"help":"","hidden":false,"id":"number4113142680","max":null,"min":null,"name":"order","onlyInt":false,"presentable":false,"required":false,"system":false,"type":"number"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text2560465762","max":0,"min":0,"name":"slug","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"","hidden":false,"id":"select1203373331","maxSelect":3,"name":"embed","presentable":false,"required":false,"system":false,"type":"select","values":["posts","events","faqs"]},{"help":"","hidden":false,"id":"bool1001664029","name":"public","presentable":false,"required":false,"system":false,"type":"bool"}],"indexes":[],"created":"2026-09-23 11:25:35.648Z","updated":"2026-09-23 11:25:35.648Z","system":false},"faqs":{"id":"pbc_2923026773","listRule":"","viewRule":"","createRule":null,"updateRule":null,"deleteRule":null,"name":"faqs","type":"base","fields":[{"autogeneratePattern":"[a-z0-9]{15}","help":"","hidden":false,"id":"text3208210256","max":15,"min":15,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text3069659470","max":0,"min":0,"name":"question","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text3671935525","max":0,"min":0,"name":"answer","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"},{"help":"","hidden":false,"id":"select2638274075","maxSelect":1,"name":"topic","presentable":false,"required":false,"system":false,"type":"select","values":["mensa","schliessfach","elterneuro","elternarbeit"]},{"help":"","hidden":false,"id":"bool1001664029","name":"public","presentable":false,"required":false,"system":false,"type":"bool"}],"indexes":[],"created":"2026-09-23 11:59:13.899Z","updated":"2026-09-23 11:59:13.899Z","system":false}} + + const records = { + pages: [{"body":"# Vorstandsteam\n\nDer Elternbeirat der IGMH vertritt die Interessen aller Eltern gegenüber der Schulleitung und dem Kollegium. Wir treffen uns regelmäßig, bringen Anliegen aus der Elternschaft ein und begleiten das Schulleben aktiv.\n\n## Vorstand\n\n- **Vorsitz** — Anke Berger\n- **Stellvertretung** — Murat Yılmaz\n- **Kasse** — Sabine Krause\n- **Schriftführung** — Thomas Heinrich\n\n## So erreichen Sie uns\n\nAm schnellsten per Mail über unsere [Kontaktseite](/contact). Zu unseren öffentlichen Sitzungen sind alle Eltern herzlich eingeladen — die Termine finden Sie unter [Termine](/events).","embed":[],"location":"header","order":2,"public":true,"slug":"board","title":"Vorstandsteam"},{"body":"# Förderverein\n\nDer Förderverein der IGMH unterstützt Projekte, für die im regulären Schulhaushalt kein Geld vorgesehen ist: von der Ausstattung der Bibliothek über Pausenspielgeräte bis zu Zuschüssen für Klassenfahrten, damit kein Kind aus finanziellen Gründen zurückbleibt.\n\n## Mitglied werden\n\nDer Jahresbeitrag beträgt 24 € — jeder Euro fließt direkt in die Arbeit an der Schule. Den Aufnahmeantrag finden Sie unter [Downloads](/downloads).\n\n## Spenden\n\nSpenden sind jederzeit willkommen und werden auf Wunsch mit einer Zuwendungsbestätigung quittiert. Sprechen Sie uns über die [Kontaktseite](/contact) an.","embed":[],"location":"header","order":3,"public":true,"slug":"patrons","title":"Förderverein"},{"body":"# Häufige Fragen\n\nHier beantworten wir die Fragen, die uns am häufigsten erreichen. Ausführliche Antworten gibt es auf den Unterseiten:\n\n- [Mensa und Mittagessen](/faq-mensa)\n- [Schließfächer](/faq-schliessfach)\n- [Eltern-Euro](/faq-elterneuro)\n- [Elternarbeit und Mitmachen](/faq-elternarbeit)\n\nIhre Frage ist nicht dabei? Schreiben Sie uns über die [Kontaktseite](/contact).","embed":[],"location":"header","order":6,"public":true,"slug":"faqs","title":"FAQ"},{"body":"# Downloads\n\nFormulare und Dokumente zum Herunterladen:\n\n- Aufnahmeantrag Förderverein\n- Einzugsermächtigung\n- Erstattungsformular Klassenkasse\n\nFehlt ein Dokument? Melden Sie sich über die [Kontaktseite](/contact).","embed":[],"location":"header","order":7,"public":true,"slug":"downloads","title":"Downloads"},{"body":"# Kontakt\n\nSie erreichen den Elternbeirat der IGMH per E-Mail:\n\n**elternbeirat@igmh.example**\n\nWir antworten in der Regel innerhalb weniger Tage. Bei dringenden schulischen Angelegenheiten wenden Sie sich bitte direkt an das Sekretariat der Schule.","embed":[],"location":"footer","order":1,"public":true,"slug":"contact","title":"Kontakt"},{"body":"# Impressum\n\nAngaben gemäß § 5 DDG\n\n**Elternbeirat der IGMH**\nVertreten durch den Vorstand\n\nKontakt: siehe [Kontaktseite](/contact)\n\nVerantwortlich für den Inhalt nach § 18 Abs. 2 MStV: der Vorstand des Elternbeirats.","embed":[],"location":"footer","order":2,"public":true,"slug":"imprint","title":"Impressum"},{"body":"# Datenschutz\n\nDer Schutz Ihrer Daten ist uns wichtig. Diese Website kommt ohne externe Dienste aus: keine Analysewerkzeuge, keine eingebundenen Karten oder Videos, keine Schriften von fremden Servern.\n\n## Server-Logs\n\nBeim Aufruf werden technisch notwendige Zugriffsdaten (IP-Adresse, Zeitpunkt, aufgerufene Seite) kurzzeitig verarbeitet, um den Betrieb sicherzustellen. Eine Zusammenführung mit anderen Daten findet nicht statt.\n\n## Ihre Rechte\n\nFragen zum Datenschutz richten Sie bitte an die [Kontaktseite](/contact).","embed":[],"location":"footer","order":3,"public":true,"slug":"privacy","title":"Datenschutz"},{"body":"# Mensa und Mittagessen\n\n## Wie melde ich mein Kind zum Mittagessen an?\n\nDie Anmeldung läuft über das Bestellsystem der Schule. Die Zugangsdaten erhalten Sie zu Schuljahresbeginn über die Klassenleitung.\n\n## Was kostet ein Essen?\n\nEin 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.\n\n## Gibt es vegetarische Gerichte?\n\nJa, täglich steht mindestens ein vegetarisches Gericht zur Wahl.\n\nWeitere Fragen? [Schreiben Sie uns.](/contact)","embed":[],"location":"","order":0,"public":true,"slug":"faq-mensa","title":"Mensa und Mittagessen"},{"body":"# Schließfächer\n\n## Wie bekommt mein Kind ein Schließfach?\n\nSchließfächer werden über einen externen Anbieter gemietet. Das Anmeldeformular liegt bei den [Downloads](/downloads).\n\n## Was kostet die Miete?\n\nDie Miete beträgt rund 15 € pro Schuljahr zuzüglich einer Kaution, die bei Rückgabe erstattet wird.\n\n## Der Schlüssel ist weg — was nun?\n\nWenden Sie sich an den Anbieter; die Kontaktdaten stehen auf dem Mietvertrag. Bei Fragen hilft auch der [Elternbeirat](/contact).","embed":[],"location":"","order":0,"public":true,"slug":"faq-schliessfach","title":"Schließfächer"},{"body":"# Eltern-Euro\n\n## Was ist der Eltern-Euro?\n\nDer Eltern-Euro ist ein freiwilliger Beitrag, mit dem kleinere Anschaffungen für die Klassen unkompliziert finanziert werden — etwa Bastelmaterial oder Ausflüge.\n\n## Ist der Beitrag verpflichtend?\n\nNein. Der Beitrag ist ausdrücklich freiwillig, und niemand wird bei Nichtzahlung benachteiligt.\n\n## Wohin fließt das Geld?\n\nIn die jeweilige Klassenkasse, verwaltet von den gewählten Klassenelternvertretungen. Fragen dazu über die [Kontaktseite](/contact).","embed":[],"location":"","order":0,"public":true,"slug":"faq-elterneuro","title":"Eltern-Euro"},{"body":"# Elternarbeit und Mitmachen\n\n## Wie kann ich mich engagieren?\n\nOb Schulfest, Basar oder eine helfende Hand bei Ausflügen — über jede Unterstützung freuen wir uns. Melden Sie sich einfach über die [Kontaktseite](/contact).\n\n## Muss ich in den Elternbeirat gewählt sein?\n\nNein. Viele Aufgaben erledigen Eltern, die einfach mithelfen möchten, ganz ohne Amt.\n\n## Wann finden die Sitzungen statt?\n\nDie öffentlichen Sitzungen und weitere Termine finden Sie unter [Termine](/events).","embed":[],"location":"","order":0,"public":true,"slug":"faq-elternarbeit","title":"Elternarbeit und Mitmachen"},{"body":"# Willkommen beim Elternbeirat der IGMH","embed":["posts","events"],"location":"header","order":1,"public":true,"slug":"home","title":"Startseite"},{"body":"# Beiträge","embed":[],"location":"header","order":4,"public":true,"slug":"posts","title":"Beiträge"},{"body":"# Termine","embed":[],"location":"header","order":5,"public":true,"slug":"events","title":"Termine"}] +, + posts: [{"body":"## Neuer Vorstand gewählt\n\nAuf der Mitgliederversammlung wurde der Vorstand des Elternbeirats für das Schuljahr **2026/27** neu gewählt. Wir danken dem bisherigen Team für sein Engagement und freuen uns auf die gemeinsame Arbeit.\n\nDie neuen Ansprechpartnerinnen und Ansprechpartner finden Sie auf der Seite [Vorstandsteam](/board).","date":"2026-09-24 18:00:00.000Z","public":true,"slug":"new-board-2026","title":"Neuer Vorstand für das Schuljahr 2026/27 gewählt"},{"body":"## Herbstbasar am 22. November\n\nAm **22. November** findet wieder unser beliebter Herbstbasar statt. Es gibt selbst gebackene Kuchen, Bastelstände und ein Bühnenprogramm der Klassen.\n\nWer einen Stand betreuen oder etwas beisteuern möchte, meldet sich bitte bis zum 10. November über das [Kontaktformular](/contact) an. Wir freuen uns über jede helfende Hand!","date":"2026-09-18 09:30:00.000Z","public":true,"slug":"herbstbasar-2026","title":"Herbstbasar am 22. November – jetzt anmelden"},{"body":"## Mehr Auswahl beim Mittagessen\n\nAb den Herbstferien erweitert der Caterer das Angebot in der Mensa: Es gibt künftig **täglich ein zusätzliches vegetarisches Gericht** sowie eine größere Salatauswahl.\n\nRückmeldungen zum Essen nehmen wir gerne über den Elternbeirat auf – sprechen Sie uns an.","date":"2026-09-10 12:15:00.000Z","public":true,"slug":"mensa-mehr-auswahl","title":"Mehr Auswahl beim Mittagessen ab den Herbstferien"},{"body":"## Besuchen Sie unseren Stand\n\nAm kommenden **Elternsprechtag** ist der Elternbeirat mit einem Info-Stand im Foyer vertreten. Kommen Sie vorbei – wir beantworten Fragen rund um die Elternarbeit und freuen uns über neue Mitstreiterinnen und Mitstreiter.\n\nAlle Termine finden Sie unter [Termine](/events).","date":"2026-09-02 08:00:00.000Z","public":true,"slug":"elternsprechtag-stand","title":"Elternbeirat am Elternsprechtag – besuchen Sie unseren Stand"},{"body":"## Neue Spielgeräte für den Pausenhof\n\nDank der Einnahmen aus dem letzten Basar und der Unterstützung des [Fördervereins](/patrons) konnten neue Spielgeräte für den Pausenhof angeschafft werden.\n\nDie Kinder haben die Klettergerüste bereits begeistert in Beschlag genommen.","date":"2026-08-28 16:45:00.000Z","public":true,"slug":"neue-spielgeraete","title":"Neue Spielgeräte für den Pausenhof"}] +, + 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"}] +, + } + + // Phase 1: schema. Create any collection that is not present yet. + for (const name of Object.keys(schema)) { + try { + app.findCollectionByNameOrId(name) + } catch { + app.save(new Collection(schema[name])) + } + } + + // Phase 2: records. Fill a collection only while it is still empty, so a + // populated volume (or, should this ever run there, production) is untouched. + for (const [name, rows] of Object.entries(records)) { + if (app.countRecords(name) > 0) continue + const collection = app.findCollectionByNameOrId(name) + for (const data of rows) { + const record = new Record(collection) + record.load(data) + app.save(record) + } + } +}, (app) => { + // Down: intentionally a no-op. This seed is a dev convenience; to start over, + // drop the whole dev volume: `docker volume rm eb-stack_pb_data_dev`. +}) From c3da5eb6e1fbf5ac7f213f65c55ef455567f1336 Mon Sep 17 00:00:00 2001 From: tleininger Date: Wed, 23 Sep 2026 15:59:15 +0200 Subject: [PATCH 7/7] Auto-seed dev PocketBase and share its schema with the tests --- Elternbeirat.Web.Tests/PocketBaseFixture.cs | 93 ++--- Elternbeirat.Web.Tests/RouteSmokeTests.cs | 2 +- compose.test.yaml | 8 + docs/entwicklung.md | 59 ++- pb/pb_migrations/1727200000_dev_seed.js | 12 +- pb/pb_migrations/collections_schema.json | 428 ++++++++++++++++++++ 6 files changed, 537 insertions(+), 65 deletions(-) create mode 100644 pb/pb_migrations/collections_schema.json diff --git a/Elternbeirat.Web.Tests/PocketBaseFixture.cs b/Elternbeirat.Web.Tests/PocketBaseFixture.cs index 1160e7b..21ba87e 100644 --- a/Elternbeirat.Web.Tests/PocketBaseFixture.cs +++ b/Elternbeirat.Web.Tests/PocketBaseFixture.cs @@ -2,6 +2,8 @@ using System.Diagnostics; using System.Globalization; using System.Net.Http.Headers; using System.Net.Http.Json; +using System.Text; +using System.Text.Json; using Elternbeirat.PocketBase; namespace Elternbeirat.Web.Tests; @@ -16,9 +18,16 @@ namespace Elternbeirat.Web.Tests; /// (only the eb-pocketbase service, not the web app) by shelling out to /// docker compose, so the image version, superuser env and port stay defined /// in one place -- the compose files -- and the tests always exercise the same -/// PocketBase the stack runs. The seed data is deliberately fixed here rather than -/// exported from the real instance, so tests assert against values this file -/// controls. Requires Docker with the Compose plugin. +/// PocketBase the stack runs. +/// +/// +/// The collection schema is not typed out here: it is imported from +/// pb/pb_migrations/collections_schema.json, the same file the dev seed +/// migration uses, so the schema is defined once and cannot drift between the two. +/// The seeded records, by contrast, are fixed in this file on purpose (not +/// the dev seed's data), so tests assert against values this file controls -- e.g. +/// a non-public draft page that must never appear, and an event at a known time. +/// Requires Docker with the Compose plugin. /// /// public sealed class PocketBaseFixture : IAsyncLifetime @@ -145,15 +154,15 @@ public sealed class PocketBaseFixture : IAsyncLifetime } /// - /// Authenticates as superuser, then creates the collections and records. This - /// mirrors the shape the client reads, not the full production schema. + /// Authenticates as superuser, then imports the shared collection schema and + /// seeds the fixed test records the tests assert against. /// private async Task SeedAsync() { var token = await AuthenticateAsync(_http); _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(token); - await CreateCollectionsAsync(_http); + await ImportCollectionsAsync(_http); await SeedRecordsAsync(_http); // Drop the superuser token so the tests read as an anonymous visitor would, @@ -184,59 +193,27 @@ public sealed class PocketBaseFixture : IAsyncLifetime } } - private static async Task CreateCollectionsAsync(HttpClient http) + /// + /// Imports the four content collections from the shared schema file, the same + /// one the dev seed migration reads (pb/pb_migrations/collections_schema.json). + /// Uses PocketBase's /api/collections/import so the whole schema is + /// defined in one place instead of being typed out here. + /// + private static async Task ImportCollectionsAsync(HttpClient http) { - // listRule/viewRule = "" means publicly readable; the client's - // filter=public=true does the visibility gating on top. - await CreateCollectionAsync(http, "pages", new object[] - { - new { name = "title", type = "text", required = true }, - new { name = "body", type = "text" }, - new { name = "location", type = "select", maxSelect = 1, values = new[] { "header", "footer" } }, - new { name = "order", type = "number" }, - new { name = "slug", type = "text", required = true }, - new { name = "embed", type = "select", maxSelect = 3, values = new[] { "posts", "events", "faqs" } }, - new { name = "public", type = "bool" }, - }); + // The file maps name -> collection object; the import endpoint wants a + // flat list, so unwrap the values. deleteMissing=false leaves anything + // already present untouched (the import is idempotent). + var path = Path.Combine(RepoRoot(), "pb", "pb_migrations", "collections_schema.json"); + using var schema = JsonDocument.Parse(await File.ReadAllTextAsync(path)); + var collections = schema.RootElement.EnumerateObject() + .Select(property => property.Value) + .ToArray(); - await CreateCollectionAsync(http, "posts", new object[] - { - new { name = "date", type = "date" }, - new { name = "title", type = "text", required = true }, - new { name = "body", type = "text" }, - new { name = "slug", type = "text", required = true }, - new { name = "public", type = "bool" }, - }); - - await CreateCollectionAsync(http, "events", new object[] - { - new { name = "start", type = "date", required = true }, - new { name = "end", type = "date" }, - new { name = "title", type = "text", required = true }, - new { name = "location", type = "text" }, - new { name = "note", type = "text" }, - new { name = "public", type = "bool" }, - }); - - await CreateCollectionAsync(http, "faqs", new object[] - { - new { name = "question", type = "text", required = true }, - new { name = "answer", type = "text" }, - new { name = "topic", type = "select", maxSelect = 1, values = new[] { "mensa", "schliessfach", "elterneuro", "elternarbeit" } }, - new { name = "public", type = "bool" }, - }); - } - - private static async Task CreateCollectionAsync(HttpClient http, string name, object[] fields) - { - var response = await http.PostAsJsonAsync("/api/collections", new - { - name, - type = "base", - listRule = "", - viewRule = "", - fields, - }); + // Serialize the JsonElement list back to JSON for the request body. + var body = JsonSerializer.Serialize(new { collections, deleteMissing = false }); + using var content = new StringContent(body, Encoding.UTF8, "application/json"); + var response = await http.PutAsync(new Uri("/api/collections/import", UriKind.Relative), content); response.EnsureSuccessStatusCode(); } @@ -277,7 +254,7 @@ public sealed class PocketBaseFixture : IAsyncLifetime await CreateRecordAsync(http, "pages", new { title = "FAQ Mensa", body = "# Mensa", location = "", - order = 0, slug = "faq-mensa", embed = Array.Empty(), @public = true, + order = 0, slug = "faq-lunch", embed = Array.Empty(), @public = true, }); await CreateRecordAsync(http, "pages", new { diff --git a/Elternbeirat.Web.Tests/RouteSmokeTests.cs b/Elternbeirat.Web.Tests/RouteSmokeTests.cs index c5fd147..821951b 100644 --- a/Elternbeirat.Web.Tests/RouteSmokeTests.cs +++ b/Elternbeirat.Web.Tests/RouteSmokeTests.cs @@ -49,7 +49,7 @@ public sealed class RouteSmokeTests : IDisposable "/board", "/patrons", "/faqs", - "/faq-mensa", + "/faq-lunch", "/downloads", "/contact", "/imprint", diff --git a/compose.test.yaml b/compose.test.yaml index 388c195..1f94e42 100644 --- a/compose.test.yaml +++ b/compose.test.yaml @@ -18,6 +18,14 @@ services: # lets Docker pick a random free one; the tests read it back with `compose port`. ports: !override - "8090" + # Drop the dev overlay's pb_migrations bind mount. The tests must NOT run the + # dev seed -- it would insert the dev data set, which the tests do not assert + # against (and which lacks the fixtures' draft page, known event time, etc.). + # The fixture creates the collections itself by importing the shared schema + # file, then seeds its own fixed records. !override replaces the inherited + # volumes list; the throwaway pb_data volume is re-declared so it survives. + volumes: !override + - pb_data_dev:/pb_data eb-web: container_name: !reset null # see eb-pocketbase above (eb-web is not started in tests) diff --git a/docs/entwicklung.md b/docs/entwicklung.md index f4783db..e2455e5 100644 --- a/docs/entwicklung.md +++ b/docs/entwicklung.md @@ -17,8 +17,8 @@ the base for a local run — you layer an overlay on top of it. | File | Role | |---|---| | `compose.yaml` | Base, production-shaped: both services, **no** host ports, PocketBase data on the Unraid bind mount. Also holds the fixed project name `eb-stack`. | -| `compose.dev.yaml` | Dev overlay: adds host ports (5000, 8090), builds the web image from local source instead of pulling it, swaps the Unraid bind mount for a throwaway volume, and seeds a local PocketBase superuser. | -| `compose.test.yaml` | Test overlay: used **only** by the integration tests. Drops the fixed host port and container names so a test run does not collide with a running dev stack. | +| `compose.dev.yaml` | Dev overlay: adds host ports (5000, 8090), builds the web image from local source instead of pulling it, swaps the Unraid bind mount for a throwaway volume, seeds a local PocketBase superuser, and mounts `pb/pb_migrations` so a fresh volume is auto-seeded (see below). | +| `compose.test.yaml` | Test overlay: used **only** by the integration tests. Drops the fixed host port and container names so a test run does not collide with a running dev stack, and removes the seed mount so the dev seed does not run during tests. | > **Project name.** `name: eb-stack` is set once, in the base. The overlays > deliberately don't set their own `name:` — a competing `name:` across `-f` @@ -88,11 +88,60 @@ dev stack — you can have DevEnvironment up in Rider and run `dotnet test` at t same time. The fixture cleans up before and after each run, so a hard-killed earlier run leaves nothing behind. +The fixture creates the collections by importing the **same** schema file the dev +seed uses (see the next section), so the schema is defined once. Its *records*, +though, are fixed in `PocketBaseFixture` on purpose — the tests assert against +values that file controls (a non-public draft page that must never show, an event +at a known time), not against the dev seed's content. The test overlay therefore +drops the `pb_migrations` mount so the dev seed does **not** run during tests. + **Requirements:** Docker with the Compose plugin must be available when the tests run (they call `docker compose`). --- +## Example content on a fresh volume (the dev seed) + +A fresh dev volume comes up populated, so a first `docker compose … up` shows a +working site instead of an empty one. Two files under `pb/pb_migrations/` do this, +and the dev overlay mounts that folder into PocketBase (`./pb/pb_migrations:/pb_migrations`): + +| File | Role | +|---|---| +| `collections_schema.json` | The four content collections (`pages`, `posts`, `events`, `faqs`) as PocketBase exports them. **The single source of truth for the schema** — the test fixture imports this same file. | +| `_dev_seed.js` | The PocketBase migration. Reads the schema file, creates any missing collection, then inserts the example records. | + +Both steps are **idempotent**: a collection is created only if it is missing, and +filled only while it is empty. So a populated volume — or the production DB, should +this ever run there — is never touched. To start over from an empty, then freshly +seeded volume: + +```bash +docker volume rm eb-stack_pb_data_dev +docker compose -f compose.yaml -f compose.dev.yaml up --build +``` + +> **Seed only in dev.** The `pb_migrations` mount lives in `compose.dev.yaml` +> alone. The base (production) file does not mount it, and the test overlay +> removes it again — production content is seeded by hand, never from this repo. + +### Refreshing the seed after a schema or content change + +The seed is generated from a running dev instance, not hand-edited. When you +change a collection's schema in the admin UI, or want to capture the current +example content as the new seed, re-export from the running dev PocketBase +(authenticated as the dev superuser) and regenerate the two files. The schema file +is the plain export of the four collections; the migration embeds the records the +same way. After regenerating, prove it end to end: + +```bash +docker volume rm eb-stack_pb_data_dev # force a fresh volume +docker compose -f compose.yaml -f compose.dev.yaml up --build +dotnet test # fixture imports the new schema +``` + +--- + ## Content while developing The app reads its content from PocketBase over the compose network @@ -126,6 +175,12 @@ It takes three steps, all small because the plumbing is shared: `EventList`, `FaqList`) for the load-in-`OnInitializedAsync`, log-and-degrade pattern, and add a route smoke test plus a fixture seed. +Also refresh the seed so the new collection comes up on a fresh volume and in the +tests: re-export it into `pb/pb_migrations/collections_schema.json` (the shared +schema) and add example rows to the dev seed migration — see *Refreshing the seed* +above. The test fixture picks the new schema up automatically once it is in the +file. + Text pages need none of this — they are all served generically by `ContentPage` (`@page "/{Slug}"`), which is why a new `pages` record is live without a rebuild. diff --git a/pb/pb_migrations/1727200000_dev_seed.js b/pb/pb_migrations/1727200000_dev_seed.js index 5212000..14e0548 100644 --- a/pb/pb_migrations/1727200000_dev_seed.js +++ b/pb/pb_migrations/1727200000_dev_seed.js @@ -5,16 +5,20 @@ // `docker compose up`. Mounted into the container ONLY by compose.dev.yaml (bind // `./pb/pb_migrations:/pb_migrations`), so it never runs against production. // +// The collection SCHEMA is not embedded here -- it is read from +// collections_schema.json in this same folder, which is the single source of +// truth for the schema. The test fixture (Elternbeirat.Web.Tests) imports that +// very same file, so the schema is defined once, not typed twice. +// // Two guarded phases, both idempotent: // 1. Schema -- a collection is created only if it does not exist yet. // 2. Records -- a collection is filled only while it is still empty. // So editing content locally, or pointing this at a populated volume, is safe: // existing collections and existing data are left untouched. -// -// Generated from the local dev database. To refresh the seed, re-export and -// regenerate this file rather than hand-editing the schema blocks. migrate((app) => { - const schema = {"posts":{"id":"pbc_1125843985","listRule":"","viewRule":"","createRule":null,"updateRule":null,"deleteRule":null,"name":"posts","type":"base","fields":[{"autogeneratePattern":"[a-z0-9]{15}","help":"","hidden":false,"id":"text3208210256","max":15,"min":15,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"},{"help":"","hidden":false,"id":"date2862495610","max":"","min":"","name":"date","presentable":false,"required":false,"system":false,"type":"date"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text724990059","max":0,"min":0,"name":"title","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text3685223346","max":0,"min":0,"name":"body","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text2560465762","max":0,"min":0,"name":"slug","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"","hidden":false,"id":"bool1001664029","name":"public","presentable":false,"required":false,"system":false,"type":"bool"}],"indexes":[],"created":"2026-09-23 10:38:12.035Z","updated":"2026-09-23 11:25:30.698Z","system":false},"events":{"id":"pbc_1687431684","listRule":"","viewRule":"","createRule":null,"updateRule":null,"deleteRule":null,"name":"events","type":"base","fields":[{"autogeneratePattern":"[a-z0-9]{15}","help":"","hidden":false,"id":"text3208210256","max":15,"min":15,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"},{"help":"","hidden":false,"id":"date2675529103","max":"","min":"","name":"start","presentable":false,"required":true,"system":false,"type":"date"},{"help":"","hidden":false,"id":"date16528305","max":"","min":"","name":"end","presentable":false,"required":false,"system":false,"type":"date"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text724990059","max":0,"min":0,"name":"title","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text1587448267","max":0,"min":0,"name":"location","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text3485334036","max":0,"min":0,"name":"note","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"},{"help":"","hidden":false,"id":"bool1001664029","name":"public","presentable":false,"required":false,"system":false,"type":"bool"}],"indexes":[],"created":"2026-09-23 10:38:12.110Z","updated":"2026-09-23 10:38:12.110Z","system":false},"pages":{"id":"pbc_3945946014","listRule":"","viewRule":"","createRule":null,"updateRule":null,"deleteRule":null,"name":"pages","type":"base","fields":[{"autogeneratePattern":"[a-z0-9]{15}","help":"","hidden":false,"id":"text3208210256","max":15,"min":15,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text724990059","max":0,"min":0,"name":"title","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text3685223346","max":0,"min":0,"name":"body","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"},{"help":"","hidden":false,"id":"select1587448267","maxSelect":1,"name":"location","presentable":false,"required":false,"system":false,"type":"select","values":["header","footer"]},{"help":"","hidden":false,"id":"number4113142680","max":null,"min":null,"name":"order","onlyInt":false,"presentable":false,"required":false,"system":false,"type":"number"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text2560465762","max":0,"min":0,"name":"slug","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"","hidden":false,"id":"select1203373331","maxSelect":3,"name":"embed","presentable":false,"required":false,"system":false,"type":"select","values":["posts","events","faqs"]},{"help":"","hidden":false,"id":"bool1001664029","name":"public","presentable":false,"required":false,"system":false,"type":"bool"}],"indexes":[],"created":"2026-09-23 11:25:35.648Z","updated":"2026-09-23 11:25:35.648Z","system":false},"faqs":{"id":"pbc_2923026773","listRule":"","viewRule":"","createRule":null,"updateRule":null,"deleteRule":null,"name":"faqs","type":"base","fields":[{"autogeneratePattern":"[a-z0-9]{15}","help":"","hidden":false,"id":"text3208210256","max":15,"min":15,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text3069659470","max":0,"min":0,"name":"question","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text3671935525","max":0,"min":0,"name":"answer","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"},{"help":"","hidden":false,"id":"select2638274075","maxSelect":1,"name":"topic","presentable":false,"required":false,"system":false,"type":"select","values":["mensa","schliessfach","elterneuro","elternarbeit"]},{"help":"","hidden":false,"id":"bool1001664029","name":"public","presentable":false,"required":false,"system":false,"type":"bool"}],"indexes":[],"created":"2026-09-23 11:59:13.899Z","updated":"2026-09-23 11:59:13.899Z","system":false}} + // The schema file sits next to this migration. In this image the migrations + // dir is mounted at /pb_migrations (see compose.dev.yaml). + const schema = JSON.parse(toString($os.readFile("/pb_migrations/collections_schema.json"))) const records = { pages: [{"body":"# Vorstandsteam\n\nDer Elternbeirat der IGMH vertritt die Interessen aller Eltern gegenüber der Schulleitung und dem Kollegium. Wir treffen uns regelmäßig, bringen Anliegen aus der Elternschaft ein und begleiten das Schulleben aktiv.\n\n## Vorstand\n\n- **Vorsitz** — Anke Berger\n- **Stellvertretung** — Murat Yılmaz\n- **Kasse** — Sabine Krause\n- **Schriftführung** — Thomas Heinrich\n\n## So erreichen Sie uns\n\nAm schnellsten per Mail über unsere [Kontaktseite](/contact). Zu unseren öffentlichen Sitzungen sind alle Eltern herzlich eingeladen — die Termine finden Sie unter [Termine](/events).","embed":[],"location":"header","order":2,"public":true,"slug":"board","title":"Vorstandsteam"},{"body":"# Förderverein\n\nDer Förderverein der IGMH unterstützt Projekte, für die im regulären Schulhaushalt kein Geld vorgesehen ist: von der Ausstattung der Bibliothek über Pausenspielgeräte bis zu Zuschüssen für Klassenfahrten, damit kein Kind aus finanziellen Gründen zurückbleibt.\n\n## Mitglied werden\n\nDer Jahresbeitrag beträgt 24 € — jeder Euro fließt direkt in die Arbeit an der Schule. Den Aufnahmeantrag finden Sie unter [Downloads](/downloads).\n\n## Spenden\n\nSpenden sind jederzeit willkommen und werden auf Wunsch mit einer Zuwendungsbestätigung quittiert. Sprechen Sie uns über die [Kontaktseite](/contact) an.","embed":[],"location":"header","order":3,"public":true,"slug":"patrons","title":"Förderverein"},{"body":"# Häufige Fragen\n\nHier beantworten wir die Fragen, die uns am häufigsten erreichen. Ausführliche Antworten gibt es auf den Unterseiten:\n\n- [Mensa und Mittagessen](/faq-mensa)\n- [Schließfächer](/faq-schliessfach)\n- [Eltern-Euro](/faq-elterneuro)\n- [Elternarbeit und Mitmachen](/faq-elternarbeit)\n\nIhre Frage ist nicht dabei? Schreiben Sie uns über die [Kontaktseite](/contact).","embed":[],"location":"header","order":6,"public":true,"slug":"faqs","title":"FAQ"},{"body":"# Downloads\n\nFormulare und Dokumente zum Herunterladen:\n\n- Aufnahmeantrag Förderverein\n- Einzugsermächtigung\n- Erstattungsformular Klassenkasse\n\nFehlt ein Dokument? Melden Sie sich über die [Kontaktseite](/contact).","embed":[],"location":"header","order":7,"public":true,"slug":"downloads","title":"Downloads"},{"body":"# Kontakt\n\nSie erreichen den Elternbeirat der IGMH per E-Mail:\n\n**elternbeirat@igmh.example**\n\nWir antworten in der Regel innerhalb weniger Tage. Bei dringenden schulischen Angelegenheiten wenden Sie sich bitte direkt an das Sekretariat der Schule.","embed":[],"location":"footer","order":1,"public":true,"slug":"contact","title":"Kontakt"},{"body":"# Impressum\n\nAngaben gemäß § 5 DDG\n\n**Elternbeirat der IGMH**\nVertreten durch den Vorstand\n\nKontakt: siehe [Kontaktseite](/contact)\n\nVerantwortlich für den Inhalt nach § 18 Abs. 2 MStV: der Vorstand des Elternbeirats.","embed":[],"location":"footer","order":2,"public":true,"slug":"imprint","title":"Impressum"},{"body":"# Datenschutz\n\nDer Schutz Ihrer Daten ist uns wichtig. Diese Website kommt ohne externe Dienste aus: keine Analysewerkzeuge, keine eingebundenen Karten oder Videos, keine Schriften von fremden Servern.\n\n## Server-Logs\n\nBeim Aufruf werden technisch notwendige Zugriffsdaten (IP-Adresse, Zeitpunkt, aufgerufene Seite) kurzzeitig verarbeitet, um den Betrieb sicherzustellen. Eine Zusammenführung mit anderen Daten findet nicht statt.\n\n## Ihre Rechte\n\nFragen zum Datenschutz richten Sie bitte an die [Kontaktseite](/contact).","embed":[],"location":"footer","order":3,"public":true,"slug":"privacy","title":"Datenschutz"},{"body":"# Mensa und Mittagessen\n\n## Wie melde ich mein Kind zum Mittagessen an?\n\nDie Anmeldung läuft über das Bestellsystem der Schule. Die Zugangsdaten erhalten Sie zu Schuljahresbeginn über die Klassenleitung.\n\n## Was kostet ein Essen?\n\nEin 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.\n\n## Gibt es vegetarische Gerichte?\n\nJa, täglich steht mindestens ein vegetarisches Gericht zur Wahl.\n\nWeitere Fragen? [Schreiben Sie uns.](/contact)","embed":[],"location":"","order":0,"public":true,"slug":"faq-mensa","title":"Mensa und Mittagessen"},{"body":"# Schließfächer\n\n## Wie bekommt mein Kind ein Schließfach?\n\nSchließfächer werden über einen externen Anbieter gemietet. Das Anmeldeformular liegt bei den [Downloads](/downloads).\n\n## Was kostet die Miete?\n\nDie Miete beträgt rund 15 € pro Schuljahr zuzüglich einer Kaution, die bei Rückgabe erstattet wird.\n\n## Der Schlüssel ist weg — was nun?\n\nWenden Sie sich an den Anbieter; die Kontaktdaten stehen auf dem Mietvertrag. Bei Fragen hilft auch der [Elternbeirat](/contact).","embed":[],"location":"","order":0,"public":true,"slug":"faq-schliessfach","title":"Schließfächer"},{"body":"# Eltern-Euro\n\n## Was ist der Eltern-Euro?\n\nDer Eltern-Euro ist ein freiwilliger Beitrag, mit dem kleinere Anschaffungen für die Klassen unkompliziert finanziert werden — etwa Bastelmaterial oder Ausflüge.\n\n## Ist der Beitrag verpflichtend?\n\nNein. Der Beitrag ist ausdrücklich freiwillig, und niemand wird bei Nichtzahlung benachteiligt.\n\n## Wohin fließt das Geld?\n\nIn die jeweilige Klassenkasse, verwaltet von den gewählten Klassenelternvertretungen. Fragen dazu über die [Kontaktseite](/contact).","embed":[],"location":"","order":0,"public":true,"slug":"faq-elterneuro","title":"Eltern-Euro"},{"body":"# Elternarbeit und Mitmachen\n\n## Wie kann ich mich engagieren?\n\nOb Schulfest, Basar oder eine helfende Hand bei Ausflügen — über jede Unterstützung freuen wir uns. Melden Sie sich einfach über die [Kontaktseite](/contact).\n\n## Muss ich in den Elternbeirat gewählt sein?\n\nNein. Viele Aufgaben erledigen Eltern, die einfach mithelfen möchten, ganz ohne Amt.\n\n## Wann finden die Sitzungen statt?\n\nDie öffentlichen Sitzungen und weitere Termine finden Sie unter [Termine](/events).","embed":[],"location":"","order":0,"public":true,"slug":"faq-elternarbeit","title":"Elternarbeit und Mitmachen"},{"body":"# Willkommen beim Elternbeirat der IGMH","embed":["posts","events"],"location":"header","order":1,"public":true,"slug":"home","title":"Startseite"},{"body":"# Beiträge","embed":[],"location":"header","order":4,"public":true,"slug":"posts","title":"Beiträge"},{"body":"# Termine","embed":[],"location":"header","order":5,"public":true,"slug":"events","title":"Termine"}] diff --git a/pb/pb_migrations/collections_schema.json b/pb/pb_migrations/collections_schema.json new file mode 100644 index 0000000..0b87140 --- /dev/null +++ b/pb/pb_migrations/collections_schema.json @@ -0,0 +1,428 @@ +{ + "posts": { + "id": "pbc_1125843985", + "listRule": "", + "viewRule": "", + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "posts", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "date2862495610", + "max": "", + "min": "", + "name": "date", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text724990059", + "max": 0, + "min": 0, + "name": "title", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3685223346", + "max": 0, + "min": 0, + "name": "body", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2560465762", + "max": 0, + "min": 0, + "name": "slug", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "bool1001664029", + "name": "public", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + } + ], + "indexes": [], + "created": "2026-09-23 10:38:12.035Z", + "updated": "2026-09-23 11:25:30.698Z", + "system": false + }, + "events": { + "id": "pbc_1687431684", + "listRule": "", + "viewRule": "", + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "events", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "date2675529103", + "max": "", + "min": "", + "name": "start", + "presentable": false, + "required": true, + "system": false, + "type": "date" + }, + { + "help": "", + "hidden": false, + "id": "date16528305", + "max": "", + "min": "", + "name": "end", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text724990059", + "max": 0, + "min": 0, + "name": "title", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1587448267", + "max": 0, + "min": 0, + "name": "location", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3485334036", + "max": 0, + "min": 0, + "name": "note", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "bool1001664029", + "name": "public", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + } + ], + "indexes": [], + "created": "2026-09-23 10:38:12.110Z", + "updated": "2026-09-23 10:38:12.110Z", + "system": false + }, + "pages": { + "id": "pbc_3945946014", + "listRule": "", + "viewRule": "", + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "pages", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text724990059", + "max": 0, + "min": 0, + "name": "title", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3685223346", + "max": 0, + "min": 0, + "name": "body", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "select1587448267", + "maxSelect": 1, + "name": "location", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "header", + "footer" + ] + }, + { + "help": "", + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2560465762", + "max": 0, + "min": 0, + "name": "slug", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "select1203373331", + "maxSelect": 3, + "name": "embed", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "posts", + "events", + "faqs" + ] + }, + { + "help": "", + "hidden": false, + "id": "bool1001664029", + "name": "public", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + } + ], + "indexes": [], + "created": "2026-09-23 11:25:35.648Z", + "updated": "2026-09-23 11:25:35.648Z", + "system": false + }, + "faqs": { + "id": "pbc_2923026773", + "listRule": "", + "viewRule": "", + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "faqs", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3069659470", + "max": 0, + "min": 0, + "name": "question", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3671935525", + "max": 0, + "min": 0, + "name": "answer", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "select2638274075", + "maxSelect": 1, + "name": "topic", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "mensa", + "schliessfach", + "elterneuro", + "elternarbeit" + ] + }, + { + "help": "", + "hidden": false, + "id": "bool1001664029", + "name": "public", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + } + ], + "indexes": [], + "created": "2026-09-23 11:59:13.899Z", + "updated": "2026-09-23 11:59:13.899Z", + "system": false + } +}