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:
30
docs/README.md
Normal file
30
docs/README.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
128
docs/deployment.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
244
docs/journal.md
Normal 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
|
||||

|
||||

|
||||

|
||||
```
|
||||
|
||||
| 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
|
||||

|
||||
```
|
||||
|
||||
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.
|
||||
BIN
docs/screenshots/admin-journal-list.png
Normal file
BIN
docs/screenshots/admin-journal-list.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 197 KiB |
BIN
docs/screenshots/admin-journal-settings.png
Normal file
BIN
docs/screenshots/admin-journal-settings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
BIN
docs/screenshots/admin-journal-studio.png
Normal file
BIN
docs/screenshots/admin-journal-studio.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
BIN
docs/screenshots/header.png
Normal file
BIN
docs/screenshots/header.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 453 KiB |
BIN
docs/screenshots/journal-entry.png
Normal file
BIN
docs/screenshots/journal-entry.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
BIN
docs/screenshots/journal-index.png
Normal file
BIN
docs/screenshots/journal-index.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
@@ -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`
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user