diff --git a/.env.example b/.env.example index 7eaf7d3..27d1b18 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # This file documents every variable the app/deployment actually reads. # In production, setup.sh generates the real file at /opt/anouma/.env for -# you — you shouldn't need to hand-edit this. For local development, copy +# you - you shouldn't need to hand-edit this. For local development, copy # this to .env at the repo root and fill in DATABASE_URI/PAYLOAD_SECRET # (see README.md → "Lokale Entwicklung"). @@ -11,7 +11,7 @@ # dev server against the Dockerized Postgres, use "127.0.0.1" instead. DATABASE_URI=postgresql://postgres:postgres@postgres:5432/anouma -# Only used by the "postgres" service in docker-compose.yml — keep these in +# Only used by the "postgres" service in docker-compose.yml - keep these in # sync with DATABASE_URI above (setup.sh does this for you). POSTGRES_USER=postgres POSTGRES_PASSWORD=replace-with-a-long-random-secret @@ -40,11 +40,11 @@ CRON_SECRET=replace-with-a-long-random-secret NEXT_PUBLIC_SERVER_URL=http://localhost:3000 # Port the app container publishes on the host (behind a reverse proxy this -# usually doesn't need to be reachable directly — see DEPLOYMENT.md). +# usually doesn't need to be reachable directly - see DEPLOYMENT.md). APP_PORT=3000 # ── Email (SMTP) ──────────────────────────────────────────────────────── -# The existing ANOUMA mail system — no external newsletter service. +# The existing ANOUMA mail system - no external newsletter service. SMTP_HOST= SMTP_PORT=587 SMTP_USER= @@ -79,7 +79,7 @@ DOCKER_NETWORK=anouma-network # Name of an existing external Docker network to join (e.g. Nginx Proxy # Manager's network) so a reverse proxy can reach the app container -# directly. Leave empty if you don't use one — setup.sh auto-detects this. +# directly. Leave empty if you don't use one - setup.sh auto-detects this. NPM_NETWORK= # ── Backups (used by deploy.sh) ───────────────────────────────────────── @@ -87,7 +87,7 @@ NPM_NETWORK= BACKUP_RETENTION_DAYS=14 # ── Container registry & deployment (used by deploy.sh) ──────────────── -# GitHub Container Registry. deploy.sh builds nothing — it only ever pulls +# GitHub Container Registry. deploy.sh builds nothing - it only ever pulls # the image that GitHub Actions built and pushed. REGISTRY=ghcr.io REGISTRY_REPO=whitehai11/anouma @@ -96,10 +96,10 @@ REGISTRY_REPO=whitehai11/anouma # account password. REGISTRY_USERNAME= REGISTRY_PASSWORD= -# Set automatically by deploy.sh on every successful deploy — do not edit +# Set automatically by deploy.sh on every successful deploy - do not edit # by hand. Empty until the first release has been deployed. ANOUMA_IMAGE= -# Informational only (shown in the setup summary) — actual automatic +# Informational only (shown in the setup summary) - actual automatic # deployment is controlled by whether release.yml + its GitHub secrets are # configured, not by this flag. AUTO_DEPLOY=true diff --git a/.gitignore b/.gitignore index dbf0f6b..1b8fa01 100644 --- a/.gitignore +++ b/.gitignore @@ -49,7 +49,7 @@ next-env.d.ts *.deb *.AppImage -# docker / deployment (machine-specific, generated by setup.sh — production +# docker / deployment (machine-specific, generated by setup.sh - production # artifacts actually live in /opt/anouma on the server, never inside this # repo checkout, but these are ignored here too in case of local testing) docker-compose.override.yml diff --git a/AGENTS.md b/AGENTS.md index 643577d..44a03a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,8 @@ # This is NOT the Next.js you know -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. +This version has breaking changes - APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. -This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. +This block is written and re-added by `next dev` - verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index d2553ae..9c42cbc 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,6 +1,6 @@ -# ANOUMA — Deployment +# ANOUMA - Deployment -Production deployment is fully CI/CD-driven via GitHub Actions. There is no `update.sh` and the production server never builds anything — it only ever pulls a tested image from GitHub Container Registry. +Production deployment is fully CI/CD-driven via GitHub Actions. There is no `update.sh` and the production server never builds anything - it only ever pulls a tested image from GitHub Container Registry. ```text Developer → git push main → GitHub Actions (CI) @@ -9,7 +9,7 @@ Developer → git push main → GitHub Actions (CI) → SSH → deploy.sh → docker compose pull → migrate → up -d → Healthcheck → LIVE ``` -Building and testing happen exactly once, on `main` — tagging a release never +Building and testing happen exactly once, on `main` - tagging a release never rebuilds or re-tests, it only promotes the already-verified image for that commit. Tagging a commit that hasn't been pushed to `main` (or whose CI run hasn't finished yet) fails the release with a clear error instead of quietly @@ -27,18 +27,18 @@ chmod +x setup.sh `setup.sh` needs root (directly or via `sudo`) because it creates a system user and writes to `/opt`. It: 1. Checks/installs Docker, Docker Compose, git, openssl, curl, ssh. -2. Creates `/opt/anouma` (refuses to touch it if an installation already exists there — you choose "use existing" or abort; nothing is ever deleted). +2. Creates `/opt/anouma` (refuses to touch it if an installation already exists there - you choose "use existing" or abort; nothing is ever deleted). 3. Detects an existing Nginx Proxy Manager network and optionally joins it. 4. Writes `/opt/anouma/.env` with every secret the app needs, freshly generated (`openssl rand -hex 32`) and **never regenerated** on a later run. -5. Asks the few things that can't be generated (domain, optionally SMTP, optionally the registry credentials) — skipped automatically if already answered before. -6. Creates a restricted `anouma-deploy` system user, generates a dedicated SSH keypair for it, and installs the public key with a **forced command** so that key can only ever run `deploy.sh` — never an interactive shell. +5. Asks the few things that can't be generated (domain, optionally SMTP, optionally the registry credentials) - skipped automatically if already answered before. +6. Creates a restricted `anouma-deploy` system user, generates a dedicated SSH keypair for it, and installs the public key with a **forced command** so that key can only ever run `deploy.sh` - never an interactive shell. 7. Copies `docker-compose.yml` and `deploy.sh` into `/opt/anouma`. 8. Starts Postgres (so it's ready for the first deploy). -9. Prints the exact values to paste into **GitHub → whitehai11/anouma → Settings → Secrets and variables → Actions**, including the deploy private key — shown only once, the first time the key is generated. +9. Prints the exact values to paste into **GitHub → whitehai11/anouma → Settings → Secrets and variables → Actions**, including the deploy private key - shown only once, the first time the key is generated. Run `./setup.sh --non-interactive` for a scripted install; any value that has no safe default and wasn't exported as an environment variable beforehand aborts with a clear message naming the missing variable, instead of silently guessing. -Re-running `./setup.sh` later (e.g. after pulling script updates) is always safe: existing secrets, the SSH key, the database and all volumes are left exactly as they are — only `docker-compose.yml` and `deploy.sh` themselves get refreshed, since those are generated artifacts, not data. +Re-running `./setup.sh` later (e.g. after pulling script updates) is always safe: existing secrets, the SSH key, the database and all volumes are left exactly as they are - only `docker-compose.yml` and `deploy.sh` themselves get refreshed, since those are generated artifacts, not data. ### After setup: add the GitHub secrets @@ -50,19 +50,19 @@ Copy the deployment values `setup.sh` printed into **GitHub → whitehai11/anoum | `DEPLOY_PORT` | `22` (or your SSH port) | | `DEPLOY_USER` | `anouma-deploy` | | `DEPLOY_SSH_KEY` | the private key setup.sh printed | -| `DEPLOY_HOST_KEY` | optional — pin the server's SSH host public key instead of trusting it on first connect (`ssh-keyscan -p ` on a machine you already trust) | +| `DEPLOY_HOST_KEY` | optional - pin the server's SSH host public key instead of trusting it on first connect (`ssh-keyscan -p ` on a machine you already trust) | | `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` | **not needed in GitHub Actions**; these belong in `/opt/anouma/.env` on the server for pulling the private GHCR image | ### First deploy -Nothing runs on the server yet at this point — no release has been built. Push the first tag: +Nothing runs on the server yet at this point - no release has been built. Push the first tag: ```bash git tag v1.0.0 git push github v1.0.0 # GitHub Actions ``` -The GitHub workflow takes it from there. Once it's live, seed the original content once: `ssh anouma-deploy@` won't work (the key is restricted — see Security), so run it directly on the server instead: `cd /opt/anouma && docker compose run --rm app npm run seed`. +The GitHub workflow takes it from there. Once it's live, seed the original content once: `ssh anouma-deploy@` won't work (the key is restricted - see Security), so run it directly on the server instead: `cd /opt/anouma && docker compose run --rm app npm run seed`. ## GitHub Actions @@ -91,7 +91,7 @@ Release-Workflows ausführen. ## `deploy.sh` (server-side, `/opt/anouma/deploy.sh`) -Not a manual tool — it's invoked exclusively by GitHub Actions over SSH. What it does for `deploy.sh deploy vX.Y.Z`: +Not a manual tool - it's invoked exclusively by GitHub Actions over SSH. What it does for `deploy.sh deploy vX.Y.Z`: ```text Lock (refuse a second concurrent deploy) @@ -105,13 +105,13 @@ Lock (refuse a second concurrent deploy) → success, or: automatic rollback to the previous version ``` -If the post-deploy healthcheck fails, `deploy.sh` automatically redeploys the **previous** version (recorded from `ANOUMA_IMAGE` before the attempt) and reports the failed deploy as exit code 1 — GitHub Actions shows the release as failed even though production recovered. Database migrations from the failed release are **not** reverted (write migrations to be forward-compatible); restore the pre-deploy backup manually if a migration truly needs undoing. +If the post-deploy healthcheck fails, `deploy.sh` automatically redeploys the **previous** version (recorded from `ANOUMA_IMAGE` before the attempt) and reports the failed deploy as exit code 1 - GitHub Actions shows the release as failed even though production recovered. Database migrations from the failed release are **not** reverted (write migrations to be forward-compatible); restore the pre-deploy backup manually if a migration truly needs undoing. A lock file (`/opt/anouma/.deploy.lock`) prevents two deploys from running at once; a stale lock from a killed process is detected and ignored automatically. ## Backups -Every deploy backs up the database first, to `/opt/anouma/backups/database-YYYY-MM-DD-HHMM.sql.gz`, before anything else changes. Backups older than `BACKUP_RETENTION_DAYS` (default 14) are cleaned up automatically — the backup just created is never deleted, even if retention is set very low. +Every deploy backs up the database first, to `/opt/anouma/backups/database-YYYY-MM-DD-HHMM.sql.gz`, before anything else changes. Backups older than `BACKUP_RETENTION_DAYS` (default 14) are cleaned up automatically - the backup just created is never deleted, even if retention is set very low. Restore manually if needed: @@ -137,7 +137,7 @@ In Nginx Proxy Manager, add a Proxy Host: | Scheme | `http` | | Forward Host | `app` (the Compose service name) | | Forward Port | `3000` | -| **Websockets Support** | **enabled** — required for the LiveKit signal connection | +| **Websockets Support** | **enabled** - required for the LiveKit signal connection | Für LiveKit legst du zusätzlich einen zweiten Proxy Host an: @@ -163,7 +163,7 @@ Client C ── WebRTC ──┘ │ ``` `docker-compose.yml` startet LiveKit mit Host-Networking, dem UDP-Medienbereich -`50000–60000`, dem TCP-Fallback `7881` und eingebettetem TURN/UDP auf `3478`. +`50000-60000`, dem TCP-Fallback `7881` und eingebettetem TURN/UDP auf `3478`. Die API-Keys werden von `setup.sh` generiert und nur an den LiveKit-Container und die serverseitige Token-Route gegeben. Für den Browser ist ausschließlich `LIVEKIT_URL` als `wss://`-Endpoint sichtbar. @@ -196,30 +196,30 @@ See `.env.example` for the full, commented list. `setup.sh` writes the real file | `DOCKER_NETWORK`, `NPM_NETWORK` | Docker network names | | `BACKUP_RETENTION_DAYS` | Deploy-backup retention | | `REGISTRY`, `REGISTRY_REPO`, `REGISTRY_USERNAME`, `REGISTRY_PASSWORD` | Where `deploy.sh` pulls the image from | -| `ANOUMA_IMAGE` | Set automatically by `deploy.sh` on every deploy — don't edit by hand | +| `ANOUMA_IMAGE` | Set automatically by `deploy.sh` on every deploy - don't edit by hand | ## Persistent data Deployments never delete data: -- **Database** — the `pgdata` named volume, independent of the `postgres` container's lifecycle. -- **Uploaded media** — the `/opt/anouma/media` bind mount, independent of the `app` container's lifecycle. -- **Backups** — `/opt/anouma/backups`. +- **Database** - the `pgdata` named volume, independent of the `postgres` container's lifecycle. +- **Uploaded media** - the `/opt/anouma/media` bind mount, independent of the `app` container's lifecycle. +- **Backups** - `/opt/anouma/backups`. Neither `setup.sh` nor `deploy.sh` ever runs `docker system prune`, `docker volume prune`, `docker network prune`, or `docker compose down -v`. Neither touches a container or network it doesn't own (an existing NPM install is never modified beyond optionally joining its network). ## Healthchecks -- **app**: `GET /api/health` (built into the image's `HEALTHCHECK`) — checks that the app can actually query Postgres, not just that the process is listening. +- **app**: `GET /api/health` (built into the image's `HEALTHCHECK`) - checks that the app can actually query Postgres, not just that the process is listening. - **postgres**: `pg_isready`. `docker compose ps` (from `/opt/anouma`) shows the app, Postgres and LiveKit services. ## Security -- `anouma-deploy` has **no sudo rights at all** and **no password** — the only way in is its dedicated SSH key. +- `anouma-deploy` has **no sudo rights at all** and **no password** - the only way in is its dedicated SSH key. - That SSH key is restricted with `restrict,command="/opt/anouma/deploy.sh"` in `authorized_keys`: whatever command an SSH client requests is ignored and `deploy.sh` runs instead (the client's actual request only reaches it via `$SSH_ORIGINAL_COMMAND`, which `deploy.sh` validates against a strict `deploy vX.Y.Z` pattern before doing anything). No arbitrary shell access is possible through this key, even if it leaks. -- `anouma-deploy` **is** a member of the `docker` group, because running `docker compose` requires it — and Docker-group membership is well known to be effectively root-equivalent (a container can bind-mount the host filesystem). This is a deliberate, documented trade-off: the blast radius is contained by the two points above (no interactive access, and the only reachable code path is the fixed `deploy.sh` logic), not eliminated. For a stricter setup, consider rootless Docker or a dedicated deployment tool with finer-grained Docker API ACLs — out of scope here. +- `anouma-deploy` **is** a member of the `docker` group, because running `docker compose` requires it - and Docker-group membership is well known to be effectively root-equivalent (a container can bind-mount the host filesystem). This is a deliberate, documented trade-off: the blast radius is contained by the two points above (no interactive access, and the only reachable code path is the fixed `deploy.sh` logic), not eliminated. For a stricter setup, consider rootless Docker or a dedicated deployment tool with finer-grained Docker API ACLs - out of scope here. - `.env` and the deploy SSH private key are `chmod 600`. - Secrets are never committed, never logged, and never printed more than once (`setup.sh` shows the private key only the first time it's generated; on a later run it prints "Existing deployment key found." instead). - `REGISTRY_PASSWORD`/`SMTP_PASSWORD`/etc. are masked (`********`) in `setup.sh`'s configuration summary. The GHCR token is needed only on the production server for pulling the private image; GitHub Actions uses its automatic `GITHUB_TOKEN`. @@ -228,10 +228,10 @@ Neither `setup.sh` nor `deploy.sh` ever runs `docker system prune`, `docker volu | Symptom | Check | | --- | --- | -| `setup.sh` fails at "Datenbank wurde nicht rechtzeitig healthy" | `docker compose logs postgres` (from `/opt/anouma`) — usually a bad `POSTGRES_PASSWORD`/`DATABASE_URI` mismatch if you hand-edited `.env` | -| Release workflow fails at "Pull the tested image" | The tagged commit was never pushed to `main`, or `main`'s CI/build-image run for it hasn't finished yet — push to `main`, wait for CI to go green, then tag | +| `setup.sh` fails at "Datenbank wurde nicht rechtzeitig healthy" | `docker compose logs postgres` (from `/opt/anouma`) - usually a bad `POSTGRES_PASSWORD`/`DATABASE_URI` mismatch if you hand-edited `.env` | +| Release workflow fails at "Pull the tested image" | The tagged commit was never pushed to `main`, or `main`'s CI/build-image run for it hasn't finished yet - push to `main`, wait for CI to go green, then tag | | Release workflow fails at "Deploy to production" | Check the GitHub repository secrets are all set correctly, and that the server's SSH port is reachable from the runner | | App container unhealthy after a deploy | `ssh` directly to the server (not via the restricted key) and run `docker compose logs app` from `/opt/anouma` | -| Video calls connect but no audio/video | Check LiveKit logs, the UDP 3478/50000–60000 firewall rules and the public `LIVEKIT_URL` | +| Video calls connect but no audio/video | Check LiveKit logs, the UDP 3478/50000-60000 firewall rules and the public `LIVEKIT_URL` | | Browser cannot connect to LiveKit | Check the LiveKit proxy host, trusted TLS certificate and that Websockets Support is enabled | | Need to redeploy the same version | `git tag -f vX.Y.Z && git push -f origin vX.Y.Z`, or push a new patch tag | diff --git a/Dockerfile b/Dockerfile index 5d1175c..05e7878 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ # Multi-stage build for the ANOUMA app (Next.js + Payload CMS + LiveKit SFU -# integration, all served by the custom server.ts — see next.config.ts for why +# integration, all served by the custom server.ts - see next.config.ts for why # this can't use Next's "standalone" output). DATABASE_URI/PAYLOAD_SECRET/etc. # are only needed at *runtime*, not at build time (see docker-compose.yml and -# .env.example) — this image builds without a database connection. +# .env.example) - this image builds without a database connection. FROM node:22-alpine AS base @@ -32,12 +32,12 @@ RUN addgroup --system --gid 1001 nodejs \ # Copied wholesale (not a hand-picked list of directories) so newly added # source folders (collections, scripts, lib/*, components/*, ...) are never -# silently missing at runtime — .dockerignore already excludes what doesn't +# silently missing at runtime - .dockerignore already excludes what doesn't # belong in the image (node_modules is re-added explicitly below, .next is # the build output we do want). COPY --from=builder --chown=nextjs:nodejs /app /app -# Writable at runtime, independent of the image — actual data lives in the +# Writable at runtime, independent of the image - actual data lives in the # Docker volumes mounted over these paths (see docker-compose.yml). RUN mkdir -p media backups && chown -R nextjs:nodejs media backups diff --git a/README.md b/README.md index 685db0e..855777e 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,20 @@ # Anouma -Die Website von Anouma — Next.js (App Router) mit einem eingebauten [Payload CMS](https://payloadcms.com) (PostgreSQL) für Termine, Beiträge, Angebote und Seiteninhalte, plus Online-Termine mit selbst gehostetem [LiveKit](https://livekit.io) SFU-Video-Call, Anmeldungen und E-Mail-Erinnerungen. +Die Website von Anouma - Next.js (App Router) mit einem eingebauten [Payload CMS](https://payloadcms.com) (PostgreSQL) für Termine, Beiträge, Angebote und Seiteninhalte, plus Online-Termine mit selbst gehostetem [LiveKit](https://livekit.io) SFU-Video-Call, Anmeldungen und E-Mail-Erinnerungen. ## Projektstruktur -- `app/(frontend)/` — die öffentliche Website (bestehendes ANOUMA-Design, eigener Root-Layout) -- `app/(call)/` — die Video-Call-Oberfläche (`/termine/[slug]/call`), eigenes minimalistisches dunkles Layout ohne Navbar/Footer -- `app/(payload)/` — der Admin-Bereich unter `/admin`, Payloads REST/GraphQL-API sowie die Meeting-/Cron-API-Routen (`api/meetings/...`, `api/cron/...`) -- `app/global-not-found.tsx` — statische 404-Seite (siehe unten, warum sie nötig ist) -- `collections/`, `globals/`, `access/`, `fields/`, `payload.config.ts` — die CMS-Konfiguration -- `lib/payload/` — Local-API-Zugriffe für die öffentliche Website (mit `React.cache` pro Request memoisiert) -- `lib/meeting/` — Meeting-Passwort, Zeitfenster/Status, signierte Beitritts-Tokens, LiveKit-SFU-Token und Reminder-Logik -- `lib/email/` — SMTP-Versand (nodemailer) und das E-Mail-Template für Reminder -- `lib/texte.ts` — die ursprünglichen Anouma-Texte, nur noch als Seed-Quelle verwendet -- `scripts/seed.ts` — überträgt die vorhandenen Inhalte ins CMS -- `server.ts` — eigener Node-Server (statt `next start`), passend zur bestehenden Payload-Konfiguration +- `app/(frontend)/` - die öffentliche Website (bestehendes ANOUMA-Design, eigener Root-Layout) +- `app/(call)/` - die Video-Call-Oberfläche (`/termine/[slug]/call`), eigenes minimalistisches dunkles Layout ohne Navbar/Footer +- `app/(payload)/` - der Admin-Bereich unter `/admin`, Payloads REST/GraphQL-API sowie die Meeting-/Cron-API-Routen (`api/meetings/...`, `api/cron/...`) +- `app/global-not-found.tsx` - statische 404-Seite (siehe unten, warum sie nötig ist) +- `collections/`, `globals/`, `access/`, `fields/`, `payload.config.ts` - die CMS-Konfiguration +- `lib/payload/` - Local-API-Zugriffe für die öffentliche Website (mit `React.cache` pro Request memoisiert) +- `lib/meeting/` - Meeting-Passwort, Zeitfenster/Status, signierte Beitritts-Tokens, LiveKit-SFU-Token und Reminder-Logik +- `lib/email/` - SMTP-Versand (nodemailer) und das E-Mail-Template für Reminder +- `lib/texte.ts` - die ursprünglichen Anouma-Texte, nur noch als Seed-Quelle verwendet +- `scripts/seed.ts` - überträgt die vorhandenen Inhalte ins CMS +- `server.ts` - eigener Node-Server (statt `next start`), passend zur bestehenden Payload-Konfiguration Zwei bzw. drei Root-Layouts (`(frontend)`, `(payload)`, `(call)`) bedeuten: Next.js kann daraus keine einzelne 404-Seite komponieren, deshalb gibt es `app/global-not-found.tsx` (siehe `experimental.globalNotFound` in `next.config.ts`). @@ -47,11 +47,11 @@ Erinnerungs-Mails (60/30 Minuten vorher, pro Termin im CMS einzeln an-/abschaltb curl -H "Authorization: Bearer $CRON_SECRET" https://anouma.org/api/cron/event-reminders ``` -Der Versand ist idempotent (`reminder60Sent`/`reminder30Sent` je Anmeldung, `hostReminder60Sent`/`hostReminder30Sent` je Termin) — ein häufiger laufender Cron verschickt also nie doppelt. +Der Versand ist idempotent (`reminder60Sent`/`reminder30Sent` je Anmeldung, `hostReminder60Sent`/`hostReminder30Sent` je Termin) - ein häufiger laufender Cron verschickt also nie doppelt. ## Setup (Produktion / Deployment) -Produktion läuft komplett über GitHub Actions CI/CD — der Server baut nichts selbst, er zieht ein fertiges, getestetes Image aus GitHub Container Registry. Einmalig auf dem Server: +Produktion läuft komplett über GitHub Actions CI/CD - der Server baut nichts selbst, er zieht ein fertiges, getestetes Image aus GitHub Container Registry. Einmalig auf dem Server: ```bash git clone https://github.com/whitehai11/anouma.git @@ -68,7 +68,7 @@ Jedes weitere Deployment läuft danach ausschließlich über einen Git-Tag: git tag v1.2.0 && git push origin v1.2.0 ``` -GitHub Actions testet, baut das Image, pusht es nach GHCR und deployt es per SSH auf den Server — inklusive Backup, Healthcheck und automatischem Rollback bei Fehlschlag. Es gibt kein separates Update-Script. +GitHub Actions testet, baut das Image, pusht es nach GHCR und deployt es per SSH auf den Server - inklusive Backup, Healthcheck und automatischem Rollback bei Fehlschlag. Es gibt kein separates Update-Script. Für alle Details (Architektur, GitHub Secrets, CI/Release-Workflows, Reverse-Proxy- und LiveKit-Einrichtung, Environment-Variablen, Backups, Rollback, Security) siehe **[DEPLOYMENT.md](./DEPLOYMENT.md)**. @@ -88,7 +88,7 @@ Nur Postgres per Docker, die App läuft direkt auf dem Host: docker compose up -d postgres ``` -(oder eine gehostete Postgres-Instanz, z. B. Neon/Supabase — einfach `DATABASE_URI` in `.env` entsprechend setzen). +(oder eine gehostete Postgres-Instanz, z. B. Neon/Supabase - einfach `DATABASE_URI` in `.env` entsprechend setzen). ### Entwicklung @@ -97,7 +97,7 @@ npm run dev ``` - Website: http://localhost:3000 -- Admin: http://localhost:3000/admin — beim ersten Aufruf legst du dort direkt deinen eigenen Admin-Zugang (E-Mail + Passwort) an. Es gibt kein Standardpasswort im Code. +- Admin: http://localhost:3000/admin - beim ersten Aufruf legst du dort direkt deinen eigenen Admin-Zugang (E-Mail + Passwort) an. Es gibt kein Standardpasswort im Code. ### Vorhandene Inhalte ins CMS übertragen @@ -105,7 +105,7 @@ npm run dev npm run seed ``` -Überträgt die 7 Angebote sowie die Texte für Startseite, Über mich, Angebote-Einleitung, Aktuelles-Einleitung, Kontakt und Termin buchen aus `lib/texte.ts` ins CMS. Kann gefahrlos mehrfach ausgeführt werden. Kontaktdaten (E-Mail/Telefon/Region) werden zunächst als Platzhalter gesetzt — bitte im Admin unter „Kontakt“ durch die echten Angaben ersetzen. +Überträgt die 7 Angebote sowie die Texte für Startseite, Über mich, Angebote-Einleitung, Aktuelles-Einleitung, Kontakt und Termin buchen aus `lib/texte.ts` ins CMS. Kann gefahrlos mehrfach ausgeführt werden. Kontaktdaten (E-Mail/Telefon/Region) werden zunächst als Platzhalter gesetzt - bitte im Admin unter „Kontakt“ durch die echten Angaben ersetzen. ### Produktions-Build ohne Docker @@ -125,8 +125,8 @@ Für einen vollständig dockerisierten Produktionsbetrieb (App + Datenbank, Heal | `npm run generate:importmap`| Admin-Importmap neu erzeugen (nach neuen Feldtypen) | | `npm run migrate:create` | Neue Datenbank-Migration aus Config-Änderungen bauen | | `npm run lint` | ESLint | -| `npm test` | Test-Suite (aktuell ein Platzhalter — noch keine Tests vorhanden) | -| `npm run seo:check` | Optionale SEO-Prüfung (doppelte Slugs, fehlende Alt-Texte) — nicht produktionskritisch | +| `npm test` | Test-Suite (aktuell ein Platzhalter - noch keine Tests vorhanden) | +| `npm run seo:check` | Optionale SEO-Prüfung (doppelte Slugs, fehlende Alt-Texte) - nicht produktionskritisch | ## Design diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index fe1838c..81fd97b 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -1,4 +1,4 @@ -# Security-Audit — 17.09.2026 +# Security-Audit - 17.09.2026 Geprüfter Commit: `cb30eca`. Ergebnis: **ein Befund hoher Priorität (P1), fünf Befunde mittlerer Priorität (P2)** sowie Hinweise zu Abhängigkeiten. Keine Behebung vorgenommen. @@ -8,7 +8,7 @@ Quellcodeprüfung von Payload-Zugriffsregeln, Kunden-/Admin-Authentifizierung, B `npm test`: 5/5 Tests bestanden. Zusätzlich wurden die unveränderten Route Handler für Stornierung, Meeting-Beitritt, Token-Austausch und Buchungsanfragen isoliert ausgeführt, mit simulierter Datenbank und simuliertem LiveKit. Keine Produktivzugriffe, echten E-Mails oder Datenänderungen. Diese Prüfungen belegen die Handler-Logik, nicht das Verhalten eines realen Reverse-Proxys oder Video-Servers. Produktionskonfiguration, Git-Historie und laufende Infrastruktur waren nicht Gegenstand eines Penetrationstests. -## 1. P1 — Interne Buchungsnotizen gelangen in den Kundenbrowser +## 1. P1 - Interne Buchungsnotizen gelangen in den Kundenbrowser **Stellen:** `lib/booking/queries.ts:12`, `app/(frontend)/konto/termine/page.tsx:27`, `app/(frontend)/konto/page.tsx:46`, `collections/BookingRequests.ts:364`. @@ -24,7 +24,7 @@ Die Kontoseiten übergeben das gesamte Ergebnis als `booking` an die Client Comp **Regressionstest:** Eine Buchung mit einer eindeutigen internen Testnotiz anlegen; die Notiz darf weder in HTML noch in einer RSC-Antwort des Kundenkontos vorkommen. -## 2. P2 — Stornierung widerruft den privaten Meeting-Zugang nicht +## 2. P2 - Stornierung widerruft den privaten Meeting-Zugang nicht **Stellen:** `app/(payload)/api/booking/[id]/cancel/route.ts:29`, `app/(payload)/api/meetings/[slug]/join/route.ts:49`, `collections/BookingRequests.ts:229`. @@ -36,7 +36,7 @@ Die Stornierung setzt nur den Buchungsstatus. Der verknüpfte Online-Event und s **Behebung:** Für private Events bei jedem Join und Connect eine weiterhin bestätigte, passend verknüpfte Buchung verlangen. Bei Stornierung bestehende Sitzungen widerrufen; gegebenenfalls aktive Teilnehmer über LiveKit entfernen. -## 3. P2 — Token-Austausch umgeht das Meeting-Ende +## 3. P2 - Token-Austausch umgeht das Meeting-Ende **Stelle:** `app/(payload)/api/meetings/[slug]/connect/route.ts:27`. @@ -48,7 +48,7 @@ Die Stornierung setzt nur den Buchungsstatus. Der verknüpfte Online-Event und s **Behebung:** Dieselbe serverseitige Zugangsprüfung für Join und Connect verwenden. Token-Laufzeiten am erlaubten Meeting-Ende begrenzen und einen Sitzungs-/Berechtigungsstand für Widerrufe prüfen. Die beabsichtigte Admin-Testzimmer-Ausnahme separat behandeln. -## 4. P2 — Krankheits- und Urlaubsgründe sind öffentlich abrufbar +## 4. P2 - Krankheits- und Urlaubsgründe sind öffentlich abrufbar **Stelle:** `collections/AvailabilityOverrides.ts:17`. @@ -60,7 +60,7 @@ Die Collection erlaubt anonymes Lesen aller Dokumente. Das Feld `reason` enthäl **Behebung:** Collection-Lesen auf Admins beschränken oder mindestens den Grund schützen. Der bestehende serverseitige Slot-Endpunkt kann weiterhin ausschließlich verfügbare Zeiten herausgeben. -## 5. P2 — Registrierung und E-Mail-Wechsel umgehen die Versandlimits +## 5. P2 - Registrierung und E-Mail-Wechsel umgehen die Versandlimits **Stellen:** `collections/Customers.ts:84`, `collections/Customers.ts:46`. @@ -72,7 +72,7 @@ Die öffentliche Kundenerstellung löst unmittelbar eine Bestätigungs-E-Mail au **Behebung:** Erstellung und E-Mail-Änderung vor dem Versand zentral pro IP, Konto und Zieladresse begrenzen. Regeln auf Collection-/Hook-Ebene oder einem exklusiven, geschützten Einstiegspunkt durchsetzen, sodass REST und GraphQL sie nicht umgehen können. -## 6. P2 — Ein bestätigtes Konto kann beliebig viele identische Buchungsanfragen erzeugen +## 6. P2 - Ein bestätigtes Konto kann beliebig viele identische Buchungsanfragen erzeugen **Stellen:** `app/(payload)/api/booking/request/route.ts:75`, `collections/BookingRequests.ts:163`. diff --git a/access/index.ts b/access/index.ts index b274c8a..c21747c 100644 --- a/access/index.ts +++ b/access/index.ts @@ -1,7 +1,7 @@ import type { Access, FieldAccess } from "payload"; /** - * Only the "users" auth collection (Anna/admins) may perform the action — + * Only the "users" auth collection (Anna/admins) may perform the action - * and only with the "admin" role, so adding further roles to Users later * only means extending this one check. Site visitors authenticate against * the separate "customers" collection (see collections/Customers.ts) and @@ -15,7 +15,7 @@ export const isAdminFieldLevel: FieldAccess = ({ req: { user } }) => /** * Admins always see Analytics; any other "users" account additionally needs - * its own `canViewAnalytics` flag (see collections/Users.ts) — this is the + * its own `canViewAnalytics` flag (see collections/Users.ts) - this is the * granular permission the single "admin" role doesn't otherwise provide. * Used both as a Payload Access/FieldAccess function and called directly * (with a minimal `{ req: { user } }` shape) from the custom admin nav-link diff --git a/app/(call)/layout.tsx b/app/(call)/layout.tsx index 478b01c..aeb447d 100644 --- a/app/(call)/layout.tsx +++ b/app/(call)/layout.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from "react"; import { Inter } from "next/font/google"; import "../(frontend)/globals.css"; -// Its own root layout — the video-call UI is intentionally full-bleed and +// Its own root layout - the video-call UI is intentionally full-bleed and // dark, without the marketing navbar/footer around it. const inter = Inter({ variable: "--font-inter", subsets: ["latin"] }); diff --git a/app/(frontend)/aktuelles/[slug]/page.tsx b/app/(frontend)/aktuelles/[slug]/page.tsx index bb798e1..982bcfd 100644 --- a/app/(frontend)/aktuelles/[slug]/page.tsx +++ b/app/(frontend)/aktuelles/[slug]/page.tsx @@ -38,7 +38,7 @@ export async function generateMetadata({ params }: Args): Promise { const resolved = resolveSeo({ seo: post.seo, - fallbackTitle: `${post.title} – ${siteConfig.name}`, + fallbackTitle: `${post.title} - ${siteConfig.name}`, fallbackDescription: () => post.teaser, fallbackImage: post.coverImage, defaultOgImage: seoSettings.defaultOgImage, diff --git a/app/(frontend)/angebote/[slug]/page.tsx b/app/(frontend)/angebote/[slug]/page.tsx index b5a22a4..ed707da 100644 --- a/app/(frontend)/angebote/[slug]/page.tsx +++ b/app/(frontend)/angebote/[slug]/page.tsx @@ -45,7 +45,7 @@ export async function generateMetadata({ params }: Args): Promise { const resolved = resolveSeo({ seo: offer.seo, - fallbackTitle: `${offer.title} – ${siteConfig.name}`, + fallbackTitle: `${offer.title} - ${siteConfig.name}`, fallbackDescription: () => offer.shortDescription, fallbackImage: offer.image, defaultOgImage: seoSettings.defaultOgImage, @@ -133,7 +133,7 @@ export default async function OfferDetailPage({ params }: Args) { {offer.groupSchedule && (

