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:
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
396
docs/brainstorm-2026-08-15.md
Normal file
396
docs/brainstorm-2026-08-15.md
Normal 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.
|
||||
@@ -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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
407
docs/superpowers/plans/2026-08-15-portfolio-features.md
Normal file
407
docs/superpowers/plans/2026-08-15-portfolio-features.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user