diff --git a/.gitignore b/.gitignore index f400df3..fcf70a4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,8 @@ obj/ /packages/ riderModule.iml /_ReSharper.Caches/ -.idea/ \ No newline at end of file +.idea/ + +# Altbestand der WordPress-Seite (Sichtung/Migration, kann DB-Dumps mit +# personenbezogenen Daten und grosse Binaerdateien enthalten) -- nie ins Repo. +/backup/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index abc026f..6de3178 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,8 +38,9 @@ vollständige Stand. Port-Mapping hat und nur über NPM erreichbar ist. - **Das chiseled-Runtime-Image hat keine Shell.** `HEALTHCHECK` mit `curl` oder `sh` schlägt dort fehl. -- Neue Seite = Markdown in `Content/seiten/`. Neuer Termin = Eintrag in - `Content/termine.yml`. In beiden Fällen wird **kein** `.razor` angefasst. +- Neue Seite = Markdown in `Content/pages/`. Neuer Beitrag = Markdown in + `Content/posts/`. Neuer Termin = Eintrag in `Content/events.yml`. In allen + Fällen wird **kein** `.razor` angefasst. - Slugs ohne Umlaute (`ueber-uns`, nicht `über-uns`). ## Regeln @@ -60,7 +61,11 @@ vollständige Stand. - Nullable aktiviert, `ImplicitUsings` an, File-scoped Namespaces. - Services über DI, als Singleton registriert (Inhalte werden beim Start eingelesen und gecacht). -- Öffentliche Typen und Methoden der `Services` bekommen XML-Doc, Razor-Markup - nicht. -- Fachbegriffe im Code auf Deutsch, wenn sie Domänenbegriffe sind - (`Termin`, `Protokoll`, `Beitrag`) — Framework-Begriffe bleiben englisch. +- Öffentliche Typen und Methoden der `Services` bekommen XML-Doc (auf Englisch, + leicht verständlich), Razor-Markup nicht. +- **Code durchgängig auf Englisch** — Typen, Member, Variablen, Kommentare, + Skripte und Doku. Das gilt **auch für Domänenbegriffe**: im Code `Event`, nicht + `Termin`; `Post`, nicht `Beitrag`. Deutsch bleibt ausschließlich, was ein + Besucher liest oder ein Redakteur pflegt: UI-Texte, Markdown-Inhalte, + Frontmatter-Schlüssel wie `titel:` sowie Slugs/Dateinamen unter `Content/` + (z. B. `vorstandsteam`, `/termine`). diff --git a/Elternbeirat.Web.Tests/Elternbeirat.Web.Tests.csproj b/Elternbeirat.Web.Tests/Elternbeirat.Web.Tests.csproj new file mode 100644 index 0000000..bc3d902 --- /dev/null +++ b/Elternbeirat.Web.Tests/Elternbeirat.Web.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Elternbeirat.Web.Tests/RouteSmokeTests.cs b/Elternbeirat.Web.Tests/RouteSmokeTests.cs new file mode 100644 index 0000000..38200e9 --- /dev/null +++ b/Elternbeirat.Web.Tests/RouteSmokeTests.cs @@ -0,0 +1,67 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Elternbeirat.Web.Tests; + +/// +/// Smoke tests: every known route must return 200, unknown routes must return +/// 404. This catches broken content files, renamed slugs or routing regressions +/// before a deploy. +/// +public sealed class RouteSmokeTests : IClassFixture> +{ + private readonly WebApplicationFactory _factory; + + public RouteSmokeTests(WebApplicationFactory factory) + { + _factory = factory; + } + + /// + /// Every route that a visitor can reach through the navigation, the FAQ hub + /// or the news section. + /// + public static TheoryData KnownRoutes => + [ + "/", + "/vorstandsteam", + "/foerderverein", + "/faq", + "/faq-mensa", + "/faq-schliessfach", + "/faq-elterneuro", + "/faq-elternarbeit", + "/downloads", + "/kontakt", + "/impressum", + "/datenschutz", + "/beitraege", + "/beitraege/neuer-vorstand-gewaehlt", + "/beitraege/neue-sporthalle-eroeffnet", + "/termine", + "/termine.ics", + ]; + + [Theory] + [MemberData(nameof(KnownRoutes))] + public async Task Known_route_returns_200(string route) + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync(route); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Theory] + [InlineData("/gibt-es-nicht")] + [InlineData("/beitraege/gibt-es-nicht")] + public async Task Unknown_route_returns_404(string route) + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync(route); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/Elternbeirat.Web/Components/App.razor b/Elternbeirat.Web/Components/App.razor index d649b4a..a336909 100644 --- a/Elternbeirat.Web/Components/App.razor +++ b/Elternbeirat.Web/Components/App.razor @@ -1,5 +1,5 @@  - + diff --git a/Elternbeirat.Web/Components/Layout/MainLayout.razor b/Elternbeirat.Web/Components/Layout/MainLayout.razor index 53a4e95..1ca2f47 100644 --- a/Elternbeirat.Web/Components/Layout/MainLayout.razor +++ b/Elternbeirat.Web/Components/Layout/MainLayout.razor @@ -1,3 +1,34 @@ -@inherits LayoutComponentBase +@inherits LayoutComponentBase -@Body \ No newline at end of file +
+ + +
+ @Body +
+ + +
diff --git a/Elternbeirat.Web/Components/Layout/MainLayout.razor.css b/Elternbeirat.Web/Components/Layout/MainLayout.razor.css index 60cec92..6932f38 100644 --- a/Elternbeirat.Web/Components/Layout/MainLayout.razor.css +++ b/Elternbeirat.Web/Components/Layout/MainLayout.razor.css @@ -18,3 +18,100 @@ right: 0.75rem; top: 0.5rem; } + +/* Page frame: header and footer stay put, the content grows and pushes the + footer to the bottom even on short pages. */ +.page { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* Header */ +.site-header { + border-bottom: 1px solid #e0e0e0; + background: #fff; +} + +.header-inner { + max-width: 60rem; + margin: 0 auto; + padding: 1rem 1.25rem; + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.5rem 1.5rem; +} + +.brand { + font-size: 1.35rem; + font-weight: 700; + color: #1f3a5f; + text-decoration: none; + white-space: nowrap; +} + +.main-nav { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1.25rem; +} + +.main-nav a { + color: #333; + text-decoration: none; + padding: 0.2rem 0; + border-bottom: 2px solid transparent; +} + +.main-nav a:hover { + color: #1f3a5f; +} + +/* NavLink adds .active to the link of the current route. */ +.main-nav a.active { + color: #1f3a5f; + border-bottom-color: #1f3a5f; +} + +/* Content */ +.content { + flex: 1; + width: 100%; + max-width: 60rem; + margin: 0 auto; + padding: 1.5rem 1.25rem 3rem; +} + +/* Footer */ +.site-footer { + border-top: 1px solid #e0e0e0; + background: #fafafa; + color: #555; + font-size: 0.9rem; +} + +.footer-inner { + max-width: 60rem; + margin: 0 auto; + padding: 1rem 1.25rem; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.5rem 1.5rem; +} + +.footer-nav { + display: flex; + gap: 1.25rem; +} + +.footer-nav a { + color: #555; + text-decoration: none; +} + +.footer-nav a:hover { + color: #1f3a5f; + text-decoration: underline; +} diff --git a/Elternbeirat.Web/Components/Pages/ContentPage.razor b/Elternbeirat.Web/Components/Pages/ContentPage.razor new file mode 100644 index 0000000..76f4b2e --- /dev/null +++ b/Elternbeirat.Web/Components/Pages/ContentPage.razor @@ -0,0 +1,37 @@ +@page "/{Slug}" +@using Elternbeirat.Web.Services +@inject PageService PageService + +@if (_page is null) +{ + Nicht gefunden +} +else +{ + @_page.Title +
+ @((MarkupString)_page.ContentHtml) +
+} + +@code { + [Parameter] + public string Slug { get; set; } = ""; + + private Page? _page; + + protected override void OnParametersSet() + { + _page = PageService.Find(Slug); + + // Unknown slug -> 404, so UseStatusCodePagesWithReExecute serves the + // /not-found page instead of an empty 200 response. + if (_page is null && HttpContext is not null) + { + HttpContext.Response.StatusCode = StatusCodes.Status404NotFound; + } + } + + [CascadingParameter] + private HttpContext? HttpContext { get; set; } +} diff --git a/Elternbeirat.Web/Components/Pages/EventList.razor b/Elternbeirat.Web/Components/Pages/EventList.razor new file mode 100644 index 0000000..0fdee26 --- /dev/null +++ b/Elternbeirat.Web/Components/Pages/EventList.razor @@ -0,0 +1,91 @@ +@page "/termine" +@using System.Globalization +@using Elternbeirat.Web.Services +@inject EventService EventService + +Termine + +

Termine

+ +

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

+ +

Kommende Termine

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

Zurzeit sind keine Termine geplant.

+} +else +{ +
    + @foreach (var ev in EventService.Upcoming) + { +
  • + + @ev.Title + @if (!string.IsNullOrWhiteSpace(ev.Location)) + { + @ev.Location + } + @if (!string.IsNullOrWhiteSpace(ev.Note)) + { +

    @ev.Note

    + } +
  • + } +
+} + +@if (EventService.Past.Count > 0) +{ +

Vergangene Termine

+
    + @foreach (var ev in EventService.Past) + { +
  • + + @ev.Title + @if (!string.IsNullOrWhiteSpace(ev.Location)) + { + @ev.Location + } +
  • + } +
+} + +@code { + private static readonly CultureInfo _german = CultureInfo.GetCultureInfo("de-DE"); + + // Formats the entry's date range for display: a single day, a date with a + // time, or a span across days. + private static string Format(Event ev) + { + var start = ev.HasTime + ? 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 ev.HasTime + ? start + "–" + end.ToString("HH:mm", _german) + " Uhr" + : start; + } + + var endText = ev.HasTime + ? end.ToString("d. MMMM yyyy, HH:mm", _german) + " Uhr" + : end.ToString("d. MMMM yyyy", _german); + return start + " – " + endText; + } +} diff --git a/Elternbeirat.Web/Components/Pages/Home.razor b/Elternbeirat.Web/Components/Pages/Home.razor index dfcdf75..829e27e 100644 --- a/Elternbeirat.Web/Components/Pages/Home.razor +++ b/Elternbeirat.Web/Components/Pages/Home.razor @@ -1,7 +1,49 @@ -@page "/" +@page "/" +@using System.Globalization +@using Elternbeirat.Web.Services +@inject PostService PostService -Home +Elternbeirat der IGMH -

Hello, world!

+

Elternbeirat der IGMH

-Welcome to your new app. \ No newline at end of file +

+ Willkommen beim Elternbeirat der IGMH. Hier finden Sie aktuelle Informationen, + Antworten auf häufige Fragen und Unterlagen rund um die Elternarbeit. +

+ +
+

Aktuelle Beiträge

+ + @if (_latest.Count == 0) + { +

Zurzeit gibt es keine Beiträge.

+ } + else + { +
    + @foreach (var post in _latest) + { +
  • + @post.Title + +
  • + } +
+

Alle Beiträge →

+ } +
+ +@code { + private static readonly CultureInfo _german = CultureInfo.GetCultureInfo("de-DE"); + + private IReadOnlyList _latest = []; + + protected override void OnInitialized() + { + // Show the three most recent posts as a teaser on the home page. + _latest = PostService.All.Take(3).ToList(); + } +} diff --git a/Elternbeirat.Web/Components/Pages/PostDetail.razor b/Elternbeirat.Web/Components/Pages/PostDetail.razor new file mode 100644 index 0000000..5e678a7 --- /dev/null +++ b/Elternbeirat.Web/Components/Pages/PostDetail.razor @@ -0,0 +1,46 @@ +@page "/beitraege/{Slug}" +@using System.Globalization +@using Elternbeirat.Web.Services +@inject PostService PostService + +@if (_post is null) +{ + Nicht gefunden +} +else +{ + @_post.Title +
+ + @((MarkupString)_post.ContentHtml) +
+} + +@code { + private static readonly CultureInfo _german = CultureInfo.GetCultureInfo("de-DE"); + + [Parameter] + public string Slug { get; set; } = ""; + + private Post? _post; + + protected override void OnParametersSet() + { + _post = PostService.Find(Slug); + + // Unknown slug -> 404, so UseStatusCodePagesWithReExecute serves the + // /not-found page instead of an empty 200 response. + if (_post is null && HttpContext is not null) + { + HttpContext.Response.StatusCode = StatusCodes.Status404NotFound; + } + } + + [CascadingParameter] + private HttpContext? HttpContext { get; set; } +} diff --git a/Elternbeirat.Web/Components/Pages/PostList.razor b/Elternbeirat.Web/Components/Pages/PostList.razor new file mode 100644 index 0000000..1b2ee6c --- /dev/null +++ b/Elternbeirat.Web/Components/Pages/PostList.razor @@ -0,0 +1,31 @@ +@page "/beitraege" +@using System.Globalization +@using Elternbeirat.Web.Services +@inject PostService PostService + +Beiträge + +

Beiträge

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

Zurzeit gibt es keine Beiträge.

+} +else +{ +
    + @foreach (var post in PostService.All) + { +
  • + @post.Title + +
  • + } +
+} + +@code { + private static readonly CultureInfo _german = CultureInfo.GetCultureInfo("de-DE"); +} diff --git a/Elternbeirat.Web/Content/events.yml b/Elternbeirat.Web/Content/events.yml new file mode 100644 index 0000000..5d8f418 --- /dev/null +++ b/Elternbeirat.Web/Content/events.yml @@ -0,0 +1,26 @@ +# Calendar entries of the Elternbeirat. +# +# One entry per event. Required keys: title, start. +# start/end: +# date only -> "2026-03-15" (all-day) +# date with time -> "2026-03-15 19:30" +# location and note are optional. + +- title: Elternbeiratssitzung + start: "2026-10-08 19:30" + location: Lehrerzimmer + note: Themen bitte vorab per E-Mail einreichen. + +- title: Herbstbasar + start: "2026-11-14 10:00" + end: "2026-11-14 16:00" + location: Aula + +- title: Weihnachtsferien + start: "2026-12-21" + end: "2027-01-06" + +- title: Elternsprechtag + start: "2026-09-05 15:00" + end: "2026-09-05 18:00" + location: Klassenräume diff --git a/Elternbeirat.Web/Content/pages/datenschutz.md b/Elternbeirat.Web/Content/pages/datenschutz.md new file mode 100644 index 0000000..b64b662 --- /dev/null +++ b/Elternbeirat.Web/Content/pages/datenschutz.md @@ -0,0 +1,22 @@ +--- +title: Datenschutz +--- + +# Datenschutzerklärung + +*TODO: Rechtlich verbindliche Datenschutzerklärung ergänzen. Erst nach Freigabe +finalisieren – siehe `docs/recht.md` (noch anzulegen).* + +Diese Website wird bewusst ohne externe Ressourcen betrieben: keine CDN-Skripte, +keine Google Fonts, keine Karten- oder Video-Einbettungen. Schriften werden von +unserem eigenen Server ausgeliefert. Dadurch werden beim Besuch keine Daten an +Dritte übertragen. + +## Verantwortliche Stelle + +*TODO: siehe [Impressum](/impressum).* + +## Server-Logdaten + +*TODO: beschreiben, welche Zugriffsdaten der Server (bzw. der vorgelagerte +Proxy) protokolliert und wie lange.* diff --git a/Elternbeirat.Web/Content/pages/downloads.md b/Elternbeirat.Web/Content/pages/downloads.md new file mode 100644 index 0000000..d4ed098 --- /dev/null +++ b/Elternbeirat.Web/Content/pages/downloads.md @@ -0,0 +1,17 @@ +--- +title: Downloads +--- + +# Downloads + +Hier stellen wir Unterlagen zur Elternarbeit als Datei bereit: Protokolle der +Sitzungen, Elterninformationen und die Geschäftsordnung. + +## Unterlagen + +*TODO: Download-Liste ergänzen. Dateien liegen unter `wwwroot/downloads/` und +werden hier verlinkt, z. B.:* + +- *Protokoll der Vollversammlung (PDF)* +- *Geschäftsordnung des Elternbeirats (PDF)* +- *Elterninformation Elternvertreter (PDF)* diff --git a/Elternbeirat.Web/Content/pages/faq-elternarbeit.md b/Elternbeirat.Web/Content/pages/faq-elternarbeit.md new file mode 100644 index 0000000..e26735b --- /dev/null +++ b/Elternbeirat.Web/Content/pages/faq-elternarbeit.md @@ -0,0 +1,20 @@ +--- +title: FAQ Elternarbeit +--- + +# Elternarbeit + +Unterstützung für neu gewählte und erfahrene Elternvertreterinnen und +Elternvertreter. + +## Welche Aufgaben habe ich als Elternvertreter? + +*TODO: Aufgaben und Rechte der Klassenelternvertretung beschreiben.* + +## Wie läuft die Zusammenarbeit mit dem Elternbeirat? + +*TODO: Sitzungsrhythmus und Ansprechpartner ergänzen.* + +## Wo finde ich Vorlagen und Unterlagen? + +Unterlagen und Protokolle finden Sie im Bereich [Downloads](/downloads). diff --git a/Elternbeirat.Web/Content/pages/faq-elterneuro.md b/Elternbeirat.Web/Content/pages/faq-elterneuro.md new file mode 100644 index 0000000..3465b3d --- /dev/null +++ b/Elternbeirat.Web/Content/pages/faq-elterneuro.md @@ -0,0 +1,22 @@ +--- +title: FAQ Elterneuro +--- + +# Elterneuro + +Der Elterneuro ist ein freiwilliger Beitrag der Eltern, mit dem der Elternbeirat +Projekte an der Schule unterstützt. + +## Wofür wird der Elterneuro verwendet? + +*TODO: konkrete Beispiele für die Verwendung ergänzen.* + +## Wie hoch ist der Beitrag? + +Der Elterneuro ist freiwillig. + +*TODO: übliche Beitragshöhe und Zahlungsweg ergänzen.* + +## An wen kann ich mich bei Fragen wenden? + +Bei Fragen erreichen Sie uns über die [Kontaktseite](/kontakt). diff --git a/Elternbeirat.Web/Content/pages/faq-mensa.md b/Elternbeirat.Web/Content/pages/faq-mensa.md new file mode 100644 index 0000000..3d5f91c --- /dev/null +++ b/Elternbeirat.Web/Content/pages/faq-mensa.md @@ -0,0 +1,21 @@ +--- +title: FAQ Mensa +--- + +# Mensa + +Alles rund um das Mittagessen an der IGMH: Anmeldung, Guthaben und Fristen. + +## Wie melde ich mein Kind an? + +Die Essensbestellung läuft über das System i-NET Menue. + +*TODO: Ablauf der Erstanmeldung und Zugangsdaten beschreiben.* + +## Wie lade ich Guthaben auf? + +*TODO: Aufladeweg und Zahlungsarten ergänzen.* + +## Bis wann kann ich bestellen oder stornieren? + +*TODO: Fristen für Bestellung und Stornierung ergänzen.* diff --git a/Elternbeirat.Web/Content/pages/faq-schliessfach.md b/Elternbeirat.Web/Content/pages/faq-schliessfach.md new file mode 100644 index 0000000..7ab707d --- /dev/null +++ b/Elternbeirat.Web/Content/pages/faq-schliessfach.md @@ -0,0 +1,21 @@ +--- +title: FAQ Schließfach +--- + +# Schließfächer + +Informationen zur Anmietung eines Schließfachs an der IGMH. + +## Welche Größen gibt es und was kosten sie? + +*TODO: Verfügbare Größen und Preise ergänzen.* + +## Wie miete ich ein Schließfach an? + +Die Verwaltung läuft über das Serviceportal von AstraDirect. + +*TODO: Ablauf der Anmietung und Link zum Portal ergänzen.* + +## Wie tausche oder kündige ich mein Fach? + +*TODO: Vorgehen für Fachtausch und Kündigung ergänzen.* diff --git a/Elternbeirat.Web/Content/pages/faq.md b/Elternbeirat.Web/Content/pages/faq.md new file mode 100644 index 0000000..c88fabf --- /dev/null +++ b/Elternbeirat.Web/Content/pages/faq.md @@ -0,0 +1,15 @@ +--- +title: FAQ +--- + +# Häufige Fragen + +Hier finden Eltern Antworten auf wiederkehrende Fragen rund um den Schulalltag. +Die Themen sind nach Bereichen aufgeteilt: + +- [Mensa](/faq-mensa) – Anmeldung, Guthaben, Fristen +- [Schließfächer](/faq-schliessfach) – Größen, Preise, Verwaltung +- [Elterneuro](/faq-elterneuro) – freiwilliger Beitrag und Verwendung +- [Elternarbeit](/faq-elternarbeit) – Tipps für Elternvertreterinnen und Elternvertreter + +*TODO: Reihenfolge und weitere Themen ergänzen, sobald die Unterseiten stehen.* diff --git a/Elternbeirat.Web/Content/pages/foerderverein.md b/Elternbeirat.Web/Content/pages/foerderverein.md new file mode 100644 index 0000000..ecb6bfe --- /dev/null +++ b/Elternbeirat.Web/Content/pages/foerderverein.md @@ -0,0 +1,19 @@ +--- +title: Förderverein +--- + +# Förderverein „Freunde der IGMH" + +Der Förderverein „Freunde der IGMH" unterstützt die Schule bei Anschaffungen und +Projekten, die aus dem regulären Budget nicht finanziert werden können. Mitglieder +sind Eltern, Lehrkräfte, Ehemalige und Förderer der Schule. + +## Was der Verein fördert + +- Ausstattung für Unterricht und Arbeitsgemeinschaften +- Musische, sportliche und kulturelle Projekte +- Anschaffungen, die allen Schülerinnen und Schülern zugutekommen + +## Mitglied werden + +*TODO: Beitrittsformular bzw. Ansprechpartner und Beitragshöhe ergänzen.* diff --git a/Elternbeirat.Web/Content/pages/impressum.md b/Elternbeirat.Web/Content/pages/impressum.md new file mode 100644 index 0000000..26b3b1e --- /dev/null +++ b/Elternbeirat.Web/Content/pages/impressum.md @@ -0,0 +1,24 @@ +--- +title: Impressum +--- + +# Impressum + +*TODO: Rechtlich verbindliches Impressum ergänzen. Erst nach Freigabe mit echten +Daten füllen – siehe `docs/recht.md` (noch anzulegen).* + +## Angaben gemäß § 5 DDG + +*TODO: Name und Anschrift des Diensteanbieters (Elternbeirat / Schule).* + +## Vertreten durch + +*TODO: gesetzlicher Vertreter.* + +## Kontakt + +*TODO: E-Mail-Adresse (siehe [Kontakt](/kontakt)).* + +## Verantwortlich i. S. d. § 18 Abs. 2 MStV + +*TODO: Name und Anschrift der verantwortlichen Person.* diff --git a/Elternbeirat.Web/Content/pages/kontakt.md b/Elternbeirat.Web/Content/pages/kontakt.md new file mode 100644 index 0000000..5e2ccfb --- /dev/null +++ b/Elternbeirat.Web/Content/pages/kontakt.md @@ -0,0 +1,16 @@ +--- +title: Kontakt +--- + +# Kontakt + +Sie erreichen den Elternbeirat der IGMH per E-Mail: + +[TODO-adresse@example.org](mailto:TODO-adresse@example.org) + +*TODO: Echte Kontakt-E-Mail-Adresse eintragen.* + +## Anschrift + +Elternbeirat der IGMH +*TODO: Anschrift der Schule ergänzen.* diff --git a/Elternbeirat.Web/Content/pages/vorstandsteam.md b/Elternbeirat.Web/Content/pages/vorstandsteam.md new file mode 100644 index 0000000..df13a9d --- /dev/null +++ b/Elternbeirat.Web/Content/pages/vorstandsteam.md @@ -0,0 +1,18 @@ +--- +title: Vorstandsteam +--- + +# Das Vorstandsteam + +Der Elternbeirat der IGMH wird von einem ehrenamtlichen Vorstand geleitet. +Er vertritt die Elternschaft gegenüber Schule und Schulträger und koordiniert +die Arbeit der Klassenelternvertreter. + +## Aufgaben des Vorstands + +- Vertretung der Eltern in der Schulkonferenz +- Zusammenarbeit mit Schulleitung und Kollegium +- Organisation der Vollversammlungen und Sitzungen +- Ansprechpartner für Fragen rund um Mensa, Schließfächer und Förderverein + +*Die namentliche Vorstellung der Vorstandsmitglieder folgt.* diff --git a/Elternbeirat.Web/Content/posts/neue-sporthalle-eroeffnet.md b/Elternbeirat.Web/Content/posts/neue-sporthalle-eroeffnet.md new file mode 100644 index 0000000..f59270e --- /dev/null +++ b/Elternbeirat.Web/Content/posts/neue-sporthalle-eroeffnet.md @@ -0,0 +1,11 @@ +--- +title: Neue Sporthalle feierlich eröffnet +date: 2025-10-05 +--- + +# Neue Sporthalle feierlich eröffnet + +Die neue Sporthalle der IGMH ist eröffnet. Schülerinnen, Schüler und Lehrkräfte +haben damit deutlich mehr Platz für Sportunterricht und Arbeitsgemeinschaften. + +*TODO: Bericht und Fotos ergänzen.* diff --git a/Elternbeirat.Web/Content/posts/neuer-vorstand-gewaehlt.md b/Elternbeirat.Web/Content/posts/neuer-vorstand-gewaehlt.md new file mode 100644 index 0000000..8cb545c --- /dev/null +++ b/Elternbeirat.Web/Content/posts/neuer-vorstand-gewaehlt.md @@ -0,0 +1,12 @@ +--- +title: Neuer Vorstand des Elternbeirats gewählt +date: 2025-02-26 +--- + +# Neuer Vorstand des Elternbeirats gewählt + +Bei der Vollversammlung hat der Elternbeirat der IGMH einen neuen Vorstand +gewählt. Das Team bedankt sich für das entgegengebrachte Vertrauen und freut +sich auf die gemeinsame Arbeit im neuen Schuljahr. + +*TODO: Namen und Ämter des neuen Vorstands ergänzen (nach Freigabe).* diff --git a/Elternbeirat.Web/Elternbeirat.Web.csproj b/Elternbeirat.Web/Elternbeirat.Web.csproj index fca27e2..e6069f2 100644 --- a/Elternbeirat.Web/Elternbeirat.Web.csproj +++ b/Elternbeirat.Web/Elternbeirat.Web.csproj @@ -7,4 +7,21 @@ true + + + + + + + + + + + + + + + diff --git a/Elternbeirat.Web/Program.cs b/Elternbeirat.Web/Program.cs index 9bdcf2f..8fe62ab 100644 --- a/Elternbeirat.Web/Program.cs +++ b/Elternbeirat.Web/Program.cs @@ -1,4 +1,5 @@ using Elternbeirat.Web.Components; +using Elternbeirat.Web.Services; using Microsoft.AspNetCore.HttpOverrides; var builder = WebApplication.CreateBuilder(args); @@ -6,13 +7,18 @@ var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddRazorComponents(); +// Content is read once at startup and cached -> singletons. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + var app = builder.Build(); -// NPM terminiert TLS und ist der einzige Weg zum Container (kein Port-Mapping -// im Produktivbetrieb, siehe plan.md AE-4). Ohne UseForwardedHeaders sieht die -// App jede Anfrage als HTTP und mit der Proxy-IP statt der Client-IP. -// KnownNetworks/KnownProxies bewusst geleert, weil ausschliesslich NPM den -// Container erreicht. +// NPM terminates TLS and is the only way to reach the container (no port +// mapping in production). Without UseForwardedHeaders the app sees every request +// as HTTP and with the proxy IP instead of the client IP. +// KnownNetworks/KnownProxies are deliberately empty because only NPM reaches +// the container. app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, @@ -24,9 +30,8 @@ app.UseForwardedHeaders(new ForwardedHeadersOptions if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error", createScopeForErrors: true); - // Kein UseHsts() und kein UseHttpsRedirection(): NPM setzt HSTS und - // terminiert TLS. Beides hier wuerde hinter dem Proxy eine - // Redirect-Schleife erzeugen (plan.md AE-4). + // No UseHsts() and no UseHttpsRedirection(): NPM sets HSTS and terminates + // TLS. Both here would create a redirect loop behind the proxy. } app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); @@ -36,4 +41,9 @@ app.UseAntiforgery(); app.MapStaticAssets(); app.MapRazorComponents(); +// Subscribable calendar feed of all events. A minimal API endpoint rather than +// a Razor page because it returns text/calendar, not HTML. +app.MapGet("/termine.ics", (EventService events) => + Results.Text(IcsCalendar.Build(events.All), "text/calendar; charset=utf-8")); + app.Run(); diff --git a/Elternbeirat.Web/Services/Event.cs b/Elternbeirat.Web/Services/Event.cs new file mode 100644 index 0000000..282f074 --- /dev/null +++ b/Elternbeirat.Web/Services/Event.cs @@ -0,0 +1,33 @@ +namespace Elternbeirat.Web.Services; + +/// +/// A single calendar entry (parents' evening, meeting, deadline). Read from +/// Content/events.yml at startup. All display text is German because it +/// is visitor-facing content. +/// +public sealed class Event +{ + /// Short title shown in the list and the calendar. + public required string Title { get; init; } + + /// + /// When the entry starts. A date-only entry (no time) is treated as an + /// all-day event; see . + /// + public required DateTime Start { get; init; } + + /// + /// True when carries a wall-clock time, false for an + /// all-day entry. Drives both the list formatting and the ICS export. + /// + public required bool HasTime { get; init; } + + /// Optional end, for multi-hour or multi-day entries. + public DateTime? End { get; init; } + + /// Optional location, e.g. "Aula" or "Mensa". + public string? Location { get; init; } + + /// Optional free-text note shown below the entry. + public string? Note { get; init; } +} diff --git a/Elternbeirat.Web/Services/EventService.cs b/Elternbeirat.Web/Services/EventService.cs new file mode 100644 index 0000000..5ac727e --- /dev/null +++ b/Elternbeirat.Web/Services/EventService.cs @@ -0,0 +1,137 @@ +using System.Globalization; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Elternbeirat.Web.Services; + +/// +/// Reads the calendar entries from Content/events.yml once at startup and +/// keeps them in memory. Registered as a singleton because the content lives in +/// the image and does not change at runtime. +/// +public sealed class EventService +{ + private readonly IReadOnlyList _events; + + /// + /// Reads and parses Content/events.yml below the application root. + /// A missing file yields an empty list rather than an error. + /// + public EventService(IWebHostEnvironment environment) + { + var path = Path.Combine(environment.ContentRootPath, "Content", "events.yml"); + _events = File.Exists(path) + ? Parse(File.ReadAllText(path)) + : []; + } + + /// + /// Entries that start today or later, earliest first — the upcoming section. + /// "Today" is compared by date, so an entry earlier today still counts as + /// upcoming. + /// + public IReadOnlyList Upcoming => + _events + .Where(e => DateOnly.FromDateTime(e.Start) >= DateOnly.FromDateTime(DateTime.Today)) + .OrderBy(e => e.Start) + .ToList(); + + /// + /// Entries that already passed, most recent first — the past section shown + /// below the upcoming ones. + /// + public IReadOnlyList Past => + _events + .Where(e => DateOnly.FromDateTime(e.Start) < DateOnly.FromDateTime(DateTime.Today)) + .OrderByDescending(e => e.Start) + .ToList(); + + /// All entries in chronological order, for the ICS export. + public IReadOnlyList All => + _events.OrderBy(e => e.Start).ToList(); + + private static List Parse(string yaml) + { + // Keys are lowercase single words (title, start, ...), which the camel + // case convention maps onto the PascalCase properties. + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + + var raw = deserializer.Deserialize?>(yaml) ?? []; + return raw + .Where(entry => entry.Title is not null && entry.Start is not null) + .Select(ToEvent) + .OfType() + .ToList(); + } + + private static Event? ToEvent(EventEntry entry) + { + if (!TryParseWhen(entry.Start!, out var start, out var hasTime)) + { + // A malformed date drops the entry rather than crashing startup. + return null; + } + + DateTime? end = null; + if (entry.End is not null && TryParseWhen(entry.End, out var parsedEnd, out _)) + { + end = parsedEnd; + } + + return new Event + { + Title = entry.Title!, + Start = start, + HasTime = hasTime, + End = end, + Location = entry.Location, + Note = entry.Note, + }; + } + + /// + /// Parses a value that is either a date (2026-03-15) or a date with a + /// time (2026-03-15 19:30). Sets so the + /// caller knows whether to treat the entry as all-day. + /// + private static bool TryParseWhen(string value, out DateTime when, out bool hasTime) + { + var trimmed = value.Trim(); + + if (DateOnly.TryParseExact(trimmed, "yyyy-MM-dd", + CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateOnly)) + { + when = dateOnly.ToDateTime(TimeOnly.MinValue); + hasTime = false; + return true; + } + + string[] formats = ["yyyy-MM-dd HH:mm", "yyyy-MM-dd'T'HH:mm"]; + if (DateTime.TryParseExact(trimmed, formats, + CultureInfo.InvariantCulture, DateTimeStyles.None, out when)) + { + hasTime = true; + return true; + } + + when = default; + hasTime = false; + return false; + } + + /// + /// Raw shape of one YAML entry, before validation. String-typed on purpose so + /// we control date parsing and can distinguish date-only from date-and-time. + /// + private sealed class EventEntry + { + public string? Title { get; set; } + public string? Start { get; set; } + public string? End { get; set; } + public string? Location { get; set; } + public string? Note { get; set; } + } +} diff --git a/Elternbeirat.Web/Services/FrontMatter.cs b/Elternbeirat.Web/Services/FrontMatter.cs new file mode 100644 index 0000000..950ddab --- /dev/null +++ b/Elternbeirat.Web/Services/FrontMatter.cs @@ -0,0 +1,42 @@ +using Markdig; +using Markdig.Extensions.Yaml; +using Markdig.Syntax; + +namespace Elternbeirat.Web.Services; + +/// +/// Reads simple key: value pairs from a Markdown file's YAML front +/// matter. Kept deliberately minimal (one value per line, no nesting) — this is +/// enough for titles and dates. YamlDotNet will take over once we need +/// structured metadata. Front-matter keys stay German because the Markdown +/// files are edited by German-speaking editors. +/// +public static class FrontMatter +{ + /// + /// Returns the value of the given front-matter key, or null if the + /// file has no front matter or the key is missing. The key match is + /// case-insensitive; surrounding quotes on the value are removed. + /// + public static string? Read(string key, MarkdownDocument document, string source) + { + var block = document.Descendants().FirstOrDefault(); + if (block is null) + { + return null; + } + + var prefix = key + ":"; + var frontMatter = source.Substring(block.Span.Start, block.Span.Length); + foreach (var line in frontMatter.Split('\n')) + { + var trimmed = line.Trim(); + if (trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return trimmed[prefix.Length..].Trim().Trim('"'); + } + } + + return null; + } +} diff --git a/Elternbeirat.Web/Services/IcsCalendar.cs b/Elternbeirat.Web/Services/IcsCalendar.cs new file mode 100644 index 0000000..1351c0c --- /dev/null +++ b/Elternbeirat.Web/Services/IcsCalendar.cs @@ -0,0 +1,105 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace Elternbeirat.Web.Services; + +/// +/// Builds an iCalendar (RFC 5545) document from the events so visitors can +/// subscribe to the calendar in their own app. Times are written as local +/// wall-clock times without a time zone, which is what a school calendar needs: +/// "19:30" should read as 19:30 everywhere. +/// +public static class IcsCalendar +{ + private const string ProductId = "-//Elternbeirat IGMH//Termine//DE"; + + /// + /// Serialises the given entries into a single VCALENDAR document. + /// + public static string Build(IReadOnlyList events) + { + var sb = new StringBuilder(); + AppendLine(sb, "BEGIN:VCALENDAR"); + AppendLine(sb, "VERSION:2.0"); + AppendLine(sb, $"PRODID:{ProductId}"); + AppendLine(sb, "CALSCALE:GREGORIAN"); + AppendLine(sb, "METHOD:PUBLISH"); + AppendLine(sb, "X-WR-CALNAME:Elternbeirat IGMH"); + + foreach (var ev in events) + { + AppendEvent(sb, ev); + } + + AppendLine(sb, "END:VCALENDAR"); + return sb.ToString(); + } + + private static void AppendEvent(StringBuilder sb, Event ev) + { + AppendLine(sb, "BEGIN:VEVENT"); + AppendLine(sb, $"UID:{Uid(ev)}"); + // No live timestamp: a stable DTSTAMP keeps the feed byte-identical + // between requests, so caches and clients do not see spurious changes. + AppendLine(sb, "DTSTAMP:20000101T000000Z"); + + if (ev.HasTime) + { + AppendLine(sb, $"DTSTART:{Local(ev.Start)}"); + if (ev.End is { } end) + { + AppendLine(sb, $"DTEND:{Local(end)}"); + } + } + else + { + AppendLine(sb, $"DTSTART;VALUE=DATE:{Date(ev.Start)}"); + // For an all-day event DTEND is exclusive: add one day so a single + // day shows as one day, and a range covers its last day. + var end = ev.End ?? ev.Start; + AppendLine(sb, $"DTEND;VALUE=DATE:{Date(end.AddDays(1))}"); + } + + AppendLine(sb, $"SUMMARY:{Escape(ev.Title)}"); + if (!string.IsNullOrWhiteSpace(ev.Location)) + { + AppendLine(sb, $"LOCATION:{Escape(ev.Location)}"); + } + if (!string.IsNullOrWhiteSpace(ev.Note)) + { + AppendLine(sb, $"DESCRIPTION:{Escape(ev.Note)}"); + } + + AppendLine(sb, "END:VEVENT"); + } + + /// + /// 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. + /// + private static string Uid(Event ev) + { + var seed = $"{ev.Title}|{ev.Start:O}"; + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed)); + return $"{Convert.ToHexString(hash)[..16].ToLowerInvariant()}@elternbeirat-igmh"; + } + + private static string Local(DateTime value) => + value.ToString("yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture); + + private static string Date(DateTime value) => + value.ToString("yyyyMMdd", CultureInfo.InvariantCulture); + + /// Escapes the characters that are special in an ICS text value. + private static string Escape(string value) => value + .Replace("\\", "\\\\") + .Replace(";", "\\;") + .Replace(",", "\\,") + .Replace("\r\n", "\\n") + .Replace("\n", "\\n"); + + // ICS lines are terminated with CRLF regardless of platform. + private static void AppendLine(StringBuilder sb, string line) => + sb.Append(line).Append("\r\n"); +} diff --git a/Elternbeirat.Web/Services/Page.cs b/Elternbeirat.Web/Services/Page.cs new file mode 100644 index 0000000..4b0f451 --- /dev/null +++ b/Elternbeirat.Web/Services/Page.cs @@ -0,0 +1,25 @@ +namespace Elternbeirat.Web.Services; + +/// +/// A static content page, read from a Markdown file in Content/pages/. +/// The file name (without extension) is the slug. +/// +public sealed class Page +{ + /// + /// URL identifier of the page, taken from the file name without umlauts + /// (e.g. vorstandsteam). It appears in the route /{slug}. + /// + public required string Slug { get; init; } + + /// + /// Display title from the YAML front matter (titel:). Falls back to + /// the slug when no title is set. + /// + public required string Title { get; init; } + + /// + /// The page body rendered from Markdown to HTML (front matter excluded). + /// + public required string ContentHtml { get; init; } +} diff --git a/Elternbeirat.Web/Services/PageService.cs b/Elternbeirat.Web/Services/PageService.cs new file mode 100644 index 0000000..d191887 --- /dev/null +++ b/Elternbeirat.Web/Services/PageService.cs @@ -0,0 +1,57 @@ +using Markdig; + +namespace Elternbeirat.Web.Services; + +/// +/// Reads the static content pages from Content/pages/*.md, renders them +/// to HTML once at startup and keeps them in memory. Registered as a singleton +/// because the content lives in the image and does not change at runtime. +/// +public sealed class PageService +{ + private readonly IReadOnlyDictionary _pagesBySlug; + + /// + /// Reads every Markdown page from the Content/pages directory below + /// the application root and caches it. + /// + public PageService(IWebHostEnvironment environment) + { + var pipeline = new MarkdownPipelineBuilder() + .UseYamlFrontMatter() + .Build(); + + var directory = Path.Combine(environment.ContentRootPath, "Content", "pages"); + var pages = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (Directory.Exists(directory)) + { + foreach (var path in Directory.EnumerateFiles(directory, "*.md")) + { + var page = Read(path, pipeline); + pages[page.Slug] = page; + } + } + + _pagesBySlug = pages; + } + + /// + /// Returns the page for the given slug, or null if there is none. + /// The lookup is case-insensitive. + /// + public Page? Find(string slug) => + _pagesBySlug.GetValueOrDefault(slug); + + private static Page Read(string path, MarkdownPipeline pipeline) + { + var slug = Path.GetFileNameWithoutExtension(path); + var source = File.ReadAllText(path); + + var document = Markdown.Parse(source, pipeline); + var title = FrontMatter.Read("title", document, source) ?? slug; + var html = Markdown.ToHtml(source, pipeline); + + return new Page { Slug = slug, Title = title, ContentHtml = html }; + } +} diff --git a/Elternbeirat.Web/Services/Post.cs b/Elternbeirat.Web/Services/Post.cs new file mode 100644 index 0000000..8631cd4 --- /dev/null +++ b/Elternbeirat.Web/Services/Post.cs @@ -0,0 +1,33 @@ +namespace Elternbeirat.Web.Services; + +/// +/// A news post, read from a Markdown file in Content/posts/. Unlike a +/// , a post has a date and +/// is shown in a chronological list. The file name (without extension) is the +/// slug. +/// +public sealed class Post +{ + /// + /// URL identifier of the post, taken from the file name without umlauts. + /// It appears in the route /beitraege/{slug}. + /// + public required string Slug { get; init; } + + /// + /// Display title from the YAML front matter (titel:). Falls back to + /// the slug when no title is set. + /// + public required string Title { get; init; } + + /// + /// Publication date from the front matter (datum:). Used to sort the + /// list newest first. + /// + public required DateOnly Date { get; init; } + + /// + /// The post body rendered from Markdown to HTML (front matter excluded). + /// + public required string ContentHtml { get; init; } +} diff --git a/Elternbeirat.Web/Services/PostService.cs b/Elternbeirat.Web/Services/PostService.cs new file mode 100644 index 0000000..996d8da --- /dev/null +++ b/Elternbeirat.Web/Services/PostService.cs @@ -0,0 +1,86 @@ +using System.Globalization; +using Markdig; + +namespace Elternbeirat.Web.Services; + +/// +/// Reads the news posts from Content/posts/*.md, renders them to HTML +/// once at startup and keeps them in memory, sorted newest first. Registered as +/// a singleton because the content lives in the image and does not change at +/// runtime. +/// +public sealed class PostService +{ + private readonly IReadOnlyList _posts; + private readonly IReadOnlyDictionary _postsBySlug; + + /// + /// Reads every Markdown post from the Content/posts directory + /// below the application root and caches it. + /// + public PostService(IWebHostEnvironment environment) + { + var pipeline = new MarkdownPipelineBuilder() + .UseYamlFrontMatter() + .Build(); + + var directory = Path.Combine(environment.ContentRootPath, "Content", "posts"); + var posts = new List(); + + if (Directory.Exists(directory)) + { + foreach (var path in Directory.EnumerateFiles(directory, "*.md")) + { + posts.Add(Read(path, pipeline)); + } + } + + // Newest first for the list; a stable slug order breaks date ties. + posts.Sort((a, b) => + { + var byDate = b.Date.CompareTo(a.Date); + return byDate != 0 ? byDate : string.CompareOrdinal(a.Slug, b.Slug); + }); + + _posts = posts; + _postsBySlug = posts.ToDictionary(p => p.Slug, StringComparer.OrdinalIgnoreCase); + } + + /// + /// All posts, newest first. Used for the overview list. + /// + public IReadOnlyList All => _posts; + + /// + /// Returns the post for the given slug, or null if there is none. + /// The lookup is case-insensitive. + /// + public Post? Find(string slug) => + _postsBySlug.GetValueOrDefault(slug); + + private static Post Read(string path, MarkdownPipeline pipeline) + { + var slug = Path.GetFileNameWithoutExtension(path); + var source = File.ReadAllText(path); + + var document = Markdown.Parse(source, pipeline); + var title = FrontMatter.Read("title", document, source) ?? slug; + var date = ParseDate(FrontMatter.Read("date", document, source)); + var html = Markdown.ToHtml(source, pipeline); + + return new Post { Slug = slug, Title = title, Date = date, ContentHtml = html }; + } + + /// + /// Parses the datum: value as an ISO date (yyyy-MM-dd). Falls back to + /// when the value is missing or malformed, + /// so a single bad file does not crash startup — it just sorts last. + /// + private static DateOnly ParseDate(string? value) + { + return DateOnly.TryParse(value, CultureInfo.InvariantCulture, + DateTimeStyles.None, out var date) + ? date + : DateOnly.MinValue; + } +} diff --git a/Elternbeirat.Web/wwwroot/app.css b/Elternbeirat.Web/wwwroot/app.css index 5388357..161563d 100644 --- a/Elternbeirat.Web/wwwroot/app.css +++ b/Elternbeirat.Web/wwwroot/app.css @@ -1,7 +1,112 @@ +/* Base styles. Self-hosted system font stack — no external fonts, for privacy. */ +html { + font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + line-height: 1.6; + color: #222; +} + +body { + margin: 0; +} + +a { + color: #1f3a5f; +} + +h1, h2, h3 { + line-height: 1.25; + color: #1f3a5f; +} + h1:focus { outline: none; } +/* Home page */ +.intro { + font-size: 1.1rem; + color: #444; +} + +.home-news { + margin-top: 2rem; +} + +/* News list on /beitraege */ +.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, +.post-meta time { + display: block; + color: #777; + font-size: 0.85rem; +} + +.post-meta { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 1rem; + margin-bottom: 1rem; +} + +/* Event list on /termine */ +.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; +} + +.event-note { + margin: 0.25rem 0 0; + color: #444; +} + +.event-list-past { + color: #777; +} + +.event-list-past .event-title { + font-weight: 400; +} + .valid.modified:not([type=checkbox]) { outline: 1px solid #26b050; } diff --git a/Elternbeirat.slnx b/Elternbeirat.slnx index 99c32aa..e50c80f 100644 --- a/Elternbeirat.slnx +++ b/Elternbeirat.slnx @@ -1,3 +1,4 @@ + diff --git a/compose.yaml b/compose.yaml index 85ad2e8..e3ff3ef 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,16 +1,16 @@ -# TEST-Setup fuer den ersten Lauf auf Unraid (ohne NPM). +# TEST setup for the first run on Unraid (without NPM). # -# Zieht das fertige Image aus der Gitea-Registry (Build passiert auf dem -# Entwicklungsrechner, siehe docs/deployment.md), statt auf Unraid aus dem -# Quellcode zu bauen. +# Pulls the finished image from the Gitea registry (the build happens on the +# development machine, see docs/deployment.md) instead of building from source +# on Unraid. # -# Weicht bewusst vom Produktiv-Setup in plan.md Abschnitt 8 ab: -# - Es gibt ein Port-Mapping (5000 aussen -> 8080 innen), damit die App im -# lokalen Netz unter http://:5000 erreichbar ist. Im Produktiv- -# betrieb entfaellt das Mapping; dort ist nur NPM der Weg zum Container -# (dann stattdessen das externe npm-Netz, siehe docs/deployment.md). +# Deliberately differs from the production setup: +# - There is a port mapping (5000 outside -> 8080 inside) so the app is +# reachable in the local network at http://:5000. In production the +# mapping is dropped; there NPM is the only path to the container (use the +# external npm network instead, see docs/deployment.md). # -# Sobald NPM steht, wird diese Datei durch das Produktiv-compose ersetzt. +# Once NPM is in place, this file is replaced by the production compose. services: eb-web: @@ -21,4 +21,4 @@ services: ASPNETCORE_URLS: http://+:8080 TZ: Europe/Berlin ports: - - "5000:8080" # TEST-Zugang, im Produktivbetrieb entfernen + - "5000:8080" # TEST access, remove in production diff --git a/docs/deployment.md b/docs/deployment.md index 0329275..d429770 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,122 +1,120 @@ # Deployment -Wie ein neuer Stand der Website auf Unraid landet. Aktueller Stand: -**Handbetrieb** — Image lokal bauen, in die Gitea-Registry pushen, auf Unraid -ziehen. Die Automatisierung per Gitea Actions (plan.md Schritt 10) kommt später. +How a new version of the website ends up on Unraid. Current state: +**manual** — build the image locally, push it to the Gitea registry, pull it on +Unraid. Automation via Gitea Actions comes later. -> **Test- vs. Produktivbetrieb.** Solange die Domain noch nicht umgezogen ist und -> NPM nicht davorsteht, läuft der Container mit einem Port-Mapping und ist im -> lokalen Netz direkt erreichbar (`http://:5000`). Im Produktivbetrieb -> entfällt das Mapping — dann ist nur NPM der Weg zum Container (plan.md AE-4, -> Abschnitt 8). Die beiden compose-Varianten sind unten getrennt beschrieben. +> **Test vs. production.** As long as the domain has not moved yet and NPM is not +> in front of it, the container runs with a port mapping and is directly reachable +> on the local network (`http://:5000`). In production the mapping is gone +> — then NPM is the only path to the container. The two compose variants are +> described separately below. --- -## Voraussetzungen (einmalig) +## Prerequisites (one-time) -### Gitea-Registry-Token +### Gitea registry token -Der Push in die Registry braucht ein Gitea-Zugriffstoken mit **Paket-Schreibrecht** -(`package: Read and Write`) — **nicht** das Kontopasswort. +The push to the registry needs a Gitea access token with **package write +permission** (`package: Read and Write`) — **not** the account password. -1. Gitea → oben rechts Profilbild → **Settings** → **Applications**. -2. Abschnitt **Manage Access Tokens**: Name vergeben (z.B. `registry-push`). -3. Unter **Select scopes**: `package` auf **Read and Write** stellen. -4. **Generate Token** klicken, Zeichenkette **sofort kopieren** (nur einmal - sichtbar). +1. Gitea → top right profile picture → **Settings** → **Applications**. +2. **Manage Access Tokens** section: assign a name (e.g. `registry-push`). +3. Under **Select scopes**: set `package` to **Read and Write**. +4. Click **Generate Token**, **copy the string immediately** (shown only once). -> **Token ist ein Geheimnis.** Niemals in Git, Chats, Screenshots oder Tickets -> im Klartext ablegen. Wird eins doch einmal sichtbar: in Gitea **löschen** und -> neu erzeugen. Ein `package`-Token erlaubt das Hochladen beliebiger Images in -> die Registry. +> **The token is a secret.** Never store it in plain text in Git, chats, +> screenshots, or tickets. If one does become visible: **delete** it in Gitea and +> generate a new one. A `package` token allows uploading arbitrary images to the +> registry. -Der Docker-Login speichert das Token danach lokal, sodass es nur einmal -eingegeben werden muss. +The Docker login then stores the token locally, so it only has to be entered once. --- -## Arbeitsweise: Branches, nie direkt auf main +## Way of working: branches, never directly on main -`main` ist der **veröffentlichte** Stand — nur daraus wird `:latest` gebaut, und -nur `:latest` zieht Unraid. Deshalb wird nie direkt auf `main` committet: +`main` is the **published** state — only `:latest` is built from it, and only +`:latest` is pulled by Unraid. That is why we never commit directly to `main`: -1. Feature-Branch anlegen: `git switch -c /` (z.B. +1. Create a feature branch: `git switch -c /` (e.g. `deployment/sha-tagging`, `content/pipeline`). -2. Dort committen, Branch pushen: `git push -u origin `. -3. In Gitea einen **Pull Request** gegen `main` öffnen und dort mergen. -4. Erst danach aus `main` das Release-Image bauen (unten). +2. Commit there, push the branch: `git push -u origin `. +3. Open a **Pull Request** against `main` in Gitea and merge it there. +4. Only then build the release image from `main` (below). -**Dev-Images bleiben lokal.** Zum Ausprobieren auf dem eigenen Rechner: +**Dev images stay local.** For trying things out on your own machine: ```bash -scripts/dev-build.sh # baut elternbeirat-web:dev-, pusht NICHTS +scripts/dev-build.sh # builds elternbeirat-web:dev-, pushes NOTHING ``` -So kann ein Entwicklungsstand nie versehentlich als `:latest` in der Registry -landen. +This way a development state can never accidentally land in the registry as +`:latest`. -## Neuen Stand ausrollen (Handbetrieb) +## Rolling out a new version (manual) -Voraussetzung: einmalig an der Registry angemeldet (siehe unten). Dann, **auf -main** und mit sauberem Arbeitsverzeichnis: +Prerequisite: logged in to the registry once (see below). Then, **on main** and +with a clean working directory: ```bash scripts/release.sh ``` -Das Skript baut das Image, taggt es mit `:latest` **und** dem Commit-Kurz-SHA -(für Rollback) und pusht beide. Es **bricht ab**, wenn du nicht auf `main` bist -oder uncommittete Änderungen hast, und warnt bei ungepushten Commits. +The script builds the image, tags it with `:latest` **and** the short commit SHA +(for rollback), and pushes both. It **aborts** if you are not on `main` or have +uncommitted changes, and warns about unpushed commits. -> Der Image-Pfad `gitea.anticarnist.de/tom/elternbeirat` ist **kleingeschrieben** -> — Container-Registries verlangen das im Pfad, obwohl Benutzer (`Tom`) und Repo -> (`Elternbeirat`) großgeschrieben sind. +> The image path `gitea.anticarnist.de/tom/elternbeirat` is **lowercase** — +> container registries require that in the path, even though the user (`Tom`) and +> the repo (`Elternbeirat`) are capitalized. -### Unraid einmalig an der Registry anmelden +### Logging Unraid in to the registry once -Das Image ist **privat**, deshalb muss Unraid sich einmal anmelden, bevor es -ziehen kann. Das **Compose Manager Plus**-Plugin hat dafür kein UI-Feld — der -Login läuft über das Unraid-Terminal (`>_`-Symbol oben rechts in der -Weboberfläche, Prompt `root@Cube:~#`): +The image is **private**, so Unraid has to log in once before it can pull. The +**Compose Manager Plus** plugin has no UI field for this — the login runs through +the Unraid terminal (`>_` symbol at the top right of the web interface, prompt +`root@Cube:~#`): ```bash docker login gitea.anticarnist.de # Username: Tom -# Password: +# Password: ``` -Der Login bleibt gespeichert; er muss nur wiederholt werden, wenn das Token -wechselt. Zwei erfahrungsgemäße Stolpersteine: +The login stays stored; it only has to be repeated when the token changes. Two +pitfalls learned from experience: -- **Nicht mit PowerShell/Laptop verwechseln.** Der Login muss im *Unraid*-Terminal - passieren (`root@Cube`), nicht in der Windows-PowerShell (`PS C:\`). Der Laptop - braucht den Login nur zum *Pushen*, Unraid zum *Ziehen*. -- **Falscher Username bleibt hängen.** Meldet der Login „Stored credentials - invalid or expired" und fragt *nicht* nach dem Namen, erst `docker logout - gitea.anticarnist.de`, dann neu einloggen — sonst wird versehentlich ein - Nonsens-Username gespeichert. -- **Klartext-Warnung.** Docker speichert das Token unverschlüsselt in - `/root/.docker/config.json`. Auf dem eigenen Server für den Anfang okay. - *Später sauberer:* einen Credential-Helper einrichten (→ offener Punkt unten). +- **Don't confuse it with PowerShell/laptop.** The login has to happen in the + *Unraid* terminal (`root@Cube`), not in Windows PowerShell (`PS C:\`). The laptop + needs the login only to *push*, Unraid to *pull*. +- **A wrong username gets stuck.** If the login reports "Stored credentials + invalid or expired" and does *not* ask for the name, first `docker logout + gitea.anticarnist.de`, then log in again — otherwise a nonsense username gets + stored by accident. +- **Plain-text warning.** Docker stores the token unencrypted in + `/root/.docker/config.json`. On your own server, okay for a start. + *Cleaner later:* set up a credential helper (→ open item below). -### Auf Unraid neu ziehen und starten +### Pull and start again on Unraid -Im **Compose Manager Plus**-Plugin (Unraid-Weboberfläche): +In the **Compose Manager Plus** plugin (Unraid web interface): -- Reiter **Docker** → Abschnitt **Compose** → Stack **elternbeirat** → - **Compose Down**, dann **Compose Up** (oder „Pull" + „Up", je nach - Plugin-Version), damit die neue `:latest` gezogen wird. +- **Docker** tab → **Compose** section → stack **elternbeirat** → + **Compose Down**, then **Compose Up** (or "Pull" + "Up", depending on the plugin + version), so that the new `:latest` is pulled. -> `docker compose up` zieht ein `:latest` **nicht** automatisch neu, wenn schon -> ein gleichnamiges Image lokal liegt. Im Zweifel vorher explizit „Pull". +> `docker compose up` does **not** automatically re-pull a `:latest` if an image of +> the same name is already present locally. When in doubt, explicitly "Pull" first. --- -## compose-Varianten +## compose variants -### Test (jetzt: ohne NPM, direkt im LAN erreichbar) +### Test (now: without NPM, directly reachable on the LAN) -Liegt im Repo als `compose.yaml`. Zieht das Registry-Image und mappt Port +Present in the repo as `compose.yaml`. Pulls the registry image and maps port **5000 → 8080**: ```yaml @@ -129,15 +127,14 @@ services: ASPNETCORE_URLS: http://+:8080 TZ: Europe/Berlin ports: - - "5000:8080" # TEST-Zugang, im Produktivbetrieb entfernen + - "5000:8080" # TEST access, remove in production ``` -Erreichbar unter `http://:5000`. +Reachable at `http://:5000`. -### Produktiv (später: nur über NPM) +### Production (later: only via NPM) -Kein `ports:`-Block, stattdessen das externe NPM-Docker-Netz (plan.md -Abschnitt 8): +No `ports:` block, instead the external NPM Docker network: ```yaml services: @@ -159,27 +156,27 @@ networks: ## Rollback -`scripts/release.sh` taggt jeden Release zusätzlich mit dem Commit-Kurz-SHA, der -sich — anders als das wandernde `:latest` — nie verschiebt. Zum Zurückrollen in -der Unraid-`compose.yaml` `:latest` durch `:` des letzten funktionierenden -Stands ersetzen und neu hochfahren: +`scripts/release.sh` additionally tags each release with the short commit SHA, +which — unlike the moving `:latest` — never shifts. To roll back, replace +`:latest` in the Unraid `compose.yaml` with the `:` of the last working state +and bring it back up: ```yaml image: gitea.anticarnist.de/tom/elternbeirat:99dd052 # statt :latest ``` -Welche SHA-Tags in der Registry liegen, zeigt Gitea unter +Which SHA tags are in the registry is shown by Gitea under `Tom/-/packages` → `elternbeirat`. --- -## Offene Automatisierung (später, plan.md Schritt 10) +## Open automation (later) -- `.gitea/workflows/deploy.yml`: auf Push nach `main` bauen und pushen. -- Zwei bekannte Stolpersteine: der `act_runner` braucht Docker-Socket-Zugriff; - die Registry braucht das `write:package`-Token, nicht das Login-Passwort. -- Redeploy per Watchtower **label-scoped**, sonst aktualisiert er den ganzen - Home-Lab-Bestand. -- **Registry-Token auf Unraid verschlüsseln:** aktuell liegt es im Klartext in - `/root/.docker/config.json`. Später einen Credential-Helper einrichten, damit - die Klartext-Warnung von `docker login` verschwindet. +- `.gitea/workflows/deploy.yml`: build and push on push to `main`. +- Two known pitfalls: the `act_runner` needs Docker socket access; the registry + needs the `write:package` token, not the login password. +- Redeploy via Watchtower **label-scoped**, otherwise it updates the entire + home-lab inventory. +- **Encrypt the registry token on Unraid:** currently it sits in plain text in + `/root/.docker/config.json`. Set up a credential helper later, so that the + plain-text warning from `docker login` disappears. diff --git a/docs/inhalte-pflegen.md b/docs/inhalte-pflegen.md new file mode 100644 index 0000000..a34c8ef --- /dev/null +++ b/docs/inhalte-pflegen.md @@ -0,0 +1,129 @@ +# Editing content + +How to add or change the visitor-facing content of the site. All content lives +as files under `Elternbeirat.Web/Content/` — there is no database and no admin +interface. Every change needs a **commit** and a **rebuild** of the image, since +the content ships inside the image, not in a volume. + +> **Language convention.** File and folder names, front-matter keys and YAML keys +> are English (`posts/`, `title:`, `start:`). The text a visitor reads stays +> German — that includes the *value* after a key (`title: Vorstandsteam`) and the +> Markdown body. So you write English keys with German values. + +The three content kinds: + +| Kind | Where | New entry = | +|---|---|---| +| Page | `Content/pages/*.md` | one Markdown file | +| Post (news) | `Content/posts/*.md` | one Markdown file | +| Event (calendar) | `Content/events.yml` | one entry in the list | + +None of these touch a `.razor` file. + +--- + +## Pages + +A page is a standalone Markdown file in `Content/pages/`. The file name (without +`.md`) is the slug and therefore the URL: `vorstandsteam.md` is served at +`/vorstandsteam`. + +Front matter at the top sets the title; the body is the content: + +```markdown +--- +title: Vorstandsteam +--- + +# Vorstandsteam + +Hier stellt sich das Team vor … +``` + +- **Slugs have no umlauts.** Use `ueber-uns`, not `über-uns`; `foerderverein`, + not `förderverein`. The visible heading in the body may of course use umlauts. +- If you omit `title:`, the slug is used as the title — so always set it. +- Add the new page to the navigation only if it should appear there: edit + `Components/Layout/MainLayout.razor`. Pages that are only linked from other + pages (like the FAQ sub-pages) do not need a nav entry. + +--- + +## Posts (news) + +A post is a Markdown file in `Content/posts/`, same idea as a page but with a +date. Posts show up in the news list at `/beitraege`, newest first, and each has +its own URL at `/beitraege/`. + +```markdown +--- +title: Neue Sporthalle feierlich eröffnet +date: 2025-10-05 +--- + +# Neue Sporthalle feierlich eröffnet + +Der Text des Beitrags … +``` + +- `date:` is an ISO date, **`yyyy-MM-dd`**. It drives the sort order (newest + first) and the displayed date. A missing or malformed date sorts the post last + rather than breaking the build. +- The three most recent posts are also teased on the home page automatically — + nothing to do there. + +--- + +## Events (calendar) + +Events are **not** separate files. They all live in the single list +`Content/events.yml`. Add an entry to the list: + +```yaml +- title: Elternbeiratssitzung + start: "2026-10-08 19:30" + location: Lehrerzimmer + note: Themen bitte vorab per E-Mail einreichen. +``` + +Keys: + +| Key | Required | Meaning | +|---|---|---| +| `title` | yes | Short name shown in the list and calendar | +| `start` | yes | When it starts — see date formats below | +| `end` | no | End, for entries that span hours or days | +| `location` | no | e.g. `Aula`, `Mensa` | +| `note` | no | Free text shown below the entry | + +**Date formats** for `start` and `end`: + +- Date only → all-day event: `"2026-03-15"` +- Date with time: `"2026-03-15 19:30"` (24-hour clock) + +Always keep the value in quotes so YAML treats it as text. + +The overview at `/termine` splits the list automatically: entries today or later +appear under *Kommende Termine* (earliest first), past ones under *Vergangene +Termine* (most recent first). You do not sort the file yourself — order in the +YAML does not matter. + +Visitors can subscribe to `/termine.ics` in their own calendar app; that feed is +generated from the same file, so a new entry appears there too. + +> A malformed `start` drops just that one entry instead of breaking the whole +> page, so a typo in one event will not take the calendar down — but the entry +> silently disappears. If an event does not show up, check its `start` value. + +--- + +## Publishing a change + +A content change is not live until the image is rebuilt and redeployed: + +1. Edit or add the file under `Content/`. +2. Check it locally with `dotnet run --project Elternbeirat.Web`. Content is read + once at startup, so after editing a file **restart the process** to see the + change (or use `dotnet watch` to restart on save automatically). +3. Commit the change. +4. Rebuild and redeploy the image — see [deployment](deployment.md). diff --git a/plan.md b/plan.md index e4cb577..73d24ce 100644 --- a/plan.md +++ b/plan.md @@ -1,160 +1,159 @@ -# elternbeirat-igmh.de — Neuaufbau +# elternbeirat-igmh.de — Rebuild -Ablösung der bisherigen WordPress-Seite durch eine eigene .NET-Anwendung auf -eigener Infrastruktur. +Replacing the previous WordPress site with a custom .NET application on +self-hosted infrastructure. -Stand: 2026-09-20 · Verantwortlich: Tom (Thomas Leininger) +As of: 2026-09-20 · Responsible: Tom (Thomas Leininger) --- -## 1. Ausgangslage +## 1. Starting Situation -- Die Domain `elternbeirat-igmh.de` lag bei Maik Palm (ausscheidendes - Elternbeirat-Mitglied) im Paket „STRATO Hosting Basic", Auftragsnummer 9157927. -- Domaininhaber-Wechsel und Domainumzug sind beidseitig unterschrieben - (19.09.2026), die Einreichung bei Strato steht noch aus. -- Ziel-Paket: Toms „STRATO Mail Plus" (Auftragsnummer 8844576) — Domain + E-Mail, - **kein Webspace**. -- **E-Mail bleibt bei Strato.** Nur die Website zieht auf eigene Hardware. -- **Die bisherige Website ist aktuell offline.** Der Inhaltsbestand ist damit der - zeitkritischste offene Punkt (→ Abschnitt 9). +- The domain `elternbeirat-igmh.de` was held by Maik Palm (departing + Elternbeirat member) under the "STRATO Hosting Basic" package, order number 9157927. +- The change of domain owner and the domain transfer are signed by both parties + (19.09.2026); the submission to Strato is still pending. +- Target package: Tom's "STRATO Mail Plus" (order number 8844576) — domain + email, + **no web space**. +- **Email stays with Strato.** Only the website moves to self-hosted hardware. +- **The previous website is currently offline.** The content is therefore the + most time-critical open item (→ section 9). --- -## 2. Ziele und Nicht-Ziele +## 2. Goals and Non-Goals -**Ziele** +**Goals** -- Öffentliche Informationsseite des Elternbeirats: wer, wann, welche Protokolle, - wie erreichbar. -- Betrieb auf eigener Infrastruktur (Unraid), ohne fremden Hoster. -- Inhalte versionierbar und ohne Datenbank — ein `git clone` ist das vollständige - Backup. -- Wartungsarm: keine Plugin-Updates, keine PHP-Sicherheitslücken, kein CMS-Login - als Angriffsfläche. +- A public information site for the Elternbeirat: who, when, which Protokolle, + how to reach us. +- Operation on self-hosted infrastructure (Unraid), without a third-party hoster. +- Content that is versionable and needs no database — a `git clone` is the complete + backup. +- Low maintenance: no plugin updates, no PHP security holes, no CMS login as an + attack surface. -**Nicht-Ziele (bewusst)** +**Non-Goals (deliberate)** -- Kein CMS mit Web-Editor in Stufe 1. Falls andere Beiratsmitglieder später selbst - redaktionell arbeiten sollen, ist das ein eigener Ausbauschritt (→ Abschnitt 11). -- Keine Benutzerkonten, kein Login, kein Mitgliederbereich. -- Keine Datenbank. -- Keine externen Einbindungen (Fonts, Analytics, Maps, Social Widgets) — aus - Datenschutzgründen, siehe Abschnitt 10. +- No CMS with a web editor in stage 1. If other Elternbeirat members should later + edit content themselves, that is a separate expansion step (→ section 11). +- No user accounts, no login, no members' area. +- No database. +- No external integrations (fonts, analytics, maps, social widgets) — for + data-protection reasons, see section 10. --- -## 3. Architekturentscheidungen +## 3. Architecture Decisions -### AE-1: Blazor mit Static Server-Side Rendering, nicht WebAssembly +### AE-1: Blazor with Static Server-Side Rendering, not WebAssembly -**Entscheidung:** Blazor Web App mit Interaktivitätsmodus *None* (reines statisches -SSR). Zielframework .NET 10 (LTS). +**Decision:** Blazor Web App with interactivity mode *None* (pure static SSR). +Target framework .NET 10 (LTS). -**Begründung:** Blazor WASM lädt mehrere MB Runtime vor dem ersten sichtbaren -Buchstaben, liefert Suchmaschinen und Link-Vorschauen (WhatsApp, Signal, Messenger -— der Hauptverbreitungsweg bei Elternschaften) eine leere Shell und bringt auf -einer reinen Informationsseite keinerlei Gegenwert. Static SSR liefert fertiges -HTML, braucht kein JavaScript und kostet im Container rund 60 MB RAM. +**Rationale:** Blazor WASM loads several MB of runtime before the first visible +letter, serves search engines and link previews (WhatsApp, Signal, Messenger — +the main distribution channel among parents) an empty shell, and provides no value +whatsoever on a pure information site. Static SSR delivers finished HTML, needs no +JavaScript, and costs around 60 MB of RAM in the container. -**Konsequenz:** Interaktivität ist später pro Komponente nachrüstbar -(`@rendermode InteractiveServer` an genau der einen Komponente), ohne die -Architektur zu ändern. +**Consequence:** Interactivity can be added later per component +(`@rendermode InteractiveServer` on exactly that one component), without changing +the architecture. -**Verworfene Alternativen:** +**Rejected alternatives:** -| Alternative | Warum nicht | +| Alternative | Why not | |---|---| -| Blazor WASM | Payload, SEO, Link-Vorschauen, kein Nutzen | -| ASP.NET Core MVC/Razor Pages | Funktioniert genauso, aber Razor Components sind das modernere Modell | -| Statiq.Web (C#-SSG) → nginx | Ops-technisch am schlanksten (nichts zu patchen), aber jede Textänderung erzwingt einen Build-Lauf. Bleibt als Rückfallebene. | -| Astro/Hugo | Ausgereifteres SSG-Ökosystem, aber fremdes Terrain | +| Blazor WASM | Payload, SEO, link previews, no benefit | +| ASP.NET Core MVC/Razor Pages | Works just as well, but Razor Components are the more modern model | +| Statiq.Web (C# SSG) → nginx | Leanest from an ops standpoint (nothing to patch), but every text change forces a build run. Kept as a fallback. | +| Astro/Hugo | More mature SSG ecosystem, but unfamiliar territory | -### AE-2: Inhalte als Dateien im Repo, nicht in einer Datenbank +### AE-2: Content as files in the repo, not in a database -**Entscheidung:** Seiteninhalte als Markdown mit YAML-Frontmatter, Termine als -strukturiertes YAML, Dokumente (Protokolle, Satzung) als PDF unter `wwwroot`. +**Decision:** Page content as Markdown with YAML frontmatter, Termine as +structured YAML, documents (Protokolle, bylaws) as PDF under `wwwroot`. -**Begründung:** Kein DB-Backup, kein Migrationsschema, keine Konsistenzprobleme -zwischen Dateien und Datenbank. Änderungen sind Commits und damit nachvollziehbar -und rückrollbar. Für eine Seite mit ~10 Unterseiten und ein paar Terminen pro Jahr -ist alles andere Overhead. +**Rationale:** No DB backup, no migration schema, no consistency problems between +files and database. Changes are commits and are therefore traceable and +reversible. For a site with ~10 subpages and a few Termine per year, anything else +is overhead. -**Konsequenz:** Textänderungen erfordern einen Commit und ein Redeploy. Das ist bei -erwarteten fünf Änderungen im Jahr akzeptabel — und der Grund, warum Abschnitt 11 -den Ausbau zum Web-Editor als eigene Stufe führt. +**Consequence:** Text changes require a commit and a redeploy. With an expected +five changes a year that is acceptable — and the reason why section 11 treats the +expansion to a web editor as a separate stage. -### AE-3: Inhalte werden ins Image gebacken, nicht als Volume gemountet +### AE-3: Content is baked into the image, not mounted as a volume -**Entscheidung:** `Content/` und `wwwroot/` sind Teil des Images. +**Decision:** `Content/` and `wwwroot/` are part of the image. -**Begründung:** Gemountete Inhalte aus dem Appdata-Share ließen sich zwar direkt -am NAS editieren, aber genau dann driften Repo und Live-Stand auseinander — und -die Eigenschaft „Backup = `git clone`" aus AE-2 wäre wertlos. +**Rationale:** Mounted content from the appdata share could be edited directly on +the NAS, but that is exactly when the repo and the live state drift apart — and +the "backup = `git clone`" property from AE-2 would be worthless. -**Konsequenz:** Kein Schnell-Fix am Live-System. Tippfehler werden korrekt über -einen Commit behoben. +**Consequence:** No quick fix on the live system. Typos are corrected properly via +a commit. -### AE-4: TLS und Zertifikate ausschließlich im Nginx Proxy Manager +### AE-4: TLS and certificates exclusively in the Nginx Proxy Manager -**Entscheidung:** Der Container spricht nur HTTP auf Port 8080 und hat **kein** -Port-Mapping nach außen. NPM terminiert TLS und ist der einzige Weg zum Container. +**Decision:** The container speaks only HTTP on port 8080 and has **no** port +mapping to the outside. NPM terminates TLS and is the only path to the container. -**Begründung:** Entspricht dem bereits etablierten Muster im Home-Lab -(`cloud.anticarnist.de`). Zertifikatsverwaltung bleibt an einer Stelle. +**Rationale:** Matches the pattern already established in the home lab +(`cloud.anticarnist.de`). Certificate management stays in one place. -**Konsequenz (wichtig):** In `Program.cs` **kein** `UseHttpsRedirection()` und -**kein** `UseHsts()` — sonst Redirect-Schleife hinter dem Proxy. HSTS setzt NPM. +**Consequence (important):** In `Program.cs`, **no** `UseHttpsRedirection()` and +**no** `UseHsts()` — otherwise a redirect loop behind the proxy. NPM sets HSTS. --- -## 4. Projekt anlegen (Rider) +## 4. Creating the Project (Rider) -Template **Blazor Web App** mit diesen Optionen: +Template **Blazor Web App** with these options: -| Option | Wert | +| Option | Value | |---|---| | Framework | .NET 10.0 | | Authentication | None | | Interactive render mode | **None** | -| Include sample pages | aus | -| Configure for HTTPS | an (nur für lokale Entwicklung relevant) | -| Do not use top-level statements | egal | -| Enlist in .NET Aspire orchestration | **aus** | +| Include sample pages | off | +| Configure for HTTPS | on (only relevant for local development) | +| Do not use top-level statements | doesn't matter | +| Enlist in .NET Aspire orchestration | **off** | -Projektname: `Elternbeirat.Web`, Solution: `Elternbeirat`. +Project name: `Elternbeirat.Web`, Solution: `Elternbeirat`. -Wichtig ist allein *Interactive render mode = None* — damit erzeugt das Template -kein `.Client`-Projekt und kein WebAssembly-Bundle. +The only thing that matters is *Interactive render mode = None* — this way the +template creates no `.Client` project and no WebAssembly bundle. -**Angelegt und bestätigt (2026-09-20):** Blazor Web App, `net10.0`, Interactive -render mode `None`, Auth `None`, Sample pages aus, Docker-Optionen im Dialog aus -(Dockerfile schreiben wir selbst, siehe Abschnitt 7). Git-Repository beim -Anlegen mit erzeugt. Solution liegt direkt unter -`RiderProjects\Elternbeirat\`, das Projekt in `Elternbeirat.Web\` darunter — -**kein** `src/`-Zwischenverzeichnis, anders als ursprünglich in Abschnitt 5 -skizziert. +**Created and confirmed (2026-09-20):** Blazor Web App, `net10.0`, Interactive +render mode `None`, Auth `None`, sample pages off, Docker options in the dialog off +(we write the Dockerfile ourselves, see section 7). Git repository created along +with it. The solution sits directly under `RiderProjects\Elternbeirat\`, the +project in `Elternbeirat.Web\` below it — **no** intermediate `src/` directory, +unlike originally sketched in section 5. -NuGet-Pakete, die dazukommen: +NuGet packages that get added: -- `Markdig` — Markdown-Rendering -- `YamlDotNet` — Frontmatter und `termine.yml` +- `Markdig` — Markdown rendering +- `YamlDotNet` — frontmatter and `termine.yml` --- -## 5. Repo-Struktur +## 5. Repo Structure ``` -Elternbeirat/ ← Repo-Root, = Solution-Verzeichnis +Elternbeirat/ ← repo root, = solution directory ├── Elternbeirat.sln -├── plan.md ← dieses Dokument -├── CLAUDE.md ← minimal: Trigger, nicht-offensichtliche Kommandos +├── plan.md ← this document +├── CLAUDE.md ← minimal: triggers, non-obvious commands ├── docs/ -│ ├── deployment.md ← Unraid, NPM, Registry, Rollback -│ ├── dns.md ← Strato, DynDNS, Mail-Records -│ ├── inhalte-pflegen.md ← Anleitung für den Nicht-Alltagsfall -│ ├── inhalte-migration.md ← Übernahme aus dem alten WordPress -│ └── recht.md ← Impressum, Datenschutz, Fotos +│ ├── deployment.md ← Unraid, NPM, registry, rollback +│ ├── dns.md ← Strato, DynDNS, mail records +│ ├── inhalte-pflegen.md ← guide for the not-everyday case +│ ├── inhalte-migration.md ← import from the old WordPress +│ └── recht.md ← imprint, privacy, photos ├── Elternbeirat.Web/ │ ├── Elternbeirat.Web.csproj │ ├── Components/ @@ -164,7 +163,7 @@ Elternbeirat/ ← Repo-Root, = Solution-Verzeichnis │ │ └── Pages/ Start, UeberUns, Termine, Protokolle, │ │ News, NewsBeitrag, Kontakt, │ │ Impressum, Datenschutz, Fehler404 -│ ├── Content/ ← Inhalte, kein Code +│ ├── Content/ ← content, no code │ │ ├── seiten/*.md │ │ ├── news/2026-09-20-titel.md │ │ └── termine.yml @@ -173,25 +172,25 @@ Elternbeirat/ ← Repo-Root, = Solution-Verzeichnis │ │ ├── TermineService.cs │ │ └── IcsWriter.cs │ ├── wwwroot/ -│ │ ├── css/site.css ← eigenes CSS, keine CDN-Einbindung +│ │ ├── css/site.css ← own CSS, no CDN integration │ │ ├── img/ -│ │ └── dokumente/ ← Protokolle, Satzung (PDF) +│ │ └── dokumente/ ← Protokolle, bylaws (PDF) │ └── Program.cs -├── Elternbeirat.Web.Tests/ ← Smoke-Tests: jede Route liefert 200 +├── Elternbeirat.Web.Tests/ ← smoke tests: every route returns 200 ├── Dockerfile ├── compose.yaml ├── .dockerignore └── .gitea/workflows/deploy.yml ``` -`CLAUDE.md` bleibt bewusst kurz (Build-/Run-Kommandos, Stilregeln, Verweis auf -`docs/`). Die Details liegen in `docs/` und werden nur bei Bedarf gelesen. +`CLAUDE.md` deliberately stays short (build/run commands, style rules, pointer to +`docs/`). The details live in `docs/` and are read only when needed. --- -## 6. Inhaltsmodell +## 6. Content Model -**Seite** (`Content/seiten/ueber-uns.md`): +**Page** (`Content/seiten/ueber-uns.md`): ```markdown --- @@ -203,7 +202,7 @@ beschreibung: Wer im Elternbeirat der IGMH mitarbeitet und wie wir arbeiten. ## Der Elternbeirat -Fließtext … +Body text … ``` **Termin** (`Content/termine.yml`): @@ -217,69 +216,69 @@ Fließtext … notiz: Gäste willkommen ``` -**News-Beitrag** (`Content/news/2026-09-20-neue-website.md`) — wie Seite, plus -`datum` und `autor`. +**News post** (`Content/news/2026-09-20-neue-website.md`) — like a page, plus +`datum` and `autor`. -Die Services lesen beim Start alles ein, cachen es im Speicher und stellen es -typisiert bereit. In Development zusätzlich ein `FileSystemWatcher`, damit -Textänderungen ohne Neustart sichtbar werden. +The services read everything at startup, cache it in memory, and provide it in a +typed form. In Development there is also a `FileSystemWatcher`, so that text +changes become visible without a restart. -**Zusatznutzen ohne Mehraufwand:** ein ICS-Endpoint unter `/termine.ics`, der die -öffentlichen Termine ausliefert. Eltern abonnieren den Kalender einmal im Handy -und sehen jede Sitzung automatisch. Das ist der eine Punkt, an dem die Eigenbau- -Lösung die alte WordPress-Seite spürbar schlägt. +**Added benefit at no extra cost:** an ICS endpoint at `/termine.ics` that serves +the public Termine. Parents subscribe to the calendar once on their phone and see +every session automatically. This is the one point where the self-built solution +noticeably beats the old WordPress site. --- -## 7. Build und Deployment +## 7. Build and Deployment -### Stufe 1 — Handbetrieb (Start hier) +### Stage 1 — Manual (start here) ```bash docker compose build docker compose up -d ``` -Für fünf Deployments im Jahr vollkommen ausreichend. CI vorab zu bauen wäre -Selbstzweck. +Entirely sufficient for five deployments a year. Building via CI in advance would +be an end in itself. -### Stufe 2 — Gitea Actions (Runner ist vorhanden) +### Stage 2 — Gitea Actions (runner is available) -`.gitea/workflows/deploy.yml` auf Push nach `main`: +`.gitea/workflows/deploy.yml` on push to `main`: 1. `actions/checkout` -2. Login an der Gitea-eigenen Container-Registry -3. `docker/build-push-action` → `gitea./tom/elternbeirat:latest` **und** `:${{ gitea.sha }}` +2. Login to Gitea's own container registry +3. `docker/build-push-action` → `gitea./tom/elternbeirat:latest` **and** `:${{ gitea.sha }}` -Zwei Stolpersteine, die erfahrungsgemäß Zeit kosten: +Two pitfalls that experience shows cost time: -- Der `act_runner` im Docker-Modus braucht Zugriff auf einen Docker-Socket oder - einen DinD-Service, sonst schlägt `build-push-action` fehl. -- Die Gitea-Registry braucht ein Paket-Token mit Schreibrecht (`write:package`), - nicht das normale Login-Passwort. +- The `act_runner` in Docker mode needs access to a Docker socket or a DinD + service, otherwise `build-push-action` fails. +- The Gitea registry needs a package token with write permission (`write:package`), + not the normal login password. -**Immer auch den SHA-Tag pushen.** `:latest` allein macht Rollback unmöglich. +**Always push the SHA tag too.** `:latest` alone makes rollback impossible. -### Redeploy auf Unraid +### Redeploy on Unraid -Watchtower, aber **label-scoped** — sonst aktualisiert er ungefragt den ganzen -Home-Lab-Bestand: +Watchtower, but **label-scoped** — otherwise it updates the entire home-lab +inventory unasked: ```yaml -# im Watchtower-Container +# in the Watchtower container WATCHTOWER_LABEL_ENABLE: "true" ``` ```yaml -# im eb-web-Service +# in the eb-web service labels: com.centurylinklabs.watchtower.enable: "true" ``` -Alternativ: manuell im Unraid-Docker-Tab „Update" drücken. Bei dieser -Änderungsfrequenz völlig legitim. +Alternatively: press "Update" manually in the Unraid Docker tab. At this rate of +change entirely legitimate. -### Dockerfile (Skizze) +### Dockerfile (sketch) ```dockerfile FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build @@ -296,14 +295,14 @@ EXPOSE 8080 ENTRYPOINT ["dotnet", "Elternbeirat.Web.dll"] ``` -Das chiseled-Image läuft ab .NET 8 standardmäßig als non-root (UID 1654) und -hört auf Port 8080. **Es enthält keine Shell** — ein `HEALTHCHECK` mit `curl` -funktioniert dort nicht. Entweder das normale `aspnet:10.0-noble` verwenden oder -die Überwachung NPM bzw. Uptime Kuma überlassen. +The chiseled image runs as non-root (UID 1654) by default from .NET 8 on and +listens on port 8080. **It contains no shell** — a `HEALTHCHECK` with `curl` does +not work there. Either use the normal `aspnet:10.0-noble` or leave monitoring to +NPM or Uptime Kuma. --- -## 8. Hosting auf Unraid +## 8. Hosting on Unraid ```yaml # compose.yaml @@ -324,22 +323,22 @@ networks: external: true ``` -Kein `ports:`-Block. Der Container ist ausschließlich über das NPM-Docker-Netz -erreichbar. +No `ports:` block. The container is reachable exclusively via the NPM Docker +network. **NPM Proxy Host:** -| Feld | Wert | +| Field | Value | |---|---| | Domain Names | `elternbeirat-igmh.de`, `www.elternbeirat-igmh.de` | | Scheme | `http` | | Forward Hostname | `eb-web` | | Forward Port | `8080` | -| Block Common Exploits | an | -| Websockets Support | aus (wird bei statischem SSR nicht gebraucht) | +| Block Common Exploits | on | +| Websockets Support | off (not needed with static SSR) | | SSL | Let's Encrypt, Force SSL, HTTP/2, HSTS | -**In `Program.cs` nicht vergessen:** +**Don't forget in `Program.cs`:** ```csharp app.UseForwardedHeaders(new ForwardedHeadersOptions @@ -351,125 +350,124 @@ app.UseForwardedHeaders(new ForwardedHeadersOptions }); ``` -Ohne das sieht die App jede Anfrage als HTTP und mit der Proxy-IP statt der -Client-IP — relevant für korrekte absolute URLs und für die Logs. +Without this, the app sees every request as HTTP and with the proxy IP instead of +the client IP — relevant for correct absolute URLs and for the logs. --- -## 9. Inhalte — zeitkritisch +## 9. Content — time-critical -Die alte Seite ist offline, Maiks Strato-Vertrag läuft aber noch. Solange er läuft, -ist der Webspace erreichbar; nach der Kündigung ist der Bestand endgültig weg. +The old site is offline, but Maik's Strato contract is still running. As long as it +runs, the web space is reachable; after cancellation the content is gone for good. -**Reihenfolge der Rettungsversuche:** +**Order of rescue attempts:** -1. Prüfen, was beim Termin mit Maik tatsächlich gesichert wurde (Dateien? MySQL-Dump? - Beides?). Ein vollständiger Dump wäre der Idealfall — daraus lassen sich Texte, - Seitenstruktur, Medien und PDFs sauber extrahieren. -2. Falls nur Dateien vorliegen: `wp-content/uploads` enthält Bilder und PDFs, die - Texte liegen aber in der Datenbank. Dann Schritt 3. -3. Wayback Machine auf Snapshots von `elternbeirat-igmh.de` prüfen. -4. Falls nichts davon greift: Maik bitten, vor Vertragsende noch einen Export zu - ziehen — oder Inhalte aus den Protokollen und von der Schulseite neu aufbauen. +1. Check what was actually secured at the meeting with Maik (files? MySQL dump? + both?). A complete dump would be the ideal case — from it, texts, page + structure, media, and PDFs can be extracted cleanly. +2. If only files are available: `wp-content/uploads` contains images and PDFs, but + the texts live in the database. Then step 3. +3. Check the Wayback Machine for snapshots of `elternbeirat-igmh.de`. +4. If none of that works: ask Maik to pull an export before the contract ends — or + rebuild the content from the Protokolle and the school's website. -**Diese Frage blockiert den Inhaltsteil, nicht den Technikteil.** Das Gerüst lässt -sich mit Platzhaltern bauen und später befüllen. +**This question blocks the content part, not the technical part.** The scaffolding +can be built with placeholders and filled in later. --- -## 10. Rechtliches +## 10. Legal -Kein Rechtsrat — aber die Punkte, an denen Schulseiten regelmäßig auffallen: +Not legal advice — but the points where school sites regularly get flagged: -- **Impressum (§ 5 DDG):** Hat der Elternbeirat keine eigene Rechtsform, steht der - Betreiber persönlich mit Name und ladungsfähiger Anschrift im Impressum. Das ist - eine Entscheidung, keine Formalie — die Privatadresse wird damit öffentlich. - Alternative: Anschrift der Schule, aber nur mit deren ausdrücklichem Einverständnis - und wenn die Schule Mitbetreiberin ist. -- **Datenschutzerklärung:** Mit dem Umzug ist Tom Verantwortlicher im Sinne der DSGVO. - Server-Logs mit IP-Adressen benennen, Rechtsgrundlage und Löschfrist festlegen. -- **Keine externen Ressourcen.** Google Fonts, Maps, YouTube-Embeds und CDN-Skripte - übertragen die IP der Besucher an Dritte. Fonts werden selbst ausgeliefert. -- **Fotos von Kindern:** nur mit Einwilligung der Erziehungsberechtigten — bei - Schulseiten der mit Abstand häufigste Fehler. Im Zweifel keine Personenfotos. -- Hosting am privaten Anschluss bedeutet: Die öffentliche IP des Privatanschlusses - steht im DNS einer Schulseite. Bewusste Entscheidung, kein Nebeneffekt. +- **Imprint (§ 5 DDG):** If the Elternbeirat has no legal form of its own, the + operator is listed personally in the imprint with name and a valid postal + address for service. This is a decision, not a formality — the private address + becomes public. Alternative: the school's address, but only with its explicit + consent and if the school is a co-operator. +- **Privacy policy:** With the move, Tom becomes the controller in the sense of the + GDPR. Name server logs with IP addresses, define the legal basis and the deletion + period. +- **No external resources.** Google Fonts, Maps, YouTube embeds, and CDN scripts + transmit visitors' IPs to third parties. Fonts are served by ourselves. +- **Photos of children:** only with the consent of the legal guardians — by far the + most common mistake on school sites. When in doubt, no photos of people. +- Hosting on a private connection means: the public IP of the private connection + appears in the DNS of a school site. A deliberate decision, not a side effect. --- -## 11. Offene Punkte +## 11. Open Items -| # | Punkt | Status | +| # | Item | Status | |---|---|---| -| 1 | Was wurde vom alten WordPress gesichert? | **offen, zeitkritisch** | -| 2 | Kann „STRATO Mail Plus" DynDNS? Laut Strato-FAQ ab „PowerWeb Basic 2013 bzw. STRATO Domain" — ob Mail Plus dazuzählt, ist unklar. Beim Support mit anfragen, solange der Umzugsvorgang läuft. | offen | -| 3 | DynDNS-Updater: Fritzbox (kennt als Exposed-Host-Vorschaltgerät die öffentliche IP) oder ddclient-Container auf Unraid? | offen | -| 4 | Sollen andere Beiratsmitglieder Inhalte selbst pflegen können? Falls ja: eigener Ausbauschritt (Decap CMS auf Git-Basis oder kleines Admin-UI). | offen | -| 5 | Kontaktformular gewünscht? Würde Server-Interaktivität und Spam-Schutz erfordern — `mailto:` ist die aufwandsfreie Alternative. | offen | -| 6 | Wer springt ein, wenn Tom nicht verfügbar ist? Eine Seite, die nur einer deployen kann, ist eine Abhängigkeit, die der Beirat kennen sollte. | offen | -| 7 | Formulare bei Strato einreichen (unterschrieben, liegt bereit) | offen | +| 1 | What was secured from the old WordPress? | **open, time-critical** | +| 2 | Can "STRATO Mail Plus" do DynDNS? Per the Strato FAQ from "PowerWeb Basic 2013 or STRATO Domain" on — whether Mail Plus counts is unclear. Ask support along with the transfer while it is in progress. | open | +| 3 | DynDNS updater: Fritzbox (which, as an exposed-host upstream device, knows the public IP) or a ddclient container on Unraid? | open | +| 4 | Should other Elternbeirat members be able to maintain content themselves? If yes: a separate expansion step (Decap CMS on a Git basis or a small admin UI). | open | +| 5 | Contact form wanted? Would require server interactivity and spam protection — `mailto:` is the effort-free alternative. | open | +| 6 | Who steps in when Tom is unavailable? A site that only one person can deploy is a dependency the board should be aware of. | open | +| 7 | Submit forms to Strato (signed, ready to go) | open | --- -## 12. Umsetzungsreihenfolge +## 12. Implementation Order -| # | Schritt | Abhängig von | +| # | Step | Depends on | |---|---|---| -| 1 | Strato-Formulare einreichen, Domainumzug anstoßen | ✅ beauftragt (2026-09-20) | -| 2 | Inhaltslage klären (Abschnitt 9) | — | -| 3 | Blazor-Projekt in Rider anlegen ✅ (2026-09-20) → nach Gitea pushen ✅ | — | -| 6 | Dockerfile, compose.yaml → als Container auf Unraid ✅ (2026-09-20) | 3 | -| 4 | Content-Pipeline: Markdig, YamlDotNet, Services, ICS-Endpoint | 3 | -| 5 | Layout, Navigation, Seiten mit Platzhaltern | 4 | -| 7 | Echte Inhalte einpflegen | 2, 5 | -| 8 | Impressum und Datenschutzerklärung | 7 | -| 9 | DNS umstellen, NPM Proxy Host, Let's Encrypt | 1, 6 | -| 10 | Gitea Actions + Registry + Watchtower | 6, 9 | +| 1 | Submit Strato forms, start the domain transfer | ✅ commissioned (2026-09-20) | +| 2 | Clarify the content situation (section 9) | — | +| 3 | Create the Blazor project in Rider ✅ (2026-09-20) → push to Gitea ✅ | — | +| 6 | Dockerfile, compose.yaml → as a container on Unraid ✅ (2026-09-20) | 3 | +| 4 | Content pipeline: Markdig, YamlDotNet, services, ICS endpoint | 3 | +| 5 | Layout, navigation, pages with placeholders | 4 | +| 7 | Add the real content | 2, 5 | +| 8 | Imprint and privacy policy | 7 | +| 9 | Switch DNS, NPM Proxy Host, Let's Encrypt | 1, 6 | +| 10 | Gitea Actions + registry + Watchtower | 6, 9 | -Schritte 1 und 2 laufen unabhängig vom Code und sollten sofort starten — -Schritt 2 ist der einzige, bei dem Warten echten Schaden anrichtet. +Steps 1 and 2 run independently of the code and should start immediately — +step 2 is the only one where waiting does real damage. -**Abweichung von der ursprünglichen Reihenfolge:** Schritt 6 (Deployment) wurde -bewusst **vor** die Content-Pipeline (4/5) gezogen. Grund: die riskanteste Kette -— Image bauen → Gitea-Registry → auf Unraid ziehen → Container läuft — früh -beweisen, statt sie erst kurz vor dem Livegang zu entdecken. Details in -`docs/deployment.md`. +**Deviation from the original order:** Step 6 (deployment) was deliberately pulled +**ahead of** the content pipeline (4/5). Reason: prove the riskiest chain — +build image → Gitea registry → pull on Unraid → container runs — early, rather +than discovering it just before go-live. Details in `docs/deployment.md`. -### Stand 2026-09-20 (abends) +### As of 2026-09-20 (evening) -Erreicht: Das rohe „Hello world" der Blazor-App läuft als Container auf Unraid -(`Cube`), erreichbar im LAN unter `http://cube:5000`. Bewiesen ist damit die -komplette Deploy-Kette inkl. privater Gitea-Registry. +Achieved: The raw "Hello world" of the Blazor app runs as a container on Unraid +(`Cube`), reachable on the LAN at `http://cube:5000`. This proves the complete +deploy chain including the private Gitea registry. -Bewusste **Test-Abweichungen** vom Produktivziel (Abschnitt 8), später -zurückzubauen: +Deliberate **test deviations** from the production target (section 8), to be +rolled back later: -- `compose.yaml` hat ein Port-Mapping `5000:8080`. Produktiv: kein Mapping, nur - über das externe `npm`-Netz (NPM ist noch nicht testbar, Domain zieht erst um). -- Image-Tag nur `:latest`, noch kein SHA-Tag (→ Schritt 10, Rollback). -- Registry-Token liegt auf Unraid im Klartext (`/root/.docker/config.json`). - Credential-Helper ist als späterer Punkt in `docs/deployment.md` notiert. +- `compose.yaml` has a port mapping `5000:8080`. In production: no mapping, only + via the external `npm` network (NPM is not testable yet, the domain moves first). +- Image tag only `:latest`, no SHA tag yet (→ step 10, rollback). +- The registry token sits on Unraid in plain text (`/root/.docker/config.json`). + A credential helper is noted as a later item in `docs/deployment.md`. -**Nächster Schritt:** Content-Pipeline (Schritt 4) — Markdig + YamlDotNet, -Services für Seiten/Termine, ICS-Endpoint. Parallel offen und unabhängig vom -Code: Inhaltslage klären (Schritt 2, zeitkritisch). +**Next step:** Content pipeline (step 4) — Markdig + YamlDotNet, services for +pages/Termine, ICS endpoint. Open in parallel and independent of the code: +clarify the content situation (step 2, time-critical). -### Stand 2026-09-21 (vormittags) +### As of 2026-09-21 (morning) -Deployment weiter ausgebaut und einmal komplett durchgespielt: +Deployment further built out and run through completely once: -- **Branch/PR-Workflow** etabliert: nie direkt auf `main`; Feature-Branch → Pull - Request in Gitea → Merge. Erstmals durchgeführt (PR #1). -- **Zwei Build-Skripte** in `scripts/`: `dev-build.sh` (lokales Dev-Image, kein - Push — Dev-Stände bleiben aus der Registry raus) und `release.sh` (baut aus - `main`, taggt `:latest` **und** Commit-Kurz-SHA, pusht beide; bricht ab, wenn - nicht auf `main` oder Arbeitsverzeichnis unsauber). -- **SHA-Tagging** ist damit Standard → Rollback möglich. Erster Release-Tag: - `:8643f2c`. Redeploy auf Unraid (Compose Down/Up) bewusst geübt, läuft. -- `.gitattributes` erzwingt LF für `*.sh` (sonst scheitert der Shebang unter - Windows). +- **Branch/PR workflow** established: never directly on `main`; feature branch → + pull request in Gitea → merge. Carried out for the first time (PR #1). +- **Two build scripts** in `scripts/`: `dev-build.sh` (local dev image, no push — + dev states stay out of the registry) and `release.sh` (builds from `main`, tags + `:latest` **and** the short commit SHA, pushes both; aborts if not on `main` or + the working directory is dirty). +- **SHA tagging** is now standard → rollback possible. First release tag: + `:8643f2c`. Redeploy on Unraid (Compose Down/Up) deliberately practiced, works. +- `.gitattributes` enforces LF for `*.sh` (otherwise the shebang fails on Windows). -Offen fürs nächste Mal (unverändert): Content-Pipeline (Schritt 4) und die -zeitkritische Inhaltslage (Schritt 2). Deployment-Automatisierung per Gitea -Actions (Schritt 10) ist der nächste optionale Deployment-Ausbau, aber nicht -dringend — der Handbetrieb über `release.sh` reicht. +Open for next time (unchanged): content pipeline (step 4) and the time-critical +content situation (step 2). Deployment automation via Gitea Actions (step 10) is +the next optional deployment expansion, but not urgent — manual operation via +`release.sh` is enough. diff --git a/scripts/dev-build.sh b/scripts/dev-build.sh index 31c49a2..321ea2b 100644 --- a/scripts/dev-build.sh +++ b/scripts/dev-build.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash -# Baut ein lokales Dev-Image zum Ausprobieren auf dem eigenen Rechner. -# Pusht NICHTS in die Registry -- der Stand bleibt privat auf dem Laptop. +# Builds a local dev image for trying things out on your own machine. +# Pushes NOTHING to the registry -- the build stays private on the laptop. # -# Der Tag enthaelt den aktuellen Branchnamen, damit Dev-Images nicht mit dem -# Produktiv-:latest verwechselt werden. Anschliessend z.B. lokal starten: +# The tag carries the current branch name so dev images are not confused with +# the production :latest. Afterwards start it locally, e.g.: # docker run --rm -p 5000:8080 elternbeirat-web:dev- -# oder ueber die lokale compose.yaml. +# or via the local compose.yaml. set -euo pipefail @@ -14,9 +14,9 @@ cd "$(dirname "$0")/.." branch="$(git rev-parse --abbrev-ref HEAD | tr '/' '-')" tag="elternbeirat-web:dev-$branch" -echo "Baue lokales Dev-Image: $tag (kein Push)" +echo "Building local dev image: $tag (no push)" docker build -t "$tag" . echo -echo "Fertig. Lokal starten z.B. mit:" +echo "Done. Start it locally, e.g. with:" echo " docker run --rm -p 5000:8080 $tag" diff --git a/scripts/release.sh b/scripts/release.sh index 49e43b6..c9cdd6b 100644 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,55 +1,55 @@ #!/usr/bin/env bash -# Baut das Produktiv-Image aus dem aktuellen main-Stand und pusht es in die -# Gitea-Registry, getaggt mit :latest UND dem Commit-Kurz-SHA (Rollback). +# Builds the production image from the current main state and pushes it to the +# Gitea registry, tagged with :latest AND the short commit SHA (rollback). # -# Nur fuer freigegebene Staende: das Skript verweigert den Push, wenn du nicht -# auf main bist oder uncommittete Aenderungen hast. Fuer Dev-Builds ohne Push -# stattdessen scripts/dev-build.sh nutzen. +# For approved states only: the script refuses to push if you are not on main +# or have uncommitted changes. For dev builds without a push use +# scripts/dev-build.sh instead. # -# Voraussetzung: einmalig `docker login gitea.anticarnist.de` (siehe +# Prerequisite: a one-time `docker login gitea.anticarnist.de` (see # docs/deployment.md). set -euo pipefail IMAGE="gitea.anticarnist.de/tom/elternbeirat" -# Ins Repo-Root wechseln (Skript liegt in scripts/), damit der Docker-Build- -# Kontext stimmt, egal von wo aufgerufen. +# Change into the repo root (the script lives in scripts/) so the Docker build +# context is correct no matter where it is called from. cd "$(dirname "$0")/.." branch="$(git rev-parse --abbrev-ref HEAD)" if [[ "$branch" != "main" ]]; then - echo "ABBRUCH: du bist auf '$branch', nicht auf 'main'." >&2 - echo "Ein :latest-Release darf nur aus main gebaut werden." >&2 - echo "Fuer einen Dev-Build ohne Push: scripts/dev-build.sh" >&2 + echo "ABORT: you are on '$branch', not on 'main'." >&2 + echo "A :latest release may only be built from main." >&2 + echo "For a dev build without a push: scripts/dev-build.sh" >&2 exit 1 fi if [[ -n "$(git status --porcelain)" ]]; then - echo "ABBRUCH: Arbeitsverzeichnis nicht sauber (uncommittete Aenderungen)." >&2 - echo "Erst committen, damit der SHA-Tag den Image-Inhalt eindeutig benennt." >&2 + echo "ABORT: working tree is not clean (uncommitted changes)." >&2 + echo "Commit first so the SHA tag names the image content unambiguously." >&2 exit 1 fi -# Warnung, wenn lokaler main dem Remote voraus ist (ungepushte Commits) — dann -# wuerde ein SHA getaggt, den es auf Gitea noch nicht gibt. +# Warn if the local main is ahead of the remote (unpushed commits) -- otherwise +# a SHA would be tagged that does not yet exist on Gitea. if git rev-parse --verify --quiet origin/main >/dev/null; then ahead="$(git rev-list --count origin/main..HEAD)" if [[ "$ahead" -gt 0 ]]; then - echo "WARNUNG: lokaler main ist origin/main um $ahead Commit(s) voraus." >&2 - echo " Erst 'git push', damit der SHA auf Gitea existiert." >&2 - read -r -p "Trotzdem fortfahren? [y/N] " answer + echo "WARNING: local main is ahead of origin/main by $ahead commit(s)." >&2 + echo " Run 'git push' first so the SHA exists on Gitea." >&2 + read -r -p "Continue anyway? [y/N] " answer [[ "$answer" == "y" || "$answer" == "Y" ]] || exit 1 fi fi sha="$(git rev-parse --short HEAD)" -echo "Baue $IMAGE (Tags: latest, $sha)" +echo "Building $IMAGE (tags: latest, $sha)" docker build -t "$IMAGE:latest" -t "$IMAGE:$sha" . docker push "$IMAGE" --all-tags echo -echo "Fertig. Gepusht: $IMAGE:latest und $IMAGE:$sha" -echo "Auf Unraid: Stack 'elternbeirat' -> Compose Down/Up (bzw. Pull), damit" -echo "das neue :latest gezogen wird." +echo "Done. Pushed: $IMAGE:latest and $IMAGE:$sha" +echo "On Unraid: stack 'elternbeirat' -> Compose Down/Up (or Pull) so the new" +echo ":latest is fetched."