Fester Termin

-

{offer.groupSchedule.recurrence === "monthly" ? "Monatlich" : "Wöchentlich"}{offer.groupSchedule.weekday ? ` · ${WEEKDAY_LABELS[offer.groupSchedule.weekday] ?? offer.groupSchedule.weekday}` : ""}{offer.groupSchedule.startTime ? ` · ${offer.groupSchedule.startTime}` : ""}{offer.groupSchedule.endTime ? `–${offer.groupSchedule.endTime}` : ""}

+

{offer.groupSchedule.recurrence === "monthly" ? "Monatlich" : "Wöchentlich"}{offer.groupSchedule.weekday ? ` · ${WEEKDAY_LABELS[offer.groupSchedule.weekday] ?? offer.groupSchedule.weekday}` : ""}{offer.groupSchedule.startTime ? ` · ${offer.groupSchedule.startTime}` : ""}{offer.groupSchedule.endTime ? `-${offer.groupSchedule.endTime}` : ""}

)} {offer.groupCapacity &&

Plätze: {offer.groupCapacity}

} diff --git a/app/(frontend)/angebote/page.tsx b/app/(frontend)/angebote/page.tsx index 2e7a73e..8ba04f9 100644 --- a/app/(frontend)/angebote/page.tsx +++ b/app/(frontend)/angebote/page.tsx @@ -19,13 +19,13 @@ import { siteConfig } from "@/lib/site"; export const dynamic = "force-dynamic"; const FALLBACK_DESCRIPTION = - "Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise – die Angebote von Anouma im Überblick."; + "Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise - die Angebote von Anouma im Überblick."; export async function generateMetadata(): Promise { const [intro, seoSettings] = await Promise.all([getAngeboteIntroGlobal(), getSEOSettingsGlobal()]); const resolved = resolveSeo({ seo: intro.seo, - fallbackTitle: `Begleitung für dich und deine Familie – ${siteConfig.name}`, + fallbackTitle: `Begleitung für dich und deine Familie - ${siteConfig.name}`, fallbackDescription: () => intro.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION, defaultOgImage: seoSettings.defaultOgImage, }); @@ -170,7 +170,7 @@ export default async function AngebotePage() { ); diff --git a/app/(frontend)/angebote/singkreise/page.tsx b/app/(frontend)/angebote/singkreise/page.tsx index f9399ca..a2c7893 100644 --- a/app/(frontend)/angebote/singkreise/page.tsx +++ b/app/(frontend)/angebote/singkreise/page.tsx @@ -14,7 +14,7 @@ export const dynamic = "force-dynamic"; export async function generateMetadata(): Promise { return buildMetadata({ - title: `Singkreise – ${siteConfig.name}`, + title: `Singkreise - ${siteConfig.name}`, description: "Gemeinsam singen. Verbinden. Heilen. Für Herz und Seele.", path: "/angebote/singkreise", }); @@ -30,7 +30,7 @@ export default async function SingkreisePage() { <> @@ -53,7 +53,7 @@ export default async function SingkreisePage() { ); diff --git a/app/(frontend)/calendar/[tokenFile]/route.ts b/app/(frontend)/calendar/[tokenFile]/route.ts index 4f0b9a9..cde1fc9 100644 --- a/app/(frontend)/calendar/[tokenFile]/route.ts +++ b/app/(frontend)/calendar/[tokenFile]/route.ts @@ -11,7 +11,7 @@ const TOKEN_SHAPE = /^[0-9a-f]{64}$/i; type Args = { params: Promise<{ tokenFile: string }> }; /** - * GET /calendar/.ics — a private iCalendar feed, subscribable from + * GET /calendar/.ics - a private iCalendar feed, subscribable from * Apple Calendar, Google Calendar, Outlook, Thunderbird etc. The token * itself is the only credential (there's no login here), so it's treated * like a secret: looked up by hash only, never logged, and the response is diff --git a/app/(frontend)/datenschutz/page.tsx b/app/(frontend)/datenschutz/page.tsx index 0395a68..8774468 100644 --- a/app/(frontend)/datenschutz/page.tsx +++ b/app/(frontend)/datenschutz/page.tsx @@ -33,9 +33,9 @@ export default async function DatenschutzPage() {

Verantwortliche Stelle

- [Platzhalter – Vor- und Nachname] + [Platzhalter - Vor- und Nachname]
- [Platzhalter – Anschrift] + [Platzhalter - Anschrift]
E-Mail: {contact.email}

@@ -48,7 +48,7 @@ export default async function DatenschutzPage() {

Beim Besuch dieser Website werden aus technischen Gründen automatisch Informationen erfasst, die dein Browser übermittelt (z. B. IP-Adresse, Datum und Uhrzeit des - Zugriffs). [Platzhalter – Details zum verwendeten Hosting-Anbieter ergänzen.] + Zugriffs). [Platzhalter - Details zum verwendeten Hosting-Anbieter ergänzen.]

diff --git a/app/(frontend)/globals.css b/app/(frontend)/globals.css index 4986107..dbe8eb9 100644 --- a/app/(frontend)/globals.css +++ b/app/(frontend)/globals.css @@ -2,7 +2,7 @@ @font-face { font-family: "Antropos"; - src: url("/fonts/antropos.ttf") format("truetype"); + src: url("/fonts/baar-antropos.ttf") format("truetype"); font-display: swap; font-style: normal; font-weight: 100 900; @@ -80,6 +80,10 @@ body { font-family: "Antropos", ui-sans-serif, system-ui, sans-serif; } +.font-antropos { + font-family: "Antropos", ui-serif, Georgia, serif; +} + ::selection { background: var(--color-anouma-rose-soft); color: var(--color-anouma-plum); diff --git a/app/(frontend)/impressionen/page.tsx b/app/(frontend)/impressionen/page.tsx index db12bf8..b660f49 100644 --- a/app/(frontend)/impressionen/page.tsx +++ b/app/(frontend)/impressionen/page.tsx @@ -10,7 +10,7 @@ import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld"; export const dynamic = "force-dynamic"; -const DESCRIPTION = "Bildeindrücke aus der Arbeit von Anouma – Natur, Gemeinschaft und gemeinsame Räume."; +const DESCRIPTION = "Bildeindrücke aus der Arbeit von Anouma - Natur, Gemeinschaft und gemeinsame Räume."; export async function generateMetadata(): Promise { return buildMetadata({ title: "Impressionen", description: DESCRIPTION, path: "/impressionen" }); @@ -36,7 +36,7 @@ export default async function ImpressionenPage() {
diff --git a/app/(frontend)/impressum/page.tsx b/app/(frontend)/impressum/page.tsx index 23b8459..35efa2d 100644 --- a/app/(frontend)/impressum/page.tsx +++ b/app/(frontend)/impressum/page.tsx @@ -12,7 +12,7 @@ export async function generateMetadata(): Promise { return { title: "Impressum", description: "Impressum von Anouma gemäß § 5 TMG.", - // noindex, but still crawlable — legal boilerplate deliberately kept + // noindex, but still crawlable - legal boilerplate deliberately kept // out of search results without cutting off the internal links on it. robots: { index: false, follow: true }, alternates: { canonical: `${siteUrl}/impressum` }, @@ -35,11 +35,11 @@ export default async function ImpressumPage() {

Angaben gemäß § 5 TMG

- [Platzhalter – Vor- und Nachname] + [Platzhalter - Vor- und Nachname]
- [Platzhalter – Straße und Hausnummer] + [Platzhalter - Straße und Hausnummer]
- [Platzhalter – PLZ und Ort] + [Platzhalter - PLZ und Ort]

@@ -61,7 +61,7 @@ export default async function ImpressumPage() { Umsatzsteuer-Identifikationsnummer

- [Platzhalter – sofern vorhanden, gemäß § 27 a Umsatzsteuergesetz] + [Platzhalter - sofern vorhanden, gemäß § 27 a Umsatzsteuergesetz]

@@ -70,7 +70,7 @@ export default async function ImpressumPage() { Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV

- [Platzhalter – Vor- und Nachname, Anschrift wie oben] + [Platzhalter - Vor- und Nachname, Anschrift wie oben]

diff --git a/app/(frontend)/kontakt/page.tsx b/app/(frontend)/kontakt/page.tsx index c3a7b29..ff67408 100644 --- a/app/(frontend)/kontakt/page.tsx +++ b/app/(frontend)/kontakt/page.tsx @@ -19,7 +19,7 @@ export async function generateMetadata(): Promise { const [contact, seoSettings] = await Promise.all([getContactGlobal(), getSEOSettingsGlobal()]); const resolved = resolveSeo({ seo: contact.seo, - fallbackTitle: `Kontakt – ${siteConfig.name}`, + fallbackTitle: `Kontakt - ${siteConfig.name}`, fallbackDescription: () => contact.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION, defaultOgImage: seoSettings.defaultOgImage, }); diff --git a/app/(frontend)/konto/layout.tsx b/app/(frontend)/konto/layout.tsx index f3838a1..866d90b 100644 --- a/app/(frontend)/konto/layout.tsx +++ b/app/(frontend)/konto/layout.tsx @@ -8,7 +8,7 @@ import { ResendVerificationButton } from "@/components/auth/ResendVerificationBu import { getCurrentCustomer } from "@/lib/auth/customer"; export const dynamic = "force-dynamic"; -// Applies to every /konto/* page at once — private account area, never indexable. +// Applies to every /konto/* page at once - private account area, never indexable. export const metadata: Metadata = { robots: { index: false, follow: false } }; const navItems = [ diff --git a/app/(frontend)/konto/profil/page.tsx b/app/(frontend)/konto/profil/page.tsx index cbbec28..be77e40 100644 --- a/app/(frontend)/konto/profil/page.tsx +++ b/app/(frontend)/konto/profil/page.tsx @@ -14,7 +14,7 @@ export default async function KontoProfilPage() {

- E-Mail: {customer.email} – für eine Änderung der E-Mail-Adresse melde dich bitte direkt bei Anna. + E-Mail: {customer.email} - für eine Änderung der E-Mail-Adresse melde dich bitte direkt bei Anna.

diff --git a/app/(frontend)/layout.tsx b/app/(frontend)/layout.tsx index 2fbd36a..491905a 100644 --- a/app/(frontend)/layout.tsx +++ b/app/(frontend)/layout.tsx @@ -31,7 +31,7 @@ export const metadata: Metadata = { metadataBase: new URL(siteConfig.domain), title: { default: siteConfig.title, - template: `%s – ${siteConfig.name}`, + template: `%s - ${siteConfig.name}`, }, description: siteConfig.description, openGraph: { @@ -47,7 +47,7 @@ export const metadata: Metadata = { title: siteConfig.title, description: siteConfig.description, }, - // Configurable via env only — never a hardcoded verification ID (see .env.example). + // Configurable via env only - never a hardcoded verification ID (see .env.example). ...(process.env.GOOGLE_SITE_VERIFICATION ? { verification: { google: process.env.GOOGLE_SITE_VERIFICATION } } : {}), }; diff --git a/app/(frontend)/page.tsx b/app/(frontend)/page.tsx index 0dedc80..6b837c4 100644 --- a/app/(frontend)/page.tsx +++ b/app/(frontend)/page.tsx @@ -27,11 +27,11 @@ export async function generateMetadata(): Promise { const [home, seoSettings] = await Promise.all([getHomeGlobal(), getSEOSettingsGlobal()]); const resolved = resolveSeo({ seo: home.seo, - fallbackTitle: `${siteConfig.name} – Begleitung für dich und deine Familie`, + fallbackTitle: `${siteConfig.name} - Begleitung für dich und deine Familie`, fallbackDescription: () => home.heroSupporting || seoSettings.defaultDescription || - "Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise – Räume für Verbindung mit dir selbst, miteinander und mit der Natur.", + "Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise - Räume für Verbindung mit dir selbst, miteinander und mit der Natur.", fallbackImage: home.heroImage, defaultOgImage: seoSettings.defaultOgImage, }); @@ -42,8 +42,8 @@ export async function generateMetadata(): Promise { ogImageUrl: resolved.ogImageUrl, keywords: resolved.keywords, }); - // Homepage bypasses the "%s — Anouma" title template — the brand is - // already part of the fallback/CMS title itself, avoiding "Anouma — Anouma". + // Homepage bypasses the "%s - Anouma" title template - the brand is + // already part of the fallback/CMS title itself, avoiding "Anouma - Anouma". return { ...meta, title: { absolute: resolved.title } }; } @@ -93,10 +93,10 @@ export default async function Home() {
- {/* Prozessbegleitung ist das Kernangebot – bekommt deshalb den größeren, ersten Platz statt eines gleichwertigen 4er-Rasters. */} + {/* Prozessbegleitung ist das Kernangebot - bekommt deshalb den größeren, ersten Platz statt eines gleichwertigen 4er-Rasters. */} {featuredGroup && ( diff --git a/app/(frontend)/termin-buchen/page.tsx b/app/(frontend)/termin-buchen/page.tsx index 1eef7e4..64d7681 100644 --- a/app/(frontend)/termin-buchen/page.tsx +++ b/app/(frontend)/termin-buchen/page.tsx @@ -22,7 +22,7 @@ export async function generateMetadata(): Promise { const [booking, seoSettings] = await Promise.all([getBookingGlobal(), getSEOSettingsGlobal()]); const resolved = resolveSeo({ seo: booking.seo, - fallbackTitle: `Termin buchen – ${siteConfig.name}`, + fallbackTitle: `Termin buchen - ${siteConfig.name}`, fallbackDescription: () => booking.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION, defaultOgImage: seoSettings.defaultOgImage, }); @@ -115,7 +115,7 @@ export default async function TerminBuchenPage() { title="Allgemeine Anfrage" lead={ bookableOffers.length > 0 - ? "Für einen konkreten Termin wähle oben ein Angebot – hier kannst du auch einfach eine allgemeine Nachricht schreiben." + ? "Für einen konkreten Termin wähle oben ein Angebot - hier kannst du auch einfach eine allgemeine Nachricht schreiben." : undefined } align="left" diff --git a/app/(frontend)/termine/[slug]/beitreten/page.tsx b/app/(frontend)/termine/[slug]/beitreten/page.tsx index 8eaeb65..32779ff 100644 --- a/app/(frontend)/termine/[slug]/beitreten/page.tsx +++ b/app/(frontend)/termine/[slug]/beitreten/page.tsx @@ -30,7 +30,7 @@ export default async function BeitretenPage({ params }: Args) {
- + ANOUMA

diff --git a/app/(frontend)/termine/[slug]/page.tsx b/app/(frontend)/termine/[slug]/page.tsx index 2ba1cb7..6c364a5 100644 --- a/app/(frontend)/termine/[slug]/page.tsx +++ b/app/(frontend)/termine/[slug]/page.tsx @@ -47,7 +47,7 @@ export async function generateMetadata({ params }: Args): Promise { const resolved = resolveSeo({ seo: event.seo, - fallbackTitle: `${event.title} – ${siteConfig.name}`, + fallbackTitle: `${event.title} - ${siteConfig.name}`, fallbackDescription: () => excerptFromRichText(event.description) || `${event.title} bei ${siteConfig.name}.`, fallbackImage: event.image, defaultOgImage: seoSettings.defaultOgImage, @@ -74,7 +74,7 @@ export default async function EventDetailPage({ params }: Args) { if (event.isOnline) { const payload = await getPayload({ config }); const { user } = await payload.auth({ headers: await headers() }); - // Must check the collection, not just presence — a logged-in customer + // Must check the collection, not just presence - a logged-in customer // (collection "customers") must never be treated as host. const isHost = user?.collection === "users" && user.role === "admin"; const settings = await payload.findGlobal({ slug: "meeting-settings" }); diff --git a/app/(frontend)/termine/page.tsx b/app/(frontend)/termine/page.tsx index 2666a3b..1eb3fe9 100644 --- a/app/(frontend)/termine/page.tsx +++ b/app/(frontend)/termine/page.tsx @@ -44,7 +44,7 @@ export default async function TerminePage() {

Aktuell sind keine Termine geplant

- Schau gerne bald wieder vorbei, oder melde dich direkt – ich freue mich von dir zu hören. + Schau gerne bald wieder vorbei, oder melde dich direkt - ich freue mich von dir zu hören.

Zur Kontaktseite diff --git a/app/(frontend)/ueber-mich/page.tsx b/app/(frontend)/ueber-mich/page.tsx index a1e1702..1f474ca 100644 --- a/app/(frontend)/ueber-mich/page.tsx +++ b/app/(frontend)/ueber-mich/page.tsx @@ -21,7 +21,7 @@ export async function generateMetadata(): Promise { const [about, seoSettings] = await Promise.all([getAboutGlobal(), getSEOSettingsGlobal()]); const resolved = resolveSeo({ seo: about.seo, - fallbackTitle: `Über mich – ${siteConfig.name}`, + fallbackTitle: `Über mich - ${siteConfig.name}`, fallbackDescription: () => excerptFromRichText(about.body) || seoSettings.defaultDescription || "Mein Weg, meine Werte und was mich bewegt.", fallbackImage: about.portrait, defaultOgImage: seoSettings.defaultOgImage, diff --git a/app/(payload)/admin/importMap.js b/app/(payload)/admin/importMap.js index 5f8a7b7..b42ee0d 100644 --- a/app/(payload)/admin/importMap.js +++ b/app/(payload)/admin/importMap.js @@ -24,6 +24,7 @@ import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0 import { CalendarNavLink as CalendarNavLink_4d06fac03127b339306d5f5ecb073251 } from '../../../components/admin/CalendarNavLink' import { AnalyticsNavLink as AnalyticsNavLink_29fa5c324cecba6cafb11381cbfac962 } from '../../../components/admin/AnalyticsNavLink' import { ConversationsNavLink as ConversationsNavLink_67a2594cf28806bc76971e67212a6181 } from '../../../components/admin/ConversationsNavLink' +import { AdminWelcome as AdminWelcome_b3c24bec21c663ad093f131aaae3d0a6 } from '../../../components/admin/AdminWelcome' import { AdminCalendarView as AdminCalendarView_a2760c3ae85f4fd51d79a465baaf023a } from '../../../components/admin/AdminCalendarView' import { AdminAnalyticsView as AdminAnalyticsView_55d38702e4d86d52435b5ac4df3f55d0 } from '../../../components/admin/AdminAnalyticsView' import { AdminConversationsView as AdminConversationsView_180f5c400a066a0a8f248da44d961055 } from '../../../components/admin/AdminConversationsView' @@ -57,6 +58,7 @@ export const importMap = { "/components/admin/CalendarNavLink#CalendarNavLink": CalendarNavLink_4d06fac03127b339306d5f5ecb073251, "/components/admin/AnalyticsNavLink#AnalyticsNavLink": AnalyticsNavLink_29fa5c324cecba6cafb11381cbfac962, "/components/admin/ConversationsNavLink#ConversationsNavLink": ConversationsNavLink_67a2594cf28806bc76971e67212a6181, + "/components/admin/AdminWelcome#AdminWelcome": AdminWelcome_b3c24bec21c663ad093f131aaae3d0a6, "/components/admin/AdminCalendarView#AdminCalendarView": AdminCalendarView_a2760c3ae85f4fd51d79a465baaf023a, "/components/admin/AdminAnalyticsView#AdminAnalyticsView": AdminAnalyticsView_55d38702e4d86d52435b5ac4df3f55d0, "/components/admin/AdminConversationsView#AdminConversationsView": AdminConversationsView_180f5c400a066a0a8f248da44d961055, diff --git a/app/(payload)/api/admin/calendar-feed/regenerate/route.ts b/app/(payload)/api/admin/calendar-feed/regenerate/route.ts index 217c3fa..587bfcd 100644 --- a/app/(payload)/api/admin/calendar-feed/regenerate/route.ts +++ b/app/(payload)/api/admin/calendar-feed/regenerate/route.ts @@ -6,10 +6,10 @@ import { calendarFeedUrl, generateCalendarFeedToken, hashCalendarFeedToken } fro export const dynamic = "force-dynamic"; /** - * POST /api/admin/calendar-feed/regenerate — (re)generates Anna's personal + * POST /api/admin/calendar-feed/regenerate - (re)generates Anna's personal * ICS feed link. Only the new token's hash is stored; the plaintext URL is * returned exactly once in this response and can never be retrieved again - * — generating a new one immediately invalidates whichever link was issued + * - generating a new one immediately invalidates whichever link was issued * before (see collections/Users.ts). */ export async function POST(request: Request) { diff --git a/app/(payload)/api/analytics/track/route.ts b/app/(payload)/api/analytics/track/route.ts index bed5e99..6872698 100644 --- a/app/(payload)/api/analytics/track/route.ts +++ b/app/(payload)/api/analytics/track/route.ts @@ -23,7 +23,7 @@ function normalizeEventName(value: unknown): string | null { return trimmed; } -/** Hostname only (never path/query) — see collections/AnalyticsEvents.ts. */ +/** Hostname only (never path/query) - see collections/AnalyticsEvents.ts. */ function referrerHostFrom(referrer: unknown): string | undefined { if (typeof referrer !== "string" || !referrer) return undefined; try { @@ -35,7 +35,7 @@ function referrerHostFrom(referrer: unknown): string | undefined { /** * Public tracking beacon, called by lib/analytics/client.ts only after the - * visitor has granted statistics consent — but the client's own claim is + * visitor has granted statistics consent - but the client's own claim is * never trusted alone: this route re-checks the CookieSettings global's * `statistics.enabled` flag server-side before writing anything, so a * global kill-switch (or a stale/tampered client) can never bypass it. diff --git a/app/(payload)/api/auth/resend-verification/route.ts b/app/(payload)/api/auth/resend-verification/route.ts index af5b64c..69b69fe 100644 --- a/app/(payload)/api/auth/resend-verification/route.ts +++ b/app/(payload)/api/auth/resend-verification/route.ts @@ -11,7 +11,7 @@ import type { Customer } from "@/payload-types"; export const dynamic = "force-dynamic"; // Deliberately identical whether or not an account exists / is already -// verified — this endpoint must never let a caller find out either of +// verified - this endpoint must never let a caller find out either of // those things about an email address that isn't their own session. const GENERIC_RESPONSE = { ok: true, @@ -60,7 +60,7 @@ export async function POST(request: Request) { body = await request.json(); if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body"); } catch { - // no body — handled below as a missing email + // no body - handled below as a missing email } const email = typeof body?.email === "string" ? body.email.trim().toLowerCase() : ""; if (!email || email.length > 254) { diff --git a/app/(payload)/api/booking/[id]/accept-alternative/route.ts b/app/(payload)/api/booking/[id]/accept-alternative/route.ts index d59b124..b67b21e 100644 --- a/app/(payload)/api/booking/[id]/accept-alternative/route.ts +++ b/app/(payload)/api/booking/[id]/accept-alternative/route.ts @@ -38,7 +38,7 @@ export async function POST(request: Request, { params }: Args) { } // The beforeChange hook on booking-requests re-checks for conflicts - // (with an advisory lock) before allowing this to become "confirmed" — + // (with an advisory lock) before allowing this to become "confirmed" - // the alternative slot may have been taken by someone else since it was // proposed. await payload.update({ diff --git a/app/(payload)/api/booking/[id]/cancel/route.ts b/app/(payload)/api/booking/[id]/cancel/route.ts index 091935a..b211677 100644 --- a/app/(payload)/api/booking/[id]/cancel/route.ts +++ b/app/(payload)/api/booking/[id]/cancel/route.ts @@ -16,7 +16,7 @@ export async function POST(request: Request, { params }: Args) { if (!user || user.collection !== "customers") { return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 }); } - // Cancelling is deliberately allowed regardless of verification status — + // Cancelling is deliberately allowed regardless of verification status - // only creating/confirming a booking is a gated "protected" action. return await withBookingTransaction(payload, `booking:${id}`, async (req) => { diff --git a/app/(payload)/api/booking/request/route.ts b/app/(payload)/api/booking/request/route.ts index 6800427..8e0ba78 100644 --- a/app/(payload)/api/booking/request/route.ts +++ b/app/(payload)/api/booking/request/route.ts @@ -67,7 +67,7 @@ export async function POST(request: Request) { const end = new Date(start.getTime() + offer.durationMinutes * 60_000); // Re-validate the slot is actually free right now (defense against a - // stale slot list) — the hard, race-safe check still runs again when + // stale slot list) - the hard, race-safe check still runs again when // Anna confirms (see collections/BookingRequests.ts). const dayBounds = berlinDayBounds(start); return await withBookingTransaction(payload, dayBounds.key, async (req) => { diff --git a/app/(payload)/api/cron/analytics-cleanup/route.ts b/app/(payload)/api/cron/analytics-cleanup/route.ts index 718b962..ca1e88e 100644 --- a/app/(payload)/api/cron/analytics-cleanup/route.ts +++ b/app/(payload)/api/cron/analytics-cleanup/route.ts @@ -16,7 +16,7 @@ function safeEqual(a: string, b: string): boolean { /** * Intended to be called by an external cron job once daily, e.g.: * curl -H "Authorization: Bearer $CRON_SECRET" https://anouma.org/api/cron/analytics-cleanup - * (a second, separate cron entry from /api/cron/event-reminders — reuses + * (a second, separate cron entry from /api/cron/event-reminders - reuses * the same CRON_SECRET, no new secret needed) */ export async function GET(request: Request) { diff --git a/app/(payload)/api/health/route.ts b/app/(payload)/api/health/route.ts index c84832a..e29dda2 100644 --- a/app/(payload)/api/health/route.ts +++ b/app/(payload)/api/health/route.ts @@ -8,7 +8,7 @@ export const dynamic = "force-dynamic"; /** * Used by the Docker healthcheck (see docker-compose.yml) and by * setup.sh/deploy.sh to confirm the app can actually reach Postgres before - * being considered "up" — not just that the Node process is listening. + * being considered "up" - not just that the Node process is listening. */ export async function GET() { try { diff --git a/app/(payload)/api/meetings/[slug]/join/route.ts b/app/(payload)/api/meetings/[slug]/join/route.ts index 3596767..6588a33 100644 --- a/app/(payload)/api/meetings/[slug]/join/route.ts +++ b/app/(payload)/api/meetings/[slug]/join/route.ts @@ -57,7 +57,7 @@ export async function POST(request: Request, { params }: Args) { } const { user } = await payload.auth({ headers: request.headers }); - // Must check the collection, not just presence — a logged-in customer + // Must check the collection, not just presence - a logged-in customer // (collection "customers") must never be treated as host. const isHost = user?.collection === "users" && user.role === "admin"; @@ -81,7 +81,7 @@ export async function POST(request: Request, { params }: Args) { role: isHost ? "host" : "participant", accessVersion: access.accessVersion, ttlMs: Math.min(6 * 3600000, access.expiresAt - Date.now()), - // Only meaningful for participants — a host authenticates via a real + // Only meaningful for participants - a host authenticates via a real // session already, so there's no need to anchor a kick to it (and // hosts can't be kicked in the first place. clientId: isHost ? undefined : clientId, diff --git a/app/(payload)/custom.css b/app/(payload)/custom.css index 3bfcf3c..9b15daa 100644 --- a/app/(payload)/custom.css +++ b/app/(payload)/custom.css @@ -1,3 +1,383 @@ -/* Optional custom overrides for the Payload admin UI. Left empty intentionally — - the admin panel uses Payload's own default styling, only the public site - uses the ANOUMA design system (see app/(frontend)/globals.css). */ +@font-face { + font-family: "Anouma Admin"; + src: url("/fonts/baar-antropos.ttf") format("truetype"); + font-display: swap; + font-style: normal; + font-weight: 100 900; +} + +:root { + --anouma-ink: #342c31; + --anouma-plum: #66505f; + --anouma-mauve: #8b616d; + --anouma-rose: #d19ca1; + --anouma-cream: #f7f2ed; + --anouma-paper: #fffdfb; + --anouma-line: #e4d9d2; + --font-body: "Anouma Admin", Inter, ui-sans-serif, system-ui, sans-serif; + --font-mono: "SFMono-Regular", Consolas, monospace; +} + +html, +body { + background: var(--anouma-cream); + color: var(--anouma-ink); + font-family: var(--font-body); +} + +body, +button, +input, +textarea, +select { + font-family: var(--font-body); +} + +/* The left navigation is intentionally dark and quiet. Content stays light. */ +.nav { + background: var(--anouma-ink); + border-right: 1px solid #4b3f46; + color: #f8f2ed; +} + +.nav__header { + border-bottom: 1px solid #4b3f46; + padding: 24px 20px 20px; +} + +.nav__header a, +.nav__header button { + color: #fffaf6; +} + +.nav__links { + padding: 18px 12px; +} + +.nav-group { + margin: 0 0 18px; +} + +.nav-group__label, +.nav-group__toggle { + color: #bdaeb4; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.nav-group__label { + padding: 0 10px 8px; +} + +.nav__link { + border-radius: 4px; + color: #f8f2ed; + font-size: 14px; + margin: 2px 0; + min-height: 38px; + padding: 9px 10px; +} + +.nav__link:hover, +.nav__link:focus-visible, +.nav__link.active { + background: #4c3d47; + color: #fff; +} + +.nav__link.active { + box-shadow: inset 3px 0 0 var(--anouma-rose); +} + +.nav__controls { + border-top: 1px solid #4b3f46; + padding: 14px 12px; +} + +.app-header, +.step-nav { + background: var(--anouma-paper); + border-bottom: 1px solid var(--anouma-line); +} + +.template-default__wrap, +.dashboard__wrap, +.collection-list__wrap, +.edit-view__wrap { + background: var(--anouma-cream); +} + +.template-default__wrap { + min-height: 100vh; +} + +.dashboard__header, +.collection-list__header, +.edit-view__header { + border-bottom: 1px solid var(--anouma-line); + margin-bottom: 26px; + padding-bottom: 18px; +} + +.dashboard__header h1, +.collection-list__header h1, +.edit-view__header h1 { + color: var(--anouma-ink); + font-size: clamp(26px, 3vw, 38px); + font-weight: 600; + letter-spacing: -0.025em; +} + +.card, +.dashboard__card, +.table-wrap, +.collection-list__table-wrap, +.field-type, +.document-fields__main { + border-color: var(--anouma-line); +} + +.card, +.dashboard__card, +.table-wrap { + border-radius: 6px; + box-shadow: 0 8px 24px rgb(52 44 49 / 4%); +} + +.btn { + border-radius: 4px; + font-weight: 600; +} + +.btn--style-primary { + --bg-color: var(--anouma-mauve); + --hover-bg: var(--anouma-plum); +} + +input, +textarea, +select, +.rs__control { + border-color: #d7c9c1; + border-radius: 4px; +} + +input:focus, +textarea:focus, +select:focus, +.rs__control--is-focused { + border-color: var(--anouma-mauve); + box-shadow: 0 0 0 2px rgb(139 97 109 / 12%); +} + +.field-label, +.field-label label { + color: var(--anouma-plum); + font-weight: 650; +} + +.description, +.field-description { + color: #7b6b72; +} + +.table thead th, +.table th { + background: #f1e9e4; + color: var(--anouma-plum); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.table tbody tr:hover { + background: #fbf7f4; +} + +.anouma-admin-welcome { + background: var(--anouma-paper); + border: 1px solid var(--anouma-line); + border-left: 4px solid var(--anouma-mauve); + border-radius: 6px; + box-shadow: 0 12px 32px rgb(52 44 49 / 5%); + margin: 0 0 28px; + padding: 28px; +} + +.anouma-admin-welcome__intro { + max-width: 680px; +} + +.anouma-admin-eyebrow { + color: var(--anouma-mauve); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.16em; + margin: 0 0 8px; + text-transform: uppercase; +} + +.anouma-admin-welcome h1 { + color: var(--anouma-ink); + font-size: clamp(25px, 3vw, 36px); + font-weight: 600; + letter-spacing: -0.03em; + margin: 0; +} + +.anouma-admin-welcome__intro > p:last-child { + color: #776870; + font-size: 15px; + line-height: 1.6; + margin: 10px 0 0; +} + +.anouma-admin-quicklinks { + display: grid; + gap: 10px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-top: 26px; +} + +.anouma-admin-quicklink { + background: #faf5f1; + border: 1px solid var(--anouma-line); + border-radius: 4px; + color: var(--anouma-ink); + display: flex; + flex-direction: column; + min-height: 154px; + padding: 16px; + text-decoration: none; + transition: border-color 120ms ease, background 120ms ease, transform 120ms ease; +} + +.anouma-admin-quicklink:hover, +.anouma-admin-quicklink:focus-visible { + background: #fffaf7; + border-color: var(--anouma-mauve); + color: var(--anouma-ink); + transform: translateY(-2px); +} + +.anouma-admin-quicklink__number { + color: var(--anouma-rose); + font-family: var(--font-mono); + font-size: 12px; + margin-bottom: 24px; +} + +.anouma-admin-quicklink__kicker { + color: var(--anouma-mauve); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.anouma-admin-quicklink strong { + font-size: 17px; + font-weight: 650; + margin-top: 5px; +} + +.anouma-admin-quicklink > span:last-child { + color: #7b6b72; + font-size: 12px; + line-height: 1.45; + margin-top: 6px; +} + +.anouma-custom-nav-item, +.anouma-custom-nav-action { + margin: 0 0 6px; + padding: 0; +} + +.anouma-custom-nav-link, +.anouma-custom-nav-button { + align-items: center; + border: 1px solid #5a4a55; + border-radius: 4px; + display: flex; + font-size: 13px; + font-weight: 650; + gap: 10px; + min-height: 38px; + padding: 8px 10px; + text-decoration: none; + width: 100%; +} + +.anouma-custom-nav-link { + color: #fffaf6; +} + +.anouma-custom-nav-link:hover, +.anouma-custom-nav-link:focus-visible { + background: #4c3d47; + border-color: #7d6371; + color: #fff; +} + +.anouma-custom-nav-link__icon { + color: var(--anouma-rose); + font-family: var(--font-mono); + font-size: 10px; +} + +.anouma-custom-nav-button { + background: var(--anouma-mauve); + border-color: var(--anouma-mauve); + color: #fff; + cursor: pointer; + justify-content: flex-start; +} + +.anouma-custom-nav-button:hover, +.anouma-custom-nav-button:focus-visible { + background: var(--anouma-plum); + border-color: var(--anouma-plum); +} + +.anouma-custom-nav-button:disabled { + cursor: wait; + opacity: 0.7; +} + +.anouma-custom-nav-input { + background: #fffaf6; + border: 1px solid #d7c9c1; + border-radius: 3px; + color: var(--anouma-ink); + font-family: var(--font-mono); + font-size: 10px; + margin-top: 6px; + padding: 6px 7px; + width: 100%; +} + +.anouma-custom-nav-error { + color: #f1b5b5; + font-size: 11px; + line-height: 1.4; + margin: 6px 0 0; +} + +@media (max-width: 1100px) { + .anouma-admin-quicklinks { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 700px) { + .anouma-admin-welcome { + padding: 20px; + } + + .anouma-admin-quicklinks { + grid-template-columns: 1fr; + } +} diff --git a/app/global-not-found.tsx b/app/global-not-found.tsx index a77363f..73f41f8 100644 --- a/app/global-not-found.tsx +++ b/app/global-not-found.tsx @@ -7,7 +7,7 @@ import { ImagePlaceholder } from "@/components/ImagePlaceholder"; import { siteConfig } from "@/lib/site"; import "./(frontend)/globals.css"; -// Required because the app has two root layouts — (frontend) and (payload) — +// Required because the app has two root layouts - (frontend) and (payload) - // so there is no single layout Next.js could compose a 404 page from. // See node_modules/next/dist/docs/.../not-found.md ("global-not-found.js"). @@ -36,7 +36,7 @@ export default function GlobalNotFound() { > {/* This page is always statically prerendered, so it can't read live - CMS data — the mega menu is intentionally omitted here. */} + CMS data - the mega menu is intentionally omitted here. */}
@@ -67,7 +67,7 @@ export default function GlobalNotFound() {
- {/* Minimal static footer — the full Footer reads live CMS data, + {/* Minimal static footer - the full Footer reads live CMS data, which isn't available on this always-static 404 page. */}