Build the content pipeline: pages, posts, events, layout #2

Merged
Tom merged 9 commits from feature/content-pipeline into main 2026-09-21 16:33:59 +02:00
44 changed files with 1909 additions and 393 deletions

No files matched your search

+5 -1
View File
@@ -3,4 +3,8 @@ obj/
/packages/
riderModule.iml
/_ReSharper.Caches/
.idea/
.idea/
# Altbestand der WordPress-Seite (Sichtung/Migration, kann DB-Dumps mit
# personenbezogenen Daten und grosse Binaerdateien enthalten) -- nie ins Repo.
/backup/
+11 -6
View File
@@ -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`).
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Elternbeirat.Web\Elternbeirat.Web.csproj" />
</ItemGroup>
</Project>
+67
View File
@@ -0,0 +1,67 @@
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
namespace Elternbeirat.Web.Tests;
/// <summary>
/// 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.
/// </summary>
public sealed class RouteSmokeTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public RouteSmokeTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
/// <summary>
/// Every route that a visitor can reach through the navigation, the FAQ hub
/// or the news section.
/// </summary>
public static TheoryData<string> 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);
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="de">
<head>
<meta charset="utf-8"/>
@@ -1,3 +1,34 @@
@inherits LayoutComponentBase
@inherits LayoutComponentBase
@Body
<div class="page">
<header class="site-header">
<div class="header-inner">
<a class="brand" href="/">Elternbeirat IGMH</a>
<nav class="main-nav">
<NavLink href="/" Match="NavLinkMatch.All">Start</NavLink>
<NavLink href="/vorstandsteam">Vorstandsteam</NavLink>
<NavLink href="/foerderverein">Förderverein</NavLink>
<NavLink href="/beitraege">Beiträge</NavLink>
<NavLink href="/termine">Termine</NavLink>
<NavLink href="/faq">FAQ</NavLink>
<NavLink href="/downloads">Downloads</NavLink>
<NavLink href="/kontakt">Kontakt</NavLink>
</nav>
</div>
</header>
<main class="content">
@Body
</main>
<footer class="site-footer">
<div class="footer-inner">
<span>Elternbeirat der IGMH</span>
<nav class="footer-nav">
<a href="/kontakt">Kontakt</a>
<a href="/impressum">Impressum</a>
<a href="/datenschutz">Datenschutz</a>
</nav>
</div>
</footer>
</div>
@@ -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;
}
@@ -0,0 +1,37 @@
@page "/{Slug}"
@using Elternbeirat.Web.Services
@inject PageService PageService
@if (_page is null)
{
<PageTitle>Nicht gefunden</PageTitle>
}
else
{
<PageTitle>@_page.Title</PageTitle>
<article>
@((MarkupString)_page.ContentHtml)
</article>
}
@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; }
}
@@ -0,0 +1,91 @@
@page "/termine"
@using System.Globalization
@using Elternbeirat.Web.Services
@inject EventService EventService
<PageTitle>Termine</PageTitle>
<h1>Termine</h1>
<p class="intro">
Alle Termine des Elternbeirats auf einen Blick. Sie können den Kalender auch
<a href="/termine.ics">abonnieren</a> und in Ihrer Kalender-App automatisch
aktuell halten.
</p>
<h2>Kommende Termine</h2>
@if (EventService.Upcoming.Count == 0)
{
<p>Zurzeit sind keine Termine geplant.</p>
}
else
{
<ul class="event-list">
@foreach (var ev in EventService.Upcoming)
{
<li>
<time datetime="@ev.Start.ToString("s")">@Format(ev)</time>
<span class="event-title">@ev.Title</span>
@if (!string.IsNullOrWhiteSpace(ev.Location))
{
<span class="event-location">@ev.Location</span>
}
@if (!string.IsNullOrWhiteSpace(ev.Note))
{
<p class="event-note">@ev.Note</p>
}
</li>
}
</ul>
}
@if (EventService.Past.Count > 0)
{
<h2>Vergangene Termine</h2>
<ul class="event-list event-list-past">
@foreach (var ev in EventService.Past)
{
<li>
<time datetime="@ev.Start.ToString("s")">@Format(ev)</time>
<span class="event-title">@ev.Title</span>
@if (!string.IsNullOrWhiteSpace(ev.Location))
{
<span class="event-location">@ev.Location</span>
}
</li>
}
</ul>
}
@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;
}
}
+46 -4
View File
@@ -1,7 +1,49 @@
@page "/"
@page "/"
@using System.Globalization
@using Elternbeirat.Web.Services
@inject PostService PostService
<PageTitle>Home</PageTitle>
<PageTitle>Elternbeirat der IGMH</PageTitle>
<h1>Hello, world!</h1>
<h1>Elternbeirat der IGMH</h1>
Welcome to your new app.
<p class="intro">
Willkommen beim Elternbeirat der IGMH. Hier finden Sie aktuelle Informationen,
Antworten auf häufige Fragen und Unterlagen rund um die Elternarbeit.
</p>
<section class="home-news">
<h2>Aktuelle Beiträge</h2>
@if (_latest.Count == 0)
{
<p>Zurzeit gibt es keine Beiträge.</p>
}
else
{
<ul class="post-list">
@foreach (var post in _latest)
{
<li>
<a href="@($"/beitraege/{post.Slug}")">@post.Title</a>
<time datetime="@post.Date.ToString("yyyy-MM-dd")">
@post.Date.ToString("d. MMMM yyyy", _german)
</time>
</li>
}
</ul>
<p><a href="/beitraege">Alle Beiträge &rarr;</a></p>
}
</section>
@code {
private static readonly CultureInfo _german = CultureInfo.GetCultureInfo("de-DE");
private IReadOnlyList<Post> _latest = [];
protected override void OnInitialized()
{
// Show the three most recent posts as a teaser on the home page.
_latest = PostService.All.Take(3).ToList();
}
}
@@ -0,0 +1,46 @@
@page "/beitraege/{Slug}"
@using System.Globalization
@using Elternbeirat.Web.Services
@inject PostService PostService
@if (_post is null)
{
<PageTitle>Nicht gefunden</PageTitle>
}
else
{
<PageTitle>@_post.Title</PageTitle>
<article>
<p class="post-meta">
<a href="/beitraege">&larr; Alle Beiträge</a>
<time datetime="@_post.Date.ToString("yyyy-MM-dd")">
@_post.Date.ToString("d. MMMM yyyy", _german)
</time>
</p>
@((MarkupString)_post.ContentHtml)
</article>
}
@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; }
}
@@ -0,0 +1,31 @@
@page "/beitraege"
@using System.Globalization
@using Elternbeirat.Web.Services
@inject PostService PostService
<PageTitle>Beiträge</PageTitle>
<h1>Beiträge</h1>
@if (PostService.All.Count == 0)
{
<p>Zurzeit gibt es keine Beiträge.</p>
}
else
{
<ul class="post-list">
@foreach (var post in PostService.All)
{
<li>
<a href="@($"/beitraege/{post.Slug}")">@post.Title</a>
<time datetime="@post.Date.ToString("yyyy-MM-dd")">
@post.Date.ToString("d. MMMM yyyy", _german)
</time>
</li>
}
</ul>
}
@code {
private static readonly CultureInfo _german = CultureInfo.GetCultureInfo("de-DE");
}
+26
View File
@@ -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
@@ -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.*
@@ -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)*
@@ -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).
@@ -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).
@@ -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.*
@@ -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.*
+15
View File
@@ -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.*
@@ -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.*
@@ -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.*
+16
View File
@@ -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.*
@@ -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.*
@@ -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.*
@@ -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).*
+17
View File
@@ -7,4 +7,21 @@
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup>
<!-- The test project uses WebApplicationFactory<Program>, which needs the
auto-generated Program class (internal with top-level statements). -->
<ItemGroup>
<InternalsVisibleTo Include="Elternbeirat.Web.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Markdig" Version="0.38.0" />
<PackageReference Include="YamlDotNet" Version="16.2.1" />
</ItemGroup>
<!-- Content ships as files inside the image, not in a volume. Copy it to
the output directory so the container can find it. -->
<ItemGroup>
<Content Include="Content\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+18 -8
View File
@@ -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<PageService>();
builder.Services.AddSingleton<PostService>();
builder.Services.AddSingleton<EventService>();
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<App>();
// 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();
+33
View File
@@ -0,0 +1,33 @@
namespace Elternbeirat.Web.Services;
/// <summary>
/// A single calendar entry (parents' evening, meeting, deadline). Read from
/// <c>Content/events.yml</c> at startup. All display text is German because it
/// is visitor-facing content.
/// </summary>
public sealed class Event
{
/// <summary>Short title shown in the list and the calendar.</summary>
public required string Title { get; init; }
/// <summary>
/// When the entry starts. A date-only entry (no time) is treated as an
/// all-day event; see <see cref="HasTime"/>.
/// </summary>
public required DateTime Start { get; init; }
/// <summary>
/// True when <see cref="Start"/> carries a wall-clock time, false for an
/// all-day entry. Drives both the list formatting and the ICS export.
/// </summary>
public required bool HasTime { get; init; }
/// <summary>Optional end, for multi-hour or multi-day entries.</summary>
public DateTime? End { get; init; }
/// <summary>Optional location, e.g. "Aula" or "Mensa".</summary>
public string? Location { get; init; }
/// <summary>Optional free-text note shown below the entry.</summary>
public string? Note { get; init; }
}
+137
View File
@@ -0,0 +1,137 @@
using System.Globalization;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace Elternbeirat.Web.Services;
/// <summary>
/// Reads the calendar entries from <c>Content/events.yml</c> once at startup and
/// keeps them in memory. Registered as a singleton because the content lives in
/// the image and does not change at runtime.
/// </summary>
public sealed class EventService
{
private readonly IReadOnlyList<Event> _events;
/// <summary>
/// Reads and parses <c>Content/events.yml</c> below the application root.
/// A missing file yields an empty list rather than an error.
/// </summary>
public EventService(IWebHostEnvironment environment)
{
var path = Path.Combine(environment.ContentRootPath, "Content", "events.yml");
_events = File.Exists(path)
? Parse(File.ReadAllText(path))
: [];
}
/// <summary>
/// Entries that start today or later, earliest first — the upcoming section.
/// "Today" is compared by date, so an entry earlier today still counts as
/// upcoming.
/// </summary>
public IReadOnlyList<Event> Upcoming =>
_events
.Where(e => DateOnly.FromDateTime(e.Start) >= DateOnly.FromDateTime(DateTime.Today))
.OrderBy(e => e.Start)
.ToList();
/// <summary>
/// Entries that already passed, most recent first — the past section shown
/// below the upcoming ones.
/// </summary>
public IReadOnlyList<Event> Past =>
_events
.Where(e => DateOnly.FromDateTime(e.Start) < DateOnly.FromDateTime(DateTime.Today))
.OrderByDescending(e => e.Start)
.ToList();
/// <summary>All entries in chronological order, for the ICS export.</summary>
public IReadOnlyList<Event> All =>
_events.OrderBy(e => e.Start).ToList();
private static List<Event> Parse(string yaml)
{
// Keys are lowercase single words (title, start, ...), which the camel
// case convention maps onto the PascalCase properties.
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
var raw = deserializer.Deserialize<List<EventEntry>?>(yaml) ?? [];
return raw
.Where(entry => entry.Title is not null && entry.Start is not null)
.Select(ToEvent)
.OfType<Event>()
.ToList();
}
private static Event? ToEvent(EventEntry entry)
{
if (!TryParseWhen(entry.Start!, out var start, out var hasTime))
{
// A malformed date drops the entry rather than crashing startup.
return null;
}
DateTime? end = null;
if (entry.End is not null && TryParseWhen(entry.End, out var parsedEnd, out _))
{
end = parsedEnd;
}
return new Event
{
Title = entry.Title!,
Start = start,
HasTime = hasTime,
End = end,
Location = entry.Location,
Note = entry.Note,
};
}
/// <summary>
/// Parses a value that is either a date (<c>2026-03-15</c>) or a date with a
/// time (<c>2026-03-15 19:30</c>). Sets <paramref name="hasTime"/> so the
/// caller knows whether to treat the entry as all-day.
/// </summary>
private static bool TryParseWhen(string value, out DateTime when, out bool hasTime)
{
var trimmed = value.Trim();
if (DateOnly.TryParseExact(trimmed, "yyyy-MM-dd",
CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateOnly))
{
when = dateOnly.ToDateTime(TimeOnly.MinValue);
hasTime = false;
return true;
}
string[] formats = ["yyyy-MM-dd HH:mm", "yyyy-MM-dd'T'HH:mm"];
if (DateTime.TryParseExact(trimmed, formats,
CultureInfo.InvariantCulture, DateTimeStyles.None, out when))
{
hasTime = true;
return true;
}
when = default;
hasTime = false;
return false;
}
/// <summary>
/// Raw shape of one YAML entry, before validation. String-typed on purpose so
/// we control date parsing and can distinguish date-only from date-and-time.
/// </summary>
private sealed class EventEntry
{
public string? Title { get; set; }
public string? Start { get; set; }
public string? End { get; set; }
public string? Location { get; set; }
public string? Note { get; set; }
}
}
+42
View File
@@ -0,0 +1,42 @@
using Markdig;
using Markdig.Extensions.Yaml;
using Markdig.Syntax;
namespace Elternbeirat.Web.Services;
/// <summary>
/// Reads simple <c>key: value</c> pairs from a Markdown file's YAML front
/// matter. Kept deliberately minimal (one value per line, no nesting) — this is
/// enough for titles and dates. YamlDotNet will take over once we need
/// structured metadata. Front-matter keys stay German because the Markdown
/// files are edited by German-speaking editors.
/// </summary>
public static class FrontMatter
{
/// <summary>
/// Returns the value of the given front-matter key, or <c>null</c> if the
/// file has no front matter or the key is missing. The key match is
/// case-insensitive; surrounding quotes on the value are removed.
/// </summary>
public static string? Read(string key, MarkdownDocument document, string source)
{
var block = document.Descendants<YamlFrontMatterBlock>().FirstOrDefault();
if (block is null)
{
return null;
}
var prefix = key + ":";
var frontMatter = source.Substring(block.Span.Start, block.Span.Length);
foreach (var line in frontMatter.Split('\n'))
{
var trimmed = line.Trim();
if (trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
return trimmed[prefix.Length..].Trim().Trim('"');
}
}
return null;
}
}
+105
View File
@@ -0,0 +1,105 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace Elternbeirat.Web.Services;
/// <summary>
/// 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.
/// </summary>
public static class IcsCalendar
{
private const string ProductId = "-//Elternbeirat IGMH//Termine//DE";
/// <summary>
/// Serialises the given entries into a single VCALENDAR document.
/// </summary>
public static string Build(IReadOnlyList<Event> 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");
}
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>Escapes the characters that are special in an ICS text value.</summary>
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");
}
+25
View File
@@ -0,0 +1,25 @@
namespace Elternbeirat.Web.Services;
/// <summary>
/// A static content page, read from a Markdown file in <c>Content/pages/</c>.
/// The file name (without extension) is the slug.
/// </summary>
public sealed class Page
{
/// <summary>
/// URL identifier of the page, taken from the file name without umlauts
/// (e.g. <c>vorstandsteam</c>). It appears in the route <c>/{slug}</c>.
/// </summary>
public required string Slug { get; init; }
/// <summary>
/// Display title from the YAML front matter (<c>titel:</c>). Falls back to
/// the slug when no title is set.
/// </summary>
public required string Title { get; init; }
/// <summary>
/// The page body rendered from Markdown to HTML (front matter excluded).
/// </summary>
public required string ContentHtml { get; init; }
}
+57
View File
@@ -0,0 +1,57 @@
using Markdig;
namespace Elternbeirat.Web.Services;
/// <summary>
/// Reads the static content pages from <c>Content/pages/*.md</c>, renders them
/// to HTML once at startup and keeps them in memory. Registered as a singleton
/// because the content lives in the image and does not change at runtime.
/// </summary>
public sealed class PageService
{
private readonly IReadOnlyDictionary<string, Page> _pagesBySlug;
/// <summary>
/// Reads every Markdown page from the <c>Content/pages</c> directory below
/// the application root and caches it.
/// </summary>
public PageService(IWebHostEnvironment environment)
{
var pipeline = new MarkdownPipelineBuilder()
.UseYamlFrontMatter()
.Build();
var directory = Path.Combine(environment.ContentRootPath, "Content", "pages");
var pages = new Dictionary<string, Page>(StringComparer.OrdinalIgnoreCase);
if (Directory.Exists(directory))
{
foreach (var path in Directory.EnumerateFiles(directory, "*.md"))
{
var page = Read(path, pipeline);
pages[page.Slug] = page;
}
}
_pagesBySlug = pages;
}
/// <summary>
/// Returns the page for the given slug, or <c>null</c> if there is none.
/// The lookup is case-insensitive.
/// </summary>
public Page? Find(string slug) =>
_pagesBySlug.GetValueOrDefault(slug);
private static Page Read(string path, MarkdownPipeline pipeline)
{
var slug = Path.GetFileNameWithoutExtension(path);
var source = File.ReadAllText(path);
var document = Markdown.Parse(source, pipeline);
var title = FrontMatter.Read("title", document, source) ?? slug;
var html = Markdown.ToHtml(source, pipeline);
return new Page { Slug = slug, Title = title, ContentHtml = html };
}
}
+33
View File
@@ -0,0 +1,33 @@
namespace Elternbeirat.Web.Services;
/// <summary>
/// A news post, read from a Markdown file in <c>Content/posts/</c>. Unlike a
/// <see cref="Page"/>, a post has a date and
/// is shown in a chronological list. The file name (without extension) is the
/// slug.
/// </summary>
public sealed class Post
{
/// <summary>
/// URL identifier of the post, taken from the file name without umlauts.
/// It appears in the route <c>/beitraege/{slug}</c>.
/// </summary>
public required string Slug { get; init; }
/// <summary>
/// Display title from the YAML front matter (<c>titel:</c>). Falls back to
/// the slug when no title is set.
/// </summary>
public required string Title { get; init; }
/// <summary>
/// Publication date from the front matter (<c>datum:</c>). Used to sort the
/// list newest first.
/// </summary>
public required DateOnly Date { get; init; }
/// <summary>
/// The post body rendered from Markdown to HTML (front matter excluded).
/// </summary>
public required string ContentHtml { get; init; }
}
+86
View File
@@ -0,0 +1,86 @@
using System.Globalization;
using Markdig;
namespace Elternbeirat.Web.Services;
/// <summary>
/// Reads the news posts from <c>Content/posts/*.md</c>, renders them to HTML
/// once at startup and keeps them in memory, sorted newest first. Registered as
/// a singleton because the content lives in the image and does not change at
/// runtime.
/// </summary>
public sealed class PostService
{
private readonly IReadOnlyList<Post> _posts;
private readonly IReadOnlyDictionary<string, Post> _postsBySlug;
/// <summary>
/// Reads every Markdown post from the <c>Content/posts</c> directory
/// below the application root and caches it.
/// </summary>
public PostService(IWebHostEnvironment environment)
{
var pipeline = new MarkdownPipelineBuilder()
.UseYamlFrontMatter()
.Build();
var directory = Path.Combine(environment.ContentRootPath, "Content", "posts");
var posts = new List<Post>();
if (Directory.Exists(directory))
{
foreach (var path in Directory.EnumerateFiles(directory, "*.md"))
{
posts.Add(Read(path, pipeline));
}
}
// Newest first for the list; a stable slug order breaks date ties.
posts.Sort((a, b) =>
{
var byDate = b.Date.CompareTo(a.Date);
return byDate != 0 ? byDate : string.CompareOrdinal(a.Slug, b.Slug);
});
_posts = posts;
_postsBySlug = posts.ToDictionary(p => p.Slug, StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// All posts, newest first. Used for the overview list.
/// </summary>
public IReadOnlyList<Post> All => _posts;
/// <summary>
/// Returns the post for the given slug, or <c>null</c> if there is none.
/// The lookup is case-insensitive.
/// </summary>
public Post? Find(string slug) =>
_postsBySlug.GetValueOrDefault(slug);
private static Post Read(string path, MarkdownPipeline pipeline)
{
var slug = Path.GetFileNameWithoutExtension(path);
var source = File.ReadAllText(path);
var document = Markdown.Parse(source, pipeline);
var title = FrontMatter.Read("title", document, source) ?? slug;
var date = ParseDate(FrontMatter.Read("date", document, source));
var html = Markdown.ToHtml(source, pipeline);
return new Post { Slug = slug, Title = title, Date = date, ContentHtml = html };
}
/// <summary>
/// Parses the <c>datum:</c> value as an ISO date (yyyy-MM-dd). Falls back to
/// <see cref="DateOnly.MinValue"/> when the value is missing or malformed,
/// so a single bad file does not crash startup — it just sorts last.
/// </summary>
private static DateOnly ParseDate(string? value)
{
return DateOnly.TryParse(value, CultureInfo.InvariantCulture,
DateTimeStyles.None, out var date)
? date
: DateOnly.MinValue;
}
}
+105
View File
@@ -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;
}
+1
View File
@@ -1,3 +1,4 @@
<Solution>
<Project Path="Elternbeirat.Web/Elternbeirat.Web.csproj" />
<Project Path="Elternbeirat.Web.Tests/Elternbeirat.Web.Tests.csproj" />
</Solution>
+11 -11
View File
@@ -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://<unraid>: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://<unraid>: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
+87 -90
View File
@@ -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://<unraid>: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://<unraid>: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 <bereich>/<kurz>` (z.B.
1. Create a feature branch: `git switch -c <area>/<short>` (e.g.
`deployment/sha-tagging`, `content/pipeline`).
2. Dort committen, Branch pushen: `git push -u origin <branch>`.
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 <branch>`.
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-<branch>, pusht NICHTS
scripts/dev-build.sh # builds elternbeirat-web:dev-<branch>, 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: <package-Token>
# Password: <package token>
```
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://<unraid>:5000`.
Reachable at `http://<unraid>: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 `:<sha>` 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 `:<sha>` 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.
+129
View File
@@ -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/<slug>`.
```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).
+239 -241
View File
@@ -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.<domain>/tom/elternbeirat:latest` **und** `:${{ gitea.sha }}`
2. Login to Gitea's own container registry
3. `docker/build-push-action` → `gitea.<domain>/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.
+7 -7
View File
@@ -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-<branch>
# 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"
+22 -22
View File
@@ -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."