release: v0.11.0 (#459)

Ships `dev` as **v0.11.0**. `main` contains nothing that `dev` does not,
so this is a clean sync.

## Headline changes since v0.10.0

- **Journal & Journal Studio** — photo essays at `/journal`,
split-screen block editor with live preview, drafts, per-entry
passwords, cover images
- **First-run setup wizard at `/install`** — configure a fresh
deployment from the browser; credentials are verified against Immich
before anything is written, and the wizard is gated by a one-time token
- **About page editor and toggle** in the admin panel
- **Per-album photo order** independent of Immich, with a drag & drop
editor
- **Custom favicon upload**
- **Experimental portfolio features** — justified grid, `cover` hero,
unlisted subpages, per-album grid overrides, cover focal points,
external nav links
- **Security** — asset tokens length-capped before decoding (#423),
journal author markdown escaped rather than filtered, journal file paths
contained, admin session key derived with scrypt (#432), Next.js 16.3.0
for CVE-2025-13465
- **Fixed** — client proofing honours its own configuration again
(#454); journal photo rendering (#432)

Full detail in [CHANGELOG.md](CHANGELOG.md#0110--2026-08-15).

## Verified

`npx tsc --noEmit` clean · `npx vitest run` 435 tests pass · `npm run
build` succeeds · lint 0 errors · `prettier --check` clean · local
`docker build` of the release tree.

## After merge

Tag `v0.11.0` on `main` and publish the release — that triggers
`docker-publish.yml`, which pushes the image and moves `latest`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Ralksta
2026-08-15 09:09:18 +02:00
committed by GitHub
parent 6eda34cc50
commit 2f546f440d
61 changed files with 3953 additions and 1115 deletions

30
docs/README.md Normal file
View File

@@ -0,0 +1,30 @@
# Documentation
Start at the [project README](../README.md) for the pitch, the feature list and
a quick start.
## Guides
| Guide | Covers |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| **[Gallery Configuration](gallery-config.md)** | `gallery.yaml` and `settings.yaml` — albums, subpages, sections, photo order, passwords, grid, SEO, proofing, watermark |
| **[Theming](theming.md)** | The seven presets, custom colours and fonts, hero styles, grid layouts, with screenshots |
| **[Journal & Photo Essays](journal.md)** | Long-form storytelling — file format, block syntax, drafts, per-entry passwords, Journal Studio |
| **[Admin Panel](admin-panel.md)** | The visual editor at `/admin` — pages, journal, settings, analytics, backups, security |
| **[Deployment](deployment.md)** | Setup wizard internals, Docker, health check, reverse proxy, behaviour during an Immich outage |
Contributing? See [CONTRIBUTING.md](../CONTRIBUTING.md). Security policy:
[SECURITY.md](../SECURITY.md). Release history: [CHANGELOG.md](../CHANGELOG.md).
## Design notes
Working documents kept for context. They record thinking at a point in time and
are **not maintained** — where they disagree with the guides above, the guides
are correct.
- [brainstorm.md](brainstorm.md), [brainstorm-2026-08-05.md](brainstorm-2026-08-05.md) — feature brainstorming
- [deep-dive-2026-08-05.md](deep-dive-2026-08-05.md) — architecture review
- [ideas.md](ideas.md) — unsorted ideas backlog
- [journal-studio-design.md](journal-studio-design.md) — design notes for the journal editor
`screenshots/` holds the images used by the guides and the README.

View File

@@ -1,13 +1,16 @@
# Admin Panel
Immich Folio includes a built-in visual admin panel at `/admin` that lets you manage your gallery structure and site settings without editing YAML files by hand.
Immich Folio includes a built-in visual admin panel at `/admin` that lets you manage your gallery structure, journal entries and site settings without editing YAML files by hand.
**Contents:**
- [Enabling the Admin Panel](#enabling-the-admin-panel)
- [Page Builder](#page-builder)
- [Settings Editor](#settings-editor)
- [Album Picker](#album-picker)
- [Layout](#layout)
- [Pages](#pages)
- [Journal](#journal)
- [Settings](#settings)
- [Analytics](#analytics)
- [Album & Asset Pickers](#album--asset-pickers)
- [Security](#security)
- [Backups](#backups)
- [Docker Usage](#docker-usage)
@@ -22,97 +25,147 @@ ADMIN_PASSWORD=your-secure-admin-password
Then navigate to `http://your-site/admin`. The panel is completely disabled if no password is set — it won't even render the login page.
The [setup wizard](../README.md#first-run-setup) can set the password instead, storing it as an scrypt hash in `content/install.json`. An `ADMIN_PASSWORD` environment variable takes precedence if both are present.
> [!IMPORTANT]
> The admin password is separate from any album or subpage passwords. It controls access to the entire gallery configuration.
## Page Builder
## Layout
The **Pages** tab provides a visual tree view of your gallery structure:
Each area has its own URL, so a section can be bookmarked and the browser's back button works as expected:
| Tab | URL | Writes to |
| ------------- | ------------------ | ------------------------------------------- |
| **Pages** | `/admin/pages` | `content/gallery.yaml` |
| **Journal** | `/admin/journal` | `content/journal/<slug>.md` |
| **Settings** | `/admin/settings` | `content/settings.yaml`, `content/about.md` |
| **Analytics** | `/admin/analytics` | nothing — read-only |
Unsaved changes raise a save bar pinned to the bottom of the viewport, so the Save button is reachable without scrolling back up. Saving applies immediately — no server restart.
## Pages
A visual tree of your gallery structure. Each item opens a slide-over drawer with its settings.
### Hero Images
Add Immich asset UUIDs for the homepage hero carousel. Supports single or multiple images that crossfade automatically.
Immich asset UUIDs for the homepage hero. One image, or several that crossfade as a carousel. Pick them from your library with the [asset picker](#album--asset-pickers) instead of typing UUIDs.
### Standalone Albums
Albums displayed directly on the homepage. Click **+ Add Album** to open the Album Picker and select from your Immich library.
Albums displayed directly on the homepage. **+ Add Album** opens the [Album Picker](#album--asset-pickers).
Each album card supports:
- **Title override** — display a custom name instead of the Immich album name
- **Description** — shown below the album title on the subpage
- **Password** — protect individual albums with a password
Each album supports:
- **Title override** — a display name instead of the Immich album name
- **Description** — shown below the album title
- **Password** — protect a single album (written as an scrypt hash)
- **Cover image** — override the album cover with any asset
- **Photo order** — Immich order, newest, oldest, filename, or **Manual** with a drag & drop editor
- **Grid override** _(experimental)_ — a different grid layout for this album alone (the panel exposes the layout; columns, gap and aspect ratio can be set in `gallery.yaml`)
- **Cover focal point** _(experimental)_ — which part of the cover survives the crop
### Subpages
Group albums under custom URL paths (e.g., `/japan`, `/wedding-smith`).
Group albums under custom URL paths (e.g. `/japan`, `/wedding-smith`).
Each subpage has:
- **Name** — used for navigation and URL generation (auto-slugified)
- **Title** — optional display title (defaults to name)
- **Subtitle** — optional text shown below the page heading
- **Password** — optional protection for the entire subpage
- **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
- **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
A live preview shows the page as visitors will see it.
### Sections
Within a subpage, you can organize albums into named sections. This generates a table of contents with anchor links. Add sections with **+ Add Section** and fill each with albums from the picker.
Within a subpage, albums can be grouped into named sections, which renders a table of contents with anchor links.
### Reordering
Use the **↑/↓** buttons on subpages to change their display order. The order in the admin panel matches the order on your site.
Subpages, albums and sections are reordered in place; the order in the panel is the order on the site.
## Settings Editor
## Journal
The **Settings** tab lets you configure global site behavior:
The **Journal Studio** — a split-screen editor with the blocks on the left and a live preview of the real page on the right, separated by a divider you can drag with mouse, keyboard or touch.
| Section | Controls |
| --------- | ---------------------------------------------------------------- |
| General | Site title, subtitle, language, EXIF on hover, map, transitions, scroll-to-top |
| Theme | Preset, accent color, photo frame, hero style, grain, header dot |
| Grid | Layout mode, columns, gap, aspect ratio |
| Footer | Name, Instagram, email, website |
| Legal | Impressum toggle and all legal fields |
| SEO | Meta title, description, noindex, nofollow |
Blocks are added from a menu (heading, text, quote, photo, photo pair), reordered, and deleted; each carries a type chip so long entries stay scannable. Photos are inserted through the asset picker, which fills in the Immich asset UUID for you. The frontmatter — cover image, title, subtitle, author, date, draft flag, password — is edited as form fields.
Changes are applied immediately after saving — no server restart required.
Saving writes plain Markdown to `content/journal/<slug>.md`, which can equally be edited by hand.
## Album Picker
→ **[Journal & Photo Essays](journal.md)** for the file format.
When adding albums, a modal overlay shows **all shared albums** from your Immich instance (not just the ones already configured). Features:
## Settings
Eight sections, each at its own URL (`/admin/settings/theme`, …):
| Section | Controls |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| **General** | Site title, subtitle, language, favicon upload, and the feature toggles (EXIF on hover, map, transitions, scroll-to-top, analytics) |
| **Theme** | Preset, colour mode, accent, photo frame, hero style, grain, header dot |
| **Grid** | Layout algorithm, columns, gap, aspect ratio |
| **Footer** | Name, Instagram, email, website, and the header navigation links |
| **Legal** | Impressum toggle and all legal fields |
| **SEO** | Meta title, subpage title template, description, noindex, nofollow |
| **Security & Protection** | Right-click and image-drag deterrents, lightbox watermark (text, position, opacity) |
| **About** | Portrait asset, name, location, gear list, and the biography — written to `content/about.md` |
Theme presets, grid layouts, photo frames, hero styles and the Google search snippet are picked from **preview cards** rather than text fields, so the effect of a choice is visible before saving. A light/dark toggle previews both variants of a preset.
### Favicon
**General → Favicon** accepts an SVG, PNG, ICO or JPEG. It is stored in the writable `content/` volume and served through `/api/favicon` under a policy that stops an uploaded SVG from executing script. **Reset** restores the bundled default.
## Analytics
View counts per page and album, read from `content/analytics.json`. No cookies, no third party, nothing leaving your server. Switch the collection off entirely in **Settings → General**; the tracking endpoint then refuses to record.
## Album & Asset Pickers
**Album Picker** — a modal listing **all shared albums** from your Immich instance, not just the configured ones:
- **Search** by album name or UUID
- **Photo count** shown for each album
- **Photo count** per album
- **"In use" badge** for albums already assigned somewhere
- Albums are sorted: configured first, then alphabetically
- Sorted configured-first, then alphabetically
**Asset Picker** — browses your full Immich library for hero images, album covers, journal photos and the about-page portrait. It uses `POST /search/metadata`, which is covered by the `asset.read` permission your API key already needs.
## Security
| Aspect | Implementation |
| -------------- | ----------------------------------------------------------- |
| Authentication | HMAC-signed session token in an `HttpOnly` cookie |
| Session expiry | 24 hours (automatic logout) |
| Password check | Constant-time comparison to prevent timing attacks |
| Cookie flags | `HttpOnly`, `Secure` (in production), `SameSite=Strict` |
| Rate limiting | Admin endpoints share the global rate limiter |
| Robots | `/admin` is marked `noindex, nofollow` in metadata |
| Aspect | Implementation |
| -------------- | --------------------------------------------------------------------- |
| Authentication | HMAC-signed session token in an `HttpOnly` cookie |
| Signing key | Derived from `AUTH_SECRET` with scrypt |
| Session expiry | 24 hours (automatic logout) |
| Password check | Constant-time comparison to prevent timing attacks |
| Cookie flags | `HttpOnly`, `Secure` (in production), `SameSite=Strict` |
| Rate limiting | `POST /api/admin/auth` is capped at 5 attempts per minute per IP |
| Authorization | Every `/api/admin/*` route re-checks the session, including analytics |
| Robots | `/admin` is marked `noindex, nofollow` in metadata |
> [!NOTE]
> The admin panel uses its own session management, completely separate from album password protection. Admin sessions use `AUTH_SECRET` for token signing.
> The admin panel uses its own session management, completely separate from album password protection. Rotating `AUTH_SECRET` invalidates every admin session along with every unlocked gallery.
## Backups
Every time you save changes, the previous YAML file is automatically backed up to `content/.backups/`:
Every time you save, the previous file is backed up automatically:
```
content/.backups/
├── gallery.yaml.2026-05-29T14-30-00-000Z.bak
├── gallery.yaml.2026-05-29T15-45-00-000Z.bak
├── settings.yaml.2026-05-29T14-30-00-000Z.bak
└── ...
content/journal/.backups/
└── my-story.md.2026-05-29T14-30-00-000Z.bak
```
- Up to **10 backups** per file are retained (oldest are pruned)
- The **Backup Manager** lists them and restores any one with a single click
- Before a restore, a `*.pre-restore.bak` snapshot is created
- All writes are **atomic** (write to temp file, then rename) — no risk of corrupted YAML
- All writes are **atomic** (write to temp file, then rename) — no risk of a half-written YAML
## Docker Usage
@@ -125,17 +178,24 @@ services:
environment:
- ADMIN_PASSWORD=your-secure-password
volumes:
- ./content:/app/content # Must be writable for admin panel
- ./content:/app/content # Must be writable for admin panel
```
> [!IMPORTANT]
> When using the admin panel, the content volume must be **read-write** (not `:ro`). Remove the `:ro` flag if you had it set previously.
> The content volume must be **read-write** (not `:ro`). The admin panel, the journal, the favicon upload, the setup wizard and the backup rotation all write into it.
### Reload Button
The admin header includes a **↻ Reload** button that:
1. Invalidates the in-memory config cache
2. Clears the Immich album/asset cache
3. Forces the next request to re-read all YAML files
This is useful after making external changes to config files or when Immich data has changed.
### Diagnostics
The panel reports the health of the installation: whether Immich answers, whether `gallery.yaml` and `settings.yaml` parse, the number of cached entries, and the backup count with the timestamp of the most recent one. `settings.yaml` is optional, so its absence is not a fault — only a file that exists and cannot be parsed counts as invalid. A check that could not run (an expired session, for example) is reported as such rather than as an outage.
Separately, while you are logged in, the public site shows a diagnostic banner on any album Immich returns empty — the case that otherwise looks like a broken page to you and to nobody else.

View File

@@ -51,7 +51,7 @@ wahrgenommenen Reifegrad.
Default 300s). Läuft ein Eintrag ab und Immich antwortet nicht, gibt es keinen
Fallback — die Seite ist tot.
**Idee:** Abgelaufene Einträge nicht wegwerfen, sondern als *stale* markieren.
**Idee:** Abgelaufene Einträge nicht wegwerfen, sondern als _stale_ markieren.
Bei Upstream-Fehler den veralteten Eintrag trotzdem ausliefern, im Hintergrund
erneut versuchen, und (optional konfigurierbar) einen dezenten Hinweis
einblenden. Das öffentliche Portfolio überlebt damit ein Immich-Update,

View File

@@ -6,23 +6,23 @@ _Stand: 2026-05-29 — generiert aus Projekt-Review + ideas.md_
## ✅ Bereits implementiert
| Feature | Datum | Notizen |
|---|---|---|
| **Album-Beschreibungen** | 2026-05-29 | `description:` in `gallery.yaml` (pro Album), eigenes `<p>` unter dem Titel in `AlbumDetailView` |
| Feature | Datum | Notizen |
| ------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------ |
| **Album-Beschreibungen** | 2026-05-29 | `description:` in `gallery.yaml` (pro Album), eigenes `<p>` unter dem Titel in `AlbumDetailView` |
| **Webhook Cache-Invalidierung** | 2026-05-29 | `POST /api/webhook`, HMAC-SHA256, `WEBHOOK_SECRET` in `.env`, gezielte oder vollständige Cache-Invalidierung |
| **Video-Support** | 2026-05-29 | Proxy `GET /api/video/[token]` mit Range-Support, Play-Button im Grid, `<video controls>` in Lightbox |
| **Video-Support** | 2026-05-29 | Proxy `GET /api/video/[token]` mit Range-Support, Play-Button im Grid, `<video controls>` in Lightbox |
---
## ⚡ Quick Wins (offen)
| Idee | Effort | Warum interessant |
|---|---|---|
| **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) |
| **Privacy-Analytics-Integration** | S | Plausible / Umami / GoatCounter via Script-Tag in `settings.yaml` konfigurieren (kein Tracking-Framework nötig) |
| Idee | Effort | Warum interessant |
| ----------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------- |
| **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) |
| **Privacy-Analytics-Integration** | S | Plausible / Umami / GoatCounter via Script-Tag in `settings.yaml` konfigurieren (kein Tracking-Framework nötig) |
---

View File

@@ -30,12 +30,12 @@ unterschiedliche Antworten brauchen:
**Modus A — die Konfiguration wirft.** `getConfig()` wirft an mehreren Stellen hart:
| Stelle | Auslöser |
|---|---|
| Stelle | Auslöser |
| ------------------------- | ------------------------------------------ |
| `lib/config/index.ts:136` | `gallery.yaml` ohne Album und ohne Subpage |
| `lib/config/index.ts:144` | Subpage ohne `name` |
| `lib/config/index.ts:170` | Subpage ohne Alben/Sections |
| `lib/secret.ts:20` | `AUTH_SECRET` fehlt in Production |
| `lib/config/index.ts:144` | Subpage ohne `name` |
| `lib/config/index.ts:170` | Subpage ohne Alben/Sections |
| `lib/secret.ts:20` | `AUTH_SECRET` fehlt in Production |
Nicht in dieser Liste: `validateUuid()` (`lib/config/parser.ts`) wirft **nicht** —
es warnt und liefert bewusst eine Dummy-UUID zurück, damit der Build nicht bricht.
@@ -85,8 +85,8 @@ Immich-Antwort warten, bevor irgendetwas erscheint.
Heute kann die App nicht unterscheiden zwischen „dieses Album existiert nicht"
und „ich kann Immich gerade nicht fragen". Beides endet in `notFound()`.
Das ist nicht nur kosmetisch: Ein 404 sagt Suchmaschinen *„diese URL löschen"*,
ein 503 sagt *„später nochmal versuchen"*. Ein Immich-Update während eines
Das ist nicht nur kosmetisch: Ein 404 sagt Suchmaschinen _„diese URL löschen"_,
ein 503 sagt _„später nochmal versuchen"_. Ein Immich-Update während eines
Google-Crawls kann so die Sichtbarkeit des Portfolios kosten.
**Vorschlag:** Bevor `app/[...path]/page.tsx` `notFound()` aufruft, den
@@ -108,12 +108,12 @@ erst mit der Statusunterscheidung werden sie ein Feature.
### Befund: das Hindernis
`components/SetupScreen.tsx` erklärt vier Dateikopien und endet mit
*„restart your server"*. Ein Assistent müsste stattdessen selbst schreiben — und
_„restart your server"_. Ein Assistent müsste stattdessen selbst schreiben — und
stößt sofort auf ein Problem:
```ts
// lib/env.ts, letzte Zeile
export const env = parseEnv(); // läuft genau einmal beim Modul-Load
export const env = parseEnv(); // läuft genau einmal beim Modul-Load
```
`IMMICH_API_URL` und `IMMICH_API_KEY` kommen aus der Umgebung. Ein Browser-Formular
@@ -145,14 +145,14 @@ aufgelöst werden (Zeilen 53–54). Ein Eingriff, eine Stelle.
Bemerkenswert viel — der Assistent ist eher Verdrahtung als Neubau:
| Baustein | Wo |
|---|---|
| Baustein | Wo |
| --------------------------------------------- | --------------------------------------------------------- |
| `/admin` bleibt während des Setups erreichbar | `app/layout.tsx:68–79`, `isAdminPath()`, Commit `8f0bb94` |
| Atomares YAML-Schreiben mit Backups | `lib/admin/yaml-service.ts` |
| Alle Shared Albums listen (ohne Allowlist) | `GET /api/admin/albums` |
| Visuelle Album-/Asset-Auswahl | `AlbumPicker.tsx`, `AssetPicker.tsx` |
| Theme-Vorschaubilder | `docs/screenshots/theme-*.png` |
| Cache nach dem Speichern invalidieren | `invalidateConfigCache()` |
| Atomares YAML-Schreiben mit Backups | `lib/admin/yaml-service.ts` |
| Alle Shared Albums listen (ohne Allowlist) | `GET /api/admin/albums` |
| Visuelle Album-/Asset-Auswahl | `AlbumPicker.tsx`, `AssetPicker.tsx` |
| Theme-Vorschaubilder | `docs/screenshots/theme-*.png` |
| Cache nach dem Speichern invalidieren | `invalidateConfigCache()` |
Dass `/admin` während `needsSetup` offen bleibt, wurde in `#392` bewusst gebaut —
mit genau dieser Begründung („it is the tool that completes the setup").
@@ -199,12 +199,12 @@ Credentials-Entscheidung (c) sinnvoll anzufangen, weil sie den Rest bestimmt.
Meine ursprüngliche Formulierung war ungenau. `request()` wirft nicht, es gibt
`null` zurück. Wenn Immich weg ist, passiert Folgendes:
| Aufruf | Ergebnis | Für den Besucher |
|---|---|---|
| `getAlbums()` | `[]` | Startseite ohne Alben |
| `getAlbum(id)` | `null` → `notFound()` | **HTTP 404 auf echter URL** |
| `getAssetInfo()` | `null` | Kein EXIF, keine Map-Punkte |
| `/api/image/[id]` | 404 pro Bild | Kaputte Bildplätze |
| Aufruf | Ergebnis | Für den Besucher |
| ----------------- | --------------------- | --------------------------- |
| `getAlbums()` | `[]` | Startseite ohne Alben |
| `getAlbum(id)` | `null` → `notFound()` | **HTTP 404 auf echter URL** |
| `getAssetInfo()` | `null` | Kein EXIF, keine Map-Punkte |
| `/api/image/[id]` | 404 pro Bild | Kaputte Bildplätze |
Immerhin sauber gelöst: das leere Ergebnis wird **nicht** gecacht
(`lib/immich.ts:214`, `if (!all) return []` steht vor `cache.set`) — es brennt
@@ -230,7 +230,7 @@ zuerst `cache.getStale()` versuchen. Die Rückgabe sollte mitführen, ob die Dat
frisch oder stale sind — sonst kann die Seite es nicht anzeigen.
**3. Konsequenz ziehen: nicht `notFound()`, wenn der Upstream tot ist.**
Gibt es weder frische noch stale Daten *und* ist Immich nachweislich unerreichbar,
Gibt es weder frische noch stale Daten _und_ ist Immich nachweislich unerreichbar,
dann werfen statt 404. Das ist derselbe Eingriff wie in Thema 1 — die beiden
Punkte sind eigentlich ein Feature von zwei Seiten. Den Ping nicht neu bauen:
`app/api/health/route.ts` hat bereits einen 10-Sekunden-gecachten, gegen
@@ -286,8 +286,8 @@ Der Einstieg ist also billiger, als es aussieht.
Ein Album aus `gallery.yaml` entfernen → Route muss 404 liefern.
**Token-Grenze.** `/api/image/[id]` mit roher UUID statt Token → 400. Mit einem
Token, das unter einem *anderen* `AUTH_SECRET` erzeugt wurde → 400. `tokens.ts`
ist unit-getestet; hier geht es darum, dass die *Route* das durchsetzt.
Token, das unter einem _anderen_ `AUTH_SECRET` erzeugt wurde → 400. `tokens.ts`
ist unit-getestet; hier geht es darum, dass die _Route_ das durchsetzt.
**Die Lücke, die dokumentiert gehört.** Der Bild-Proxy dekodiert den Token und
streamt (`app/api/image/[id]/route.ts:78`) — er prüft **nicht**, ob das Asset noch
@@ -313,7 +313,7 @@ Secret signierten Token. Das ist genau die Fläche von Commit `37f7f62`.
Entscheidend ist die Bauform: **tabellengetrieben über die Liste der
Admin-Routen**, nicht sieben Einzeltests. Dann schlägt die Suite auch bei einer
*künftig hinzugefügten* Route ohne Guard an — sie testet dann die Regel statt der
_künftig hinzugefügten_ Route ohne Guard an — sie testet dann die Regel statt der
sieben bekannten Fälle. Das ist der Unterschied zwischen einer Suite, die alte
Bugs festhält, und einer, die neue verhindert.

128
docs/deployment.md Normal file
View File

@@ -0,0 +1,128 @@
# Deployment
Operational detail for running Immich Folio: what the setup wizard writes, the
Docker options beyond the basic Compose recipe, and what happens when Immich
goes away.
For the quick path — clone, `npm run dev`, open `/install` — see the
[README](../README.md#quick-start).
**Contents:**
- [What the Setup Wizard Writes](#what-the-setup-wizard-writes)
- [Environment Variables Always Win](#environment-variables-always-win)
- [Docker Compose](#docker-compose)
- [Standalone Docker](#standalone-docker)
- [Health Check](#health-check)
- [Behaviour when Immich is Unreachable](#behaviour-when-immich-is-unreachable)
- [Reverse Proxy](#reverse-proxy)
## What the Setup Wizard Writes
The wizard at `/install` writes three files into `content/`:
| File | Contents |
| ----------------------- | -------------------------------------------------------------------- |
| `content/gallery.yaml` | The albums you picked |
| `content/settings.yaml` | Site title, subtitle, theme — only if it does not exist yet |
| `content/install.json` | Immich URL and API key, a generated site secret, admin password hash |
> [!IMPORTANT]
> **`content/install.json` holds credentials.** It is written with mode `0600`,
> and it is the reason a backup of your `content/` directory is also a backup of
> your Immich API key — treat it accordingly. The admin password is stored as an
> scrypt hash, not as you typed it; the API key and site secret have to stay
> readable to be usable.
Nothing is written until your credentials have been verified against your Immich
server, so a typo cannot leave you with an "installed" site that loads no photos.
## Environment Variables Always Win
`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.
Set all of them up front and the wizard never appears, which is the usual choice
for an infrastructure-as-code deployment.
## Docker Compose
```yaml
services:
lightbox:
build: .
container_name: immich-folio
restart: unless-stopped
ports:
- '7211:7211'
env_file:
- .env.local
volumes:
- ./content:/app/content
```
```bash
docker compose up -d
```
The gallery will be available at `http://localhost:7211`.
## Standalone Docker
```bash
# Build
docker build -t immich-folio .
# Run
docker run -d \
--name immich-folio \
--restart unless-stopped \
-p 7211:7211 \
--env-file .env.local \
-v ./content:/app/content \
immich-folio
```
> [!IMPORTANT]
> The `content/` volume mount lets you update `gallery.yaml` and `about.md`
> without rebuilding the image. It must be **read-write**: the setup wizard, the
> admin panel, the journal, the favicon upload and the backup rotation all write
> into it. A `:ro` mount leaves the wizard unable to complete and the admin panel
> unable to save.
## Health Check
The container includes a built-in health check at `/api/health`:
```bash
curl http://localhost:7211/api/health
```
## Behaviour when Immich is Unreachable
Immich Folio buffers your gallery rather than merely proxying it:
- Album and asset pages keep serving the last known good data for up to `STALE_MAX_AGE`, so a restarting or briefly unreachable Immich does not take the public site down with it.
- Once nothing cached is left, they return `503`, never `404` — a `404` would tell search engines to drop a URL for content that still exists.
- Outages are never cached, so the gallery recovers as soon as Immich does.
The cache lives in the process, so it is empty right after a container restart.
## Reverse Proxy
Put Immich Folio behind nginx / Caddy / Traefik with TLS. Example Caddy config:
```
photos.example.com {
reverse_proxy localhost:7211
}
```
> [!IMPORTANT]
> Set `TRUSTED_PROXY_HOPS` to the number of proxies in front of the app, and
> make sure they forward `X-Forwarded-For` correctly — otherwise the client IP
> comes from a header the client itself can set, which defeats the brute-force
> limits on the password endpoints. See
> [Trusted Proxies](gallery-config.md#trusted-proxies) for the setting and a
> matching nginx config.

View File

@@ -1,24 +1,50 @@
# Gallery Configuration
All gallery structure is defined in `content/gallery.yaml`. Copy the example to get started:
Configuration is split across two files in `content/`:
| File | Contains |
| --------------- | ---------------------------------------------------------------------------- |
| `gallery.yaml` | **Structure** — hero assets, albums, subpages, sections, per-album settings |
| `settings.yaml` | **Behaviour and identity** — title, theme, grid defaults, footer, legal, SEO |
Copy the examples to get started:
```bash
cp content/gallery.yaml.example content/gallery.yaml
cp content/settings.yaml.example content/settings.yaml
```
`settings.yaml` is optional — without it, everything falls back to defaults.
> [!TIP]
> Prefer a visual interface? Enable the **[Admin Panel](admin-panel.md)** by setting `ADMIN_PASSWORD` in your environment. It provides a drag-and-drop page builder that writes to these same YAML files automatically.
**Contents:**
`gallery.yaml`
- [Hero Images](#hero-images)
- [Standalone Albums](#standalone-albums)
- [Subpages & Categories](#subpages)
- [Per-Album Options](#per-album-options)
- [Photo Order Within an Album](#photo-order-within-an-album)
`settings.yaml`
- [Grid Layout](#grid-layout)
- [EXIF on Hover](#exif-on-hover)
- [Site Behaviour](#site-behaviour)
- [Navigation Links](#navigation-links)
- [Client Proofing](#client-proofing)
- [Image Protection & Watermark](#image-protection--watermark)
- [SEO](#seo)
- [Footer](#footer)
Other
- [About Page](#about-page)
- [Journal & Photo Essays](journal.md)
- [Finding UUIDs](#finding-uuids)
- [System & Security](#system--security)
## Hero Images
@@ -82,6 +108,43 @@ subpages:
- 33333333-3333-3333-3333-333333333333
```
### 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 |
```yaml
subpages:
- name: Client Preview
hidden: true # unlisted: not in the nav, still reachable at /client-preview
proofing: true # visitors can mark favourites and export the selection
password: 'clientpass123'
albums:
- 55555555-5555-5555-5555-555555555555
- name: Old Series
enabled: false # offline, keeps its configuration
albums:
- 66666666-6666-6666-6666-666666666666
```
> [!NOTE]
> `hidden` is unlisting, not access control. The URL is guessable — a slug is
> derived from the name — and nothing stops a visitor who has it. Combine it
> with `password` for anything that must stay private.
### Sections
When a subpage contains many albums you can split them into **named sections**. A typographic table of contents with anchor links is automatically rendered above the albums. Sections are fully optional — omit them and you get the standard flat grid.
@@ -94,18 +157,7 @@ Each section can have:
| `description` | string | ➖ | Optional subline under the heading |
| `albums` | list | ✅ | Album UUIDs (same format as regular albums) |
Within the `albums` list you can use a plain UUID, a simple title override, or an object with any of `title`, `description`, `password`, `heroImage`, `sort` and `assetOrder`:
```yaml
albums:
- 'album-uuid' # → uses the Immich album name
- 'album-uuid': My Title # → displays "My Title" instead
- 'album-uuid':
title: My Private Title
password: 'secure-password' # → adds a password gate to this specific album
heroImage: 'asset-uuid' # → overrides the album cover
sort: filename # → see "Photo Order Within an Album" below
```
Within the `albums` list you can use a plain UUID, a simple title override, or an object — see [Per-Album Options](#per-album-options).
**Full example:**
@@ -135,6 +187,86 @@ subpages:
> [!NOTE]
> Each section title is automatically converted to a URL-safe anchor (`#tokyo`, `#kyoto`, …). The TOC appearance (separator character, numbering style, section rule) is fully controlled by the active theme.
## Per-Album Options
Every album entry — standalone, on a subpage, or inside a section — accepts the
same three notations:
```yaml
albums:
- 'album-uuid' # → uses the Immich album name
- 'album-uuid': My Title # → displays "My Title" instead
- 'album-uuid':
title: My Private Title
description: 'Shot on 35mm film in Vienna, spring 2025.'
password: 'secure-password'
heroImage: 'asset-uuid'
sort: filename
```
| Key | Description |
| --------------- | ----------------------------------------------------------------------------- |
| `title` | Display name instead of the Immich album name |
| `description` | Shown below the album title |
| `password` | Gates this album alone — see [Password Protection](#password-protection) |
| `heroImage` | Asset UUID overriding the album cover |
| `sort` | Photo order — see [Photo Order Within an Album](#photo-order-within-an-album) |
| `assetOrder` | Pinned asset UUIDs for `sort: manual` |
| `grid` | **Experimental.** Grid override for this album only |
| `coverPosition` | **Experimental.** Focal point for the cover crop |
### Per-album grid (experimental)
An album can override the grid it is shown in. It is merged over the subpage
grid, which is merged over the global default — so an album only has to state
what differs:
```yaml
albums:
- 'album-uuid':
title: Panoramas
grid:
layout: justified
columns: 2
```
### Cover focal point (experimental)
Album covers are cropped to the card. `coverPosition` decides which part
survives the crop — the same values CSS `object-position` takes:
```yaml
albums:
- 'album-uuid':
coverPosition: '50% 25%' # or: "top", "center bottom", "left"
```
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
`HttpOnly` cookie that is valid for 24 hours; nothing is stored server-side.
Three storage formats are accepted:
| Format | Status |
| ------------------ | --------------------------------------------------------------------- |
| `scrypt:salt:hash` | **Recommended.** The password is not recoverable from `gallery.yaml`. |
| Plaintext | Works, but deprecated — logs a warning naming the recommended hash. |
| bcrypt (`$2a$…`) | Rejected. |
To get the hash for an existing plaintext password, unlock the gallery once and
read the server log: the successful unlock prints the matching `scrypt:…` line
to paste back into `gallery.yaml`. The admin panel writes the hashed form
directly.
> [!NOTE]
> Album and subpage gates protect the **page**. Image URLs handed out while a
> gallery was unlocked keep working — see
> [Image Token Revocation](#image-token-revocation).
## 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.
@@ -178,37 +310,169 @@ The `/admin` page builder has a drag & drop editor for this (album → **Photo o
## Grid Layout
Configure the photo grid globally or per-subpage:
The global default lives in `settings.yaml`; subpages and individual albums can
override it in `gallery.yaml`. The three levels merge, most specific winning:
**global (`settings.yaml`) → subpage → album**
```yaml
# Global defaults
# settings.yaml — global defaults
grid:
columns: 3 # number of columns (default: 3)
gap: 12 # gap in pixels (default: 12)
aspectRatio: '1' # "1", "3/2", "auto", etc. (default: "1")
layout: masonry # "masonry" | "uniform" | "showcase" | "filmstrip" | "editorial-flow"
aspectRatio: '1' # "1", "3/2", "2/3", "16/9", "auto" (default: "1")
layout: masonry
```
```yaml
# gallery.yaml — per-subpage override
subpages:
- name: Japan
grid: # per-subpage override
grid:
columns: 4
layout: uniform
aspectRatio: '3/2'
albums:
- '33333333-3333-3333-3333-333333333333'
```
Available layouts: `masonry`, `uniform`, `showcase`, `filmstrip`,
`editorial-flow`, `justified` (experimental), and `essay`
(see [Journal & Photo Essays](journal.md)). Each is illustrated in the
**[Theming guide](theming.md#gridlayout)**.
## Site Behaviour
Feature toggles in `settings.yaml`:
```yaml
title: 'My Portfolio'
subtitle: 'A visual journal'
lang: 'en'
exifOnHover: true # camera/lens/settings on photo grid hover
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
analytics: true # cookieless view counts, see below
about:
enabled: true # the /about page, rendered from content/about.md
```
| Key | Default | Notes |
| --------------- | ------- | ------------------------------------------- |
| `lang` | `en` | `<html lang>` and date formatting |
| `exifOnHover` | on | |
| `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 |
### Analytics
Page and album view counts, stored in `content/analytics.json` — no cookies, no
third party, nothing leaving your server. The numbers are shown in the admin
panel's **Analytics** tab.
```yaml
analytics: false # stop counting entirely
```
With `analytics: false` the tracking endpoint refuses to record, so no data is
collected regardless of what the browser sends.
## Navigation Links
**Experimental.** External links appended to the header navigation. Only
`http(s)` URLs are accepted, and they open in a new tab:
```yaml
navLinks:
- label: 'Shop'
url: 'https://shop.example.com'
- label: 'Instagram'
url: 'https://instagram.com/your-handle'
```
## Client Proofing
Lets a visitor mark favourites and export the selection — for handing a client a
gallery and getting their picks back. A heart button sits on every photo in the
grid; once something is picked, a bar appears with a filter and an export
dialog.
The selection is encoded into the URL as a compact bitmask and mirrored into
`localStorage`, so **nothing is stored server-side** and a set of picks can be
shared, bookmarked, or sent back as a plain link.
Configured in `settings.yaml`:
```yaml
proofing:
enabled: true # hearts, selection bar and export modal (default: true)
allowMailto: true # offer "send by email" alongside the copyable list (default: true)
```
A subpage overrides `enabled` in either direction — useful to keep proofing off
across a public portfolio and switch it on for a single client handover:
```yaml
# gallery.yaml
subpages:
- name: Wedding – Smith
password: clientpass123
proofing: true # or `false` to disable it on this subpage only
albums:
- ...
```
## EXIF on Hover
Precedence is **subpage → global**: a subpage's own `proofing:` wins, and an
album reached without a subpage follows `proofing.enabled`.
Show camera/lens/settings when hovering over photos (enabled by default):
> [!NOTE]
> Photo essays are the exception. A published story is not an album handover,
> so an essay (`layout: essay`, `essayFile`, `essayText`) shows the proofing
> controls only when its subpage sets `proofing: true` **explicitly** — the
> global default does not reach into essays. Journal entries never show them.
## Image Protection & Watermark
```yaml
exifOnHover: false # set to false to disable
protection:
disableRightClick: true # suppress the context menu on images
disableImageDrag: true # block drag-to-desktop
watermark:
enabled: true
text: '© Your Name'
opacity: 0.5
position: 'bottom-right' # bottom-right | bottom-left | center
```
> [!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
> an overlay on the lightbox, not burned into the file.
## SEO
```yaml
seo:
title: 'My Portfolio | Photography'
description: 'A curated selection of my best photography work.'
titleTemplate: '%s | My Portfolio' # %s is the subpage or album title
noIndex: false # true → ask search engines not to index the site
noFollow: false # true → ask search engines not to follow links
```
`titleTemplate` shapes the browser and search-result title of every subpage and
album; the homepage uses `title` unchanged.
## Footer
Optional minimal footer with social links:
Optional minimal footer with social links, in `settings.yaml`:
```yaml
footer:
@@ -218,6 +482,9 @@ footer:
website: https://example.com
```
A `legal:` block with `enabled: true` adds an Impressum page at `/impressum` and
links it in the footer — see `content/settings.yaml.example` for every field.
## About Page
Create `content/about.md` with YAML frontmatter:
@@ -235,6 +502,26 @@ gear:
Your bio text here. Supports full Markdown.
```
The page is served at `/about` and linked in the navigation. Turn it off in
`settings.yaml` without deleting the file:
```yaml
about:
enabled: false
```
The admin panel's **Settings → About** section edits the same file — portrait,
name, location, gear list and bio — so `about.md` does not have to be written by
hand.
## Journal & Photo Essays
Long-form storytelling with fullbleed photos, side-by-side pairs, quotes and
captions lives in `content/journal/`, served at `/journal`. A subpage can also
be turned into a single photo essay.
→ **[Journal & Photo Essays](journal.md)**
## Finding UUIDs
- **Album UUIDs**: In Immich, go to Albums → click an album → the UUID is in the URL bar
@@ -246,9 +533,25 @@ Advanced system settings are configured via environment variables in your `.env`
### Rate Limiting
To protect against brute-force attacks and resource exhaustion, Immich Folio includes an in-memory rate limiter.
To protect against brute-force attacks and resource exhaustion, Immich Folio includes an in-memory rate limiter. Each endpoint has its own bucket, so heavy image traffic cannot exhaust the budget for the password endpoints.
- `RATE_LIMIT_RPM`: Maximum requests per minute per IP (default: `1500` for general API, `120` for map data).
- `RATE_LIMIT_RPM`: requests per minute per IP for image, video, EXIF and health requests (default: `1500`).
The endpoints where a high limit would be the wrong default are fixed and not configurable:
| Endpoint | Limit (req/min/IP) |
| ---------------------- | ------------------ |
| `POST /api/admin/auth` | 5 |
| `POST /api/auth` | 10 |
| `/api/install` | 10 |
| `/api/og` | 30 |
| `/api/install/albums` | 30 |
| `/api/webhook` | 60 |
| `/api/map` | 120 |
> [!IMPORTANT]
> The limiter lives in the process memory of a single instance. It does not
> coordinate across replicas, and it starts empty after a restart.
### Trusted Proxies

View File

@@ -7,11 +7,11 @@ _Status: Entwurf genehmigt (Brainstorming abgeschlossen) · Datum: 2026-08-14_
## 1. Understanding Summary
- **Ziel & Vision:** Bereitstellung eines intuitiven, erstklassigen **Journal Studios** für Fotografen, um Bild-Text-Reportagen, Reisetagebücher und Ausstellungsberichte (im Stil von Leica Journal / National Geographic) zu verfassen und zu veröffentlichen.
- **Zielgruppe:**
- **Zielgruppe:**
- **Besucher:** Immersives, typografisch elegantes Leseerlebnis mit progressiv ladenden Bildern (ThumbHash), responsiven Layouts und Lightbox-Interaktion.
- **Fotograf:** Mühelose Redaktion im Admin-Panel mit Live-Split-Screen-Vorschau und universeller Immich-Bildauswahl.
- **Konzept:** Reines Markdown mit Frontmatter im Content-Volume (`content/journal/*.md`), verwaltet über eine dedizierte Admin-UI und ausgeliefert über saubere Next.js Server Components.
- **Kern-Vorteile:**
- **Kern-Vorteile:**
- Trennung von Galerie-Konfiguration (`gallery.yaml`) und redaktionellen Inhalten.
- Volle Git- & Backup-Kompatibilität durch Standard-Markdown.
- Schnelle Erstellung ohne manuelle UUID-Eingabe.
@@ -29,30 +29,31 @@ _Status: Entwurf genehmigt (Brainstorming abgeschlossen) · Datum: 2026-08-14_
## 3. Decision Log
| # | Entscheidung | Alternativen | Begründung |
|---|---|---|---|
| 1 | **Nomenklatur "Journal"** | Stories, Photo Essays, Magazin | "Stories" wirkt wie flüchtige Social-Media-Inhalte. "Journal" spiegelt zeitlose, fotografische Wertigkeit (wie Leica Journal) wider. |
| 2 | **Architektur-Ansatz 1 (Eigenständiges Studio)** | Subpage-Drawer, Quick-Fix | Maximale Schreib- und Layout-Ergonomie durch echten Split-Screen und Entkopplung von `gallery.yaml`. |
| 3 | **Persistenz als `.md`-Dateien** | Strings in `gallery.yaml`, SQLite/DB | Behält das dateibasierte, zustandsarme Self-Hosting-Paradigma bei; einfache Versionierung via Git. |
| 4 | **Bidirektionaler Editor (Blöcke ↔ Markdown)** | Nur WYSIWYG, Nur Markdown | Fotografen können visuell Blöcke anordnen oder direkt mit Markdown-Code arbeiten, ohne Datenverlust. |
| 5 | **Sicherheit via Slug-Sanitisierung** | Ungeprüfte Dateinamen | Verhindert Path Traversal (`../`) im Docker-Container/Dateisystem. |
| # | Entscheidung | Alternativen | Begründung |
| --- | ------------------------------------------------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| 1 | **Nomenklatur "Journal"** | Stories, Photo Essays, Magazin | "Stories" wirkt wie flüchtige Social-Media-Inhalte. "Journal" spiegelt zeitlose, fotografische Wertigkeit (wie Leica Journal) wider. |
| 2 | **Architektur-Ansatz 1 (Eigenständiges Studio)** | Subpage-Drawer, Quick-Fix | Maximale Schreib- und Layout-Ergonomie durch echten Split-Screen und Entkopplung von `gallery.yaml`. |
| 3 | **Persistenz als `.md`-Dateien** | Strings in `gallery.yaml`, SQLite/DB | Behält das dateibasierte, zustandsarme Self-Hosting-Paradigma bei; einfache Versionierung via Git. |
| 4 | **Bidirektionaler Editor (Blöcke ↔ Markdown)** | Nur WYSIWYG, Nur Markdown | Fotografen können visuell Blöcke anordnen oder direkt mit Markdown-Code arbeiten, ohne Datenverlust. |
| 5 | **Sicherheit via Slug-Sanitisierung** | Ungeprüfte Dateinamen | Verhindert Path Traversal (`../`) im Docker-Container/Dateisystem. |
---
## 4. Technische Architektur & Komponenten
### A. Dateisystem & Datenmodell
Jeder Eintrag ist eine `.md`-Datei mit folgendem Schema:
```markdown
---
title: "Expedition Nordkap"
subtitle: "Mit dem Bulli durch die Fjorde"
date: "2026-08-14"
author: "Ralf"
coverAssetId: "b8c2d111-0000-4000-8000-000000000000"
password: "" # Optionaler Passwortschutz (scrypt)
draft: false # Entwurf oder öffentlich
title: 'Expedition Nordkap'
subtitle: 'Mit dem Bulli durch die Fjorde'
date: '2026-08-14'
author: 'Ralf'
coverAssetId: 'b8c2d111-0000-4000-8000-000000000000'
password: '' # Optionaler Passwortschutz (scrypt)
draft: false # Entwurf oder öffentlich
---
Hier beginnt der Text der Reportage...
@@ -61,6 +62,7 @@ Hier beginnt der Text der Reportage...
```
Unterstützte Block-Typen:
- **Heading:** `# H1`, `## H2`, `### H3`
- **Paragraph:** Fliesstext mit Inline-Markdown (`**fett**`, `*kursiv*`, `[Link](url)`)
- **Pullquote:** `> Zitattext` mit optionalem `— Autor`
@@ -81,7 +83,7 @@ Unterstützte Block-Typen:
### C. UI: Das Journal Studio (`/admin` ➔ Tab "Journal")
1. **Journal Dashboard:**
- Übersicht aller Einträge als Karten mit Thumbnail, Status (*Draft*, *Live*, *Protected*), Erstellungsdatum.
- Übersicht aller Einträge als Karten mit Thumbnail, Status (_Draft_, _Live_, _Protected_), Erstellungsdatum.
- Button `+ New Journal Entry`.
2. **Studio Split-Screen Editor:**
- **Top Navigation:** Titel-Eingabe, Slug, Status-Schalter, Metadaten-Modal (Autor, Datum, Passwort, Cover-Foto) und Save-Button (`Cmd+S`).

244
docs/journal.md Normal file
View File

@@ -0,0 +1,244 @@
# Journal & Photo Essays
Immich Folio has two ways to publish long-form, text-and-image storytelling. They
share one Markdown format and one renderer, and differ only in where the result
appears:
| Feature | Lives in | URL | Use for |
| --------------- | ---------------------- | ----------------- | ---------------------------------------------------------------- |
| **Journal** | `content/journal/*.md` | `/journal/<slug>` | A running series of stories, listed on a `/journal` index page |
| **Photo Essay** | `content/essays/*.md` | the subpage's URL | Turning one subpage of your gallery into a single narrative page |
If you are unsure: use the **Journal**. A photo essay is the older mechanism and
is tied to a subpage; the journal is a standalone section with its own index,
cover images, reading times, and drafts.
**Contents:**
- [Journal Entries](#journal-entries)
- [Frontmatter](#frontmatter)
- [Block Syntax](#block-syntax)
- [Drafts](#drafts)
- [Password-protected Entries](#password-protected-entries)
- [Journal Studio](#journal-studio)
- [Photo Essays on a Subpage](#photo-essays-on-a-subpage)
## Journal Entries
Each entry is one Markdown file in `content/journal/`. The filename is the URL
slug — `content/journal/iceland-highlands.md` is served at
`/journal/iceland-highlands`.
A `/journal` link appears in the site navigation automatically as soon as at
least one **published** (non-draft) entry exists. There is no setting to turn
the journal on: an empty `content/journal/` directory means no journal.
<p align="center">
<img src="screenshots/journal-index.png" width="98%" alt="The journal index listing entries with cover images, dates and reading times" />
</p>
<p align="center"><em>The index at <code>/journal</code> — cover image, title, date and reading time per entry.</em></p>
A template ships with the project:
```bash
cp content/journal/sample-story.md.example content/journal/my-first-story.md
```
The template is an `.example` file on purpose — it references no asset IDs
(those are specific to your Immich server), so copying it gives you a working
skeleton rather than a page full of broken images.
## Frontmatter
Optional YAML block at the top of the file:
```markdown
---
title: 'The Icelandic Highlands'
subtitle: 'A visual journey across volcanic deserts and arctic silence'
author: 'Your Name'
date: '2026-08-05'
coverAssetId: 'asset-uuid-for-the-cover'
draft: false
password: 'clients-only'
---
```
| Field | Description |
| -------------- | ---------------------------------------------------------------------------------------- |
| `title` | Entry heading and browser/OG title. Defaults to the slug. |
| `subtitle` | Subline under the heading; also used as the meta description. |
| `author` | Shown in the entry byline. |
| `date` | Publication date shown on the index and the entry. |
| `coverAssetId` | Immich asset UUID used as the index card image and the OG preview image. |
| `draft` | `true` hides the entry from visitors — see [Drafts](#drafts). |
| `password` | Gates this single entry — see [Password-protected Entries](#password-protected-entries). |
Reading time and the index excerpt are computed from the text; you do not
configure them.
Every one of these fields is also a form control in the
[Journal Studio](#journal-studio), behind **Story Settings**:
<p align="center">
<img src="screenshots/admin-journal-settings.png" width="98%" alt="The Story Settings dialog: subtitle, author, publish date, cover photo, password and draft toggle" />
</p>
<p align="center"><em>The same frontmatter, as a form — including the cover picker, the password field and the draft toggle.</em></p>
## Block Syntax
The parser is deliberately small. It understands headings, paragraphs, quotes
and photos — one block per blank-line-separated chunk.
### Headings
```markdown
# Into the Wilderness
## Landmannalaugar
```
`#` through `######` are supported.
### Text
Plain paragraphs. Inline `**bold**`, `*italic*` and `[links](https://example.com)`
work; links open in a new tab. Everything else is escaped rather than passed
through, so a paragraph can never inject HTML into the page.
### Quotes
```markdown
> Silence in the highlands is a physical weight. -- Anonymous
```
Text after a `--` separator becomes the attribution line.
### Photos
Photos are referenced by their **Immich asset UUID**, not by a file path:
```markdown
![asset-uuid:fullbleed](Optional caption)
![asset-uuid:wide](Optional caption)
![asset-uuid](Optional caption)
```
| Suffix | Width |
| ------------ | --------------------------------------------- |
| `:fullbleed` | Edge to edge, breaking out of the text column |
| `:wide` | Wider than the text column, but still inset |
| _(none)_ | Contained — same width as the text |
Two UUIDs separated by a comma render as a side-by-side pair, sized to their
real aspect ratios rather than forced equal:
```markdown
![asset-uuid-left, asset-uuid-right](Optional caption)
```
Photos in an entry open in the same lightbox as the rest of the site, with
keyboard and swipe navigation.
<p align="center">
<img src="screenshots/journal-entry.png" width="98%" alt="A rendered journal entry with heading, body text, a fullbleed photo and a quote" />
</p>
<p align="center"><em>A rendered entry — headings, body text, a fullbleed photo and a pull quote, from the blocks above.</em></p>
> [!TIP]
> Writing asset UUIDs by hand is tedious. In the [Journal Studio](#journal-studio),
> **Add Block → Photo** opens the asset picker and fills the UUID in for you.
## Drafts
`draft: true` in the frontmatter keeps an entry out of the `/journal` index and
out of the navigation link check — but a logged-in admin still sees it, both in
the index and at its URL. That makes the draft a preview of the real page rather
than a separate preview mode.
Drafts are not secret: the entry stays reachable at its URL for anyone who knows
the slug. Use a `password` if it must not be readable.
## Password-protected Entries
A `password` in the frontmatter puts the entry behind the same gate used for
subpages and albums. The unlock cookie is `lb_auth_journal_<slug>` and expires
after 24 hours.
Store a hash rather than the plaintext where possible — the `scrypt:salt:hash`
format described in [Gallery Configuration](gallery-config.md) applies here too.
A plaintext password works but is logged as a warning at startup.
## Journal Studio
The admin panel's **Journal** tab lists every entry — drafts included, marked as
such — with its cover, date and password state:
<p align="center">
<img src="screenshots/admin-journal-list.png" width="98%" alt="The Journal tab listing entries with covers, dates and draft badges" />
</p>
<p align="center"><em>Published and draft entries side by side; the lock marks an entry with a password.</em></p>
Opening one gives a split-screen editor: blocks on the left, a live preview of
the real page on the right, with a draggable divider between them (mouse,
keyboard, and touch).
<p align="center">
<img src="screenshots/admin-journal-studio.png" width="98%" alt="The Journal Studio: block editor on the left, live preview of the entry on the right" />
</p>
<p align="center"><em>The Journal Studio — every block on the left, the real page on the right.</em></p>
- **Add Block** — heading, text, quote, photo, or photo pair
- **Photo picker** — browse your Immich library and insert the asset UUID
- **Cover image, title, subtitle, author, date, draft, password** — the
frontmatter fields, as form controls
- **Reorder and delete** blocks; each block carries a type chip so a long entry
stays scannable
- **Save** writes `content/journal/<slug>.md` in exactly the format documented
above, with a backup of the previous version in `content/journal/.backups/`
Everything the studio writes can be edited by hand afterwards, and vice versa —
the studio parses the same files.
> [!NOTE]
> Entries written before the current photo syntax may contain position-based
> photo references. The studio marks those blocks instead of silently
> misrendering them, so they can be repointed at an asset UUID.
## Photo Essays on a Subpage
A subpage can render a Markdown essay instead of an album grid. Point it at a
file, inline the text, or let it build itself from the albums:
```yaml
subpages:
- name: Highlands
title: 'Icelandic Highlands'
subtitle: 'A Photo Essay'
essayFile: 'sample-story' # content/journal/sample-story.md
albums:
- 'album-uuid-iceland-1'
```
| Key | Description |
| ------------- | ------------------------------------------------------------------------------------------------- |
| `essayFile` | A **slug**, not a path — resolved as `content/journal/<slug>.md`, then `content/essays/<slug>.md` |
| `essayText` | The essay Markdown inline in `gallery.yaml` — what the admin block editor writes |
| `grid.layout` | `essay` — renders as an essay even without `essayFile` or `essayText` |
Any one of the three switches the page into essay mode; setting `essayFile` or
`essayText` implies it, so `grid.layout: essay` is only needed on its own.
> [!IMPORTANT]
> `essayFile` takes a bare slug — letters, digits, `-` and `_`. A path such as
> `content/essays/sample-story.md` is rejected (dots and slashes are not valid
> slug characters) and the page falls back to the generated essay below.
The Markdown format is identical to a journal entry, including the photo block
syntax. The admin panel has a visual block editor for `essayText` under the
subpage's settings.
The album list is used either way: it supplies the photos the lightbox pages
through. And with `grid.layout: essay` but no Markdown at all, the essay is
generated from those albums — one heading per album, followed by its photos,
using each photo's Immich description as the caption.

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

BIN
docs/screenshots/header.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

View File

@@ -14,17 +14,17 @@
Der ursprüngliche Plan (v1) entstand gegen v0.9.1. PR #394 hat davon einen großen Teil umgesetzt — teils anders und besser als geplant. Ersatzlos gestrichen:
| Ehemalige Task | Status in v0.9.2 |
|---|---|
| Upstream-Statusmodul (`lib/upstream.ts`) | **Überholt.** Statt eines Seiten-Pings unterscheidet `request()` jetzt an der Quelle: `ImmichUnavailableError` für Ausfälle, `null` nur für 404/410. Das ist die bessere Lösung — kein Nebenkanal, keine Race zwischen Ping und Abruf. |
| 404-vs-503-Politik in `app/[...path]/page.tsx` | **Erledigt.** Der Fehler propagiert, Seiten liefern 5xx statt 404. Zusätzlich geben `/api/exif` und `/api/map` 503 mit `Retry-After`. |
| `app/error.tsx`, `app/not-found.tsx` | **Erledigt**, mit `.empty-state` aus `globals.css` und 8 Tests in `lib/__tests__/error-pages.test.ts`. |
| `app/global-error.tsx` | **Nicht mehr nötig.** Das Layout wirft nicht mehr: `getConfigOrNull()` (`lib/config/index.ts:39`) fängt ab und rendert den SetupScreen, wobei `/admin` erreichbar bleibt. Das war der einzige konkrete Anlass für die Boundary. |
| Bild-Größen-Tiers | **Erledigt**, nach `lib/imageSize.ts` ausgelagert und getestet. |
| Ehemalige Task | Status in v0.9.2 |
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Upstream-Statusmodul (`lib/upstream.ts`) | **Überholt.** Statt eines Seiten-Pings unterscheidet `request()` jetzt an der Quelle: `ImmichUnavailableError` für Ausfälle, `null` nur für 404/410. Das ist die bessere Lösung — kein Nebenkanal, keine Race zwischen Ping und Abruf. |
| 404-vs-503-Politik in `app/[...path]/page.tsx` | **Erledigt.** Der Fehler propagiert, Seiten liefern 5xx statt 404. Zusätzlich geben `/api/exif` und `/api/map` 503 mit `Retry-After`. |
| `app/error.tsx`, `app/not-found.tsx` | **Erledigt**, mit `.empty-state` aus `globals.css` und 8 Tests in `lib/__tests__/error-pages.test.ts`. |
| `app/global-error.tsx` | **Nicht mehr nötig.** Das Layout wirft nicht mehr: `getConfigOrNull()` (`lib/config/index.ts:39`) fängt ab und rendert den SetupScreen, wobei `/admin` erreichbar bleibt. Das war der einzige konkrete Anlass für die Boundary. |
| Bild-Größen-Tiers | **Erledigt**, nach `lib/imageSize.ts` ausgelagert und getestet. |
Obendrein neu und relevant: `IMMICH_TIMEOUT_MS`, ein `MISSING`-Sentinel, der definitive 404-Antworten cacht, `middleware.ts` → `proxy.ts`, sowie 210 statt 98 Unit-Tests.
**Was das für Task 1 bedeutet:** Die vitest-Grenze wurde in #394 zweimal *umgangen* statt gehoben — `lib/imageSize.ts` wurde eigens ausgelagert, damit die Größenlogik aus `lib/__tests__/` erreichbar ist, und `error-pages.test.ts` benutzt `createElement` statt JSX, weil eine `.tsx`-Datei dort nie ausgeführt würde (so im Commit-Text vermerkt). Die Sicherheitslogik, die sich nicht auslagern lässt — Admin-Guards und Content-Type-Sanitisierung — ist deshalb weiterhin ungetestet.
**Was das für Task 1 bedeutet:** Die vitest-Grenze wurde in #394 zweimal _umgangen_ statt gehoben — `lib/imageSize.ts` wurde eigens ausgelagert, damit die Größenlogik aus `lib/__tests__/` erreichbar ist, und `error-pages.test.ts` benutzt `createElement` statt JSX, weil eine `.tsx`-Datei dort nie ausgeführt würde (so im Commit-Text vermerkt). Die Sicherheitslogik, die sich nicht auslagern lässt — Admin-Guards und Content-Type-Sanitisierung — ist deshalb weiterhin ungetestet.
## Global Constraints
@@ -44,25 +44,25 @@ Obendrein neu und relevant: `IMMICH_TIMEOUT_MS`, ein `MISSING`-Sentinel, der def
**Neu:**
| Datei | Verantwortung |
|---|---|
| Datei | Verantwortung |
| ---------------------------------------------- | ---------------------------------------------------------- |
| `app/api/admin/__tests__/admin-guards.test.ts` | Tabellengetrieben: jede Admin-Route lehnt ohne Session ab. |
| `app/api/image/__tests__/image-route.test.ts` | Token-Grenze, Content-Type-Sanitisierung, ETag/304. |
| `lib/__tests__/cache-stale.test.ts` | Stale-Fenster und harte Obergrenze des Caches. |
| `app/loading.tsx` | Skeleton für die Startseite. |
| `app/[...path]/loading.tsx` | Skeleton für Album-/Subpage-Routen. |
| `app/loading.module.css` | Skeleton-Styles. |
| `app/api/image/__tests__/image-route.test.ts` | Token-Grenze, Content-Type-Sanitisierung, ETag/304. |
| `lib/__tests__/cache-stale.test.ts` | Stale-Fenster und harte Obergrenze des Caches. |
| `app/loading.tsx` | Skeleton für die Startseite. |
| `app/[...path]/loading.tsx` | Skeleton für Album-/Subpage-Routen. |
| `app/loading.module.css` | Skeleton-Styles. |
**Geändert:**
| Datei | Änderung |
|---|---|
| `vitest.config.ts` | `include` um `app/**/__tests__/**/*.test.ts` erweitern. |
| `lib/cache.ts` | Zwei Deadlines pro Eintrag, `getStale()`. |
| `lib/immich.ts` | `ImmichUnavailableError` abfangen, Stale-Fallback, sonst weiterwerfen. |
| `lib/env.ts`, `.env.local.example` | `STALE_MAX_AGE`. |
| `lib/config/schema.ts`, `lib/config/index.ts` | `staleMaxAge` in `AppConfig` und beide Rückgabepfade. |
| `README.md`, `CLAUDE.md` | Stale-Verhalten und Token-Widerruf dokumentieren. |
| Datei | Änderung |
| --------------------------------------------- | ---------------------------------------------------------------------- |
| `vitest.config.ts` | `include` um `app/**/__tests__/**/*.test.ts` erweitern. |
| `lib/cache.ts` | Zwei Deadlines pro Eintrag, `getStale()`. |
| `lib/immich.ts` | `ImmichUnavailableError` abfangen, Stale-Fallback, sonst weiterwerfen. |
| `lib/env.ts`, `.env.local.example` | `STALE_MAX_AGE`. |
| `lib/config/schema.ts`, `lib/config/index.ts` | `staleMaxAge` in `AppConfig` und beide Rückgabepfade. |
| `README.md`, `CLAUDE.md` | Stale-Verhalten und Token-Widerruf dokumentieren. |
**Abhängigkeiten:**
@@ -79,14 +79,16 @@ Task 4 (Doku) ── nach Task 2
**Warum:** `vitest.config.ts` schließt alles außerhalb von `lib/__tests__/` aus. Zwei Stellen sind deshalb ungetestet und lassen sich auch nicht auslagern: die Guards der acht Admin-Routen und die Content-Type-Normalisierung in `app/api/image/[id]/route.ts:99-107`, die aus einem Stored-XSS-Fix stammt (`c2fa8e7`).
**Erwartungshaltung:** Beide Mechanismen funktionieren heute korrekt. Diese Tests finden keinen Bug — sie sind Regressionsbremsen. Der Wert der Admin-Tabelle liegt darin, dass eine *künftig* ergänzte Route ohne Guard die Suite rot macht.
**Erwartungshaltung:** Beide Mechanismen funktionieren heute korrekt. Diese Tests finden keinen Bug — sie sind Regressionsbremsen. Der Wert der Admin-Tabelle liegt darin, dass eine _künftig_ ergänzte Route ohne Guard die Suite rot macht.
**Files:**
- Modify: `vitest.config.ts`
- Create: `app/api/admin/__tests__/admin-guards.test.ts`
- Create: `app/api/image/__tests__/image-route.test.ts`
**Interfaces:**
- Consumes: `isAdminAuthenticated()`, `isAdminEnabled()` (`lib/admin/auth.ts`, gemockt); `encodeAssetId()` (`lib/tokens.ts`); `immich.streamAsset()` (gemockt).
- Produces: nichts für spätere Tasks.
@@ -414,6 +416,7 @@ Run: `npm run test:unit -- app/api/image/__tests__/image-route.test.ts`
Erwartung: 12 Tests PASS.
Zwei mögliche Stolperstellen — beide zuerst gegen den Code prüfen, bevor der Test angepasst wird:
- Falls der Rate-Limiter über die Aufrufe hinweg greift, in `vitest.setup.ts` `process.env.RATE_LIMIT_RPM = '100000'` ergänzen.
- Falls die 503-Header anders heißen als erwartet, den tatsächlichen Wert aus `app/api/image/[id]/route.ts` übernehmen — der Test soll das reale Verhalten festschreiben.
@@ -451,9 +454,10 @@ git commit -m "test: enable route-handler tests and cover admin guards and image
**Warum:** v0.9.2 unterscheidet Ausfall und Nichtexistenz sauber und liefert bei Ausfall 5xx statt 404 — das war die dringendere Hälfte. Die andere Hälfte fehlt: Ein Besucher sieht während eines Immich-Neustarts eine Fehlerseite, obwohl die Alben Sekunden zuvor noch im Speicher lagen. `lib/cache.ts` löscht abgelaufene Einträge beim Zugriff (Zeile 23).
**Wichtig zur Abgrenzung:** v0.9.2 cacht Ausfälle bewusst *nicht* — ein `ImmichUnavailableError` wird nie gespeichert, damit die Galerie nach der Erholung nicht kaputt bleibt. Diese Task ändert daran nichts. Sie sorgt nur dafür, dass ein früherer **Erfolg** länger verfügbar bleibt, als seine TTL erlaubt. Beides zusammen ergibt: Ausfälle werden nicht eingebrannt, Erfolge überdauern sie.
**Wichtig zur Abgrenzung:** v0.9.2 cacht Ausfälle bewusst _nicht_ — ein `ImmichUnavailableError` wird nie gespeichert, damit die Galerie nach der Erholung nicht kaputt bleibt. Diese Task ändert daran nichts. Sie sorgt nur dafür, dass ein früherer **Erfolg** länger verfügbar bleibt, als seine TTL erlaubt. Beides zusammen ergibt: Ausfälle werden nicht eingebrannt, Erfolge überdauern sie.
**Files:**
- Modify: `lib/cache.ts`
- Create: `lib/__tests__/cache-stale.test.ts`
- Modify: `lib/env.ts`, `.env.local.example`
@@ -461,6 +465,7 @@ git commit -m "test: enable route-handler tests and cover admin guards and image
- Modify: `lib/immich.ts`, `lib/__tests__/immich.test.ts`
**Interfaces:**
- Consumes: `ImmichUnavailableError` aus `lib/immich.ts` (in v0.9.2 eingeführt, Zeile 97).
- Produces:
- `cache.get<T>(key): T | null` — unverändert, nur frische Daten.
@@ -664,19 +669,17 @@ Erwartung: alle 218 Tests grün. Schlägt `config-cache.test.ts` oder `immich.te
In `lib/env.ts` im `Env`-Interface direkt nach `CACHE_TTL: number;` ergänzen:
```ts
STALE_MAX_AGE: number;
STALE_MAX_AGE: number;
```
In `parseEnv()` nach dem `cacheTtl`-Block einfügen:
```ts
// How long an expired cache entry may still be served while Immich is
// unreachable. 0 disables the fallback.
const staleMaxAgeStr = process.env.STALE_MAX_AGE;
const staleMaxAge =
staleMaxAgeStr && !isNaN(parseInt(staleMaxAgeStr, 10))
? parseInt(staleMaxAgeStr, 10)
: 86400; // 24 hours
// How long an expired cache entry may still be served while Immich is
// unreachable. 0 disables the fallback.
const staleMaxAgeStr = process.env.STALE_MAX_AGE;
const staleMaxAge =
staleMaxAgeStr && !isNaN(parseInt(staleMaxAgeStr, 10)) ? parseInt(staleMaxAgeStr, 10) : 86400; // 24 hours
```
Im Rückgabeobjekt nach `CACHE_TTL: Math.max(0, cacheTtl),` einfügen:
@@ -688,7 +691,7 @@ Im Rückgabeobjekt nach `CACHE_TTL: Math.max(0, cacheTtl),` einfügen:
In `lib/config/schema.ts` im `AppConfig`-Interface neben `cacheTtl` ergänzen:
```ts
staleMaxAge: number;
staleMaxAge: number;
```
In `lib/config/index.ts` **beide** Rückgabepfade ergänzen — jeweils direkt nach `cacheTtl: env.CACHE_TTL * 1000,` (Zeilen 278 und 358):
@@ -924,11 +927,13 @@ git commit -m "feat: serve stale cache entries while Immich is unavailable"
**Warum:** Alle Galerieseiten sind `force-dynamic` und warten vollständig auf Immich. Mit `IMMICH_TIMEOUT_MS` (Default 15 s) kann diese Wartezeit im Störungsfall lang werden — ohne `loading.tsx` sieht der Besucher währenddessen nichts.
**Files:**
- Create: `app/loading.module.css`
- Create: `app/loading.tsx`
- Create: `app/[...path]/loading.tsx`
**Interfaces:**
- Consumes: Tokens aus `app/tokens.css`; die Grid-Variablen, die `app/[...path]/page.tsx` setzt.
- Produces: nichts.
@@ -1060,6 +1065,7 @@ git commit -m "feat: add loading skeletons for dynamic gallery routes"
## Task 4: Dokumentation
**Files:**
- Modify: `README.md`
- Modify: `CLAUDE.md`

View File

@@ -1,38 +1,39 @@
# Theming
Control the visual identity of your gallery from `content/gallery.yaml`. Choose a built-in preset or customize individual properties — no CSS editing required.
Control the visual identity of your gallery from `content/settings.yaml`. Choose a built-in preset or customize individual properties — no CSS editing required.
## Quick Start
Add a single line to your `gallery.yaml`:
Add a single line to your `settings.yaml`:
```yaml
theme: minimal
```
That's it. Your entire gallery switches to the minimal theme.
That's it. Your entire gallery switches to the minimal theme. Leave it out and
you get `studio-modern`, the default.
## Built-in Presets
| Preset | Style | Accent | Fonts | Hero | Frame | Grain |
| ----------------- | ------------------------------ | ------------ | --------------------------------- | ----------- | ------------ | ----- |
| **studio** | Leica-inspired, editorial | 🔴 `#e60012` | Playfair Display + DM Sans | Split | Passepartout | ✓ |
| **studio-modern** | Leica precision, grotesque | 🔴 `#e60012` | Archivo + IBM Plex Mono | Split | None | ✗ |
| **studio** | Leica-inspired, editorial | 🔴 `#e60012` | Playfair Display + DM Sans | Split | Passepartout | ✓ |
| **minimal** | Swiss brutalist, high contrast | ⚫ `#000000` | Geist + IBM Plex Mono | Fullbleed | None | ✗ |
| **editorial** | Cinematic magazine, warm tones | 🟤 `#8B2500` | Bodoni Moda + Newsreader | Split | Shadow | ✗ |
| **classic** | Gilded gallery, warm luxury | 🟡 `#c49a3c` | Cinzel + Crimson Pro | Minimal | Passepartout | ✗ |
| **noir** | Darkroom analog, film noir | 🟠 `#ff6b35` | Libre Baskerville + Source Sans 3 | Fullbleed | Passepartout | ✓ |
| **monograph** | Typographic, book-like | ⬛ `#333333` | Instrument Serif + Inter | Typographic | None | ✗ |
Default is `studio` if no theme is specified.
**`studio-modern` is the default** if no theme is specified.
### Theme Gallery
_From left to right, top to bottom: Studio, Studio Modern, Minimal, Editorial, Classic, Noir, Monograph_
_From left to right, top to bottom: Studio Modern, Studio, Minimal, Editorial, Classic, Noir, Monograph_
<p align="center">
<img src="screenshots/theme-studio-home.png" width="49%" alt="Studio theme" />
<img src="screenshots/theme-studio-modern-home.png" width="49%" alt="Studio Modern theme" />
<img src="screenshots/theme-studio-home.png" width="49%" alt="Studio theme" />
<img src="screenshots/theme-minimal-home.png" width="49%" alt="Minimal theme" />
<img src="screenshots/theme-editorial-home.png" width="49%" alt="Editorial theme" />
<img src="screenshots/theme-classic-home.png" width="49%" alt="Classic theme" />
@@ -45,8 +46,8 @@ _From left to right, top to bottom: Studio, Studio Modern, Minimal, Editorial, C
_Same order as above._
<p align="center">
<img src="screenshots/theme-studio-grid.png" width="49%" alt="Studio grid" />
<img src="screenshots/theme-studio-modern-grid.png" width="49%" alt="Studio Modern grid" />
<img src="screenshots/theme-studio-grid.png" width="49%" alt="Studio grid" />
<img src="screenshots/theme-minimal-grid.png" width="49%" alt="Minimal grid" />
<img src="screenshots/theme-editorial-grid.png" width="49%" alt="Editorial grid" />
<img src="screenshots/theme-classic-grid.png" width="49%" alt="Classic grid" />
@@ -67,9 +68,9 @@ their accent, fonts and framing in both modes.
### What makes each theme unique
**Studio** (default) — Leica-inspired with a red nav dot, passepartout photo frames, film grain, and a split hero layout. Clean editorial feel with Playfair Display headings.
**Studio Modern** (default) — The Leica language of `studio` rebuilt around the precision grotesque Archivo, with IBM Plex Mono for every piece of photographic metadata (EXIF chips, counters, indices). Hairline rules at 8% opacity, zero radius, an indexed hero nav with album counts, an always-visible caption bar under album covers, and a film-edge EXIF strip in the lightbox. Red is signal only — nav dot, active state, hover, indices, map markers.
**Studio Modern** — The Leica language of `studio` rebuilt around the precision grotesque Archivo, with IBM Plex Mono for every piece of photographic metadata (EXIF chips, counters, indices). Hairline rules at 8% opacity, zero radius, an indexed hero nav with album counts, an always-visible caption bar under album covers, and a film-edge EXIF strip in the lightbox. Red is signal only — nav dot, active state, hover, indices, map markers.
**Studio** — Leica-inspired with a red nav dot, passepartout photo frames, film grain, and a split hero layout. Clean editorial feel with Playfair Display headings.
**Minimal** — Swiss Brutalist aesthetic inspired by Müller-Brockmann and Dieter Rams. Fullbleed hero image, true black/white palette, zero-gap photo grid with no hover effects, ultra-tiny navigation text. Every pixel earns its place.
@@ -87,7 +88,7 @@ Start from any preset and override individual properties:
```yaml
theme:
preset: studio # base preset to extend (default: studio)
preset: studio # base preset to extend (default: studio-modern)
accent: '#2563eb' # brand/accent color (any hex)
fonts:
heading: 'Inter' # Google Fonts name for headings
@@ -97,7 +98,7 @@ theme:
photoFrame: none # "none" | "passepartout" | "shadow"
grain: false # film grain overlay on photos
headerDot: false # accent-colored dot in the nav bar
heroStyle: split # "split" | "fullbleed" | "minimal" | "stacked" | "typographic" | "mosaic"
heroStyle: split # "split" | "fullbleed" | "minimal" | "stacked" | "typographic" | "mosaic" | "cover"
```
All properties are optional — omitted values fall back to the preset defaults.
@@ -150,6 +151,7 @@ Controls the homepage hero layout:
- **`stacked`** — full-viewport hero image with title gradient-overlaid at the bottom, horizontal thumbnail navigation strip below
- **`typographic`** — no hero image; massive centered title with numbered album navigation list (used by Monograph)
- **`mosaic`** — asymmetric multi-image grid with frosted-glass title overlay centered on top
- **`cover`** _(experimental)_ — a splash screen: one fullscreen image, the site title and subtitle, and a single **Enter** link into the first navigation entry. The rest of the homepage is not rendered — visitors arrive at a title page and click through.
<table>
<tr>
@@ -183,15 +185,18 @@ Controls the photo grid layout on album pages:
- **`showcase`** — first image displayed at full width (16:9), rest in standard grid
- **`filmstrip`** — horizontal scroll of tall vertical image strips with scroll snapping
- **`editorial-flow`** — alternating full-width (21:9) and side-by-side (4:3) image pairs
- **`justified`** _(experimental)_ — row-based: every row fills the container width and all images in a row share one height, so aspect ratios stay intact and nothing is cropped. `columns` sets the density — more columns give shorter rows. The last row keeps its natural sizes rather than stretching.
- **`essay`** — not a grid: renders the page as a photo essay. See **[Journal & Photo Essays](journal.md)**.
Set it globally in `settings.yaml`, or per subpage in `gallery.yaml`:
Set it globally in `settings.yaml`, per subpage in `gallery.yaml`, or on a
single album (experimental) — the three merge, most specific winning:
```yaml
grid:
layout: showcase
```
The same album in all five layouts (theme `studio-modern`):
The same album in five of the grid layouts (theme `studio-modern`):
<table>
<tr>