Files
immich-folio/docs/superpowers/plans/2026-08-15-portfolio-features.md
Ralksta 10ec68f493 release: v0.12.0 (#520)
Forty-one commits since v0.11.0. See CHANGELOG.md for the full list and the upgrade notes.
2026-08-23 23:18:14 +02:00

408 lines
25 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Portfolio-Features: Auffindbarkeit, Zoom, Sequenzierung, Ortsauflösung
> **For agentic workers:** Steps use checkbox (`- [ ]`) syntax for tracking. Die vier Tasks sind
> **voneinander unabhängig** und können einzeln umgesetzt, getestet und gemerged werden.
**Goal:** Vier Vorschläge aus [`brainstorm-2026-08-15.md`](../../brainstorm-2026-08-15.md) umsetzen —
E (Auffindbarkeit), B2 (1:1-Zoom), B1 (Sequenzierung), C1 (Ortsauflösung). Reihenfolge nach
steigendem Risiko: erst zwei abgeschlossene Kleinigkeiten, dann der größte Eingriff, dann ein
Datenschutz-Feature mit klarer Angriffsfläche.
**Architecture:** Vier getrennte Eingriffe ohne gemeinsame Dateien — bis auf `lib/config/schema.ts`
und `lib/config/index.ts`, die von B1 und C1 beide angefasst werden (verschiedene Felder, kein
inhaltlicher Konflikt). Jede neue Per-Album-Option folgt dem etablierten Muster: ein
`Record<albumId, X>` in `AppConfig`, befüllt in `deriveGallery()`, genau wie `albumGrids` und
`albumCoverPositions` es seit #431 vormachen.
**Tech Stack:** Next.js 16 (App Router, React 19), TypeScript strict, Vitest 4 (node environment),
Playwright, Vanilla CSS, keine neuen Laufzeit-Dependencies.
---
## Global Constraints
- **Keine neuen Runtime-Dependencies.** Das Projekt hat sieben; das bleibt so.
- **TypeScript strict** — kein untypisiertes `any`; Suppressions nur als `@ts-expect-error` mit Begründung.
- **Rohe Immich-UUIDs dürfen nie im Client-HTML/JS landen.** Immer `imageUrl()` / `encodeAssetId()`.
- **Vanilla CSS**, Tokens aus `app/tokens.css`.
- **Formatierung:** nur geänderte Dateien (`npx prettier --write <datei>`), nie `npm run format` im Root.
- **Vor jedem Commit grün:** `npm run test:unit`, `npx tsc --noEmit`, `npm run lint`, `npm run build`.
- **Baseline vor Beginn:** 443 Tests in 35 Dateien. Jede Task nennt die erwartete neue Summe.
- **Commit-Stil:** Conventional Commits. Experimentelle Optionen als `EXPERIMENTAL` in YAML-Kommentar
und Admin-Label markieren, so wie #431 es gemacht hat.
- **Neue Env-Variablen** gehören in `lib/env.ts` **und** `.env.local.example`.
- **Vitest läuft im `node`-Environment** — es gibt kein jsdom. Komponentenlogik ist deshalb nur
testbar, wenn die _reine_ Berechnung in einem eigenen Modul liegt; die DOM-Verdrahtung deckt
Playwright ab. Das ist bei B1 und B2 jeweils eingeplant und ausdrücklich **kein** Verstoß gegen
die CLAUDE.md-Regel (die verbietet, _Route_-Logik nur der Testbarkeit wegen nach `lib/` zu ziehen —
hier geht es um echte, wiederverwendbare Geometrie- bzw. Gruppierungsfunktionen).
---
## Task E — Auffindbarkeit: Site-URL, `robots.txt`, `sitemap.xml`, JSON-LD
**Aufwand:** S (die Vorbedingung E1 und die Admin-UI machen daraus mehr als das im Brainstorm
geschätzte XS.)
### Befund vorab: es fehlt eine Site-URL
`lib/env.ts` kennt keine `SITE_URL`, `settings.yaml` keine `url:`, und `app/layout.tsx` setzt
**keine `metadataBase`**. OG-Bilder werden heute als relativer Pfad angegeben
(`app/layout.tsx:57`). Für `sitemap.xml` ist das ein hartes Hindernis: Der Sitemap-Standard
verlangt absolute URLs, und `app/sitemap.ts` läuft ohne Request-Kontext — der Host lässt sich also
nicht aus Headern ableiten.
**Entscheidung:** `url` in `settings.yaml`, überschreibbar durch `SITE_URL`. Env gewinnt — dasselbe
Präzedenzmuster wie bei Titel und Credentials. In `settings.yaml`, **weil der Wert im Admin-Panel
setzbar sein muss**: Wer per `docker run` startet, soll die Site-URL nicht in einer Env-Variable
nachtragen und neu starten müssen, sondern sie dort eingeben, wo er ohnehin Titel und SEO pflegt.
### Die Falle dabei — und warum E1b im Plan steht
Env gewinnt über YAML. Setzt jemand `SITE_URL` **und** trägt im Admin etwas anderes ein, speichert
das Panel brav, ändert aber nichts — der Wert wird stillschweigend überstimmt. Genau dieser Zustand
existiert heute schon unbemerkt bei `SITE_TITLE`/`SITE_SUBTITLE`: `lib/env.ts:98-99` schlägt
`settings.yaml` und **das Admin-Panel sagt es nirgends**. Eine repo-weite Suche findet in
`SettingsEditor.tsx`, `AdminDashboard.tsx` und `/api/admin/status` keinen einzigen Hinweis auf
Env-Überschreibungen.
Weil dieses Feld nun ausdrücklich über die UI bedienbar sein soll, wird die Anzeige mitgebaut —
klein, generisch und für die bestehenden Felder gleich mitverwendet.
### Schritte
- [ ] **E1 — Site-URL einführen**
- `SITE_URL` in `lib/env.ts` (`interface Env` + `parseEnv()`), validiert über `new URL()`,
trailing Slash entfernt — analog zur bestehenden Behandlung von `IMMICH_API_URL:26-32`.
- `url?: string` in `SettingsYaml` (`lib/config/schema.ts`) und `siteUrl: string` in `AppConfig`.
- Auflösung in `lib/config/index.ts`: `env.SITE_URL || settings.url || ''`.
- `metadataBase` in `app/layout.tsx` setzen, wenn `siteUrl` nicht leer ist.
- In `settings.yaml.example` dokumentieren, inklusive des Hinweises, dass ohne diesen Wert
Sitemap und JSON-LD entfallen.
- [ ] **E1a — Feld im Admin-Panel** (`app/admin/components/SettingsEditor.tsx`, Sektion `seo`,
`SETTINGS_SECTIONS:197`)
- Textfeld **„Site URL"** als erstes Feld der SEO-Sektion, über „SEO Title" — es ist die
Voraussetzung für alles andere dort. Anbindung wie die Nachbarfelder: `update('url', …)`.
(Achtung: `url` liegt auf oberster Ebene, nicht unter `seo.` — sonst landet es beim Auflösen
an der falschen Stelle.)
- Placeholder `https://folio.example.com`, Hilfetext: wofür der Wert gebraucht wird (Sitemap,
`robots.txt`, JSON-LD, absolute OG-Bild-URLs).
- **Validierung im Feld, nicht erst beim Speichern:** muss mit `http://` oder `https://`
beginnen und über `new URL()` parsebar sein; trailing Slash beim Speichern abschneiden, damit
aus Nutzersicht egal ist, ob er einen setzt. Ungültige Eingabe blockiert das Speichern mit
einer Meldung am Feld — eine kaputte Site-URL erzeugt sonst eine Sitemap voller kaputter Links.
- Leeres Feld ist ein gültiger Zustand (= Funktionen aus, wie heute), keine Fehlermeldung.
- Ein Hinweis in der SEO-Sektion, solange das Feld leer ist: „Ohne Site URL werden `sitemap.xml`
und JSON-LD nicht ausgeliefert." Sonst sucht jemand den Fehler an der falschen Stelle.
- [ ] **E1b — Env-Überschreibungen im Panel sichtbar machen** (klein, generisch)
- `GET /api/admin/settings` (`app/api/admin/settings/route.ts:18-19`) gibt zusätzlich
`envLocked: string[]` zurück — die Namen der Felder, die gerade aus der Umgebung kommen.
Anfangs: `url` (wenn `SITE_URL` gesetzt), `title` (`SITE_TITLE`), `subtitle` (`SITE_SUBTITLE`).
**Nur Feldnamen, keine Werte** — die Route soll keine Env-Inhalte spiegeln, die sonst nirgends
im Panel stehen.
- `SettingsEditor` rendert betroffene Felder deaktiviert mit dem Zusatz „Von der Umgebungsvariable
`SITE_URL` gesetzt" statt eines editierbaren Eingabefelds. Damit ist der stille Fehlschlag
weg — beim neuen Feld und bei Titel/Untertitel gleich mit.
- Das ist die erste Stelle dieser Art im Projekt; entsprechend klein halten (eine Liste, ein
`disabled`, ein Hinweistext) und nicht zu einem Mechanismus ausbauen.
- [ ] **E2 — `app/robots.ts`**
- `Disallow` für `/admin`, `/install`, `/api`.
- `seo.noIndex: true` → `Disallow: /` für alle Agents.
- `sitemap:`-Verweis nur, wenn `siteUrl` gesetzt ist.
- [ ] **E3 — `app/sitemap.ts`**
- Gibt `[]` zurück, wenn `siteUrl` leer ist oder `seo.noIndex` gilt — kein halber Zustand.
- Enthalten: `/`, `/about` (nur wenn `aboutEnabled`), `/map` (nur wenn `config.map`),
`/journal` und je Eintrag `/journal/<slug>`, Subpages und Alben.
- **Ausschlussliste — der eigentliche Inhalt dieser Task:**
| Ausgeschlossen | Quelle |
| ----------------------------------------------------- | ------------------------------------------------------------- |
| Subpage mit `password` | `SubpageConfig.password` |
| Subpage mit `hidden: true` | `SubpageConfig.hidden` |
| Subpage mit `enabled: false` | `SubpageConfig.enabled` |
| Album mit eigenem Passwort | `config.albumPasswords` |
| Album unterhalb einer geschützten/versteckten Subpage | Vererbung, siehe unten |
| Journal-Draft | `frontmatter.draft` |
| Journal-Eintrag mit Passwort | Frontmatter |
| `/impressum` | trägt bereits `robots: noindex` (`app/impressum/page.tsx:14`) |
- **Vererbung nicht vergessen:** Ein öffentliches Album unter einer passwortgeschützten Subpage
ist über `/<subpage>/<album>` nicht erreichbar. Die Prüfung muss den Pfad entlanglaufen, nicht
nur das Album ansehen.
- `lastModified` aus `album.updatedAt` bzw. dem Journal-Datum.
- [ ] **E4 — JSON-LD**
- `Person` bzw. `ProfilePage` auf `/about`, gespeist aus dem Frontmatter von `content/about.md`.
- `ImageObject` mit `creator` und optional `license` auf Album-Detailseiten.
- **CSP-Falle:** `proxy.ts` setzt eine CSP mit Per-Request-Nonce und reicht sie über den
`x-nonce`-Header an die Seiten. Ein `<script type="application/ld+json">` **muss** diese Nonce
tragen, sonst blockt der Browser es still. Im Browser mit gesetzter CSP verifizieren, nicht nur
im Markup nachsehen.
- `license` als optionales Feld in `settings.yaml` (`seo.license`), leer = weglassen.
- [ ] **E5 — Tests**
- `lib/__tests__/sitemap.test.ts`: **tabellengetrieben über die Ausschlusskategorien**, nicht als
Einzelfälle — dann schlägt der Test auch bei einer künftig hinzugefügten Sichtbarkeitsregel an.
Vorbild: `app/api/admin/__tests__/admin-guards.test.ts`. Zusätzlich: leere Sitemap bei fehlender
`siteUrl`; `noIndex` unterdrückt alles.
- `lib/__tests__/site-url.test.ts`: Präzedenz `SITE_URL` > `settings.url` > leer; trailing Slash
wird entfernt; ungültige URL führt nicht zum Absturz, sondern zu „nicht gesetzt".
- `app/api/admin/__tests__/settings-env-lock.test.ts`: `envLocked` enthält `url` genau dann, wenn
`SITE_URL` gesetzt ist — und die Antwort enthält **nirgends den Env-Wert selbst**.
- Erwartet: **+18 bis +24 Tests** (461–467 gesamt).
**Verifikation:**
1. **Ohne** `SITE_URL`: Im Admin unter SEO die Site URL eintragen, speichern, dann `/sitemap.xml`
und `/robots.txt` abrufen — beide müssen ohne Neustart die neue Domain führen (`invalidateConfigCache()`
läuft beim Speichern bereits). Feld leeren → Sitemap wird wieder leer, Hinweistext erscheint.
2. Ungültige Eingaben gegenprüfen: `folio.example.com` ohne Schema und `https://` allein müssen am
Feld abgewiesen werden, nicht erst beim Rendern der Sitemap auffallen.
3. **Mit** gesetztem `SITE_URL`: Das Feld muss deaktiviert sein und die Herkunft nennen — dasselbe
für Titel/Untertitel bei gesetztem `SITE_TITLE`.
4. Eine passwortgeschützte Subpage anlegen und prüfen, dass weder ihr Slug noch ihre Alben in der
Sitemap auftauchen.
5. JSON-LD im Rich-Results-Test von Google gegenprüfen; Browser-Konsole auf CSP-Fehler ansehen.
**Commits:** `feat(seo): add a site URL setting, editable in the admin panel` ·
`feat(seo): add robots.txt, sitemap.xml and JSON-LD`
---
## Task B2 — 1:1-Zoom im Lightbox
**Aufwand:** S
### Befund
Der Lightbox rendert `current.previewUrl` (`components/Lightbox.tsx:237`), während sein Dateikopf
„Full-resolution image display" verspricht (`:5`). `imageUrl()` steht per Default auf `'preview'`
(`lib/urls.ts:29`), und **keine Stelle in `app/`, `components/` oder `lib/urls.ts` fordert
`'original'` an** — die oberste Stufe aus `lib/imageSize.ts` ist im Frontend unerreichbar.
Als Default ist das richtig. Für Schärfebeurteilung fehlt der Weg dorthin.
### Schritte
- [ ] **B2.1 — `originalUrl` durchreichen**
- `originalUrl?: string` in `PhotoItem` (`app/[...path]/PhotoGrid.tsx:18-33`) — **optional**,
damit `JournalStudio.tsx:616-617` (Admin-Thumbnails über eine andere Route) unverändert bleibt.
- Befüllen an den zwei öffentlichen Stellen: `app/[...path]/page.tsx:117-118` und
`app/journal/[slug]/page.tsx:179-180`, jeweils `imageUrl(a.id, 'original')`.
- Fehlt `originalUrl`, wird der Zoom-Knopf nicht gerendert. Kein Fallback auf Preview — ein Zoom,
der auf ein Preview zoomt, ist genau die Verwechslung, die es zu beheben gilt.
- [ ] **B2.2 — Zoom-Geometrie als reines Modul** (`lib/zoom.ts`)
- `clampPan(offset, scale, viewport, natural)` → begrenzt den Bildausschnitt auf die Bildkanten.
- `zoomAt(point, scale, offset, factor)` → zoomt auf den Cursor/Pinch-Mittelpunkt statt auf die
Bildmitte. Das ist der Unterschied zwischen brauchbar und ärgerlich.
- Pure Funktionen, keine DOM-Abhängigkeit — direkt unit-testbar.
- [ ] **B2.3 — Verdrahtung im Lightbox**
- Zustand: `zoomed` (bool), `scale`, `offset`. Auslöser: Doppelklick/Doppeltipp, Pinch, Taste `z`;
zusätzlich ein sichtbarer Knopf neben dem Info-Toggle.
- **Das Original wird erst bei dieser Geste geladen**, nie vorher. Die bestehende
Nachbar-Vorladung (`Lightbox.tsx:96-105`) bleibt auf `previewUrl` und wird **nicht** angefasst —
sonst zieht sich jeder Besucher unbemerkt Originale.
- Ladeindikator, solange das Original unterwegs ist; das Preview bleibt so lange skaliert stehen.
- Verlassen: `Esc` (erste Stufe verlässt den Zoom, nicht den Lightbox), Doppelklick, Knopf.
- Zurücksetzen bei jedem Bildwechsel — sonst hängt der Zoomfaktor des vorherigen Bildes am nächsten.
- **Konflikte prüfen:** `useSwipe` (Pinch/Pan darf nicht als Wisch durchgehen) und
`AssetProtection` (`dragstart` ist global unterdrückt — Pan muss über Pointer-Events laufen,
nicht über natives Dragging).
- Tastatur: `+` / `-` / `0`, `aria-label` am Knopf, Zoomzustand über `aria-pressed`.
- [ ] **B2.4 — Kommentar korrigieren**
- `components/Lightbox.tsx:5` beschreibt eine Absicht, kein Verhalten. Auf „Preview-tier image,
with opt-in 1:1 zoom to the original" ändern.
- [ ] **B2.5 — Tests**
- `lib/__tests__/zoom.test.ts`: Clamping an allen vier Kanten, Zoom auf Punkt, Rückkehr auf
`scale: 1` zentriert das Bild wieder. Erwartet: **+10 bis +12 Tests**.
- `e2e/lightbox-zoom.spec.ts`: Zoom öffnet, lädt eine `?size=original`-URL (Request abfangen),
Pan bleibt in den Grenzen, Bildwechsel setzt zurück.
**Verifikation:** Netzwerk-Tab — beim Öffnen des Lightbox darf **keine** `size=original`-Anfrage
stehen, erst nach der Zoom-Geste. Auf einem Touchgerät gegenprüfen, dass Pinch nicht mehr als Wisch
zum nächsten Bild interpretiert wird.
**Commit:** `feat(lightbox): add opt-in 1:1 zoom against the original asset`
---
## Task B1 — Sequenzierung wie im Fotobuch
**Aufwand:** M — der größte Eingriff hier, und der einzige mit UI-Anteil im Admin.
### Befund
`PhotoGrid` bildet die Assets flach auf ein einziges `.photo-grid`-Element ab
(`app/[...path]/PhotoGrid.tsx:132-243`); das Layout gilt für alle gleich. `assetOrder`
(`lib/config/schema.ts:224`) bestimmt nur die Reihenfolge. Die Essay-Ansicht kennt `fullbleed`,
`wide` und Paare bereits (`lib/journal.ts`) — das Album nicht.
### Designentscheidung: Hinweise leben in `assetOrder`
Eine zweite, parallele Liste (`sequence:`) würde die Reihenfolge doppelt führen und damit
zwangsläufig auseinanderlaufen. Stattdessen wird `assetOrder` erweitert — Sequenzierung setzt
ohnehin `sort: manual` voraus:
```yaml
- 'album-uuid':
sort: manual
assetOrder:
- 'asset-opening': fullbleed # allein, volle Breite
- 'asset-a': pair # diese beiden nebeneinander,
- 'asset-b': pair # auf gleiche Höhe gebracht
- 'asset-c' # unverändert im normalen Raster
- break # bewusste Lücke
```
`deriveGallery()` erzeugt daraus **zwei** Strukturen: `albumManualOrders` bleibt exakt wie heute
eine reine UUID-Liste (`break` herausgefiltert), damit `lib/albumSort.ts` unverändert
weiterfunktioniert — und neu `albumSequences: Record<albumId, SequenceEntry[]>` mit den Hinweisen.
### Die Invariante, an der dieses Feature scheitern kann
Der Lightbox-Index und die Permalinks `#photo-N` sind **positional** über `displayedAssets`
(`app/[...path]/PhotoGrid.tsx:51-107`, `openLightbox(index)`). Blockbildung darf deshalb
**ausschließlich gruppieren, niemals umsortieren, nichts auslassen und nichts doppeln**. Wird das
verletzt, öffnet ein Klick das falsche Foto und jeder geteilte Permalink zeigt woanders hin — ein
Fehler, der beim Entwickeln kaum auffällt und beim Kunden sofort.
### Schritte
- [ ] **B1.1 — Schema & Parser**
- `SequenceHint = 'fullbleed' | 'pair' | 'break'`; `assetOrder?: Array<string | Record<string, string> | 'break'>`
in `AlbumEntryObject`.
- In `deriveGallery()` (`lib/config/index.ts:162-236`, direkt neben der `coverPosition`-Validierung):
unbekannte Hinweise **werfen** mit nennender Meldung — dieselbe Linie wie beim `sort`-Wert, wo
ein Tippfehler bewusst nicht still durchgeht.
- `albumManualOrders` bleibt unverändert UUID-only; `albumSequences` kommt dazu (beides in
`AppConfig`, `GalleryDerivation` und den drei Rückgabestellen in `lib/config/index.ts`).
- Ein einzelnes `pair` ohne Partner fällt auf normales Verhalten zurück, statt zu werfen —
beim Umsortieren im Admin ist dieser Zustand ein normaler Zwischenschritt.
- [ ] **B1.2 — Blockbildung als reines Modul** (`lib/sequence.ts`)
- `buildSequenceBlocks(assets, hints)` → `Array<{ kind: 'grid' | 'pair' | 'fullbleed' | 'break', items: Array<{ asset, index }> }>`.
- **Jedes Item trägt seinen ursprünglichen Index mit.** Das ist die technische Absicherung der
Invariante oben.
- Aufeinanderfolgende Assets ohne Hinweis landen in einem gemeinsamen `grid`-Block, damit das
bestehende Layout (Masonry, Justified …) darin unverändert greift.
- [ ] **B1.3 — Rendering**
- `PhotoGrid` rendert Blöcke statt einer flachen Liste; `openLightbox(item.index)` statt
`openLightbox(index)`.
- CSS: `.photo-sequence__pair` (zwei Spalten, auf gleiche Höhe normiert über die vorhandenen
Aspect-Ratios), `.photo-sequence__fullbleed`, `.photo-sequence__break`. Mobil kollabieren Paare
auf eine Spalte.
- Ohne Hinweise entsteht genau ein `grid`-Block — das Markup bleibt dann identisch zu heute.
**Das ist die Rückfallgarantie und gehört als Test festgeschrieben.**
- [ ] **B1.4 — Admin**
- `AssetOrderEditor.tsx` bekommt pro Foto eine Hinweis-Auswahl und einen Trenner-Einfügen-Knopf.
- Eine Andeutung der Blöcke in der Sortierliste (Paare zusammengefasst, Trenner als Linie).
**Ohne diese Vorschau ist das Feature im Admin unbenutzbar** — das ist die halbe Arbeit an der
Task, nicht das YAML-Schema.
- [ ] **B1.5 — Tests**
- `lib/__tests__/sequence.test.ts`: Indizes bleiben lückenlos und in Reihenfolge (die Invariante,
als expliziter Test über ein Album mit allen Hinweisarten); leere Hinweise → genau ein Block;
verwaistes `pair`; `break` am Anfang und am Ende; Hinweis auf ein Asset, das gar nicht im Album
ist.
- `lib/__tests__/config-sequence.test.ts`: Parser akzeptiert die neue Form, wirft bei unbekanntem
Hinweis, `albumManualOrders` bleibt UUID-only.
- Erwartet: **+20 bis +25 Tests**.
- `e2e/sequence.spec.ts`: Klick auf das dritte Foto in einem sequenzierten Album öffnet das dritte
Foto; `#photo-3` landet auf demselben.
**Verifikation:** Ein Album mit `fullbleed`, einem Paar und einem `break` anlegen, im Browser
ansehen, jedes Foto anklicken und den Lightbox-Zähler gegen die visuelle Position prüfen. Ein Album
**ohne** Hinweise gegen `git stash` vergleichen: das Markup muss identisch sein.
**Commit:** `feat(album): sequence photos into pairs, spreads and breaks (EXPERIMENTAL)`
---
## Task C1 — Ortsauflösung pro Album
**Aufwand:** S
### Befund, präzise
Die Map ist besser gebaut als erwartet: Passwortgeschützte Alben werden **vor** der Aggregation
gefiltert (`app/api/map/route.ts:63-90`), und `lib/mapService.ts:5-11` hält Alben ausdrücklich
getrennt, damit genau das möglich ist. Die öffentliche EXIF-Route gibt Koordinaten gar nicht heraus
— die Map ist die einzige öffentliche GPS-Fläche.
Die Lücke betrifft **öffentliche** Alben: Die Marker-Position ist der Mittelwert der tatsächlichen
Koordinaten (`app/api/map/route.ts:97-104`). Bei einem Album von _einem_ Ort — Garten, Studio,
Wohnung eines Kunden, empfindlicher Naturstandort — ist dieser Mittelwert genau dieser Ort. Der
Marker heißt nach der Stadt und steht auf dem Grundstück.
### Zwei Entwurfsentscheidungen, die vorab feststehen müssen
**1. Keine Geodaten-Abhängigkeit.** Für „Stadtzentrum" bräuchte es einen Ortsdatensatz. Stattdessen
wird die Koordinate **gerastert**: `city` auf 0,05° (~5 km), `country` auf 1° (~110 km). Das ist
dependency-frei, erklärbar und ausreichend — 5 km trennen zuverlässig ein Grundstück von einem
Stadtgebiet.
**2. Marker fassen mehrere Alben zusammen.** Liegen an einer Stadt ein `exact`- und ein
`city`-Album, gibt es trotzdem nur einen Marker. Regel: **die strengste Stufe unter den
beitragenden Alben gewinnt.** Sicherheitsentscheidungen fallen in die vorsichtige Richtung, und die
Alternative — Marker aufspalten — würde über die Kartenansicht verraten, dass ein Album
zurückhaltender eingestellt ist.
### Schritte
- [ ] **C1.1 — Konfiguration**
- `location?: 'exact' | 'city' | 'country' | 'hidden'` in `AlbumEntryObject` und in
`SubpageObjectValue`/`SubpageConfig`.
- `albumLocationModes: Record<string, LocationMode>` in `AppConfig`, befüllt in `deriveGallery()`
neben `albumCoverPositions`; unbekannter Wert wirft.
- Präzedenz wie beim Grid: global (neu: `map.locationDefault` in `settings.yaml`, Default
`exact` = heutiges Verhalten) < Subpage < Album.
- [ ] **C1.2 — `lib/mapService.ts`**
- Alben mit `hidden` überspringen, bevor gebucketet wird — das ist die einzige Stufe, die gar
nicht erst in den Cache soll.
- `locationMode` auf `MapAlbumEntry` mitführen. **Nicht** hier schon runden: gerundete Werte
aufsummieren und danach mitteln ergibt wieder ungerundete Koordinaten.
- [ ] **C1.3 — `app/api/map/route.ts`**
- Strengste Stufe der sichtbaren Alben je Marker bestimmen.
- Mittelwert bilden wie heute, **danach** rastern: `city` → 0,05°, `country` → 1°, `exact` → unverändert.
- Bei `country` das Stadtlabel weglassen (die Popup-Überschrift wäre sonst genauer als die Position).
- Ist ein Marker nach dem Ausschluss leer, entfällt er — der Pfad existiert bereits (`allowedAlbums.length === 0`).
- [ ] **C1.4 — Admin & Doku**
- Auswahlfeld pro Album und pro Subpage im `PageBuilder`, Standard in `SettingsEditor`.
- `gallery.yaml.example` und `settings.yaml.example` ergänzen, mit einem Satz dazu, **warum** es
das gibt.
- [ ] **C1.5 — Tests** (`lib/__tests__/map-location.test.ts`)
- Rasterung: eine Koordinate auf einem Grundstück landet bei `city` sichtbar daneben, bleibt aber
in derselben Stadt; `exact` verändert nichts.
- Strengste Stufe gewinnt (alle sechs Paarungen).
- `hidden` erscheint in keiner Ausgabe — auch dann nicht, wenn ein anderes Album dieselbe Stadt beisteuert.
- Präzedenz global < Subpage < Album.
- Erwartet: **+14 bis +18 Tests**.
**Verifikation:** Ein Album mit GPS-Daten von einem einzigen Ort anlegen, `/api/map` einmal mit
`exact` und einmal mit `city` abrufen und die Koordinaten vergleichen; auf der Karte gegenprüfen,
dass der Marker die Stadt trifft, aber nicht mehr die Adresse. Zusätzlich sicherstellen, dass ein
`hidden`-Album auch nach einem Cache-Durchlauf nicht auftaucht (`POST /api/admin/reload`).
**Commit:** `feat(map): make location precision configurable per album`
---
## Reihenfolge und Zusammenhänge
```
E (Auffindbarkeit) ── unabhängig, bringt die Site-URL, die später auch ein Feed bräuchte
B2 (Zoom) ── unabhängig, erste Stelle die Originale ausliefert
B1 (Sequenzierung) ── größter Eingriff, berührt die Lightbox-Index-Invariante
C1 (Ortsauflösung) ── unabhängig, Datenschutz
↑ B1 und C1 fassen beide lib/config/{schema,index}.ts an,
aber verschiedene Felder — nacheinander mergen, nicht parallel
```
Vier PRs, in dieser Reihenfolge. E und B2 sind jeweils an einem Tag abgeschlossen und liefern
sofort etwas Sichtbares; B1 braucht am meisten Sorgfalt und sollte nicht unter Zeitdruck; C1 ist
klein, aber sicherheitsrelevant und verdient einen eigenen, ruhigen Review.
**Erwartete Testsumme am Ende:** 443 → etwa **516**.
## Was dieser Plan bewusst nicht enthält
Aus dem Brainstorm bleiben offen und sind hier **nicht** eingeplant: A1 (Client-Links), A2
(Anfrage-Formular), A3 (Proofing-Notizen), B3 (Farbmanagement-Messung), C2 (terminierte
Veröffentlichung) und D (RSS-Feed). Falls C2 und D später kommen: Beide brauchen dieselbe
Sichtbarkeitsprüfung, die in **E3** entsteht. Sie sollte dort so geschnitten werden, dass ein
zweiter Aufrufer sie wiederverwenden kann, statt sie zu kopieren.