Build the content pipeline: pages, posts, events, layout #2
No files matched your search
@@ -4,3 +4,7 @@ obj/
|
|||||||
riderModule.iml
|
riderModule.iml
|
||||||
/_ReSharper.Caches/
|
/_ReSharper.Caches/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# Altbestand der WordPress-Seite (Sichtung/Migration, kann DB-Dumps mit
|
||||||
|
# personenbezogenen Daten und grosse Binaerdateien enthalten) -- nie ins Repo.
|
||||||
|
/backup/
|
||||||
@@ -38,8 +38,9 @@ vollständige Stand.
|
|||||||
Port-Mapping hat und nur über NPM erreichbar ist.
|
Port-Mapping hat und nur über NPM erreichbar ist.
|
||||||
- **Das chiseled-Runtime-Image hat keine Shell.** `HEALTHCHECK` mit `curl` oder
|
- **Das chiseled-Runtime-Image hat keine Shell.** `HEALTHCHECK` mit `curl` oder
|
||||||
`sh` schlägt dort fehl.
|
`sh` schlägt dort fehl.
|
||||||
- Neue Seite = Markdown in `Content/seiten/`. Neuer Termin = Eintrag in
|
- Neue Seite = Markdown in `Content/pages/`. Neuer Beitrag = Markdown in
|
||||||
`Content/termine.yml`. In beiden Fällen wird **kein** `.razor` angefasst.
|
`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`).
|
- Slugs ohne Umlaute (`ueber-uns`, nicht `über-uns`).
|
||||||
|
|
||||||
## Regeln
|
## Regeln
|
||||||
@@ -60,7 +61,11 @@ vollständige Stand.
|
|||||||
- Nullable aktiviert, `ImplicitUsings` an, File-scoped Namespaces.
|
- Nullable aktiviert, `ImplicitUsings` an, File-scoped Namespaces.
|
||||||
- Services über DI, als Singleton registriert (Inhalte werden beim Start
|
- Services über DI, als Singleton registriert (Inhalte werden beim Start
|
||||||
eingelesen und gecacht).
|
eingelesen und gecacht).
|
||||||
- Öffentliche Typen und Methoden der `Services` bekommen XML-Doc, Razor-Markup
|
- Öffentliche Typen und Methoden der `Services` bekommen XML-Doc (auf Englisch,
|
||||||
nicht.
|
leicht verständlich), Razor-Markup nicht.
|
||||||
- Fachbegriffe im Code auf Deutsch, wenn sie Domänenbegriffe sind
|
- **Code durchgängig auf Englisch** — Typen, Member, Variablen, Kommentare,
|
||||||
(`Termin`, `Protokoll`, `Beitrag`) — Framework-Begriffe bleiben englisch.
|
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>
|
||||||
@@ -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,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="de">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8"/>
|
<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;
|
right: 0.75rem;
|
||||||
top: 0.5rem;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 →</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">← 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");
|
||||||
|
}
|
||||||
@@ -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.*
|
||||||
@@ -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.*
|
||||||
@@ -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).*
|
||||||
@@ -7,4 +7,21 @@
|
|||||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||||
</PropertyGroup>
|
</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>
|
</Project>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Elternbeirat.Web.Components;
|
using Elternbeirat.Web.Components;
|
||||||
|
using Elternbeirat.Web.Services;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -6,13 +7,18 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
// Add services to the container.
|
// Add services to the container.
|
||||||
builder.Services.AddRazorComponents();
|
builder.Services.AddRazorComponents();
|
||||||
|
|
||||||
|
// Content is read once at startup and cached -> singletons.
|
||||||
|
builder.Services.AddSingleton<PageService>();
|
||||||
|
builder.Services.AddSingleton<PostService>();
|
||||||
|
builder.Services.AddSingleton<EventService>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// NPM terminiert TLS und ist der einzige Weg zum Container (kein Port-Mapping
|
// NPM terminates TLS and is the only way to reach the container (no port
|
||||||
// im Produktivbetrieb, siehe plan.md AE-4). Ohne UseForwardedHeaders sieht die
|
// mapping in production). Without UseForwardedHeaders the app sees every request
|
||||||
// App jede Anfrage als HTTP und mit der Proxy-IP statt der Client-IP.
|
// as HTTP and with the proxy IP instead of the client IP.
|
||||||
// KnownNetworks/KnownProxies bewusst geleert, weil ausschliesslich NPM den
|
// KnownNetworks/KnownProxies are deliberately empty because only NPM reaches
|
||||||
// Container erreicht.
|
// the container.
|
||||||
app.UseForwardedHeaders(new ForwardedHeadersOptions
|
app.UseForwardedHeaders(new ForwardedHeadersOptions
|
||||||
{
|
{
|
||||||
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
|
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
|
||||||
@@ -24,9 +30,8 @@ app.UseForwardedHeaders(new ForwardedHeadersOptions
|
|||||||
if (!app.Environment.IsDevelopment())
|
if (!app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||||
// Kein UseHsts() und kein UseHttpsRedirection(): NPM setzt HSTS und
|
// No UseHsts() and no UseHttpsRedirection(): NPM sets HSTS and terminates
|
||||||
// terminiert TLS. Beides hier wuerde hinter dem Proxy eine
|
// TLS. Both here would create a redirect loop behind the proxy.
|
||||||
// Redirect-Schleife erzeugen (plan.md AE-4).
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
|
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
|
||||||
@@ -36,4 +41,9 @@ app.UseAntiforgery();
|
|||||||
app.MapStaticAssets();
|
app.MapStaticAssets();
|
||||||
app.MapRazorComponents<App>();
|
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();
|
app.Run();
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
h1:focus {
|
||||||
outline: none;
|
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]) {
|
.valid.modified:not([type=checkbox]) {
|
||||||
outline: 1px solid #26b050;
|
outline: 1px solid #26b050;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<Project Path="Elternbeirat.Web/Elternbeirat.Web.csproj" />
|
<Project Path="Elternbeirat.Web/Elternbeirat.Web.csproj" />
|
||||||
|
<Project Path="Elternbeirat.Web.Tests/Elternbeirat.Web.Tests.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
+11
-11
@@ -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
|
# Pulls the finished image from the Gitea registry (the build happens on the
|
||||||
# Entwicklungsrechner, siehe docs/deployment.md), statt auf Unraid aus dem
|
# development machine, see docs/deployment.md) instead of building from source
|
||||||
# Quellcode zu bauen.
|
# on Unraid.
|
||||||
#
|
#
|
||||||
# Weicht bewusst vom Produktiv-Setup in plan.md Abschnitt 8 ab:
|
# Deliberately differs from the production setup:
|
||||||
# - Es gibt ein Port-Mapping (5000 aussen -> 8080 innen), damit die App im
|
# - There is a port mapping (5000 outside -> 8080 inside) so the app is
|
||||||
# lokalen Netz unter http://<unraid>:5000 erreichbar ist. Im Produktiv-
|
# reachable in the local network at http://<unraid>:5000. In production the
|
||||||
# betrieb entfaellt das Mapping; dort ist nur NPM der Weg zum Container
|
# mapping is dropped; there NPM is the only path to the container (use the
|
||||||
# (dann stattdessen das externe npm-Netz, siehe docs/deployment.md).
|
# 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:
|
services:
|
||||||
eb-web:
|
eb-web:
|
||||||
@@ -21,4 +21,4 @@ services:
|
|||||||
ASPNETCORE_URLS: http://+:8080
|
ASPNETCORE_URLS: http://+:8080
|
||||||
TZ: Europe/Berlin
|
TZ: Europe/Berlin
|
||||||
ports:
|
ports:
|
||||||
- "5000:8080" # TEST-Zugang, im Produktivbetrieb entfernen
|
- "5000:8080" # TEST access, remove in production
|
||||||
+87
-90
@@ -1,122 +1,120 @@
|
|||||||
# Deployment
|
# Deployment
|
||||||
|
|
||||||
Wie ein neuer Stand der Website auf Unraid landet. Aktueller Stand:
|
How a new version of the website ends up on Unraid. Current state:
|
||||||
**Handbetrieb** — Image lokal bauen, in die Gitea-Registry pushen, auf Unraid
|
**manual** — build the image locally, push it to the Gitea registry, pull it on
|
||||||
ziehen. Die Automatisierung per Gitea Actions (plan.md Schritt 10) kommt später.
|
Unraid. Automation via Gitea Actions comes later.
|
||||||
|
|
||||||
> **Test- vs. Produktivbetrieb.** Solange die Domain noch nicht umgezogen ist und
|
> **Test vs. production.** As long as the domain has not moved yet and NPM is not
|
||||||
> NPM nicht davorsteht, läuft der Container mit einem Port-Mapping und ist im
|
> in front of it, the container runs with a port mapping and is directly reachable
|
||||||
> lokalen Netz direkt erreichbar (`http://<unraid>:5000`). Im Produktivbetrieb
|
> on the local network (`http://<unraid>:5000`). In production the mapping is gone
|
||||||
> entfällt das Mapping — dann ist nur NPM der Weg zum Container (plan.md AE-4,
|
> — then NPM is the only path to the container. The two compose variants are
|
||||||
> Abschnitt 8). Die beiden compose-Varianten sind unten getrennt beschrieben.
|
> 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**
|
The push to the registry needs a Gitea access token with **package write
|
||||||
(`package: Read and Write`) — **nicht** das Kontopasswort.
|
permission** (`package: Read and Write`) — **not** the account password.
|
||||||
|
|
||||||
1. Gitea → oben rechts Profilbild → **Settings** → **Applications**.
|
1. Gitea → top right profile picture → **Settings** → **Applications**.
|
||||||
2. Abschnitt **Manage Access Tokens**: Name vergeben (z.B. `registry-push`).
|
2. **Manage Access Tokens** section: assign a name (e.g. `registry-push`).
|
||||||
3. Unter **Select scopes**: `package` auf **Read and Write** stellen.
|
3. Under **Select scopes**: set `package` to **Read and Write**.
|
||||||
4. **Generate Token** klicken, Zeichenkette **sofort kopieren** (nur einmal
|
4. Click **Generate Token**, **copy the string immediately** (shown only once).
|
||||||
sichtbar).
|
|
||||||
|
|
||||||
> **Token ist ein Geheimnis.** Niemals in Git, Chats, Screenshots oder Tickets
|
> **The token is a secret.** Never store it in plain text in Git, chats,
|
||||||
> im Klartext ablegen. Wird eins doch einmal sichtbar: in Gitea **löschen** und
|
> screenshots, or tickets. If one does become visible: **delete** it in Gitea and
|
||||||
> neu erzeugen. Ein `package`-Token erlaubt das Hochladen beliebiger Images in
|
> generate a new one. A `package` token allows uploading arbitrary images to the
|
||||||
> die Registry.
|
> registry.
|
||||||
|
|
||||||
Der Docker-Login speichert das Token danach lokal, sodass es nur einmal
|
The Docker login then stores the token locally, so it only has to be entered once.
|
||||||
eingegeben werden muss.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
`main` is the **published** state — only `:latest` is built from it, and only
|
||||||
nur `:latest` zieht Unraid. Deshalb wird nie direkt auf `main` committet:
|
`: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`).
|
`deployment/sha-tagging`, `content/pipeline`).
|
||||||
2. Dort committen, Branch pushen: `git push -u origin <branch>`.
|
2. Commit there, push the branch: `git push -u origin <branch>`.
|
||||||
3. In Gitea einen **Pull Request** gegen `main` öffnen und dort mergen.
|
3. Open a **Pull Request** against `main` in Gitea and merge it there.
|
||||||
4. Erst danach aus `main` das Release-Image bauen (unten).
|
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
|
```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
|
This way a development state can never accidentally land in the registry as
|
||||||
landen.
|
`:latest`.
|
||||||
|
|
||||||
## Neuen Stand ausrollen (Handbetrieb)
|
## Rolling out a new version (manual)
|
||||||
|
|
||||||
Voraussetzung: einmalig an der Registry angemeldet (siehe unten). Dann, **auf
|
Prerequisite: logged in to the registry once (see below). Then, **on main** and
|
||||||
main** und mit sauberem Arbeitsverzeichnis:
|
with a clean working directory:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scripts/release.sh
|
scripts/release.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Das Skript baut das Image, taggt es mit `:latest` **und** dem Commit-Kurz-SHA
|
The script builds the image, tags it with `:latest` **and** the short commit SHA
|
||||||
(für Rollback) und pusht beide. Es **bricht ab**, wenn du nicht auf `main` bist
|
(for rollback), and pushes both. It **aborts** if you are not on `main` or have
|
||||||
oder uncommittete Änderungen hast, und warnt bei ungepushten Commits.
|
uncommitted changes, and warns about unpushed commits.
|
||||||
|
|
||||||
> Der Image-Pfad `gitea.anticarnist.de/tom/elternbeirat` ist **kleingeschrieben**
|
> The image path `gitea.anticarnist.de/tom/elternbeirat` is **lowercase** —
|
||||||
> — Container-Registries verlangen das im Pfad, obwohl Benutzer (`Tom`) und Repo
|
> container registries require that in the path, even though the user (`Tom`) and
|
||||||
> (`Elternbeirat`) großgeschrieben sind.
|
> 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
|
The image is **private**, so Unraid has to log in once before it can pull. The
|
||||||
ziehen kann. Das **Compose Manager Plus**-Plugin hat dafür kein UI-Feld — der
|
**Compose Manager Plus** plugin has no UI field for this — the login runs through
|
||||||
Login läuft über das Unraid-Terminal (`>_`-Symbol oben rechts in der
|
the Unraid terminal (`>_` symbol at the top right of the web interface, prompt
|
||||||
Weboberfläche, Prompt `root@Cube:~#`):
|
`root@Cube:~#`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker login gitea.anticarnist.de
|
docker login gitea.anticarnist.de
|
||||||
# Username: Tom
|
# Username: Tom
|
||||||
# Password: <package-Token>
|
# Password: <package token>
|
||||||
```
|
```
|
||||||
|
|
||||||
Der Login bleibt gespeichert; er muss nur wiederholt werden, wenn das Token
|
The login stays stored; it only has to be repeated when the token changes. Two
|
||||||
wechselt. Zwei erfahrungsgemäße Stolpersteine:
|
pitfalls learned from experience:
|
||||||
|
|
||||||
- **Nicht mit PowerShell/Laptop verwechseln.** Der Login muss im *Unraid*-Terminal
|
- **Don't confuse it with PowerShell/laptop.** The login has to happen in the
|
||||||
passieren (`root@Cube`), nicht in der Windows-PowerShell (`PS C:\`). Der Laptop
|
*Unraid* terminal (`root@Cube`), not in Windows PowerShell (`PS C:\`). The laptop
|
||||||
braucht den Login nur zum *Pushen*, Unraid zum *Ziehen*.
|
needs the login only to *push*, Unraid to *pull*.
|
||||||
- **Falscher Username bleibt hängen.** Meldet der Login „Stored credentials
|
- **A wrong username gets stuck.** If the login reports "Stored credentials
|
||||||
invalid or expired" und fragt *nicht* nach dem Namen, erst `docker logout
|
invalid or expired" and does *not* ask for the name, first `docker logout
|
||||||
gitea.anticarnist.de`, dann neu einloggen — sonst wird versehentlich ein
|
gitea.anticarnist.de`, then log in again — otherwise a nonsense username gets
|
||||||
Nonsens-Username gespeichert.
|
stored by accident.
|
||||||
- **Klartext-Warnung.** Docker speichert das Token unverschlüsselt in
|
- **Plain-text warning.** Docker stores the token unencrypted in
|
||||||
`/root/.docker/config.json`. Auf dem eigenen Server für den Anfang okay.
|
`/root/.docker/config.json`. On your own server, okay for a start.
|
||||||
*Später sauberer:* einen Credential-Helper einrichten (→ offener Punkt unten).
|
*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** →
|
- **Docker** tab → **Compose** section → stack **elternbeirat** →
|
||||||
**Compose Down**, dann **Compose Up** (oder „Pull" + „Up", je nach
|
**Compose Down**, then **Compose Up** (or "Pull" + "Up", depending on the plugin
|
||||||
Plugin-Version), damit die neue `:latest` gezogen wird.
|
version), so that the new `:latest` is pulled.
|
||||||
|
|
||||||
> `docker compose up` zieht ein `:latest` **nicht** automatisch neu, wenn schon
|
> `docker compose up` does **not** automatically re-pull a `:latest` if an image of
|
||||||
> ein gleichnamiges Image lokal liegt. Im Zweifel vorher explizit „Pull".
|
> 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**:
|
**5000 → 8080**:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -129,15 +127,14 @@ services:
|
|||||||
ASPNETCORE_URLS: http://+:8080
|
ASPNETCORE_URLS: http://+:8080
|
||||||
TZ: Europe/Berlin
|
TZ: Europe/Berlin
|
||||||
ports:
|
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
|
No `ports:` block, instead the external NPM Docker network:
|
||||||
Abschnitt 8):
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
@@ -159,27 +156,27 @@ networks:
|
|||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
`scripts/release.sh` taggt jeden Release zusätzlich mit dem Commit-Kurz-SHA, der
|
`scripts/release.sh` additionally tags each release with the short commit SHA,
|
||||||
sich — anders als das wandernde `:latest` — nie verschiebt. Zum Zurückrollen in
|
which — unlike the moving `:latest` — never shifts. To roll back, replace
|
||||||
der Unraid-`compose.yaml` `:latest` durch `:<sha>` des letzten funktionierenden
|
`:latest` in the Unraid `compose.yaml` with the `:<sha>` of the last working state
|
||||||
Stands ersetzen und neu hochfahren:
|
and bring it back up:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
image: gitea.anticarnist.de/tom/elternbeirat:99dd052 # statt :latest
|
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`.
|
`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.
|
- `.gitea/workflows/deploy.yml`: build and push on push to `main`.
|
||||||
- Zwei bekannte Stolpersteine: der `act_runner` braucht Docker-Socket-Zugriff;
|
- Two known pitfalls: the `act_runner` needs Docker socket access; the registry
|
||||||
die Registry braucht das `write:package`-Token, nicht das Login-Passwort.
|
needs the `write:package` token, not the login password.
|
||||||
- Redeploy per Watchtower **label-scoped**, sonst aktualisiert er den ganzen
|
- Redeploy via Watchtower **label-scoped**, otherwise it updates the entire
|
||||||
Home-Lab-Bestand.
|
home-lab inventory.
|
||||||
- **Registry-Token auf Unraid verschlüsseln:** aktuell liegt es im Klartext in
|
- **Encrypt the registry token on Unraid:** currently it sits in plain text in
|
||||||
`/root/.docker/config.json`. Später einen Credential-Helper einrichten, damit
|
`/root/.docker/config.json`. Set up a credential helper later, so that the
|
||||||
die Klartext-Warnung von `docker login` verschwindet.
|
plain-text warning from `docker login` disappears.
|
||||||
@@ -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).
|
||||||
@@ -1,160 +1,159 @@
|
|||||||
# elternbeirat-igmh.de — Neuaufbau
|
# elternbeirat-igmh.de — Rebuild
|
||||||
|
|
||||||
Ablösung der bisherigen WordPress-Seite durch eine eigene .NET-Anwendung auf
|
Replacing the previous WordPress site with a custom .NET application on
|
||||||
eigener Infrastruktur.
|
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
|
- The domain `elternbeirat-igmh.de` was held by Maik Palm (departing
|
||||||
Elternbeirat-Mitglied) im Paket „STRATO Hosting Basic", Auftragsnummer 9157927.
|
Elternbeirat member) under the "STRATO Hosting Basic" package, order number 9157927.
|
||||||
- Domaininhaber-Wechsel und Domainumzug sind beidseitig unterschrieben
|
- The change of domain owner and the domain transfer are signed by both parties
|
||||||
(19.09.2026), die Einreichung bei Strato steht noch aus.
|
(19.09.2026); the submission to Strato is still pending.
|
||||||
- Ziel-Paket: Toms „STRATO Mail Plus" (Auftragsnummer 8844576) — Domain + E-Mail,
|
- Target package: Tom's "STRATO Mail Plus" (order number 8844576) — domain + email,
|
||||||
**kein Webspace**.
|
**no web space**.
|
||||||
- **E-Mail bleibt bei Strato.** Nur die Website zieht auf eigene Hardware.
|
- **Email stays with Strato.** Only the website moves to self-hosted hardware.
|
||||||
- **Die bisherige Website ist aktuell offline.** Der Inhaltsbestand ist damit der
|
- **The previous website is currently offline.** The content is therefore the
|
||||||
zeitkritischste offene Punkt (→ Abschnitt 9).
|
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,
|
- A public information site for the Elternbeirat: who, when, which Protokolle,
|
||||||
wie erreichbar.
|
how to reach us.
|
||||||
- Betrieb auf eigener Infrastruktur (Unraid), ohne fremden Hoster.
|
- Operation on self-hosted infrastructure (Unraid), without a third-party hoster.
|
||||||
- Inhalte versionierbar und ohne Datenbank — ein `git clone` ist das vollständige
|
- Content that is versionable and needs no database — a `git clone` is the complete
|
||||||
Backup.
|
backup.
|
||||||
- Wartungsarm: keine Plugin-Updates, keine PHP-Sicherheitslücken, kein CMS-Login
|
- Low maintenance: no plugin updates, no PHP security holes, no CMS login as an
|
||||||
als Angriffsfläche.
|
attack surface.
|
||||||
|
|
||||||
**Nicht-Ziele (bewusst)**
|
**Non-Goals (deliberate)**
|
||||||
|
|
||||||
- Kein CMS mit Web-Editor in Stufe 1. Falls andere Beiratsmitglieder später selbst
|
- No CMS with a web editor in stage 1. If other Elternbeirat members should later
|
||||||
redaktionell arbeiten sollen, ist das ein eigener Ausbauschritt (→ Abschnitt 11).
|
edit content themselves, that is a separate expansion step (→ section 11).
|
||||||
- Keine Benutzerkonten, kein Login, kein Mitgliederbereich.
|
- No user accounts, no login, no members' area.
|
||||||
- Keine Datenbank.
|
- No database.
|
||||||
- Keine externen Einbindungen (Fonts, Analytics, Maps, Social Widgets) — aus
|
- No external integrations (fonts, analytics, maps, social widgets) — for
|
||||||
Datenschutzgründen, siehe Abschnitt 10.
|
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
|
**Decision:** Blazor Web App with interactivity mode *None* (pure static SSR).
|
||||||
SSR). Zielframework .NET 10 (LTS).
|
Target framework .NET 10 (LTS).
|
||||||
|
|
||||||
**Begründung:** Blazor WASM lädt mehrere MB Runtime vor dem ersten sichtbaren
|
**Rationale:** Blazor WASM loads several MB of runtime before the first visible
|
||||||
Buchstaben, liefert Suchmaschinen und Link-Vorschauen (WhatsApp, Signal, Messenger
|
letter, serves search engines and link previews (WhatsApp, Signal, Messenger —
|
||||||
— der Hauptverbreitungsweg bei Elternschaften) eine leere Shell und bringt auf
|
the main distribution channel among parents) an empty shell, and provides no value
|
||||||
einer reinen Informationsseite keinerlei Gegenwert. Static SSR liefert fertiges
|
whatsoever on a pure information site. Static SSR delivers finished HTML, needs no
|
||||||
HTML, braucht kein JavaScript und kostet im Container rund 60 MB RAM.
|
JavaScript, and costs around 60 MB of RAM in the container.
|
||||||
|
|
||||||
**Konsequenz:** Interaktivität ist später pro Komponente nachrüstbar
|
**Consequence:** Interactivity can be added later per component
|
||||||
(`@rendermode InteractiveServer` an genau der einen Komponente), ohne die
|
(`@rendermode InteractiveServer` on exactly that one component), without changing
|
||||||
Architektur zu ändern.
|
the architecture.
|
||||||
|
|
||||||
**Verworfene Alternativen:**
|
**Rejected alternatives:**
|
||||||
|
|
||||||
| Alternative | Warum nicht |
|
| Alternative | Why not |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Blazor WASM | Payload, SEO, Link-Vorschauen, kein Nutzen |
|
| Blazor WASM | Payload, SEO, link previews, no benefit |
|
||||||
| ASP.NET Core MVC/Razor Pages | Funktioniert genauso, aber Razor Components sind das modernere Modell |
|
| ASP.NET Core MVC/Razor Pages | Works just as well, but Razor Components are the more modern model |
|
||||||
| Statiq.Web (C#-SSG) → nginx | Ops-technisch am schlanksten (nichts zu patchen), aber jede Textänderung erzwingt einen Build-Lauf. Bleibt als Rückfallebene. |
|
| 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 | Ausgereifteres SSG-Ökosystem, aber fremdes Terrain |
|
| 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
|
**Decision:** Page content as Markdown with YAML frontmatter, Termine as
|
||||||
strukturiertes YAML, Dokumente (Protokolle, Satzung) als PDF unter `wwwroot`.
|
structured YAML, documents (Protokolle, bylaws) as PDF under `wwwroot`.
|
||||||
|
|
||||||
**Begründung:** Kein DB-Backup, kein Migrationsschema, keine Konsistenzprobleme
|
**Rationale:** No DB backup, no migration schema, no consistency problems between
|
||||||
zwischen Dateien und Datenbank. Änderungen sind Commits und damit nachvollziehbar
|
files and database. Changes are commits and are therefore traceable and
|
||||||
und rückrollbar. Für eine Seite mit ~10 Unterseiten und ein paar Terminen pro Jahr
|
reversible. For a site with ~10 subpages and a few Termine per year, anything else
|
||||||
ist alles andere Overhead.
|
is overhead.
|
||||||
|
|
||||||
**Konsequenz:** Textänderungen erfordern einen Commit und ein Redeploy. Das ist bei
|
**Consequence:** Text changes require a commit and a redeploy. With an expected
|
||||||
erwarteten fünf Änderungen im Jahr akzeptabel — und der Grund, warum Abschnitt 11
|
five changes a year that is acceptable — and the reason why section 11 treats the
|
||||||
den Ausbau zum Web-Editor als eigene Stufe führt.
|
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
|
**Rationale:** Mounted content from the appdata share could be edited directly on
|
||||||
am NAS editieren, aber genau dann driften Repo und Live-Stand auseinander — und
|
the NAS, but that is exactly when the repo and the live state drift apart — and
|
||||||
die Eigenschaft „Backup = `git clone`" aus AE-2 wäre wertlos.
|
the "backup = `git clone`" property from AE-2 would be worthless.
|
||||||
|
|
||||||
**Konsequenz:** Kein Schnell-Fix am Live-System. Tippfehler werden korrekt über
|
**Consequence:** No quick fix on the live system. Typos are corrected properly via
|
||||||
einen Commit behoben.
|
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**
|
**Decision:** The container speaks only HTTP on port 8080 and has **no** port
|
||||||
Port-Mapping nach außen. NPM terminiert TLS und ist der einzige Weg zum Container.
|
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
|
**Rationale:** Matches the pattern already established in the home lab
|
||||||
(`cloud.anticarnist.de`). Zertifikatsverwaltung bleibt an einer Stelle.
|
(`cloud.anticarnist.de`). Certificate management stays in one place.
|
||||||
|
|
||||||
**Konsequenz (wichtig):** In `Program.cs` **kein** `UseHttpsRedirection()` und
|
**Consequence (important):** In `Program.cs`, **no** `UseHttpsRedirection()` and
|
||||||
**kein** `UseHsts()` — sonst Redirect-Schleife hinter dem Proxy. HSTS setzt NPM.
|
**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 |
|
| Framework | .NET 10.0 |
|
||||||
| Authentication | None |
|
| Authentication | None |
|
||||||
| Interactive render mode | **None** |
|
| Interactive render mode | **None** |
|
||||||
| Include sample pages | aus |
|
| Include sample pages | off |
|
||||||
| Configure for HTTPS | an (nur für lokale Entwicklung relevant) |
|
| Configure for HTTPS | on (only relevant for local development) |
|
||||||
| Do not use top-level statements | egal |
|
| Do not use top-level statements | doesn't matter |
|
||||||
| Enlist in .NET Aspire orchestration | **aus** |
|
| 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
|
The only thing that matters is *Interactive render mode = None* — this way the
|
||||||
kein `.Client`-Projekt und kein WebAssembly-Bundle.
|
template creates no `.Client` project and no WebAssembly bundle.
|
||||||
|
|
||||||
**Angelegt und bestätigt (2026-09-20):** Blazor Web App, `net10.0`, Interactive
|
**Created and confirmed (2026-09-20):** Blazor Web App, `net10.0`, Interactive
|
||||||
render mode `None`, Auth `None`, Sample pages aus, Docker-Optionen im Dialog aus
|
render mode `None`, Auth `None`, sample pages off, Docker options in the dialog off
|
||||||
(Dockerfile schreiben wir selbst, siehe Abschnitt 7). Git-Repository beim
|
(we write the Dockerfile ourselves, see section 7). Git repository created along
|
||||||
Anlegen mit erzeugt. Solution liegt direkt unter
|
with it. The solution sits directly under `RiderProjects\Elternbeirat\`, the
|
||||||
`RiderProjects\Elternbeirat\`, das Projekt in `Elternbeirat.Web\` darunter —
|
project in `Elternbeirat.Web\` below it — **no** intermediate `src/` directory,
|
||||||
**kein** `src/`-Zwischenverzeichnis, anders als ursprünglich in Abschnitt 5
|
unlike originally sketched in section 5.
|
||||||
skizziert.
|
|
||||||
|
|
||||||
NuGet-Pakete, die dazukommen:
|
NuGet packages that get added:
|
||||||
|
|
||||||
- `Markdig` — Markdown-Rendering
|
- `Markdig` — Markdown rendering
|
||||||
- `YamlDotNet` — Frontmatter und `termine.yml`
|
- `YamlDotNet` — frontmatter and `termine.yml`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Repo-Struktur
|
## 5. Repo Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
Elternbeirat/ ← Repo-Root, = Solution-Verzeichnis
|
Elternbeirat/ ← repo root, = solution directory
|
||||||
├── Elternbeirat.sln
|
├── Elternbeirat.sln
|
||||||
├── plan.md ← dieses Dokument
|
├── plan.md ← this document
|
||||||
├── CLAUDE.md ← minimal: Trigger, nicht-offensichtliche Kommandos
|
├── CLAUDE.md ← minimal: triggers, non-obvious commands
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── deployment.md ← Unraid, NPM, Registry, Rollback
|
│ ├── deployment.md ← Unraid, NPM, registry, rollback
|
||||||
│ ├── dns.md ← Strato, DynDNS, Mail-Records
|
│ ├── dns.md ← Strato, DynDNS, mail records
|
||||||
│ ├── inhalte-pflegen.md ← Anleitung für den Nicht-Alltagsfall
|
│ ├── inhalte-pflegen.md ← guide for the not-everyday case
|
||||||
│ ├── inhalte-migration.md ← Übernahme aus dem alten WordPress
|
│ ├── inhalte-migration.md ← import from the old WordPress
|
||||||
│ └── recht.md ← Impressum, Datenschutz, Fotos
|
│ └── recht.md ← imprint, privacy, photos
|
||||||
├── Elternbeirat.Web/
|
├── Elternbeirat.Web/
|
||||||
│ ├── Elternbeirat.Web.csproj
|
│ ├── Elternbeirat.Web.csproj
|
||||||
│ ├── Components/
|
│ ├── Components/
|
||||||
@@ -164,7 +163,7 @@ Elternbeirat/ ← Repo-Root, = Solution-Verzeichnis
|
|||||||
│ │ └── Pages/ Start, UeberUns, Termine, Protokolle,
|
│ │ └── Pages/ Start, UeberUns, Termine, Protokolle,
|
||||||
│ │ News, NewsBeitrag, Kontakt,
|
│ │ News, NewsBeitrag, Kontakt,
|
||||||
│ │ Impressum, Datenschutz, Fehler404
|
│ │ Impressum, Datenschutz, Fehler404
|
||||||
│ ├── Content/ ← Inhalte, kein Code
|
│ ├── Content/ ← content, no code
|
||||||
│ │ ├── seiten/*.md
|
│ │ ├── seiten/*.md
|
||||||
│ │ ├── news/2026-09-20-titel.md
|
│ │ ├── news/2026-09-20-titel.md
|
||||||
│ │ └── termine.yml
|
│ │ └── termine.yml
|
||||||
@@ -173,25 +172,25 @@ Elternbeirat/ ← Repo-Root, = Solution-Verzeichnis
|
|||||||
│ │ ├── TermineService.cs
|
│ │ ├── TermineService.cs
|
||||||
│ │ └── IcsWriter.cs
|
│ │ └── IcsWriter.cs
|
||||||
│ ├── wwwroot/
|
│ ├── wwwroot/
|
||||||
│ │ ├── css/site.css ← eigenes CSS, keine CDN-Einbindung
|
│ │ ├── css/site.css ← own CSS, no CDN integration
|
||||||
│ │ ├── img/
|
│ │ ├── img/
|
||||||
│ │ └── dokumente/ ← Protokolle, Satzung (PDF)
|
│ │ └── dokumente/ ← Protokolle, bylaws (PDF)
|
||||||
│ └── Program.cs
|
│ └── Program.cs
|
||||||
├── Elternbeirat.Web.Tests/ ← Smoke-Tests: jede Route liefert 200
|
├── Elternbeirat.Web.Tests/ ← smoke tests: every route returns 200
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
├── compose.yaml
|
├── compose.yaml
|
||||||
├── .dockerignore
|
├── .dockerignore
|
||||||
└── .gitea/workflows/deploy.yml
|
└── .gitea/workflows/deploy.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
`CLAUDE.md` bleibt bewusst kurz (Build-/Run-Kommandos, Stilregeln, Verweis auf
|
`CLAUDE.md` deliberately stays short (build/run commands, style rules, pointer to
|
||||||
`docs/`). Die Details liegen in `docs/` und werden nur bei Bedarf gelesen.
|
`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
|
```markdown
|
||||||
---
|
---
|
||||||
@@ -203,7 +202,7 @@ beschreibung: Wer im Elternbeirat der IGMH mitarbeitet und wie wir arbeiten.
|
|||||||
|
|
||||||
## Der Elternbeirat
|
## Der Elternbeirat
|
||||||
|
|
||||||
Fließtext …
|
Body text …
|
||||||
```
|
```
|
||||||
|
|
||||||
**Termin** (`Content/termine.yml`):
|
**Termin** (`Content/termine.yml`):
|
||||||
@@ -217,69 +216,69 @@ Fließtext …
|
|||||||
notiz: Gäste willkommen
|
notiz: Gäste willkommen
|
||||||
```
|
```
|
||||||
|
|
||||||
**News-Beitrag** (`Content/news/2026-09-20-neue-website.md`) — wie Seite, plus
|
**News post** (`Content/news/2026-09-20-neue-website.md`) — like a page, plus
|
||||||
`datum` und `autor`.
|
`datum` and `autor`.
|
||||||
|
|
||||||
Die Services lesen beim Start alles ein, cachen es im Speicher und stellen es
|
The services read everything at startup, cache it in memory, and provide it in a
|
||||||
typisiert bereit. In Development zusätzlich ein `FileSystemWatcher`, damit
|
typed form. In Development there is also a `FileSystemWatcher`, so that text
|
||||||
Textänderungen ohne Neustart sichtbar werden.
|
changes become visible without a restart.
|
||||||
|
|
||||||
**Zusatznutzen ohne Mehraufwand:** ein ICS-Endpoint unter `/termine.ics`, der die
|
**Added benefit at no extra cost:** an ICS endpoint at `/termine.ics` that serves
|
||||||
öffentlichen Termine ausliefert. Eltern abonnieren den Kalender einmal im Handy
|
the public Termine. Parents subscribe to the calendar once on their phone and see
|
||||||
und sehen jede Sitzung automatisch. Das ist der eine Punkt, an dem die Eigenbau-
|
every session automatically. This is the one point where the self-built solution
|
||||||
Lösung die alte WordPress-Seite spürbar schlägt.
|
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
|
```bash
|
||||||
docker compose build
|
docker compose build
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Für fünf Deployments im Jahr vollkommen ausreichend. CI vorab zu bauen wäre
|
Entirely sufficient for five deployments a year. Building via CI in advance would
|
||||||
Selbstzweck.
|
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`
|
1. `actions/checkout`
|
||||||
2. Login an der Gitea-eigenen Container-Registry
|
2. Login to Gitea's own container registry
|
||||||
3. `docker/build-push-action` → `gitea.<domain>/tom/elternbeirat:latest` **und** `:${{ gitea.sha }}`
|
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
|
- The `act_runner` in Docker mode needs access to a Docker socket or a DinD
|
||||||
einen DinD-Service, sonst schlägt `build-push-action` fehl.
|
service, otherwise `build-push-action` fails.
|
||||||
- Die Gitea-Registry braucht ein Paket-Token mit Schreibrecht (`write:package`),
|
- The Gitea registry needs a package token with write permission (`write:package`),
|
||||||
nicht das normale Login-Passwort.
|
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
|
Watchtower, but **label-scoped** — otherwise it updates the entire home-lab
|
||||||
Home-Lab-Bestand:
|
inventory unasked:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# im Watchtower-Container
|
# in the Watchtower container
|
||||||
WATCHTOWER_LABEL_ENABLE: "true"
|
WATCHTOWER_LABEL_ENABLE: "true"
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# im eb-web-Service
|
# in the eb-web service
|
||||||
labels:
|
labels:
|
||||||
com.centurylinklabs.watchtower.enable: "true"
|
com.centurylinklabs.watchtower.enable: "true"
|
||||||
```
|
```
|
||||||
|
|
||||||
Alternativ: manuell im Unraid-Docker-Tab „Update" drücken. Bei dieser
|
Alternatively: press "Update" manually in the Unraid Docker tab. At this rate of
|
||||||
Änderungsfrequenz völlig legitim.
|
change entirely legitimate.
|
||||||
|
|
||||||
### Dockerfile (Skizze)
|
### Dockerfile (sketch)
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
@@ -296,14 +295,14 @@ EXPOSE 8080
|
|||||||
ENTRYPOINT ["dotnet", "Elternbeirat.Web.dll"]
|
ENTRYPOINT ["dotnet", "Elternbeirat.Web.dll"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Das chiseled-Image läuft ab .NET 8 standardmäßig als non-root (UID 1654) und
|
The chiseled image runs as non-root (UID 1654) by default from .NET 8 on and
|
||||||
hört auf Port 8080. **Es enthält keine Shell** — ein `HEALTHCHECK` mit `curl`
|
listens on port 8080. **It contains no shell** — a `HEALTHCHECK` with `curl` does
|
||||||
funktioniert dort nicht. Entweder das normale `aspnet:10.0-noble` verwenden oder
|
not work there. Either use the normal `aspnet:10.0-noble` or leave monitoring to
|
||||||
die Überwachung NPM bzw. Uptime Kuma überlassen.
|
NPM or Uptime Kuma.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Hosting auf Unraid
|
## 8. Hosting on Unraid
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# compose.yaml
|
# compose.yaml
|
||||||
@@ -324,22 +323,22 @@ networks:
|
|||||||
external: true
|
external: true
|
||||||
```
|
```
|
||||||
|
|
||||||
Kein `ports:`-Block. Der Container ist ausschließlich über das NPM-Docker-Netz
|
No `ports:` block. The container is reachable exclusively via the NPM Docker
|
||||||
erreichbar.
|
network.
|
||||||
|
|
||||||
**NPM Proxy Host:**
|
**NPM Proxy Host:**
|
||||||
|
|
||||||
| Feld | Wert |
|
| Field | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Domain Names | `elternbeirat-igmh.de`, `www.elternbeirat-igmh.de` |
|
| Domain Names | `elternbeirat-igmh.de`, `www.elternbeirat-igmh.de` |
|
||||||
| Scheme | `http` |
|
| Scheme | `http` |
|
||||||
| Forward Hostname | `eb-web` |
|
| Forward Hostname | `eb-web` |
|
||||||
| Forward Port | `8080` |
|
| Forward Port | `8080` |
|
||||||
| Block Common Exploits | an |
|
| Block Common Exploits | on |
|
||||||
| Websockets Support | aus (wird bei statischem SSR nicht gebraucht) |
|
| Websockets Support | off (not needed with static SSR) |
|
||||||
| SSL | Let's Encrypt, Force SSL, HTTP/2, HSTS |
|
| SSL | Let's Encrypt, Force SSL, HTTP/2, HSTS |
|
||||||
|
|
||||||
**In `Program.cs` nicht vergessen:**
|
**Don't forget in `Program.cs`:**
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
app.UseForwardedHeaders(new ForwardedHeadersOptions
|
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
|
Without this, the app sees every request as HTTP and with the proxy IP instead of
|
||||||
Client-IP — relevant für korrekte absolute URLs und für die Logs.
|
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,
|
The old site is offline, but Maik's Strato contract is still running. As long as it
|
||||||
ist der Webspace erreichbar; nach der Kündigung ist der Bestand endgültig weg.
|
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?
|
1. Check what was actually secured at the meeting with Maik (files? MySQL dump?
|
||||||
Beides?). Ein vollständiger Dump wäre der Idealfall — daraus lassen sich Texte,
|
both?). A complete dump would be the ideal case — from it, texts, page
|
||||||
Seitenstruktur, Medien und PDFs sauber extrahieren.
|
structure, media, and PDFs can be extracted cleanly.
|
||||||
2. Falls nur Dateien vorliegen: `wp-content/uploads` enthält Bilder und PDFs, die
|
2. If only files are available: `wp-content/uploads` contains images and PDFs, but
|
||||||
Texte liegen aber in der Datenbank. Dann Schritt 3.
|
the texts live in the database. Then step 3.
|
||||||
3. Wayback Machine auf Snapshots von `elternbeirat-igmh.de` prüfen.
|
3. Check the Wayback Machine for snapshots of `elternbeirat-igmh.de`.
|
||||||
4. Falls nichts davon greift: Maik bitten, vor Vertragsende noch einen Export zu
|
4. If none of that works: ask Maik to pull an export before the contract ends — or
|
||||||
ziehen — oder Inhalte aus den Protokollen und von der Schulseite neu aufbauen.
|
rebuild the content from the Protokolle and the school's website.
|
||||||
|
|
||||||
**Diese Frage blockiert den Inhaltsteil, nicht den Technikteil.** Das Gerüst lässt
|
**This question blocks the content part, not the technical part.** The scaffolding
|
||||||
sich mit Platzhaltern bauen und später befüllen.
|
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
|
- **Imprint (§ 5 DDG):** If the Elternbeirat has no legal form of its own, the
|
||||||
Betreiber persönlich mit Name und ladungsfähiger Anschrift im Impressum. Das ist
|
operator is listed personally in the imprint with name and a valid postal
|
||||||
eine Entscheidung, keine Formalie — die Privatadresse wird damit öffentlich.
|
address for service. This is a decision, not a formality — the private address
|
||||||
Alternative: Anschrift der Schule, aber nur mit deren ausdrücklichem Einverständnis
|
becomes public. Alternative: the school's address, but only with its explicit
|
||||||
und wenn die Schule Mitbetreiberin ist.
|
consent and if the school is a co-operator.
|
||||||
- **Datenschutzerklärung:** Mit dem Umzug ist Tom Verantwortlicher im Sinne der DSGVO.
|
- **Privacy policy:** With the move, Tom becomes the controller in the sense of the
|
||||||
Server-Logs mit IP-Adressen benennen, Rechtsgrundlage und Löschfrist festlegen.
|
GDPR. Name server logs with IP addresses, define the legal basis and the deletion
|
||||||
- **Keine externen Ressourcen.** Google Fonts, Maps, YouTube-Embeds und CDN-Skripte
|
period.
|
||||||
übertragen die IP der Besucher an Dritte. Fonts werden selbst ausgeliefert.
|
- **No external resources.** Google Fonts, Maps, YouTube embeds, and CDN scripts
|
||||||
- **Fotos von Kindern:** nur mit Einwilligung der Erziehungsberechtigten — bei
|
transmit visitors' IPs to third parties. Fonts are served by ourselves.
|
||||||
Schulseiten der mit Abstand häufigste Fehler. Im Zweifel keine Personenfotos.
|
- **Photos of children:** only with the consent of the legal guardians — by far the
|
||||||
- Hosting am privaten Anschluss bedeutet: Die öffentliche IP des Privatanschlusses
|
most common mistake on school sites. When in doubt, no photos of people.
|
||||||
steht im DNS einer Schulseite. Bewusste Entscheidung, kein Nebeneffekt.
|
- 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** |
|
| 1 | What was secured from the old WordPress? | **open, time-critical** |
|
||||||
| 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 |
|
| 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 (kennt als Exposed-Host-Vorschaltgerät die öffentliche IP) oder ddclient-Container auf Unraid? | offen |
|
| 3 | DynDNS updater: Fritzbox (which, as an exposed-host upstream device, knows the public IP) or a ddclient container on Unraid? | open |
|
||||||
| 4 | Sollen andere Beiratsmitglieder Inhalte selbst pflegen können? Falls ja: eigener Ausbauschritt (Decap CMS auf Git-Basis oder kleines Admin-UI). | offen |
|
| 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 | Kontaktformular gewünscht? Würde Server-Interaktivität und Spam-Schutz erfordern — `mailto:` ist die aufwandsfreie Alternative. | offen |
|
| 5 | Contact form wanted? Would require server interactivity and spam protection — `mailto:` is the effort-free alternative. | open |
|
||||||
| 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 |
|
| 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 | Formulare bei Strato einreichen (unterschrieben, liegt bereit) | offen |
|
| 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) |
|
| 1 | Submit Strato forms, start the domain transfer | ✅ commissioned (2026-09-20) |
|
||||||
| 2 | Inhaltslage klären (Abschnitt 9) | — |
|
| 2 | Clarify the content situation (section 9) | — |
|
||||||
| 3 | Blazor-Projekt in Rider anlegen ✅ (2026-09-20) → nach Gitea pushen ✅ | — |
|
| 3 | Create the Blazor project in Rider ✅ (2026-09-20) → push to Gitea ✅ | — |
|
||||||
| 6 | Dockerfile, compose.yaml → als Container auf Unraid ✅ (2026-09-20) | 3 |
|
| 6 | Dockerfile, compose.yaml → as a container on Unraid ✅ (2026-09-20) | 3 |
|
||||||
| 4 | Content-Pipeline: Markdig, YamlDotNet, Services, ICS-Endpoint | 3 |
|
| 4 | Content pipeline: Markdig, YamlDotNet, services, ICS endpoint | 3 |
|
||||||
| 5 | Layout, Navigation, Seiten mit Platzhaltern | 4 |
|
| 5 | Layout, navigation, pages with placeholders | 4 |
|
||||||
| 7 | Echte Inhalte einpflegen | 2, 5 |
|
| 7 | Add the real content | 2, 5 |
|
||||||
| 8 | Impressum und Datenschutzerklärung | 7 |
|
| 8 | Imprint and privacy policy | 7 |
|
||||||
| 9 | DNS umstellen, NPM Proxy Host, Let's Encrypt | 1, 6 |
|
| 9 | Switch DNS, NPM Proxy Host, Let's Encrypt | 1, 6 |
|
||||||
| 10 | Gitea Actions + Registry + Watchtower | 6, 9 |
|
| 10 | Gitea Actions + registry + Watchtower | 6, 9 |
|
||||||
|
|
||||||
Schritte 1 und 2 laufen unabhängig vom Code und sollten sofort starten —
|
Steps 1 and 2 run independently of the code and should start immediately —
|
||||||
Schritt 2 ist der einzige, bei dem Warten echten Schaden anrichtet.
|
step 2 is the only one where waiting does real damage.
|
||||||
|
|
||||||
**Abweichung von der ursprünglichen Reihenfolge:** Schritt 6 (Deployment) wurde
|
**Deviation from the original order:** Step 6 (deployment) was deliberately pulled
|
||||||
bewusst **vor** die Content-Pipeline (4/5) gezogen. Grund: die riskanteste Kette
|
**ahead of** the content pipeline (4/5). Reason: prove the riskiest chain —
|
||||||
— Image bauen → Gitea-Registry → auf Unraid ziehen → Container läuft — früh
|
build image → Gitea registry → pull on Unraid → container runs — early, rather
|
||||||
beweisen, statt sie erst kurz vor dem Livegang zu entdecken. Details in
|
than discovering it just before go-live. Details in `docs/deployment.md`.
|
||||||
`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
|
Achieved: The raw "Hello world" of the Blazor app runs as a container on Unraid
|
||||||
(`Cube`), erreichbar im LAN unter `http://cube:5000`. Bewiesen ist damit die
|
(`Cube`), reachable on the LAN at `http://cube:5000`. This proves the complete
|
||||||
komplette Deploy-Kette inkl. privater Gitea-Registry.
|
deploy chain including the private Gitea registry.
|
||||||
|
|
||||||
Bewusste **Test-Abweichungen** vom Produktivziel (Abschnitt 8), später
|
Deliberate **test deviations** from the production target (section 8), to be
|
||||||
zurückzubauen:
|
rolled back later:
|
||||||
|
|
||||||
- `compose.yaml` hat ein Port-Mapping `5000:8080`. Produktiv: kein Mapping, nur
|
- `compose.yaml` has a port mapping `5000:8080`. In production: no mapping, only
|
||||||
über das externe `npm`-Netz (NPM ist noch nicht testbar, Domain zieht erst um).
|
via the external `npm` network (NPM is not testable yet, the domain moves first).
|
||||||
- Image-Tag nur `:latest`, noch kein SHA-Tag (→ Schritt 10, Rollback).
|
- Image tag only `:latest`, no SHA tag yet (→ step 10, rollback).
|
||||||
- Registry-Token liegt auf Unraid im Klartext (`/root/.docker/config.json`).
|
- The registry token sits on Unraid in plain text (`/root/.docker/config.json`).
|
||||||
Credential-Helper ist als späterer Punkt in `docs/deployment.md` notiert.
|
A credential helper is noted as a later item in `docs/deployment.md`.
|
||||||
|
|
||||||
**Nächster Schritt:** Content-Pipeline (Schritt 4) — Markdig + YamlDotNet,
|
**Next step:** Content pipeline (step 4) — Markdig + YamlDotNet, services for
|
||||||
Services für Seiten/Termine, ICS-Endpoint. Parallel offen und unabhängig vom
|
pages/Termine, ICS endpoint. Open in parallel and independent of the code:
|
||||||
Code: Inhaltslage klären (Schritt 2, zeitkritisch).
|
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
|
- **Branch/PR workflow** established: never directly on `main`; feature branch →
|
||||||
Request in Gitea → Merge. Erstmals durchgeführt (PR #1).
|
pull request in Gitea → merge. Carried out for the first time (PR #1).
|
||||||
- **Zwei Build-Skripte** in `scripts/`: `dev-build.sh` (lokales Dev-Image, kein
|
- **Two build scripts** in `scripts/`: `dev-build.sh` (local dev image, no push —
|
||||||
Push — Dev-Stände bleiben aus der Registry raus) und `release.sh` (baut aus
|
dev states stay out of the registry) and `release.sh` (builds from `main`, tags
|
||||||
`main`, taggt `:latest` **und** Commit-Kurz-SHA, pusht beide; bricht ab, wenn
|
`:latest` **and** the short commit SHA, pushes both; aborts if not on `main` or
|
||||||
nicht auf `main` oder Arbeitsverzeichnis unsauber).
|
the working directory is dirty).
|
||||||
- **SHA-Tagging** ist damit Standard → Rollback möglich. Erster Release-Tag:
|
- **SHA tagging** is now standard → rollback possible. First release tag:
|
||||||
`:8643f2c`. Redeploy auf Unraid (Compose Down/Up) bewusst geübt, läuft.
|
`:8643f2c`. Redeploy on Unraid (Compose Down/Up) deliberately practiced, works.
|
||||||
- `.gitattributes` erzwingt LF für `*.sh` (sonst scheitert der Shebang unter
|
- `.gitattributes` enforces LF for `*.sh` (otherwise the shebang fails on Windows).
|
||||||
Windows).
|
|
||||||
|
|
||||||
Offen fürs nächste Mal (unverändert): Content-Pipeline (Schritt 4) und die
|
Open for next time (unchanged): content pipeline (step 4) and the time-critical
|
||||||
zeitkritische Inhaltslage (Schritt 2). Deployment-Automatisierung per Gitea
|
content situation (step 2). Deployment automation via Gitea Actions (step 10) is
|
||||||
Actions (Schritt 10) ist der nächste optionale Deployment-Ausbau, aber nicht
|
the next optional deployment expansion, but not urgent — manual operation via
|
||||||
dringend — der Handbetrieb über `release.sh` reicht.
|
`release.sh` is enough.
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Baut ein lokales Dev-Image zum Ausprobieren auf dem eigenen Rechner.
|
# Builds a local dev image for trying things out on your own machine.
|
||||||
# Pusht NICHTS in die Registry -- der Stand bleibt privat auf dem Laptop.
|
# Pushes NOTHING to the registry -- the build stays private on the laptop.
|
||||||
#
|
#
|
||||||
# Der Tag enthaelt den aktuellen Branchnamen, damit Dev-Images nicht mit dem
|
# The tag carries the current branch name so dev images are not confused with
|
||||||
# Produktiv-:latest verwechselt werden. Anschliessend z.B. lokal starten:
|
# the production :latest. Afterwards start it locally, e.g.:
|
||||||
# docker run --rm -p 5000:8080 elternbeirat-web:dev-<branch>
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -14,9 +14,9 @@ cd "$(dirname "$0")/.."
|
|||||||
branch="$(git rev-parse --abbrev-ref HEAD | tr '/' '-')"
|
branch="$(git rev-parse --abbrev-ref HEAD | tr '/' '-')"
|
||||||
tag="elternbeirat-web:dev-$branch"
|
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" .
|
docker build -t "$tag" .
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Fertig. Lokal starten z.B. mit:"
|
echo "Done. Start it locally, e.g. with:"
|
||||||
echo " docker run --rm -p 5000:8080 $tag"
|
echo " docker run --rm -p 5000:8080 $tag"
|
||||||
+22
-22
@@ -1,55 +1,55 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Baut das Produktiv-Image aus dem aktuellen main-Stand und pusht es in die
|
# Builds the production image from the current main state and pushes it to the
|
||||||
# Gitea-Registry, getaggt mit :latest UND dem Commit-Kurz-SHA (Rollback).
|
# Gitea registry, tagged with :latest AND the short commit SHA (rollback).
|
||||||
#
|
#
|
||||||
# Nur fuer freigegebene Staende: das Skript verweigert den Push, wenn du nicht
|
# For approved states only: the script refuses to push if you are not on main
|
||||||
# auf main bist oder uncommittete Aenderungen hast. Fuer Dev-Builds ohne Push
|
# or have uncommitted changes. For dev builds without a push use
|
||||||
# stattdessen scripts/dev-build.sh nutzen.
|
# 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).
|
# docs/deployment.md).
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
IMAGE="gitea.anticarnist.de/tom/elternbeirat"
|
IMAGE="gitea.anticarnist.de/tom/elternbeirat"
|
||||||
|
|
||||||
# Ins Repo-Root wechseln (Skript liegt in scripts/), damit der Docker-Build-
|
# Change into the repo root (the script lives in scripts/) so the Docker build
|
||||||
# Kontext stimmt, egal von wo aufgerufen.
|
# context is correct no matter where it is called from.
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
branch="$(git rev-parse --abbrev-ref HEAD)"
|
branch="$(git rev-parse --abbrev-ref HEAD)"
|
||||||
if [[ "$branch" != "main" ]]; then
|
if [[ "$branch" != "main" ]]; then
|
||||||
echo "ABBRUCH: du bist auf '$branch', nicht auf 'main'." >&2
|
echo "ABORT: you are on '$branch', not on 'main'." >&2
|
||||||
echo "Ein :latest-Release darf nur aus main gebaut werden." >&2
|
echo "A :latest release may only be built from main." >&2
|
||||||
echo "Fuer einen Dev-Build ohne Push: scripts/dev-build.sh" >&2
|
echo "For a dev build without a push: scripts/dev-build.sh" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "$(git status --porcelain)" ]]; then
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
echo "ABBRUCH: Arbeitsverzeichnis nicht sauber (uncommittete Aenderungen)." >&2
|
echo "ABORT: working tree is not clean (uncommitted changes)." >&2
|
||||||
echo "Erst committen, damit der SHA-Tag den Image-Inhalt eindeutig benennt." >&2
|
echo "Commit first so the SHA tag names the image content unambiguously." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Warnung, wenn lokaler main dem Remote voraus ist (ungepushte Commits) — dann
|
# Warn if the local main is ahead of the remote (unpushed commits) -- otherwise
|
||||||
# wuerde ein SHA getaggt, den es auf Gitea noch nicht gibt.
|
# a SHA would be tagged that does not yet exist on Gitea.
|
||||||
if git rev-parse --verify --quiet origin/main >/dev/null; then
|
if git rev-parse --verify --quiet origin/main >/dev/null; then
|
||||||
ahead="$(git rev-list --count origin/main..HEAD)"
|
ahead="$(git rev-list --count origin/main..HEAD)"
|
||||||
if [[ "$ahead" -gt 0 ]]; then
|
if [[ "$ahead" -gt 0 ]]; then
|
||||||
echo "WARNUNG: lokaler main ist origin/main um $ahead Commit(s) voraus." >&2
|
echo "WARNING: local main is ahead of origin/main by $ahead commit(s)." >&2
|
||||||
echo " Erst 'git push', damit der SHA auf Gitea existiert." >&2
|
echo " Run 'git push' first so the SHA exists on Gitea." >&2
|
||||||
read -r -p "Trotzdem fortfahren? [y/N] " answer
|
read -r -p "Continue anyway? [y/N] " answer
|
||||||
[[ "$answer" == "y" || "$answer" == "Y" ]] || exit 1
|
[[ "$answer" == "y" || "$answer" == "Y" ]] || exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
sha="$(git rev-parse --short HEAD)"
|
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 build -t "$IMAGE:latest" -t "$IMAGE:$sha" .
|
||||||
docker push "$IMAGE" --all-tags
|
docker push "$IMAGE" --all-tags
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Fertig. Gepusht: $IMAGE:latest und $IMAGE:$sha"
|
echo "Done. Pushed: $IMAGE:latest and $IMAGE:$sha"
|
||||||
echo "Auf Unraid: Stack 'elternbeirat' -> Compose Down/Up (bzw. Pull), damit"
|
echo "On Unraid: stack 'elternbeirat' -> Compose Down/Up (or Pull) so the new"
|
||||||
echo "das neue :latest gezogen wird."
|
echo ":latest is fetched."
|
||||||
Reference in new issue
Block a user