release: v0.12.0 (#520)

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

View File

@@ -1,5 +1,6 @@
# ── Immich Server Connection (required) ──────────────────────────
# Validated by Zod at startup — missing or invalid values produce clear errors.
# Parsed once at startup by lib/env.ts. An unparsable URL is reported and
# treated as unset, which lands you on the setup screen rather than a crash.
# Base URL of your Immich instance (must be a valid URL)
IMMICH_API_URL=http://your-immich-server:2283
@@ -8,7 +9,7 @@ IMMICH_API_URL=http://your-immich-server:2283
IMMICH_API_KEY=your-api-key-here
# ── Optional Settings ────────────────────────────────────────────
# All have sensible defaults and are type-checked by Zod.
# All have sensible defaults; an unparsable value falls back to the default.
# Site title shown in the header (string, default: "Gallery")
SITE_TITLE=My Photography
@@ -49,9 +50,12 @@ STALE_MAX_AGE=86400
# The app must be reachable ONLY through the proxy (bind to 127.0.0.1).
# TRUSTED_PROXY_HOPS=1
# Secret for signing auth cookies and encrypting asset tokens (string, min 16 chars).
# If not provided, IMMICH_API_KEY will be used as a fallback (not recommended).
# AUTH_SECRET=your-super-long-random-secret-here
# Secret for signing auth cookies and encrypting asset tokens. REQUIRED in
# production: without it the server refuses to start. In development a random
# one is generated per process, so every restart invalidates all existing image
# URLs and logins — set it there too unless you enjoy that.
# Generate one with: openssl rand -hex 32
AUTH_SECRET=change-me-to-a-long-random-string
# Webhook secret for cache invalidation (optional, string).
# Set this to a long random string and configure Immich to send webhooks to
@@ -64,6 +68,12 @@ STALE_MAX_AGE=86400
# Leave unset or empty to disable the admin UI entirely.
# ADMIN_PASSWORD=your-secure-admin-password
# ── Site password (optional) ─────────────────────────────────────
# Puts the WHOLE public site behind one password. Overrides
# `sitePassword` in settings.yaml, so it can be rotated without
# touching the file. /admin keeps its own password.
# SITE_PASSWORD=your-site-password
# ── Album configuration ──────────────────────────────────────────
# Albums, subpages, hero image, and passwords are defined in content/gallery.yaml
# See content/gallery.yaml.example for the full reference.

3
.gitignore vendored
View File

@@ -47,6 +47,9 @@ Thumbs.db
.Jules/
.jules/
.claude/
# Agent instructions — local working notes, not part of the published project.
CLAUDE.md
.planning/
# ── One-off Scripts (Root) ───────────────────────────

View File

@@ -7,7 +7,239 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Releases up to and including v0.9.2 are documented in the
[GitHub releases](https://github.com/ralksta/immich-folio/releases).
## [Unreleased]
## [0.12.0] — 2026-08-23
### Added
- **The setup wizard seeds the home page with photographs**
([#518](https://github.com/ralksta/immich-folio/issues/518)). It always wrote
`hero: []`, so a finished portfolio opened with a title and a list of album
names and no image — on the one page that most needs to show a photograph.
The covers of the albums just chosen are used, up to five, changeable in the
admin panel afterwards. Best effort: if the lookup fails the install proceeds
with an empty hero, as before.
- **The album picker marks albums Immich does not consider shared**
([#515](https://github.com/ralksta/immich-folio/issues/515)). Not a warning
and not a barrier — publishing them has always worked, because Immich ignores
the `?shared=true` the list is fetched with. It is there so nobody publishes a
private album unaware. The same appears in the config doctor as a warning.
- **`mode:` sets the colour mode visitors land on**
([#512](https://github.com/ralksta/immich-folio/issues/512)). `dark` (the
existing default), `light`, or `auto` to follow the visitor's operating
system — in `settings.yaml` or under Settings › Theme. It is rendered onto
`<html>` server-side, so the first paint is already right. The admin panel's
own light/dark switch previews your view and never decided what visitors saw,
which is what made this necessary; a visitor's choice from the header toggle
is still remembered on their device and still wins.
- **`exif:` decides which EXIF groups a site publishes**
([#506](https://github.com/ralksta/immich-folio/issues/506)). Four switches —
`camera`, `settings`, `location`, `caption` — in `settings.yaml` or under
Settings › Portfolio Features. They govern all three places the data appears
(grid hover, lightbox panel, album header), so those cannot disagree. Grouped
rather than field-by-field on purpose: a list of individual field names is a
small language of its own, for a choice people make in groups anyway.
`exifOnHover` keeps its meaning and supplies the default for the three
technical groups, so an existing `exifOnHover: false` still hides all of them.
With every group off, the lightbox withdraws its info button and the `i` key
instead of opening an empty panel.
- **The lightbox lists its keyboard shortcuts under `?` or `H`**
([#501](https://github.com/ralksta/immich-folio/pull/501)). The viewer has
always had keys — arrows, `i`, `Esc` — with nothing that wrote them down.
There is deliberately no button and no first-run hint: a permanent control in
the corner of a photograph costs every visitor something, and whoever tries
`?` or `h` finds the list. `Esc` now unwinds one layer at a time, closing the
panel before the viewer.
- **`F` puts the lightbox into real fullscreen**
([#474](https://github.com/ralksta/immich-folio/issues/474)). The viewer
covered the viewport but not the browser's own chrome, so every photo was
shown inside a tab strip and a URL bar. `F` toggles it, the shortcut panel
lists it, and `Esc` unwinds one layer at a time — shortcut panel, then
fullscreen, then the viewer. Leaving the lightbox while fullscreen returns the
page to normal instead of stranding the gallery there. Browsers without
element fullscreen (iPhone Safari, which offers it for video only) do not
advertise the key.
- **The album covers on a subpage follow the grid setting**
([#460](https://github.com/ralksta/immich-folio/pull/460)). They were tiled
two-up by a hardcoded CSS rule, so the site-wide `grid.columns` only ever
reached the photo grids inside an album and the covers stayed enormous on wide
screens. A subpage can also override the count and the spacing on its own,
under Pages › _subpage_ › **Album Cover Grid**. Tablet widths show at most two
covers per row, phones one, as before.
`gap` deliberately does **not** follow the global setting: each theme preset
picks its cover spacing as part of its look (1px `monograph`, 20px
`studio-modern`), so only an explicit per-subpage `gap` overrides it. See
[docs/gallery-config.md](docs/gallery-config.md#album-covers-on-a-subpage).
**A site whose `settings.yaml` sets `grid.columns` to anything other than 2
will see its subpage cover grids change on upgrade.** That is the point of the
change; to keep two-up covers, set `grid.columns: 2` on the subpage.
### Security
- **The admin session token is bounded before it is parsed**
([#505](https://github.com/ralksta/immich-folio/pull/505)). A cookie longer
than 512 characters is rejected outright. Reported as a memory-exhaustion DoS;
the decode it named is only reached after the HMAC matches, which needs the
signing key, so this is hygiene at the boundary rather than a fix for a
reachable exhaustion — but a bound on untrusted input costs nothing.
### Fixed
- **The setup wizard runs on a container with no environment variables**
([#519](https://github.com/ralksta/immich-folio/issues/519)). Its own API
answered 500, because rate limiting resolved the client IP through
`getConfig()`, which resolves `AUTH_SECRET`, which throws in production when
none is set — and the secret the wizard is meant to _generate_ therefore had
to exist before the wizard could run. That is the deployment
`docs/deployment.md` recommends. `getClientIp()` reads the one value it needs
straight from the environment now.
- **A container with a broken `gallery.yaml` no longer restarts in a loop**
([#519](https://github.com/ralksta/immich-folio/issues/519)). `/api/health` is
the Dockerfile's health probe and reported 503 when the config could not be
parsed. Restarting cannot fix a YAML typo, and the app is still serving the
setup screen and `/admin`, which is where the fix happens. It answers 200 with
`status: "setup"` instead. An unreachable Immich keeps its 503.
- **An ID that is not a UUID is dropped instead of becoming the zero UUID**
([#517](https://github.com/ralksta/immich-folio/issues/517)). `validateUuid()`
logged "(Ignored for build/setup)" and returned
`00000000-0000-0000-0000-000000000000`, which then travelled on as a real
asset ID and came back 400 from Immich — so a leftover placeholder turned the
home page into "Something went wrong" while the log claimed it had been
ignored. Now it is: one fewer hero image, one album not published, an album
falling back to its Immich cover — each with a warning naming the entry.
**A copied `gallery.yaml.example` therefore yields page structure and no
albums**, since its IDs are placeholders. That is the honest outcome; replace
them with real album IDs.
- **A malformed `gallery.yaml` shows the setup screen instead of a 500**
([#516](https://github.com/ralksta/immich-folio/issues/516)). The site gate
resolves the password through `getConfig()`, which throws on a config it
cannot parse — inside `proxy.ts`, before `app/layout.tsx` could fall back to
`getConfigOrNull()`. One typo became a bare "Internal Server Error" on every
route, with nothing naming the file.
- **The About link no longer points at an empty page**
([#518](https://github.com/ralksta/immich-folio/issues/518)). `about.enabled`
defaults to on, but `content/about.md` does not exist until someone writes it,
so a fresh install shipped a navigation entry leading nowhere. The link now
needs the file as well.
- **A gallery finished through the setup wizard is no longer empty until the
next restart.** While the install was unfinished the album list was fetched,
filtered against the still-empty allowlist, and the resulting `[]` was cached
— and the wizard's cache invalidation runs in the install route's own module
instance, so it never reached the one the page render used. The list is not
cached while setup is unfinished.
- **`content/gallery.yaml.example` is valid YAML again.** It carried two
`subpages:` keys, so js-yaml refused the file — and README, CONTRIBUTING and
the setup screen all tell a new user to copy exactly that file, which meant a
fresh installation answered HTTP 500 on every route. The two blocks are now
one list, and a test loads both shipped examples through the config pipeline
so this cannot come back.
- **Corrected three claims in `.env.local.example`.** It advertised Zod
validation (Zod is not a dependency), described a fallback from `AUTH_SECRET`
to `IMMICH_API_KEY` that does not exist, and left `AUTH_SECRET` commented out
— which in production stops the server from starting and in development
regenerates a random secret on every restart, invalidating every image URL.
It is now set, with a command to generate one.
- **The README's command for reading the setup token works.**
`docker compose logs immich-folio` names the container, not the service, and
fails with "no such service".
- **Long values in the lightbox info panel no longer run into their label**
([#514](https://github.com/ralksta/immich-folio/issues/514)). The rows are
`space-between` with no gap, so a value that left no free space simply touched
its label — `CAMERALEICA CAMERA AG LEICA Q3`. The rows carry a gap now, and
the camera name drops a maker the model already spells out, so a Leica reads
`LEICA Q3` and a Nikon `NIKON Z 6` instead of saying the brand twice.
- **The lightbox info panel is readable in light mode**
([#511](https://github.com/ralksta/immich-folio/issues/511)). The EXIF panel
and the shortcut list paint their own dark surface but took their text and
border colours from the site theme, so in light mode they rendered dark text
on a dark panel. Both now carry their own light-on-dark colours, as the
caption line always did. The rest of the lightbox chrome still follows the
theme, because the overlay behind it does too.
- **`grid.gap` reaches every theme**
([#513](https://github.com/ralksta/immich-folio/issues/513)). `minimal`,
`editorial` and `monograph` hardcoded the column spacing on `.photo-grid`, so
the Gap Spacing control did nothing in those presets — while the row spacing
followed it, leaving columns and rows visibly out of step. Each preset now
declares its spacing as a default that an explicit `grid.gap` overrides.
**An unset `gap` is now the preset's own spacing rather than a flat 12px.**
That is what those three presets already rendered horizontally, so the visible
change is that their rows finally match their columns. Sites that set `gap`
are unaffected, and `classic` and `studio-modern` keep the 12px they had.
- **The Immich description can be switched off**
([#506](https://github.com/ralksta/immich-folio/issues/506)). It was served
regardless of `exifOnHover`, on the grounds that a caption is editorial rather
than metadata — which made it the one field an operator could not hide, while
Immich descriptions are exactly where people keep private notes and whatever a
decade of cataloguing software left behind. It is now a group like any other.
- **The album header no longer picks a camera at random**
([#509](https://github.com/ralksta/immich-folio/issues/509)). The date and the
camera/lens line were read off the first asset that happened to carry EXIF, so
an album shot on three bodies advertised whichever one sorted first — and
changing the album's sort mode changed the claim. The line now appears only
when the whole album agrees on one body and one lens, and the date spans a
range when the photos do.
- **The lightbox watermark honours the configured opacity**
([#508](https://github.com/ralksta/immich-folio/issues/508)). The docs and the
admin slider express it as a fraction (`opacity: 0.5`), but the lightbox
divided by 100 anyway — so a configured `0.9` rendered as `0.009` and was
invisible, and the workaround of writing `90` came back as "9000%" in the
admin panel. The fraction is now used as written; a value above 1 is still
read as a percentage so those workarounds keep working, and the panel writes
the fraction back on the next save. Its slider reaches 100% instead of
stopping at 80%.
- **A deployment with credentials but no `gallery.yaml` no longer reports
"System Degraded"** ([#507](https://github.com/ralksta/immich-folio/issues/507)).
A missing `gallery.yaml` and missing Immich credentials were one and the same
`needsSetup` flag, and the Immich client returned `null` on that flag before
ever reaching the network. The admin panel showed the server as disconnected,
the album pickers refused with "Immich not configured" — so the panel could
not create the very file it was waiting for — and nothing at all appeared in
the log. The two faults are now separate: with credentials present, Immich is
contacted and the admin pickers work, the badge reads "Setup Incomplete" and
names what is missing, and an unreachable Immich is logged instead of being
swallowed.
- **The first-run setup token is printed at startup**, not on the first visit to
`/install`. An operator who set `ADMIN_PASSWORD` and went straight to `/admin`
never triggered it and found nothing in `docker logs`. The setup screen now
points at the wizard as well.
- **A failing site no longer renders a blank page.** `app/error.tsx` is rendered
_inside_ the root layout, so it could never catch the layout itself throwing —
and the layout is what reads the configuration and the request headers. The
new `app/global-error.tsx` replaces the whole document with a readable
message and a retry, self-contained so it works without the stylesheet or the
theme variables.
### Changed
- The package is named `immich-folio` instead of `immich-lightbox`, which it had
kept from before the rename. Only visible when working on the source.
## [0.11.0] — 2026-08-15

197
CLAUDE.md
View File

@@ -1,197 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this project is
Immich Folio is a self-hosted photography portfolio that acts as a secure reverse proxy between visitors and a private Immich instance. It serves albums via an image proxy so the Immich server and API key are never exposed to the public internet. Asset UUIDs are AES-256 encrypted into opaque URL tokens.
## Commands
```bash
npm run dev # Start dev server at http://localhost:3000
npm run build # Production build (must pass before any PR)
npm run lint # ESLint
npm run format # Prettier (write)
npm run format:check # Prettier (check only)
npx tsc --noEmit # TypeScript type-check (must be 0 errors before any PR)
# Unit tests (Vitest, lib/__tests__/ only)
npm run test # Watch mode
npm run test:unit # Single run
npm run test:coverage
# E2E tests (Playwright, requires dev server running or starts it automatically)
npm run test:e2e
```
**Formatting caution:** Running `npm run format` at the repo root applies changes to the entire codebase and pollutes diffs. Prefer formatting only modified files.
## Architecture
### Configuration loading (`lib/config/`)
Configuration is loaded once at startup (cached in a module-level singleton, re-read in dev mode per request).
- `lib/env.ts` — parses and validates all environment variables into a typed `Env` object. All env access in the codebase must go through this module.
- `lib/config/schema.ts` — TypeScript types for `AppConfig`, `GalleryYaml`, `SettingsYaml`, `SubpageConfig`, `GridConfig`, `ThemeConfig`, etc., plus the `slugify()` utility.
- `lib/config/index.ts` — `getConfig()`: reads `content/gallery.yaml` and `content/settings.yaml`, merges them into a single `AppConfig`. Also exports `buildSubpageGrid()` and re-exports from schema/theme.
- `lib/config/theme.ts` — seven built-in theme presets (`studio-modern`, `studio`, `minimal`, `editorial`, `classic`, `noir`, `monograph`) and `resolveTheme()` which merges partial overrides over a preset. `DEFAULT_PRESET` (`studio-modern`) is the single source for every "no preset configured" fallback — import it rather than writing the literal, and note the client components (`InstallWizard`, `SettingsEditor`) import it from `lib/config/theme` directly, since `lib/config/index.ts` pulls in `fs`.
`getConfig()` returns a `needsSetup: true` dummy config (instead of throwing) when `gallery.yaml` or credentials are missing — this lets the app render a `SetupScreen` instead of crashing.
### Content files (`content/`)
- `content/gallery.yaml` — gallery structure: hero asset IDs, standalone albums, subpages (with optional sections, passwords, per-subpage grid overrides). Use `gallery.yaml.example` as reference.
- `content/settings.yaml` — site-wide settings: title, subtitle, theme, grid defaults, footer, legal, map, transitions, SEO. Use `settings.yaml.example` as reference.
- `content/about.md` — Markdown with frontmatter for the about page (portrait, name, location, gear).
- `content/journal/*.md` — journal entries, one file per entry (`<slug>.md` → `/journal/<slug>`). `content/essays/` is the legacy location, still resolved.
- `content/install.json` — written by the setup wizard: Immich URL, API key, generated site secret, scrypt-hashed admin password. Mode `0600`; env vars override every field.
- `content/analytics.json` — cookieless view counters.
- `content/favicon.svg` — uploaded favicon (whatever the source format), served via `/api/favicon`.
- `content/.setup-token` — one-time `/install` gate token (mode `0600`), deleted once setup completes.
- `content/.backups/`, `content/journal/.backups/` — rotating backups (10 per file) written before every admin save.
The whole `content/` directory must be writable: the wizard, admin panel, journal, favicon upload, analytics and backup rotation all write into it.
### Immich client (`lib/immich.ts`)
Singleton `ImmichClient` class exported as `immich`. All Immich API calls are server-side only. Key design points:
- **Album allowlist** — `getAlbums()` fetches `?shared=true` albums but only returns IDs listed in `config.albums`. Requests for unlisted albums are silently rejected.
- **Request coalescing** — pending promises are stored in `Map<id, Promise>` fields to deduplicate concurrent requests for the same album/asset (important for `Promise.all` calls in grids).
- **In-memory LRU cache** (`lib/cache.ts`) — 200-entry LRU. Entries carry two deadlines: `staleAt` (the `CACHE_TTL` window, after which `get()` reports a miss) and `hardExpiresAt` (`STALE_MAX_AGE`, after which the entry is dropped). Between the two, `getStale()` still returns the data — `lib/immich.ts` falls back to it when a request fails with `ImmichUnavailableError`, so the gallery keeps serving the last known albums during an Immich outage instead of erroring. Definitive 404s are cached as a `MISSING` sentinel under the normal TTL and are deliberately excluded from the stale window. Cache keys: `albums-list`, `album-<id>`, `asset-<id>`.
- **Image streaming** — `streamAsset()` proxies binary responses; never loads the full image into memory.
### Asset token security (`lib/tokens.ts`)
`encodeAssetId(uuid)` / `decodeAssetId(token)` — AES-256-GCM with a deterministic IV derived from the asset ID (same UUID → same token, enabling browser caching). Token format: `v2:<base64url(iv+authTag+ciphertext)>`. The encryption key is derived from `AUTH_SECRET` via SHA-256. Legacy CBC tokens are still decoded for backward compatibility.
### API routes (`app/api/`)
Public:
| Route | Purpose |
| ---------------------------------------------- | -------------------------------------------------------------------------------- |
| `GET /api/image/[id]` | Image proxy — decodes token, rate-limits, streams from Immich |
| `GET /api/video/[id]` | Video proxy, range requests included |
| `GET /api/exif/[id]` | EXIF data for lightbox panel |
| `POST /api/auth` | Password submission (`subpage` \| `album` \| `journal`) → sets `HttpOnly` cookie |
| `GET /api/og` | Dynamic OG image generation (rate-limited) |
| `GET /api/map` | Aggregated GPS coordinates for map view |
| `GET /api/favicon` | Uploaded favicon from `content/`, with an SVG-safe content policy |
| `POST /api/analytics/track` | Cookieless view counter; refuses when `analytics: false` |
| `POST /api/webhook` | Immich cache invalidation, HMAC-verified; `501` unless `WEBHOOK_SECRET` is set |
| `GET /api/health` | Health check |
| `POST /api/install`, `GET /api/install/albums` | First-run wizard; refuse to run once setup completed |
Admin (every route re-checks `isAdminEnabled()` + `isAdminAuthenticated()`):
| Route | Purpose |
| ------------------------------------------ | ---------------------------------------------------- |
| `GET/POST/DELETE /api/admin/auth` | Admin login, session check, logout |
| `GET/PUT /api/admin/gallery` | Read/write `gallery.yaml` |
| `GET/PUT /api/admin/settings` | Read/write `settings.yaml` |
| `GET/PUT /api/admin/about` | Read/write `content/about.md` |
| `GET/POST /api/admin/journal` | List / create journal entries |
| `GET/PUT/DELETE /api/admin/journal/[slug]` | Read, write, delete one entry |
| `GET /api/admin/albums` | Browse all shared Immich albums (bypasses allowlist) |
| `GET /api/admin/albums/[albumId]/assets` | Assets of one album, for the manual-order editor |
| `GET /api/admin/assets` | Library search (`POST /search/metadata`) for pickers |
| `GET /api/admin/thumbnail/[id]` | Raw-UUID thumbnails for the admin UI |
| `GET/POST /api/admin/backups` | List and restore backups |
| `POST/DELETE /api/admin/favicon` | Favicon upload and reset |
| `GET /api/admin/analytics` | Aggregated view counts |
| `GET /api/admin/status` | Immich reachability, config validity, cache, backups |
| `POST /api/admin/reload` | Invalidate config + Immich cache |
The image proxy maps requested pixel widths (`?w=`) to Immich size tiers: `≤250px→thumbnail`, `≤1440px→preview`, `>1440px→original` (`lib/imageSize.ts`).
`?size=` (written by `lib/urls.ts`) acts as a **ceiling**, not an override. When both parameters are present the smaller tier wins, so `?w=` can narrow the request but never widen it — `next/image` emits widths up to 3840, so letting width win outright would serve full-size originals to every large display.
### Routing (`app/[...path]/page.tsx`)
Single catch-all route handles three cases:
1. `/[subpage-slug]/[album-slug]` — renders album detail with back-link to subpage
2. `/[subpage-slug]` — if subpage has >1 album, renders `SubpageGridView`; if exactly 1 album, renders `AlbumDetailView` directly
3. `/[album-slug]` — standalone album detail
A subpage renders as an essay instead when any of `grid.layout === 'essay'`, `essayFile`, or `essayText` is set. With `layout: essay` and no markdown, the essay is generated from the albums (heading per album + its photos).
Fixed routes outside the catch-all: `/about`, `/map`, `/impressum`, `/journal`, `/journal/[slug]`, `/install`, `/admin/*`.
### Journal & essays (`lib/journal.ts`, `lib/essay.ts`, `lib/admin/journal-service.ts`)
- `lib/journal.ts` — **client-safe**, no `fs`. Parses and serializes the block markdown (`parseJournalMarkdown` / `serializeJournalMarkdown`), and owns `sanitizeHtml`, `isValidSlug`, reading time and excerpts. Photo blocks are `![<assetId>:fullbleed|wide](caption)` and `![<id>, <id>](caption)`.
- `lib/admin/journal-service.ts` — **server only**: reads/writes `content/journal/`, falls back to the legacy `content/essays/`, rotates backups. The client/server split is load-bearing — importing the service from a client component breaks the build.
- `essayFile` is a **slug**, not a path: `isValidSlug` rejects dots and slashes, so `content/essays/x.md` cannot resolve.
- Author markdown is escaped, never filtered — `sanitizeHtml` runs first and the renderer only emits tags it builds itself.
- Drafts (`draft: true`) are hidden from the index and the nav check, but remain visible to an authenticated admin. Entry passwords use the `lb_auth_journal_<slug>` cookie.
### Proofing (`lib/proofing.ts`, `components/ProofingContext.tsx`)
Client-side favourite selection encoded as a bitmask in the URL and mirrored to `localStorage`; nothing is stored server-side. Note that `PhotoGrid` mounts `ProofingProvider` unconditionally — the `proofing.enabled` / per-subpage `proofing` config keys are parsed but not consulted, so the feature is currently always on for grids.
### Analytics
`POST /api/analytics/track` appends to `content/analytics.json` and short-circuits when `config.analytics === false`. `GET /api/admin/analytics` is admin-only. No cookies, no third party.
### First-run setup (`lib/install.ts`, `app/install/`)
A deployment with no `gallery.yaml` and no credentials serves `SetupScreen`, and `/install` runs the wizard. It is gated by a one-time token printed to the server log and stored in `content/.setup-token` — a fresh deployment is reachable before it is configured. Credentials are verified against Immich before anything is written, then land in `content/install.json`. Environment variables always take precedence over that file, so rotation works without touching it. Once `isInstalled()` is true the wizard routes return `403`.
All pages are `dynamic = 'force-dynamic'` (no SSG; requires live Immich).
### Password protection (`lib/auth.ts`)
Per-subpage, per-album and per-journal-entry password gating using HMAC tokens in `HttpOnly` cookies (no database). Cookie names: `lb_auth_<slug>` (subpage), `lb_auth_album_<slug>` (album) and `lb_auth_journal_<slug>` (journal entry). Password storage supports plaintext (deprecated, logs a warning with recommended scrypt hash), `scrypt:salt:hash` format (`lib/password.ts`), and rejects legacy bcrypt. Token expiry: 24 hours.
### Rate limiting (`lib/rate-limit.ts`)
In-memory sliding-window rate limiter. Important: **in-memory only** — does not work across multiple Node.js instances. Uses FIFO eviction (not reject-on-full) to prevent DoS via store flooding. `TRUSTED_PROXY_HOPS` must be set to the number of reverse proxies in front of the app (nginx = 1); the client IP is then read that many entries from the right of `X-Forwarded-For`, which proxies append to. Without it the IP comes from a spoofable header. Bucket keys are namespaced per endpoint (`image:`, `map:`, `auth:`, …) so limits don't collide.
`RATE_LIMIT_RPM` (default 1500) covers image, video, EXIF and health. Endpoints where a high ceiling would be wrong carry a fixed constant in the route: `admin-auth` 5, `auth` 10, `install` 10, `og` 30, `install-albums` 30, `webhook` 60, `map` 120.
### Theming system
Theme is applied via CSS custom properties on the `<html>` element (`data-preset`, `data-grain`, `data-photo-frame`, etc.) and inline `style` vars (`--accent`, `--font-serif`, etc.). Theme preset CSS files live in `app/themes/`. Base tokens are in `app/tokens.css`. Vanilla CSS throughout — no Tailwind, no CSS-in-JS.
### `next/image` loader
A custom loader (`lib/immichLoader.ts`) maps `next/image` requests to `/api/image/[token]?w=<width>`, keeping all image traffic through the proxy.
### Admin Panel (`app/admin/`, `lib/admin/`, `app/api/admin/`)
Visual page builder, journal studio, settings editor and analytics at `/admin`. Enabled by setting `ADMIN_PASSWORD` env var, or via the setup wizard (scrypt hash in `install.json`).
Each area is a real route — `/admin/pages`, `/admin/journal`, `/admin/journal/[slug]`, `/admin/settings/[section]`, `/admin/analytics`. `app/admin/AdminShell.tsx` lives in the layout and holds the auth gate and chrome, so switching tabs does not re-run the session check.
- `lib/admin/auth.ts` — HMAC-signed session tokens (HttpOnly cookie `folio_admin_session`), 24h expiry. The signing key is derived from `ADMIN_PASSWORD` + `AUTH_SECRET` with scrypt and cached keyed on its inputs.
- `lib/admin/yaml-service.ts` — Atomic YAML read/write with automatic backups to `content/.backups/` (max 10 per file). Writes use temp-file + rename pattern.
- `lib/admin/journal-service.ts` — the same pattern for `content/journal/*.md`.
- `lib/admin/paths.ts` — `containedPath()`; every admin file path goes through it so a slug cannot escape its directory.
- `app/admin/components/PageBuilder.tsx` — Tree editor for hero, standalone albums, subpages, and sections, with a slide-over drawer per item.
- `app/admin/components/JournalStudio.tsx` — Split-screen block editor with live preview.
- `app/admin/components/SettingsEditor.tsx` — Sidebar-nav settings form (general, theme, grid, footer, legal, SEO, security & protection, about).
- `app/admin/components/AlbumPicker.tsx` / `AssetPicker.tsx` / `AssetOrderEditor.tsx` — album browser, library search, manual photo ordering.
- `app/admin/components/BackupManagerModal.tsx` — list and restore backups.
Every `/api/admin/*` route must check `isAdminEnabled()` **and** `isAdminAuthenticated()` itself — there is no shared middleware guard. `app/api/admin/__tests__/admin-guards.test.ts` enforces this across all of them.
After saving, `invalidateConfigCache()` is called so the next request picks up the new YAML without restart.
### Proxy (`proxy.ts`)
Applies CSP (with per-request nonce), HSTS, and other security headers on all non-API, non-static routes. The nonce is passed to pages via the `x-nonce` request header.
Next.js 16 renamed the `middleware` file convention to `proxy`: the file is `proxy.ts` and it exports `proxy()` (not `middleware()`). The `config.matcher` export is unchanged.
## Code conventions
- **TypeScript strict mode** — no untyped `any`; use `@ts-expect-error` (not `@ts-ignore`) with a comment when suppressing type errors.
- **Server Components by default** — add `'use client'` only when browser APIs or React state/effects are needed.
- **All Immich data flows server-side** — raw asset UUIDs must never appear in client-rendered HTML or JS. Always use `encodeAssetId()` / `imageUrl()` / `exifUrl()` from `lib/urls.ts` before passing IDs to components.
- **Rate-limit all expensive endpoints** — apply `checkRateLimit` from `lib/rate-limit.ts` to any route doing heavy computation or upstream API calls.
- **Commit style** — Conventional Commits: `feat:`, `fix:`, `security:`, `docs:`, `chore:`.
- **Route handlers are testable** — `vitest.config.ts` includes `app/**/__tests__/**/*.test.ts`. Logic that lives in a route (auth guards, header sanitisation) belongs in a route-level test; do not extract it into `lib/` purely to make it reachable by the test runner.

View File

@@ -172,8 +172,9 @@ and without the gate, whoever finds the URL first could configure it:
Token: PTmUKFIdce2DwuqBG71RAExH7tVxceXX
```
Read it from your container logs (`docker compose logs immich-folio`) and append
it to the URL:
Read it from your container logs (`docker logs immich-folio`, or
`docker compose logs lightbox` — `immich-folio` is the container name, `lightbox`
the service) and append it to the URL:
```
http://your-site/install?token=PTmUKFIdce2DwuqBG71RAExH7tVxceXX

View File

@@ -6,6 +6,8 @@ import type { GridConfig } from '@/lib/config';
import type { LightboxWatermark } from '@/components/Lightbox';
import Image from 'next/image';
import React from 'react';
import { getServerDictionary } from '@/lib/i18n/server';
import { albumMetaDetail } from '@/lib/albumMeta';
interface AlbumDetailViewProps {
album: ImmichAlbum;
@@ -18,37 +20,16 @@ interface AlbumDetailViewProps {
heroImageUrl?: string;
heroBlurDataURL?: string;
watermark?: LightboxWatermark;
/** Whether the lightbox may offer its info panel at all. */
showExifPanel?: boolean;
/** Whether the header may name the camera and lens. Follows the `camera` group. */
showGear?: boolean;
/** Client proofing — favorite hearts, selection bar and send-off modal. */
proofing?: boolean;
/** Offer the "send by email" button in the proofing modal. */
allowMailto?: boolean;
}
/**
* Extra detail for the album header — shooting date and camera/lens, read off
* the first asset that carries EXIF. Both are rendered in their own spans and
* hidden by default; only presets that ask for them show them, so the header
* keeps reading "86 photos" everywhere else.
*/
function albumMetaDetail(album: ImmichAlbum): { date?: string; gear?: string } {
const first = album.assets.find((a) => a.exifInfo);
const exif = first?.exifInfo;
const taken = exif?.dateTimeOriginal || first?.fileCreatedAt;
const gear = [exif?.model, exif?.lensModel].filter(Boolean).join(' · ');
return {
...(taken ? { date: formatMonthYear(taken) } : {}),
...(gear ? { gear } : {}),
};
}
function formatMonthYear(iso: string): string | undefined {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return undefined;
return `${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
}
export function AlbumDetailView({
album,
images,
@@ -60,10 +41,13 @@ export function AlbumDetailView({
heroImageUrl,
heroBlurDataURL,
watermark,
showExifPanel = true,
showGear = true,
proofing,
allowMailto,
}: AlbumDetailViewProps) {
const metaDetail = albumMetaDetail(album);
const metaDetail = albumMetaDetail(album, showGear);
const t = getServerDictionary();
return (
<>
@@ -95,9 +79,7 @@ export function AlbumDetailView({
{album.description && <p className="album-header__description">{album.description}</p>}
</div>
<p className="album-header__meta">
<span className="album-header__meta-count">
{images.length} {images.length === 1 ? 'photo' : 'photos'}
</span>
<span className="album-header__meta-count">{t.common.photos(images.length)}</span>
{metaDetail.date && <span className="album-header__meta-date"> · {metaDetail.date}</span>}
{metaDetail.gear && <span className="album-header__meta-gear">{metaDetail.gear}</span>}
</p>
@@ -107,6 +89,7 @@ export function AlbumDetailView({
layout={layout}
gridStyle={gridStyle}
watermark={watermark}
showExifPanel={showExifPanel}
proofing={proofing}
allowMailto={allowMailto}
/>

View File

@@ -9,6 +9,7 @@ import { FadeIn } from '@/components/FadeIn';
import type { ParsedEssay, EssayBlock } from '@/lib/essay';
import type { PhotoItem } from './PhotoGrid';
import './essay.css';
import { useDictionary } from '@/components/I18nProvider';
interface EssayViewProps {
essay: ParsedEssay;
@@ -34,6 +35,7 @@ function EssayViewContent({
watermark,
proofing: proofingEnabled = false,
}: EssayViewProps) {
const t = useDictionary();
const proofing = useProofing();
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
@@ -122,7 +124,7 @@ function EssayViewContent({
e.stopPropagation();
proofing.toggleFavorite(item.id);
}}
aria-label={isFav ? 'Remove favorite' : 'Add favorite'}
aria-label={isFav ? t.proofing.removeFavorite : t.proofing.addFavorite}
style={{
position: 'absolute',
top: '12px',
@@ -205,7 +207,7 @@ function EssayViewContent({
e.stopPropagation();
proofing.toggleFavorite(item.id);
}}
aria-label={isFav ? 'Remove favorite' : 'Add favorite'}
aria-label={isFav ? t.proofing.removeFavorite : t.proofing.addFavorite}
style={{
position: 'absolute',
top: '12px',
@@ -333,7 +335,9 @@ function EssayViewContent({
cursor: 'pointer',
}}
>
{proofing.isFilterActive ? 'Show All' : `❤️ ${proofing.favorites.size} Selected`}
{proofing.isFilterActive
? t.proofing.showAll
: t.proofing.selected(proofing.favorites.size)}
</button>
<button
type="button"
@@ -348,7 +352,7 @@ function EssayViewContent({
textDecoration: 'underline',
}}
>
Share & Export
{t.proofing.shareExport}
</button>
</div>
)}

View File

@@ -14,6 +14,7 @@ import { Lightbox, type LightboxWatermark } from '@/components/Lightbox';
import { FadeIn } from '@/components/FadeIn';
import { ProofingProvider, useProofing } from '@/components/ProofingContext';
import { ProofingModal } from '@/components/ProofingModal';
import { useDictionary } from '@/components/I18nProvider';
export interface PhotoItem {
id: string;
@@ -38,6 +39,11 @@ interface PhotoGridProps {
'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay' | 'justified';
gridStyle?: React.CSSProperties;
watermark?: LightboxWatermark;
/**
* Offer the lightbox info panel. Off when every EXIF group is switched off,
* so the `i` key does not open an empty panel (#506).
*/
showExifPanel?: boolean;
/**
* Client proofing — favorite hearts, the selection bar and the send-off
* modal. Off unless the caller opts in, so a grid rendered outside the
@@ -61,7 +67,14 @@ function buildPhotoHash(index: number): string {
return `#photo-${index + 1}`; // 1-indexed for user-friendliness
}
function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: PhotoGridProps) {
function PhotoGridInner({
assets,
layout = 'masonry',
gridStyle,
watermark,
showExifPanel = true,
}: PhotoGridProps) {
const t = useDictionary();
const proofing = useProofing();
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
@@ -190,8 +203,8 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph
e.stopPropagation();
proofing.toggleFavorite(asset.id);
}}
aria-label={isFav ? 'Remove favorite' : 'Add favorite'}
title={isFav ? 'Remove favorite' : 'Add favorite'}
aria-label={isFav ? t.proofing.removeFavorite : t.proofing.addFavorite}
title={isFav ? t.proofing.removeFavorite : t.proofing.addFavorite}
style={{
position: 'absolute',
top: '8px',
@@ -240,7 +253,7 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph
</FadeIn>
);
});
}, [displayedAssets, layout, openLightbox, proofing]);
}, [displayedAssets, layout, openLightbox, proofing, t]);
return (
<>
@@ -284,7 +297,9 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph
cursor: 'pointer',
}}
>
{proofing.isFilterActive ? 'Show All' : `❤️ ${proofing.favorites.size} Selected`}
{proofing.isFilterActive
? t.proofing.showAll
: t.proofing.selected(proofing.favorites.size)}
</button>
<button
type="button"
@@ -299,7 +314,7 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph
textDecoration: 'underline',
}}
>
Share & Export
{t.proofing.shareExport}
</button>
</div>
)}
@@ -314,6 +329,7 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph
onNext={goNext}
onPrev={goPrev}
watermark={watermark}
showExifToggle={showExifPanel}
/>
)}
</>

View File

@@ -2,6 +2,7 @@ import Link from 'next/link';
import Image from 'next/image';
import { imageUrl } from '@/lib/urls';
import { SubpageSectionConfig } from '@/lib/config';
import { getServerDictionary } from '@/lib/i18n/server';
interface SubpageAlbum {
id: string;
@@ -27,22 +28,26 @@ interface SubpageGridViewProps {
sections?: SubpageSectionConfig[];
/** 1-based position among all subpages — feeds the "03 — Collection" kicker. */
index?: number;
/** `--subpage-columns` / `--subpage-gap` from the resolved grid config. */
gridStyle?: React.CSSProperties;
}
const pad2 = (n: number) => String(n).padStart(2, '0');
const photoCount = (n: number) => `${n} ${n === 1 ? 'photo' : 'photos'}`;
const photoCount = (n: number) => getServerDictionary().common.photos(n);
function AlbumGrid({
albums,
placeholderMap,
slug,
gridStyle,
}: {
albums: SubpageAlbum[];
placeholderMap: Map<string, Placeholder | null>;
slug: string;
gridStyle?: React.CSSProperties;
}) {
return (
<div className="subpage-grid">
<div className="subpage-grid" style={gridStyle}>
{albums.map((album, i) => {
const ph = placeholderMap.get(album.id) ?? null;
return (
@@ -51,7 +56,10 @@ function AlbumGrid({
href={`/${slug}/${album.slug}`}
className="subpage-grid__item"
style={ph?.dominantColor ? { backgroundColor: ph.dominantColor } : undefined}
aria-label={`${album.albumName}, ${photoCount(album.assetCount)}`}
aria-label={getServerDictionary().subpage.coverAria(
album.albumName,
photoCount(album.assetCount),
)}
>
<span className="subpage-grid__item-media">
{album.albumThumbnailAssetId ? (
@@ -105,11 +113,13 @@ export function SubpageGridView({
coverPlaceholders,
sections,
index,
gridStyle,
}: SubpageGridViewProps) {
// Build lookup maps once
const albumMap = new Map(albums.map((a) => [a.id, a]));
const placeholderMap = new Map(albums.map((a, i) => [a.id, coverPlaceholders[i] ?? null]));
const t = getServerDictionary();
const hasSections = sections && sections.length > 0;
const totalPhotos = albums.reduce((sum, a) => sum + a.assetCount, 0);
@@ -120,21 +130,21 @@ export function SubpageGridView({
<div className="subpage-header__main">
{index !== undefined && (
<p className="subpage-header__kicker" aria-hidden="true">
{pad2(index)} — Collection
{t.subpage.collectionKicker(pad2(index))}
</p>
)}
{title && <h1 className="subpage-title">{title}</h1>}
{subtitle && <p className="subpage-subtitle">{subtitle}</p>}
</div>
<p className="subpage-header__meta" aria-hidden="true">
{albums.length} {albums.length === 1 ? 'album' : 'albums'} · {photoCount(totalPhotos)}
{t.common.albums(albums.length)} · {photoCount(totalPhotos)}
</p>
</header>
)}
{/* Typographic Table of Contents */}
{hasSections && (
<nav className="subpage-toc" aria-label="Sections">
<nav className="subpage-toc" aria-label={t.subpage.sectionsNav}>
{sections.map((sec, i) => (
<span key={sec.slug} className="subpage-toc__entry">
<a href={`#${sec.slug}`} className="subpage-toc__link">
@@ -160,13 +170,23 @@ export function SubpageGridView({
{sec.description && <p className="subpage-section__desc">{sec.description}</p>}
<div className="subpage-section__rule" aria-hidden="true" />
</header>
<AlbumGrid albums={sectionAlbums} placeholderMap={placeholderMap} slug={slug} />
<AlbumGrid
albums={sectionAlbums}
placeholderMap={placeholderMap}
slug={slug}
gridStyle={gridStyle}
/>
</section>
);
})
) : (
/* Flat layout (no sections) */
<AlbumGrid albums={albums} placeholderMap={placeholderMap} slug={slug} />
<AlbumGrid
albums={albums}
placeholderMap={placeholderMap}
slug={slug}
gridStyle={gridStyle}
/>
)}
</div>
);

View File

@@ -5,12 +5,14 @@
*/
import styles from '../loading.module.css';
import { getServerDictionary } from '@/lib/i18n/server';
export default function Loading() {
const t = getServerDictionary();
return (
<>
<div className={styles.heading} aria-hidden="true" />
<div className={styles.grid} role="status" aria-label="Loading photos">
<div className={styles.grid} role="status" aria-label={t.common.loadingPhotos}>
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className={styles.tile} />
))}

View File

@@ -24,7 +24,13 @@ import {
assetAspectRatio,
} from '@/lib/urls';
import { encodeAssetId } from '@/lib/tokens';
import { getConfig, resolveProofing, type GridConfig } from '@/lib/config';
import {
buildCoverGridVars,
getConfig,
hasExifPanelContent,
resolveProofing,
type GridConfig,
} from '@/lib/config';
import { isProtected, isAuthenticated } from '@/lib/auth';
import { isAdminAuthenticated } from '@/lib/admin/auth';
import PasswordGate from '@/components/PasswordGate';
@@ -34,6 +40,7 @@ import { SubpageGridView } from './SubpageGridView';
import { EssayView } from './EssayView';
import { parseEssayMarkdown } from '@/lib/essay';
import { loadEssayFromFile } from '@/lib/admin/journal-service';
import { getServerDictionary } from '@/lib/i18n/server';
// Render at request time — requires live Immich connection
export const dynamic = 'force-dynamic';
@@ -104,6 +111,10 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
}
/** Map Immich assets to PhotoItem props for the grid/lightbox. */
/**
* `showExif` covers the hover overlay only, and that overlay carries camera,
* lens and focal length — so it follows the `camera` group, not the panel.
*/
function toPhotoItems(assets: ImmichAsset[], showExif: boolean): PhotoItem[] {
return assets
.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO')
@@ -173,15 +184,28 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
// Build grid CSS custom properties, optionally merging subpage overrides
const buildGridStyle = (overrides?: Partial<GridConfig>): React.CSSProperties => {
const g = { ...config.grid, ...overrides };
// `gap` is only emitted when someone chose one. Each preset declares its own
// photo spacing as part of its look (2px minimal, 40px monograph), and
// emitting the resolved fallback unconditionally overwrote that — the three
// presets that fought back with a hardcoded `column-gap` then ignored the
// setting entirely, which is #513. Inline beats the preset's declaration, so
// an explicit value still wins everywhere.
return {
'--grid-columns': g.columns,
'--grid-gap': `${g.gap}px`,
...(overrides?.gap != null || config.gridGapExplicit ? { '--grid-gap': `${g.gap}px` } : {}),
'--grid-aspect-ratio': g.aspectRatio,
} as React.CSSProperties;
};
const resolveLayout = (overrides?: Partial<GridConfig>) =>
overrides?.layout ?? config.grid.layout;
// The album-cover grid on a subpage takes its column count from the same
// config as the photo grids, so "3 columns" in the admin means three columns
// everywhere. `gap` deliberately does not follow the global setting — see
// buildCoverGridVars() for why.
const buildCoverGridStyle = (overrides?: Partial<GridConfig>): React.CSSProperties =>
buildCoverGridVars(overrides, config.grid.columns) as React.CSSProperties;
// Client proofing — `proofing.enabled` in settings.yaml is the default and a
// subpage's own `proofing:` flag overrides it in either direction. Albums
// reached without a subpage follow the global setting.
@@ -230,7 +254,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
const spGrid = subpageData?.subpage.grid;
const subpageName = subpageData?.subpage.name ?? subpageSlug;
const images = toPhotoItems(album.assets, config.exifOnHover);
const images = toPhotoItems(album.assets, config.exif.onHover && config.exif.camera);
// Password gate for protected albums
const albumGate = await gateIfProtected(album.id, 'album', album.albumName);
@@ -247,6 +271,8 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
backLinkHref={`/${subpageSlug}`}
backLinkLabel={`Back to ${subpageName}`}
watermark={config.watermark}
showExifPanel={hasExifPanelContent(config.exif)}
showGear={config.exif.camera}
proofing={proofingFor(subpageData?.subpage)}
allowMailto={config.proofing.allowMailto}
{...heroData}
@@ -298,7 +324,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
albums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh)),
);
const allAssets = allAlbums.flatMap((a) => (a ? a.assets : []));
const images = toPhotoItems(allAssets, config.exifOnHover);
const images = toPhotoItems(allAssets, config.exif.onHover && config.exif.camera);
// Fallback structured essay if layout: 'essay' is set without custom markdown file
if (!essayParsed) {
@@ -355,7 +381,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
notFound();
}
const images = toPhotoItems(album.assets, config.exifOnHover);
const images = toPhotoItems(album.assets, config.exif.onHover && config.exif.camera);
// Password gate for protected albums
const albumGate = await gateIfProtected(album.id, 'album', album.albumName);
@@ -371,8 +397,10 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
gridStyle={buildGridStyle(mergeAlbumGrid(album.id, result.subpage.grid))}
subtitle={result.subpage.subtitle}
backLinkHref="/"
backLinkLabel="Back to Gallery"
backLinkLabel={getServerDictionary().common.backToGallery}
watermark={config.watermark}
showExifPanel={hasExifPanelContent(config.exif)}
showGear={config.exif.camera}
proofing={proofingFor(result.subpage)}
allowMailto={config.proofing.allowMailto}
{...heroData}
@@ -416,6 +444,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
albums={albumsWithHero}
coverPlaceholders={coverPlaceholders}
sections={result.subpage.sections}
gridStyle={buildCoverGridStyle(result.subpage.grid)}
{...(subpageIndex >= 0 ? { index: subpageIndex + 1 } : {})}
/>
);
@@ -435,7 +464,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
notFound();
}
const images = toPhotoItems(album.assets, config.exifOnHover);
const images = toPhotoItems(album.assets, config.exif.onHover && config.exif.camera);
// Password gate for protected albums
const albumGate = await gateIfProtected(album.id, 'album', album.albumName);
@@ -450,8 +479,10 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
layout={resolveLayout(mergeAlbumGrid(album.id))}
gridStyle={buildGridStyle(mergeAlbumGrid(album.id))}
backLinkHref="/"
backLinkLabel="Back to Gallery"
backLinkLabel={getServerDictionary().common.backToGallery}
watermark={config.watermark}
showExifPanel={hasExifPanelContent(config.exif)}
showGear={config.exif.camera}
proofing={proofingFor()}
allowMailto={config.proofing.allowMailto}
{...heroData}

View File

@@ -11,6 +11,7 @@ import type { Metadata } from 'next';
import { imageUrl, assetPlaceholder } from '@/lib/urls';
import { immich } from '@/lib/immich';
import { getConfig } from '@/lib/config';
import { getServerDictionary } from '@/lib/i18n/server';
import { notFound } from 'next/navigation';
import './about.css';
@@ -51,7 +52,8 @@ async function getAboutContent() {
export async function generateMetadata(): Promise<Metadata> {
const { meta, body } = await getAboutContent();
const title = meta.name ? `About — ${meta.name}` : 'About';
const t = getServerDictionary();
const title = meta.name ? t.about.metaTitle(meta.name) : t.about.title;
const description = body ? body.slice(0, 160).replace(/\s+/g, ' ').trim() : undefined;
return {
@@ -72,6 +74,7 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function AboutPage() {
const config = getConfig();
if (!config.aboutEnabled) notFound();
const t = getServerDictionary();
const { meta, body } = await getAboutContent();
@@ -86,7 +89,7 @@ export default async function AboutPage() {
{meta.portrait ? (
<Image
src={imageUrl(meta.portrait, 'preview')}
alt={meta.name || 'Portrait'}
alt={meta.name || t.about.portraitAlt}
className="about__portrait"
fill
sizes="(max-width: 768px) 100vw, 40vw"
@@ -102,7 +105,7 @@ export default async function AboutPage() {
{/* ── Text ─────────────────────────────────────── */}
<div className="about__text-col">
<p className="about__kicker" aria-hidden="true">
About
{t.about.kicker}
</p>
{meta.name && <h1 className="about__name">{meta.name}</h1>}
{meta.location && <p className="about__location">{meta.location}</p>}
@@ -123,7 +126,7 @@ export default async function AboutPage() {
{meta.gear && meta.gear.length > 0 && (
<>
<hr className="about__rule" />
<h2 className="about__section-label">Gear</h2>
<h2 className="about__section-label">{t.about.gear}</h2>
<ul className="about__gear">
{meta.gear.map((item) => (
<li key={item}>{item}</li>

View File

@@ -1581,6 +1581,323 @@
flex-direction: column;
}
/* ── Grouped feature settings (#510) ──────────────────────────
The features panel used to be one flat grid of identical cards. These give it
the structure the config already had: labelled groups, and sub-settings that
look like sub-settings instead of peers. */
.settings-group {
margin-bottom: 1.75rem;
}
.settings-group-head {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-bottom: 0.65rem;
}
.settings-group-title {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--admin-text-secondary);
}
/* Marks a group whose switches publish something, so it does not read like the
one next to it that controls a fade. */
.settings-group-chip {
margin-left: 4px;
padding: 2px 7px;
border: 1px solid var(--admin-border);
border-radius: 999px;
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.06em;
color: var(--admin-text-secondary);
}
.settings-group-desc {
font-size: 0.75rem;
color: var(--admin-text-muted);
line-height: 1.35;
}
.settings-subpanel {
padding: 1rem 1.25rem;
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 12px;
}
.settings-subpanel-head {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding-bottom: 0.75rem;
}
.settings-subpanel-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.88rem;
font-weight: 600;
color: var(--admin-text);
}
.settings-subpanel-desc {
font-size: 0.75rem;
color: var(--admin-text-muted);
}
/* Quiet marker on an album Immich still considers private (#515). Deliberately
not styled as a warning: publishing it is allowed, this only says what it is. */
.picker-item-note {
display: inline-flex;
align-items: center;
margin-top: 0.2rem;
font-size: 0.68rem;
letter-spacing: 0.02em;
color: var(--admin-text-muted);
}
/* ── Config doctor report (#491) ──────────────────────────────
Each finding is one line with a severity dot: the point is scanning a list
for the ones that are not green, not reading prose. */
.doctor-list {
margin: 0;
padding: 0;
list-style: none;
}
.doctor-item {
display: flex;
align-items: flex-start;
gap: 0.7rem;
padding: 0.8rem 0;
border-bottom: 1px solid var(--admin-border);
}
.doctor-item:last-child {
border-bottom: none;
}
.doctor-dot {
width: 8px;
height: 8px;
margin-top: 0.42rem;
border-radius: 50%;
flex-shrink: 0;
background: var(--admin-success);
}
.doctor-item--warn .doctor-dot {
background: var(--admin-accent);
}
.doctor-item--error .doctor-dot {
background: var(--admin-error);
}
.doctor-item-body {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.doctor-item-title {
font-size: 0.84rem;
font-weight: 600;
color: var(--admin-text);
}
/* A finding that needs nothing recedes; the eye should land on the others. */
.doctor-item--ok .doctor-item-title {
font-weight: 500;
color: var(--admin-text-secondary);
}
.doctor-item-detail {
font-size: 0.75rem;
line-height: 1.45;
color: var(--admin-text-muted);
}
/* The metadata group as one card with an expander: four permanently visible
sub-switches read as four decisions, when they are details of one. */
.metadata-card {
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 12px;
overflow: hidden;
transition: border-color 0.15s ease;
}
.metadata-card.active {
border-color: var(--admin-accent);
box-shadow: 0 0 0 1px var(--admin-accent);
}
.metadata-card-main {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
background: none;
border: none;
color: inherit;
text-align: left;
font: inherit;
cursor: pointer;
}
.metadata-details-btn {
display: inline-flex;
align-items: center;
gap: 5px;
margin: 0 1.25rem 0.9rem;
padding: 0;
background: none;
border: none;
color: var(--admin-text-secondary);
font: inherit;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
cursor: pointer;
}
.metadata-details-btn:hover {
color: var(--admin-text);
}
.metadata-details-chevron {
transition: transform 0.15s ease;
}
.metadata-details-chevron.open {
transform: rotate(180deg);
}
.metadata-details {
padding: 0 1.25rem 0.6rem;
border-top: 1px solid var(--admin-border);
}
/* The first row sits directly under that border and does not need its own. */
.metadata-details .setting-row:first-child {
border-top: none;
}
.setting-row {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.7rem 0;
background: none;
border: none;
border-top: 1px solid var(--admin-border);
color: inherit;
text-align: left;
cursor: pointer;
font: inherit;
}
/* A sub-setting of the rows above it — indented so the dependency is visible
rather than only true. */
.setting-row--indented {
padding-left: 0.9rem;
margin-top: 0.35rem;
}
.setting-row--disabled {
cursor: default;
}
.setting-row--disabled .setting-row-title,
.setting-row--disabled .setting-row-desc {
color: var(--admin-text-muted);
}
.setting-row-info {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.setting-row-title {
font-size: 0.81rem;
font-weight: 500;
color: var(--admin-text);
}
.setting-row-desc {
font-size: 0.72rem;
color: var(--admin-text-muted);
line-height: 1.3;
}
/* Says why an inert row is inert, instead of leaving it looking broken. */
.setting-row-hint {
display: inline-flex;
align-items: center;
gap: 5px;
margin-top: 0.2rem;
font-size: 0.69rem;
color: var(--admin-text-secondary);
}
.switch-toggle--sm {
width: 34px;
height: 20px;
border-radius: 10px;
}
.switch-toggle--sm .switch-slider {
width: 14px;
height: 14px;
}
.switch-toggle--sm.on .switch-slider {
transform: translateX(14px);
}
/* An inert switch must not look like an off switch someone can turn on. */
.setting-row--disabled .switch-toggle {
background: var(--admin-bg-elevated);
}
.setting-row--disabled .switch-slider {
background: var(--admin-text-muted);
}
/* Explains an unfinished installation before the indicators below it, which
would otherwise read as a fault rather than a pending step. */
.status-setup-note {
margin: 0 0 0.35rem;
padding: 0.5rem 0.6rem;
border-radius: 6px;
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
color: var(--admin-text-secondary);
font-size: 0.75rem;
line-height: 1.45;
}
.status-setup-note a {
color: var(--admin-text);
text-decoration: underline;
}
.status-item {
display: flex;
justify-content: space-between;

View File

@@ -1,9 +1,11 @@
'use client';
import { useState, useEffect, type ReactNode } from 'react';
import { useState, useEffect, useCallback, type ReactNode } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import BackupManagerModal from './BackupManagerModal';
import DoctorModal from './DoctorModal';
import type { DoctorLevel } from '@/lib/admin/doctor';
import * as Icons from './Icons';
interface Props {
@@ -26,6 +28,10 @@ export default function AdminDashboard({ onLogout, children }: Props) {
// Diagnostics & Backup state
const [showStatus, setShowStatus] = useState(false);
const [showBackupModal, setShowBackupModal] = useState(false);
const [showDoctor, setShowDoctor] = useState(false);
// The badge's job is "does anything need me?", and the doctor is the only
// check that can answer that beyond "is Immich up" (#491).
const [doctorLevel, setDoctorLevel] = useState<DoctorLevel | null>(null);
const [status, setStatus] = useState<any>(null);
const [statusLoading, setStatusLoading] = useState(false);
// Distinct from `status === null`: the check itself did not run. Without this
@@ -80,16 +86,37 @@ export default function AdminDashboard({ onLogout, children }: Props) {
return healthy ? { className: 'ok', label: okLabel } : { className: 'error', label: badLabel };
};
const fetchDoctor = useCallback(async () => {
try {
const res = await fetch('/api/admin/doctor');
if (!res.ok) return;
const data = await res.json();
setDoctorLevel(data.level ?? null);
} catch {
// A failed check must not colour the badge — "unknown" is not "broken".
}
}, []);
useEffect(() => {
fetchStatus();
}, []);
fetchDoctor();
}, [fetchDoctor]);
const immichIndicator = indicator(
status?.immich?.status === 'connected',
'Connected',
'Disconnected',
);
const configIndicator = indicator(status?.config?.status === 'valid', 'Valid', 'Degraded');
const configIndicator =
!statusLoading && !statusError && status?.config?.status === 'setup'
? { className: 'unknown', label: 'Not set up' }
: indicator(status?.config?.status === 'valid', 'Valid', 'Degraded');
// An installation that was never finished is not a fault. It used to render
// as "System Degraded" with no further hint, which sent operators looking for
// a network problem that did not exist (#507).
const setupIncomplete = !statusLoading && !statusError && status?.setup?.complete === false;
const missingCredentials = status?.setup?.credentials === 'missing';
return (
<div className="admin-dashboard">
@@ -112,7 +139,15 @@ export default function AdminDashboard({ onLogout, children }: Props) {
{/* Diagnostics Badge */}
<div className="status-indicator-container">
<button
className={`status-badge-btn ${immichIndicator.className === 'ok' ? 'connected' : immichIndicator.className === 'error' ? 'disconnected' : 'unknown'}`}
className={`status-badge-btn ${
setupIncomplete || doctorLevel === 'warn'
? 'unknown'
: immichIndicator.className === 'error' || doctorLevel === 'error'
? 'disconnected'
: immichIndicator.className === 'ok'
? 'connected'
: 'unknown'
}`}
onClick={() => {
setShowStatus(!showStatus);
if (!showStatus) fetchStatus();
@@ -123,11 +158,17 @@ export default function AdminDashboard({ onLogout, children }: Props) {
<span className="status-text">
{statusLoading
? 'Checking...'
: immichIndicator.className === 'ok'
? 'System OK'
: setupIncomplete
? 'Setup Incomplete'
: immichIndicator.className === 'error'
? 'System Degraded'
: 'Status Unknown'}
: doctorLevel === 'error'
? 'Needs Attention'
: doctorLevel === 'warn'
? 'Check Diagnostics'
: immichIndicator.className === 'ok'
? 'System OK'
: 'Status Unknown'}
</span>
</button>
@@ -147,6 +188,19 @@ export default function AdminDashboard({ onLogout, children }: Props) {
</button>
</div>
<div className="status-dropdown-body">
{setupIncomplete && (
<p className="status-setup-note">
{missingCredentials
? 'No Immich URL or API key configured. Set IMMICH_API_URL and IMMICH_API_KEY, or run the '
: 'No content/gallery.yaml yet — the public site has nothing to show. Add a page below, or run the '}
<a href="/install" target="_blank" rel="noopener noreferrer">
setup wizard
</a>
{missingCredentials
? '.'
: ' (the one-time token is printed to the server log).'}
</p>
)}
<div className="status-item">
<span className="status-label">Immich Connection</span>
<span className={`status-val ${immichIndicator.className}`}>
@@ -177,6 +231,15 @@ export default function AdminDashboard({ onLogout, children }: Props) {
</div>
</div>
<div className="status-dropdown-footer">
<button
className="admin-btn admin-btn-sm"
onClick={() => {
setShowStatus(false);
setShowDoctor(true);
}}
>
<Icons.IconShieldCheck size={14} /> Diagnostics
</button>
<button
className="admin-btn admin-btn-sm"
onClick={() => {
@@ -226,6 +289,14 @@ export default function AdminDashboard({ onLogout, children }: Props) {
<main className="admin-main">{children}</main>
<DoctorModal
isOpen={showDoctor}
onClose={() => {
setShowDoctor(false);
fetchDoctor();
}}
/>
<BackupManagerModal
isOpen={showBackupModal}
onClose={() => setShowBackupModal(false)}

View File

@@ -11,6 +11,8 @@ interface ImmichAlbumInfo {
thumbnailAssetId: string | null;
assetCount: number;
isConfigured: boolean;
/** Immich's own flag. Undefined on older Immich — silence beats a false alarm. */
shared?: boolean;
}
interface Props {
@@ -89,6 +91,13 @@ export default function AlbumPicker({ albums, onSelect, onClose, usedAlbumIds }:
{album.assetCount} photos
{album.description && ` · ${album.description.slice(0, 40)}`}
</span>
{/* Not a warning and not a barrier: publishing an unshared
album has always worked, because Immich ignores the
?shared=true filter this list is fetched with (#515). It
is here so nobody publishes a private album unaware. */}
{album.shared === false && (
<span className="picker-item-note">Not shared in Immich</span>
)}
</div>
<div className="picker-item-action">
{isUsed ? (

View File

@@ -0,0 +1,131 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import type { DoctorFinding, DoctorLevel } from '@/lib/admin/doctor';
import * as Icons from './Icons';
import { useScrollLock } from './useScrollLock';
interface Props {
isOpen: boolean;
onClose: () => void;
}
const LEVEL_LABEL: Record<DoctorLevel, string> = {
ok: 'OK',
warn: 'Check',
error: 'Problem',
};
/**
* The config doctor's report (#491).
*
* Most "it does not work for me" reports are one of a handful of
* misconfigurations that fail silently — a wrong proxy hop count, a deleted
* album ID, a plaintext password. Each of those already logs a warning to the
* server log, where nobody looks. This puts them where the operator is.
*/
export default function DoctorModal({ isOpen, onClose }: Props) {
const [findings, setFindings] = useState<DoctorFinding[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const run = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/admin/doctor');
if (!res.ok) throw new Error(`Diagnostics failed (${res.status})`);
const data = await res.json();
setFindings(data.findings || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Diagnostics failed');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (isOpen) {
setCopied(false);
run();
}
}, [isOpen, run]);
useScrollLock(isOpen);
if (!isOpen) return null;
/** Markdown, so it can be pasted straight into a GitHub issue. */
async function copyReport() {
const body = findings
.map((f) => `- **${LEVEL_LABEL[f.level]}** — ${f.title}\n ${f.detail}`)
.join('\n');
try {
await navigator.clipboard.writeText(`### Immich Folio diagnostics\n\n${body}\n`);
setCopied(true);
} catch {
setError('Could not copy — your browser refused clipboard access.');
}
}
const problems = findings.filter((f) => f.level !== 'ok').length;
return (
<div className="backup-modal-backdrop" onClick={onClose}>
<div className="backup-modal-container" onClick={(e) => e.stopPropagation()}>
<div className="backup-modal-header">
<div>
<h2>Diagnostics</h2>
<p className="backup-modal-subtitle">
{loading
? 'Checking…'
: problems === 0
? 'Nothing needs your attention.'
: `${problems} of ${findings.length} checks want a look.`}
</p>
</div>
<button className="backup-modal-close-btn" onClick={onClose} aria-label="Close">
<Icons.IconX size={18} />
</button>
</div>
<div className="backup-modal-body">
{error && <div className="admin-error">{error}</div>}
{loading && !findings.length ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<ul className="doctor-list">
{findings.map((f) => (
<li key={f.id} className={`doctor-item doctor-item--${f.level}`}>
<span className="doctor-dot" aria-hidden="true" />
<span className="doctor-item-body">
<span className="doctor-item-title">{f.title}</span>
<span className="doctor-item-detail">{f.detail}</span>
</span>
</li>
))}
</ul>
)}
</div>
<div className="backup-modal-footer">
<button className="admin-btn admin-btn-sm" onClick={run} disabled={loading}>
<Icons.IconRefresh size={14} /> {loading ? 'Checking…' : 'Run again'}
</button>
<button
className="admin-btn admin-btn-sm"
onClick={copyReport}
disabled={loading || !findings.length}
title="Markdown, ready to paste into an issue"
>
<Icons.IconCopy size={14} /> {copied ? 'Copied' : 'Copy report'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -31,7 +31,12 @@ import {
isAlbumSortMode,
type AlbumSortMode,
} from '@/lib/albumSort';
import type { AlbumEntryObject } from '@/lib/config/schema';
import {
COVER_GRID_COLUMNS_MAX,
COVER_GRID_COLUMNS_MIN,
COVER_GRID_GAP_MAX,
type AlbumEntryObject,
} from '@/lib/config/schema';
import {
IconArrowDown,
IconArrowUp,
@@ -122,6 +127,21 @@ interface Subpage {
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
}
/**
* Drops keys the editor cleared so an emptied field never persists as `null`
* in gallery.yaml, and returns undefined once nothing is left — `handleSave`
* only writes `grid` when it is truthy.
*/
function normalizeSubpageGrid(grid: Subpage['grid']): Subpage['grid'] {
if (!grid) return undefined;
const next = { ...grid };
for (const key of Object.keys(next) as Array<keyof typeof next>) {
const value = next[key];
if (value === undefined || value === '') delete next[key];
}
return Object.keys(next).length > 0 ? next : undefined;
}
interface GalleryState {
hero: string[];
albums: AlbumEntry[];
@@ -1452,6 +1472,80 @@ export default function PageBuilder() {
</option>
</select>
</div>
{/* Album cover grid — hidden in essay mode, which
renders a story instead of a cover grid. The
album count is deliberately not checked: it
changes in this very drawer, and a field that
vanishes while you edit is worse than one that
has no effect yet. */}
{sp.grid?.layout !== 'essay' && sp.essayText == null && (
<div className="admin-field" style={{ marginTop: '1rem' }}>
<label>Album Cover Grid</label>
<div style={{ display: 'flex', gap: '12px' }}>
<input
type="number"
min={COVER_GRID_COLUMNS_MIN}
max={COVER_GRID_COLUMNS_MAX}
value={sp.grid?.columns ?? ''}
placeholder="Columns (site default)"
onChange={(e) => {
const raw = e.target.value;
const columns = raw === '' ? undefined : Number(raw);
updateSubpage(spIndex, {
grid: normalizeSubpageGrid({
...(sp.grid || {}),
columns:
columns != null &&
columns >= COVER_GRID_COLUMNS_MIN &&
columns <= COVER_GRID_COLUMNS_MAX
? columns
: undefined,
}),
});
}}
style={{
padding: '8px 12px',
borderRadius: '6px',
flex: 1,
fontSize: '0.9rem',
}}
/>
<input
type="number"
min={0}
max={COVER_GRID_GAP_MAX}
value={sp.grid?.gap ?? ''}
placeholder="Gap in px (theme default)"
onChange={(e) => {
const raw = e.target.value;
const gap = raw === '' ? undefined : Number(raw);
updateSubpage(spIndex, {
grid: normalizeSubpageGrid({
...(sp.grid || {}),
gap:
gap != null && gap >= 0 && gap <= COVER_GRID_GAP_MAX
? gap
: undefined,
}),
});
}}
style={{
padding: '8px 12px',
borderRadius: '6px',
flex: 1,
fontSize: '0.9rem',
}}
/>
</div>
<p className="admin-field-hint">
Overrides how the album covers are tiled on this page. Leave the
column count empty to follow the site-wide grid setting, and the
gap empty to keep the theme&apos;s own spacing. Tablet widths
show at most two covers per row.
</p>
</div>
)}
</div>
{/* Visual Photo Essay Builder (if layout === 'essay') */}

View File

@@ -8,12 +8,22 @@ import SaveBar from './SaveBar';
// Direct import from the theme module, not from '@/lib/config': the config
// index pulls in `fs` and cannot be bundled into a client component.
import { DEFAULT_PRESET } from '@/lib/config/theme';
import { resolveExifDisplay, resolveWatermarkOpacity } from '@/lib/config/schema';
import { SUPPORTED_LOCALES } from '@/lib/i18n';
interface Settings {
title?: string;
subtitle?: string;
lang?: string;
sitePassword?: string;
mode?: 'light' | 'dark' | 'auto';
exifOnHover?: boolean;
exif?: {
camera?: boolean;
settings?: boolean;
location?: boolean;
caption?: boolean;
};
map?: boolean;
transitions?: boolean;
scrollToTop?: boolean;
@@ -204,6 +214,91 @@ interface SettingsEditorProps {
section?: string;
}
/**
* One labelled block of related switches.
*
* The features panel was a single flat grid of identical cards, so nothing said
* which switches belonged together — and a switch that publishes private notes
* looked exactly like one that controls a fade (#510).
*/
function FeatureGroup({
icon,
title,
description,
chip,
children,
}: {
icon: React.ReactNode;
title: string;
description: string;
/** Short marker for a group that needs its own weight, e.g. what visitors see. */
chip?: string;
children: React.ReactNode;
}) {
return (
<div className="settings-group">
<div className="settings-group-head">
<span className="settings-group-title">
{icon}
{title}
{chip && <span className="settings-group-chip">{chip}</span>}
</span>
<span className="settings-group-desc">{description}</span>
</div>
{children}
</div>
);
}
/**
* A switch inside a group panel. Deliberately lighter than a toggle card: these
* are settings *of* the panel they sit in, and rendering them as peers of the
* top-level cards is what hid the structure in the first place.
*/
function SettingRow({
title,
description,
checked,
onToggle,
disabled,
hint,
indented,
}: {
title: string;
description: string;
checked: boolean;
onToggle: () => void;
/** A row whose parent switch is off — shown, but inert and explained. */
disabled?: boolean;
hint?: string;
indented?: boolean;
}) {
return (
<button
type="button"
className={`setting-row${indented ? ' setting-row--indented' : ''}${
disabled ? ' setting-row--disabled' : ''
}`}
onClick={onToggle}
disabled={disabled}
aria-pressed={checked}
>
<span className="setting-row-info">
<span className="setting-row-title">{title}</span>
<span className="setting-row-desc">{description}</span>
{hint && (
<span className="setting-row-hint">
<Icons.IconBan size={11} /> {hint}
</span>
)}
</span>
<span className={`switch-toggle switch-toggle--sm ${checked && !disabled ? 'on' : ''}`}>
<span className="switch-slider" />
</span>
</button>
);
}
export default function SettingsEditor({ section }: SettingsEditorProps) {
const router = useRouter();
// An unknown section in the URL falls back to General instead of an empty panel.
@@ -212,6 +307,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
: 'general';
const [settings, setSettings] = useState<Settings>({});
const [loading, setLoading] = useState(true);
// Collapsed by default: the four metadata switches are a detail of one
// decision, and showing them permanently is what made the section a wall (#510).
const [metadataOpen, setMetadataOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [saveMessage, setSaveMessage] = useState('');
@@ -330,6 +428,25 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
}
}
/** Write several paths in one state update — used by the metadata master switch. */
function updateMany(entries: Record<string, unknown>) {
setSettings((s) => {
const copy = JSON.parse(JSON.stringify(s));
for (const [path, value] of Object.entries(entries)) {
const parts = path.split('.');
let obj = copy;
for (let i = 0; i < parts.length - 1; i++) {
if (!obj[parts[i]]) obj[parts[i]] = {};
obj = obj[parts[i]];
}
obj[parts[parts.length - 1]] = value;
}
return copy;
});
setDirty(true);
setSaveMessage('');
}
function update(path: string, value: unknown) {
setSettings((s) => {
const copy = JSON.parse(JSON.stringify(s));
@@ -457,6 +574,34 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
);
}
// Resolved the same way the site resolves it, so the switches show what a
// visitor actually sees — including a config that only ever set the older
// `exifOnHover`.
const exif = resolveExifDisplay(settings.exif, settings.exifOnHover);
// The master switch has no key of its own and needs none: "off" is all four
// groups off, a state the site already acts on — with nothing left to show,
// the lightbox withdraws its info button and the `i` key entirely (#506).
const anyMetadata = exif.camera || exif.settings || exif.location || exif.caption;
const metadataSummary =
[
exif.camera && 'Camera & lens',
exif.settings && 'Exposure',
exif.location && 'Location',
exif.caption && 'Description',
]
.filter(Boolean)
.join(' · ') || 'Nothing published — the lightbox hides its info panel';
/** Turning it back on selects everything; the details below narrow it again. */
const toggleMetadata = () =>
updateMany({
'exif.camera': !anyMetadata,
'exif.settings': !anyMetadata,
'exif.location': !anyMetadata,
'exif.caption': !anyMetadata,
});
return (
<div className="settings-editor">
{/* The About panel edits about.md, not settings.yaml, so it drives the bar itself. */}
@@ -537,6 +682,11 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<option value="es">Español (ES)</option>
<option value="ja">日本語 (JA)</option>
</select>
<p className="admin-field-hint">
The visitor-facing interface is translated for {SUPPORTED_LOCALES.join(', ')}.
Other languages still set <code>&lt;html lang&gt;</code> and date formatting, but
show the English interface. The admin panel is always English.
</p>
</div>
<div className="settings-section-divider" />
@@ -550,136 +700,224 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
</p>
</div>
<div className="admin-toggle-cards-grid">
<button
type="button"
className={`admin-toggle-card ${settings.exifOnHover !== false ? 'active' : ''}`}
onClick={() => update('exifOnHover', settings.exifOnHover === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconCamera size={16} /> EXIF Data on Hover
</span>
<span className="toggle-card-desc">
Display camera gear, lens, aperture &amp; shutter speed on hover
</span>
</div>
<div className={`switch-toggle ${settings.exifOnHover !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
<button
type="button"
className={`admin-toggle-card ${settings.map === true ? 'active' : ''}`}
onClick={() => update('map', !settings.map)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconMap size={16} /> Interactive GPS Map
</span>
<span className="toggle-card-desc">
Enable /map view showing photo locations on a world map
</span>
</div>
<div className={`switch-toggle ${settings.map === true ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
<button
type="button"
className={`admin-toggle-card ${settings.transitions !== false ? 'active' : ''}`}
onClick={() => update('transitions', settings.transitions === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconSparkles size={16} /> Smooth Page Transitions
</span>
<span className="toggle-card-desc">
Enable subtle fade-in animations between page navigation
</span>
</div>
<div className={`switch-toggle ${settings.transitions !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
<button
type="button"
className={`admin-toggle-card ${settings.scrollToTop !== false ? 'active' : ''}`}
onClick={() => update('scrollToTop', settings.scrollToTop === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconArrowUp size={16} /> Scroll-to-Top Button
</span>
<span className="toggle-card-desc">
Show a floating arrow that returns visitors to the top of long pages
</span>
</div>
<div className={`switch-toggle ${settings.scrollToTop !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
<button
type="button"
className={`admin-toggle-card ${settings.analytics !== false ? 'active' : ''}`}
onClick={() => update('analytics', settings.analytics === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconBarChart size={16} /> Analytics Tracking
</span>
<span className="toggle-card-desc">
Collect anonymous privacy-friendly visit statistics
</span>
</div>
<div className={`switch-toggle ${settings.analytics !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
<button
type="button"
className={`admin-toggle-card ${settings.proofing?.enabled !== false ? 'active' : ''}`}
onClick={() => update('proofing.enabled', settings.proofing?.enabled === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconHeart size={16} /> Client Proofing &amp; Favorites
</span>
<span className="toggle-card-desc">
Allow visitors &amp; clients to heart, filter, and export favorite photo
selections
</span>
</div>
<div
className={`switch-toggle ${settings.proofing?.enabled !== false ? 'on' : ''}`}
<FeatureGroup
icon={<Icons.IconFrame size={13} />}
title="Pages &amp; motion"
description="Chrome the visitor sees on every page."
>
<div className="admin-toggle-cards-grid">
<button
type="button"
className={`admin-toggle-card ${settings.about?.enabled !== false ? 'active' : ''}`}
onClick={() => update('about.enabled', settings.about?.enabled === false)}
>
<span className="switch-slider" />
</div>
</button>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconCamera size={16} /> About Page
</span>
<span className="toggle-card-desc">
Show a portrait, bio, and gear section on your portfolio
</span>
</div>
<div
className={`switch-toggle ${settings.about?.enabled !== false ? 'on' : ''}`}
>
<span className="switch-slider" />
</div>
</button>
<button
type="button"
className={`admin-toggle-card ${settings.about?.enabled !== false ? 'active' : ''}`}
onClick={() => update('about.enabled', settings.about?.enabled === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconCamera size={16} /> About Page
<button
type="button"
className={`admin-toggle-card ${settings.transitions !== false ? 'active' : ''}`}
onClick={() => update('transitions', settings.transitions === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconSparkles size={16} /> Smooth Page Transitions
</span>
<span className="toggle-card-desc">
Enable subtle fade-in animations between page navigation
</span>
</div>
<div className={`switch-toggle ${settings.transitions !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
<button
type="button"
className={`admin-toggle-card ${settings.scrollToTop !== false ? 'active' : ''}`}
onClick={() => update('scrollToTop', settings.scrollToTop === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconArrowUp size={16} /> Scroll-to-Top Button
</span>
<span className="toggle-card-desc">
Show a floating arrow that returns visitors to the top of long pages
</span>
</div>
<div className={`switch-toggle ${settings.scrollToTop !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
</div>
</FeatureGroup>
<FeatureGroup
icon={<Icons.IconShieldCheck size={13} />}
title="What each photo reveals"
description="Published to anyone who opens a photo."
chip="visible to visitors"
>
<div className={`metadata-card ${anyMetadata ? 'active' : ''}`}>
<button
type="button"
className="metadata-card-main"
onClick={toggleMetadata}
aria-pressed={anyMetadata}
>
<span className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconCamera size={16} /> Photo metadata
</span>
<span className="toggle-card-desc">{metadataSummary}</span>
</span>
<span className="toggle-card-desc">
Show a portrait, bio, and gear section on your portfolio
<span className={`switch-toggle ${anyMetadata ? 'on' : ''}`}>
<span className="switch-slider" />
</span>
</div>
<div className={`switch-toggle ${settings.about?.enabled !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
</div>
</button>
<button
type="button"
className="metadata-details-btn"
onClick={() => setMetadataOpen((open) => !open)}
aria-expanded={metadataOpen}
>
Details
<Icons.IconChevronDown
size={13}
className={`metadata-details-chevron${metadataOpen ? ' open' : ''}`}
/>
</button>
{metadataOpen && (
<div className="metadata-details">
<SettingRow
title="Camera &amp; Lens"
description="Body, lens and focal length"
checked={exif.camera}
onToggle={() => update('exif.camera', !exif.camera)}
/>
<SettingRow
title="Exposure Settings"
description="Aperture, shutter speed and ISO"
checked={exif.settings}
onToggle={() => update('exif.settings', !exif.settings)}
/>
<SettingRow
title="Location"
description="City and country from the photo's GPS data"
checked={exif.location}
onToggle={() => update('exif.location', !exif.location)}
/>
<SettingRow
title="Photo Description"
description="The description written in Immich"
checked={exif.caption}
onToggle={() => update('exif.caption', !exif.caption)}
/>
{/* Dependent, not a peer: this only controls the grid overlay, and
the overlay carries camera and lens. With those off it has
nothing to show, which used to leave it switched on and
silently doing nothing (#510). */}
<SettingRow
indented
title="Summary on grid hover"
description="Camera and lens over the photo, not only in the lightbox"
checked={settings.exifOnHover !== false}
disabled={!exif.camera}
hint={exif.camera ? undefined : 'Needs Camera & Lens'}
onToggle={() => update('exifOnHover', settings.exifOnHover === false)}
/>
</div>
)}
</div>
</FeatureGroup>
<FeatureGroup
icon={<Icons.IconHeart size={13} />}
title="What visitors can do"
description="Optional pages and interactions."
>
<div className="admin-toggle-cards-grid">
<button
type="button"
className={`admin-toggle-card ${settings.map === true ? 'active' : ''}`}
onClick={() => update('map', !settings.map)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconMap size={16} /> Interactive GPS Map
</span>
<span className="toggle-card-desc">
Enable /map view showing photo locations on a world map
</span>
</div>
<div className={`switch-toggle ${settings.map === true ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
<button
type="button"
className={`admin-toggle-card ${settings.proofing?.enabled !== false ? 'active' : ''}`}
onClick={() => update('proofing.enabled', settings.proofing?.enabled === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconHeart size={16} /> Client Proofing &amp; Favorites
</span>
<span className="toggle-card-desc">
Allow visitors &amp; clients to heart, filter, and export favorite photo
selections
</span>
</div>
<div
className={`switch-toggle ${settings.proofing?.enabled !== false ? 'on' : ''}`}
>
<span className="switch-slider" />
</div>
</button>
</div>
</FeatureGroup>
<FeatureGroup
icon={<Icons.IconBarChart size={13} />}
title="Measurement"
description="Cookieless and self-hosted — nothing leaves your server."
>
<div className="admin-toggle-cards-grid">
<button
type="button"
className={`admin-toggle-card ${settings.analytics !== false ? 'active' : ''}`}
onClick={() => update('analytics', settings.analytics === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconBarChart size={16} /> Analytics Tracking
</span>
<span className="toggle-card-desc">
Collect anonymous privacy-friendly visit statistics
</span>
</div>
<div className={`switch-toggle ${settings.analytics !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
</div>
</FeatureGroup>
<div className="admin-field favicon-field">
<label>Favicon</label>
@@ -758,6 +996,23 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
</p>
</div>
<div className="admin-field">
<label>Visitor Default Mode</label>
<select
value={settings.mode || 'dark'}
onChange={(e) => update('mode', e.target.value)}
>
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="auto">Follow the visitor's system</option>
</select>
<p className="admin-field-hint">
What a first-time visitor sees. The light/dark toggle in the site header still
lets them choose, and their choice is remembered on their device. The preview
below is your own, and does not change this.
</p>
</div>
<div className="admin-field">
<label>Preset</label>
<div className="preset-card-grid">
@@ -1256,7 +1511,11 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<h3>
<Icons.IconColumns size={18} /> Spacing &amp; Columns
</h3>
<p className="settings-section-sub">Adjust column counts and grid gap spacing.</p>
<p className="settings-section-sub">
Adjust column counts and grid gap spacing. The column count also tiles the album
covers on a subpage; their spacing stays with the theme unless a page overrides
it.
</p>
</div>
<div className="admin-field-row">
@@ -1618,6 +1877,35 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
{activeSection === 'security' && (
<div className="settings-panel">
<div className="settings-section-header">
<h3>
<Icons.IconLock size={18} /> Site Password
</h3>
<p className="settings-section-sub">
Puts the whole public site behind one password — useful while a portfolio is still
being built, or for a folio only ever shown to clients.
</p>
</div>
<div className="admin-field">
<label>Site Password</label>
<input
type="text"
value={settings.sitePassword || ''}
onChange={(e) => update('sitePassword', e.target.value)}
placeholder="Leave empty for a public site"
autoComplete="off"
/>
<p className="admin-field-hint">
Stored in <code>settings.yaml</code>. Log in once and the server log prints a{' '}
<code>scrypt:…</code> hash to paste back here instead of the plaintext. The{' '}
<code>SITE_PASSWORD</code> environment variable overrides this field. The admin
panel keeps its own password and is never behind this gate.
</p>
</div>
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3>
<Icons.IconShieldCheck size={18} /> Asset Protection &amp; Watermark
@@ -1730,15 +2018,20 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
</select>
</div>
<div className="admin-field">
{/* Read through the same normaliser the lightbox uses, so a
config written as a percentage shows as "90%" here rather
than "9000%" — and is written back as a fraction on save. */}
<label>
Opacity ({Math.round((settings.watermark?.opacity ?? 0.3) * 100)}%)
Opacity (
{Math.round(resolveWatermarkOpacity(settings.watermark?.opacity) * 100)}
%)
</label>
<input
type="range"
min="0.1"
max="0.8"
max="1"
step="0.05"
value={settings.watermark?.opacity ?? 0.3}
value={resolveWatermarkOpacity(settings.watermark?.opacity)}
onChange={(e) => update('watermark.opacity', parseFloat(e.target.value))}
/>
</div>

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* The Dockerfile's HEALTHCHECK probes this route. A config that cannot be
* parsed therefore used to restart the container in a loop — at exactly the
* moment the app was still repairable through /admin, and when restarting
* cannot fix a YAML typo anyway (#519).
*/
const configOrNull = vi.fn<() => object | null>(() => ({}));
vi.mock('@/lib/config', () => ({
getConfigOrNull: () => configOrNull(),
}));
vi.mock('@/lib/env', () => ({ env: { RATE_LIMIT_RPM: 1500, TRUSTED_PROXY_HOPS: 0 } }));
vi.mock('@/lib/immich', () => ({
immich: { ping: vi.fn(async () => true) },
}));
import { NextRequest } from 'next/server';
const req = () => new NextRequest('http://localhost/api/health');
/**
* The route caches Immich's answer for ten seconds in a module variable, so
* each case needs its own module instance — otherwise the second test reads the
* first one's verdict.
*/
async function route() {
vi.resetModules();
return (await import('../health/route')).GET;
}
beforeEach(() => {
vi.clearAllMocks();
configOrNull.mockReturnValue({});
});
describe('GET /api/health', () => {
it('is healthy when the config parses and Immich answers', async () => {
const res = await (await route())(req());
const body = await res.json();
expect(res.status).toBe(200);
expect(body.status).toBe('ok');
expect(body.config).toBe('ok');
});
it('stays healthy when the config cannot be parsed', async () => {
configOrNull.mockReturnValue(null);
const res = await (await route())(req());
const body = await res.json();
expect(res.status).toBe(200);
expect(body.status).toBe('setup');
expect(body.config).toBe('invalid');
});
/** Immich being down is a real degradation and keeps its 503. */
it('still reports 503 when Immich is unreachable', async () => {
const GET = await route();
const { immich } = await import('@/lib/immich');
(immich.ping as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(false);
const res = await GET(req());
expect(res.status).toBe(503);
});
});

View File

@@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
/**
* Route handlers do not render through the root layout, so the page-level site
* gate does not cover them. Without a check of their own, a locked site would
* still stream every photo to anyone holding an asset URL.
*
* Adding a public content route means adding a row to GATED — a route that
* forgets its guard then fails this suite instead of shipping. The OPEN list is
* the deliberate counterpart: /api/health must stay reachable (a locked site
* still has to answer a container health probe), and the login endpoint cannot
* sit behind the lock it exists to open.
*/
vi.mock('@/lib/env', () => ({
env: {
IMMICH_API_URL: 'http://localhost:2283/api',
IMMICH_API_KEY: 'test-key',
SITE_TITLE: 'Test',
SITE_SUBTITLE: '',
CACHE_TTL: 300,
STALE_MAX_AGE: 600,
IMAGE_CACHE_VERSION: '1',
IMMICH_TIMEOUT_MS: 15000,
RATE_LIMIT_RPM: 1500,
TRUSTED_PROXY_HOPS: 0,
},
}));
const config = {
authSecret: 'test-secret-that-is-at-least-32-chars-long',
sitePassword: 'letmein',
rateLimitRpm: 1500,
exifOnHover: true,
map: true,
subpages: [],
albumPasswords: {},
theme: { accent: '#e60012', fonts: { heading: 'Inter', body: 'Inter', caption: 'Inter' } },
};
vi.mock('@/lib/config', () => ({
getConfig: () => config,
getConfigOrNull: () => config,
}));
/** Routes read `nextUrl` and the client IP, so a bare Request is not enough. */
const request = (path: string) => new NextRequest(`http://localhost${path}`);
const params = (id: string) => ({ params: Promise.resolve({ id }) });
const GATED: { name: string; call: () => Promise<Response> }[] = [
{
name: 'GET /api/image/[id]',
call: async () =>
(await import('../image/[id]/route')).GET(
request('/api/image/tok') as never,
params('tok') as never,
),
},
{
name: 'GET /api/video/[id]',
call: async () =>
(await import('../video/[id]/route')).GET(
request('/api/video/tok') as never,
params('tok') as never,
),
},
{
name: 'GET /api/exif/[id]',
call: async () =>
(await import('../exif/[id]/route')).GET(
request('/api/exif/tok') as never,
params('tok') as never,
),
},
{
name: 'GET /api/map',
call: async () => (await import('../map/route')).GET(request('/api/map') as never),
},
{
name: 'GET /api/og',
call: async () => (await import('../og/route')).GET(request('/api/og?title=x') as never),
},
];
describe('public content routes behind a locked site', () => {
beforeEach(() => {
config.sitePassword = 'letmein';
});
it.each(GATED)('$name answers 401 without a session', async ({ call }) => {
const res = await call();
expect(res.status).toBe(401);
});
it.each(GATED)('$name does not cache the refusal', async ({ call }) => {
const res = await call();
expect(res.headers.get('Cache-Control')).toBe('no-store');
});
it.each(GATED)('$name stops gating once no password is set', async ({ name, call }) => {
config.sitePassword = '';
/*
* Everything past the gate is unmocked, so a route may answer with its own
* error or throw outright (Immich is absent, /api/map wants Next's request
* store). Either is fine — the only claim here is that an open site does
* not answer 401, i.e. the guard is genuinely opt-in.
*/
const status = await call().then(
(res) => res.status,
() => 'threw past the gate',
);
expect(status, name).not.toBe(401);
});
});
describe('routes that must stay open', () => {
it('GET /api/health answers a locked site', async () => {
config.sitePassword = 'letmein';
const res = await (await import('../health/route')).GET(request('/api/health') as never);
// A health probe runs without cookies; a 401 here takes the container down.
expect(res.status).not.toBe(401);
});
it('POST /api/auth is not behind the lock it opens', async () => {
config.sitePassword = 'letmein';
const res = await (
await import('../auth/route')
).POST(
new Request('http://localhost/api/auth', {
method: 'POST',
body: JSON.stringify({ slug: 'site', password: 'letmein', type: 'site' }),
}) as never,
);
expect(res.status).toBe(200);
});
});

View File

@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('@/lib/admin/auth', () => ({
isAdminEnabled: vi.fn(),
@@ -73,6 +74,14 @@ const ROUTES: {
method: 'GET',
args: () => [],
},
{
// Reports album titles, where passwords live and the proxy topology.
name: 'GET /api/admin/doctor',
path: 'doctor',
load: () => import('../doctor/route'),
method: 'GET',
args: () => [new NextRequest('http://localhost/api/admin/doctor')],
},
{
name: 'GET /api/admin/assets',
path: 'assets',

View File

@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* The report names album titles, password locations and proxy topology. Like
* every other /api/admin route it has to check both guards itself — there is no
* shared middleware (see admin-guards.test.ts).
*/
const enabled = vi.fn(() => true);
const authed = vi.fn(async () => true);
vi.mock('@/lib/admin/auth', () => ({
isAdminEnabled: () => enabled(),
isAdminAuthenticated: async () => authed(),
COOKIE_NAME: 'folio_admin_session',
}));
vi.mock('@/lib/config', () => ({
getConfig: () => ({
needsCredentials: true,
trustedProxyHops: 0,
authSecret: 'x'.repeat(64),
albums: [],
subpages: [],
albumPasswords: {},
sitePassword: '',
immich: { apiUrl: '', apiKey: '' },
immichTimeoutMs: 1000,
}),
}));
vi.mock('@/lib/env', () => ({ env: { AUTH_SECRET: 'x'.repeat(64) } }));
vi.mock('@/lib/admin/journal-service', () => ({ listJournalEntries: async () => [] }));
import { GET } from '../doctor/route';
import { NextRequest } from 'next/server';
function req(headers: Record<string, string> = {}) {
return new NextRequest('http://localhost/api/admin/doctor', { headers });
}
beforeEach(() => {
vi.clearAllMocks();
enabled.mockReturnValue(true);
authed.mockResolvedValue(true);
});
describe('GET /api/admin/doctor', () => {
it('refuses when the admin panel is disabled', async () => {
enabled.mockReturnValue(false);
expect((await GET(req())).status).toBe(403);
});
it('refuses an unauthenticated caller', async () => {
authed.mockResolvedValue(false);
expect((await GET(req())).status).toBe(401);
});
it('reports findings and the worst level', async () => {
const body = await (await GET(req())).json();
expect(Array.isArray(body.findings)).toBe(true);
expect(['ok', 'warn', 'error']).toContain(body.level);
// Missing credentials must surface as an error, not a silent pass.
expect(body.level).toBe('error');
});
/** The proxy check reads the chain off the request being served. */
it('measures the forwarded chain of the incoming request', async () => {
const body = await (await GET(req({ 'x-forwarded-for': '203.0.113.9, 10.0.0.2' }))).json();
const proxy = body.findings.find((f: { id: string }) => f.id === 'proxy-hops');
expect(proxy.level).toBe('warn');
expect(proxy.title).toContain('2');
});
/**
* Next writes X-Forwarded-For itself for a direct request, so this must stay
* quiet — otherwise the warning fires on every proxy-less deployment.
*/
it('stays quiet on the single entry Next synthesises', async () => {
const body = await (await GET(req({ 'x-forwarded-for': '127.0.0.1' }))).json();
const proxy = body.findings.find((f: { id: string }) => f.id === 'proxy-hops');
expect(proxy.level).toBe('ok');
});
it('trusts a lone entry that a proxy header corroborates', async () => {
const body = await (
await GET(req({ 'x-forwarded-for': '203.0.113.9', 'x-real-ip': '203.0.113.9' }))
).json();
const proxy = body.findings.find((f: { id: string }) => f.id === 'proxy-hops');
expect(proxy.level).toBe('warn');
});
});

View File

@@ -20,11 +20,21 @@ vi.mock('@/lib/admin/yaml-service', () => ({
listBackups: vi.fn(async () => []),
}));
vi.mock('@/lib/config', () => ({
getConfig: vi.fn(() => ({
needsSetup: false,
needsCredentials: false,
needsGallery: false,
})),
}));
import { GET } from '../status/route';
import { readGalleryYaml, readSettingsYaml } from '@/lib/admin/yaml-service';
import { getConfig } from '@/lib/config';
const mockGallery = readGalleryYaml as unknown as ReturnType<typeof vi.fn>;
const mockSettings = readSettingsYaml as unknown as ReturnType<typeof vi.fn>;
const mockConfig = getConfig as unknown as ReturnType<typeof vi.fn>;
async function status() {
return (await GET()).json();
@@ -35,6 +45,11 @@ describe('GET /api/admin/status — config integrity', () => {
vi.clearAllMocks();
mockGallery.mockResolvedValue({ albums: ['a'] });
mockSettings.mockResolvedValue({ title: 'Site' });
mockConfig.mockReturnValue({
needsSetup: false,
needsCredentials: false,
needsGallery: false,
});
});
it('reports a fully configured install as valid', async () => {
@@ -58,10 +73,76 @@ describe('GET /api/admin/status — config integrity', () => {
expect((await status()).config).toMatchObject({ status: 'invalid', settings: 'invalid' });
});
// gallery.yaml, unlike settings.yaml, is genuinely required.
it('reports a missing gallery.yaml as invalid', async () => {
/**
* gallery.yaml, unlike settings.yaml, is genuinely required — but a file that
* was never created is a pending setup step, not a corrupt config. Reporting
* both as "invalid" told a fresh deployment its config was broken and offered
* a backup restore as the cure (#507).
*/
it('reports a missing gallery.yaml as pending setup, not as invalid', async () => {
mockGallery.mockResolvedValue(null);
expect((await status()).config).toMatchObject({ status: 'setup', gallery: 'missing' });
});
it('still reports a gallery.yaml that cannot be parsed as invalid', async () => {
mockGallery.mockRejectedValue(new Error('bad indentation'));
expect((await status()).config).toMatchObject({ status: 'invalid', gallery: 'invalid' });
});
});
describe('GET /api/admin/status — setup state', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGallery.mockResolvedValue({ albums: ['a'] });
mockSettings.mockResolvedValue({ title: 'Site' });
});
it('reports a finished install as complete', async () => {
mockConfig.mockReturnValue({
needsSetup: false,
needsCredentials: false,
needsGallery: false,
});
expect((await status()).setup).toEqual({
complete: true,
credentials: 'present',
gallery: 'present',
});
});
/**
* The case from #507: credentials from the environment, no gallery.yaml. The
* admin panel must be able to tell those apart to say what is actually left
* to do instead of "System Degraded".
*/
it('separates missing credentials from a missing gallery.yaml', async () => {
mockConfig.mockReturnValue({
needsSetup: true,
needsCredentials: false,
needsGallery: true,
});
expect((await status()).setup).toEqual({
complete: false,
credentials: 'present',
gallery: 'missing',
});
});
it('reports absent credentials', async () => {
mockConfig.mockReturnValue({
needsSetup: true,
needsCredentials: true,
needsGallery: true,
});
expect((await status()).setup).toEqual({
complete: false,
credentials: 'missing',
gallery: 'missing',
});
});
});

View File

@@ -21,7 +21,10 @@ export async function GET(
}
const config = getConfig();
if (config.needsSetup) {
// Credentials, not `needsSetup`: this route is how the operator picks the
// albums that gallery.yaml is built from, so refusing to run until that file
// exists is a deadlock (#507).
if (config.needsCredentials) {
return NextResponse.json({ error: 'Immich not configured' }, { status: 503 });
}

View File

@@ -5,6 +5,8 @@ import { getConfig } from '@/lib/config';
interface ImmichAlbumSummary {
id: string;
albumName: string;
/** Immich's own flag. Absent on older versions, which is not the same as false. */
shared?: boolean;
description: string;
albumThumbnailAssetId: string | null;
assetCount: number;
@@ -22,7 +24,10 @@ export async function GET() {
}
const config = getConfig();
if (config.needsSetup) {
// Credentials, not `needsSetup`: this route is how the operator picks the
// albums that gallery.yaml is built from, so refusing to run until that file
// exists is a deadlock (#507).
if (config.needsCredentials) {
return NextResponse.json({ error: 'Immich not configured' }, { status: 503 });
}
@@ -51,6 +56,12 @@ export async function GET() {
createdAt: album.createdAt,
updatedAt: album.updatedAt,
isConfigured: configuredIds.has(album.id),
// Immich ignores ?shared=true and returns everything, so this list is the
// whole library rather than the shared part of it (#515). Passing the flag
// through lets the picker say which albums Immich still considers private
// — the accident-prevention the filter was supposed to give, without
// taking away the ability to publish them.
shared: album.shared,
}));
// Sort: configured first, then by name

View File

@@ -31,7 +31,10 @@ export async function GET(request: NextRequest) {
}
const config = getConfig();
if (config.needsSetup) {
// Credentials, not `needsSetup`: this route is how the operator picks the
// albums that gallery.yaml is built from, so refusing to run until that file
// exists is a deadlock (#507).
if (config.needsCredentials) {
return NextResponse.json({ error: 'Immich not configured' }, { status: 503 });
}

View File

@@ -0,0 +1,147 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'node:fs/promises';
import path from 'node:path';
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
import { getConfig } from '@/lib/config';
import { env } from '@/lib/env';
import { listJournalEntries } from '@/lib/admin/journal-service';
import {
checkAlbumIds,
checkAlbumsShared,
checkAuthSecret,
checkImmichCalls,
checkPasswords,
checkProxyHops,
checkWritable,
countForwardedHops,
PROXY_MARKER_HEADERS,
worstLevel,
type AlbumRef,
type DoctorFinding,
type PasswordRef,
} from '@/lib/admin/doctor';
/**
* Config doctor (#491). Gathers the evidence; lib/admin/doctor.ts judges it.
*
* Everything is best-effort: a check that cannot gather its input reports that
* rather than failing the whole report, because a broken install is exactly
* when this route needs to answer.
*/
export async function GET(request: NextRequest) {
if (!isAdminEnabled()) {
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
}
if (!(await isAdminAuthenticated())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const config = getConfig();
const findings: DoctorFinding[] = [];
// ── Secret and proxy: read off the environment and this very request ──
findings.push(checkAuthSecret(env.AUTH_SECRET || config.authSecret));
findings.push(
checkProxyHops(
config.trustedProxyHops,
countForwardedHops(request.headers.get('x-forwarded-for')),
PROXY_MARKER_HEADERS.some((h) => !!request.headers.get(h)),
),
);
// ── Immich: the three calls Folio actually depends on ────────────────
const calls: Array<{ endpoint: string; ok: boolean }> = [];
let albums: AlbumRef[] = [];
if (config.needsCredentials) {
findings.push({
id: 'immich-api',
level: 'error',
title: 'No Immich URL or API key configured',
detail: 'Set IMMICH_API_URL and IMMICH_API_KEY, or run the setup wizard at /install.',
});
} else {
const headers = { 'x-api-key': config.immich.apiKey, Accept: 'application/json' };
const call = async (endpoint: string, init?: RequestInit) => {
try {
const res = await fetch(`${config.immich.apiUrl}${endpoint}`, {
headers,
signal: AbortSignal.timeout(config.immichTimeoutMs),
...init,
});
calls.push({ endpoint, ok: res.ok });
return res.ok ? res : null;
} catch {
calls.push({ endpoint, ok: false });
return null;
}
};
await call('/server/ping');
const albumRes = await call('/albums');
await call('/search/metadata', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ size: 1, page: 1 }),
});
if (albumRes) {
try {
albums = (await albumRes.json()) as AlbumRef[];
} catch {
// A malformed body is already reflected by the call above.
}
}
findings.push(checkImmichCalls(calls));
}
if (albums.length) {
findings.push(checkAlbumIds(config.albums, albums));
findings.push(checkAlbumsShared(config.albums, albums));
}
// ── Passwords: every place one can be configured ─────────────────────
const passwords: PasswordRef[] = [];
if (config.sitePassword) passwords.push({ label: 'Site password', value: config.sitePassword });
for (const sp of config.subpages) {
if (sp.password) passwords.push({ label: `Subpage ${sp.slug}`, value: sp.password });
}
for (const [slug, value] of Object.entries(config.albumPasswords)) {
if (value) passwords.push({ label: `Album ${slug}`, value });
}
try {
for (const entry of await listJournalEntries()) {
const password = entry.frontmatter.password;
if (password) passwords.push({ label: `Journal ${entry.slug}`, value: password });
}
} catch {
// Journal entries are optional; a missing directory is not a fault.
}
findings.push(checkPasswords(passwords));
// ── Writability of the content volume ────────────────────────────────
const contentDir = path.join(process.cwd(), 'content');
const unwritable: string[] = [];
for (const dir of ['', '.backups', 'journal']) {
const target = path.join(contentDir, dir);
try {
await fs.access(target, (await import('node:fs')).constants.W_OK);
} catch {
// A directory that does not exist yet is fine as long as its parent is
// writable — only report one that exists and refuses writes.
try {
await fs.stat(target);
unwritable.push(`content/${dir}`.replace(/\/$/, ''));
} catch {
// Not created yet.
}
}
}
findings.push(checkWritable(unwritable));
return NextResponse.json(
{ level: worstLevel(findings), findings },
{ headers: { 'Cache-Control': 'no-store' } },
);
}

View File

@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server';
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
import { immich } from '@/lib/immich';
import { getConfig } from '@/lib/config';
import { cache } from '@/lib/cache';
import { listBackups, readGalleryYaml, readSettingsYaml } from '@/lib/admin/yaml-service';
@@ -22,6 +23,7 @@ export async function GET() {
// 2. Config Health Check
let galleryValid = false;
let galleryMissing = false;
// settings.yaml is optional — getConfig() falls back to defaults when it is
// absent, so a missing file is a valid installation, not a fault. Only a file
// that exists and cannot be parsed counts as degraded: readSettingsYaml()
@@ -31,6 +33,10 @@ export async function GET() {
try {
const gallery = await readGalleryYaml();
galleryValid = !!gallery;
// A file that was never created is a pending setup step, not a corrupt
// config. Reporting both as "invalid" told a fresh deployment its config
// was broken and offered a backup restore as the cure (#507).
galleryMissing = !gallery;
} catch (err) {
console.error('[Admin API] Gallery YAML parse failed:', err);
}
@@ -60,13 +66,20 @@ export async function GET() {
}
}
const config = getConfig();
return NextResponse.json({
immich: {
status: immichOk ? 'connected' : 'disconnected',
},
setup: {
complete: !config.needsSetup,
credentials: config.needsCredentials ? 'missing' : 'present',
gallery: config.needsGallery ? 'missing' : 'present',
},
config: {
status: galleryValid && settingsValid ? 'valid' : 'invalid',
gallery: galleryValid ? 'valid' : 'invalid',
status: galleryValid && settingsValid ? 'valid' : galleryMissing ? 'setup' : 'invalid',
gallery: galleryValid ? 'valid' : galleryMissing ? 'missing' : 'invalid',
settings: settingsValid ? 'valid' : 'invalid',
},
cache: {

View File

@@ -17,7 +17,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
}
const config = getConfig();
if (config.needsSetup) {
// Credentials, not `needsSetup`: this route is how the operator picks the
// albums that gallery.yaml is built from, so refusing to run until that file
// exists is a deadlock (#507).
if (config.needsCredentials) {
return NextResponse.json({ error: 'Not configured' }, { status: 503 });
}

View File

@@ -1,10 +1,13 @@
/**
* Auth API — validates album passwords and sets auth cookies.
* POST /api/auth { slug, password }
* Auth API — validates passwords and sets auth cookies.
* POST /api/auth { slug, password, type }
*
* `type: 'site'` unlocks the whole site; its `slug` is the fixed
* SITE_AUTH_KEY, since there is only ever one site-wide gate.
*/
import { NextRequest, NextResponse } from 'next/server';
import { authenticate, isProtected } from '@/lib/auth';
import { authenticate, isProtected, type ProtectedType } from '@/lib/auth';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
/** Tight limit for auth attempts — 10 per minute per IP. */
@@ -46,13 +49,20 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Invalid password format or length' }, { status: 400 });
}
if (type !== 'subpage' && type !== 'album' && type !== 'journal') {
const TYPES: ProtectedType[] = ['subpage', 'album', 'journal', 'site'];
if (!TYPES.includes(type)) {
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
}
if (!isProtected(slug, type)) {
const typeLabel =
type === 'journal' ? 'Journal entry' : type === 'subpage' ? 'Subpage' : 'Album';
type === 'journal'
? 'Journal entry'
: type === 'subpage'
? 'Subpage'
: type === 'site'
? 'Site'
: 'Album';
return NextResponse.json(
{
error: `${typeLabel} is not password-protected`,

View File

@@ -10,14 +10,16 @@ import { immich, ImmichUnavailableError } from '@/lib/immich';
import { decodeAssetId } from '@/lib/tokens';
import { getConfig } from '@/lib/config';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
import { siteLockResponse } from '@/lib/auth';
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const config = getConfig();
// exifOnHover only gates the *technical* fields below. The asset description
// (EXPERIMENTAL caption) is editorial content, so the route stays reachable
// and serves captions even when EXIF display is disabled.
const exifEnabled = config.exifOnHover;
// Which groups this site publishes. The description used to be served
// unconditionally as editorial caption, which made it the one field an
// operator could not switch off — the problem behind #506. It is a group like
// any other now.
const show = config.exif;
// ── Rate limiting ──────────────────────────────────
const ip = getClientIp(request);
@@ -35,6 +37,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
);
}
// The page-level gate does not cover route handlers — without this a locked
// site would still serve to anyone holding the URL.
const locked = siteLockResponse(request);
if (locked) return locked;
const { id: token } = await params;
// Decode the opaque token back to an Immich asset ID
@@ -64,28 +71,38 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
}
const exif = asset.exifInfo;
const caption = exif.description?.trim() || undefined;
return NextResponse.json(
{
...(exifEnabled
? {
make: exif.make,
model: exif.model,
lensModel: exif.lensModel,
focalLength: exif.focalLength,
fNumber: exif.fNumber,
exposureTime: exif.exposureTime,
iso: exif.iso,
city: exif.city,
country: exif.country,
}
: {}),
...(caption ? { description: caption } : {}),
const caption = show.caption ? exif.description?.trim() || undefined : undefined;
const payload = {
...(show.camera
? {
make: exif.make,
model: exif.model,
lensModel: exif.lensModel,
focalLength: exif.focalLength,
}
: {}),
...(show.settings
? {
fNumber: exif.fNumber,
exposureTime: exif.exposureTime,
iso: exif.iso,
}
: {}),
...(show.location ? { city: exif.city, country: exif.country } : {}),
...(caption ? { description: caption } : {}),
};
// Every group is switched off, or this asset carries nothing from the ones
// that are on. Say "no data" rather than answering with an empty object, which
// the lightbox would render as a blank panel.
if (Object.values(payload).every((value) => value === null || value === undefined)) {
return NextResponse.json({ error: 'No EXIF data' }, { status: 404 });
}
return NextResponse.json(payload, {
headers: {
'Cache-Control': 'private, max-age=86400, stale-while-revalidate=3600',
},
{
headers: {
'Cache-Control': 'private, max-age=86400, stale-while-revalidate=3600',
},
},
);
});
}

View File

@@ -1,10 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* EXPERIMENTAL captions (L10): the route serves the Immich asset description
* as a caption. Captions are editorial, not technical — so exifOnHover: false
* must strip the technical fields but still serve the caption, instead of the
* old blanket 403.
* The route serves the Immich asset description as a caption. It used to do so
* unconditionally — captions were treated as editorial rather than technical —
* which left it as the one field an operator could not hide (#506). Each group
* is now switchable, and `exifOnHover: false` keeps meaning "no technical EXIF"
* without touching captions.
*/
vi.mock('@/lib/config', () => ({
@@ -27,6 +28,8 @@ vi.mock('@/lib/rate-limit', () => ({
}));
import { GET } from '../[id]/route';
// Not from '@/lib/config' — that module is mocked above.
import { resolveExifDisplay, type ExifDisplayYaml } from '@/lib/config/schema';
import { getConfig } from '@/lib/config';
import { immich } from '@/lib/immich';
import { NextRequest } from 'next/server';
@@ -55,6 +58,15 @@ function makeRequest() {
const params = { params: Promise.resolve({ id: 'token123' }) };
/** A config shaped the way getConfig() builds it, for the groups under test. */
function configWith(raw?: ExifDisplayYaml, exifOnHover?: boolean) {
return {
exifOnHover: exifOnHover !== false,
exif: resolveExifDisplay(raw, exifOnHover),
rateLimitRpm: 120,
};
}
beforeEach(() => {
vi.clearAllMocks();
mockAssetInfo.mockResolvedValue(EXIF_ASSET);
@@ -62,7 +74,7 @@ beforeEach(() => {
describe('GET /api/exif/[id] captions', () => {
it('includes the trimmed description alongside technical fields', async () => {
mockConfig.mockReturnValue({ exifOnHover: true, rateLimitRpm: 120 });
mockConfig.mockReturnValue(configWith());
const res = await GET(makeRequest(), params);
expect(res.status).toBe(200);
@@ -72,7 +84,7 @@ describe('GET /api/exif/[id] captions', () => {
});
it('serves only the caption when exifOnHover is disabled', async () => {
mockConfig.mockReturnValue({ exifOnHover: false, rateLimitRpm: 120 });
mockConfig.mockReturnValue(configWith(undefined, false));
const res = await GET(makeRequest(), params);
expect(res.status).toBe(200);
@@ -83,7 +95,7 @@ describe('GET /api/exif/[id] captions', () => {
});
it('omits the description entirely when the asset has none', async () => {
mockConfig.mockReturnValue({ exifOnHover: true, rateLimitRpm: 120 });
mockConfig.mockReturnValue(configWith());
mockAssetInfo.mockResolvedValue({
exifInfo: { ...EXIF_ASSET.exifInfo, description: null },
});
@@ -92,4 +104,51 @@ describe('GET /api/exif/[id] captions', () => {
const body = await res.json();
expect(body).not.toHaveProperty('description');
});
it('drops the caption when the group is off', async () => {
mockConfig.mockReturnValue(configWith({ caption: false }));
const body = await (await GET(makeRequest(), params)).json();
expect(body).not.toHaveProperty('description');
expect(body.model).toBe('X-T5');
});
it('drops the location without touching the camera', async () => {
mockConfig.mockReturnValue(configWith({ location: false }));
const body = await (await GET(makeRequest(), params)).json();
expect(body).not.toHaveProperty('city');
expect(body).not.toHaveProperty('country');
expect(body.model).toBe('X-T5');
expect(body.iso).toBe(200);
});
it('drops the exposure settings without touching the camera', async () => {
mockConfig.mockReturnValue(configWith({ settings: false }));
const body = await (await GET(makeRequest(), params)).json();
expect(body).not.toHaveProperty('iso');
expect(body).not.toHaveProperty('fNumber');
expect(body.lensModel).toBe('XF 35mm');
});
/** An empty object would render as a blank panel rather than "no data". */
it('answers 404 when every group is switched off', async () => {
mockConfig.mockReturnValue(
configWith({ camera: false, settings: false, location: false, caption: false }),
);
const res = await GET(makeRequest(), params);
expect(res.status).toBe(404);
});
it('answers 404 when the enabled groups carry nothing for this asset', async () => {
mockConfig.mockReturnValue(configWith({ camera: false, settings: false, caption: false }));
mockAssetInfo.mockResolvedValue({
exifInfo: { ...EXIF_ASSET.exifInfo, city: null, country: null },
});
const res = await GET(makeRequest(), params);
expect(res.status).toBe(404);
});
});

View File

@@ -8,7 +8,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { immich } from '@/lib/immich';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
import { getConfig } from '@/lib/config';
import { env } from '@/lib/env';
import { getConfigOrNull } from '@/lib/config';
const startTime = Date.now();
@@ -21,7 +22,11 @@ const CACHE_DURATION_MS = 10_000;
export async function GET(request: NextRequest) {
const ip = getClientIp(request);
const { rateLimitRpm } = getConfig();
// env, not getConfig(): a health check that cannot answer while the content
// config is unparsable is worse than useless — the Dockerfile probes this
// route, so a container with a broken gallery.yaml would restart in a loop at
// exactly the moment the app is still repairable through /admin (#519).
const rateLimitRpm = env.RATE_LIMIT_RPM;
const { success, remaining, resetAt } = checkRateLimit(`health:${ip}`, rateLimitRpm);
if (!success) {
@@ -38,6 +43,28 @@ export async function GET(request: NextRequest) {
);
}
/*
* A config that cannot be parsed is not a reason to kill the container.
* Restarting cannot fix a YAML typo, the app is still serving the setup
* screen, and /admin — where the fix happens — is still reachable. Reporting
* it as unhealthy made the Dockerfile's HEALTHCHECK restart the container in
* a loop at exactly the moment it was still repairable (#519).
*
* Immich itself is not consulted here: the ping reads the same config and
* would answer "unreachable" for a reason that has nothing to do with Immich.
*/
if (!getConfigOrNull()) {
return NextResponse.json(
{
status: 'setup',
config: 'invalid',
immich: 'unknown',
uptime: Math.floor((Date.now() - startTime) / 1000),
},
{ status: 200, headers: { 'Cache-Control': 'no-store' } },
);
}
const now = Date.now();
if (!cachedImmichOkPromise || now - lastCheckTime > CACHE_DURATION_MS) {
cachedImmichOkPromise = immich.ping();
@@ -56,6 +83,7 @@ export async function GET(request: NextRequest) {
const body = {
status: immichOk ? 'ok' : 'degraded',
config: 'ok',
immich: immichOk ? 'connected' : 'unreachable',
uptime: Math.floor((Date.now() - startTime) / 1000),
};

View File

@@ -13,6 +13,7 @@ import { resolveImageSize } from '@/lib/imageSize';
import { decodeAssetId } from '@/lib/tokens';
import { getConfig } from '@/lib/config';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
import { siteLockResponse } from '@/lib/auth';
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
// ── Rate limiting ──────────────────────────────────
@@ -38,6 +39,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
);
}
// The page-level gate does not cover route handlers — without this a locked
// site would still serve to anyone holding the URL.
const locked = siteLockResponse(request);
if (locked) return locked;
const { id: token } = await params;
// Decode the opaque token back to an Immich asset ID

View File

@@ -92,8 +92,14 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Could not reach Immich at this URL' }, { status: 502 });
}
// The covers of the albums just chosen, as the home page hero. One extra
// request during install, and it is the difference between a portfolio that
// opens with a photograph and one that opens with a list of names (#518).
const hero = albums.length ? await fetchAlbumCovers(apiBase, apiKey, albums) : [];
try {
await completeInstall({
hero,
apiUrl,
apiKey,
siteTitle: typeof body.siteTitle === 'string' ? body.siteTitle : undefined,
@@ -115,3 +121,37 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Failed to write configuration files' }, { status: 500 });
}
}
/** How many covers the home page carousel is seeded with. */
const HERO_SEED_MAX = 5;
/**
* Cover asset IDs for the given albums, in the order they were chosen.
*
* Best effort by design: this runs while the wizard is finishing, and a hero
* image is a nicety. Anything that goes wrong here leaves `hero: []`, which is
* exactly what the wizard wrote before.
*/
async function fetchAlbumCovers(
apiBase: string,
apiKey: string,
albumIds: string[],
): Promise<string[]> {
try {
const res = await fetch(`${apiBase}/albums`, {
headers: { 'x-api-key': apiKey, Accept: 'application/json' },
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) return [];
const all = (await res.json()) as Array<{ id: string; albumThumbnailAssetId: string | null }>;
const covers = new Map(all.map((a) => [a.id, a.albumThumbnailAssetId]));
return albumIds
.map((id) => covers.get(id))
.filter((assetId): assetId is string => !!assetId && UUID_REGEX.test(assetId))
.slice(0, HERO_SEED_MAX);
} catch {
return [];
}
}

View File

@@ -8,7 +8,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { getConfig } from '@/lib/config';
import { imageUrl } from '@/lib/urls';
import { isAuthenticated } from '@/lib/auth';
import { isAuthenticated, siteLockResponse } from '@/lib/auth';
import { getMapData } from '@/lib/mapService';
import { ImmichUnavailableError } from '@/lib/immich';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
@@ -35,6 +35,11 @@ export async function GET(request: NextRequest) {
);
}
// The page-level gate does not cover route handlers — without this a locked
// site would still serve to anyone holding the URL.
const locked = siteLockResponse(request);
if (locked) return locked;
const config = getConfig();
if (!config.map) {

View File

@@ -10,6 +10,7 @@ import { ImageResponse } from 'next/og';
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
import { siteLockResponse } from '@/lib/auth';
/**
* OG image rendering runs satori + resvg per request and each unique ?title
@@ -34,6 +35,11 @@ export async function GET(request: NextRequest) {
});
}
// The page-level gate does not cover route handlers — without this a locked
// site would still serve to anyone holding the URL.
const locked = siteLockResponse(request);
if (locked) return locked;
const { searchParams } = request.nextUrl;
const title = (searchParams.get('title') || 'Gallery').slice(0, 200);
const subtitle = (searchParams.get('subtitle') || '').slice(0, 100);

View File

@@ -14,6 +14,7 @@ import { immich, ImmichUnavailableError } from '@/lib/immich';
import { decodeAssetId } from '@/lib/tokens';
import { getConfig } from '@/lib/config';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
import { siteLockResponse } from '@/lib/auth';
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
// ── Rate limiting ──────────────────────────────────
@@ -32,6 +33,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
);
}
// The page-level gate does not cover route handlers — without this a locked
// site would still serve to anyone holding the URL.
const locked = siteLockResponse(request);
if (locked) return locked;
const { id: token } = await params;
// Decode the opaque token back to an Immich asset ID

View File

@@ -2,6 +2,7 @@
import { useEffect } from 'react';
import Link from 'next/link';
import { useDictionary } from '@/components/I18nProvider';
/**
* Rendered when a page throws — in practice almost always
@@ -22,22 +23,22 @@ export default function Error({
error: Error & { digest?: string };
reset: () => void;
}) {
const t = useDictionary();
useEffect(() => {
console.error('[Folio] Page render failed:', error);
}, [error]);
return (
<div className="empty-state">
<h1 className="empty-state__title">Something went wrong</h1>
<p className="empty-state__text">
This page could not be loaded right now. It is usually temporary — try again in a moment.
</p>
<h1 className="empty-state__title">{t.error.errorTitle}</h1>
<p className="empty-state__text">{t.error.errorText}</p>
<div className="empty-state__actions">
<button type="button" onClick={reset} className="empty-state__button">
Try again
{t.error.tryAgain}
</button>
<Link href="/" className="empty-state__button empty-state__button--quiet">
Back to the gallery
{t.common.backToGallery}
</Link>
</div>
{error.digest && (
@@ -45,7 +46,7 @@ export default function Error({
className="empty-state__text"
style={{ marginTop: 24, fontSize: '0.8rem', opacity: 0.5 }}
>
Reference: {error.digest}
{t.error.reference(error.digest)}
</p>
)}
</div>

28
app/gate/page.tsx Normal file
View File

@@ -0,0 +1,28 @@
/**
* The site-wide password gate.
*
* Nothing links here. `proxy.ts` rewrites every request to this route while
* the site is locked, which is what keeps the requested page from rendering
* at all — a gate in the layout would still let the page produce its RSC
* payload, album names and asset tokens included.
*/
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getConfig } from '@/lib/config';
import { isSiteLocked, SITE_AUTH_KEY } from '@/lib/auth';
import PasswordGate from '@/components/PasswordGate';
export const dynamic = 'force-dynamic';
export const metadata: Metadata = {
robots: { index: false, follow: false },
};
export default function GatePage() {
// Reachable on its own URL, so it has to answer for itself: with no site
// password configured there is no gate to show.
if (!isSiteLocked()) notFound();
return <PasswordGate slug={SITE_AUTH_KEY} title={getConfig().siteTitle} type="site" />;
}

97
app/global-error.tsx Normal file
View File

@@ -0,0 +1,97 @@
'use client';
import { useEffect } from 'react';
/**
* The last resort: rendered when the root layout itself throws, which
* `app/error.tsx` cannot catch — it is rendered *inside* that layout. The
* layout reads the config and the request headers, so this is not a theoretical
* case; without this file such a failure renders a blank page.
*
* Everything here is deliberately self-contained: it replaces the whole
* document, so `globals.css` and the theme custom properties are not
* guaranteed to be in scope. No config lookup, no `next/link` (there is no
* router shell to speak of), no shared components. Same wording as
* `app/error.tsx`, which explains why it does not name a cause: production
* builds replace the message with a digest, so the component genuinely cannot
* tell an Immich outage from anything else.
*/
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error('[Folio] Root layout render failed:', error);
}, [error]);
return (
<html lang="en">
<body
style={{
margin: 0,
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#0c0c0c',
color: '#ededed',
colorScheme: 'dark',
fontFamily: 'system-ui, -apple-system, Segoe UI, Roboto, sans-serif',
padding: '24px',
}}
>
<main style={{ maxWidth: '32rem', textAlign: 'center' }}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 500, margin: '0 0 12px' }}>
Something went wrong
</h1>
<p style={{ margin: '0 0 24px', lineHeight: 1.6, opacity: 0.75 }}>
This site could not be loaded right now. It is usually temporary — try again in a
moment.
</p>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center' }}>
<button
type="button"
onClick={reset}
style={{
padding: '10px 20px',
border: '1px solid #ededed',
borderRadius: '4px',
background: 'transparent',
color: 'inherit',
font: 'inherit',
cursor: 'pointer',
}}
>
Try again
</button>
{/* A plain <a>, not next/link: a full document request is exactly
what is wanted here — a client-side transition would stay inside
the tree whose root layout just failed. */}
{/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
<a
href="/"
style={{
padding: '10px 20px',
border: '1px solid transparent',
borderRadius: '4px',
color: 'inherit',
textDecoration: 'none',
opacity: 0.6,
}}
>
Back to the gallery
</a>
</div>
{error.digest && (
<p style={{ marginTop: '24px', fontSize: '0.8rem', opacity: 0.4 }}>
Reference: {error.digest}
</p>
)}
</main>
</body>
</html>
);
}

View File

@@ -820,10 +820,15 @@
width: 100%;
}
/* The album-cover grid. `--subpage-columns` comes from the resolved grid
config (global settings.yaml < per-subpage override); the fallback keeps the
historical two-up layout for anything rendering this grid without the vars.
`--subpage-gap` is a *preset* decision — the theme files below set it, and
only an explicit per-subpage `gap:` overrides it (inline vars win). */
.subpage-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px;
grid-template-columns: repeat(var(--subpage-columns, 2), 1fr);
gap: var(--subpage-gap, 4px);
}
.subpage-grid__item {
@@ -1043,6 +1048,13 @@
}
.photo-grid {
/* The gap the grid uses when settings.yaml does not set one. Declared rather
than left to each layout's own `var(--grid-gap, N)` fallback, because those
fallbacks disagree (6px filmstrip, 16px editorial-flow) and were dead code
while the variable was emitted unconditionally — 12px is what every layout
has actually rendered. A preset overrides it, an explicit setting beats
both by landing inline on this element (#513). */
--grid-gap: 12px;
column-count: var(--grid-columns, 3);
column-gap: var(--grid-gap, 12px);
}
@@ -1746,6 +1758,13 @@
.photo-grid--showcase {
grid-template-columns: repeat(2, 1fr);
}
/* Album covers follow the photo grids here: two-up on tablet widths, so a
configured 4 or 6 columns does not squeeze them below ~300px. The count is
computed server-side because repeat() does not reliably take a min(). */
.subpage-grid {
grid-template-columns: repeat(var(--subpage-columns-tablet, 2), 1fr);
}
}
@media (max-width: 640px) {

View File

@@ -7,17 +7,21 @@ import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getConfig } from '@/lib/config';
import { BackLink } from '@/components/BackLink';
import { getServerDictionary } from '@/lib/i18n/server';
import './impressum.css';
export const metadata: Metadata = {
title: 'Impressum',
robots: { index: false, follow: true }, // Usually no need to index legal pages
};
export function generateMetadata(): Metadata {
return {
title: getServerDictionary().legal.title,
robots: { index: false, follow: true }, // Usually no need to index legal pages
};
}
export const dynamic = 'force-dynamic';
export default function ImpressumPage() {
const { legal } = getConfig();
const t = getServerDictionary();
if (!legal.enabled) {
notFound();
@@ -26,14 +30,14 @@ export default function ImpressumPage() {
return (
<div className="subpage-container">
<header className="subpage-header">
<BackLink href="/" label="Home" />
<h1 className="subpage-title">Impressum</h1>
<p className="subpage-subtitle">Angaben gemäß § 5 TMG</p>
<BackLink href="/" label={t.common.home} />
<h1 className="subpage-title">{t.legal.title}</h1>
<p className="subpage-subtitle">{t.legal.subtitle}</p>
</header>
<main className="subpage-content">
<section className="legal-section">
<h2 className="legal-section__title">Anschrift</h2>
<h2 className="legal-section__title">{t.legal.address}</h2>
<p className="legal-section__text">
{legal.name}
<br />
@@ -47,43 +51,51 @@ export default function ImpressumPage() {
{(legal.email || legal.phone) && (
<section className="legal-section">
<h2 className="legal-section__title">Kontakt</h2>
<h2 className="legal-section__title">{t.legal.contact}</h2>
<p className="legal-section__text">
{legal.email && (
<>
E-Mail: {legal.email}
{t.legal.email}: {legal.email}
<br />
</>
)}
{legal.phone && <>Telefon: {legal.phone}</>}
{legal.phone && (
<>
{t.legal.phone}: {legal.phone}
</>
)}
</p>
</section>
)}
{(legal.taxId || legal.vatId) && (
<section className="legal-section">
<h2 className="legal-section__title">Steuernummer</h2>
<h2 className="legal-section__title">{t.legal.taxSection}</h2>
<p className="legal-section__text">
{legal.taxId && (
<>
Steuernummer: {legal.taxId}
{t.legal.taxId}: {legal.taxId}
<br />
</>
)}
{legal.vatId && <>Umsatzsteuer-ID: {legal.vatId}</>}
{legal.vatId && (
<>
{t.legal.vatId}: {legal.vatId}
</>
)}
</p>
</section>
)}
{legal.extraInfo && (
<section className="legal-section">
<h2 className="legal-section__title">Weitere Informationen</h2>
<h2 className="legal-section__title">{t.legal.extraInfo}</h2>
<p className="legal-section__text legal-section__text--pre">{legal.extraInfo}</p>
</section>
)}
<section className="legal-source">
<p>Quelle: Erstellt mit dem Impressum-Generator von eRecht24.</p>
<p>{t.legal.source}</p>
</section>
</main>
</div>

View File

@@ -19,6 +19,7 @@ import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
import type { ImmichAsset } from '@/lib/immich';
import PasswordGate from '@/components/PasswordGate';
import { BackLink } from '@/components/BackLink';
import { getServerDictionary } from '@/lib/i18n/server';
import crypto from 'crypto';
export const dynamic = 'force-dynamic';
@@ -30,11 +31,12 @@ interface JournalDetailPageProps {
export async function generateMetadata({ params }: JournalDetailPageProps): Promise<Metadata> {
const { slug } = await params;
const entry = await readJournalEntry(slug);
if (!entry) return { title: 'Not Found' };
const t = getServerDictionary();
if (!entry) return { title: t.journal.notFound };
const { frontmatter } = entry.parsed;
const title = frontmatter.title || slug;
const description = frontmatter.subtitle || 'Journal entry on Immich Folio';
const description = frontmatter.subtitle || t.journal.entryDescription;
const ogImages = frontmatter.coverAssetId
? [{ url: imageUrl(frontmatter.coverAssetId, 'preview') }]
@@ -171,7 +173,10 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO')
.map((a) => {
const ph = assetPlaceholder(a);
const exif = config.exifOnHover && a.type === 'IMAGE' ? assetExifSummary(a) : undefined;
const exif =
config.exif.onHover && config.exif.camera && a.type === 'IMAGE'
? assetExifSummary(a)
: undefined;
const isVideo = a.type === 'VIDEO';
return {
id: encodeAssetId(a.id),
@@ -188,7 +193,7 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
return (
<div style={{ paddingTop: '2rem' }}>
<div style={{ maxWidth: '1100px', margin: '0 auto', padding: '0 1.5rem 1rem' }}>
<BackLink href="/journal" label="Back to Journal" />
<BackLink href="/journal" label={getServerDictionary().common.backToJournal} />
</div>
<EssayView
essay={essayForClient}

View File

@@ -8,14 +8,15 @@ import { immich } from '@/lib/immich';
import { imageUrl, assetPlaceholder } from '@/lib/urls';
import { BackLink } from '@/components/BackLink';
import { IconBook } from '@/components/Icons';
import { getServerDictionary } from '@/lib/i18n/server';
import './journal.css';
export const dynamic = 'force-dynamic';
export const metadata: Metadata = {
title: 'Journal',
description: 'Photo essays, travel stories, and behind-the-scenes journals.',
};
export function generateMetadata(): Metadata {
const t = getServerDictionary();
return { title: t.journal.title, description: t.journal.description };
}
interface EnrichedJournalEntry extends JournalEntrySummary {
coverUrl?: string;
@@ -24,6 +25,7 @@ interface EnrichedJournalEntry extends JournalEntrySummary {
}
export default async function JournalIndexPage() {
const t = getServerDictionary();
const isAuthedAdmin = await isAdminAuthenticated();
const allEntries = await listJournalEntries();
@@ -60,25 +62,23 @@ export default async function JournalIndexPage() {
return (
<div className="journal-index-container">
<div className="journal-index-header">
<BackLink href="/" label="Back to Gallery" />
<BackLink href="/" label={t.common.backToGallery} />
<p className="journal-index-header__kicker" aria-hidden="true">
Stories &amp; Essays
</p>
<h1 className="journal-index-header__title">Journal</h1>
<p className="journal-index-header__subtitle">
Photo essays, visual stories, and field notes.
{t.journal.kicker}
</p>
<h1 className="journal-index-header__title">{t.journal.title}</h1>
<p className="journal-index-header__subtitle">{t.journal.subtitle}</p>
</div>
{enrichedEntries.length === 0 ? (
<div className="journal-empty-state">
<p>No journal entries published yet.</p>
<p>{t.journal.empty}</p>
</div>
) : (
<div className="journal-grid">
{enrichedEntries.map((entry) => {
const dateStr = entry.frontmatter.date
? new Date(entry.frontmatter.date).toLocaleDateString('en-US', {
? new Date(entry.frontmatter.date).toLocaleDateString(t.dateLocale, {
year: 'numeric',
month: 'short',
day: 'numeric',
@@ -113,9 +113,11 @@ export default async function JournalIndexPage() {
<div className="journal-card__meta">
{dateStr && <span>{dateStr}</span>}
{dateStr && entry.readingTimeMinutes && <span>•</span>}
{entry.readingTimeMinutes && <span>{entry.readingTimeMinutes} min read</span>}
{entry.readingTimeMinutes && (
<span>{t.journal.minRead(entry.readingTimeMinutes)}</span>
)}
{entry.frontmatter.draft && (
<span className="journal-card__draft-badge">Draft</span>
<span className="journal-card__draft-badge">{t.journal.draft}</span>
)}
</div>
@@ -134,9 +136,11 @@ export default async function JournalIndexPage() {
)}
<div className="journal-card__footer">
<span className="journal-card__read-more">Read Story →</span>
<span className="journal-card__read-more">{t.journal.readStory}</span>
{entry.frontmatter.author && (
<span style={{ opacity: 0.65 }}>by {entry.frontmatter.author}</span>
<span style={{ opacity: 0.65 }}>
{t.journal.by(entry.frontmatter.author)}
</span>
)}
</div>
</div>

View File

@@ -5,7 +5,7 @@
*/
import type { Metadata } from 'next';
import { headers } from 'next/headers';
import { cookies, headers } from 'next/headers';
import Link from 'next/link';
import './globals.css';
import { SubpageNav } from '@/components/SubpageNav';
@@ -16,10 +16,13 @@ import { SetupScreen } from '@/components/SetupScreen';
import { getConfigOrNull, getGoogleFontsUrl, AppConfig } from '@/lib/config';
import { isAdminPath } from '@/lib/admin/paths';
import { isInstallPath } from '@/lib/install';
import { isSiteLocked, isSiteUnlocked } from '@/lib/auth';
// DevToolbarLoader is a Client Component (ssr: false is only allowed there)
import { DevToolbarLoader } from '@/components/DevToolbarLoader';
import AssetProtection from '@/components/AssetProtection';
import AnalyticsTracker from '@/components/AnalyticsTracker';
import { I18nProvider } from '@/components/I18nProvider';
import { resolveLocale, getDictionary } from '@/lib/i18n';
export async function generateMetadata(): Promise<Metadata> {
const config = getConfigOrNull();
@@ -34,9 +37,13 @@ export async function generateMetadata(): Promise<Metadata> {
const siteTitle = config.seo.title;
const siteDescription = config.seo.description;
// A site behind a password has nothing to offer a crawler, and the SEO
// toggles are about a *public* site — so the lock overrides them rather
// than depending on the operator also remembering to set noIndex.
const locked = isSiteLocked();
const robots = {
index: !config.seo.noIndex,
follow: !config.seo.noFollow,
index: !locked && !config.seo.noIndex,
follow: !locked && !config.seo.noFollow,
};
return {
@@ -114,6 +121,18 @@ export default async function RootLayout({ children }: { children: React.ReactNo
}
const isAdmin = isAdminPath(pathname);
const locale = resolveLocale(config.lang);
const t = getDictionary(locale);
/*
* Whether this render is the site-wide gate. The gate itself is a page
* (`app/gate/page.tsx`) that `proxy.ts` rewrites to; all the layout does is
* strip the chrome around it, because a header listing every subpage would
* give away the shape of a site that is supposed to be shut.
*/
const cookieStore = await cookies();
const siteGated =
!isAdmin && !isInstallPath(pathname) && !isSiteUnlocked((name) => cookieStore.get(name)?.value);
return (
<html
@@ -125,6 +144,12 @@ export default async function RootLayout({ children }: { children: React.ReactNo
data-header-dot={String(theme.headerDot)}
data-photo-frame={theme.photoFrame}
data-transitions={String(config.transitions)}
/* The configured starting mode is rendered server-side so the first paint
is already right; `auto` deliberately carries no data-theme and lets the
inline script below decide from the OS. data-default-theme is what
ThemeToggle reads for a visitor who has never chosen (#512). */
{...(config.colorMode === 'auto' ? {} : { 'data-theme': config.colorMode })}
data-default-theme={config.colorMode}
>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -132,49 +157,51 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<link rel="stylesheet" href={fontsUrl} />
</head>
<body>
{isAdmin ? (
children
) : (
<>
<a href="#main-content" className="skip-link">
Skip to content
</a>
<header className="header">
<nav className="header__nav">
{/* Brand wordmark — presets that show it pair it with the dot. */}
<span className="header__wordmark" aria-hidden="true">
{config.siteTitle}
</span>
<Link href="/" className="header__nav-link">
Home
</Link>
<SubpageNav />
{config.aboutEnabled && (
<Link href="/about" className="header__nav-link">
About
<I18nProvider locale={locale}>
{siteGated || isAdmin ? (
children
) : (
<>
<a href="#main-content" className="skip-link">
{t.nav.skipToContent}
</a>
<header className="header">
<nav className="header__nav">
{/* Brand wordmark — presets that show it pair it with the dot. */}
<span className="header__wordmark" aria-hidden="true">
{config.siteTitle}
</span>
<Link href="/" className="header__nav-link">
{t.nav.home}
</Link>
)}
{config.map && (
<Link href="/map" className="header__nav-link">
Map
</Link>
)}
<ThemeToggle />
</nav>
</header>
<main id="main-content" tabIndex={-1} className="main">
{children}
</main>
<Footer />
{config.scrollToTop && <ScrollToTop />}
<AssetProtection
disableRightClick={config.protection?.disableRightClick}
disableImageDrag={config.protection?.disableImageDrag}
/>
<AnalyticsTracker />
{process.env.NODE_ENV === 'development' && <DevToolbarLoader />}
</>
)}
<SubpageNav />
{config.aboutEnabled && (
<Link href="/about" className="header__nav-link">
{t.nav.about}
</Link>
)}
{config.map && (
<Link href="/map" className="header__nav-link">
{t.nav.map}
</Link>
)}
<ThemeToggle />
</nav>
</header>
<main id="main-content" tabIndex={-1} className="main">
{children}
</main>
<Footer />
{config.scrollToTop && <ScrollToTop />}
<AssetProtection
disableRightClick={config.protection?.disableRightClick}
disableImageDrag={config.protection?.disableImageDrag}
/>
<AnalyticsTracker />
{process.env.NODE_ENV === 'development' && <DevToolbarLoader />}
</>
)}
</I18nProvider>
</body>
</html>
);

View File

@@ -3,10 +3,12 @@
*/
import styles from './loading.module.css';
import { getServerDictionary } from '@/lib/i18n/server';
export default function Loading() {
const t = getServerDictionary();
return (
<div className={styles.grid} role="status" aria-label="Loading gallery">
<div className={styles.grid} role="status" aria-label={t.common.loadingGallery}>
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className={styles.tile} />
))}

View File

@@ -8,16 +8,18 @@ import { notFound } from 'next/navigation';
import { MapView } from '@/components/MapView';
import { BackLink } from '@/components/BackLink';
import type { Metadata } from 'next';
import { getServerDictionary } from '@/lib/i18n/server';
import './map.css';
export const dynamic = 'force-dynamic';
export const metadata: Metadata = {
title: 'Map',
};
export function generateMetadata(): Metadata {
return { title: getServerDictionary().map.title };
}
export default function MapPage() {
const config = getConfig();
const t = getServerDictionary();
if (!config.map) {
notFound();
@@ -42,16 +44,15 @@ export default function MapPage() {
<div className="map-page">
<div className="map-page__header">
<div className="map-page__header-main">
<BackLink href="/" label="Back to Gallery" />
<BackLink href="/" label={t.common.backToGallery} />
<p className="map-page__kicker" aria-hidden="true">
Where
{t.map.kicker}
</p>
<h1 className="map-page__title">Map</h1>
<p className="map-page__subtitle">Where the photos were taken</p>
<h1 className="map-page__title">{t.map.title}</h1>
<p className="map-page__subtitle">{t.map.subtitle}</p>
</div>
<p className="map-page__meta" aria-hidden="true">
{collectionCount} {collectionCount === 1 ? 'collection' : 'collections'} · {albumCount}{' '}
{albumCount === 1 ? 'album' : 'albums'}
{t.common.collections(collectionCount)} · {t.common.albums(albumCount)}
</p>
</div>
<MapView />

View File

@@ -1,4 +1,5 @@
import Link from 'next/link';
import { getServerDictionary } from '@/lib/i18n/server';
/**
* Rendered when a page calls notFound() — i.e. Immich answered and the album
@@ -10,15 +11,14 @@ import Link from 'next/link';
* crawler) that their content is gone.
*/
export default function NotFound() {
const t = getServerDictionary();
return (
<div className="empty-state">
<h1 className="empty-state__title">Not found</h1>
<p className="empty-state__text">
This page does not exist, or the album is no longer published.
</p>
<h1 className="empty-state__title">{t.error.notFoundTitle}</h1>
<p className="empty-state__text">{t.error.notFoundText}</p>
<div className="empty-state__actions">
<Link href="/" className="empty-state__button">
Back to the gallery
{t.common.backToGallery}
</Link>
</div>
</div>

View File

@@ -11,6 +11,8 @@ import { getConfig } from '@/lib/config';
import { imageUrl, assetPlaceholder } from '@/lib/urls';
import { HeroCarousel } from '@/components/HeroCarousel';
import { FadeIn } from '@/components/FadeIn';
import { getServerDictionary } from '@/lib/i18n/server';
import type { Dictionary } from '@/lib/i18n';
// Render at request time — requires live Immich connection
export const dynamic = 'force-dynamic';
@@ -19,25 +21,24 @@ type HeroSubpage = { slug: string; name: string; albumCount: number };
type HeroAlbum = { id: string; slug: string; albumName: string; assetCount: number };
const pad2 = (n: number) => String(n).padStart(2, '0');
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
/**
* Flatten subpages + standalone albums into one indexed nav list.
* Every entry carries an index and a count; presets decide what to show.
*/
function heroNavEntries(subpages: HeroSubpage[], albums: HeroAlbum[]) {
function heroNavEntries(subpages: HeroSubpage[], albums: HeroAlbum[], t: Dictionary) {
return [
...subpages.map((sp) => ({
key: `sp-${sp.slug}`,
href: `/${sp.slug}`,
label: sp.name,
count: plural(sp.albumCount, 'album'),
count: t.common.albums(sp.albumCount),
})),
...albums.map((a) => ({
key: `al-${a.id}`,
href: `/${a.slug}`,
label: a.albumName,
count: plural(a.assetCount, 'photo'),
count: t.common.photos(a.assetCount),
})),
];
}
@@ -61,9 +62,10 @@ function heroExifLine(asset: ImmichAsset): string | undefined {
/** Shared nav links for hero sections. */
function HeroNavLinks({ subpages, albums }: { subpages: HeroSubpage[]; albums: HeroAlbum[] }) {
const t = getServerDictionary();
return (
<>
{heroNavEntries(subpages, albums).map((entry, i) => (
{heroNavEntries(subpages, albums, t).map((entry, i) => (
<Link key={entry.key} href={entry.href} className="hero__nav-link">
<span className="hero__nav-index" aria-hidden="true">
{pad2(i + 1)}
@@ -111,6 +113,7 @@ function HeroTextContent({
export default async function HomePage() {
const config = getConfig();
const t = getServerDictionary();
if (config.needsSetup) {
return null;
@@ -141,7 +144,7 @@ export default async function HomePage() {
// Welcome-page splash: one fullbleed image, the site
// title, and one link into the portfolio (the first nav entry).
if (heroStyle === 'cover') {
const entries = heroNavEntries(subpages, albums);
const entries = heroNavEntries(subpages, albums, t);
const enterHref = entries[0]?.href ?? '/about';
return (
@@ -158,7 +161,7 @@ export default async function HomePage() {
)}
<FadeIn delay={250}>
<Link href={enterHref} className="hero__cover-enter">
Enter
{t.home.enter}
<span className="hero__cover-enter-arrow" aria-hidden="true">
→
</span>
@@ -197,7 +200,7 @@ export default async function HomePage() {
// ── Stacked: fullbleed image + text at bottom + thumbnail strip ─
if (heroStyle === 'stacked') {
const allEntries = heroNavEntries(subpages, albums);
const allEntries = heroNavEntries(subpages, albums, t);
return (
<div className="hero hero--stacked">

View File

@@ -54,7 +54,8 @@
/* Photo grid: generous spacing, cinematic transitions */
[data-preset='editorial'] .photo-grid {
column-gap: 32px;
/* Preset default only — an explicit grid.gap lands inline and wins (#513). */
--grid-gap: 32px;
}
[data-preset='editorial'] .photo-grid__item {
@@ -161,7 +162,7 @@
/* Subpage grid */
[data-preset='editorial'] .subpage-grid {
gap: 2px;
--subpage-gap: 2px;
}
[data-preset='editorial'] .subpage-grid__item {

View File

@@ -90,7 +90,11 @@
/* Photo grid: zero hover effects, clinical stillness */
[data-preset='minimal'] .photo-grid {
column-gap: 2px;
/* The preset's own spacing, and only its default: an explicit grid.gap lands
inline on this element and wins. It used to be a hardcoded column-gap, which
no setting could override and which left the row spacing on the global 12px
while the columns stayed at 2px (#513). */
--grid-gap: 2px;
}
[data-preset='minimal'] .photo-grid__item {
@@ -179,7 +183,7 @@
/* Subpage grid */
[data-preset='minimal'] .subpage-grid {
gap: 2px;
--subpage-gap: 2px;
}
[data-preset='minimal'] .subpage-grid__item {

View File

@@ -30,7 +30,8 @@
/* Photo grid: numbered indices with CSS counter */
[data-preset='monograph'] .photo-grid {
counter-reset: photo-index;
column-gap: 40px;
/* Preset default only — an explicit grid.gap lands inline and wins (#513). */
--grid-gap: 40px;
}
[data-preset='monograph'] .photo-grid__item {
@@ -170,7 +171,7 @@
/* Subpage grid */
[data-preset='monograph'] .subpage-grid {
gap: 1px;
--subpage-gap: 1px;
}
[data-preset='monograph'] .subpage-grid__item {

View File

@@ -615,8 +615,7 @@
/* ── Subpage: Album-Cover mit Caption-Bar ───────────────────── */
[data-preset='studio-modern'] .subpage-grid {
grid-template-columns: repeat(2, 1fr);
gap: 20px;
--subpage-gap: 20px;
}
[data-preset='studio-modern'] .subpage-grid__item {

View File

@@ -4,6 +4,7 @@
*/
import Link from 'next/link';
import { getServerDictionary } from '@/lib/i18n/server';
interface BackLinkProps {
href: string;
@@ -11,8 +12,9 @@ interface BackLinkProps {
}
export function BackLink({ href, label }: BackLinkProps) {
const t = getServerDictionary();
return (
<Link href={href} className="album-header__back" aria-label={`Back to ${label}`}>
<Link href={href} className="album-header__back" aria-label={t.common.backTo(label)}>
<svg
aria-hidden="true"
viewBox="0 0 24 24"

View File

@@ -5,10 +5,12 @@
import Link from 'next/link';
import { getConfig } from '@/lib/config';
import { getServerDictionary } from '@/lib/i18n/server';
export function Footer() {
const config = getConfig();
const { footer, legal } = config;
const t = getServerDictionary();
// Don't render if no footer config AND no legal config
if (
@@ -25,7 +27,7 @@ export function Footer() {
{footer?.name && <span className="footer__name">{footer.name}</span>}
{legal.enabled && (
<Link href="/impressum" className="footer__legal-link">
Impressum
{t.legal.navLabel}
</Link>
)}
</div>

View File

@@ -0,0 +1,29 @@
'use client';
/**
* Carries the resolved locale to client components.
*
* Only the locale string crosses the server/client boundary — dictionaries
* contain functions and would not serialise, so `useDictionary()` looks the
* object up on the client instead of receiving it as a prop.
*/
import { createContext, useContext, useMemo } from 'react';
import { DEFAULT_LOCALE, getDictionary, type Dictionary, type Locale } from '@/lib/i18n';
const LocaleContext = createContext<Locale>(DEFAULT_LOCALE);
export function I18nProvider({ locale, children }: { locale: Locale; children: React.ReactNode }) {
return <LocaleContext.Provider value={locale}>{children}</LocaleContext.Provider>;
}
/** Resolved locale — `DEFAULT_LOCALE` outside a provider. */
export function useLocale(): Locale {
return useContext(LocaleContext);
}
/** Dictionary for the current locale. */
export function useDictionary(): Dictionary {
const locale = useLocale();
return useMemo(() => getDictionary(locale), [locale]);
}

View File

@@ -175,6 +175,17 @@
}
.exifPanel {
/* Both floating panels paint their own dark surface, so their contents have
to stay light-on-dark. In dark mode the theme tokens already are, and each
preset tints them (warm #6b6359 in editorial, neutral #555 in monograph) —
so they are used as-is and dark mode is untouched. Only light mode, where
--text-primary is near-black and put dark text on that dark panel (#511),
swaps in fixed light values below. .exifCaption never had the bug because
it was hardcoded. */
--lb-panel-text: var(--text-primary);
--lb-panel-text-dim: var(--text-secondary);
--lb-panel-muted: var(--text-muted);
--lb-panel-border: var(--border-subtle);
position: fixed;
bottom: 60px;
right: 24px;
@@ -182,7 +193,7 @@
background: rgba(20, 20, 20, 0.92);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--border-subtle);
border: 1px solid var(--lb-panel-border);
border-radius: var(--radius-lg);
padding: 20px;
min-width: 240px;
@@ -214,10 +225,14 @@
.exifRow {
display: flex;
/* `space-between` distributes the *free* space, so a value long enough to
leave none ran straight into its own label (#514). An explicit gap is the
floor; the panel has no max-width and grows to keep it. */
gap: 16px;
justify-content: space-between;
align-items: baseline;
padding: 6px 0;
border-bottom: 1px solid var(--border-subtle);
border-bottom: 1px solid var(--lb-panel-border);
}
.exifRow:last-child {
@@ -226,15 +241,18 @@
.exifLabel {
font-size: 0.75rem;
color: var(--text-muted);
color: var(--lb-panel-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.exifValue {
font-size: 0.85rem;
color: var(--text-primary);
color: var(--lb-panel-text);
font-weight: 400;
/* Right-aligned so a value that does wrap stays flush with the ones above it,
rather than starting a ragged second column. */
text-align: right;
}
/* Marks the ISO row so a preset can keep its label — "200" alone reads
@@ -243,6 +261,105 @@
display: flex;
}
/* ── Keyboard shortcuts ─────────────────────────────
The panel has no trigger of its own — `?` or `h` opens it. It sits where a
trigger would have been, bottom left, clear of the EXIF panel opposite. */
.shortcutsPanel {
/* Both floating panels paint their own dark surface, so their contents have
to stay light-on-dark. In dark mode the theme tokens already are, and each
preset tints them (warm #6b6359 in editorial, neutral #555 in monograph) —
so they are used as-is and dark mode is untouched. Only light mode, where
--text-primary is near-black and put dark text on that dark panel (#511),
swaps in fixed light values below. .exifCaption never had the bug because
it was hardcoded. */
--lb-panel-text: var(--text-primary);
--lb-panel-text-dim: var(--text-secondary);
--lb-panel-muted: var(--text-muted);
--lb-panel-border: var(--border-subtle);
position: fixed;
bottom: 62px;
left: 24px;
z-index: 1001;
animation: exif-slide-up var(--transition-normal) ease forwards;
min-width: 240px;
padding: 16px 20px;
background: rgba(20, 20, 20, 0.92);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--lb-panel-border);
border-radius: var(--radius-lg);
}
.shortcutsTitle {
margin: 0 0 10px;
font-family: var(--font-sans);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--lb-panel-muted);
}
.shortcutsList {
margin: 0;
}
/* Two columns, not space-between: a ragged left edge on the descriptions is
what makes a shortcut list hard to scan. */
.shortcutsRow {
display: flex;
align-items: baseline;
gap: 16px;
padding: 6px 0;
border-bottom: 1px solid var(--lb-panel-border);
}
.shortcutsRow:last-child {
border-bottom: none;
}
.shortcutsKeys {
display: flex;
flex: 0 0 66px;
gap: 4px;
}
.shortcutsLabel {
margin: 0;
font-size: 0.8rem;
color: var(--lb-panel-text);
}
.kbd {
min-width: 22px;
padding: 2px 6px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: var(--radius-sm);
font-family: var(--font-caption);
font-size: 0.75rem;
line-height: 1.4;
text-align: center;
color: var(--lb-panel-text-dim);
}
/* The panels stay dark while the page around them turns light, so their
contents cannot follow the theme here. Preset tinting is lost in light mode;
an unreadable panel is the worse trade (#511). */
:global([data-theme='light']) .exifPanel,
:global([data-theme='light']) .shortcutsPanel {
--lb-panel-text: rgba(255, 255, 255, 0.92);
--lb-panel-text-dim: rgba(255, 255, 255, 0.62);
--lb-panel-muted: rgba(255, 255, 255, 0.5);
--lb-panel-border: rgba(255, 255, 255, 0.12);
}
/* No keyboard, nothing to show. */
@media (hover: none) {
.shortcutsPanel {
display: none;
}
}
/* ── Responsive ──────────────────────────────────── */
@media (max-width: 640px) {
.nav {
@@ -429,6 +546,24 @@
right: 176px;
}
/* The EXIF strip owns the bottom edge in this preset, so the panel opens from
the top instead. */
:global([data-preset='studio-modern']) .shortcutsPanel {
top: 58px;
bottom: auto;
left: 32px;
border-radius: 0;
background: rgba(10, 10, 10, 0.94);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
:global([data-preset='studio-modern']) .shortcutsTitle,
:global([data-preset='studio-modern']) .kbd {
border-radius: 0;
font-family: var(--font-caption);
}
/* Film-edge strip replaces the popup panel */
:global([data-preset='studio-modern']) .exifPanel {
top: auto;

View File

@@ -6,6 +6,8 @@
* - Previous/Next navigation (arrows + swipe)
* - Close (Esc, click outside, X button)
* - EXIF metadata panel (fetched on demand)
* - Keyboard shortcut list (`?` or `h`), deliberately unadvertised
* - Real fullscreen (`f`), where the browser offers it
* - Preloads adjacent images
*/
@@ -19,6 +21,11 @@ import { useSwipe } from '@/hooks/useSwipe';
import styles from './Lightbox.module.css';
import { useProofing } from './ProofingContext';
import { IconHeart } from './Icons';
import { useDictionary } from './I18nProvider';
// From lib/config/schema directly: lib/config/index.ts pulls in `fs`, which a
// client component cannot import.
import { resolveWatermarkOpacity } from '@/lib/config/schema';
import { formatCamera } from '@/lib/exif';
export interface LightboxWatermark {
enabled?: boolean;
@@ -50,7 +57,11 @@ export function Lightbox({
watermark,
showExifToggle = true,
}: LightboxProps) {
const t = useDictionary();
const [showExif, setShowExif] = useState(false);
const [showShortcuts, setShowShortcuts] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [canFullscreen, setCanFullscreen] = useState(false);
const { exifData, exifLoading, fetchExif, clearExif } = useExif();
const [imageLoaded, setImageLoaded] = useState(false);
const overlayRef = useRef<HTMLDivElement>(null);
@@ -92,6 +103,61 @@ export function Lightbox({
}
}, [showExif, current, fetchExif]);
const toggleShortcuts = useCallback(() => {
setShowShortcuts((open) => !open);
}, []);
/*
* Real fullscreen. The overlay already covers the viewport, but the browser's
* own chrome sits above it — tab strip, URL bar, bookmarks — which is exactly
* the frame a photograph should not be shown in.
*
* `fullscreenEnabled` is the honest gate: iPhone Safari implements the API on
* `<video>` only, so the key and its shortcut row are simply absent there
* rather than failing silently.
*/
useEffect(() => {
setCanFullscreen(typeof document !== 'undefined' && !!document.fullscreenEnabled);
}, []);
const toggleFullscreen = useCallback(() => {
const overlay = overlayRef.current;
if (!overlay) return;
// Every rejection here is a decision the browser is entitled to make
// (denied permission, gesture requirement, already exiting) — the state
// then simply stays where it was.
if (document.fullscreenElement) {
void document.exitFullscreen().catch(() => {});
} else {
void overlay.requestFullscreen().catch(() => {});
}
}, []);
/*
* The state is derived from the event, never from the click: F11, the browser
* Esc and the window manager all leave fullscreen without asking us.
*/
useEffect(() => {
const syncFullscreen = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', syncFullscreen);
return () => document.removeEventListener('fullscreenchange', syncFullscreen);
}, []);
/*
* Leaving the lightbox by any route — X, backdrop click, Esc, a navigation —
* unmounts this component while the document is still fullscreen. Without
* this the visitor is left in a fullscreen *gallery page* with no obvious way
* back. Only ever exits a fullscreen this component asked for.
*/
useEffect(() => {
const overlay = overlayRef.current;
return () => {
if (overlay && document.fullscreenElement === overlay) {
void document.exitFullscreen().catch(() => {});
}
};
}, []);
// Preload adjacent images (skip videos — they stream on demand)
useEffect(() => {
const preload = (index: number) => {
@@ -119,7 +185,15 @@ export function Lightbox({
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'Escape':
onClose();
// Innermost layer first: Esc dismisses the shortcut list, then leaves
// fullscreen, and only closes the viewer once nothing is stacked on
// top of it. Most browsers swallow this Esc to exit fullscreen
// themselves and never dispatch it — the middle branch is for the
// ones that do dispatch it, which would otherwise close the lightbox
// and leave the page behind it fullscreen.
if (showShortcuts) setShowShortcuts(false);
else if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
else onClose();
break;
case 'ArrowRight':
onNext();
@@ -131,6 +205,19 @@ export function Lightbox({
case 'I':
if (showExifToggle) handleExifToggle();
break;
// `?` is matched on the produced character, not the physical key:
// Shift+/ on a US layout, Shift+ß on a German one. `h` is the escape
// hatch for layouts where `?` is awkward — and the one key someone
// guesses without having been told.
case '?':
case 'h':
case 'H':
toggleShortcuts();
break;
case 'f':
case 'F':
if (canFullscreen) toggleFullscreen();
break;
}
};
@@ -141,7 +228,17 @@ export function Lightbox({
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [handleExifToggle, onClose, onNext, onPrev, showExifToggle]);
}, [
canFullscreen,
handleExifToggle,
onClose,
onNext,
onPrev,
showExifToggle,
showShortcuts,
toggleFullscreen,
toggleShortcuts,
]);
// Click on overlay background → close
const handleOverlayClick = useCallback(
@@ -153,6 +250,28 @@ export function Lightbox({
[onClose],
);
/*
* The advertised shortcuts, in the order they are worth learning. This is the
* list the `?` panel renders — a key added to the handler above belongs here
* too, or it stays as undiscoverable as the whole set was before.
*/
const shortcutRows = [
{ keys: ['←', '→'], label: t.lightbox.shortcutNavigate },
// Journal entries hide the EXIF toggle, so `i` does nothing there.
...(showExifToggle ? [{ keys: ['I'], label: t.lightbox.shortcutInfo }] : []),
// Absent where the browser has no element fullscreen to give (iPhone).
...(canFullscreen
? [
{
keys: ['F'],
label: isFullscreen ? t.lightbox.shortcutExitFullscreen : t.lightbox.shortcutFullscreen,
},
]
: []),
{ keys: ['?', 'H'], label: t.lightbox.shortcutList },
{ keys: ['Esc'], label: t.lightbox.shortcutClose },
];
const lightboxJsx = (
<div
className={styles.overlay}
@@ -162,15 +281,15 @@ export function Lightbox({
onTouchEnd={handleTouchEnd}
role="dialog"
aria-modal="true"
aria-label="Image viewer"
aria-label={t.lightbox.viewer}
>
{/* Close button */}
<button
ref={closeBtnRef}
className={styles.close}
onClick={onClose}
aria-label="Close"
title="Close (Esc)"
aria-label={t.lightbox.close}
title={t.lightbox.closeTitle}
>
<svg
aria-hidden="true"
@@ -190,8 +309,8 @@ export function Lightbox({
<button
className={`${styles.nav} ${styles.navPrev}`}
onClick={onPrev}
aria-label="Previous photo"
title="Previous photo (Left arrow)"
aria-label={t.lightbox.previous}
title={t.lightbox.previousTitle}
>
<svg
aria-hidden="true"
@@ -251,7 +370,7 @@ export function Lightbox({
? styles.watermark_center
: styles.watermark_bottom_right
}`}
style={{ opacity: (watermark.opacity ?? 50) / 100 }}
style={{ opacity: resolveWatermarkOpacity(watermark.opacity) }}
>
{watermark.text}
</div>
@@ -262,8 +381,8 @@ export function Lightbox({
<button
className={`${styles.nav} ${styles.navNext}`}
onClick={onNext}
aria-label="Next photo"
title="Next photo (Right arrow)"
aria-label={t.lightbox.next}
title={t.lightbox.nextTitle}
>
<svg
aria-hidden="true"
@@ -297,11 +416,11 @@ export function Lightbox({
fontWeight: isFav ? 600 : 400,
}}
onClick={() => proofing.toggleFavorite(current.id)}
aria-label={isFav ? 'Remove from favorites' : 'Add to favorites'}
title={isFav ? 'Remove from favorites' : 'Add to favorites'}
aria-label={isFav ? t.proofing.removeFromFavorites : t.proofing.addToFavorites}
title={isFav ? t.proofing.removeFromFavorites : t.proofing.addToFavorites}
>
<IconHeart size={14} fill={isFav ? 'currentColor' : 'none'} aria-hidden="true" />
{isFav ? 'Saved' : 'Favorite'}
{isFav ? t.proofing.saved : t.proofing.favorite}
</button>
)}
@@ -312,10 +431,10 @@ export function Lightbox({
onClick={handleExifToggle}
aria-expanded={showExif}
aria-controls="exif-panel"
aria-label="Toggle photo info"
title="Toggle photo info (i)"
aria-label={t.lightbox.toggleInfo}
title={t.lightbox.toggleInfoTitle}
>
{showExif ? 'Hide Info' : 'Info'}
{showExif ? t.lightbox.hideInfo : t.lightbox.info}
</button>
)}
@@ -324,52 +443,52 @@ export function Lightbox({
<div id="exif-panel" className={styles.exifPanel}>
{exifLoading ? (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Loading...</span>
<span className={styles.exifLabel}>{t.lightbox.loading}</span>
</div>
) : exifData ? (
<>
{exifData.description && <p className={styles.exifCaption}>{exifData.description}</p>}
{exifData.model && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Camera</span>
<span className={styles.exifLabel}>{t.lightbox.camera}</span>
<span className={styles.exifValue}>
{[exifData.make, exifData.model].filter(Boolean).join(' ')}
{formatCamera(exifData.make, exifData.model)}
</span>
</div>
)}
{exifData.lensModel && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Lens</span>
<span className={styles.exifLabel}>{t.lightbox.lens}</span>
<span className={styles.exifValue}>{exifData.lensModel}</span>
</div>
)}
{exifData.focalLength && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Focal Length</span>
<span className={styles.exifLabel}>{t.lightbox.focalLength}</span>
<span className={styles.exifValue}>{exifData.focalLength}mm</span>
</div>
)}
{exifData.fNumber && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Aperture</span>
<span className={styles.exifLabel}>{t.lightbox.aperture}</span>
<span className={styles.exifValue}>ƒ/{exifData.fNumber}</span>
</div>
)}
{exifData.exposureTime && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Shutter</span>
<span className={styles.exifLabel}>{t.lightbox.shutter}</span>
<span className={styles.exifValue}>{exifData.exposureTime}s</span>
</div>
)}
{exifData.iso && (
<div className={`${styles.exifRow} ${styles.exifRowIso}`}>
<span className={styles.exifLabel}>ISO</span>
<span className={styles.exifLabel}>{t.lightbox.iso}</span>
<span className={styles.exifValue}>{exifData.iso}</span>
</div>
)}
{(exifData.city || exifData.country) && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Location</span>
<span className={styles.exifLabel}>{t.lightbox.location}</span>
<span className={styles.exifValue}>
{[exifData.city, exifData.country].filter(Boolean).join(', ')}
</span>
@@ -378,11 +497,37 @@ export function Lightbox({
</>
) : (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>No EXIF data available</span>
<span className={styles.exifLabel}>{t.lightbox.noExif}</span>
</div>
)}
</div>
)}
{/*
The shortcuts have no control of their own and are not advertised: a
permanent button in the corner of a photograph costs every visitor
something, and the keys are worth nothing to the ones who would never
press them anyway. Whoever tries `?` or `h` finds them.
*/}
{showShortcuts && (
<div id="lightbox-shortcuts" className={styles.shortcutsPanel}>
<p className={styles.shortcutsTitle}>{t.lightbox.shortcuts}</p>
<dl className={styles.shortcutsList}>
{shortcutRows.map((row) => (
<div key={row.label} className={styles.shortcutsRow}>
<dt className={styles.shortcutsKeys}>
{row.keys.map((key) => (
<kbd key={key} className={styles.kbd}>
{key}
</kbd>
))}
</dt>
<dd className={styles.shortcutsLabel}>{row.label}</dd>
</div>
))}
</dl>
</div>
)}
</div>
);

View File

@@ -6,6 +6,7 @@
*/
import { useEffect, useRef, useState } from 'react';
import { useDictionary } from './I18nProvider';
/** Escapes special HTML characters to prevent XSS in Leaflet popup templates. */
function escapeHtml(str: string): string {
@@ -28,6 +29,7 @@ interface MapLocationPublic {
}
export function MapView() {
const t = useDictionary();
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<unknown>(null);
const [loading, setLoading] = useState(true);
@@ -52,7 +54,7 @@ export function MapView() {
/* ignore */
}
console.error('[Map] API error', res.status, detail);
setError(`Failed to load map data (${res.status})`);
setError(t.map.loadFailed(res.status));
setLoading(false);
return;
}
@@ -107,7 +109,7 @@ export function MapView() {
<div class="map-popup__body">
<h3 class="map-popup__city">${escapeHtml(loc.city)}</h3>
<p class="map-popup__country">${escapeHtml(loc.country)}</p>
<p class="map-popup__count">${loc.photoCount} photo${loc.photoCount === 1 ? '' : 's'}</p>
<p class="map-popup__count">${escapeHtml(t.common.photos(loc.photoCount))}</p>
<div class="map-popup__albums">${albumLinks}</div>
</div>
</div>
@@ -135,7 +137,7 @@ export function MapView() {
init().catch((err) => {
console.error('[Map] Init error:', err);
if (!cancelled) {
setError('Failed to initialize map');
setError(t.map.initFailed);
setLoading(false);
}
});
@@ -147,7 +149,7 @@ export function MapView() {
mapRef.current = null;
}
};
}, []);
}, [t]);
if (error) {
return (
@@ -194,7 +196,7 @@ export function MapView() {
<circle cx="12" cy="12" r="10" strokeOpacity="0.25"></circle>
<path d="M12 2a10 10 0 0 1 10 10"></path>
</svg>
<p>Loading map…</p>
<p>{t.map.loading}</p>
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }

View File

@@ -7,14 +7,16 @@
import { useState } from 'react';
import styles from './PasswordGate.module.css';
import { useDictionary } from './I18nProvider';
interface PasswordGateProps {
slug: string;
title: string;
type?: 'subpage' | 'album' | 'journal';
type?: 'subpage' | 'album' | 'journal' | 'site';
}
export default function PasswordGate({ slug, title, type = 'subpage' }: PasswordGateProps) {
const t = useDictionary();
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
@@ -34,11 +36,11 @@ export default function PasswordGate({ slug, title, type = 'subpage' }: Password
if (res.ok) {
window.location.reload();
} else {
setError('Incorrect password. Please try again.');
setError(t.password.incorrect);
setPassword('');
}
} catch {
setError('Unable to verify password. Please try again later.');
setError(t.password.failed);
} finally {
setLoading(false);
}
@@ -48,7 +50,9 @@ export default function PasswordGate({ slug, title, type = 'subpage' }: Password
<div className={styles.gate}>
<div className={styles.card}>
<h2 className={styles.title}>{title}</h2>
<p className={styles.subtitle}>This gallery is password-protected.</p>
<p className={styles.subtitle}>
{type === 'site' ? t.password.siteSubtitle : t.password.subtitle}
</p>
<form onSubmit={handleSubmit} className={styles.form}>
<input
@@ -56,8 +60,8 @@ export default function PasswordGate({ slug, title, type = 'subpage' }: Password
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
aria-label="Enter password"
placeholder={t.password.placeholder}
aria-label={t.password.placeholder}
className={styles.input}
autoFocus
required
@@ -83,10 +87,10 @@ export default function PasswordGate({ slug, title, type = 'subpage' }: Password
<circle cx="12" cy="12" r="10" strokeOpacity="0.25"></circle>
<path d="M12 2a10 10 0 0 1 10 10"></path>
</svg>
Verifying…
{t.password.verifying}
</>
) : (
'Enter'
t.password.submit
)}
</button>
</form>

View File

@@ -2,6 +2,7 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { encodeProofBitmask, decodeProofBitmask } from '@/lib/proofing';
import { useDictionary } from './I18nProvider';
interface ProofingContextType {
favorites: Set<string>;
@@ -30,6 +31,7 @@ export function ProofingProvider({
albumName?: string;
allowMailto?: boolean;
}) {
const t = useDictionary();
const [favorites, setFavorites] = useState<Set<string>>(new Set());
const [isFilterActive, setIsFilterActive] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -126,8 +128,12 @@ export function ProofingProvider({
selectedIndices.push(index + 1);
}
});
if (selectedIndices.length === 0) return `${albumName}: No photos selected.`;
return `${albumName} — Selected Photos (${selectedIndices.length}): #${selectedIndices.join(', #')}`;
if (selectedIndices.length === 0) return t.proofing.listEmpty(albumName);
return t.proofing.listSummary(
albumName,
selectedIndices.length,
`#${selectedIndices.join(', #')}`,
);
};
return (

View File

@@ -3,8 +3,10 @@
import React, { useState } from 'react';
import { useProofing } from './ProofingContext';
import { IconCheck, IconCopy, IconLink } from './Icons';
import { useDictionary } from './I18nProvider';
export function ProofingModal() {
const t = useDictionary();
const proofing = useProofing();
const [copiedState, setCopiedState] = useState<'none' | 'link' | 'list'>('none');
@@ -36,10 +38,8 @@ export function ProofingModal() {
};
const handleMailto = () => {
const subject = encodeURIComponent(`Photo Selection (${favorites.size} items)`);
const body = encodeURIComponent(
`Hello,\n\nHere is my photo selection:\n\n${getFormattedList()}\n\nShare Link: ${getProofingUrl()}\n\nBest regards,`,
);
const subject = encodeURIComponent(t.proofing.mailSubject(favorites.size));
const body = encodeURIComponent(t.proofing.mailBody(getFormattedList(), getProofingUrl()));
window.location.href = `mailto:?subject=${subject}&body=${body}`;
};
@@ -82,12 +82,12 @@ export function ProofingModal() {
}}
>
<h3 style={{ margin: 0, fontSize: '1.2rem', fontWeight: 600 }}>
❤️ Selection ({favorites.size})
{t.proofing.modalTitle(favorites.size)}
</h3>
<button
type="button"
onClick={() => setIsModalOpen(false)}
aria-label="Close modal"
aria-label={t.proofing.closeModal}
style={{
background: 'none',
border: 'none',
@@ -103,8 +103,7 @@ export function ProofingModal() {
</div>
<p style={{ fontSize: '0.9rem', opacity: 0.8, marginBottom: '1rem' }}>
You have selected {favorites.size} photo{favorites.size === 1 ? '' : 's'}. Choose an
export option below to share your selection:
{t.proofing.intro(favorites.size)}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
@@ -127,11 +126,11 @@ export function ProofingModal() {
>
{copiedState === 'link' ? (
<>
<IconCheck size={15} aria-hidden="true" /> Link Copied!
<IconCheck size={15} aria-hidden="true" /> {t.proofing.linkCopied}
</>
) : (
<>
<IconLink size={15} aria-hidden="true" /> Copy Shareable Link
<IconLink size={15} aria-hidden="true" /> {t.proofing.copyLink}
</>
)}
</button>
@@ -155,11 +154,11 @@ export function ProofingModal() {
>
{copiedState === 'list' ? (
<>
<IconCheck size={15} aria-hidden="true" /> List Copied!
<IconCheck size={15} aria-hidden="true" /> {t.proofing.listCopied}
</>
) : (
<>
<IconCopy size={15} aria-hidden="true" /> Copy Text List (#1, #2...)
<IconCopy size={15} aria-hidden="true" /> {t.proofing.copyList}
</>
)}
</button>
@@ -182,14 +181,14 @@ export function ProofingModal() {
cursor: 'pointer',
}}
>
✉️ Send Email to Photographer
{t.proofing.sendEmail}
</button>
)}
<button
type="button"
onClick={() => {
if (confirm('Clear all selected favorites?')) {
if (confirm(t.proofing.confirmClear)) {
clearFavorites();
setIsModalOpen(false);
}
@@ -204,7 +203,7 @@ export function ProofingModal() {
opacity: 0.8,
}}
>
Clear Selection
{t.proofing.clearSelection}
</button>
</div>
</div>

View File

@@ -6,10 +6,12 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useDictionary } from './I18nProvider';
const SHOW_THRESHOLD = 300;
export function ScrollToTop() {
const t = useDictionary();
const [visible, setVisible] = useState(false);
const frame = useRef<number | null>(null);
@@ -41,8 +43,8 @@ export function ScrollToTop() {
<button
className={`scroll-to-top ${visible ? 'scroll-to-top--visible' : ''}`}
onClick={scrollToTop}
aria-label="Scroll to top"
title="Scroll to top"
aria-label={t.theme.scrollToTop}
title={t.theme.scrollToTop}
type="button"
tabIndex={visible ? 0 : -1}
aria-hidden={!visible}

View File

@@ -46,8 +46,12 @@ export function SetupScreen() {
}}
>
<p style={{ marginBottom: '1rem' }}>
To get started, follow these steps in your repository or mounted <code>content/</code>{' '}
volume:
The quickest way is the setup wizard at <code>/install</code>. It is gated by a one-time
token printed to the server log at startup (<code>docker logs</code>) — append it as{' '}
<code>?token=…</code>.
</p>
<p style={{ marginBottom: '1rem' }}>
Or configure it by hand, in your repository or mounted <code>content/</code> volume:
</p>
<ol style={{ margin: 0, paddingLeft: '1.25rem' }}>
<li style={{ marginBottom: '0.5rem' }}>

View File

@@ -7,6 +7,7 @@ import Link from 'next/link';
import { immich } from '@/lib/immich';
import { getConfig } from '@/lib/config';
import { listJournalEntries } from '@/lib/admin/journal-service';
import { getServerDictionary } from '@/lib/i18n/server';
export async function SubpageNav() {
const [subpages, standaloneAlbums, journalEntries] = await Promise.all([
@@ -17,6 +18,7 @@ export async function SubpageNav() {
// EXPERIMENTAL: external nav links from settings.yaml, appended after the
// internal entries. Sanitised to http(s) in getConfig().
const navLinks = getConfig().navLinks;
const t = getServerDictionary();
const hasPublicJournal = journalEntries.some((e) => !e.frontmatter.draft);
@@ -29,7 +31,7 @@ export async function SubpageNav() {
))}
{hasPublicJournal && (
<Link href="/journal" className="header__nav-link">
Journal
{t.nav.journal}
</Link>
)}
{standaloneAlbums.map((album) => (

View File

@@ -1,14 +1,22 @@
/**
* ThemeToggle — dark/light mode switch.
*
* Persists preference in localStorage and sets data-theme on <html>.
* Defaults to dark theme (matching existing design).
* A visitor's own choice is kept in localStorage and always wins. Without one,
* the starting mode comes from `mode:` in settings.yaml, which the layout has
* already rendered onto <html> as data-default-theme — reading it from the DOM
* rather than taking a prop keeps this component mountable anywhere without
* threading config through the tree. `auto` follows the operating system.
*
* It used to hardcode dark, so an operator who preferred light had no way to
* give visitors anything else (#512).
*
* Uses useSyncExternalStore to satisfy React strict mode lint rules.
*/
'use client';
import { useSyncExternalStore, useCallback } from 'react';
import { useDictionary } from './I18nProvider';
type Theme = 'dark' | 'light';
@@ -29,6 +37,22 @@ function getServerSnapshot(): Theme {
return 'dark'; // SSR default
}
/** What the OS asks for. Dark unless it says otherwise, as before. */
function systemTheme(): Theme {
return typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-color-scheme: light)').matches
? 'light'
: 'dark';
}
/** The configured starting mode, read off the <html> the server rendered. */
function configuredTheme(): Theme {
const mode = document.documentElement.getAttribute('data-default-theme');
if (mode === 'light' || mode === 'dark') return mode;
// 'auto', or an older layout that renders no attribute at all.
return systemTheme();
}
function applyTheme(theme: Theme) {
currentTheme = theme;
if (typeof document !== 'undefined') {
@@ -40,13 +64,15 @@ function applyTheme(theme: Theme) {
// Initialize from localStorage on module load (client-side only)
if (typeof window !== 'undefined') {
const stored = localStorage.getItem('theme') as Theme | null;
currentTheme = stored === 'light' ? 'light' : 'dark';
const stored = localStorage.getItem('theme');
currentTheme = stored === 'light' || stored === 'dark' ? stored : configuredTheme();
document.documentElement.setAttribute('data-theme', currentTheme);
}
export function ThemeToggle() {
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const t = useDictionary();
const label = theme === 'dark' ? t.theme.switchToLight : t.theme.switchToDark;
const toggle = useCallback(() => {
applyTheme(theme === 'dark' ? 'light' : 'dark');
@@ -56,9 +82,9 @@ export function ThemeToggle() {
<button
className="theme-toggle"
onClick={toggle}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
aria-label={label}
type="button"
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
title={label}
>
{theme === 'dark' ? (
// Sun icon

View File

@@ -42,16 +42,23 @@ albums:
- "asset-uuid-opening-frame"
- "asset-uuid-second-frame"
# Subpages: group albums under custom URL paths
# Subpages: group albums under custom URL paths.
# One `subpages:` key for the whole file — every page below is one entry in it.
subpages:
"Travel":
- "album-uuid-3"
- "album-uuid-4"
"Projects":
title: "My Creative Projects" # Optional: Header title, defaults to Navigation key ("Projects")
# Simple form: a name and its albums.
- name: Travel
albums:
- "album-uuid-3"
- "album-uuid-4"
- name: Projects
title: "My Creative Projects" # Optional: Header title, defaults to `name`
subtitle: "A collection of 2026 highlights in 35mm film." # Optional: Sub-text for headers
password: "your-password" # Optional protection
# hidden: true # EXPERIMENTAL: reachable by direct link, but not shown in navigation
# grid: # Optional per-page grid override (wins over settings.yaml)
# columns: 3 # how many album covers per row on this page
# gap: 8 # spacing between the covers, in px — omit to keep the theme's own
albums:
- "album-uuid-5"
- "album-uuid-6":
@@ -63,11 +70,10 @@ subpages:
# columns: 2
# coverPosition: "50% 25%" # EXPERIMENTAL: focal point for the cover crop ("top", "center bottom", "50% 25%")
# ── Subpage with Sections ──────────────────────────────────────────
# Use sections to group albums by location, theme, or any other logic.
# A typographic table of contents is rendered above the albums.
# Sections are optional: leave them out and you get a flat album grid (as above).
subpages:
# ── Subpage with Sections ────────────────────────────────────────
# Use sections to group albums by location, theme, or any other logic.
# A typographic table of contents is rendered above the albums.
# Sections are optional: leave them out and you get a flat album grid (as above).
- name: Japan
title: Japan
subtitle: "Reisen durch ein Land voller Kontraste."

View File

@@ -6,6 +6,11 @@
title: "My Portfolio"
subtitle: "A visual journal"
# Interface language of the visitor-facing site: "en" or "de".
# Any other value still sets <html lang> and date formatting, but the
# interface stays English. The admin panel is always English.
lang: "en"
# ── SEO & Metadata ───────────────────────────────────────────────
# Optional: Overrides global title/subtitle for search engines and social cards
# seo:
@@ -15,9 +20,31 @@ subtitle: "A visual journal"
# noIndex: false # Set to true to hide from search engines
# noFollow: false # Set to true to prevent search engines from following links
# Show EXIF metadata (camera, lens, ISO) on photo grid hover
# ── Site password (optional) ─────────────────────────────────────
# Puts the WHOLE public site behind one password — useful while a portfolio
# is still being built, or for a folio only ever shown to clients.
# Log in once and the server log prints a scrypt: hash to paste back here.
# The SITE_PASSWORD environment variable overrides this value.
# /admin keeps its own password and is never behind this gate.
# sitePassword: "scrypt:..."
# What a first-time visitor sees: dark (default), light, or auto to follow
# their operating system. Their own choice from the header toggle overrides it.
mode: dark
# Show the EXIF summary overlay on photo grid hover
exifOnHover: true
# Which EXIF groups the site may publish at all — the grid overlay, the
# lightbox info panel and the album header all follow this.
# caption is the description written in Immich; turn it off if you use that
# field for private notes.
# exif:
# camera: true # body, lens, focal length
# settings: true # aperture, shutter speed, ISO
# location: true # city and country
# caption: true # the Immich description
# Enable the interactive world map view (/map)
map: true
@@ -32,6 +59,13 @@ scrollToTop: true
# enabled: true
# allowMailto: true
# Watermark overlay on the lightbox. opacity is a fraction: 0.5 = 50%.
# watermark:
# enabled: true
# text: "© Your Name"
# opacity: 0.5
# position: "bottom-right" # bottom-right | bottom-left | center
# ── Theme configuration ──────────────────────────────────────────
# Presets: studio-modern (default), studio, minimal, editorial, classic,
# noir, monograph

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 523 KiB

After

Width:  |  Height:  |  Size: 405 KiB

View File

@@ -0,0 +1,407 @@
# Portfolio-Features: Auffindbarkeit, Zoom, Sequenzierung, Ortsauflösung
> **For agentic workers:** Steps use checkbox (`- [ ]`) syntax for tracking. Die vier Tasks sind
> **voneinander unabhängig** und können einzeln umgesetzt, getestet und gemerged werden.
**Goal:** Vier Vorschläge aus [`brainstorm-2026-08-15.md`](../../brainstorm-2026-08-15.md) umsetzen —
E (Auffindbarkeit), B2 (1:1-Zoom), B1 (Sequenzierung), C1 (Ortsauflösung). Reihenfolge nach
steigendem Risiko: erst zwei abgeschlossene Kleinigkeiten, dann der größte Eingriff, dann ein
Datenschutz-Feature mit klarer Angriffsfläche.
**Architecture:** Vier getrennte Eingriffe ohne gemeinsame Dateien — bis auf `lib/config/schema.ts`
und `lib/config/index.ts`, die von B1 und C1 beide angefasst werden (verschiedene Felder, kein
inhaltlicher Konflikt). Jede neue Per-Album-Option folgt dem etablierten Muster: ein
`Record<albumId, X>` in `AppConfig`, befüllt in `deriveGallery()`, genau wie `albumGrids` und
`albumCoverPositions` es seit #431 vormachen.
**Tech Stack:** Next.js 16 (App Router, React 19), TypeScript strict, Vitest 4 (node environment),
Playwright, Vanilla CSS, keine neuen Laufzeit-Dependencies.
---
## Global Constraints
- **Keine neuen Runtime-Dependencies.** Das Projekt hat sieben; das bleibt so.
- **TypeScript strict** — kein untypisiertes `any`; Suppressions nur als `@ts-expect-error` mit Begründung.
- **Rohe Immich-UUIDs dürfen nie im Client-HTML/JS landen.** Immer `imageUrl()` / `encodeAssetId()`.
- **Vanilla CSS**, Tokens aus `app/tokens.css`.
- **Formatierung:** nur geänderte Dateien (`npx prettier --write <datei>`), nie `npm run format` im Root.
- **Vor jedem Commit grün:** `npm run test:unit`, `npx tsc --noEmit`, `npm run lint`, `npm run build`.
- **Baseline vor Beginn:** 443 Tests in 35 Dateien. Jede Task nennt die erwartete neue Summe.
- **Commit-Stil:** Conventional Commits. Experimentelle Optionen als `EXPERIMENTAL` in YAML-Kommentar
und Admin-Label markieren, so wie #431 es gemacht hat.
- **Neue Env-Variablen** gehören in `lib/env.ts` **und** `.env.local.example`.
- **Vitest läuft im `node`-Environment** — es gibt kein jsdom. Komponentenlogik ist deshalb nur
testbar, wenn die _reine_ Berechnung in einem eigenen Modul liegt; die DOM-Verdrahtung deckt
Playwright ab. Das ist bei B1 und B2 jeweils eingeplant und ausdrücklich **kein** Verstoß gegen
die CLAUDE.md-Regel (die verbietet, _Route_-Logik nur der Testbarkeit wegen nach `lib/` zu ziehen —
hier geht es um echte, wiederverwendbare Geometrie- bzw. Gruppierungsfunktionen).
---
## Task E — Auffindbarkeit: Site-URL, `robots.txt`, `sitemap.xml`, JSON-LD
**Aufwand:** S (die Vorbedingung E1 und die Admin-UI machen daraus mehr als das im Brainstorm
geschätzte XS.)
### Befund vorab: es fehlt eine Site-URL
`lib/env.ts` kennt keine `SITE_URL`, `settings.yaml` keine `url:`, und `app/layout.tsx` setzt
**keine `metadataBase`**. OG-Bilder werden heute als relativer Pfad angegeben
(`app/layout.tsx:57`). Für `sitemap.xml` ist das ein hartes Hindernis: Der Sitemap-Standard
verlangt absolute URLs, und `app/sitemap.ts` läuft ohne Request-Kontext — der Host lässt sich also
nicht aus Headern ableiten.
**Entscheidung:** `url` in `settings.yaml`, überschreibbar durch `SITE_URL`. Env gewinnt — dasselbe
Präzedenzmuster wie bei Titel und Credentials. In `settings.yaml`, **weil der Wert im Admin-Panel
setzbar sein muss**: Wer per `docker run` startet, soll die Site-URL nicht in einer Env-Variable
nachtragen und neu starten müssen, sondern sie dort eingeben, wo er ohnehin Titel und SEO pflegt.
### Die Falle dabei — und warum E1b im Plan steht
Env gewinnt über YAML. Setzt jemand `SITE_URL` **und** trägt im Admin etwas anderes ein, speichert
das Panel brav, ändert aber nichts — der Wert wird stillschweigend überstimmt. Genau dieser Zustand
existiert heute schon unbemerkt bei `SITE_TITLE`/`SITE_SUBTITLE`: `lib/env.ts:98-99` schlägt
`settings.yaml` und **das Admin-Panel sagt es nirgends**. Eine repo-weite Suche findet in
`SettingsEditor.tsx`, `AdminDashboard.tsx` und `/api/admin/status` keinen einzigen Hinweis auf
Env-Überschreibungen.
Weil dieses Feld nun ausdrücklich über die UI bedienbar sein soll, wird die Anzeige mitgebaut —
klein, generisch und für die bestehenden Felder gleich mitverwendet.
### Schritte
- [ ] **E1 — Site-URL einführen**
- `SITE_URL` in `lib/env.ts` (`interface Env` + `parseEnv()`), validiert über `new URL()`,
trailing Slash entfernt — analog zur bestehenden Behandlung von `IMMICH_API_URL:26-32`.
- `url?: string` in `SettingsYaml` (`lib/config/schema.ts`) und `siteUrl: string` in `AppConfig`.
- Auflösung in `lib/config/index.ts`: `env.SITE_URL || settings.url || ''`.
- `metadataBase` in `app/layout.tsx` setzen, wenn `siteUrl` nicht leer ist.
- In `settings.yaml.example` dokumentieren, inklusive des Hinweises, dass ohne diesen Wert
Sitemap und JSON-LD entfallen.
- [ ] **E1a — Feld im Admin-Panel** (`app/admin/components/SettingsEditor.tsx`, Sektion `seo`,
`SETTINGS_SECTIONS:197`)
- Textfeld **„Site URL"** als erstes Feld der SEO-Sektion, über „SEO Title" — es ist die
Voraussetzung für alles andere dort. Anbindung wie die Nachbarfelder: `update('url', …)`.
(Achtung: `url` liegt auf oberster Ebene, nicht unter `seo.` — sonst landet es beim Auflösen
an der falschen Stelle.)
- Placeholder `https://folio.example.com`, Hilfetext: wofür der Wert gebraucht wird (Sitemap,
`robots.txt`, JSON-LD, absolute OG-Bild-URLs).
- **Validierung im Feld, nicht erst beim Speichern:** muss mit `http://` oder `https://`
beginnen und über `new URL()` parsebar sein; trailing Slash beim Speichern abschneiden, damit
aus Nutzersicht egal ist, ob er einen setzt. Ungültige Eingabe blockiert das Speichern mit
einer Meldung am Feld — eine kaputte Site-URL erzeugt sonst eine Sitemap voller kaputter Links.
- Leeres Feld ist ein gültiger Zustand (= Funktionen aus, wie heute), keine Fehlermeldung.
- Ein Hinweis in der SEO-Sektion, solange das Feld leer ist: „Ohne Site URL werden `sitemap.xml`
und JSON-LD nicht ausgeliefert." Sonst sucht jemand den Fehler an der falschen Stelle.
- [ ] **E1b — Env-Überschreibungen im Panel sichtbar machen** (klein, generisch)
- `GET /api/admin/settings` (`app/api/admin/settings/route.ts:18-19`) gibt zusätzlich
`envLocked: string[]` zurück — die Namen der Felder, die gerade aus der Umgebung kommen.
Anfangs: `url` (wenn `SITE_URL` gesetzt), `title` (`SITE_TITLE`), `subtitle` (`SITE_SUBTITLE`).
**Nur Feldnamen, keine Werte** — die Route soll keine Env-Inhalte spiegeln, die sonst nirgends
im Panel stehen.
- `SettingsEditor` rendert betroffene Felder deaktiviert mit dem Zusatz „Von der Umgebungsvariable
`SITE_URL` gesetzt" statt eines editierbaren Eingabefelds. Damit ist der stille Fehlschlag
weg — beim neuen Feld und bei Titel/Untertitel gleich mit.
- Das ist die erste Stelle dieser Art im Projekt; entsprechend klein halten (eine Liste, ein
`disabled`, ein Hinweistext) und nicht zu einem Mechanismus ausbauen.
- [ ] **E2 — `app/robots.ts`**
- `Disallow` für `/admin`, `/install`, `/api`.
- `seo.noIndex: true` → `Disallow: /` für alle Agents.
- `sitemap:`-Verweis nur, wenn `siteUrl` gesetzt ist.
- [ ] **E3 — `app/sitemap.ts`**
- Gibt `[]` zurück, wenn `siteUrl` leer ist oder `seo.noIndex` gilt — kein halber Zustand.
- Enthalten: `/`, `/about` (nur wenn `aboutEnabled`), `/map` (nur wenn `config.map`),
`/journal` und je Eintrag `/journal/<slug>`, Subpages und Alben.
- **Ausschlussliste — der eigentliche Inhalt dieser Task:**
| Ausgeschlossen | Quelle |
| ----------------------------------------------------- | ------------------------------------------------------------- |
| Subpage mit `password` | `SubpageConfig.password` |
| Subpage mit `hidden: true` | `SubpageConfig.hidden` |
| Subpage mit `enabled: false` | `SubpageConfig.enabled` |
| Album mit eigenem Passwort | `config.albumPasswords` |
| Album unterhalb einer geschützten/versteckten Subpage | Vererbung, siehe unten |
| Journal-Draft | `frontmatter.draft` |
| Journal-Eintrag mit Passwort | Frontmatter |
| `/impressum` | trägt bereits `robots: noindex` (`app/impressum/page.tsx:14`) |
- **Vererbung nicht vergessen:** Ein öffentliches Album unter einer passwortgeschützten Subpage
ist über `/<subpage>/<album>` nicht erreichbar. Die Prüfung muss den Pfad entlanglaufen, nicht
nur das Album ansehen.
- `lastModified` aus `album.updatedAt` bzw. dem Journal-Datum.
- [ ] **E4 — JSON-LD**
- `Person` bzw. `ProfilePage` auf `/about`, gespeist aus dem Frontmatter von `content/about.md`.
- `ImageObject` mit `creator` und optional `license` auf Album-Detailseiten.
- **CSP-Falle:** `proxy.ts` setzt eine CSP mit Per-Request-Nonce und reicht sie über den
`x-nonce`-Header an die Seiten. Ein `<script type="application/ld+json">` **muss** diese Nonce
tragen, sonst blockt der Browser es still. Im Browser mit gesetzter CSP verifizieren, nicht nur
im Markup nachsehen.
- `license` als optionales Feld in `settings.yaml` (`seo.license`), leer = weglassen.
- [ ] **E5 — Tests**
- `lib/__tests__/sitemap.test.ts`: **tabellengetrieben über die Ausschlusskategorien**, nicht als
Einzelfälle — dann schlägt der Test auch bei einer künftig hinzugefügten Sichtbarkeitsregel an.
Vorbild: `app/api/admin/__tests__/admin-guards.test.ts`. Zusätzlich: leere Sitemap bei fehlender
`siteUrl`; `noIndex` unterdrückt alles.
- `lib/__tests__/site-url.test.ts`: Präzedenz `SITE_URL` > `settings.url` > leer; trailing Slash
wird entfernt; ungültige URL führt nicht zum Absturz, sondern zu „nicht gesetzt".
- `app/api/admin/__tests__/settings-env-lock.test.ts`: `envLocked` enthält `url` genau dann, wenn
`SITE_URL` gesetzt ist — und die Antwort enthält **nirgends den Env-Wert selbst**.
- Erwartet: **+18 bis +24 Tests** (461–467 gesamt).
**Verifikation:**
1. **Ohne** `SITE_URL`: Im Admin unter SEO die Site URL eintragen, speichern, dann `/sitemap.xml`
und `/robots.txt` abrufen — beide müssen ohne Neustart die neue Domain führen (`invalidateConfigCache()`
läuft beim Speichern bereits). Feld leeren → Sitemap wird wieder leer, Hinweistext erscheint.
2. Ungültige Eingaben gegenprüfen: `folio.example.com` ohne Schema und `https://` allein müssen am
Feld abgewiesen werden, nicht erst beim Rendern der Sitemap auffallen.
3. **Mit** gesetztem `SITE_URL`: Das Feld muss deaktiviert sein und die Herkunft nennen — dasselbe
für Titel/Untertitel bei gesetztem `SITE_TITLE`.
4. Eine passwortgeschützte Subpage anlegen und prüfen, dass weder ihr Slug noch ihre Alben in der
Sitemap auftauchen.
5. JSON-LD im Rich-Results-Test von Google gegenprüfen; Browser-Konsole auf CSP-Fehler ansehen.
**Commits:** `feat(seo): add a site URL setting, editable in the admin panel` ·
`feat(seo): add robots.txt, sitemap.xml and JSON-LD`
---
## Task B2 — 1:1-Zoom im Lightbox
**Aufwand:** S
### Befund
Der Lightbox rendert `current.previewUrl` (`components/Lightbox.tsx:237`), während sein Dateikopf
„Full-resolution image display" verspricht (`:5`). `imageUrl()` steht per Default auf `'preview'`
(`lib/urls.ts:29`), und **keine Stelle in `app/`, `components/` oder `lib/urls.ts` fordert
`'original'` an** — die oberste Stufe aus `lib/imageSize.ts` ist im Frontend unerreichbar.
Als Default ist das richtig. Für Schärfebeurteilung fehlt der Weg dorthin.
### Schritte
- [ ] **B2.1 — `originalUrl` durchreichen**
- `originalUrl?: string` in `PhotoItem` (`app/[...path]/PhotoGrid.tsx:18-33`) — **optional**,
damit `JournalStudio.tsx:616-617` (Admin-Thumbnails über eine andere Route) unverändert bleibt.
- Befüllen an den zwei öffentlichen Stellen: `app/[...path]/page.tsx:117-118` und
`app/journal/[slug]/page.tsx:179-180`, jeweils `imageUrl(a.id, 'original')`.
- Fehlt `originalUrl`, wird der Zoom-Knopf nicht gerendert. Kein Fallback auf Preview — ein Zoom,
der auf ein Preview zoomt, ist genau die Verwechslung, die es zu beheben gilt.
- [ ] **B2.2 — Zoom-Geometrie als reines Modul** (`lib/zoom.ts`)
- `clampPan(offset, scale, viewport, natural)` → begrenzt den Bildausschnitt auf die Bildkanten.
- `zoomAt(point, scale, offset, factor)` → zoomt auf den Cursor/Pinch-Mittelpunkt statt auf die
Bildmitte. Das ist der Unterschied zwischen brauchbar und ärgerlich.
- Pure Funktionen, keine DOM-Abhängigkeit — direkt unit-testbar.
- [ ] **B2.3 — Verdrahtung im Lightbox**
- Zustand: `zoomed` (bool), `scale`, `offset`. Auslöser: Doppelklick/Doppeltipp, Pinch, Taste `z`;
zusätzlich ein sichtbarer Knopf neben dem Info-Toggle.
- **Das Original wird erst bei dieser Geste geladen**, nie vorher. Die bestehende
Nachbar-Vorladung (`Lightbox.tsx:96-105`) bleibt auf `previewUrl` und wird **nicht** angefasst —
sonst zieht sich jeder Besucher unbemerkt Originale.
- Ladeindikator, solange das Original unterwegs ist; das Preview bleibt so lange skaliert stehen.
- Verlassen: `Esc` (erste Stufe verlässt den Zoom, nicht den Lightbox), Doppelklick, Knopf.
- Zurücksetzen bei jedem Bildwechsel — sonst hängt der Zoomfaktor des vorherigen Bildes am nächsten.
- **Konflikte prüfen:** `useSwipe` (Pinch/Pan darf nicht als Wisch durchgehen) und
`AssetProtection` (`dragstart` ist global unterdrückt — Pan muss über Pointer-Events laufen,
nicht über natives Dragging).
- Tastatur: `+` / `-` / `0`, `aria-label` am Knopf, Zoomzustand über `aria-pressed`.
- [ ] **B2.4 — Kommentar korrigieren**
- `components/Lightbox.tsx:5` beschreibt eine Absicht, kein Verhalten. Auf „Preview-tier image,
with opt-in 1:1 zoom to the original" ändern.
- [ ] **B2.5 — Tests**
- `lib/__tests__/zoom.test.ts`: Clamping an allen vier Kanten, Zoom auf Punkt, Rückkehr auf
`scale: 1` zentriert das Bild wieder. Erwartet: **+10 bis +12 Tests**.
- `e2e/lightbox-zoom.spec.ts`: Zoom öffnet, lädt eine `?size=original`-URL (Request abfangen),
Pan bleibt in den Grenzen, Bildwechsel setzt zurück.
**Verifikation:** Netzwerk-Tab — beim Öffnen des Lightbox darf **keine** `size=original`-Anfrage
stehen, erst nach der Zoom-Geste. Auf einem Touchgerät gegenprüfen, dass Pinch nicht mehr als Wisch
zum nächsten Bild interpretiert wird.
**Commit:** `feat(lightbox): add opt-in 1:1 zoom against the original asset`
---
## Task B1 — Sequenzierung wie im Fotobuch
**Aufwand:** M — der größte Eingriff hier, und der einzige mit UI-Anteil im Admin.
### Befund
`PhotoGrid` bildet die Assets flach auf ein einziges `.photo-grid`-Element ab
(`app/[...path]/PhotoGrid.tsx:132-243`); das Layout gilt für alle gleich. `assetOrder`
(`lib/config/schema.ts:224`) bestimmt nur die Reihenfolge. Die Essay-Ansicht kennt `fullbleed`,
`wide` und Paare bereits (`lib/journal.ts`) — das Album nicht.
### Designentscheidung: Hinweise leben in `assetOrder`
Eine zweite, parallele Liste (`sequence:`) würde die Reihenfolge doppelt führen und damit
zwangsläufig auseinanderlaufen. Stattdessen wird `assetOrder` erweitert — Sequenzierung setzt
ohnehin `sort: manual` voraus:
```yaml
- 'album-uuid':
sort: manual
assetOrder:
- 'asset-opening': fullbleed # allein, volle Breite
- 'asset-a': pair # diese beiden nebeneinander,
- 'asset-b': pair # auf gleiche Höhe gebracht
- 'asset-c' # unverändert im normalen Raster
- break # bewusste Lücke
```
`deriveGallery()` erzeugt daraus **zwei** Strukturen: `albumManualOrders` bleibt exakt wie heute
eine reine UUID-Liste (`break` herausgefiltert), damit `lib/albumSort.ts` unverändert
weiterfunktioniert — und neu `albumSequences: Record<albumId, SequenceEntry[]>` mit den Hinweisen.
### Die Invariante, an der dieses Feature scheitern kann
Der Lightbox-Index und die Permalinks `#photo-N` sind **positional** über `displayedAssets`
(`app/[...path]/PhotoGrid.tsx:51-107`, `openLightbox(index)`). Blockbildung darf deshalb
**ausschließlich gruppieren, niemals umsortieren, nichts auslassen und nichts doppeln**. Wird das
verletzt, öffnet ein Klick das falsche Foto und jeder geteilte Permalink zeigt woanders hin — ein
Fehler, der beim Entwickeln kaum auffällt und beim Kunden sofort.
### Schritte
- [ ] **B1.1 — Schema & Parser**
- `SequenceHint = 'fullbleed' | 'pair' | 'break'`; `assetOrder?: Array<string | Record<string, string> | 'break'>`
in `AlbumEntryObject`.
- In `deriveGallery()` (`lib/config/index.ts:162-236`, direkt neben der `coverPosition`-Validierung):
unbekannte Hinweise **werfen** mit nennender Meldung — dieselbe Linie wie beim `sort`-Wert, wo
ein Tippfehler bewusst nicht still durchgeht.
- `albumManualOrders` bleibt unverändert UUID-only; `albumSequences` kommt dazu (beides in
`AppConfig`, `GalleryDerivation` und den drei Rückgabestellen in `lib/config/index.ts`).
- Ein einzelnes `pair` ohne Partner fällt auf normales Verhalten zurück, statt zu werfen —
beim Umsortieren im Admin ist dieser Zustand ein normaler Zwischenschritt.
- [ ] **B1.2 — Blockbildung als reines Modul** (`lib/sequence.ts`)
- `buildSequenceBlocks(assets, hints)` → `Array<{ kind: 'grid' | 'pair' | 'fullbleed' | 'break', items: Array<{ asset, index }> }>`.
- **Jedes Item trägt seinen ursprünglichen Index mit.** Das ist die technische Absicherung der
Invariante oben.
- Aufeinanderfolgende Assets ohne Hinweis landen in einem gemeinsamen `grid`-Block, damit das
bestehende Layout (Masonry, Justified …) darin unverändert greift.
- [ ] **B1.3 — Rendering**
- `PhotoGrid` rendert Blöcke statt einer flachen Liste; `openLightbox(item.index)` statt
`openLightbox(index)`.
- CSS: `.photo-sequence__pair` (zwei Spalten, auf gleiche Höhe normiert über die vorhandenen
Aspect-Ratios), `.photo-sequence__fullbleed`, `.photo-sequence__break`. Mobil kollabieren Paare
auf eine Spalte.
- Ohne Hinweise entsteht genau ein `grid`-Block — das Markup bleibt dann identisch zu heute.
**Das ist die Rückfallgarantie und gehört als Test festgeschrieben.**
- [ ] **B1.4 — Admin**
- `AssetOrderEditor.tsx` bekommt pro Foto eine Hinweis-Auswahl und einen Trenner-Einfügen-Knopf.
- Eine Andeutung der Blöcke in der Sortierliste (Paare zusammengefasst, Trenner als Linie).
**Ohne diese Vorschau ist das Feature im Admin unbenutzbar** — das ist die halbe Arbeit an der
Task, nicht das YAML-Schema.
- [ ] **B1.5 — Tests**
- `lib/__tests__/sequence.test.ts`: Indizes bleiben lückenlos und in Reihenfolge (die Invariante,
als expliziter Test über ein Album mit allen Hinweisarten); leere Hinweise → genau ein Block;
verwaistes `pair`; `break` am Anfang und am Ende; Hinweis auf ein Asset, das gar nicht im Album
ist.
- `lib/__tests__/config-sequence.test.ts`: Parser akzeptiert die neue Form, wirft bei unbekanntem
Hinweis, `albumManualOrders` bleibt UUID-only.
- Erwartet: **+20 bis +25 Tests**.
- `e2e/sequence.spec.ts`: Klick auf das dritte Foto in einem sequenzierten Album öffnet das dritte
Foto; `#photo-3` landet auf demselben.
**Verifikation:** Ein Album mit `fullbleed`, einem Paar und einem `break` anlegen, im Browser
ansehen, jedes Foto anklicken und den Lightbox-Zähler gegen die visuelle Position prüfen. Ein Album
**ohne** Hinweise gegen `git stash` vergleichen: das Markup muss identisch sein.
**Commit:** `feat(album): sequence photos into pairs, spreads and breaks (EXPERIMENTAL)`
---
## Task C1 — Ortsauflösung pro Album
**Aufwand:** S
### Befund, präzise
Die Map ist besser gebaut als erwartet: Passwortgeschützte Alben werden **vor** der Aggregation
gefiltert (`app/api/map/route.ts:63-90`), und `lib/mapService.ts:5-11` hält Alben ausdrücklich
getrennt, damit genau das möglich ist. Die öffentliche EXIF-Route gibt Koordinaten gar nicht heraus
— die Map ist die einzige öffentliche GPS-Fläche.
Die Lücke betrifft **öffentliche** Alben: Die Marker-Position ist der Mittelwert der tatsächlichen
Koordinaten (`app/api/map/route.ts:97-104`). Bei einem Album von _einem_ Ort — Garten, Studio,
Wohnung eines Kunden, empfindlicher Naturstandort — ist dieser Mittelwert genau dieser Ort. Der
Marker heißt nach der Stadt und steht auf dem Grundstück.
### Zwei Entwurfsentscheidungen, die vorab feststehen müssen
**1. Keine Geodaten-Abhängigkeit.** Für „Stadtzentrum" bräuchte es einen Ortsdatensatz. Stattdessen
wird die Koordinate **gerastert**: `city` auf 0,05° (~5 km), `country` auf 1° (~110 km). Das ist
dependency-frei, erklärbar und ausreichend — 5 km trennen zuverlässig ein Grundstück von einem
Stadtgebiet.
**2. Marker fassen mehrere Alben zusammen.** Liegen an einer Stadt ein `exact`- und ein
`city`-Album, gibt es trotzdem nur einen Marker. Regel: **die strengste Stufe unter den
beitragenden Alben gewinnt.** Sicherheitsentscheidungen fallen in die vorsichtige Richtung, und die
Alternative — Marker aufspalten — würde über die Kartenansicht verraten, dass ein Album
zurückhaltender eingestellt ist.
### Schritte
- [ ] **C1.1 — Konfiguration**
- `location?: 'exact' | 'city' | 'country' | 'hidden'` in `AlbumEntryObject` und in
`SubpageObjectValue`/`SubpageConfig`.
- `albumLocationModes: Record<string, LocationMode>` in `AppConfig`, befüllt in `deriveGallery()`
neben `albumCoverPositions`; unbekannter Wert wirft.
- Präzedenz wie beim Grid: global (neu: `map.locationDefault` in `settings.yaml`, Default
`exact` = heutiges Verhalten) < Subpage < Album.
- [ ] **C1.2 — `lib/mapService.ts`**
- Alben mit `hidden` überspringen, bevor gebucketet wird — das ist die einzige Stufe, die gar
nicht erst in den Cache soll.
- `locationMode` auf `MapAlbumEntry` mitführen. **Nicht** hier schon runden: gerundete Werte
aufsummieren und danach mitteln ergibt wieder ungerundete Koordinaten.
- [ ] **C1.3 — `app/api/map/route.ts`**
- Strengste Stufe der sichtbaren Alben je Marker bestimmen.
- Mittelwert bilden wie heute, **danach** rastern: `city` → 0,05°, `country` → 1°, `exact` → unverändert.
- Bei `country` das Stadtlabel weglassen (die Popup-Überschrift wäre sonst genauer als die Position).
- Ist ein Marker nach dem Ausschluss leer, entfällt er — der Pfad existiert bereits (`allowedAlbums.length === 0`).
- [ ] **C1.4 — Admin & Doku**
- Auswahlfeld pro Album und pro Subpage im `PageBuilder`, Standard in `SettingsEditor`.
- `gallery.yaml.example` und `settings.yaml.example` ergänzen, mit einem Satz dazu, **warum** es
das gibt.
- [ ] **C1.5 — Tests** (`lib/__tests__/map-location.test.ts`)
- Rasterung: eine Koordinate auf einem Grundstück landet bei `city` sichtbar daneben, bleibt aber
in derselben Stadt; `exact` verändert nichts.
- Strengste Stufe gewinnt (alle sechs Paarungen).
- `hidden` erscheint in keiner Ausgabe — auch dann nicht, wenn ein anderes Album dieselbe Stadt beisteuert.
- Präzedenz global < Subpage < Album.
- Erwartet: **+14 bis +18 Tests**.
**Verifikation:** Ein Album mit GPS-Daten von einem einzigen Ort anlegen, `/api/map` einmal mit
`exact` und einmal mit `city` abrufen und die Koordinaten vergleichen; auf der Karte gegenprüfen,
dass der Marker die Stadt trifft, aber nicht mehr die Adresse. Zusätzlich sicherstellen, dass ein
`hidden`-Album auch nach einem Cache-Durchlauf nicht auftaucht (`POST /api/admin/reload`).
**Commit:** `feat(map): make location precision configurable per album`
---
## Reihenfolge und Zusammenhänge
```
E (Auffindbarkeit) ── unabhängig, bringt die Site-URL, die später auch ein Feed bräuchte
B2 (Zoom) ── unabhängig, erste Stelle die Originale ausliefert
B1 (Sequenzierung) ── größter Eingriff, berührt die Lightbox-Index-Invariante
C1 (Ortsauflösung) ── unabhängig, Datenschutz
↑ B1 und C1 fassen beide lib/config/{schema,index}.ts an,
aber verschiedene Felder — nacheinander mergen, nicht parallel
```
Vier PRs, in dieser Reihenfolge. E und B2 sind jeweils an einem Tag abgeschlossen und liefern
sofort etwas Sichtbares; B1 braucht am meisten Sorgfalt und sollte nicht unter Zeitdruck; C1 ist
klein, aber sicherheitsrelevant und verdient einen eigenen, ruhigen Review.
**Erwartete Testsumme am Ende:** 443 → etwa **516**.
## Was dieser Plan bewusst nicht enthält
Aus dem Brainstorm bleiben offen und sind hier **nicht** eingeplant: A1 (Client-Links), A2
(Anfrage-Formular), A3 (Proofing-Notizen), B3 (Farbmanagement-Messung), C2 (terminierte
Veröffentlichung) und D (RSS-Feed). Falls C2 und D später kommen: Beide brauchen dieselbe
Sichtbarkeitsprüfung, die in **E3** entsteht. Sie sollte dort so geschnitten werden, dass ein
zweiter Aufrufer sie wiederverwenden kann, statt sie zu kopieren.

View File

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

View File

@@ -0,0 +1,64 @@
import { test, expect } from '@playwright/test';
/**
* `f` is only worth having if it reaches the real Fullscreen API — a CSS
* overlay already covered the viewport before this, and the whole point is the
* browser chrome that CSS cannot touch. So these assert on
* `document.fullscreenElement`, not on a class name.
*/
test.describe('Lightbox fullscreen', () => {
const GRID_URL = '/deutschland/kloster-chorin';
/** Open the first photo and wait for the viewer. */
async function openLightbox(page: import('@playwright/test').Page) {
await page.goto(GRID_URL);
const item = page.locator('.photo-grid .photo-grid__item').first();
await item.scrollIntoViewIfNeeded();
await item.click();
await expect(page.getByRole('dialog')).toBeVisible();
}
const fullscreenTag = (page: import('@playwright/test').Page) =>
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
test('f enters fullscreen and f leaves it again', async ({ page }) => {
await openLightbox(page);
expect(await fullscreenTag(page)).toBeNull();
await page.keyboard.press('f');
await expect.poll(() => fullscreenTag(page)).toBe('DIV');
await page.keyboard.press('f');
await expect.poll(() => fullscreenTag(page)).toBeNull();
});
test('Esc leaves fullscreen before it closes the viewer', async ({ page }) => {
await openLightbox(page);
await page.keyboard.press('f');
await expect.poll(() => fullscreenTag(page)).toBe('DIV');
await page.keyboard.press('Escape');
await expect.poll(() => fullscreenTag(page)).toBeNull();
// One layer at a time: the viewer is still open.
await expect(page.getByRole('dialog')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toHaveCount(0);
});
test('closing the viewer does not strand the page in fullscreen', async ({ page }) => {
await openLightbox(page);
await page.keyboard.press('f');
await expect.poll(() => fullscreenTag(page)).toBe('DIV');
await page.getByRole('button', { name: /close/i }).click();
await expect(page.getByRole('dialog')).toHaveCount(0);
await expect.poll(() => fullscreenTag(page)).toBeNull();
});
test('the shortcut panel advertises the key', async ({ page }) => {
await openLightbox(page);
await page.keyboard.press('?');
await expect(page.getByText(/fullscreen|vollbild/i).first()).toBeVisible();
});
});

View File

@@ -0,0 +1,55 @@
import { test, expect } from '@playwright/test';
/**
* The shortcut panel is deliberately unadvertised: no button, no first-run
* nudge. These pin both halves of that — the keys work, and nothing is on
* screen until one of them is pressed.
*/
test.describe('Lightbox shortcuts', () => {
const GRID_URL = '/deutschland/kloster-chorin';
async function openLightbox(page: import('@playwright/test').Page) {
await page.goto(GRID_URL);
const item = page.locator('.photo-grid .photo-grid__item').first();
await item.scrollIntoViewIfNeeded();
await item.click();
await expect(page.getByRole('dialog')).toBeVisible();
}
const panel = (page: import('@playwright/test').Page) => page.locator('#lightbox-shortcuts');
test('nothing advertises the panel', async ({ page }) => {
await openLightbox(page);
await expect(panel(page)).toHaveCount(0);
// The old trigger and its one-time nudge are gone for good.
await expect(page.getByRole('button', { name: /keyboard shortcuts/i })).toHaveCount(0);
await expect(page.getByText(/press \? for keyboard shortcuts/i)).toHaveCount(0);
});
test('? opens it and closes it again', async ({ page }) => {
await openLightbox(page);
await page.keyboard.press('?');
await expect(panel(page)).toBeVisible();
await page.keyboard.press('?');
await expect(panel(page)).toHaveCount(0);
});
test('h opens it too', async ({ page }) => {
await openLightbox(page);
await page.keyboard.press('h');
await expect(panel(page)).toBeVisible();
});
test('Esc closes the panel before the viewer', async ({ page }) => {
await openLightbox(page);
await page.keyboard.press('h');
await expect(panel(page)).toBeVisible();
await page.keyboard.press('Escape');
await expect(panel(page)).toHaveCount(0);
await expect(page.getByRole('dialog')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toHaveCount(0);
});
});

25
instrumentation.ts Normal file
View File

@@ -0,0 +1,25 @@
/**
* Runs once per server start, before the first request is handled.
*
* Its only job today: print the one-time setup token of an unconfigured
* deployment. The token used to be minted lazily by /install and the setup
* screen, so an operator who went straight to /admin — which is reachable as
* soon as ADMIN_PASSWORD is set — found nothing in `docker logs` and no way to
* reach the wizard (#507).
*/
export async function register() {
// `register` also runs on the edge runtime, where `node:fs` does not exist.
if (process.env.NEXT_RUNTIME !== 'nodejs') return;
// Imported dynamically: a static import would pull `node:fs` into the edge
// bundle regardless of the guard above.
const { isInstalled, getSetupToken } = await import('./lib/install');
try {
if (!isInstalled()) getSetupToken();
} catch (error) {
// A read-only or missing content/ must not stop the server from booting —
// the setup screen reports that problem far better than a crash loop.
console.warn('[Folio] Could not prepare the first-run setup token:', error);
}
}

View File

@@ -71,6 +71,23 @@ describe('admin session tokens', () => {
expect(verifyAdminToken(createAdminToken())).toBe(true);
});
/**
* A cookie far longer than any token we issue is rejected before it is
* parsed. The decode below the signature check was never reachable without
* the key, so this is boundary hygiene rather than a fix for an exploitable
* exhaustion — but a bound on untrusted input costs nothing (#505).
*/
it('rejects an implausibly long token without parsing it', async () => {
const { createAdminToken, verifyAdminToken } = await loadAuth();
const valid = createAdminToken();
expect(valid.length).toBeLessThan(512);
expect(verifyAdminToken(valid)).toBe(true);
expect(verifyAdminToken('a'.repeat(513))).toBe(false);
// Padding a genuine token past the bound must not sneak through either.
expect(verifyAdminToken(valid + 'a'.repeat(512))).toBe(false);
});
it('rejects a token forged with the old hardcoded dev fallback secret', async () => {
const { verifyAdminToken } = await loadAuth();
// The exact key an attacker could derive from the public source before the fix.

View File

@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest';
import { albumMetaDetail } from '../albumMeta';
import type { ImmichAlbum, ImmichAsset, ImmichExifInfo } from '../immich';
function asset(fileCreatedAt: string, exif?: Partial<ImmichExifInfo>): ImmichAsset {
return {
id: fileCreatedAt,
type: 'IMAGE',
originalFileName: 'a.jpg',
originalMimeType: 'image/jpeg',
thumbhash: null,
fileCreatedAt,
isTrashed: false,
...(exif ? { exifInfo: exif as ImmichExifInfo } : {}),
};
}
function album(assets: ImmichAsset[]): ImmichAlbum {
return {
id: 'album',
slug: 'album',
albumName: 'Album',
description: '',
albumThumbnailAssetId: null,
assetCount: assets.length,
assets,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
order: 'asc',
};
}
/**
* Both values used to come from the first asset that happened to carry EXIF, so
* a mixed album advertised whichever camera sorted first — and changing the
* album's sort mode changed the claim (#509).
*/
describe('albumMetaDetail', () => {
it('names the camera when the whole album agrees', () => {
const a = album([
asset('2026-05-01T10:00:00.000Z', { model: 'X-T5', lensModel: '35mm F1.4' }),
asset('2026-05-02T10:00:00.000Z', { model: 'X-T5', lensModel: '35mm F1.4' }),
]);
expect(albumMetaDetail(a, true).gear).toBe('X-T5 · 35mm F1.4');
});
it('says nothing about gear when the album mixes bodies', () => {
const a = album([
asset('2026-05-01T10:00:00.000Z', { model: 'X-T5' }),
asset('2026-05-02T10:00:00.000Z', { model: 'M11' }),
]);
expect(albumMetaDetail(a, true).gear).toBeUndefined();
});
it('does not change its claim when the album order changes', () => {
const shot = [
asset('2026-05-01T10:00:00.000Z', { model: 'X-T5' }),
asset('2026-05-02T10:00:00.000Z', { model: 'M11' }),
];
expect(albumMetaDetail(album(shot), true)).toEqual(
albumMetaDetail(album([...shot].reverse()), true),
);
});
it('keeps a shared camera even when only some photos carry EXIF', () => {
const a = album([
asset('2026-05-01T10:00:00.000Z', { model: 'X-T5' }),
asset('2026-05-02T10:00:00.000Z'),
]);
expect(albumMetaDetail(a, true).gear).toBe('X-T5');
});
it('omits the gear entirely when the camera group is off', () => {
const a = album([asset('2026-05-01T10:00:00.000Z', { model: 'X-T5' })]);
expect(albumMetaDetail(a, false).gear).toBeUndefined();
expect(albumMetaDetail(a, false).date).toBe('05/2026');
});
it('shows one month when the album was shot in one month', () => {
const a = album([asset('2026-05-01T10:00:00.000Z'), asset('2026-05-28T10:00:00.000Z')]);
expect(albumMetaDetail(a, true).date).toBe('05/2026');
});
it('spans a range when the album does', () => {
const a = album([asset('2026-05-01T10:00:00.000Z'), asset('2025-11-02T10:00:00.000Z')]);
expect(albumMetaDetail(a, true).date).toBe('11/2025 – 05/2026');
});
it('prefers the capture time over the upload time', () => {
const a = album([
asset('2026-08-01T10:00:00.000Z', { dateTimeOriginal: '2019-03-04T10:00:00.000Z' }),
]);
expect(albumMetaDetail(a, true).date).toBe('03/2019');
});
it('returns nothing for an empty album', () => {
expect(albumMetaDetail(album([]), true)).toEqual({});
});
});

View File

@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { resolveColorMode, COLOR_MODES } from '../config/schema';
/**
* `mode:` decides what a first-time visitor lands on. The toggle used to
* hardcode dark, so an operator who preferred light had no way to give their
* visitors anything else (#512).
*/
describe('resolveColorMode', () => {
it('defaults to dark, as the site always has', () => {
expect(resolveColorMode(undefined)).toBe('dark');
});
it('accepts each supported mode', () => {
for (const mode of COLOR_MODES) {
expect(resolveColorMode(mode)).toBe(mode);
}
});
/** A typo must not silently produce an invalid data-theme on <html>. */
it('falls back to dark on anything else', () => {
expect(resolveColorMode('Light')).toBe('dark');
expect(resolveColorMode('system')).toBe('dark');
expect(resolveColorMode('')).toBe('dark');
});
});

View File

@@ -97,16 +97,20 @@ describe('gallery.yaml per-album heroImage', () => {
expect(call?.[1]).toContain(ALBUM_ID);
});
// The substitution the wiring above depends on: validateUuid never throws, so
// a bad ID degrades to a 404 image rather than taking the whole page down.
it('real validateUuid warns and substitutes rather than throwing', async () => {
/**
* validateUuid never throws — a bad ID must not take the page down. It used to
* substitute the all-zeros UUID, which then travelled on as a real asset ID and
* came back 400 from Immich, while the log claimed it had been ignored (#517).
* Null lets the caller drop the entry, which is what "ignored" should mean.
*/
it('real validateUuid returns null and names the context', async () => {
const actual =
await vi.importActual<typeof import('@/lib/config/parser')>('@/lib/config/parser');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = actual.validateUuid('not-a-uuid', 'gallery.yaml heroImage');
expect(result).toBe('00000000-0000-0000-0000-000000000000');
expect(result).toBeNull();
expect(warn).toHaveBeenCalled();
expect(String(warn.mock.calls[0][0])).toContain('gallery.yaml heroImage');
warn.mockRestore();

View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
// Imported from the schema module directly: it is client-safe (no `fs`), and
// going through '@/lib/config' would pull the filesystem loader into the test.
import {
buildCoverGridVars,
COVER_GRID_COLUMNS_MAX,
COVER_GRID_GAP_MAX,
} from '@/lib/config/schema';
describe('buildCoverGridVars', () => {
it('falls back to the site-wide column count', () => {
expect(buildCoverGridVars(undefined, 3)).toEqual({
'--subpage-columns': 3,
'--subpage-columns-tablet': 2,
});
});
it('lets a per-subpage column count win over the site-wide one', () => {
expect(buildCoverGridVars({ columns: 4 }, 3)['--subpage-columns']).toBe(4);
});
it('clamps a hand-edited column count into range', () => {
// `repeat(0, 1fr)` would collapse the grid, `repeat(99, 1fr)` shred it.
expect(buildCoverGridVars({ columns: 0 }, 3)['--subpage-columns']).toBe(1);
expect(buildCoverGridVars({ columns: 99 }, 3)['--subpage-columns']).toBe(
COVER_GRID_COLUMNS_MAX,
);
});
it('falls back to the minimum for a non-numeric column count', () => {
// YAML happily yields NaN for `columns: none`, and repeat(NaN, 1fr) drops
// the whole declaration.
expect(buildCoverGridVars({ columns: Number.NaN }, 3)['--subpage-columns']).toBe(1);
});
it('never puts more than two covers per row on tablet widths', () => {
expect(buildCoverGridVars({ columns: 1 }, 3)['--subpage-columns-tablet']).toBe(1);
expect(buildCoverGridVars({ columns: 2 }, 3)['--subpage-columns-tablet']).toBe(2);
expect(buildCoverGridVars({ columns: 6 }, 3)['--subpage-columns-tablet']).toBe(2);
});
it('omits the gap unless the subpage sets one', () => {
// Without the variable the theme preset's own --subpage-gap stands, which
// is the whole point of not letting the global grid.gap through.
expect(buildCoverGridVars({ columns: 4 }, 3)).not.toHaveProperty('--subpage-gap');
expect(buildCoverGridVars(undefined, 3)).not.toHaveProperty('--subpage-gap');
});
it('emits an explicit gap, including zero', () => {
expect(buildCoverGridVars({ gap: 24 }, 3)['--subpage-gap']).toBe('24px');
// 0 is a deliberate choice (flush covers), not an absent value.
expect(buildCoverGridVars({ gap: 0 }, 3)['--subpage-gap']).toBe('0px');
});
it('clamps the gap on both ends', () => {
expect(buildCoverGridVars({ gap: -10 }, 3)['--subpage-gap']).toBe('0px');
expect(buildCoverGridVars({ gap: 5000 }, 3)['--subpage-gap']).toBe(`${COVER_GRID_GAP_MAX}px`);
});
});

View File

@@ -0,0 +1,218 @@
import { describe, it, expect } from 'vitest';
import {
checkAuthSecret,
checkProxyHops,
countForwardedHops,
checkAlbumIds,
checkAlbumsShared,
checkPasswords,
checkWritable,
checkImmichCalls,
worstLevel,
} from '../admin/doctor';
describe('checkAuthSecret', () => {
it('accepts a long secret', () => {
expect(checkAuthSecret('a'.repeat(64)).level).toBe('ok');
});
it('warns about a short one', () => {
const f = checkAuthSecret('short');
expect(f.level).toBe('warn');
expect(f.title).toContain('5 characters');
});
it('errors when absent', () => {
expect(checkAuthSecret(undefined).level).toBe('error');
});
/** The report is shown in a browser and pasted into issues. */
it('never repeats the secret itself', () => {
const secret = 'super-secret-value-nobody-should-see';
const f = checkAuthSecret(secret);
expect(JSON.stringify(f)).not.toContain(secret);
});
});
describe('countForwardedHops', () => {
it('counts the entries of a chain', () => {
expect(countForwardedHops('203.0.113.1, 10.0.0.1')).toBe(2);
expect(countForwardedHops('203.0.113.1')).toBe(1);
});
it('treats an absent or empty header as no proxy', () => {
expect(countForwardedHops(null)).toBe(0);
expect(countForwardedHops('')).toBe(0);
expect(countForwardedHops(' , ')).toBe(0);
});
});
/**
* Both directions fail silently today: too high and the lookup runs off the end
* of the chain, too low and the IP comes from a header the client writes.
*/
describe('checkProxyHops', () => {
it('is happy when the value matches what arrived', () => {
expect(checkProxyHops(1, 1, true).level).toBe('ok');
expect(checkProxyHops(0, 0, false).level).toBe('ok');
});
it('warns when a chain arrived but no hops are configured', () => {
const f = checkProxyHops(0, 2, true);
expect(f.level).toBe('warn');
expect(f.detail).toContain('spoofing');
});
it('warns when more hops are configured than arrived', () => {
expect(checkProxyHops(3, 2, true).level).toBe('warn');
});
/** An admin on the LAN legitimately sees no chain — say so, do not accuse. */
it('explains the direct-admin case instead of calling it broken', () => {
const f = checkProxyHops(1, 0, false);
expect(f.level).toBe('warn');
expect(f.detail).toContain('directly');
});
/**
* Next fills X-Forwarded-For with the socket address when a request arrives
* without one, so a lone entry is what a *direct* request looks like. Warning
* on that would fire on every deployment that has no proxy at all.
*/
it('does not mistake the header Next writes itself for a proxy', () => {
const f = checkProxyHops(0, 1, false);
expect(f.level).toBe('ok');
expect(f.title).toContain('No reverse proxy');
});
it('does trust a lone entry when a real proxy header backs it up', () => {
const f = checkProxyHops(0, 1, true);
expect(f.level).toBe('warn');
expect(f.detail).toContain('spoofing');
});
it('accepts one configured hop against one corroborated entry', () => {
expect(checkProxyHops(1, 1, true).level).toBe('ok');
});
});
describe('checkAlbumIds', () => {
const known = [
{ id: 'a', albumName: 'Japan' },
{ id: 'b', albumName: 'Poland' },
];
it('passes when every configured album resolves', () => {
expect(checkAlbumIds(['a', 'b'], known).level).toBe('ok');
});
it('names the ids Immich does not know', () => {
const f = checkAlbumIds(['a', 'ghost'], known);
expect(f.level).toBe('error');
expect(f.detail).toContain('ghost');
});
it('flags an empty gallery', () => {
expect(checkAlbumIds([], known).level).toBe('warn');
});
});
/** Never an error: publishing an unshared album has always worked (#515). */
describe('checkAlbumsShared', () => {
const known = [
{ id: 'a', albumName: 'Japan', shared: true },
{ id: 'b', albumName: 'Private trip', shared: false },
];
it('is quiet when everything published is shared', () => {
expect(checkAlbumsShared(['a'], known).level).toBe('ok');
});
it('warns and names the unshared album', () => {
const f = checkAlbumsShared(['a', 'b'], known);
expect(f.level).toBe('warn');
expect(f.detail).toContain('Private trip');
});
/** An older Immich may not report the flag; silence beats a false alarm. */
it('says nothing when the flag is absent', () => {
expect(checkAlbumsShared(['x'], [{ id: 'x', albumName: 'Unknown' }]).level).toBe('ok');
});
});
describe('checkPasswords', () => {
it('passes when everything is hashed', () => {
expect(checkPasswords([{ label: 'Site', value: 'scrypt:aa:bb' }]).level).toBe('ok');
});
it('warns about plaintext and names where it lives', () => {
const f = checkPasswords([
{ label: 'Site', value: 'scrypt:aa:bb' },
{ label: 'Album japan-2024', value: 'hunter2' },
]);
expect(f.level).toBe('warn');
expect(f.detail).toContain('Album japan-2024');
});
/** Bcrypt cannot be verified at all any more — that is broken, not sloppy. */
it('errors on a leftover bcrypt hash', () => {
expect(checkPasswords([{ label: 'Site', value: '$2b$10$abcdefghijklmno' }]).level).toBe(
'error',
);
});
it('never repeats a password', () => {
const f = checkPasswords([{ label: 'Album x', value: 'hunter2' }]);
expect(JSON.stringify(f)).not.toContain('hunter2');
});
});
describe('checkWritable', () => {
it('passes when nothing is blocked', () => {
expect(checkWritable([]).level).toBe('ok');
});
it('errors and names the paths', () => {
const f = checkWritable(['content/', 'content/.backups']);
expect(f.level).toBe('error');
expect(f.detail).toContain('content/.backups');
});
});
describe('checkImmichCalls', () => {
it('passes when every call came back', () => {
expect(
checkImmichCalls([
{ endpoint: '/server/ping', ok: true },
{ endpoint: '/albums', ok: true },
]).level,
).toBe('ok');
});
it('names the failing endpoint', () => {
const f = checkImmichCalls([
{ endpoint: '/server/ping', ok: true },
{ endpoint: '/albums', ok: false },
]);
expect(f.level).toBe('error');
expect(f.detail).toContain('/albums');
});
});
describe('worstLevel', () => {
it('reports the most severe finding', () => {
expect(worstLevel([{ id: 'a', level: 'ok', title: '', detail: '' }])).toBe('ok');
expect(
worstLevel([
{ id: 'a', level: 'ok', title: '', detail: '' },
{ id: 'b', level: 'warn', title: '', detail: '' },
]),
).toBe('warn');
expect(
worstLevel([
{ id: 'a', level: 'warn', title: '', detail: '' },
{ id: 'b', level: 'error', title: '', detail: '' },
]),
).toBe('error');
});
});

View File

@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import yaml from 'js-yaml';
import { deriveGallery } from '../config';
import type { GalleryYaml } from '../config';
/**
* The shipped .example files are the ones README, CONTRIBUTING and the setup
* screen all tell a new user to copy. `gallery.yaml.example` carried two
* `subpages:` keys, so js-yaml refused it and a brand-new installation answered
* HTTP 500 on every route — the first thing a user saw was a broken site.
*/
const CONTENT = path.join(process.cwd(), 'content');
function load(file: string): unknown {
return yaml.load(fs.readFileSync(path.join(CONTENT, file), 'utf8'));
}
describe('shipped example files', () => {
it('gallery.yaml.example is valid YAML', () => {
expect(() => load('gallery.yaml.example')).not.toThrow();
});
it('settings.yaml.example is valid YAML', () => {
expect(() => load('settings.yaml.example')).not.toThrow();
});
/** Parsing is not enough — it has to survive the config pipeline too. */
it('gallery.yaml.example produces a usable gallery structure', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const derived = deriveGallery(load('gallery.yaml.example') as GalleryYaml);
expect(derived.subpages.length).toBeGreaterThan(0);
// Every subpage needs a slug to be reachable at all.
for (const sp of derived.subpages) {
expect(sp.slug).toBeTruthy();
}
/*
* No albums, on purpose: the example's IDs are placeholders like
* "album-uuid-3", which are not UUIDs and are therefore dropped with a
* named warning each (#517). A copied example gives a site with page
* structure and no photos, which is honest — before, the placeholders
* became the all-zeros UUID and produced an error page.
*/
expect(derived.albums).toEqual([]);
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
});

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { formatCamera } from '../exif';
/**
* The lightbox printed make and model unconditionally, so a Leica read
* "LEICA CAMERA AG LEICA Q3" — long enough to run into its own label (#514).
*/
describe('formatCamera', () => {
it('drops a maker the model already names', () => {
expect(formatCamera('LEICA CAMERA AG', 'LEICA Q3')).toBe('LEICA Q3');
expect(formatCamera('NIKON CORPORATION', 'NIKON Z 6')).toBe('NIKON Z 6');
expect(formatCamera('Canon', 'Canon EOS R6')).toBe('Canon EOS R6');
});
it('keeps a maker the model does not name', () => {
expect(formatCamera('SONY', 'ILCE-7M4')).toBe('SONY ILCE-7M4');
expect(formatCamera('FUJIFILM', 'X-T5')).toBe('FUJIFILM X-T5');
});
/** The model's own separators count as word breaks. */
it('matches a brand across a hyphen', () => {
expect(formatCamera('OM Digital Solutions', 'OM-1')).toBe('OM-1');
expect(formatCamera('OM Digital Solutions', 'OM1')).toBe('OM1');
});
/** A short brand must not match inside an unrelated word. */
it('does not treat a substring as the brand', () => {
expect(formatCamera('OM Digital Solutions', 'COMPACT 3')).toBe(
'OM Digital Solutions COMPACT 3',
);
});
it('copes with either side missing', () => {
expect(formatCamera('SONY', undefined)).toBe('SONY');
expect(formatCamera(null, 'X-T5')).toBe('X-T5');
expect(formatCamera(' ', ' ')).toBe('');
});
});

View File

@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import { resolveExifDisplay, hasExifPanelContent } from '../config/schema';
describe('resolveExifDisplay', () => {
it('shows everything when nothing is configured', () => {
expect(resolveExifDisplay()).toEqual({
camera: true,
settings: true,
location: true,
caption: true,
onHover: true,
});
});
/**
* `exifOnHover: false` meant "no technical EXIF anywhere" long before the
* groups existed. Existing configs must keep that meaning.
*/
it('keeps the old exifOnHover switch meaning what it did', () => {
expect(resolveExifDisplay(undefined, false)).toMatchObject({
camera: false,
settings: false,
location: false,
onHover: false,
});
});
/**
* The description was served regardless of that switch, so a site that only
* ever set `exifOnHover: false` must not lose its captions on upgrade — it
* never had them switched off (#506).
*/
it('does not let the old switch turn captions off', () => {
expect(resolveExifDisplay(undefined, false).caption).toBe(true);
});
it('lets an explicit group win over the old switch', () => {
const exif = resolveExifDisplay({ camera: true, caption: false }, false);
expect(exif.camera).toBe(true);
expect(exif.caption).toBe(false);
// Unmentioned groups still follow the switch.
expect(exif.location).toBe(false);
// …and the switch still decides the hover overlay.
expect(exif.onHover).toBe(false);
});
it('can hide only the location and the caption', () => {
expect(resolveExifDisplay({ location: false, caption: false })).toEqual({
camera: true,
settings: true,
location: false,
caption: false,
onHover: true,
});
});
});
describe('hasExifPanelContent', () => {
it('is false only when every group is off', () => {
const off = resolveExifDisplay({
camera: false,
settings: false,
location: false,
caption: false,
});
expect(hasExifPanelContent(off)).toBe(false);
});
it('stays true while one group survives', () => {
const captionOnly = resolveExifDisplay({
camera: false,
settings: false,
location: false,
caption: true,
});
expect(hasExifPanelContent(captionOnly)).toBe(true);
});
/** The hover overlay is a separate switch and must not keep the panel alive. */
it('ignores the hover overlay switch', () => {
const hoverOnly = resolveExifDisplay(
{ camera: false, settings: false, location: false, caption: false },
true,
);
expect(hoverOnly.onHover).toBe(true);
expect(hasExifPanelContent(hoverOnly)).toBe(false);
});
});

110
lib/__tests__/i18n.test.ts Normal file
View File

@@ -0,0 +1,110 @@
/**
* i18n — locale resolution and dictionary parity.
*
* The parity walk is the point: `de.ts` is typed as `Dictionary`, so a *missing*
* key is already a compile error. What the type cannot catch is a key that was
* copied over but never translated, or a plural function that ignores its
* argument — both of which ship an English string on a German site.
*/
import { describe, it, expect } from 'vitest';
import { resolveLocale, getDictionary, dictionaryFor, SUPPORTED_LOCALES } from '../i18n';
import { en } from '../i18n/locales/en';
import { de } from '../i18n/locales/de';
describe('resolveLocale', () => {
it('accepts the supported locales', () => {
expect(resolveLocale('en')).toBe('en');
expect(resolveLocale('de')).toBe('de');
});
it('drops the region subtag', () => {
expect(resolveLocale('de-DE')).toBe('de');
expect(resolveLocale('de-AT')).toBe('de');
expect(resolveLocale('en_GB')).toBe('en');
});
it('is case- and whitespace-insensitive', () => {
expect(resolveLocale(' DE ')).toBe('de');
expect(resolveLocale('De-Ch')).toBe('de');
});
it('falls back to English for languages without a dictionary', () => {
expect(resolveLocale('fr')).toBe('en');
expect(resolveLocale('ja-JP')).toBe('en');
expect(resolveLocale('')).toBe('en');
expect(resolveLocale(undefined)).toBe('en');
expect(resolveLocale(null)).toBe('en');
});
});
describe('getDictionary', () => {
it('returns a dictionary for every supported locale', () => {
for (const locale of SUPPORTED_LOCALES) {
expect(getDictionary(locale)).toBeDefined();
}
});
it('dictionaryFor resolves and looks up in one step', () => {
expect(dictionaryFor('de-DE')).toBe(de);
expect(dictionaryFor('fr')).toBe(en);
});
});
/** Every leaf of a dictionary, as `['nav.home', 'Home']` pairs. */
function leaves(obj: object, prefix = ''): [string, unknown][] {
return Object.entries(obj).flatMap(([key, value]) => {
const path = prefix ? `${prefix}.${key}` : key;
return value !== null && typeof value === 'object'
? leaves(value, path)
: ([[path, value]] as [string, unknown][]);
});
}
/** Call a dictionary function with plausible arguments for its arity. */
function invoke(fn: (...args: never[]) => string): string {
const args = Array.from({ length: fn.length }, (_, i) => (i === 0 ? 2 : `arg${i}`));
return (fn as (...a: unknown[]) => string)(...args);
}
describe('dictionary parity', () => {
const enLeaves = leaves(en);
const deLeaves = new Map(leaves(de));
it('German covers every English key', () => {
expect([...deLeaves.keys()].sort()).toEqual(enLeaves.map(([k]) => k).sort());
});
it('matches value kinds — a string never stands in for an interpolator', () => {
for (const [path, value] of enLeaves) {
expect(typeof deLeaves.get(path), path).toBe(typeof value);
}
});
it('leaves no English string untranslated', () => {
// Proper nouns and units read the same in both languages; `coverAria` is
// pure interpolation with no words of its own.
const shared = new Set(['nav.journal', 'journal.title', 'lightbox.iso', 'subpage.coverAria']);
const identical = enLeaves
.filter(([path]) => !shared.has(path))
.filter(([path, value]) => {
const other = deLeaves.get(path);
if (typeof value === 'function') {
return invoke(other as (...a: never[]) => string) === invoke(value as never);
}
return other === value;
})
.map(([path]) => path);
expect(identical).toEqual([]);
});
it('plural helpers actually vary on their count', () => {
for (const dict of [en, de]) {
expect(dict.common.photos(1)).not.toBe(dict.common.photos(2));
expect(dict.common.albums(1)).not.toBe(dict.common.albums(2));
expect(dict.common.collections(1)).not.toBe(dict.common.collections(2));
}
});
});

View File

@@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as config from '../config';
import { cache } from '../cache';
/**
* A deployment with credentials in the environment but no content/gallery.yaml
* is `needsSetup`, yet Immich is perfectly reachable. The client used to gate
* every request on that flag, so it returned null before the fetch — the admin
* panel reported "Immich disconnected" and nothing at all was logged (#507).
*/
const state = {
apiUrl: 'http://immich.test/api',
apiKey: 'test-key',
needsSetup: true,
needsCredentials: false,
needsGallery: true,
albums: [] as string[],
};
vi.mock('../config', async () => {
const actual = await vi.importActual<typeof config>('../config');
return {
...actual,
getConfig: () => ({
immich: { apiUrl: state.apiUrl, apiKey: state.apiKey },
authSecret: 'test-auth-secret-32-chars-long-min',
albums: state.albums,
standaloneAlbums: [],
subpages: [],
albumOverrides: {},
albumDescriptions: {},
albumSortModes: {},
albumManualOrders: {},
cacheTtl: 60_000,
staleMaxAge: 86_400_000,
immichTimeoutMs: 15000,
needsSetup: state.needsSetup,
needsCredentials: state.needsCredentials,
needsGallery: state.needsGallery,
}),
};
});
global.fetch = vi.fn();
const { immich } = await import('../immich');
describe('ImmichClient — install state', () => {
const mockFetch = global.fetch as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
state.apiUrl = 'http://immich.test/api';
state.apiKey = 'test-key';
state.needsCredentials = false;
state.needsSetup = true;
state.needsGallery = true;
state.albums = [];
cache.clear();
});
it('pings Immich even without a gallery.yaml', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'application/json' },
json: async () => ({ res: 'pong' }),
});
await expect(immich.ping()).resolves.toBe(true);
expect(mockFetch).toHaveBeenCalledWith('http://immich.test/api/server/ping', expect.anything());
});
it('reports an unreachable Immich as disconnected rather than throwing', async () => {
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));
await expect(immich.ping()).resolves.toBe(false);
});
/**
* The setup config carries an empty allowlist, so the filtered list is [] no
* matter what Immich returned. Caching that made the gallery look empty until
* the server restarted, because the wizard's invalidateAll() runs in another
* bundle's module instance and never reached this cache.
*/
it('does not cache the empty album list while setup is unfinished', async () => {
const album = {
id: 'album-1',
albumName: 'Japan',
description: '',
albumThumbnailAssetId: null,
assetCount: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
};
const respond = () => ({
ok: true,
headers: { get: () => 'application/json' },
json: async () => [album],
});
mockFetch.mockResolvedValueOnce(respond());
await expect(immich.getAlbums()).resolves.toEqual([]);
// The wizard finishes: credentials unchanged, but now there is a gallery.
state.needsSetup = false;
state.needsGallery = false;
state.albums = ['album-1'];
mockFetch.mockResolvedValueOnce(respond());
const after = await immich.getAlbums();
expect(after.map((a) => a.id)).toEqual(['album-1']);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('makes no request at all when credentials are absent', async () => {
state.apiUrl = '';
state.apiKey = '';
state.needsCredentials = true;
await expect(immich.ping()).resolves.toBe(false);
expect(mockFetch).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import yaml from 'js-yaml';
// A getter, not a value: the mock factory runs once, but each test installs
// into a fresh temp directory.
vi.mock('@/lib/env', () => ({
env: {
get INSTALL_CONTENT_DIR() {
return process.env.INSTALL_CONTENT_DIR;
},
},
}));
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'folio-install-'));
process.env.INSTALL_CONTENT_DIR = dir;
vi.resetModules();
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
delete process.env.INSTALL_CONTENT_DIR;
});
async function install(input: Record<string, unknown>) {
const { completeInstall } = await import('@/lib/install');
await completeInstall({
apiUrl: 'http://immich.test',
apiKey: 'key',
theme: 'studio-modern',
...input,
} as Parameters<typeof completeInstall>[0]);
return yaml.load(fs.readFileSync(path.join(dir, 'gallery.yaml'), 'utf8')) as {
hero: string[];
albums: string[];
};
}
/**
* The wizard always wrote `hero: []`, so the first thing a finished portfolio
* showed was a list of album names and no photograph (#518).
*/
describe('completeInstall', () => {
it('writes the seeded hero assets', async () => {
const gallery = await install({
albums: ['11111111-1111-1111-1111-111111111111'],
hero: ['22222222-2222-2222-2222-222222222222'],
});
expect(gallery.hero).toEqual(['22222222-2222-2222-2222-222222222222']);
expect(gallery.albums).toEqual(['11111111-1111-1111-1111-111111111111']);
});
/** Seeding is a nicety; an install without it is still a valid install. */
it('writes an empty hero when none could be found', async () => {
const gallery = await install({ albums: [], hero: [] });
expect(gallery.hero).toEqual([]);
});
it('tolerates the field being omitted entirely', async () => {
const gallery = await install({ albums: [] });
expect(gallery.hero).toEqual([]);
});
});

View File

@@ -1,7 +1,22 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
/*
* The gate is mocked rather than driven through a real config: proxy() now
* consults it on every request, and reading the repository's own
* content/settings.yaml would make these tests depend on how the working copy
* happens to be configured. lib/__tests__/site-password.test.ts covers the
* verification itself.
*/
vi.mock('@/lib/auth', () => ({
isSiteUnlocked: vi.fn(() => true),
}));
import { isSiteUnlocked } from '@/lib/auth';
import { proxy, config } from '@/proxy';
const mockUnlocked = isSiteUnlocked as unknown as ReturnType<typeof vi.fn>;
// Next.js 16 renamed the "middleware" file convention to "proxy": the file must
// be proxy.ts and must export proxy(), not middleware(). A silent regression here
// (wrong filename, wrong export name) means Next.js never invokes this code and
@@ -9,6 +24,10 @@ import { proxy, config } from '@/proxy';
describe('proxy', () => {
const run = (pathname = '/') => proxy(new NextRequest(`https://example.com${pathname}`));
beforeEach(() => {
mockUnlocked.mockReturnValue(true);
});
it('is exported under the name Next.js 16 expects', () => {
expect(typeof proxy).toBe('function');
});
@@ -63,9 +82,102 @@ describe('proxy', () => {
});
it('keeps a matcher that excludes api and static assets but covers /admin', () => {
const source = config.matcher[0].source;
const source = config.matcher[0];
expect(source).toContain('api');
expect(source).toContain('_next/static');
expect(source).not.toContain('admin');
});
/**
* The gate resolves the site password through getConfig(), which throws on a
* config it cannot parse — inside the proxy, before app/layout.tsx can fall
* back to the setup screen. Unhandled, one typo in gallery.yaml became a bare
* 500 on every route (#516).
*/
it('serves the page instead of a 500 when the config cannot be read', () => {
mockUnlocked.mockImplementation(() => {
throw new Error('duplicated mapping key');
});
const res = proxy(new NextRequest('https://example.com/japan'));
// Not a rewrite to the gate, and not a throw: the page renders and shows
// the setup screen, which is the thing that can explain the fault.
expect(res.headers.get('x-middleware-rewrite')).toBeNull();
expect(res.headers.get('Content-Security-Policy')).toBeTruthy();
});
it('still gates a locked site when the config is fine', () => {
mockUnlocked.mockReturnValue(false);
const res = proxy(new NextRequest('https://example.com/japan'));
expect(res.headers.get('x-middleware-rewrite')).toContain('/gate');
});
it('no longer lets the matcher skip prefetches', () => {
/*
* The exclusion used to live in the matcher. It moved into proxy() when the
* site gate was added: a matcher that skips prefetches skips the gate with
* them, and a prefetch asks for the same RSC payload as a navigation — so
* one request header would have walked straight past a locked site.
*/
expect(JSON.stringify(config.matcher)).not.toContain('next-router-prefetch');
});
it('still leaves prefetches without a nonce or a policy', () => {
const prefetch = proxy(
new NextRequest('https://example.com/', { headers: { 'next-router-prefetch': '1' } }),
);
expect(prefetch.headers.get('Content-Security-Policy')).toBeNull();
expect(prefetch.headers.get('x-middleware-request-x-nonce')).toBeNull();
const purpose = proxy(
new NextRequest('https://example.com/', { headers: { purpose: 'prefetch' } }),
);
expect(purpose.headers.get('Content-Security-Policy')).toBeNull();
});
describe('site-wide gate', () => {
/** The header a rewrite response carries, holding the rewritten URL. */
const rewrittenTo = (res: Response) => res.headers.get('x-middleware-rewrite');
it('rewrites a locked-out visitor to the gate instead of rendering the page', () => {
mockUnlocked.mockReturnValue(false);
// A redirect would lose the requested URL; a rewrite keeps it, so
// unlocking lands the visitor where they were going.
expect(rewrittenTo(run('/japan/osaka-2023'))).toContain('/gate');
});
it('gates a prefetch too', () => {
mockUnlocked.mockReturnValue(false);
const res = proxy(
new NextRequest('https://example.com/japan', {
headers: { 'next-router-prefetch': '1' },
}),
);
// A prefetch asks for the same payload as a navigation. If the gate ran
// after the prefetch shortcut, one header would walk straight past it.
expect(rewrittenTo(res)).toContain('/gate');
});
it('leaves /admin and /install reachable', () => {
mockUnlocked.mockReturnValue(false);
// /admin owns its own password and is where the site password is set;
// /install is what a fresh deployment needs before it can have one.
expect(rewrittenTo(run('/admin'))).toBeNull();
expect(rewrittenTo(run('/admin/settings/general'))).toBeNull();
expect(rewrittenTo(run('/install'))).toBeNull();
});
it('does not rewrite the gate to itself', () => {
mockUnlocked.mockReturnValue(false);
expect(rewrittenTo(run('/gate'))).toBeNull();
});
it('stays out of the way when the site is unlocked', () => {
expect(rewrittenTo(run('/japan/osaka-2023'))).toBeNull();
expect(run().headers.get('Content-Security-Policy')).toBeTruthy();
});
});
});

View File

@@ -1,9 +1,19 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { NextRequest } from 'next/server';
/*
* getClientIp() reads the hop count straight from the environment, not through
* getConfig(): the config resolves AUTH_SECRET, which throws in production when
* none is set — and that took down the install wizard's own API on a container
* started with no environment at all (#519).
*/
let mockHops = 0;
vi.mock('@/lib/config', () => ({
getConfig: () => ({ trustedProxyHops: mockHops }),
vi.mock('@/lib/env', () => ({
env: {
get TRUSTED_PROXY_HOPS() {
return mockHops;
},
},
}));
import {

View File

@@ -0,0 +1,191 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* The site-wide gate rides on the same HMAC machinery as subpage, album and
* journal passwords — the point of these tests is that it really is the same
* path (signed expiry, constant-time compare, opt-in when unset) and not a
* second, weaker scheme bolted on beside it.
*/
const config = {
authSecret: 'test-secret-that-is-at-least-32-chars-long',
sitePassword: '',
subpages: [] as unknown[],
albumPasswords: {} as Record<string, string>,
};
vi.mock('@/lib/config', () => ({
getConfig: () => config,
}));
import {
authenticate,
isAuthenticated,
isSiteLocked,
isSiteUnlocked,
siteLockResponse,
SITE_AUTH_COOKIE,
SITE_AUTH_KEY,
} from '@/lib/auth';
/** Pull the cookie value back out of a Set-Cookie header. */
function cookieValue(setCookie: string): string {
return setCookie.slice(setCookie.indexOf('=') + 1, setCookie.indexOf(';'));
}
const jar = (value?: string) => (name: string) => (name === SITE_AUTH_COOKIE ? value : undefined);
beforeEach(() => {
config.sitePassword = '';
});
describe('an open site', () => {
it('is not locked when no password is configured', () => {
expect(isSiteLocked()).toBe(false);
});
it('serves everyone, cookie or not', () => {
expect(isSiteUnlocked(jar())).toBe(true);
expect(siteLockResponse(new Request('http://localhost/api/image/x'))).toBeNull();
});
it('refuses to issue a token — there is nothing to unlock', async () => {
expect(await authenticate(SITE_AUTH_KEY, 'anything', 'site')).toBeNull();
});
});
describe('a locked site', () => {
beforeEach(() => {
config.sitePassword = 'letmein';
});
it('reports itself locked', () => {
expect(isSiteLocked()).toBe(true);
});
it('turns away a visitor with no cookie', () => {
expect(isSiteUnlocked(jar())).toBe(false);
});
it('rejects the wrong password', async () => {
expect(await authenticate(SITE_AUTH_KEY, 'wrong', 'site')).toBeNull();
});
it('issues an HttpOnly cookie for the right password', async () => {
const setCookie = await authenticate(SITE_AUTH_KEY, 'letmein', 'site');
expect(setCookie).toContain(`${SITE_AUTH_COOKIE}=`);
expect(setCookie).toContain('HttpOnly');
expect(setCookie).toContain('SameSite=Strict');
expect(setCookie).toContain('Path=/');
});
it('accepts the cookie it just issued', async () => {
const setCookie = (await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!;
expect(isSiteUnlocked(jar(cookieValue(setCookie)))).toBe(true);
});
it('rejects a token whose expiry has been pushed out by hand', async () => {
const setCookie = (await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!;
const [, mac] = cookieValue(setCookie).split('.');
const forged = `${Date.now() + 10 * 365 * 24 * 3600_000}.${mac}`;
expect(isSiteUnlocked(jar(forged))).toBe(false);
});
it('rejects an expired token', () => {
// Signed with a past expiry: the signature is valid, the deadline is not.
expect(isSiteUnlocked(jar(`${Date.now() - 1000}.deadbeef`))).toBe(false);
});
it('rejects garbage', () => {
expect(isSiteUnlocked(jar(''))).toBe(false);
expect(isSiteUnlocked(jar('not-a-token'))).toBe(false);
expect(isSiteUnlocked(jar('...'))).toBe(false);
});
it('does not unlock the site with another gate’s cookie', async () => {
config.subpages = [{ slug: 'private', password: 'letmein' }];
const subpageCookie = (await authenticate('private', 'letmein', 'subpage'))!;
// Same password, different key — the key is part of the signed payload.
expect(isSiteUnlocked(jar(cookieValue(subpageCookie)))).toBe(false);
config.subpages = [];
});
it('stops changing its mind once the password is rotated', async () => {
const setCookie = (await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!;
config.sitePassword = 'something-else';
expect(isSiteUnlocked(jar(cookieValue(setCookie)))).toBe(false);
});
});
describe('siteLockResponse', () => {
beforeEach(() => {
config.sitePassword = 'letmein';
});
it('answers 401 without a cookie, and does not let it be cached', async () => {
const res = siteLockResponse(new Request('http://localhost/api/image/x'))!;
expect(res.status).toBe(401);
expect(res.headers.get('Cache-Control')).toBe('no-store');
// A WWW-Authenticate header would make the browser pop its own dialog.
expect(res.headers.get('WWW-Authenticate')).toBeNull();
});
it('reads the cookie out of the request header', async () => {
const setCookie = (await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!;
const req = new Request('http://localhost/api/image/x', {
headers: { cookie: `theme=dark; ${SITE_AUTH_COOKIE}=${cookieValue(setCookie)}; other=1` },
});
expect(siteLockResponse(req)).toBeNull();
});
it('is not fooled by a cookie whose name merely ends the same way', async () => {
const setCookie = (await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!;
const req = new Request('http://localhost/api/image/x', {
headers: { cookie: `not_${SITE_AUTH_COOKIE}=${cookieValue(setCookie)}` },
});
expect(siteLockResponse(req)).not.toBeNull();
});
});
describe('a subpage slugged "site"', () => {
beforeEach(() => {
config.sitePassword = 'letmein';
});
it('does not write to the site gate’s cookie', async () => {
config.subpages = [{ slug: 'site', password: 'subpage-pw' }];
const setCookie = (await authenticate('site', 'subpage-pw', 'subpage'))!;
expect(setCookie.startsWith(`${SITE_AUTH_COOKIE}=`)).toBe(false);
expect(setCookie).toContain('lb_auth_site=');
config.subpages = [];
});
it('does not unlock the site even when both passwords match', async () => {
// Same key ("site"), same password — only the domain separator in the
// signed payload keeps the two tokens apart.
config.subpages = [{ slug: 'site', password: 'letmein' }];
const subpageCookie = cookieValue((await authenticate('site', 'letmein', 'subpage'))!);
expect(isSiteUnlocked(jar(subpageCookie))).toBe(false);
config.subpages = [];
});
it('is not unlocked by the site cookie either', async () => {
config.subpages = [{ slug: 'site', password: 'letmein' }];
const siteCookie = cookieValue((await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!);
expect(isAuthenticated('site', () => siteCookie, 'subpage')).toBe(false);
config.subpages = [];
});
});
describe('the other gates are untouched', () => {
it('still protects a subpage independently of the site lock', async () => {
config.sitePassword = 'letmein';
config.subpages = [{ slug: 'private', password: 'other' }];
const siteCookie = cookieValue((await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!);
// Unlocking the site does not unlock a subpage inside it.
expect(isAuthenticated('private', () => siteCookie, 'subpage')).toBe(false);
config.subpages = [];
});
});

View File

@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
import { resolveWatermarkOpacity, WATERMARK_DEFAULT_OPACITY } from '../config/schema';
/**
* The lightbox used to divide `watermark.opacity` by 100 while both the docs
* and the admin slider expressed it as a fraction, so a configured 0.9 rendered
* as 0.009 — invisible (#508).
*/
describe('resolveWatermarkOpacity', () => {
it('passes a documented fraction through unchanged', () => {
expect(resolveWatermarkOpacity(0.5)).toBe(0.5);
expect(resolveWatermarkOpacity(0.9)).toBe(0.9);
});
it('reads a value above 1 as a percentage', () => {
expect(resolveWatermarkOpacity(90)).toBe(0.9);
expect(resolveWatermarkOpacity(50)).toBe(0.5);
});
it('treats 1 as fully opaque, not as one percent', () => {
expect(resolveWatermarkOpacity(1)).toBe(1);
});
it('clamps out-of-range values into 0–1', () => {
expect(resolveWatermarkOpacity(400)).toBe(1);
expect(resolveWatermarkOpacity(-2)).toBe(0);
});
it('falls back to the default when unset or not a number', () => {
expect(resolveWatermarkOpacity(undefined)).toBe(WATERMARK_DEFAULT_OPACITY);
expect(resolveWatermarkOpacity(NaN)).toBe(WATERMARK_DEFAULT_OPACITY);
});
it('keeps 0 as fully transparent rather than falling back', () => {
expect(resolveWatermarkOpacity(0)).toBe(0);
});
});

Some files were not shown because too many files have changed in this diff Show More