# 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` 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 `), 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/`, 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 `//` 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 `