release: v0.12.0 (#520)

Forty-one commits since v0.11.0. See CHANGELOG.md for the full list and the upgrade notes.
This commit is contained in:
Ralksta
2026-08-23 23:18:14 +02:00
committed by GitHub
parent 2f546f440d
commit 10ec68f493
119 changed files with 6888 additions and 758 deletions

View File

@@ -72,7 +72,8 @@ Group albums under custom URL paths (e.g. `/japan`, `/wedding-smith`).
- **Name** — navigation label; the URL slug is derived from it
- **Title / Subtitle** — page heading and subline
- **Password** — protect the whole subpage
- **Grid override** — layout settings for this page
- **Grid override** — layout settings for the photos on this page
- **Album Cover Grid** — how the album covers are tiled: columns (empty = the site-wide grid setting) and gap in px (empty = the theme's own cover spacing). Hidden in essay mode
- **Enabled** — take the page offline without deleting it
- **Hidden** _(experimental)_ — unlisted: reachable by direct link, absent from the navigation
- **Essay** — turn the page into a photo essay, with a block editor for the text

View File

@@ -271,10 +271,11 @@ gibt, überhaupt PRs zu stellen.
---
### B8. Kleinigkeit: Projektname in `package.json` — **XS**
### B8. Kleinigkeit: Projektname in `package.json` — **XS** — ✅ erledigt
`"name": "immich-lightbox"` bei einem Projekt namens Immich Folio. Fällt jedem
Contributor beim ersten `npm install` auf.
Contributor beim ersten `npm install` auf. Heißt seit dem Housekeeping-PR
`immich-folio`.
---

View File

@@ -0,0 +1,396 @@
# Feature-Brainstorm: Kundengeschäft, Handwerk, Vertrauen
_Stand: 2026-08-15 · Basis: v0.11.0 (dev, nach #431 und #460) · Fokus: was ein
Fotograf im Alltag mit dieser Software tut_
Dieses Dokument ergänzt [`ideas.md`](ideas.md) (Präsentation),
[`brainstorm.md`](brainstorm.md) (Mai 2026) und
[`brainstorm-2026-08-05.md`](brainstorm-2026-08-05.md) (Verbreitung & Betrieb).
Es wiederholt deren Ideen **nicht**. Wo es einen bereits gelisteten Punkt aufgreift,
steht es ausdrücklich dabei (Abschnitt D).
Die Perspektive hier ist eine andere als in den Vorgängern: nicht „was fehlt der
Software", sondern **„was tut ein Fotograf, das Folio heute nicht begleitet"** —
Bilder an Kunden übergeben, eine Serie sequenzieren, entscheiden, was die Welt über
den Aufnahmeort erfahren darf.
Alle Befunde sind gegen den Code-Stand geprüft; Aufwand: XS (Stunden), S (1 Tag),
M (2–5 Tage), L (Wochen).
---
## Was seit den alten Listen dazugekommen ist
Damit klar ist, warum die folgenden Ideen neu sind: seit #431 und #460 leben bereits
Fokuspunkt fürs Cover, Bildunterschriften aus der Immich-Description,
`justified`-Layout, unlisted Subpages, externe Nav-Links, Cover-Splash-Hero und
per-Album-Grid. Früher kamen Wasserzeichen, Rechtsklick-Schutz, Client-Proofing
inklusive Share-Link und Mailto-Export, Map, Video, Journal/Essays, Setup-Assistent,
Error-Boundaries und der Stale-Cache dazu.
Weiterhin offen aus den alten Listen und hier bewusst _nicht_ nochmal ausgeführt:
Slideshow, Fullscreen, Download-Button, Copy-Link, Photo of the Day, ISR,
EXIF-Statistikseite, Before/After-Slider, i18n, Timeline, Smart Search, PWA,
Demo-Modus, Config-Doctor, Platten-Cache, Metriken, Multi-Site.
---
## A. Kundengeschäft
Folio kann Kunden heute Bilder _zeigen_ (Passwort, Proofing) — aber der Weg
davor und danach fehlt: wie der Kunde hereinkommt und wie er sich zurückmeldet.
### A1. Ablaufende, widerrufbare Client-Links — **M, hoher Impact**
**Befund:** Kundenzugang ist heute ein statisches Passwort in `gallery.yaml`, geprüft
gegen ein HMAC-Cookie (`lib/auth.ts:44-46`, `lb_auth_album_<slug>`). Daraus folgt
dreierlei: Das Passwort läuft nie ab. Es lässt sich nicht für _einen_ Empfänger
zurückziehen, sondern nur für alle gleichzeitig ändern. Und der Kunde muss es tippen —
in der Praxis steht es deshalb ohnehin in derselben Mail wie der Link, was die
Zugangskontrolle auf ein Feigenblatt reduziert.
**Idee:** Zugangs-Links, die im Admin erzeugt werden:
```
https://folio.example/hochzeit-mueller?k=<signiertes-token>
```
Das Token trägt Slug, Ablaufdatum und eine Link-ID; signiert mit derselben Mechanik
wie die Admin-Session (`lib/admin/auth.ts`). Beim ersten Aufruf wird es gegen genau
das Cookie eingetauscht, das heute schon existiert — der gesamte Rest der
Zugangsprüfung bleibt unangetastet. Im Admin: eine Liste mit Label („Brautpaar",
„Location"), Ablaufdatum und einem Widerrufen-Knopf.
**Warum das zieht:** Es ist der Unterschied zwischen „hier ist ein Passwort" und dem,
was Kunden von Pixieset oder Pic-Time kennen. Für Hochzeits- und Auftragsfotografen
ist die Galerie-Übergabe der eigentliche Arbeitsschritt, nicht das Portfolio.
**Zwei Trade-offs, die benannt gehören:**
- **Widerruf braucht Zustand.** Ein rein signiertes Token kann man nicht
zurücknehmen, nur ablaufen lassen. Echter Widerruf verlangt eine Liste ausgegebener
Link-IDs in `content/` — sonst ist das Wort „widerrufbar" gelogen. Das ist der
erste Fall, in dem Folio Besucher-bezogenen Zustand speichert; die Liste sollte
entsprechend klein und selbstaufräumend sein.
- **Bild-URLs bleiben unberührt.** Asset-Tokens sind bewusst deterministisch, damit
Browser cachen können. Ein widerrufener Zugang macht bereits ausgelieferte
Bild-URLs also nicht ungültig — derselbe Trade-off, der in
[`deep-dive-2026-08-05.md`](deep-dive-2026-08-05.md) für die Allowlist notiert ist.
Er muss hier _dokumentiert_ werden, sonst erwartet jemand mehr, als das Feature hält.
---
### A2. Anfrage-Formular statt `mailto:` — **S–M**
**Befund:** Es gibt kein Formular. Kontakt läuft über `mailto:` im Footer
(`components/Footer.tsx:60`) und im Proofing-Modal
(`components/ProofingModal.tsx:43`). Das hat zwei Kosten, die Fotografen real treffen:
Die Adresse steht im Klartext im HTML und wird abgegrast, und auf Geräten ohne
konfigurierten Mail-Client passiert beim Klick schlicht nichts — der Kunde denkt, der
Link sei kaputt.
**Idee:** Eine rate-limitierte Route (`lib/rate-limit.ts` ist da, eigener Namensraum
`contact:`, niedriges Limit in der Größenordnung der Auth-Routen) plus Honeypot-Feld.
Zustellung wahlweise per SMTP oder per Webhook. Und — das ist der eigentliche Wert —
ein **„Zu diesem Bild anfragen"** direkt im Lightbox: Print-Anfrage, Lizenzanfrage,
Buchung, mit Bildreferenz vorausgefüllt. Aus einer Galerie wird damit ein
Verkaufskanal, ohne dass ein Shop dranhängt.
**Ehrlich zu benennen:** SMTP wäre die erste ausgehende Verbindung des Projekts
überhaupt und die erste ernsthafte neue Dependency (das Projekt hat heute sieben
Runtime-Dependencies und keine Mail-Bibliothek). Der Webhook-Weg vermeidet beides und
passt besser zur bestehenden Linie — `POST /api/webhook` zeigt, dass das Muster im
Haus schon verstanden ist. **Empfehlung: mit Webhook anfangen, SMTP optional
nachziehen.**
---
### A3. Proofing v2 — Notizen statt nur Herzen — **S**
**Befund:** Die Auswahl ist eine Bitmaske von Indizes (`lib/proofing.ts`), exportiert
als Link, als nummerierte Liste oder per Mailto. Sie transportiert also genau ein Bit
pro Foto: gewählt oder nicht.
**Idee:** Ein kurzes Notizfeld pro ausgewähltem Foto. Kunden sagen selten nur „dieses",
sondern „dieses, aber enger geschnitten" oder „das für die Danksagung". Heute landet
das in einer separaten Mail mit Bildnummern, die der Fotograf von Hand zuordnet — der
unangenehmste Teil des ganzen Ablaufs.
**Der Trade-off ist hier die eigentliche Designfrage:** Freitext passt nicht in eine
Bitmaske im URL-Parameter. Zwei saubere Auswege:
- **Klein:** Notizen bleiben in `localStorage` und wandern nur in den Export-Text.
Der Share-Link trägt weiterhin nur die Auswahl. Kein Server, kein neuer Zustand —
passt exakt zur heutigen Architektur („nichts wird serverseitig gespeichert").
- **Groß:** Ein Endpunkt, der die Auswahl entgegennimmt. Das wäre der erste
Schreibzugriff durch Besucher — mit allem, was daran hängt: Rate-Limiting,
Größenbegrenzung, Spam, Aufbewahrung. Nur sinnvoll zusammen mit A1, weil erst ein
identifizierbarer Client-Link die Rückmeldung zuordenbar macht.
**Empfehlung:** die kleine Variante. Sie kostet einen Tag und löst 90 % des Problems.
---
## B. Handwerk & Präsentation
Die Zielgruppe sind laut den bisherigen Doks „Fotografen mit Gestaltungsanspruch".
Die drei folgenden Punkte zielen genau darauf — und einer davon ist ein Befund, der
mich beim Lesen überrascht hat.
### B1. Sequenzierung wie im Fotobuch — **M, der stärkste Differenzierer hier**
**Befund:** Ein Layout gilt für ein ganzes Album (`grid.layout`, seit #431 auch pro
Album überschreibbar). Die Reihenfolge ist frei wählbar (`assetOrder`,
`lib/config/schema.ts:224`, mit Drag & Drop im Admin) — aber jedes Foto bekommt
dieselbe Behandlung wie sein Nachbar.
Ein Fotobuch funktioniert anders. Es lebt von **Paaren**, die sich auf einer
Doppelseite antworten, von **einem Bild, das allein über die volle Breite geht**, und
von **Pausen**. Genau diese Mittel besitzt die Essay-Ansicht bereits — die
Journal-Syntax kennt `fullbleed`, `wide` und Foto-Paare (`lib/journal.ts`). Das Album
kennt sie nicht.
**Idee:** Optionale Layout-Hinweise entlang des vorhandenen `assetOrder`:
```yaml
- 'album-uuid':
sort: manual
assetOrder:
- 'asset-opening': fullbleed # allein, volle Breite
- 'asset-a': pair # diese beiden nebeneinander,
- 'asset-b': pair # in gleicher Höhe
- 'asset-c'
- break # bewusste Lücke, dann geht es weiter
```
Renderer-seitig ist das kein neues Layout-System, sondern eine Segmentierung des
bestehenden Grids: `PhotoGrid` gruppiert die Assets vor dem Rendern in Blöcke und
rendert je Block das passende Muster. Die Bausteine — Aspect-Ratio pro Asset
(`assetAspectRatio`), Zeilenmathematik aus `justified` — liegen schon da.
**Warum das zieht:** Es ist das einzige Feature auf dieser Liste, das kein anderes
Immich-Portfolio-Tool hat, und es spricht die Zielgruppe an ihrer empfindlichsten
Stelle an: Reihenfolge ist Handwerk. Eine Serie, die als gleichförmiges Raster
ausgespielt wird, ist eine Serie, deren Rhythmus verloren geht.
**Trade-off:** Das Feature ist im Admin nur so gut wie seine Vorschau. Layout-Hinweise
in einer Liste zu setzen, ohne das Ergebnis zu sehen, ist frustrierend — der
`AssetOrderEditor` müsste die Blöcke andeuten. Das ist die halbe Arbeit an dieser
Idee, nicht das YAML-Schema.
---
### B2. 1:1-Zoom im Lightbox — **S** _(mit einem Nebenbefund)_
**Befund — und der ist präziser, als er zunächst aussieht:** Der Lightbox hat keinerlei
Zoom; die einzige Transformation ist eine Mount-Transition von `scale(0.96)` auf `1`
(`components/Lightbox.module.css`). So weit erwartbar.
Interessanter ist, _was_ er anzeigt. Der Dateikopf verspricht
„Full-resolution image display" (`components/Lightbox.tsx:5`), gerendert wird aber
`current.previewUrl` (`components/Lightbox.tsx:237`). Und `imageUrl()` steht per
Default auf `'preview'` (`lib/urls.ts:29`) — eine Suche über `app/`, `components/`
und `lib/urls.ts` findet **keine einzige Stelle, die `'original'` anfordert**. Die
oberste Größenstufe aus `lib/imageSize.ts` ist im gesamten Frontend unerreichbar.
Das ist als Default vollkommen richtig — Originale an jeden Besucher auszuliefern wäre
verschwenderisch, und `resolveImageSize()` verhindert das aus gutem Grund. Aber es
heißt: Der Lightbox zeigt heute Immichs Preview, nicht die Aufnahme. Wer Schärfe
beurteilen will — der Fotograf selbst, und jeder Kunde, der bei der Auswahl auf den
Fokus schaut — kann das nicht.
**Idee:** Klick oder Pinch schaltet auf `original` um und erlaubt Pan. Erst bei dieser
Geste wird die große Datei geholt, nie vorher. Bewusst getrennt von den bereits
gelisteten Punkten Fullscreen und Download: Zoom ist Beurteilung, nicht Präsentation
und nicht Übergabe.
**Nebenbei zu korrigieren:** der Kommentar in `Lightbox.tsx:5`. Er beschreibt eine
Absicht, nicht das Verhalten.
---
### B3. Farbmanagement — **eine offene Frage, keine Behauptung, S für die Messung**
Aus B2 folgt eine Frage, die ich mit Codelesen allein nicht beantworten kann und die
deshalb hier als **Messaufgabe** steht, nicht als Befund:
Fotografen arbeiten in AdobeRGB oder Display-P3. Wenn Folio ausschließlich Immichs
Preview-Tier ausliefert (siehe B2), dann hängt die Farbtreue des gesamten Portfolios
daran, was Immich beim Erzeugen dieser Previews mit dem Farbprofil macht — und ob
`streamAsset()` das Profil mit durchreicht. Fehlt es, rendert der Browser die Bytes
als sRGB: Wide-Gamut-Material wirkt dann flau oder, umgekehrt eingebettet,
übersättigt. Und zwar ausgerechnet auf dem guten Display, an dem der Fotograf sitzt.
**Testweg:** Eine Datei mit sichtbar breitem Gamut (gesättigtes Rot/Cyan) in AdobeRGB
nach Immich laden, in Folio öffnen, die ausgelieferten Bytes auf ein eingebettetes
ICC-Profil prüfen und gegen dieselbe Datei lokal im Browser vergleichen.
**Wenn sich ein Verlust zeigt,** sind die Antworten der Reihe nach: Profil im Proxy
mit durchreichen (Header und Bytes bleiben unangetastet — `streamAsset()` streamt
ohnehin unverändert); und für die Zoom-Ansicht aus B2 gleich das Original nehmen, das
sein Profil mitbringt.
**Wenn sich keiner zeigt,** ist der Abschnitt einen Satz in der Doku wert — „Folio
liefert Immichs Previews unverändert aus, inklusive Farbprofil" — und damit erledigt.
Auch das ist ein Ergebnis: Farbtreue ist bei dieser Zielgruppe ein
Vertrauensargument, und ein belegter Satz dazu ist mehr wert als ein Feature.
---
## C. Vertrauen & Privatsphäre
### C1. Ortsangaben pro Album steuern — **S**
**Befund, sorgfältig gelesen:** Die Map ist datenschutzseitig deutlich besser gebaut,
als man befürchten würde. Passwortgeschützte Alben werden vor der Aggregation
herausgefiltert (`app/api/map/route.ts:63-66`), 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 erst heraus.
Die Lücke ist eine andere und feiner: Für **öffentliche** Alben wird ein Marker aus
dem **Mittelwert der tatsächlichen Koordinaten** aller Fotos an diesem Ort gebildet.
Bei einem Reisealbum über eine ganze Stadt ist das unkritisch. Bei einem Album, das an
_einem_ Ort entstanden ist — der eigene Garten, das Wohnzimmer eines Kunden, das
Studio, ein empfindlicher Naturstandort —, ist der Mittelwert genau dieser eine Ort.
Der Marker heißt zwar nach der Stadt, steht aber auf dem Grundstück.
Niemand trifft diese Entscheidung heute bewusst; sie ergibt sich daraus, dass die
Kamera GPS mitschreibt.
**Idee:** Eine Granularität pro Album und pro Subpage:
```yaml
location: exact # heutiges Verhalten
location: city # auf das Stadtzentrum runden, nicht mitteln
location: country # nur im Land verorten
location: hidden # gar nicht auf der Karte
```
Der Eingriff sitzt in `lib/mapService.ts`, wo bereits pro Album aggregiert wird — die
Stelle, an der man die Auflösung reduziert, existiert also schon.
**Warum das zieht:** Es macht aus einer stillschweigenden Preisgabe eine bewusste
Entscheidung. Für ein Projekt, dessen Verkaufsargument „dein Immich bleibt privat"
lautet, ist das kein Nebenfeature, sondern eine Konsequenz derselben Haltung.
---
### C2. Terminierte Veröffentlichung — **S**
**Befund:** `draft: true` gibt es nur für Journal-Einträge (`app/journal/page.tsx:33`,
Drafts bleiben für den eingeloggten Admin sichtbar — ein gutes Muster). Alben und
Subpages sind live, sobald sie in der YAML stehen. Wer eine Seite vorbereiten will,
muss sie entweder verstecken (`hidden: true`, seit #431) und im richtigen Moment von
Hand umstellen, oder sie erst im letzten Moment anlegen.
**Idee:** Ein `publishAt`-Datum für Alben, Subpages und Journal-Einträge. Vor dem
Termin verhält sich der Eintrag wie ein Draft: für den Admin sichtbar, für alle
anderen nicht vorhanden.
**Warum das billig ist:** Alle Seiten sind `force-dynamic`. Es braucht keinen
Scheduler, keinen Cron, keinen Hintergrundjob — nur einen Datumsvergleich beim
Ableiten der Galerie, an derselben Stelle, an der heute schon `enabled` und `hidden`
ausgewertet werden.
**Warum es trifft:** Es ist ein realer Ablauf, kein hypothetischer. Die
Hochzeitsgalerie geht Freitag um 18 Uhr auf, wenn das Paar aus den Flitterwochen
zurück ist. Die Serie erscheint zum Ausstellungstermin. Das Journal zum Buchstart.
Heute heißt das: Wecker stellen und selbst am Rechner sitzen.
**Ein Detail, das mitgedacht gehört:** Der RSS-Feed aus D darf einen terminierten
Eintrag nicht vorab ausliefern — dieselbe Prüfung, dieselbe Stelle.
---
## D. RSS/Atom-Feed — konkretisiert
Steht seit Mai als Einzeiler in [`brainstorm.md`](brainstorm.md) („`/feed.xml` mit
neuen Alben", Zeile 23) und dort auf Platz 1 der Reihenfolge. Verifiziert: eine
Feed-Route existiert nicht. Hier zum ersten Mal durchdacht — und der interessante
Teil ist nicht das XML.
**Der eigentliche Inhalt dieses Features ist die Ausschlussliste.** Ein Feed sammelt
genau das ein, was die Zugangskontrolle sonst verbirgt: Titel, Beschreibungen,
Cover-Bilder, Veröffentlichungsdaten. Er ist damit die perfekte Leak-Fläche, und
zwar eine, die niemandem auffällt, weil sie im Browser nie sichtbar wird. Draußen
bleiben müssen:
- passwortgeschützte Subpages und Alben (`password:` auf beiden Ebenen)
- `hidden: true`-Subpages — sie sind bewusst nur per Direktlink erreichbar, und ein
Feed ist das Gegenteil eines Direktlinks
- Journal-Drafts (`draft: true`)
- alles mit einem `publishAt` in der Zukunft, falls C2 kommt
- Alben, die per Client-Link aus A1 zugänglich sind
**Zwei Quellen, ein Feed:** Journal-Einträge (haben Datum, Titel, Excerpt und Cover —
alles schon da) und neu hinzugekommene Alben. Cover-Bilder über den bestehenden
`/api/og`-Weg, damit kein neuer Bildpfad entsteht.
**Was es absichert:** ein Test, der die Ausschlussregel festschreibt — analog zu
`app/api/admin/__tests__/admin-guards.test.ts`, also **tabellengetrieben über die
Kategorien**, nicht als Sammlung von Einzelfällen. Dann schlägt er auch bei einer
künftig hinzugefügten Sichtbarkeitsregel an, die jemand im Feed zu berücksichtigen
vergisst.
**Aufwand:** S für den Feed. Die Sorgfalt steckt komplett in der Sichtbarkeitsprüfung —
und die ist der Grund, warum dieser Punkt hier nochmal auftaucht, statt als Einzeiler
stehenzubleiben.
---
## Anhang: zwei Kleinigkeiten zur Auffindbarkeit
Kurz gehalten, weil Auffindbarkeit diesmal nicht der Schwerpunkt war — aber beide sind
XS und stehen in keiner der alten Listen:
- **Es gibt weder `app/sitemap.ts` noch `app/robots.ts`.** Robots-Direktiven existieren
nur auf Meta-Ebene (`app/layout.tsx:37-48`). Next.js erzeugt beide Dateien aus je
einer Funktion; die Album- und Subpage-Liste liegt in `getConfig()` bereit. Dieselbe
Ausschlussliste wie beim Feed gilt auch hier.
- **Kein JSON-LD.** Für ein Fotografie-Portfolio ist `ImageObject` mit `creator` und
`license` die eine strukturierte Angabe, die sich wirklich auszahlt — Google Bilder
zeigt Lizenzhinweise an, und es ist die einzige Stelle, an der ein Fotograf seine
Urheberschaft maschinenlesbar an die Bilder heftet. Passt inhaltlich zu A2: wer
Lizenzanfragen will, sollte auffindbar machen, dass Lizenzen zu haben sind.
---
## Empfohlene Reihenfolge
**Erst — klein, sofort spürbar:**
1. **C2** Terminierte Veröffentlichung · _S_ — ein Datumsvergleich, ein echter Ablauf
2. **C1** Ortsangaben pro Album · _S_ — die Stelle im Code existiert bereits
3. **B2** 1:1-Zoom · _S_ — und der falsche Kommentar in `Lightbox.tsx:5` fällt mit weg
**Dann — die zwei mit dem größten Hebel:**
4. **B1** Sequenzierung wie im Fotobuch · _M_ — das Alleinstellungsmerkmal
5. **A1** Ablaufende Client-Links · _M_ — das Feature, das aus dem Portfolio ein
Arbeitswerkzeug macht
**Danach:**
6. **D** RSS-Feed · _S_ — mit der Ausschlussliste als eigentlicher Arbeit
7. **A2** Anfrage-Formular · _S–M_ — mit Webhook beginnen, SMTP später
8. **A3** Proofing-Notizen · _S_ — kleine Variante
9. **B3** Farbmanagement messen · _S_ — Ergebnis offen, Erkenntnis in jedem Fall wertvoll
10. Anhang: Sitemap, `robots.ts`, JSON-LD · _XS_
---
## Roter Faden
Drei Sätze halten die Auswahl zusammen.
**Ein Portfolio ist Arbeit, nicht nur Ausstellung.** A1–A3 begleiten den Weg, den ein
Auftragsbild ohnehin nimmt — Übergabe, Rückmeldung, Anfrage. Folio zeigt heute nur
den mittleren Teil davon.
**Reihenfolge ist Handwerk.** B1 ist der einzige Punkt hier, den kein vergleichbares
Werkzeug bietet, und er trifft die Zielgruppe genau dort, wo sie anspruchsvoll ist.
**Privatsphäre gilt nach außen genauso wie nach innen.** Das Versprechen lautet „dein
Immich bleibt privat". C1 und C2 ziehen dieselbe Linie für das, was die
veröffentlichte Seite über Orte und Zeitpunkte verrät — und D stellt sicher, dass ein
Feed sie nicht hintenherum wieder aufmacht.

View File

@@ -21,7 +21,7 @@ _Stand: 2026-05-29 — generiert aus Projekt-Review + ideas.md_
| **Sequentielle Album-Navigation** | XS | „← Vorheriges / Nächstes Album →" am Ende einer Detailseite |
| **Alt-Text aus Immich-Description** | XS | Immich-Assets haben ein `description`-Feld → als `alt`-Text → bessere Accessibility & SEO |
| **RSS/Atom Feed** | S | `/feed.xml` mit neuen Alben — Follower können „neue Arbeit" abonnieren |
| **Globaler Passwortschutz** | S | Eine einzige `site_password` für die gesamte Site (jetzt: nur per Subpage/Album) |
| ~~**Globaler Passwortschutz**~~ | S | _Umgesetzt:_ `sitePassword` in `settings.yaml` bzw. `SITE_PASSWORD` |
| **Privacy-Analytics-Integration** | S | Plausible / Umami / GoatCounter via Script-Tag in `settings.yaml` konfigurieren (kein Tracking-Framework nötig) |
---

View File

@@ -41,7 +41,8 @@ server, so a typo cannot leave you with an "installed" site that loads no photos
`IMMICH_API_URL`, `IMMICH_API_KEY`, `AUTH_SECRET` and `ADMIN_PASSWORD` override
anything in `install.json`, so any of them can be rotated by setting the variable
— no need to touch the file.
— no need to touch the file. `SITE_PASSWORD` works the same way over
`sitePassword` in `settings.yaml`.
Set all of them up front and the wizard never appears, which is the usual choice
for an infrastructure-as-code deployment.

View File

@@ -110,20 +110,20 @@ subpages:
### Subpage Options
| Key | Type | Description |
| ----------- | ------- | ------------------------------------------------------------------------------------ |
| `name` | string | Navigation label; the URL slug is derived from it |
| `title` | string | Page heading, defaults to `name` |
| `subtitle` | string | Subline under the heading |
| `password` | string | Gates the whole subpage — see [Password Protection](#password-protection) |
| `albums` | list | Album UUIDs, optionally with [per-album options](#per-album-options) |
| `sections` | list | Named groups of albums — see [Sections](#sections) |
| `grid` | object | Grid override for this page — see [Grid Layout](#grid-layout) |
| `proofing` | boolean | Enables [client proofing](#client-proofing) on this page |
| `essayFile` | string | Render a Markdown essay instead of a grid — see [Journal & Photo Essays](journal.md) |
| `essayText` | string | Essay Markdown inline; written by the admin block editor |
| `enabled` | boolean | `false` takes the page offline without deleting it |
| `hidden` | boolean | **Experimental.** Reachable by direct link, but hidden from the navigation |
| Key | Type | Description |
| ----------- | ------- | -------------------------------------------------------------------------------------- |
| `name` | string | Navigation label; the URL slug is derived from it |
| `title` | string | Page heading, defaults to `name` |
| `subtitle` | string | Subline under the heading |
| `password` | string | Gates the whole subpage — see [Password Protection](#password-protection) |
| `albums` | list | Album UUIDs, optionally with [per-album options](#per-album-options) |
| `sections` | list | Named groups of albums — see [Sections](#sections) |
| `grid` | object | Grid override for this page, photos and album covers — see [Grid Layout](#grid-layout) |
| `proofing` | boolean | Enables [client proofing](#client-proofing) on this page |
| `essayFile` | string | Render a Markdown essay instead of a grid — see [Journal & Photo Essays](journal.md) |
| `essayText` | string | Essay Markdown inline; written by the admin block editor |
| `enabled` | boolean | `false` takes the page offline without deleting it |
| `hidden` | boolean | **Experimental.** Reachable by direct link, but hidden from the navigation |
```yaml
subpages:
@@ -245,8 +245,8 @@ Useful when the automatic centre crop cuts off a horizon or a face.
## Password Protection
A password can be set on a subpage, on a single album, and on a
[journal entry](journal.md#password-protected-entries). Unlocking sets an
A password can be set on the whole site, on a subpage, on a single album, and on
a [journal entry](journal.md#password-protected-entries). Unlocking sets an
`HttpOnly` cookie that is valid for 24 hours; nothing is stored server-side.
Three storage formats are accepted:
@@ -267,6 +267,38 @@ directly.
> gallery was unlocked keep working — see
> [Image Token Revocation](#image-token-revocation).
### Locking the whole site
`sitePassword` in `settings.yaml` puts everything public behind one password —
useful while a portfolio is still being built, or for a folio that is only ever
shown to clients.
```yaml
# settings.yaml
sitePassword: 'scrypt:...'
```
`SITE_PASSWORD` in the environment overrides the file, so the password can be
rotated without editing (or backing up) `settings.yaml`. It accepts the same
three storage formats as every other gate.
Unlike the per-page gates, this one is enforced in `proxy.ts`, before the
requested page renders at all. That matters: a gate that merely swapped the page
for a login form would still have let the page produce its payload, album names
and image tokens included. The public API routes carry the same check
themselves, since route handlers do not pass through the proxy.
Three things stay reachable on a locked site:
| Path | Why |
| ------------- | ------------------------------------------------------------------------ |
| `/api/health` | A health probe runs without cookies; gating it takes the container down. |
| `/admin` | It has its own password, and it is where `sitePassword` is set. |
| `/install` | A fresh deployment has to be configurable before it can be locked. |
A locked site also serves `noindex, nofollow` regardless of the SEO settings —
there is nothing there for a crawler.
## Photo Order Within an Album
By default a Folio album mirrors the Immich timeline. That is usually right, but a curated series has a narrative order that has nothing to do with capture time — and changing the sort on the Immich album would change it for the archive too. The optional `sort` key decouples the two.
@@ -315,11 +347,18 @@ override it in `gallery.yaml`. The three levels merge, most specific winning:
**global (`settings.yaml`) → subpage → album**
> [!NOTE]
> Leaving `gap` out is not the same as setting `12`. Each theme preset picks its
> own photo spacing (2px `minimal`, 32px `editorial`, 40px `monograph`), and an
> unset `gap` lets that stand. Setting one wins in every preset — which is what
> `minimal`, `editorial` and `monograph` used to ignore
> ([#513](https://github.com/ralksta/immich-folio/issues/513)).
```yaml
# settings.yaml — global defaults
grid:
columns: 3 # number of columns (default: 3)
gap: 12 # gap in pixels (default: 12)
gap: 12 # gap in pixels — unset means the theme preset decides
aspectRatio: '1' # "1", "3/2", "2/3", "16/9", "auto" (default: "1")
layout: masonry
```
@@ -341,6 +380,37 @@ Available layouts: `masonry`, `uniform`, `showcase`, `filmstrip`,
(see [Journal & Photo Essays](journal.md)). Each is illustrated in the
**[Theming guide](theming.md#gridlayout)**.
### Album covers on a subpage
A subpage with more than one album shows a grid of album covers rather than
photos. That grid follows the same `columns` value — set it globally in
`settings.yaml` or per page in `gallery.yaml`, and the covers are tiled the same
way the photos are. Valid range is 1–6; below 1024px viewport width at most two
covers share a row, and below 640px they stack.
`gap` is the deliberate exception: it does **not** cascade from the global
setting to the covers. Every theme preset picks its own cover spacing as part of
its look — 1px in `monograph`, 2px in `minimal` and `editorial`, 20px in
`studio-modern` — and letting the site-wide photo gap through would flatten all
of them into one look. Only an explicit `gap` on the subpage overrides the
preset:
```yaml
# gallery.yaml — album covers on this page only
subpages:
- name: Editorial
grid:
columns: 3 # three covers per row (site-wide default otherwise)
gap: 24 # overrides the theme's own cover spacing
albums:
- '33333333-3333-3333-3333-333333333333'
- '44444444-4444-4444-4444-444444444444'
```
Both fields are editable per page in the admin panel under
**Pages › _subpage_ › Album Cover Grid**; left empty they follow the site-wide
setting and the theme respectively.
## Site Behaviour
Feature toggles in `settings.yaml`:
@@ -350,7 +420,13 @@ title: 'My Portfolio'
subtitle: 'A visual journal'
lang: 'en'
exifOnHover: true # camera/lens/settings on photo grid hover
mode: dark # dark (default) | light | auto — what a first-time visitor sees
exifOnHover: true # the summary overlay on photo grid hover
exif: # which groups may be shown at all
camera: true # body, lens, focal length
settings: true # aperture, shutter speed, ISO
location: true # city and country
caption: true # the description written in Immich
map: true # the interactive world map at /map
transitions: true # smooth page transitions between routes
scrollToTop: true # floating back-to-top arrow on long pages
@@ -362,14 +438,83 @@ about:
| Key | Default | Notes |
| --------------- | ------- | ------------------------------------------- |
| `lang` | `en` | `<html lang>` and date formatting |
| `exifOnHover` | on | |
| `lang` | `en` | Interface language, `<html lang>`, dates |
| `mode` | `dark` | Visitor's starting colour mode, see below |
| `exifOnHover` | on | The grid hover overlay only, see below |
| `exif.*` | on | Per-group EXIF visibility, see below |
| `map` | **off** | Opt-in: must be set to `true` explicitly |
| `transitions` | on | |
| `scrollToTop` | on | |
| `analytics` | on | |
| `about.enabled` | on | Needs a `content/about.md` to show anything |
### Colour mode
`mode` decides what a visitor sees before they touch anything: `dark` (the
default the site has always had), `light`, or `auto` to follow their operating
system. It is rendered onto `<html>` server-side, so the first paint is already
right rather than flipping after the page loads.
A visitor's own choice from the header toggle is kept in their browser and
always wins over this — `mode` only sets the starting point. The light/dark
switch in the admin panel previews _your_ view and does not change what
visitors get; that is what this setting is for
([#512](https://github.com/ralksta/immich-folio/issues/512)).
### EXIF visibility
`exif` decides what the site may publish about a photo. It governs all three
places the data appears — the grid hover overlay, the lightbox info panel, and
the camera line in the album header — so they cannot disagree.
`caption` is the description written in Immich. It used to be shown regardless
of any setting, on the grounds that a caption is editorial rather than
metadata — which left it as the one field nobody could switch off, and Immich
descriptions are also where people keep private notes and whatever a decade of
cataloguing software left behind ([#506](https://github.com/ralksta/immich-folio/issues/506)).
It is a group like any other now.
`exifOnHover` predates the block and stays: it decides whether the grid shows
its overlay at all, and it supplies the default for the three technical groups,
so an existing `exifOnHover: false` still means "no technical EXIF anywhere". An
explicit `exif` key always wins over it. Captions never followed that switch and
still do not — set `caption: false` to turn them off.
With every group off, the lightbox withdraws its info button and the `i` key
rather than opening an empty panel.
### Interface language
`lang` picks the language of the visitor-facing interface — navigation, the
lightbox, the password gate, the proofing dialog, the legal notice, error pages
— and is also emitted as `<html lang>` and used for date formatting.
| Value | Interface |
| ----- | ----------------- |
| `en` | English (default) |
| `de` | German |
Any other value (`fr`, `ja`, …) still reaches `<html lang>` and
`toLocaleDateString`, but the interface stays English: there is no dictionary
for it yet. That is deliberate — claiming `lang="en"` on a French site would be
worse for screen readers than an English interface on a page correctly marked
as French.
Two things stay in one language by design:
- **The admin panel** is always English, whatever `lang` says.
- **`/impressum`** keeps its German headings when `lang: de` and the § 5 TMG
citation in both, because that is what the statute names. Under `lang: en`
the footer link reads "Legal Notice" rather than "Impressum" — the page is
optional (`legal.enabled`), and an unexplained German word in the footer of
an English site is what drove this out of the backlog.
Adding a language means adding one file under `lib/i18n/locales/` and listing
it in `SUPPORTED_LOCALES` (`lib/i18n/index.ts`). The dictionary is typed
against the English one, so a missing key fails the type-check rather than
falling back silently, and `lib/__tests__/i18n.test.ts` additionally catches
keys that were copied over but never translated.
### Analytics
Page and album view counts, stored in `content/analytics.json` — no cookies, no
@@ -447,10 +592,14 @@ protection:
watermark:
enabled: true
text: '© Your Name'
opacity: 0.5
opacity: 0.5 # fraction, 0–1: 0.5 is 50%
position: 'bottom-right' # bottom-right | bottom-left | center
```
`opacity` is a fraction between 0 and 1, the same unit the admin slider writes.
A value above 1 is read as a percentage (`90` means 0.9), so configs written
that way before the unit was fixed keep working.
> [!NOTE]
> Both are deterrents against casual copying, not protection. The image is in
> the browser, and anyone who wants it can take a screenshot. The watermark is

View File

@@ -10,9 +10,10 @@ A consolidated list of features and ideas for future versions, cleaned up from e
| **Fullscreen API in lightbox** | Low | Medium — immersive (Chrome-free) |
| **Download originals** | Low | High — client delivery (Button in lightbox) |
| **Photo of the Day** | Low | Medium — return visits (Auto-rotate hero image) |
| **Keyboard shortcut hints** | Low | Medium — `?` button for shortcuts (arrows, Esc, F) |
| **Photo sharing** | Low | Medium — "Copy link" button in lightbox |
_Shipped: **Keyboard shortcut hints** — `?` opens the list in the lightbox, with a one-time nudge for first-time visitors._
---
## 🌍 Bigger Features & Enhancements
@@ -23,7 +24,7 @@ A consolidated list of features and ideas for future versions, cleaned up from e
- **EXIF analytics page** — Visual breakdown of shooting patterns: most-used focal lengths, aperture distribution.
- **Before/After slider** — Drag-to-reveal comparison component for retouching showcases.
- **Watermarking** — Server-side watermark overlay on proxied images.
- **Internationalization (i18n)** — Multi-language UI support (DE/EN).
- ~~**Internationalization (i18n)** — Multi-language UI support (DE/EN).~~ Shipped for the visitor UI; the admin panel is still English-only.
---

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 523 KiB

After

Width:  |  Height:  |  Size: 405 KiB

View File

@@ -0,0 +1,407 @@
# 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.

View File

@@ -225,6 +225,30 @@ The same album in five of the grid layouts (theme `studio-modern`):
</tr>
</table>
### Album cover spacing
The album-cover grid on a subpage is sized by two CSS custom properties on
`.subpage-grid`:
| Property | Set by |
| ------------------- | -------------------------------------------------------------------- |
| `--subpage-columns` | the app, from the resolved `grid.columns` (site-wide or per subpage) |
| `--subpage-gap` | the **theme preset** — a per-subpage `grid.gap` overrides it |
The column count follows the configuration, the spacing belongs to the look.
`monograph` sets 1px, `minimal` and `editorial` 2px, `studio-modern` 20px;
`classic` and `noir` set nothing and get the 4px default from `globals.css`.
A custom preset does the same:
```css
[data-preset='mytheme'] .subpage-grid {
--subpage-gap: 12px;
}
```
Do not set `gap` directly there — an explicit per-page gap is passed as an
inline custom property and would no longer win.
## Examples
### Minimal portfolio