Compare commits
8
Commits
c59e7a4f83
...
7337a1cbc0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7337a1cbc0 | ||
|
|
c3da5eb6e1 | ||
|
|
d5372963bb | ||
|
|
47d9841532 | ||
|
|
3fd3796d73 | ||
|
|
6b5d9dda55 | ||
|
|
4136af6417 | ||
|
|
da87716d97 |
No files matched your search
@@ -14,3 +14,8 @@ riderModule.iml
|
|||||||
|
|
||||||
# Secrets (Gitea-Token o.ae.) -- niemals ins Repo. Vorlage: .env.example
|
# Secrets (Gitea-Token o.ae.) -- niemals ins Repo. Vorlage: .env.example
|
||||||
.env
|
.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/
|
||||||
@@ -7,10 +7,11 @@
|
|||||||
<list />
|
<list />
|
||||||
</option>
|
</option>
|
||||||
<option name="commandLineOptions" value="--build" />
|
<option name="commandLineOptions" value="--build" />
|
||||||
<!-- Base compose (loaded first); the dev overlay in sourceFilePath wins. -->
|
<!-- The dev overlay must win over the base, so it is listed here:
|
||||||
|
secondarySourceFiles are merged after sourceFilePath. -->
|
||||||
<option name="secondarySourceFiles">
|
<option name="secondarySourceFiles">
|
||||||
<list>
|
<list>
|
||||||
<option value="$PROJECT_DIR$/compose.yaml" />
|
<option value="$PROJECT_DIR$/compose.dev.yaml" />
|
||||||
</list>
|
</list>
|
||||||
</option>
|
</option>
|
||||||
<option name="services">
|
<option name="services">
|
||||||
@@ -19,8 +20,8 @@
|
|||||||
<option value="eb-web" />
|
<option value="eb-web" />
|
||||||
</list>
|
</list>
|
||||||
</option>
|
</option>
|
||||||
<!-- Dev overlay (primary = last applied): host ports + local web build. -->
|
<!-- Base compose, merged first. -->
|
||||||
<option name="sourceFilePath" value="$PROJECT_DIR$/compose.dev.yaml" />
|
<option name="sourceFilePath" value="$PROJECT_DIR$/compose.yaml" />
|
||||||
<option name="upForceRecreate" value="true" />
|
<option name="upForceRecreate" value="true" />
|
||||||
</settings>
|
</settings>
|
||||||
</deployment>
|
</deployment>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
Website des Elternbeirats der IGMH. Blazor Web App mit statischem
|
Website des Elternbeirats der IGMH. Blazor Web App mit statischem
|
||||||
Server-Side-Rendering, .NET 10. Läuft als Container auf Unraid hinter dem Nginx
|
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
|
Architekturentscheidungen und deren Begründung: `docs/architektur.md`. Dort
|
||||||
nachlesen, bevor eine davon in Frage gestellt wird.
|
nachlesen, bevor eine davon in Frage gestellt wird.
|
||||||
@@ -11,7 +12,7 @@ nachlesen, bevor eine davon in Frage gestellt wird.
|
|||||||
|
|
||||||
| Thema | Datei |
|
| 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` |
|
| Stack lokal starten, Tests, compose-Overlays | `docs/entwicklung.md` |
|
||||||
| Unraid, compose, NPM-Proxy-Host, Registry, Rollback | `docs/deployment.md` |
|
| Unraid, compose, NPM-Proxy-Host, Registry, Rollback | `docs/deployment.md` |
|
||||||
| Inhalte anlegen und ändern (PocketBase-Admin) | `docs/redaktion.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
|
## Kommandos
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet run --project Elternbeirat.Web # lokal; Inhalte werden beim Start gelesen (kein Auto-Reload)
|
# Lokaler Stack (App + PocketBase) über das Dev-Overlay, siehe docs/entwicklung.md:
|
||||||
dotnet test # Smoke-Tests: jede Route liefert 200
|
docker compose -f compose.yaml -f compose.dev.yaml up --build # App :5000, PocketBase-Admin :8090/_/
|
||||||
docker compose build && docker compose up -d
|
dotnet test # Smoke-Tests (starten eigene PocketBase) + Unit-Tests
|
||||||
```
|
```
|
||||||
|
|
||||||
Es gibt keine Datenbank, keine Migrationen, kein Seeding. Ein `git clone` ist der
|
Die App liest ihren Inhalt pro Request aus PocketBase; sie hält selbst keinen
|
||||||
vollständige Stand.
|
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
|
## Nicht offensichtlich
|
||||||
|
|
||||||
- **Inhalte liegen im Image**, nicht in einem Volume. Jede Textänderung braucht
|
- **Inhalte liegen in PocketBase** (`pb_data`-Volume), nicht im Image. Eine
|
||||||
Commit und Rebuild. Das ist Absicht (`docs/architektur.md`, AE-3) — nicht
|
Textänderung ist ein Eintrag im PocketBase-Admin und sofort live — kein Commit,
|
||||||
„vereinfachen".
|
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
|
- **Kein `UseHttpsRedirection()`, kein `UseHsts()`.** NPM terminiert TLS; beides
|
||||||
erzeugt hinter dem Proxy eine Redirect-Schleife. `UseForwardedHeaders` mit
|
erzeugt hinter dem Proxy eine Redirect-Schleife. `UseForwardedHeaders` mit
|
||||||
geleerten `KnownNetworks`/`KnownProxies` ist korrekt so, weil der Container kein
|
geleerten `KnownNetworks`/`KnownProxies` ist korrekt so, weil der Container kein
|
||||||
Port-Mapping hat und nur über NPM erreichbar ist.
|
Port-Mapping hat und nur über NPM erreichbar ist.
|
||||||
- **Das chiseled-Runtime-Image hat keine Shell.** `HEALTHCHECK` mit `curl` oder
|
- **Das chiseled-Runtime-Image hat keine Shell.** `HEALTHCHECK` mit `curl` oder
|
||||||
`sh` schlägt dort fehl.
|
`sh` schlägt dort fehl.
|
||||||
- Neue Seite = Markdown in `Content/pages/`. Neuer Beitrag = Markdown in
|
- Neue Seite / neuer Beitrag / neuer Termin / neue Frage = **Record in der
|
||||||
`Content/posts/`. Neuer Termin = Eintrag in `Content/events.yml`. In allen
|
passenden PocketBase-Collection** (`pages` / `posts` / `events` / `faqs`), nicht
|
||||||
Fällen wird **kein** `.razor` angefasst.
|
eine Datei und **kein** `.razor`. `pages` mit `slug` ist sofort unter `/<slug>`
|
||||||
|
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`).
|
- Slugs ohne Umlaute (`ueber-uns`, nicht `über-uns`).
|
||||||
|
|
||||||
## Regeln
|
## Regeln
|
||||||
@@ -57,22 +66,28 @@ vollständige Stand.
|
|||||||
- **Keine externen Ressourcen.** Keine CDN-Skripte, keine Google Fonts, keine
|
- **Keine externen Ressourcen.** Keine CDN-Skripte, keine Google Fonts, keine
|
||||||
Maps- oder Video-Embeds. Schriften werden selbst ausgeliefert. Grund ist
|
Maps- oder Video-Embeds. Schriften werden selbst ausgeliefert. Grund ist
|
||||||
Datenschutz, nicht Geschmack (`docs/recht.md`).
|
Datenschutz, nicht Geschmack (`docs/recht.md`).
|
||||||
- **Keine Datenbank, kein EF Core, kein ORM.** Inhalte sind Dateien.
|
- **Kein EF Core, kein ORM, keine Migrationen.** Inhalte kommen aus PocketBase
|
||||||
- **Inhalte gehören nach `Content/`**, nicht als Markup in Komponenten.
|
über den `PocketBaseClient` (REST), nicht aus einer relationalen DB im Code.
|
||||||
- Kein CMS, kein Login, keine Benutzerkonten.
|
- **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`.
|
- 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
|
## Stil
|
||||||
|
|
||||||
- Nullable aktiviert, `ImplicitUsings` an, File-scoped Namespaces.
|
- Nullable aktiviert, `ImplicitUsings` an, File-scoped Namespaces.
|
||||||
- Services über DI, als Singleton registriert (Inhalte werden beim Start
|
- Datenzugriff über den typisierten `PocketBaseClient` (registriert via
|
||||||
eingelesen und gecacht).
|
`AddHttpClient`), der pro Request liest — kein Start-Cache, kein Singleton mit
|
||||||
- Öffentliche Typen und Methoden der `Services` bekommen XML-Doc (auf Englisch,
|
Inhalten. Komponenten liegen feature-basiert unter `Features/<Bereich>/`.
|
||||||
leicht verständlich), Razor-Markup nicht.
|
- Ö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,
|
- **Code durchgängig auf Englisch** — Typen, Member, Variablen, Kommentare,
|
||||||
Skripte und Doku. Das gilt **auch für Domänenbegriffe**: im Code `Event`, nicht
|
Skripte und Doku. Das gilt **auch für Domänenbegriffe**: im Code `Event`, nicht
|
||||||
`Termin`; `Post`, nicht `Beitrag`. Deutsch bleibt ausschließlich, was ein
|
`Termin`; `Post`, nicht `Beitrag`. Deutsch bleibt ausschließlich, was ein
|
||||||
Besucher liest oder ein Redakteur pflegt: UI-Texte, Markdown-Inhalte,
|
Besucher liest oder ein Redakteur pflegt: UI-Texte sowie die **Werte** der
|
||||||
Frontmatter-Schlüssel wie `titel:` sowie Slugs/Dateinamen unter `Content/`
|
PocketBase-Records (z. B. `title: Vorstandsteam`, der Markdown-`body`). Die
|
||||||
(z. B. `vorstandsteam`, `/termine`).
|
Feldnamen und Slugs bleiben dagegen englisch (`title`, `slug`, `/board`).
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="Markdig" Version="0.38.0" />
|
<PackageVersion Include="Markdig" Version="0.38.0" />
|
||||||
<PackageVersion Include="YamlDotNet" Version="16.2.1" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Test-only packages. -->
|
<!-- Test-only packages. -->
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.Web.Features.Layout;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for the navigation builder. Pure logic, no PocketBase needed.
|
||||||
|
/// </summary>
|
||||||
|
public class NavBuilderTests
|
||||||
|
{
|
||||||
|
private static Page Page(string slug, string location, double order, string title) =>
|
||||||
|
new() { Slug = slug, Location = location, Order = order, Title = title };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Header_keeps_only_header_pages_ordered_by_order()
|
||||||
|
{
|
||||||
|
var pages = new[]
|
||||||
|
{
|
||||||
|
Page("board", "header", 2, "Vorstandsteam"),
|
||||||
|
Page("home", "header", 1, "Start"),
|
||||||
|
Page("imprint", "footer", 1, "Impressum"),
|
||||||
|
Page("hidden", "", 1, "Unterseite"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var header = NavBuilder.Header(pages);
|
||||||
|
|
||||||
|
header.Select(link => link.Title).ShouldBe(["Start", "Vorstandsteam"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Footer_keeps_only_footer_pages_ordered_by_order()
|
||||||
|
{
|
||||||
|
var pages = new[]
|
||||||
|
{
|
||||||
|
Page("privacy", "footer", 2, "Datenschutz"),
|
||||||
|
Page("imprint", "footer", 1, "Impressum"),
|
||||||
|
Page("home", "header", 1, "Start"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var footer = NavBuilder.Footer(pages);
|
||||||
|
|
||||||
|
footer.Select(link => link.Title).ShouldBe(["Impressum", "Datenschutz"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Home_page_maps_to_root_every_other_slug_to_its_path()
|
||||||
|
{
|
||||||
|
var pages = new[]
|
||||||
|
{
|
||||||
|
Page("home", "header", 1, "Start"),
|
||||||
|
Page("board", "header", 2, "Vorstandsteam"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var header = NavBuilder.Header(pages);
|
||||||
|
|
||||||
|
header[0].Href.ShouldBe("/");
|
||||||
|
header[1].Href.ShouldBe("/board");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pages_without_a_location_never_appear_in_a_menu()
|
||||||
|
{
|
||||||
|
var pages = new[] { Page("hidden", "", 1, "Unterseite") };
|
||||||
|
|
||||||
|
NavBuilder.Header(pages).ShouldBeEmpty();
|
||||||
|
NavBuilder.Footer(pages).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ namespace Elternbeirat.Web.Tests;
|
|||||||
/// with a known seed (see <see cref="PocketBaseFixture"/>). Because the data is fixed,
|
/// with a known seed (see <see cref="PocketBaseFixture"/>). Because the data is fixed,
|
||||||
/// the tests assert on exact values, and they need no network to the live instance.
|
/// the tests assert on exact values, and they need no network to the live instance.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase) : IClassFixture<PocketBaseFixture>
|
[Collection(PocketBaseTestGroup.Name)]
|
||||||
|
public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Events_load_sorted_by_start()
|
public async Task Events_load_sorted_by_start()
|
||||||
@@ -69,9 +70,10 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase) : IClass
|
|||||||
|
|
||||||
var pages = await client.GetPagesAsync();
|
var pages = await client.GetPagesAsync();
|
||||||
|
|
||||||
// Three pages seeded, one with public=false; the draft must be filtered out.
|
// The draft (public=false) must be filtered out, the public ones present.
|
||||||
pages.Select(p => p.Slug).ShouldBe(["home", "contact"]);
|
|
||||||
pages.ShouldNotContain(p => p.Slug == "draft");
|
pages.ShouldNotContain(p => p.Slug == "draft");
|
||||||
|
pages.ShouldContain(p => p.Slug == "home");
|
||||||
|
pages.ShouldContain(p => p.Slug == "contact");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ using System.Diagnostics;
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
using Elternbeirat.PocketBase;
|
using Elternbeirat.PocketBase;
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Tests;
|
namespace Elternbeirat.Web.Tests;
|
||||||
@@ -16,9 +18,16 @@ namespace Elternbeirat.Web.Tests;
|
|||||||
/// (only the <c>eb-pocketbase</c> service, not the web app) by shelling out to
|
/// (only the <c>eb-pocketbase</c> service, not the web app) by shelling out to
|
||||||
/// <c>docker compose</c>, so the image version, superuser env and port stay defined
|
/// <c>docker compose</c>, so the image version, superuser env and port stay defined
|
||||||
/// in one place -- the compose files -- and the tests always exercise the same
|
/// 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
|
/// PocketBase the stack runs.
|
||||||
/// exported from the real instance, so tests assert against values this file
|
/// </para>
|
||||||
/// controls. Requires Docker with the Compose plugin.
|
/// <para>
|
||||||
|
/// The collection <b>schema</b> is not typed out here: it is imported from
|
||||||
|
/// <c>pb/pb_migrations/collections_schema.json</c>, the same file the dev seed
|
||||||
|
/// migration uses, so the schema is defined once and cannot drift between the two.
|
||||||
|
/// The seeded <b>records</b>, 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.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PocketBaseFixture : IAsyncLifetime
|
public sealed class PocketBaseFixture : IAsyncLifetime
|
||||||
@@ -49,6 +58,14 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
|||||||
/// <summary>Creates a <see cref="PocketBaseClient"/> pointed at this container.</summary>
|
/// <summary>Creates a <see cref="PocketBaseClient"/> pointed at this container.</summary>
|
||||||
public PocketBaseClient CreateClient() => new(_http);
|
public PocketBaseClient CreateClient() => new(_http);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public Uri BaseUrl => _http.BaseAddress
|
||||||
|
?? throw new InvalidOperationException("PocketBase is not started yet.");
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
// Clear any leftover from an earlier run that was aborted before DisposeAsync
|
// Clear any leftover from an earlier run that was aborted before DisposeAsync
|
||||||
@@ -137,15 +154,15 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Authenticates as superuser, then creates the collections and records. This
|
/// Authenticates as superuser, then imports the shared collection schema and
|
||||||
/// mirrors the shape the client reads, not the full production schema.
|
/// seeds the fixed test records the tests assert against.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task SeedAsync()
|
private async Task SeedAsync()
|
||||||
{
|
{
|
||||||
var token = await AuthenticateAsync(_http);
|
var token = await AuthenticateAsync(_http);
|
||||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(token);
|
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(token);
|
||||||
|
|
||||||
await CreateCollectionsAsync(_http);
|
await ImportCollectionsAsync(_http);
|
||||||
await SeedRecordsAsync(_http);
|
await SeedRecordsAsync(_http);
|
||||||
|
|
||||||
// Drop the superuser token so the tests read as an anonymous visitor would,
|
// Drop the superuser token so the tests read as an anonymous visitor would,
|
||||||
@@ -176,75 +193,84 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task CreateCollectionsAsync(HttpClient http)
|
/// <summary>
|
||||||
|
/// Imports the four content collections from the shared schema file, the same
|
||||||
|
/// one the dev seed migration reads (<c>pb/pb_migrations/collections_schema.json</c>).
|
||||||
|
/// Uses PocketBase's <c>/api/collections/import</c> so the whole schema is
|
||||||
|
/// defined in one place instead of being typed out here.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task ImportCollectionsAsync(HttpClient http)
|
||||||
{
|
{
|
||||||
// listRule/viewRule = "" means publicly readable; the client's
|
// The file maps name -> collection object; the import endpoint wants a
|
||||||
// filter=public=true does the visibility gating on top.
|
// flat list, so unwrap the values. deleteMissing=false leaves anything
|
||||||
await CreateCollectionAsync(http, "pages", new object[]
|
// already present untouched (the import is idempotent).
|
||||||
{
|
var path = Path.Combine(RepoRoot(), "pb", "pb_migrations", "collections_schema.json");
|
||||||
new { name = "title", type = "text", required = true },
|
using var schema = JsonDocument.Parse(await File.ReadAllTextAsync(path));
|
||||||
new { name = "body", type = "editor" },
|
var collections = schema.RootElement.EnumerateObject()
|
||||||
new { name = "location", type = "select", maxSelect = 1, values = new[] { "header", "footer" } },
|
.Select(property => property.Value)
|
||||||
new { name = "order", type = "number" },
|
.ToArray();
|
||||||
new { name = "slug", type = "text", required = true },
|
|
||||||
new { name = "embed", type = "select", maxSelect = 3, values = new[] { "posts", "events", "faqs" } },
|
|
||||||
new { name = "public", type = "bool" },
|
|
||||||
});
|
|
||||||
|
|
||||||
await CreateCollectionAsync(http, "posts", new object[]
|
// Serialize the JsonElement list back to JSON for the request body.
|
||||||
{
|
var body = JsonSerializer.Serialize(new { collections, deleteMissing = false });
|
||||||
new { name = "date", type = "date" },
|
using var content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||||
new { name = "title", type = "text", required = true },
|
var response = await http.PutAsync(new Uri("/api/collections/import", UriKind.Relative), content);
|
||||||
new { name = "body", type = "editor" },
|
|
||||||
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 = "editor" },
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task SeedRecordsAsync(HttpClient http)
|
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
|
await CreateRecordAsync(http, "pages", new
|
||||||
{
|
{
|
||||||
title = "Startseite", body = "# Willkommen", location = "header",
|
title = "Startseite", body = "# Willkommen", location = "header",
|
||||||
order = 1, slug = "home", embed = new[] { "posts", "events" }, @public = true,
|
order = 1, slug = "home", embed = new[] { "posts", "events" }, @public = true,
|
||||||
});
|
});
|
||||||
await CreateRecordAsync(http, "pages", new
|
await CreateRecordAsync(http, "pages", new
|
||||||
|
{
|
||||||
|
title = "Vorstandsteam", body = "# Vorstand", location = "header",
|
||||||
|
order = 2, slug = "board", embed = Array.Empty<string>(), @public = true,
|
||||||
|
});
|
||||||
|
await CreateRecordAsync(http, "pages", new
|
||||||
|
{
|
||||||
|
// Carries an embed so the ContentPage embed path is covered: /patrons
|
||||||
|
// renders its body plus the FAQ teaser.
|
||||||
|
title = "Förderverein", body = "# Förderverein", location = "header",
|
||||||
|
order = 3, slug = "patrons", embed = new[] { "faqs" }, @public = true,
|
||||||
|
});
|
||||||
|
// Nav placeholder for the FAQ list (route /faqs is served by FaqList,
|
||||||
|
// which shadows this page). Slug matches production's "faqs" nav entry.
|
||||||
|
await CreateRecordAsync(http, "pages", new
|
||||||
|
{
|
||||||
|
title = "FAQ", body = "# Häufige Fragen", location = "header",
|
||||||
|
order = 4, slug = "faqs", embed = Array.Empty<string>(), @public = true,
|
||||||
|
});
|
||||||
|
await CreateRecordAsync(http, "pages", new
|
||||||
|
{
|
||||||
|
title = "Downloads", body = "# Downloads", location = "header",
|
||||||
|
order = 5, slug = "downloads", embed = Array.Empty<string>(), @public = true,
|
||||||
|
});
|
||||||
|
await CreateRecordAsync(http, "pages", new
|
||||||
|
{
|
||||||
|
title = "FAQ Mensa", body = "# Mensa", location = "",
|
||||||
|
order = 0, slug = "faq-lunch", embed = Array.Empty<string>(), @public = true,
|
||||||
|
});
|
||||||
|
await CreateRecordAsync(http, "pages", new
|
||||||
{
|
{
|
||||||
title = "Kontakt", body = "Mail an uns", location = "footer",
|
title = "Kontakt", body = "Mail an uns", location = "footer",
|
||||||
order = 1, slug = "contact", embed = Array.Empty<string>(), @public = true,
|
order = 1, slug = "contact", embed = Array.Empty<string>(), @public = true,
|
||||||
});
|
});
|
||||||
|
await CreateRecordAsync(http, "pages", new
|
||||||
|
{
|
||||||
|
title = "Impressum", body = "# Impressum", location = "footer",
|
||||||
|
order = 2, slug = "imprint", embed = Array.Empty<string>(), @public = true,
|
||||||
|
});
|
||||||
|
await CreateRecordAsync(http, "pages", new
|
||||||
|
{
|
||||||
|
title = "Datenschutz", body = "# Datenschutz", location = "footer",
|
||||||
|
order = 3, slug = "privacy", embed = Array.Empty<string>(), @public = true,
|
||||||
|
});
|
||||||
// A draft page that must never appear (public=false).
|
// A draft page that must never appear (public=false).
|
||||||
await CreateRecordAsync(http, "pages", new
|
await CreateRecordAsync(http, "pages", new
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Elternbeirat.Web.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Groups every test that needs the seeded PocketBase container into one xUnit
|
||||||
|
/// collection, so the single <see cref="PocketBaseFixture"/> (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 <c>eb-test-stack</c>; sharing avoids two classes fighting over it.
|
||||||
|
/// </summary>
|
||||||
|
[CollectionDefinition(Name)]
|
||||||
|
public sealed class PocketBaseTestGroup : ICollectionFixture<PocketBaseFixture>
|
||||||
|
{
|
||||||
|
public const string Name = "PocketBase";
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Tests;
|
namespace Elternbeirat.Web.Tests;
|
||||||
@@ -7,39 +8,57 @@ namespace Elternbeirat.Web.Tests;
|
|||||||
/// Smoke tests: every known route must return 200, unknown routes must return
|
/// Smoke tests: every known route must return 200, unknown routes must return
|
||||||
/// 404. This catches broken content files, renamed slugs or routing regressions
|
/// 404. This catches broken content files, renamed slugs or routing regressions
|
||||||
/// before a deploy.
|
/// before a deploy.
|
||||||
|
/// <para>
|
||||||
|
/// The post routes now read from PocketBase, so the tests point the web app at the
|
||||||
|
/// seeded container from <see cref="PocketBaseFixture"/> (shared via the
|
||||||
|
/// collection) and assert against its known slugs. Requires Docker, like the
|
||||||
|
/// client tests.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RouteSmokeTests : IClassFixture<WebApplicationFactory<Program>>
|
[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<Program> _baseFactory = new();
|
||||||
private readonly WebApplicationFactory<Program> _factory;
|
private readonly WebApplicationFactory<Program> _factory;
|
||||||
|
|
||||||
public RouteSmokeTests(WebApplicationFactory<Program> 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Every route that a visitor can reach through the navigation, the FAQ hub
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static TheoryData<string> KnownRoutes =>
|
public static TheoryData<string> KnownRoutes =>
|
||||||
[
|
[
|
||||||
"/",
|
"/",
|
||||||
"/vorstandsteam",
|
"/board",
|
||||||
"/foerderverein",
|
"/patrons",
|
||||||
"/faq",
|
"/faqs",
|
||||||
"/faq-mensa",
|
"/faq-lunch",
|
||||||
"/faq-schliessfach",
|
|
||||||
"/faq-elterneuro",
|
|
||||||
"/faq-elternarbeit",
|
|
||||||
"/downloads",
|
"/downloads",
|
||||||
"/kontakt",
|
"/contact",
|
||||||
"/impressum",
|
"/imprint",
|
||||||
"/datenschutz",
|
"/privacy",
|
||||||
"/beitraege",
|
"/posts",
|
||||||
"/beitraege/neuer-vorstand-gewaehlt",
|
"/posts/new-board",
|
||||||
"/beitraege/neue-sporthalle-eroeffnet",
|
"/posts/new-hall",
|
||||||
"/termine",
|
"/events",
|
||||||
"/termine.ics",
|
"/events.ics",
|
||||||
];
|
];
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
@@ -55,7 +74,7 @@ public sealed class RouteSmokeTests : IClassFixture<WebApplicationFactory<Progra
|
|||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("/gibt-es-nicht")]
|
[InlineData("/gibt-es-nicht")]
|
||||||
[InlineData("/beitraege/gibt-es-nicht")]
|
[InlineData("/posts/gibt-es-nicht")]
|
||||||
public async Task Unknown_route_returns_404(string route)
|
public async Task Unknown_route_returns_404(string route)
|
||||||
{
|
{
|
||||||
var client = _factory.CreateClient();
|
var client = _factory.CreateClient();
|
||||||
@@ -64,4 +83,62 @@ public sealed class RouteSmokeTests : IClassFixture<WebApplicationFactory<Progra
|
|||||||
|
|
||||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Faqs_route_renders_the_grouped_list()
|
||||||
|
{
|
||||||
|
// /faqs is a literal route (FaqList) that must win over the /{Slug}
|
||||||
|
// catch-all content page. Assert it shows the FAQ list, grouped by topic
|
||||||
|
// from the seeded questions, not a content page body.
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/faqs", UriKind.Relative));
|
||||||
|
|
||||||
|
html.ShouldContain("Häufige Fragen");
|
||||||
|
html.ShouldContain("Wann gibt es Mittagessen?");
|
||||||
|
html.ShouldContain("Mensa und Mittagessen"); // German topic heading
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Layout_navigation_is_built_from_the_pages()
|
||||||
|
{
|
||||||
|
// The header and footer menus are generated from the "pages" records, not
|
||||||
|
// hard-coded. Assert a header link, a footer link, and that the non-public
|
||||||
|
// draft page never leaks into the menu.
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
|
||||||
|
|
||||||
|
html.ShouldContain("href=\"/board\""); // header page
|
||||||
|
html.ShouldContain("href=\"/imprint\""); // footer page
|
||||||
|
html.ShouldNotContain("href=\"/draft\""); // public=false, filtered out
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Home_renders_the_embeds_its_page_opts_into()
|
||||||
|
{
|
||||||
|
// The home page's embed field is ["posts","events"], so the root route
|
||||||
|
// renders the posts teaser and the events teaser below the intro.
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
|
||||||
|
|
||||||
|
html.ShouldContain("Aktuelle Beiträge"); // posts embed heading
|
||||||
|
html.ShouldContain("Neuer Vorstand"); // a seeded post title
|
||||||
|
html.ShouldContain("Kommende Termine"); // events embed heading
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Content_page_renders_the_embed_its_page_opts_into()
|
||||||
|
{
|
||||||
|
// /patrons is a catch-all content page whose embed field is ["faqs"], so it
|
||||||
|
// shows its body plus the FAQ teaser -- proving the ContentPage embed path.
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var html = await client.GetStringAsync(new Uri("/patrons", UriKind.Relative));
|
||||||
|
|
||||||
|
html.ShouldContain("Förderverein"); // the page body
|
||||||
|
html.ShouldContain("Häufige Fragen"); // faqs embed heading
|
||||||
|
html.ShouldContain("Wann gibt es Mittagessen?"); // a seeded question
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,14 +5,10 @@
|
|||||||
<div class="header-inner">
|
<div class="header-inner">
|
||||||
<a class="brand" href="/">Elternbeirat IGMH</a>
|
<a class="brand" href="/">Elternbeirat IGMH</a>
|
||||||
<nav class="main-nav">
|
<nav class="main-nav">
|
||||||
<NavLink href="/" Match="NavLinkMatch.All">Start</NavLink>
|
@foreach (var link in _header)
|
||||||
<NavLink href="/vorstandsteam">Vorstandsteam</NavLink>
|
{
|
||||||
<NavLink href="/foerderverein">Förderverein</NavLink>
|
<NavLink href="@link.Href" Match="@(link.Href == "/" ? NavLinkMatch.All : NavLinkMatch.Prefix)">@link.Title</NavLink>
|
||||||
<NavLink href="/beitraege">Beiträge</NavLink>
|
}
|
||||||
<NavLink href="/termine">Termine</NavLink>
|
|
||||||
<NavLink href="/faq">FAQ</NavLink>
|
|
||||||
<NavLink href="/downloads">Downloads</NavLink>
|
|
||||||
<NavLink href="/kontakt">Kontakt</NavLink>
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -25,9 +21,10 @@
|
|||||||
<div class="footer-inner">
|
<div class="footer-inner">
|
||||||
<span>Elternbeirat der IGMH</span>
|
<span>Elternbeirat der IGMH</span>
|
||||||
<nav class="footer-nav">
|
<nav class="footer-nav">
|
||||||
<a href="/kontakt">Kontakt</a>
|
@foreach (var link in _footer)
|
||||||
<a href="/impressum">Impressum</a>
|
{
|
||||||
<a href="/datenschutz">Datenschutz</a>
|
<a href="@link.Href">@link.Title</a>
|
||||||
|
}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Elternbeirat.PocketBase;
|
||||||
|
using Elternbeirat.Web.Features.Layout;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Components.Layout;
|
||||||
|
|
||||||
|
public partial class MainLayout
|
||||||
|
{
|
||||||
|
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load navigation pages from PocketBase.")]
|
||||||
|
private static partial void LogLoadFailed(ILogger logger, Exception exception);
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private ILogger<MainLayout> Logger { get; set; } = default!;
|
||||||
|
|
||||||
|
private IReadOnlyList<NavLinkItem> _header = [];
|
||||||
|
private IReadOnlyList<NavLinkItem> _footer = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pages = await PocketBase.GetPagesAsync();
|
||||||
|
_header = NavBuilder.Header(pages);
|
||||||
|
_footer = NavBuilder.Footer(pages);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
// Source unreachable -> render the chrome with an empty menu instead of
|
||||||
|
// a 500. The brand link still leads home.
|
||||||
|
LogLoadFailed(Logger, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
@@ -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.*
|
|
||||||
@@ -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)*
|
|
||||||
@@ -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).
|
|
||||||
@@ -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).
|
|
||||||
@@ -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.*
|
|
||||||
@@ -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.*
|
|
||||||
@@ -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.*
|
|
||||||
@@ -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.*
|
|
||||||
@@ -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.*
|
|
||||||
@@ -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.*
|
|
||||||
@@ -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.*
|
|
||||||
@@ -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.*
|
|
||||||
@@ -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).*
|
|
||||||
@@ -19,13 +19,12 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Markdig" />
|
<PackageReference Include="Markdig" />
|
||||||
<PackageReference Include="YamlDotNet" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Content ships as files inside the image, not in a volume. Copy it to
|
<!-- The typed PocketBaseClient the app reads its content from. Referencing
|
||||||
the output directory so the container can find it. -->
|
PocketBase brings in Elternbeirat.Contracts (the DTOs) transitively. -->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="Content\**\*" CopyToOutputDirectory="PreserveNewest" />
|
<ProjectReference Include="..\Elternbeirat.PocketBase\Elternbeirat.PocketBase.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<section class="embed embed-events">
|
||||||
|
<h2>Kommende Termine</h2>
|
||||||
|
|
||||||
|
@if (_upcoming.Count == 0)
|
||||||
|
{
|
||||||
|
<p>Zurzeit sind keine Termine geplant.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<ul class="event-list">
|
||||||
|
@foreach (var ev in _upcoming)
|
||||||
|
{
|
||||||
|
<li>
|
||||||
|
<time datetime="@ev.Start.ToString("s")">@EventFormat.Range(ev)</time>
|
||||||
|
<span class="event-title">@ev.Title</span>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(ev.Location))
|
||||||
|
{
|
||||||
|
<span class="event-location">@ev.Location</span>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
<p><a href="/events">Alle Termine →</a></p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.PocketBase;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Features.Embeds;
|
||||||
|
|
||||||
|
public partial class EventsEmbed
|
||||||
|
{
|
||||||
|
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load events for an embed from PocketBase.")]
|
||||||
|
private static partial void LogLoadFailed(ILogger logger, Exception exception);
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private ILogger<EventsEmbed> Logger { get; set; } = default!;
|
||||||
|
|
||||||
|
private IReadOnlyList<Event> _upcoming = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
IReadOnlyList<Event> events;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
events = await PocketBase.GetEventsAsync();
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
// Source unreachable -> show no teaser instead of a 500.
|
||||||
|
LogLoadFailed(Logger, ex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teaser: the three soonest upcoming events, earliest first. An event
|
||||||
|
// earlier today still counts as upcoming.
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
_upcoming = events
|
||||||
|
.Where(e => DateOnly.FromDateTime(e.Start) >= today)
|
||||||
|
.OrderBy(e => e.Start)
|
||||||
|
.Take(3)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
.embed-events {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-list li {
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-list time {
|
||||||
|
display: block;
|
||||||
|
color: #777;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-location::before {
|
||||||
|
content: " · ";
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-location {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<section class="embed embed-faqs">
|
||||||
|
<h2>Häufige Fragen</h2>
|
||||||
|
|
||||||
|
@if (_faqs.Count == 0)
|
||||||
|
{
|
||||||
|
<p>Zurzeit gibt es keine Fragen.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (var faq in _faqs)
|
||||||
|
{
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>@faq.Question</summary>
|
||||||
|
<div class="faq-answer">
|
||||||
|
@((MarkupString)Markdown.ToHtml(faq.Answer))
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
}
|
||||||
|
<p><a href="/faqs">Alle Fragen →</a></p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.PocketBase;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Features.Embeds;
|
||||||
|
|
||||||
|
public partial class FaqsEmbed
|
||||||
|
{
|
||||||
|
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load FAQs for an embed from PocketBase.")]
|
||||||
|
private static partial void LogLoadFailed(ILogger logger, Exception exception);
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private ILogger<FaqsEmbed> Logger { get; set; } = default!;
|
||||||
|
|
||||||
|
private IReadOnlyList<Faq> _faqs = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Teaser: the first three questions, with a link to the full hub.
|
||||||
|
var faqs = await PocketBase.GetFaqsAsync();
|
||||||
|
_faqs = [.. faqs.Take(3)];
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
// Source unreachable -> show no teaser instead of a 500.
|
||||||
|
LogLoadFailed(Logger, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
.embed-faqs {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item {
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-answer {
|
||||||
|
padding: 0.5rem 0 0.25rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
@* Renders the dynamic blocks a page opts into via its "embed" field, in the
|
||||||
|
given order. Unknown keys are ignored so a typo in PocketBase can never break
|
||||||
|
a page. Each block loads its own data. *@
|
||||||
|
|
||||||
|
@foreach (var key in Embed)
|
||||||
|
{
|
||||||
|
switch (key)
|
||||||
|
{
|
||||||
|
case "posts":
|
||||||
|
<PostsEmbed />
|
||||||
|
break;
|
||||||
|
case "events":
|
||||||
|
<EventsEmbed />
|
||||||
|
break;
|
||||||
|
case "faqs":
|
||||||
|
<FaqsEmbed />
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The page's embed keys ("posts", "events", "faqs"), in order.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public IReadOnlyList<string> Embed { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<section class="embed embed-posts">
|
||||||
|
<h2>Aktuelle Beiträge</h2>
|
||||||
|
|
||||||
|
@if (_latest.Count == 0)
|
||||||
|
{
|
||||||
|
<p>Zurzeit gibt es keine Beiträge.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<ul class="post-list">
|
||||||
|
@foreach (var post in _latest)
|
||||||
|
{
|
||||||
|
<li>
|
||||||
|
<a href="@($"/posts/{post.Slug}")">@post.Title</a>
|
||||||
|
<time datetime="@post.Date.ToString("yyyy-MM-dd")">
|
||||||
|
@post.Date.ToString("d. MMMM yyyy", German)
|
||||||
|
</time>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
<p><a href="/posts">Alle Beiträge →</a></p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.PocketBase;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Features.Embeds;
|
||||||
|
|
||||||
|
public partial class PostsEmbed
|
||||||
|
{
|
||||||
|
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
||||||
|
|
||||||
|
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load posts for an embed from PocketBase.")]
|
||||||
|
private static partial void LogLoadFailed(ILogger logger, Exception exception);
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private ILogger<PostsEmbed> Logger { get; set; } = default!;
|
||||||
|
|
||||||
|
private IReadOnlyList<Post> _latest = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Teaser: the three most recent posts, with a link to the full list.
|
||||||
|
var posts = await PocketBase.GetPostsAsync();
|
||||||
|
_latest = [.. posts.Take(3)];
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
// Source unreachable -> show no teaser instead of a 500.
|
||||||
|
LogLoadFailed(Logger, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
.embed-posts {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-list li {
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-list a {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-list time {
|
||||||
|
display: block;
|
||||||
|
color: #777;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
namespace Elternbeirat.Web.Features.Events;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A single calendar entry (parents' evening, meeting, deadline). Read from
|
|
||||||
/// <c>Content/events.yml</c> at startup. All display text is German because it
|
|
||||||
/// is visitor-facing content.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class Event
|
|
||||||
{
|
|
||||||
/// <summary>Short title shown in the list and the calendar.</summary>
|
|
||||||
public required string Title { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// When the entry starts. A date-only entry (no time) is treated as an
|
|
||||||
/// all-day event; see <see cref="HasTime"/>.
|
|
||||||
/// </summary>
|
|
||||||
public required DateTime Start { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// True when <see cref="Start"/> carries a wall-clock time, false for an
|
|
||||||
/// all-day entry. Drives both the list formatting and the ICS export.
|
|
||||||
/// </summary>
|
|
||||||
public required bool HasTime { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Optional end, for multi-hour or multi-day entries.</summary>
|
|
||||||
public DateTime? End { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Optional location, e.g. "Aula" or "Mensa".</summary>
|
|
||||||
public string? Location { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Optional free-text note shown below the entry.</summary>
|
|
||||||
public string? Note { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
@page "/termine"
|
@page "/events"
|
||||||
@inject EventService EventService
|
|
||||||
|
|
||||||
<PageTitle>Termine</PageTitle>
|
<PageTitle>Termine</PageTitle>
|
||||||
|
|
||||||
@@ -7,23 +6,23 @@
|
|||||||
|
|
||||||
<p class="intro">
|
<p class="intro">
|
||||||
Alle Termine des Elternbeirats auf einen Blick. Sie können den Kalender auch
|
Alle Termine des Elternbeirats auf einen Blick. Sie können den Kalender auch
|
||||||
<a href="/termine.ics">abonnieren</a> und in Ihrer Kalender-App automatisch
|
<a href="/events.ics">abonnieren</a> und in Ihrer Kalender-App automatisch
|
||||||
aktuell halten.
|
aktuell halten.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Kommende Termine</h2>
|
<h2>Kommende Termine</h2>
|
||||||
|
|
||||||
@if (EventService.Upcoming.Count == 0)
|
@if (_upcoming.Count == 0)
|
||||||
{
|
{
|
||||||
<p>Zurzeit sind keine Termine geplant.</p>
|
<p>Zurzeit sind keine Termine geplant.</p>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<ul class="event-list">
|
<ul class="event-list">
|
||||||
@foreach (var ev in EventService.Upcoming)
|
@foreach (var ev in _upcoming)
|
||||||
{
|
{
|
||||||
<li>
|
<li>
|
||||||
<time datetime="@ev.Start.ToString("s")">@Format(ev)</time>
|
<time datetime="@ev.Start.ToString("s")">@EventFormat.Range(ev)</time>
|
||||||
<span class="event-title">@ev.Title</span>
|
<span class="event-title">@ev.Title</span>
|
||||||
@if (!string.IsNullOrWhiteSpace(ev.Location))
|
@if (!string.IsNullOrWhiteSpace(ev.Location))
|
||||||
{
|
{
|
||||||
@@ -38,14 +37,14 @@ else
|
|||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (EventService.Past.Count > 0)
|
@if (_past.Count > 0)
|
||||||
{
|
{
|
||||||
<h2>Vergangene Termine</h2>
|
<h2>Vergangene Termine</h2>
|
||||||
<ul class="event-list event-list-past">
|
<ul class="event-list event-list-past">
|
||||||
@foreach (var ev in EventService.Past)
|
@foreach (var ev in _past)
|
||||||
{
|
{
|
||||||
<li>
|
<li>
|
||||||
<time datetime="@ev.Start.ToString("s")">@Format(ev)</time>
|
<time datetime="@ev.Start.ToString("s")">@EventFormat.Range(ev)</time>
|
||||||
<span class="event-title">@ev.Title</span>
|
<span class="event-title">@ev.Title</span>
|
||||||
@if (!string.IsNullOrWhiteSpace(ev.Location))
|
@if (!string.IsNullOrWhiteSpace(ev.Location))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,37 +1,48 @@
|
|||||||
using System.Globalization;
|
using Elternbeirat.Contracts;
|
||||||
using Elternbeirat.Web.Services;
|
using Elternbeirat.PocketBase;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Features.Events;
|
namespace Elternbeirat.Web.Features.Events;
|
||||||
|
|
||||||
public partial class EventList
|
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);
|
||||||
|
|
||||||
// Formats the entry's date range for display: a single day, a date with a
|
[Inject]
|
||||||
// time, or a span across days.
|
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||||
private static string Format(Event ev)
|
|
||||||
|
[Inject]
|
||||||
|
private ILogger<EventList> Logger { get; set; } = default!;
|
||||||
|
|
||||||
|
private IReadOnlyList<Event> _upcoming = [];
|
||||||
|
private IReadOnlyList<Event> _past = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
var start = ev.HasTime
|
IReadOnlyList<Event> events;
|
||||||
? ev.Start.ToString("dddd, d. MMMM yyyy, HH:mm", German) + " Uhr"
|
try
|
||||||
: ev.Start.ToString("dddd, d. MMMM yyyy", German);
|
|
||||||
|
|
||||||
if (ev.End is not { } end)
|
|
||||||
{
|
{
|
||||||
return start;
|
events = await PocketBase.GetEventsAsync();
|
||||||
}
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
// Same day: append just the end time. Different days: append the full
|
|
||||||
// end date.
|
|
||||||
if (end.Date == ev.Start.Date)
|
|
||||||
{
|
{
|
||||||
return ev.HasTime
|
// Source unreachable -> show empty sections instead of a 500. The page
|
||||||
? start + "–" + end.ToString("HH:mm", German) + " Uhr"
|
// stays up; the "keine Termine" note is rendered.
|
||||||
: start;
|
LogLoadFailed(Logger, ex);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var endText = ev.HasTime
|
// Split into upcoming and past by date, so an event earlier today still
|
||||||
? end.ToString("d. MMMM yyyy, HH:mm", German) + " Uhr"
|
// counts as upcoming. Upcoming earliest first, past most recent first.
|
||||||
: end.ToString("d. MMMM yyyy", German);
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
return start + " – " + endText;
|
_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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using Elternbeirat.Web.Services;
|
|
||||||
using YamlDotNet.Serialization;
|
|
||||||
using YamlDotNet.Serialization.NamingConventions;
|
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Features.Events;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads the calendar entries from <c>Content/events.yml</c> 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.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class EventService
|
|
||||||
{
|
|
||||||
private readonly IReadOnlyList<Event> _events;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads and parses <c>Content/events.yml</c> below the application root.
|
|
||||||
/// A missing file yields an empty list rather than an error.
|
|
||||||
/// </summary>
|
|
||||||
public EventService(IWebHostEnvironment environment)
|
|
||||||
{
|
|
||||||
var path = Path.Combine(environment.ContentRootPath, "Content", "events.yml");
|
|
||||||
_events = File.Exists(path)
|
|
||||||
? Parse(File.ReadAllText(path))
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<Event> Upcoming =>
|
|
||||||
_events
|
|
||||||
.Where(e => DateOnly.FromDateTime(e.Start) >= DateOnly.FromDateTime(DateTime.Today))
|
|
||||||
.OrderBy(e => e.Start)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Entries that already passed, most recent first — the past section shown
|
|
||||||
/// below the upcoming ones.
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<Event> Past =>
|
|
||||||
_events
|
|
||||||
.Where(e => DateOnly.FromDateTime(e.Start) < DateOnly.FromDateTime(DateTime.Today))
|
|
||||||
.OrderByDescending(e => e.Start)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
/// <summary>All entries in chronological order, for the ICS export.</summary>
|
|
||||||
public IReadOnlyList<Event> All =>
|
|
||||||
_events.OrderBy(e => e.Start).ToList();
|
|
||||||
|
|
||||||
private static List<Event> 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<List<EventEntry>?>(yaml) ?? [];
|
|
||||||
return raw
|
|
||||||
.Where(entry => entry.Title is not null && entry.Start is not null)
|
|
||||||
.Select(ToEvent)
|
|
||||||
.OfType<Event>()
|
|
||||||
.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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses a value that is either a date (<c>2026-03-15</c>) or a date with a
|
|
||||||
/// time (<c>2026-03-15 19:30</c>). Sets <paramref name="hasTime"/> so the
|
|
||||||
/// caller knows whether to treat the entry as all-day.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Elternbeirat.Web.Services;
|
using Elternbeirat.Contracts;
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Features.Events;
|
namespace Elternbeirat.Web.Features.Events;
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ public static class IcsCalendar
|
|||||||
// between requests, so caches and clients do not see spurious changes.
|
// between requests, so caches and clients do not see spurious changes.
|
||||||
AppendLine(sb, "DTSTAMP:20000101T000000Z");
|
AppendLine(sb, "DTSTAMP:20000101T000000Z");
|
||||||
|
|
||||||
if (ev.HasTime)
|
if (HasTime(ev))
|
||||||
{
|
{
|
||||||
AppendLine(sb, $"DTSTART:{Local(ev.Start)}");
|
AppendLine(sb, $"DTSTART:{Local(ev.Start)}");
|
||||||
if (ev.End is { } end)
|
if (ev.End is { } end)
|
||||||
@@ -75,6 +75,12 @@ public static class IcsCalendar
|
|||||||
AppendLine(sb, "END:VEVENT");
|
AppendLine(sb, "END:VEVENT");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static bool HasTime(Event ev) => ev.Start.TimeOfDay != TimeSpan.Zero;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A UID that stays the same as long as the entry's title and start do, so a
|
/// 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.
|
/// re-subscribe updates the event instead of creating a duplicate.
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
@page "/faqs"
|
||||||
|
|
||||||
|
<PageTitle>Häufige Fragen</PageTitle>
|
||||||
|
|
||||||
|
<h1>Häufige Fragen</h1>
|
||||||
|
|
||||||
|
@if (_groups.Count == 0)
|
||||||
|
{
|
||||||
|
<p>Zurzeit gibt es keine Fragen.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (var group in _groups)
|
||||||
|
{
|
||||||
|
<section class="faq-topic">
|
||||||
|
<h2>@TopicLabel(group.Key)</h2>
|
||||||
|
@foreach (var faq in group)
|
||||||
|
{
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>@faq.Question</summary>
|
||||||
|
<div class="faq-answer">
|
||||||
|
@((MarkupString)Markdown.ToHtml(faq.Answer))
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.PocketBase;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Features.Faqs;
|
||||||
|
|
||||||
|
public partial class FaqList
|
||||||
|
{
|
||||||
|
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load FAQs from PocketBase.")]
|
||||||
|
private static partial void LogLoadFailed(ILogger logger, Exception exception);
|
||||||
|
|
||||||
|
// German heading per topic. Unknown topics fall back to the raw key so a new
|
||||||
|
// topic in PocketBase still shows up rather than being silently dropped.
|
||||||
|
private static readonly Dictionary<string, string> TopicLabels =
|
||||||
|
new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["mensa"] = "Mensa und Mittagessen",
|
||||||
|
["schliessfach"] = "Schließfächer",
|
||||||
|
["elterneuro"] = "Eltern-Euro",
|
||||||
|
["elternarbeit"] = "Elternarbeit und Mitmachen",
|
||||||
|
};
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private ILogger<FaqList> Logger { get; set; } = default!;
|
||||||
|
|
||||||
|
private IReadOnlyList<IGrouping<string, Faq>> _groups = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var faqs = await PocketBase.GetFaqsAsync();
|
||||||
|
_groups = [.. faqs.GroupBy(faq => faq.Topic)];
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
// Source unreachable -> show an empty page instead of a 500. The page
|
||||||
|
// stays up; the "keine Fragen" note is rendered.
|
||||||
|
LogLoadFailed(Logger, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TopicLabel(string topic) =>
|
||||||
|
TopicLabels.TryGetValue(topic, out var label) ? label : topic;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
.faq-topic {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-topic h2 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item {
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-answer {
|
||||||
|
padding: 0.5rem 0 0.25rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
@inject PostService PostService
|
|
||||||
|
|
||||||
<PageTitle>Elternbeirat der IGMH</PageTitle>
|
<PageTitle>Elternbeirat der IGMH</PageTitle>
|
||||||
|
|
||||||
@@ -10,26 +9,4 @@
|
|||||||
Antworten auf häufige Fragen und Unterlagen rund um die Elternarbeit.
|
Antworten auf häufige Fragen und Unterlagen rund um die Elternarbeit.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<section class="home-news">
|
<PageEmbeds Embed="_embed" />
|
||||||
<h2>Aktuelle Beiträge</h2>
|
|
||||||
|
|
||||||
@if (_latest.Count == 0)
|
|
||||||
{
|
|
||||||
<p>Zurzeit gibt es keine Beiträge.</p>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<ul class="post-list">
|
|
||||||
@foreach (var post in _latest)
|
|
||||||
{
|
|
||||||
<li>
|
|
||||||
<a href="@($"/beitraege/{post.Slug}")">@post.Title</a>
|
|
||||||
<time datetime="@post.Date.ToString("yyyy-MM-dd")">
|
|
||||||
@post.Date.ToString("d. MMMM yyyy", German)
|
|
||||||
</time>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
<p><a href="/beitraege">Alle Beiträge →</a></p>
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
@@ -1,17 +1,36 @@
|
|||||||
using System.Globalization;
|
using Elternbeirat.PocketBase;
|
||||||
using Elternbeirat.Web.Services;
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Features.Home;
|
namespace Elternbeirat.Web.Features.Home;
|
||||||
|
|
||||||
public partial class Home
|
public partial class Home
|
||||||
{
|
{
|
||||||
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load the home page from PocketBase.")]
|
||||||
|
private static partial void LogLoadFailed(ILogger logger, Exception exception);
|
||||||
|
|
||||||
private IReadOnlyList<Post> _latest = [];
|
[Inject]
|
||||||
|
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||||
|
|
||||||
protected override void OnInitialized()
|
[Inject]
|
||||||
|
private ILogger<Home> Logger { get; set; } = default!;
|
||||||
|
|
||||||
|
// Which dynamic blocks the home page shows is editorial: it comes from the
|
||||||
|
// "home" page's embed field in PocketBase, not from hard-coded markup.
|
||||||
|
private IReadOnlyList<string> _embed = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
// Show the three most recent posts as a teaser on the home page.
|
try
|
||||||
_latest = [.. PostService.All.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 -> render the intro without embeds instead of a 500.
|
||||||
|
LogLoadFailed(Logger, ex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,28 +2,3 @@
|
|||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
color: #444;
|
color: #444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-news {
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-list li {
|
|
||||||
padding: 0.75rem 0;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-list a {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-list time {
|
|
||||||
display: block;
|
|
||||||
color: #777;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Elternbeirat.Contracts;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Features.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the header and footer navigation from the public pages. Pages carry a
|
||||||
|
/// <see cref="Page.Location"/> ("header"/"footer"/"") and an <see cref="Page.Order"/>;
|
||||||
|
/// pages without a location are sub-pages and never appear in a menu.
|
||||||
|
/// </summary>
|
||||||
|
public static class NavBuilder
|
||||||
|
{
|
||||||
|
/// <summary>Header links, ordered, built from pages with location "header".</summary>
|
||||||
|
public static IReadOnlyList<NavLinkItem> Header(IReadOnlyList<Page> pages) =>
|
||||||
|
Build(pages, "header");
|
||||||
|
|
||||||
|
/// <summary>Footer links, ordered, built from pages with location "footer".</summary>
|
||||||
|
public static IReadOnlyList<NavLinkItem> Footer(IReadOnlyList<Page> pages) =>
|
||||||
|
Build(pages, "footer");
|
||||||
|
|
||||||
|
private static List<NavLinkItem> Build(IReadOnlyList<Page> pages, string location) =>
|
||||||
|
[.. pages
|
||||||
|
.Where(page => string.Equals(page.Location, location, StringComparison.Ordinal))
|
||||||
|
.OrderBy(page => page.Order)
|
||||||
|
.Select(page => new NavLinkItem(HrefFor(page.Slug), page.Title))];
|
||||||
|
|
||||||
|
// The home page owns "/" (served by the Home component); every other slug is
|
||||||
|
// reached at "/{slug}", where either a literal route or the catch-all content
|
||||||
|
// page renders it.
|
||||||
|
private static string HrefFor(string slug) =>
|
||||||
|
string.Equals(slug, "home", StringComparison.Ordinal) ? "/" : $"/{slug}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Elternbeirat.Web.Features.Layout;
|
||||||
|
|
||||||
|
/// <summary>A single navigation entry derived from a page record.</summary>
|
||||||
|
/// <param name="Href">Target URL; the "home" page maps to "/".</param>
|
||||||
|
/// <param name="Title">Link text shown to the visitor.</param>
|
||||||
|
public sealed record NavLinkItem(string Href, string Title);
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
@page "/{Slug}"
|
@page "/{Slug}"
|
||||||
@inject PageService PageService
|
|
||||||
|
|
||||||
@if (_page is null)
|
@if (_page is null)
|
||||||
{
|
{
|
||||||
@@ -9,6 +8,7 @@ else
|
|||||||
{
|
{
|
||||||
<PageTitle>@_page.Title</PageTitle>
|
<PageTitle>@_page.Title</PageTitle>
|
||||||
<article>
|
<article>
|
||||||
@((MarkupString)_page.ContentHtml)
|
@Body
|
||||||
</article>
|
</article>
|
||||||
|
<PageEmbeds Embed="_page.Embed" />
|
||||||
}
|
}
|
||||||
@@ -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.Components;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
@@ -6,23 +8,49 @@ namespace Elternbeirat.Web.Features.Pages;
|
|||||||
|
|
||||||
public partial class ContentPage
|
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]
|
[Parameter]
|
||||||
public string Slug { get; set; } = "";
|
public string Slug { get; set; } = "";
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private ILogger<ContentPage> Logger { get; set; } = default!;
|
||||||
|
|
||||||
|
[CascadingParameter]
|
||||||
|
private HttpContext? HttpContext { get; set; }
|
||||||
|
|
||||||
private Page? _page;
|
private Page? _page;
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
// The page body rendered from Markdown to HTML, ready to emit as a
|
||||||
{
|
// MarkupString in the markup.
|
||||||
_page = PageService.Find(Slug);
|
private MarkupString Body => (MarkupString)Markdown.ToHtml(_page?.Body);
|
||||||
|
|
||||||
// Unknown slug -> 404, so UseStatusCodePagesWithReExecute serves the
|
protected override async Task OnParametersSetAsync()
|
||||||
// /not-found page instead of an empty 200 response.
|
{
|
||||||
|
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)
|
if (_page is null && HttpContext is not null)
|
||||||
{
|
{
|
||||||
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
|
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[CascadingParameter]
|
|
||||||
private HttpContext? HttpContext { get; set; }
|
|
||||||
}
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
namespace Elternbeirat.Web.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A static content page, read from a Markdown file in <c>Content/pages/</c>.
|
|
||||||
/// The file name (without extension) is the slug.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class Page
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// URL identifier of the page, taken from the file name without umlauts
|
|
||||||
/// (e.g. <c>vorstandsteam</c>). It appears in the route <c>/{slug}</c>.
|
|
||||||
/// </summary>
|
|
||||||
public required string Slug { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Display title from the YAML front matter (<c>titel:</c>). Falls back to
|
|
||||||
/// the slug when no title is set.
|
|
||||||
/// </summary>
|
|
||||||
public required string Title { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The page body rendered from Markdown to HTML (front matter excluded).
|
|
||||||
/// </summary>
|
|
||||||
public required string ContentHtml { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
using Markdig;
|
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads the static content pages from <c>Content/pages/*.md</c>, 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.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class PageService
|
|
||||||
{
|
|
||||||
private readonly IReadOnlyDictionary<string, Page> _pagesBySlug;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads every Markdown page from the <c>Content/pages</c> directory below
|
|
||||||
/// the application root and caches it.
|
|
||||||
/// </summary>
|
|
||||||
public PageService(IWebHostEnvironment environment)
|
|
||||||
{
|
|
||||||
var pipeline = new MarkdownPipelineBuilder()
|
|
||||||
.UseYamlFrontMatter()
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
var directory = Path.Combine(environment.ContentRootPath, "Content", "pages");
|
|
||||||
var pages = new Dictionary<string, Page>(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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the page for the given slug, or <c>null</c> if there is none.
|
|
||||||
/// The lookup is case-insensitive.
|
|
||||||
/// </summary>
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
namespace Elternbeirat.Web.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A news post, read from a Markdown file in <c>Content/posts/</c>. Unlike a
|
|
||||||
/// <see cref="Page"/>, a post has a date and
|
|
||||||
/// is shown in a chronological list. The file name (without extension) is the
|
|
||||||
/// slug.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class Post
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// URL identifier of the post, taken from the file name without umlauts.
|
|
||||||
/// It appears in the route <c>/beitraege/{slug}</c>.
|
|
||||||
/// </summary>
|
|
||||||
public required string Slug { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Display title from the YAML front matter (<c>titel:</c>). Falls back to
|
|
||||||
/// the slug when no title is set.
|
|
||||||
/// </summary>
|
|
||||||
public required string Title { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Publication date from the front matter (<c>datum:</c>). Used to sort the
|
|
||||||
/// list newest first.
|
|
||||||
/// </summary>
|
|
||||||
public required DateOnly Date { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The post body rendered from Markdown to HTML (front matter excluded).
|
|
||||||
/// </summary>
|
|
||||||
public required string ContentHtml { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
@page "/beitraege/{Slug}"
|
@page "/posts/{Slug}"
|
||||||
@inject PostService PostService
|
|
||||||
|
|
||||||
@if (_post is null)
|
@if (_post is null)
|
||||||
{
|
{
|
||||||
@@ -10,11 +9,11 @@ else
|
|||||||
<PageTitle>@_post.Title</PageTitle>
|
<PageTitle>@_post.Title</PageTitle>
|
||||||
<article>
|
<article>
|
||||||
<p class="post-meta">
|
<p class="post-meta">
|
||||||
<a href="/beitraege">← Alle Beiträge</a>
|
<a href="/posts">← Alle Beiträge</a>
|
||||||
<time datetime="@_post.Date.ToString("yyyy-MM-dd")">
|
<time datetime="@_post.Date.ToString("yyyy-MM-dd")">
|
||||||
@_post.Date.ToString("d. MMMM yyyy", German)
|
@_post.Date.ToString("d. MMMM yyyy", German)
|
||||||
</time>
|
</time>
|
||||||
</p>
|
</p>
|
||||||
@((MarkupString)_post.ContentHtml)
|
@Body
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Elternbeirat.Web.Services;
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.PocketBase;
|
||||||
|
using Elternbeirat.Web.Shared;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
@@ -9,23 +11,49 @@ public partial class PostDetail
|
|||||||
{
|
{
|
||||||
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
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]
|
[Parameter]
|
||||||
public string Slug { get; set; } = "";
|
public string Slug { get; set; } = "";
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private ILogger<PostDetail> Logger { get; set; } = default!;
|
||||||
|
|
||||||
|
[CascadingParameter]
|
||||||
|
private HttpContext? HttpContext { get; set; }
|
||||||
|
|
||||||
private Post? _post;
|
private Post? _post;
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
// The post body rendered from Markdown to HTML, ready to emit as a
|
||||||
{
|
// MarkupString in the markup.
|
||||||
_post = PostService.Find(Slug);
|
private MarkupString Body => (MarkupString)Markdown.ToHtml(_post?.Body);
|
||||||
|
|
||||||
// Unknown slug -> 404, so UseStatusCodePagesWithReExecute serves the
|
protected override async Task OnParametersSetAsync()
|
||||||
// /not-found page instead of an empty 200 response.
|
{
|
||||||
|
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)
|
if (_post is null && HttpContext is not null)
|
||||||
{
|
{
|
||||||
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
|
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[CascadingParameter]
|
|
||||||
private HttpContext? HttpContext { get; set; }
|
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,20 @@
|
|||||||
@page "/beitraege"
|
@page "/posts"
|
||||||
@inject PostService PostService
|
|
||||||
|
|
||||||
<PageTitle>Beiträge</PageTitle>
|
<PageTitle>Beiträge</PageTitle>
|
||||||
|
|
||||||
<h1>Beiträge</h1>
|
<h1>Beiträge</h1>
|
||||||
|
|
||||||
@if (PostService.All.Count == 0)
|
@if (_posts.Count == 0)
|
||||||
{
|
{
|
||||||
<p>Zurzeit gibt es keine Beiträge.</p>
|
<p>Zurzeit gibt es keine Beiträge.</p>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<ul class="post-list">
|
<ul class="post-list">
|
||||||
@foreach (var post in PostService.All)
|
@foreach (var post in _posts)
|
||||||
{
|
{
|
||||||
<li>
|
<li>
|
||||||
<a href="@($"/beitraege/{post.Slug}")">@post.Title</a>
|
<a href="@($"/posts/{post.Slug}")">@post.Title</a>
|
||||||
<time datetime="@post.Date.ToString("yyyy-MM-dd")">
|
<time datetime="@post.Date.ToString("yyyy-MM-dd")">
|
||||||
@post.Date.ToString("d. MMMM yyyy", German)
|
@post.Date.ToString("d. MMMM yyyy", German)
|
||||||
</time>
|
</time>
|
||||||
|
|||||||
@@ -1,8 +1,36 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.PocketBase;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Features.Posts;
|
namespace Elternbeirat.Web.Features.Posts;
|
||||||
|
|
||||||
public partial class PostList
|
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");
|
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private ILogger<PostList> Logger { get; set; } = default!;
|
||||||
|
|
||||||
|
private IReadOnlyList<Post> _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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using Markdig;
|
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads the news posts from <c>Content/posts/*.md</c>, 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.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class PostService
|
|
||||||
{
|
|
||||||
private readonly IReadOnlyList<Post> _posts;
|
|
||||||
private readonly IReadOnlyDictionary<string, Post> _postsBySlug;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads every Markdown post from the <c>Content/posts</c> directory
|
|
||||||
/// below the application root and caches it.
|
|
||||||
/// </summary>
|
|
||||||
public PostService(IWebHostEnvironment environment)
|
|
||||||
{
|
|
||||||
var pipeline = new MarkdownPipelineBuilder()
|
|
||||||
.UseYamlFrontMatter()
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
var directory = Path.Combine(environment.ContentRootPath, "Content", "posts");
|
|
||||||
var posts = new List<Post>();
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// All posts, newest first. Used for the overview list.
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<Post> All => _posts;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the post for the given slug, or <c>null</c> if there is none.
|
|
||||||
/// The lookup is case-insensitive.
|
|
||||||
/// </summary>
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses the <c>datum:</c> value as an ISO date (yyyy-MM-dd). Falls back to
|
|
||||||
/// <see cref="DateOnly.MinValue"/> when the value is missing or malformed,
|
|
||||||
/// so a single bad file does not crash startup — it just sorts last.
|
|
||||||
/// </summary>
|
|
||||||
private static DateOnly ParseDate(string? value)
|
|
||||||
{
|
|
||||||
return DateOnly.TryParse(value, CultureInfo.InvariantCulture,
|
|
||||||
DateTimeStyles.None, out var date)
|
|
||||||
? date
|
|
||||||
: DateOnly.MinValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.PocketBase;
|
||||||
using Elternbeirat.Web.Components;
|
using Elternbeirat.Web.Components;
|
||||||
using Elternbeirat.Web.Features.Events;
|
using Elternbeirat.Web.Features.Events;
|
||||||
using Elternbeirat.Web.Services;
|
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -8,10 +9,13 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
// Add services to the container.
|
// Add services to the container.
|
||||||
builder.Services.AddRazorComponents();
|
builder.Services.AddRazorComponents();
|
||||||
|
|
||||||
// Content is read once at startup and cached -> singletons.
|
// The typed client reads content from PocketBase over its REST API. The base URL
|
||||||
builder.Services.AddSingleton<PageService>();
|
// comes from configuration: appsettings for local dev, the PocketBase__BaseUrl
|
||||||
builder.Services.AddSingleton<PostService>();
|
// env var on the server (see compose.yaml).
|
||||||
builder.Services.AddSingleton<EventService>();
|
var pocketBaseUrl = builder.Configuration["PocketBase:BaseUrl"]
|
||||||
|
?? throw new InvalidOperationException("PocketBase:BaseUrl is not configured.");
|
||||||
|
builder.Services.AddHttpClient<PocketBaseClient>(client =>
|
||||||
|
client.BaseAddress = new Uri(pocketBaseUrl));
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
@@ -43,8 +47,21 @@ app.MapStaticAssets();
|
|||||||
app.MapRazorComponents<App>();
|
app.MapRazorComponents<App>();
|
||||||
|
|
||||||
// Subscribable calendar feed of all events. A minimal API endpoint rather than
|
// Subscribable calendar feed of all events. A minimal API endpoint rather than
|
||||||
// a Razor page because it returns text/calendar, not HTML.
|
// a Razor page because it returns text/calendar, not HTML. Reads from PocketBase
|
||||||
app.MapGet("/termine.ics", (EventService events) =>
|
// per request; an unreachable source yields an empty calendar, not a 500.
|
||||||
Results.Text(IcsCalendar.Build(events.All), "text/calendar; charset=utf-8"));
|
app.MapGet("/events.ics", async (PocketBaseClient pocketBase) =>
|
||||||
|
{
|
||||||
|
IReadOnlyList<Event> events;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
events = await pocketBase.GetEventsAsync();
|
||||||
|
}
|
||||||
|
catch (HttpRequestException)
|
||||||
|
{
|
||||||
|
events = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Text(IcsCalendar.Build(events), "text/calendar; charset=utf-8");
|
||||||
|
});
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Elternbeirat.Contracts;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Shared;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats an <see cref="Event"/>'s date range for display in German. Shared by
|
||||||
|
/// the full event list and the home-page events embed so both render dates the
|
||||||
|
/// same way.
|
||||||
|
/// </summary>
|
||||||
|
public static class EventFormat
|
||||||
|
{
|
||||||
|
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
||||||
|
|
||||||
|
// An event counts as all-day when its start carries no wall-clock time
|
||||||
|
// (00:00); PocketBase has no separate flag.
|
||||||
|
private static bool HasTime(Event ev) => ev.Start.TimeOfDay != TimeSpan.Zero;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats the entry's date range: a single day, a date with a time, or a
|
||||||
|
/// span across days.
|
||||||
|
/// </summary>
|
||||||
|
public static string Range(Event ev)
|
||||||
|
{
|
||||||
|
var start = HasTime(ev)
|
||||||
|
? ev.Start.ToString("dddd, d. MMMM yyyy, HH:mm", German) + " Uhr"
|
||||||
|
: ev.Start.ToString("dddd, d. MMMM yyyy", German);
|
||||||
|
|
||||||
|
if (ev.End is not { } end)
|
||||||
|
{
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same day: append just the end time. Different days: append the full
|
||||||
|
// end date.
|
||||||
|
if (end.Date == ev.Start.Date)
|
||||||
|
{
|
||||||
|
return HasTime(ev)
|
||||||
|
? start + "–" + end.ToString("HH:mm", German) + " Uhr"
|
||||||
|
: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
var endText = HasTime(ev)
|
||||||
|
? end.ToString("d. MMMM yyyy, HH:mm", German) + " Uhr"
|
||||||
|
: end.ToString("d. MMMM yyyy", German);
|
||||||
|
return start + " – " + endText;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
using Markdig;
|
|
||||||
using Markdig.Extensions.Yaml;
|
|
||||||
using Markdig.Syntax;
|
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads simple <c>key: value</c> 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.
|
|
||||||
/// </summary>
|
|
||||||
public static class FrontMatter
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the value of the given front-matter key, or <c>null</c> 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.
|
|
||||||
/// </summary>
|
|
||||||
public static string? Read(string key, MarkdownDocument document, string source)
|
|
||||||
{
|
|
||||||
var block = document.Descendants<YamlFrontMatterBlock>().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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Markdig;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Shared;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders Markdown to HTML. Content now comes from PocketBase as raw Markdown
|
||||||
|
/// (the <c>body</c> field), so the pages render it at display time instead of
|
||||||
|
/// reading pre-rendered HTML from files.
|
||||||
|
/// </summary>
|
||||||
|
public static class Markdown
|
||||||
|
{
|
||||||
|
private static readonly MarkdownPipeline Pipeline =
|
||||||
|
new MarkdownPipelineBuilder().Build();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a Markdown string to HTML. Returns an empty string for
|
||||||
|
/// <c>null</c> or empty input, so callers can bind the result directly.
|
||||||
|
/// </summary>
|
||||||
|
public static string ToHtml(string? markdown) =>
|
||||||
|
string.IsNullOrEmpty(markdown)
|
||||||
|
? ""
|
||||||
|
: Markdig.Markdown.ToHtml(markdown, Pipeline);
|
||||||
|
}
|
||||||
@@ -7,4 +7,6 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using Elternbeirat.Web
|
@using Elternbeirat.Web
|
||||||
@using Elternbeirat.Web.Services
|
@using Elternbeirat.Web.Shared
|
||||||
|
@using Elternbeirat.Web.Features.Layout
|
||||||
|
@using Elternbeirat.Web.Features.Embeds
|
||||||
@@ -4,5 +4,8 @@
|
|||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"PocketBase": {
|
||||||
|
"BaseUrl": "http://localhost:8090"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,5 +5,10 @@
|
|||||||
"Microsoft.AspNetCore": "Warning"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,12 @@ services:
|
|||||||
# tag clears the inherited bind mount so only this volume applies.
|
# tag clears the inherited bind mount so only this volume applies.
|
||||||
- !reset null
|
- !reset null
|
||||||
- pb_data_dev:/pb_data
|
- 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:
|
eb-web:
|
||||||
# Build from local source instead of pulling the registry image, so local code
|
# Build from local source instead of pulling the registry image, so local code
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ services:
|
|||||||
# lets Docker pick a random free one; the tests read it back with `compose port`.
|
# lets Docker pick a random free one; the tests read it back with `compose port`.
|
||||||
ports: !override
|
ports: !override
|
||||||
- "8090"
|
- "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:
|
eb-web:
|
||||||
container_name: !reset null # see eb-pocketbase above (eb-web is not started in tests)
|
container_name: !reset null # see eb-pocketbase above (eb-web is not started in tests)
|
||||||
+17
-3
@@ -1,6 +1,10 @@
|
|||||||
# Base compose: production-like. Both services, no host port mappings (NPM is the
|
# Base compose: production-like. Both services; only PocketBase's data is a volume,
|
||||||
# only path to the web app; the editors reach PocketBase through an NPM subdomain).
|
# everything else lives inside the images.
|
||||||
# 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
|
# For local development use the dev overlay on top, which adds host ports and builds
|
||||||
# the web image from source:
|
# the web image from source:
|
||||||
@@ -25,6 +29,11 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
# The only state that must survive a restart or redeploy.
|
# The only state that must survive a restart or redeploy.
|
||||||
- /mnt/user/appdata/pocketbase/pb_data:/pb_data
|
- /mnt/user/appdata/pocketbase/pb_data:/pb_data
|
||||||
|
ports:
|
||||||
|
# TEMPORARY: exposes the admin UI on the LAN (http://<unraid>: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:
|
healthcheck:
|
||||||
# The image ships this endpoint; the web app waits for it to pass.
|
# The image ships this endpoint; the web app waits for it to pass.
|
||||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8090/api/health"]
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8090/api/health"]
|
||||||
@@ -44,3 +53,8 @@ services:
|
|||||||
TZ: Europe/Berlin
|
TZ: Europe/Berlin
|
||||||
# The web app reads content from PocketBase over the compose network.
|
# The web app reads content from PocketBase over the compose network.
|
||||||
PocketBase__BaseUrl: http://eb-pocketbase:8090
|
PocketBase__BaseUrl: http://eb-pocketbase:8090
|
||||||
|
ports:
|
||||||
|
# TEMPORARY: reach the site on the LAN (http://<unraid>: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"
|
||||||
+42
-22
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
Why the site is built the way it is. Read this before questioning one of these
|
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
|
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
|
> **Note on scope.** The site started as a static information site with content
|
||||||
> with content as files. The move to editor-based content maintenance
|
> as files (stage 1). Editorial maintenance without a developer then replaced
|
||||||
> (`tasks/001-redaktion-ohne-entwickler.md`) deliberately revisits AE-2 and AE-3 —
|
> that file layer with PocketBase, which revised **AE-2** and **AE-3** below —
|
||||||
> when that lands, update this file.
|
> each now records both the original decision and why it changed.
|
||||||
|
|
||||||
## AE-1: Blazor with static server-side rendering, not WebAssembly
|
## 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
|
(`@rendermode InteractiveServer` on exactly that one component), without changing the
|
||||||
architecture. Never global.
|
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
|
**Decision:** All visible content lives in **PocketBase** (collections `pages`,
|
||||||
YAML (`Content/events.yml`), documents as PDF under `wwwroot`.
|
`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
|
**Original decision (stage 1):** Content as files in the repo — Markdown with YAML
|
||||||
files and database. Changes are commits and are therefore traceable and reversible.
|
frontmatter, events as `Content/events.yml`. The rationale then was: no DB backup,
|
||||||
For a site with ~10 subpages and a few events per year, anything else is overhead.
|
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
|
**Why it changed:** The site needs to be maintainable by the board **without a
|
||||||
changes a year that is acceptable — and the reason the editor expansion
|
developer and without a redeploy**. Files meant every text fix was a commit and a
|
||||||
(`tasks/001`) is treated as a separate stage.
|
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
|
**Decision:** The editable content lives in PocketBase's `pb_data`, a persistent
|
||||||
NAS, but that is exactly when the repo and the live state drift apart — and the
|
volume separate from the app image. Only the static assets under `wwwroot/` (CSS,
|
||||||
"backup = `git clone`" property from AE-2 would be worthless.
|
fonts, PDFs) are baked into the image.
|
||||||
|
|
||||||
**Consequence:** No quick fix on the live system. Typos are corrected properly via a
|
**Original decision (stage 1):** `Content/` and `wwwroot/` were both baked into the
|
||||||
commit. Note: the content is read once at startup and cached — there is **no**
|
image, deliberately **not** a mounted volume, so the live state could never drift
|
||||||
`FileSystemWatcher` (a change needs a restart / `dotnet watch`).
|
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
|
## AE-4: TLS and certificates exclusively in the Nginx Proxy Manager
|
||||||
|
|
||||||
|
|||||||
+86
-2
@@ -17,8 +17,8 @@ the base for a local run — you layer an overlay on top of it.
|
|||||||
| File | Role |
|
| 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.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.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. |
|
| `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
|
> **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`
|
> 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
|
same time. The fixture cleans up before and after each run, so a hard-killed
|
||||||
earlier run leaves nothing behind.
|
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
|
**Requirements:** Docker with the Compose plugin must be available when the tests
|
||||||
run (they call `docker compose`).
|
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. |
|
||||||
|
| `<timestamp>_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
|
## Content while developing
|
||||||
|
|
||||||
The app reads its content from PocketBase over the compose network
|
The app reads its content from PocketBase over the compose network
|
||||||
@@ -100,3 +149,38 @@ The app reads its content from PocketBase over the compose network
|
|||||||
inside the compose network, independent of the container name). Editing content
|
inside the compose network, independent of the container name). Editing content
|
||||||
means editing records in the PocketBase admin UI at
|
means editing records in the PocketBase admin UI at
|
||||||
<http://localhost:8090/_/>, not editing files. See `redaktion.md`.
|
<http://localhost:8090/_/>, 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<T>(collection, sort, ct)` already does the `public=true`
|
||||||
|
filter, the sort and the deserialization, so a new type is a one-liner:
|
||||||
|
`public Task<IReadOnlyList<Minutes>> GetMinutesAsync(CancellationToken ct = default)`
|
||||||
|
`=> GetRecordsAsync<Minutes>("minutes", "-date", ct);`
|
||||||
|
3. **Feature component.** Add a component under `Features/<Name>/` 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.
|
||||||
|
|
||||||
|
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.
|
||||||
+4
-3
@@ -1,9 +1,10 @@
|
|||||||
# Legal — imprint, privacy, photos
|
# Legal — imprint, privacy, photos
|
||||||
|
|
||||||
Not legal advice — but the points where school sites regularly get flagged. The
|
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
|
actual imprint and privacy texts are `pages` records in PocketBase (slugs
|
||||||
`Content/pages/datenschutz.md`; filling them in with released, real data is tracked
|
`imprint` and `privacy`), edited in the admin UI — see `redaktion.md`. Filling
|
||||||
in `tasks/003-echte-inhalte-vor-go-live.md`.
|
them in with released, real data before go-live is tracked as a Gitea issue
|
||||||
|
(milestone „Elternbeirat-Website").
|
||||||
|
|
||||||
## Imprint (§ 5 DDG)
|
## Imprint (§ 5 DDG)
|
||||||
|
|
||||||
|
|||||||
+40
-6
@@ -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
|
über die Weboberfläche bearbeitet. Keine Dateien, kein Commit, kein Rebuild: eine
|
||||||
Änderung im Admin ist sofort live.
|
Änderung im Admin ist sofort live.
|
||||||
|
|
||||||
> **Übergang (Stand #8).** Der Inhalt ist bereits vollständig in PocketBase; die
|
> **Stand.** Die Blazor-App liest ihren gesamten Inhalt aus PocketBase (Issue #8);
|
||||||
> Blazor-App wird gerade darauf umgestellt, ihn von dort zu lesen (Issue #8).
|
> die alte Datei-Schicht unter `Content/` gibt es nicht mehr. PocketBase ist damit
|
||||||
> Solange das läuft, kann die ausgelieferte Seite noch aus den alten Dateien
|
> die einzige Quelle und dieser Abschnitt der einzige Pflegeweg.
|
||||||
> unter `Content/` stammen. Sobald #8 durch ist, ist PocketBase die einzige
|
|
||||||
> Quelle und dieser Abschnitt der einzige Pflegeweg.
|
|
||||||
|
|
||||||
> **Sprachkonvention.** Feldnamen und Slugs sind **englisch** (`title`, `slug`,
|
> **Sprachkonvention.** Feldnamen und Slugs sind **englisch** (`title`, `slug`,
|
||||||
> `board`, `posts`). Der Text, den ein Besucher liest, bleibt **deutsch** — also
|
> `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,
|
`public = true` erscheinen auf der Website — so lässt sich ein Entwurf anlegen,
|
||||||
ohne dass er schon sichtbar ist.
|
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`)
|
## Seiten (`pages`)
|
||||||
|
|
||||||
Eine Seite hat `title` (Überschrift für Menschen, Umlaute erlaubt), `slug` (die
|
Eine Seite hat `title` (Überschrift für Menschen, Umlaute erlaubt), `slug` (die
|
||||||
URL, klein und **ohne Umlaute**: `board`, nicht `über-uns`) und `body` (der
|
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 `/<slug>`
|
||||||
|
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`,
|
- **Slug bleibt englisch und ohne Umlaute.** `imprint`, `privacy`, `contact`,
|
||||||
`board`, `patrons`. Der `title` darf deutsch mit Umlauten sein (`Förderverein`).
|
`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
|
- **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
|
`location` (`header` oder `footer`) und `order` (Reihenfolge, ab 1). Die App
|
||||||
baut Kopf- und Fußnavigation daraus; nichts wird im Markup angefasst.
|
baut Kopf- und Fußnavigation daraus; nichts wird im Markup angefasst.
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
migrate((app) => {
|
||||||
|
// 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 ElterLine truncated
|
||||||
|
,
|
||||||
|
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`.
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user