+}
+
+@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.
+
+}
+
+@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."