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:
@@ -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
3
.gitignore
vendored
@@ -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) ───────────────────────────
|
||||
|
||||
234
CHANGELOG.md
234
CHANGELOG.md
@@ -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
197
CLAUDE.md
@@ -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 `` and ``.
|
||||
- `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.
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
131
app/admin/components/DoctorModal.tsx
Normal file
131
app/admin/components/DoctorModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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's own spacing. Tablet widths
|
||||
show at most two covers per row.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Visual Photo Essay Builder (if layout === 'essay') */}
|
||||
|
||||
@@ -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><html lang></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 & 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 & Favorites
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Allow visitors & 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 & 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 & 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 & Favorites
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Allow visitors & 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 & 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 & 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>
|
||||
|
||||
71
app/api/__tests__/health-config.test.ts
Normal file
71
app/api/__tests__/health-config.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
137
app/api/__tests__/site-gate.test.ts
Normal file
137
app/api/__tests__/site-gate.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
91
app/api/admin/__tests__/doctor-guards.test.ts
Normal file
91
app/api/admin/__tests__/doctor-guards.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
147
app/api/admin/doctor/route.ts
Normal file
147
app/api/admin/doctor/route.ts
Normal 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' } },
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
28
app/gate/page.tsx
Normal 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
97
app/global-error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
117
app/layout.tsx
117
app/layout.tsx
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
19
app/page.tsx
19
app/page.tsx
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
29
components/I18nProvider.tsx
Normal file
29
components/I18nProvider.tsx
Normal 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]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -72,7 +72,8 @@ Group albums under custom URL paths (e.g. `/japan`, `/wedding-smith`).
|
||||
- **Name** — navigation label; the URL slug is derived from it
|
||||
- **Title / Subtitle** — page heading and subline
|
||||
- **Password** — protect the whole subpage
|
||||
- **Grid override** — layout settings for this page
|
||||
- **Grid override** — layout settings for the photos on this page
|
||||
- **Album Cover Grid** — how the album covers are tiled: columns (empty = the site-wide grid setting) and gap in px (empty = the theme's own cover spacing). Hidden in essay mode
|
||||
- **Enabled** — take the page offline without deleting it
|
||||
- **Hidden** _(experimental)_ — unlisted: reachable by direct link, absent from the navigation
|
||||
- **Essay** — turn the page into a photo essay, with a block editor for the text
|
||||
|
||||
@@ -271,10 +271,11 @@ gibt, überhaupt PRs zu stellen.
|
||||
|
||||
---
|
||||
|
||||
### B8. Kleinigkeit: Projektname in `package.json` — **XS**
|
||||
### B8. Kleinigkeit: Projektname in `package.json` — **XS** — ✅ erledigt
|
||||
|
||||
`"name": "immich-lightbox"` bei einem Projekt namens Immich Folio. Fällt jedem
|
||||
Contributor beim ersten `npm install` auf.
|
||||
Contributor beim ersten `npm install` auf. Heißt seit dem Housekeeping-PR
|
||||
`immich-folio`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
396
docs/brainstorm-2026-08-15.md
Normal file
396
docs/brainstorm-2026-08-15.md
Normal file
@@ -0,0 +1,396 @@
|
||||
# Feature-Brainstorm: Kundengeschäft, Handwerk, Vertrauen
|
||||
|
||||
_Stand: 2026-08-15 · Basis: v0.11.0 (dev, nach #431 und #460) · Fokus: was ein
|
||||
Fotograf im Alltag mit dieser Software tut_
|
||||
|
||||
Dieses Dokument ergänzt [`ideas.md`](ideas.md) (Präsentation),
|
||||
[`brainstorm.md`](brainstorm.md) (Mai 2026) und
|
||||
[`brainstorm-2026-08-05.md`](brainstorm-2026-08-05.md) (Verbreitung & Betrieb).
|
||||
Es wiederholt deren Ideen **nicht**. Wo es einen bereits gelisteten Punkt aufgreift,
|
||||
steht es ausdrücklich dabei (Abschnitt D).
|
||||
|
||||
Die Perspektive hier ist eine andere als in den Vorgängern: nicht „was fehlt der
|
||||
Software", sondern **„was tut ein Fotograf, das Folio heute nicht begleitet"** —
|
||||
Bilder an Kunden übergeben, eine Serie sequenzieren, entscheiden, was die Welt über
|
||||
den Aufnahmeort erfahren darf.
|
||||
|
||||
Alle Befunde sind gegen den Code-Stand geprüft; Aufwand: XS (Stunden), S (1 Tag),
|
||||
M (2–5 Tage), L (Wochen).
|
||||
|
||||
---
|
||||
|
||||
## Was seit den alten Listen dazugekommen ist
|
||||
|
||||
Damit klar ist, warum die folgenden Ideen neu sind: seit #431 und #460 leben bereits
|
||||
Fokuspunkt fürs Cover, Bildunterschriften aus der Immich-Description,
|
||||
`justified`-Layout, unlisted Subpages, externe Nav-Links, Cover-Splash-Hero und
|
||||
per-Album-Grid. Früher kamen Wasserzeichen, Rechtsklick-Schutz, Client-Proofing
|
||||
inklusive Share-Link und Mailto-Export, Map, Video, Journal/Essays, Setup-Assistent,
|
||||
Error-Boundaries und der Stale-Cache dazu.
|
||||
|
||||
Weiterhin offen aus den alten Listen und hier bewusst _nicht_ nochmal ausgeführt:
|
||||
Slideshow, Fullscreen, Download-Button, Copy-Link, Photo of the Day, ISR,
|
||||
EXIF-Statistikseite, Before/After-Slider, i18n, Timeline, Smart Search, PWA,
|
||||
Demo-Modus, Config-Doctor, Platten-Cache, Metriken, Multi-Site.
|
||||
|
||||
---
|
||||
|
||||
## A. Kundengeschäft
|
||||
|
||||
Folio kann Kunden heute Bilder _zeigen_ (Passwort, Proofing) — aber der Weg
|
||||
davor und danach fehlt: wie der Kunde hereinkommt und wie er sich zurückmeldet.
|
||||
|
||||
### A1. Ablaufende, widerrufbare Client-Links — **M, hoher Impact**
|
||||
|
||||
**Befund:** Kundenzugang ist heute ein statisches Passwort in `gallery.yaml`, geprüft
|
||||
gegen ein HMAC-Cookie (`lib/auth.ts:44-46`, `lb_auth_album_<slug>`). Daraus folgt
|
||||
dreierlei: Das Passwort läuft nie ab. Es lässt sich nicht für _einen_ Empfänger
|
||||
zurückziehen, sondern nur für alle gleichzeitig ändern. Und der Kunde muss es tippen —
|
||||
in der Praxis steht es deshalb ohnehin in derselben Mail wie der Link, was die
|
||||
Zugangskontrolle auf ein Feigenblatt reduziert.
|
||||
|
||||
**Idee:** Zugangs-Links, die im Admin erzeugt werden:
|
||||
|
||||
```
|
||||
https://folio.example/hochzeit-mueller?k=<signiertes-token>
|
||||
```
|
||||
|
||||
Das Token trägt Slug, Ablaufdatum und eine Link-ID; signiert mit derselben Mechanik
|
||||
wie die Admin-Session (`lib/admin/auth.ts`). Beim ersten Aufruf wird es gegen genau
|
||||
das Cookie eingetauscht, das heute schon existiert — der gesamte Rest der
|
||||
Zugangsprüfung bleibt unangetastet. Im Admin: eine Liste mit Label („Brautpaar",
|
||||
„Location"), Ablaufdatum und einem Widerrufen-Knopf.
|
||||
|
||||
**Warum das zieht:** Es ist der Unterschied zwischen „hier ist ein Passwort" und dem,
|
||||
was Kunden von Pixieset oder Pic-Time kennen. Für Hochzeits- und Auftragsfotografen
|
||||
ist die Galerie-Übergabe der eigentliche Arbeitsschritt, nicht das Portfolio.
|
||||
|
||||
**Zwei Trade-offs, die benannt gehören:**
|
||||
|
||||
- **Widerruf braucht Zustand.** Ein rein signiertes Token kann man nicht
|
||||
zurücknehmen, nur ablaufen lassen. Echter Widerruf verlangt eine Liste ausgegebener
|
||||
Link-IDs in `content/` — sonst ist das Wort „widerrufbar" gelogen. Das ist der
|
||||
erste Fall, in dem Folio Besucher-bezogenen Zustand speichert; die Liste sollte
|
||||
entsprechend klein und selbstaufräumend sein.
|
||||
- **Bild-URLs bleiben unberührt.** Asset-Tokens sind bewusst deterministisch, damit
|
||||
Browser cachen können. Ein widerrufener Zugang macht bereits ausgelieferte
|
||||
Bild-URLs also nicht ungültig — derselbe Trade-off, der in
|
||||
[`deep-dive-2026-08-05.md`](deep-dive-2026-08-05.md) für die Allowlist notiert ist.
|
||||
Er muss hier _dokumentiert_ werden, sonst erwartet jemand mehr, als das Feature hält.
|
||||
|
||||
---
|
||||
|
||||
### A2. Anfrage-Formular statt `mailto:` — **S–M**
|
||||
|
||||
**Befund:** Es gibt kein Formular. Kontakt läuft über `mailto:` im Footer
|
||||
(`components/Footer.tsx:60`) und im Proofing-Modal
|
||||
(`components/ProofingModal.tsx:43`). Das hat zwei Kosten, die Fotografen real treffen:
|
||||
Die Adresse steht im Klartext im HTML und wird abgegrast, und auf Geräten ohne
|
||||
konfigurierten Mail-Client passiert beim Klick schlicht nichts — der Kunde denkt, der
|
||||
Link sei kaputt.
|
||||
|
||||
**Idee:** Eine rate-limitierte Route (`lib/rate-limit.ts` ist da, eigener Namensraum
|
||||
`contact:`, niedriges Limit in der Größenordnung der Auth-Routen) plus Honeypot-Feld.
|
||||
Zustellung wahlweise per SMTP oder per Webhook. Und — das ist der eigentliche Wert —
|
||||
ein **„Zu diesem Bild anfragen"** direkt im Lightbox: Print-Anfrage, Lizenzanfrage,
|
||||
Buchung, mit Bildreferenz vorausgefüllt. Aus einer Galerie wird damit ein
|
||||
Verkaufskanal, ohne dass ein Shop dranhängt.
|
||||
|
||||
**Ehrlich zu benennen:** SMTP wäre die erste ausgehende Verbindung des Projekts
|
||||
überhaupt und die erste ernsthafte neue Dependency (das Projekt hat heute sieben
|
||||
Runtime-Dependencies und keine Mail-Bibliothek). Der Webhook-Weg vermeidet beides und
|
||||
passt besser zur bestehenden Linie — `POST /api/webhook` zeigt, dass das Muster im
|
||||
Haus schon verstanden ist. **Empfehlung: mit Webhook anfangen, SMTP optional
|
||||
nachziehen.**
|
||||
|
||||
---
|
||||
|
||||
### A3. Proofing v2 — Notizen statt nur Herzen — **S**
|
||||
|
||||
**Befund:** Die Auswahl ist eine Bitmaske von Indizes (`lib/proofing.ts`), exportiert
|
||||
als Link, als nummerierte Liste oder per Mailto. Sie transportiert also genau ein Bit
|
||||
pro Foto: gewählt oder nicht.
|
||||
|
||||
**Idee:** Ein kurzes Notizfeld pro ausgewähltem Foto. Kunden sagen selten nur „dieses",
|
||||
sondern „dieses, aber enger geschnitten" oder „das für die Danksagung". Heute landet
|
||||
das in einer separaten Mail mit Bildnummern, die der Fotograf von Hand zuordnet — der
|
||||
unangenehmste Teil des ganzen Ablaufs.
|
||||
|
||||
**Der Trade-off ist hier die eigentliche Designfrage:** Freitext passt nicht in eine
|
||||
Bitmaske im URL-Parameter. Zwei saubere Auswege:
|
||||
|
||||
- **Klein:** Notizen bleiben in `localStorage` und wandern nur in den Export-Text.
|
||||
Der Share-Link trägt weiterhin nur die Auswahl. Kein Server, kein neuer Zustand —
|
||||
passt exakt zur heutigen Architektur („nichts wird serverseitig gespeichert").
|
||||
- **Groß:** Ein Endpunkt, der die Auswahl entgegennimmt. Das wäre der erste
|
||||
Schreibzugriff durch Besucher — mit allem, was daran hängt: Rate-Limiting,
|
||||
Größenbegrenzung, Spam, Aufbewahrung. Nur sinnvoll zusammen mit A1, weil erst ein
|
||||
identifizierbarer Client-Link die Rückmeldung zuordenbar macht.
|
||||
|
||||
**Empfehlung:** die kleine Variante. Sie kostet einen Tag und löst 90 % des Problems.
|
||||
|
||||
---
|
||||
|
||||
## B. Handwerk & Präsentation
|
||||
|
||||
Die Zielgruppe sind laut den bisherigen Doks „Fotografen mit Gestaltungsanspruch".
|
||||
Die drei folgenden Punkte zielen genau darauf — und einer davon ist ein Befund, der
|
||||
mich beim Lesen überrascht hat.
|
||||
|
||||
### B1. Sequenzierung wie im Fotobuch — **M, der stärkste Differenzierer hier**
|
||||
|
||||
**Befund:** Ein Layout gilt für ein ganzes Album (`grid.layout`, seit #431 auch pro
|
||||
Album überschreibbar). Die Reihenfolge ist frei wählbar (`assetOrder`,
|
||||
`lib/config/schema.ts:224`, mit Drag & Drop im Admin) — aber jedes Foto bekommt
|
||||
dieselbe Behandlung wie sein Nachbar.
|
||||
|
||||
Ein Fotobuch funktioniert anders. Es lebt von **Paaren**, die sich auf einer
|
||||
Doppelseite antworten, von **einem Bild, das allein über die volle Breite geht**, und
|
||||
von **Pausen**. Genau diese Mittel besitzt die Essay-Ansicht bereits — die
|
||||
Journal-Syntax kennt `fullbleed`, `wide` und Foto-Paare (`lib/journal.ts`). Das Album
|
||||
kennt sie nicht.
|
||||
|
||||
**Idee:** Optionale Layout-Hinweise entlang des vorhandenen `assetOrder`:
|
||||
|
||||
```yaml
|
||||
- 'album-uuid':
|
||||
sort: manual
|
||||
assetOrder:
|
||||
- 'asset-opening': fullbleed # allein, volle Breite
|
||||
- 'asset-a': pair # diese beiden nebeneinander,
|
||||
- 'asset-b': pair # in gleicher Höhe
|
||||
- 'asset-c'
|
||||
- break # bewusste Lücke, dann geht es weiter
|
||||
```
|
||||
|
||||
Renderer-seitig ist das kein neues Layout-System, sondern eine Segmentierung des
|
||||
bestehenden Grids: `PhotoGrid` gruppiert die Assets vor dem Rendern in Blöcke und
|
||||
rendert je Block das passende Muster. Die Bausteine — Aspect-Ratio pro Asset
|
||||
(`assetAspectRatio`), Zeilenmathematik aus `justified` — liegen schon da.
|
||||
|
||||
**Warum das zieht:** Es ist das einzige Feature auf dieser Liste, das kein anderes
|
||||
Immich-Portfolio-Tool hat, und es spricht die Zielgruppe an ihrer empfindlichsten
|
||||
Stelle an: Reihenfolge ist Handwerk. Eine Serie, die als gleichförmiges Raster
|
||||
ausgespielt wird, ist eine Serie, deren Rhythmus verloren geht.
|
||||
|
||||
**Trade-off:** Das Feature ist im Admin nur so gut wie seine Vorschau. Layout-Hinweise
|
||||
in einer Liste zu setzen, ohne das Ergebnis zu sehen, ist frustrierend — der
|
||||
`AssetOrderEditor` müsste die Blöcke andeuten. Das ist die halbe Arbeit an dieser
|
||||
Idee, nicht das YAML-Schema.
|
||||
|
||||
---
|
||||
|
||||
### B2. 1:1-Zoom im Lightbox — **S** _(mit einem Nebenbefund)_
|
||||
|
||||
**Befund — und der ist präziser, als er zunächst aussieht:** Der Lightbox hat keinerlei
|
||||
Zoom; die einzige Transformation ist eine Mount-Transition von `scale(0.96)` auf `1`
|
||||
(`components/Lightbox.module.css`). So weit erwartbar.
|
||||
|
||||
Interessanter ist, _was_ er anzeigt. Der Dateikopf verspricht
|
||||
„Full-resolution image display" (`components/Lightbox.tsx:5`), gerendert wird aber
|
||||
`current.previewUrl` (`components/Lightbox.tsx:237`). Und `imageUrl()` steht per
|
||||
Default auf `'preview'` (`lib/urls.ts:29`) — eine Suche über `app/`, `components/`
|
||||
und `lib/urls.ts` findet **keine einzige Stelle, die `'original'` anfordert**. Die
|
||||
oberste Größenstufe aus `lib/imageSize.ts` ist im gesamten Frontend unerreichbar.
|
||||
|
||||
Das ist als Default vollkommen richtig — Originale an jeden Besucher auszuliefern wäre
|
||||
verschwenderisch, und `resolveImageSize()` verhindert das aus gutem Grund. Aber es
|
||||
heißt: Der Lightbox zeigt heute Immichs Preview, nicht die Aufnahme. Wer Schärfe
|
||||
beurteilen will — der Fotograf selbst, und jeder Kunde, der bei der Auswahl auf den
|
||||
Fokus schaut — kann das nicht.
|
||||
|
||||
**Idee:** Klick oder Pinch schaltet auf `original` um und erlaubt Pan. Erst bei dieser
|
||||
Geste wird die große Datei geholt, nie vorher. Bewusst getrennt von den bereits
|
||||
gelisteten Punkten Fullscreen und Download: Zoom ist Beurteilung, nicht Präsentation
|
||||
und nicht Übergabe.
|
||||
|
||||
**Nebenbei zu korrigieren:** der Kommentar in `Lightbox.tsx:5`. Er beschreibt eine
|
||||
Absicht, nicht das Verhalten.
|
||||
|
||||
---
|
||||
|
||||
### B3. Farbmanagement — **eine offene Frage, keine Behauptung, S für die Messung**
|
||||
|
||||
Aus B2 folgt eine Frage, die ich mit Codelesen allein nicht beantworten kann und die
|
||||
deshalb hier als **Messaufgabe** steht, nicht als Befund:
|
||||
|
||||
Fotografen arbeiten in AdobeRGB oder Display-P3. Wenn Folio ausschließlich Immichs
|
||||
Preview-Tier ausliefert (siehe B2), dann hängt die Farbtreue des gesamten Portfolios
|
||||
daran, was Immich beim Erzeugen dieser Previews mit dem Farbprofil macht — und ob
|
||||
`streamAsset()` das Profil mit durchreicht. Fehlt es, rendert der Browser die Bytes
|
||||
als sRGB: Wide-Gamut-Material wirkt dann flau oder, umgekehrt eingebettet,
|
||||
übersättigt. Und zwar ausgerechnet auf dem guten Display, an dem der Fotograf sitzt.
|
||||
|
||||
**Testweg:** Eine Datei mit sichtbar breitem Gamut (gesättigtes Rot/Cyan) in AdobeRGB
|
||||
nach Immich laden, in Folio öffnen, die ausgelieferten Bytes auf ein eingebettetes
|
||||
ICC-Profil prüfen und gegen dieselbe Datei lokal im Browser vergleichen.
|
||||
|
||||
**Wenn sich ein Verlust zeigt,** sind die Antworten der Reihe nach: Profil im Proxy
|
||||
mit durchreichen (Header und Bytes bleiben unangetastet — `streamAsset()` streamt
|
||||
ohnehin unverändert); und für die Zoom-Ansicht aus B2 gleich das Original nehmen, das
|
||||
sein Profil mitbringt.
|
||||
|
||||
**Wenn sich keiner zeigt,** ist der Abschnitt einen Satz in der Doku wert — „Folio
|
||||
liefert Immichs Previews unverändert aus, inklusive Farbprofil" — und damit erledigt.
|
||||
Auch das ist ein Ergebnis: Farbtreue ist bei dieser Zielgruppe ein
|
||||
Vertrauensargument, und ein belegter Satz dazu ist mehr wert als ein Feature.
|
||||
|
||||
---
|
||||
|
||||
## C. Vertrauen & Privatsphäre
|
||||
|
||||
### C1. Ortsangaben pro Album steuern — **S**
|
||||
|
||||
**Befund, sorgfältig gelesen:** Die Map ist datenschutzseitig deutlich besser gebaut,
|
||||
als man befürchten würde. Passwortgeschützte Alben werden vor der Aggregation
|
||||
herausgefiltert (`app/api/map/route.ts:63-66`), und `lib/mapService.ts:5-11` hält
|
||||
Alben ausdrücklich getrennt, damit genau das möglich ist. Die öffentliche EXIF-Route
|
||||
gibt Koordinaten gar nicht erst heraus.
|
||||
|
||||
Die Lücke ist eine andere und feiner: Für **öffentliche** Alben wird ein Marker aus
|
||||
dem **Mittelwert der tatsächlichen Koordinaten** aller Fotos an diesem Ort gebildet.
|
||||
Bei einem Reisealbum über eine ganze Stadt ist das unkritisch. Bei einem Album, das an
|
||||
_einem_ Ort entstanden ist — der eigene Garten, das Wohnzimmer eines Kunden, das
|
||||
Studio, ein empfindlicher Naturstandort —, ist der Mittelwert genau dieser eine Ort.
|
||||
Der Marker heißt zwar nach der Stadt, steht aber auf dem Grundstück.
|
||||
|
||||
Niemand trifft diese Entscheidung heute bewusst; sie ergibt sich daraus, dass die
|
||||
Kamera GPS mitschreibt.
|
||||
|
||||
**Idee:** Eine Granularität pro Album und pro Subpage:
|
||||
|
||||
```yaml
|
||||
location: exact # heutiges Verhalten
|
||||
location: city # auf das Stadtzentrum runden, nicht mitteln
|
||||
location: country # nur im Land verorten
|
||||
location: hidden # gar nicht auf der Karte
|
||||
```
|
||||
|
||||
Der Eingriff sitzt in `lib/mapService.ts`, wo bereits pro Album aggregiert wird — die
|
||||
Stelle, an der man die Auflösung reduziert, existiert also schon.
|
||||
|
||||
**Warum das zieht:** Es macht aus einer stillschweigenden Preisgabe eine bewusste
|
||||
Entscheidung. Für ein Projekt, dessen Verkaufsargument „dein Immich bleibt privat"
|
||||
lautet, ist das kein Nebenfeature, sondern eine Konsequenz derselben Haltung.
|
||||
|
||||
---
|
||||
|
||||
### C2. Terminierte Veröffentlichung — **S**
|
||||
|
||||
**Befund:** `draft: true` gibt es nur für Journal-Einträge (`app/journal/page.tsx:33`,
|
||||
Drafts bleiben für den eingeloggten Admin sichtbar — ein gutes Muster). Alben und
|
||||
Subpages sind live, sobald sie in der YAML stehen. Wer eine Seite vorbereiten will,
|
||||
muss sie entweder verstecken (`hidden: true`, seit #431) und im richtigen Moment von
|
||||
Hand umstellen, oder sie erst im letzten Moment anlegen.
|
||||
|
||||
**Idee:** Ein `publishAt`-Datum für Alben, Subpages und Journal-Einträge. Vor dem
|
||||
Termin verhält sich der Eintrag wie ein Draft: für den Admin sichtbar, für alle
|
||||
anderen nicht vorhanden.
|
||||
|
||||
**Warum das billig ist:** Alle Seiten sind `force-dynamic`. Es braucht keinen
|
||||
Scheduler, keinen Cron, keinen Hintergrundjob — nur einen Datumsvergleich beim
|
||||
Ableiten der Galerie, an derselben Stelle, an der heute schon `enabled` und `hidden`
|
||||
ausgewertet werden.
|
||||
|
||||
**Warum es trifft:** Es ist ein realer Ablauf, kein hypothetischer. Die
|
||||
Hochzeitsgalerie geht Freitag um 18 Uhr auf, wenn das Paar aus den Flitterwochen
|
||||
zurück ist. Die Serie erscheint zum Ausstellungstermin. Das Journal zum Buchstart.
|
||||
Heute heißt das: Wecker stellen und selbst am Rechner sitzen.
|
||||
|
||||
**Ein Detail, das mitgedacht gehört:** Der RSS-Feed aus D darf einen terminierten
|
||||
Eintrag nicht vorab ausliefern — dieselbe Prüfung, dieselbe Stelle.
|
||||
|
||||
---
|
||||
|
||||
## D. RSS/Atom-Feed — konkretisiert
|
||||
|
||||
Steht seit Mai als Einzeiler in [`brainstorm.md`](brainstorm.md) („`/feed.xml` mit
|
||||
neuen Alben", Zeile 23) und dort auf Platz 1 der Reihenfolge. Verifiziert: eine
|
||||
Feed-Route existiert nicht. Hier zum ersten Mal durchdacht — und der interessante
|
||||
Teil ist nicht das XML.
|
||||
|
||||
**Der eigentliche Inhalt dieses Features ist die Ausschlussliste.** Ein Feed sammelt
|
||||
genau das ein, was die Zugangskontrolle sonst verbirgt: Titel, Beschreibungen,
|
||||
Cover-Bilder, Veröffentlichungsdaten. Er ist damit die perfekte Leak-Fläche, und
|
||||
zwar eine, die niemandem auffällt, weil sie im Browser nie sichtbar wird. Draußen
|
||||
bleiben müssen:
|
||||
|
||||
- passwortgeschützte Subpages und Alben (`password:` auf beiden Ebenen)
|
||||
- `hidden: true`-Subpages — sie sind bewusst nur per Direktlink erreichbar, und ein
|
||||
Feed ist das Gegenteil eines Direktlinks
|
||||
- Journal-Drafts (`draft: true`)
|
||||
- alles mit einem `publishAt` in der Zukunft, falls C2 kommt
|
||||
- Alben, die per Client-Link aus A1 zugänglich sind
|
||||
|
||||
**Zwei Quellen, ein Feed:** Journal-Einträge (haben Datum, Titel, Excerpt und Cover —
|
||||
alles schon da) und neu hinzugekommene Alben. Cover-Bilder über den bestehenden
|
||||
`/api/og`-Weg, damit kein neuer Bildpfad entsteht.
|
||||
|
||||
**Was es absichert:** ein Test, der die Ausschlussregel festschreibt — analog zu
|
||||
`app/api/admin/__tests__/admin-guards.test.ts`, also **tabellengetrieben über die
|
||||
Kategorien**, nicht als Sammlung von Einzelfällen. Dann schlägt er auch bei einer
|
||||
künftig hinzugefügten Sichtbarkeitsregel an, die jemand im Feed zu berücksichtigen
|
||||
vergisst.
|
||||
|
||||
**Aufwand:** S für den Feed. Die Sorgfalt steckt komplett in der Sichtbarkeitsprüfung —
|
||||
und die ist der Grund, warum dieser Punkt hier nochmal auftaucht, statt als Einzeiler
|
||||
stehenzubleiben.
|
||||
|
||||
---
|
||||
|
||||
## Anhang: zwei Kleinigkeiten zur Auffindbarkeit
|
||||
|
||||
Kurz gehalten, weil Auffindbarkeit diesmal nicht der Schwerpunkt war — aber beide sind
|
||||
XS und stehen in keiner der alten Listen:
|
||||
|
||||
- **Es gibt weder `app/sitemap.ts` noch `app/robots.ts`.** Robots-Direktiven existieren
|
||||
nur auf Meta-Ebene (`app/layout.tsx:37-48`). Next.js erzeugt beide Dateien aus je
|
||||
einer Funktion; die Album- und Subpage-Liste liegt in `getConfig()` bereit. Dieselbe
|
||||
Ausschlussliste wie beim Feed gilt auch hier.
|
||||
- **Kein JSON-LD.** Für ein Fotografie-Portfolio ist `ImageObject` mit `creator` und
|
||||
`license` die eine strukturierte Angabe, die sich wirklich auszahlt — Google Bilder
|
||||
zeigt Lizenzhinweise an, und es ist die einzige Stelle, an der ein Fotograf seine
|
||||
Urheberschaft maschinenlesbar an die Bilder heftet. Passt inhaltlich zu A2: wer
|
||||
Lizenzanfragen will, sollte auffindbar machen, dass Lizenzen zu haben sind.
|
||||
|
||||
---
|
||||
|
||||
## Empfohlene Reihenfolge
|
||||
|
||||
**Erst — klein, sofort spürbar:**
|
||||
|
||||
1. **C2** Terminierte Veröffentlichung · _S_ — ein Datumsvergleich, ein echter Ablauf
|
||||
2. **C1** Ortsangaben pro Album · _S_ — die Stelle im Code existiert bereits
|
||||
3. **B2** 1:1-Zoom · _S_ — und der falsche Kommentar in `Lightbox.tsx:5` fällt mit weg
|
||||
|
||||
**Dann — die zwei mit dem größten Hebel:**
|
||||
|
||||
4. **B1** Sequenzierung wie im Fotobuch · _M_ — das Alleinstellungsmerkmal
|
||||
5. **A1** Ablaufende Client-Links · _M_ — das Feature, das aus dem Portfolio ein
|
||||
Arbeitswerkzeug macht
|
||||
|
||||
**Danach:**
|
||||
|
||||
6. **D** RSS-Feed · _S_ — mit der Ausschlussliste als eigentlicher Arbeit
|
||||
7. **A2** Anfrage-Formular · _S–M_ — mit Webhook beginnen, SMTP später
|
||||
8. **A3** Proofing-Notizen · _S_ — kleine Variante
|
||||
9. **B3** Farbmanagement messen · _S_ — Ergebnis offen, Erkenntnis in jedem Fall wertvoll
|
||||
10. Anhang: Sitemap, `robots.ts`, JSON-LD · _XS_
|
||||
|
||||
---
|
||||
|
||||
## Roter Faden
|
||||
|
||||
Drei Sätze halten die Auswahl zusammen.
|
||||
|
||||
**Ein Portfolio ist Arbeit, nicht nur Ausstellung.** A1–A3 begleiten den Weg, den ein
|
||||
Auftragsbild ohnehin nimmt — Übergabe, Rückmeldung, Anfrage. Folio zeigt heute nur
|
||||
den mittleren Teil davon.
|
||||
|
||||
**Reihenfolge ist Handwerk.** B1 ist der einzige Punkt hier, den kein vergleichbares
|
||||
Werkzeug bietet, und er trifft die Zielgruppe genau dort, wo sie anspruchsvoll ist.
|
||||
|
||||
**Privatsphäre gilt nach außen genauso wie nach innen.** Das Versprechen lautet „dein
|
||||
Immich bleibt privat". C1 und C2 ziehen dieselbe Linie für das, was die
|
||||
veröffentlichte Seite über Orte und Zeitpunkte verrät — und D stellt sicher, dass ein
|
||||
Feed sie nicht hintenherum wieder aufmacht.
|
||||
@@ -21,7 +21,7 @@ _Stand: 2026-05-29 — generiert aus Projekt-Review + ideas.md_
|
||||
| **Sequentielle Album-Navigation** | XS | „← Vorheriges / Nächstes Album →" am Ende einer Detailseite |
|
||||
| **Alt-Text aus Immich-Description** | XS | Immich-Assets haben ein `description`-Feld → als `alt`-Text → bessere Accessibility & SEO |
|
||||
| **RSS/Atom Feed** | S | `/feed.xml` mit neuen Alben — Follower können „neue Arbeit" abonnieren |
|
||||
| **Globaler Passwortschutz** | S | Eine einzige `site_password` für die gesamte Site (jetzt: nur per Subpage/Album) |
|
||||
| ~~**Globaler Passwortschutz**~~ | S | _Umgesetzt:_ `sitePassword` in `settings.yaml` bzw. `SITE_PASSWORD` |
|
||||
| **Privacy-Analytics-Integration** | S | Plausible / Umami / GoatCounter via Script-Tag in `settings.yaml` konfigurieren (kein Tracking-Framework nötig) |
|
||||
|
||||
---
|
||||
|
||||
@@ -41,7 +41,8 @@ server, so a typo cannot leave you with an "installed" site that loads no photos
|
||||
|
||||
`IMMICH_API_URL`, `IMMICH_API_KEY`, `AUTH_SECRET` and `ADMIN_PASSWORD` override
|
||||
anything in `install.json`, so any of them can be rotated by setting the variable
|
||||
— no need to touch the file.
|
||||
— no need to touch the file. `SITE_PASSWORD` works the same way over
|
||||
`sitePassword` in `settings.yaml`.
|
||||
|
||||
Set all of them up front and the wizard never appears, which is the usual choice
|
||||
for an infrastructure-as-code deployment.
|
||||
|
||||
@@ -110,20 +110,20 @@ subpages:
|
||||
|
||||
### Subpage Options
|
||||
|
||||
| Key | Type | Description |
|
||||
| ----------- | ------- | ------------------------------------------------------------------------------------ |
|
||||
| `name` | string | Navigation label; the URL slug is derived from it |
|
||||
| `title` | string | Page heading, defaults to `name` |
|
||||
| `subtitle` | string | Subline under the heading |
|
||||
| `password` | string | Gates the whole subpage — see [Password Protection](#password-protection) |
|
||||
| `albums` | list | Album UUIDs, optionally with [per-album options](#per-album-options) |
|
||||
| `sections` | list | Named groups of albums — see [Sections](#sections) |
|
||||
| `grid` | object | Grid override for this page — see [Grid Layout](#grid-layout) |
|
||||
| `proofing` | boolean | Enables [client proofing](#client-proofing) on this page |
|
||||
| `essayFile` | string | Render a Markdown essay instead of a grid — see [Journal & Photo Essays](journal.md) |
|
||||
| `essayText` | string | Essay Markdown inline; written by the admin block editor |
|
||||
| `enabled` | boolean | `false` takes the page offline without deleting it |
|
||||
| `hidden` | boolean | **Experimental.** Reachable by direct link, but hidden from the navigation |
|
||||
| Key | Type | Description |
|
||||
| ----------- | ------- | -------------------------------------------------------------------------------------- |
|
||||
| `name` | string | Navigation label; the URL slug is derived from it |
|
||||
| `title` | string | Page heading, defaults to `name` |
|
||||
| `subtitle` | string | Subline under the heading |
|
||||
| `password` | string | Gates the whole subpage — see [Password Protection](#password-protection) |
|
||||
| `albums` | list | Album UUIDs, optionally with [per-album options](#per-album-options) |
|
||||
| `sections` | list | Named groups of albums — see [Sections](#sections) |
|
||||
| `grid` | object | Grid override for this page, photos and album covers — see [Grid Layout](#grid-layout) |
|
||||
| `proofing` | boolean | Enables [client proofing](#client-proofing) on this page |
|
||||
| `essayFile` | string | Render a Markdown essay instead of a grid — see [Journal & Photo Essays](journal.md) |
|
||||
| `essayText` | string | Essay Markdown inline; written by the admin block editor |
|
||||
| `enabled` | boolean | `false` takes the page offline without deleting it |
|
||||
| `hidden` | boolean | **Experimental.** Reachable by direct link, but hidden from the navigation |
|
||||
|
||||
```yaml
|
||||
subpages:
|
||||
@@ -245,8 +245,8 @@ Useful when the automatic centre crop cuts off a horizon or a face.
|
||||
|
||||
## Password Protection
|
||||
|
||||
A password can be set on a subpage, on a single album, and on a
|
||||
[journal entry](journal.md#password-protected-entries). Unlocking sets an
|
||||
A password can be set on the whole site, on a subpage, on a single album, and on
|
||||
a [journal entry](journal.md#password-protected-entries). Unlocking sets an
|
||||
`HttpOnly` cookie that is valid for 24 hours; nothing is stored server-side.
|
||||
|
||||
Three storage formats are accepted:
|
||||
@@ -267,6 +267,38 @@ directly.
|
||||
> gallery was unlocked keep working — see
|
||||
> [Image Token Revocation](#image-token-revocation).
|
||||
|
||||
### Locking the whole site
|
||||
|
||||
`sitePassword` in `settings.yaml` puts everything public behind one password —
|
||||
useful while a portfolio is still being built, or for a folio that is only ever
|
||||
shown to clients.
|
||||
|
||||
```yaml
|
||||
# settings.yaml
|
||||
sitePassword: 'scrypt:...'
|
||||
```
|
||||
|
||||
`SITE_PASSWORD` in the environment overrides the file, so the password can be
|
||||
rotated without editing (or backing up) `settings.yaml`. It accepts the same
|
||||
three storage formats as every other gate.
|
||||
|
||||
Unlike the per-page gates, this one is enforced in `proxy.ts`, before the
|
||||
requested page renders at all. That matters: a gate that merely swapped the page
|
||||
for a login form would still have let the page produce its payload, album names
|
||||
and image tokens included. The public API routes carry the same check
|
||||
themselves, since route handlers do not pass through the proxy.
|
||||
|
||||
Three things stay reachable on a locked site:
|
||||
|
||||
| Path | Why |
|
||||
| ------------- | ------------------------------------------------------------------------ |
|
||||
| `/api/health` | A health probe runs without cookies; gating it takes the container down. |
|
||||
| `/admin` | It has its own password, and it is where `sitePassword` is set. |
|
||||
| `/install` | A fresh deployment has to be configurable before it can be locked. |
|
||||
|
||||
A locked site also serves `noindex, nofollow` regardless of the SEO settings —
|
||||
there is nothing there for a crawler.
|
||||
|
||||
## Photo Order Within an Album
|
||||
|
||||
By default a Folio album mirrors the Immich timeline. That is usually right, but a curated series has a narrative order that has nothing to do with capture time — and changing the sort on the Immich album would change it for the archive too. The optional `sort` key decouples the two.
|
||||
@@ -315,11 +347,18 @@ override it in `gallery.yaml`. The three levels merge, most specific winning:
|
||||
|
||||
**global (`settings.yaml`) → subpage → album**
|
||||
|
||||
> [!NOTE]
|
||||
> Leaving `gap` out is not the same as setting `12`. Each theme preset picks its
|
||||
> own photo spacing (2px `minimal`, 32px `editorial`, 40px `monograph`), and an
|
||||
> unset `gap` lets that stand. Setting one wins in every preset — which is what
|
||||
> `minimal`, `editorial` and `monograph` used to ignore
|
||||
> ([#513](https://github.com/ralksta/immich-folio/issues/513)).
|
||||
|
||||
```yaml
|
||||
# settings.yaml — global defaults
|
||||
grid:
|
||||
columns: 3 # number of columns (default: 3)
|
||||
gap: 12 # gap in pixels (default: 12)
|
||||
gap: 12 # gap in pixels — unset means the theme preset decides
|
||||
aspectRatio: '1' # "1", "3/2", "2/3", "16/9", "auto" (default: "1")
|
||||
layout: masonry
|
||||
```
|
||||
@@ -341,6 +380,37 @@ Available layouts: `masonry`, `uniform`, `showcase`, `filmstrip`,
|
||||
(see [Journal & Photo Essays](journal.md)). Each is illustrated in the
|
||||
**[Theming guide](theming.md#gridlayout)**.
|
||||
|
||||
### Album covers on a subpage
|
||||
|
||||
A subpage with more than one album shows a grid of album covers rather than
|
||||
photos. That grid follows the same `columns` value — set it globally in
|
||||
`settings.yaml` or per page in `gallery.yaml`, and the covers are tiled the same
|
||||
way the photos are. Valid range is 1–6; below 1024px viewport width at most two
|
||||
covers share a row, and below 640px they stack.
|
||||
|
||||
`gap` is the deliberate exception: it does **not** cascade from the global
|
||||
setting to the covers. Every theme preset picks its own cover spacing as part of
|
||||
its look — 1px in `monograph`, 2px in `minimal` and `editorial`, 20px in
|
||||
`studio-modern` — and letting the site-wide photo gap through would flatten all
|
||||
of them into one look. Only an explicit `gap` on the subpage overrides the
|
||||
preset:
|
||||
|
||||
```yaml
|
||||
# gallery.yaml — album covers on this page only
|
||||
subpages:
|
||||
- name: Editorial
|
||||
grid:
|
||||
columns: 3 # three covers per row (site-wide default otherwise)
|
||||
gap: 24 # overrides the theme's own cover spacing
|
||||
albums:
|
||||
- '33333333-3333-3333-3333-333333333333'
|
||||
- '44444444-4444-4444-4444-444444444444'
|
||||
```
|
||||
|
||||
Both fields are editable per page in the admin panel under
|
||||
**Pages › _subpage_ › Album Cover Grid**; left empty they follow the site-wide
|
||||
setting and the theme respectively.
|
||||
|
||||
## Site Behaviour
|
||||
|
||||
Feature toggles in `settings.yaml`:
|
||||
@@ -350,7 +420,13 @@ title: 'My Portfolio'
|
||||
subtitle: 'A visual journal'
|
||||
lang: 'en'
|
||||
|
||||
exifOnHover: true # camera/lens/settings on photo grid hover
|
||||
mode: dark # dark (default) | light | auto — what a first-time visitor sees
|
||||
exifOnHover: true # the summary overlay on photo grid hover
|
||||
exif: # which groups may be shown at all
|
||||
camera: true # body, lens, focal length
|
||||
settings: true # aperture, shutter speed, ISO
|
||||
location: true # city and country
|
||||
caption: true # the description written in Immich
|
||||
map: true # the interactive world map at /map
|
||||
transitions: true # smooth page transitions between routes
|
||||
scrollToTop: true # floating back-to-top arrow on long pages
|
||||
@@ -362,14 +438,83 @@ about:
|
||||
|
||||
| Key | Default | Notes |
|
||||
| --------------- | ------- | ------------------------------------------- |
|
||||
| `lang` | `en` | `<html lang>` and date formatting |
|
||||
| `exifOnHover` | on | |
|
||||
| `lang` | `en` | Interface language, `<html lang>`, dates |
|
||||
| `mode` | `dark` | Visitor's starting colour mode, see below |
|
||||
| `exifOnHover` | on | The grid hover overlay only, see below |
|
||||
| `exif.*` | on | Per-group EXIF visibility, see below |
|
||||
| `map` | **off** | Opt-in: must be set to `true` explicitly |
|
||||
| `transitions` | on | |
|
||||
| `scrollToTop` | on | |
|
||||
| `analytics` | on | |
|
||||
| `about.enabled` | on | Needs a `content/about.md` to show anything |
|
||||
|
||||
### Colour mode
|
||||
|
||||
`mode` decides what a visitor sees before they touch anything: `dark` (the
|
||||
default the site has always had), `light`, or `auto` to follow their operating
|
||||
system. It is rendered onto `<html>` server-side, so the first paint is already
|
||||
right rather than flipping after the page loads.
|
||||
|
||||
A visitor's own choice from the header toggle is kept in their browser and
|
||||
always wins over this — `mode` only sets the starting point. The light/dark
|
||||
switch in the admin panel previews _your_ view and does not change what
|
||||
visitors get; that is what this setting is for
|
||||
([#512](https://github.com/ralksta/immich-folio/issues/512)).
|
||||
|
||||
### EXIF visibility
|
||||
|
||||
`exif` decides what the site may publish about a photo. It governs all three
|
||||
places the data appears — the grid hover overlay, the lightbox info panel, and
|
||||
the camera line in the album header — so they cannot disagree.
|
||||
|
||||
`caption` is the description written in Immich. It used to be shown regardless
|
||||
of any setting, on the grounds that a caption is editorial rather than
|
||||
metadata — which left it as the one field nobody could switch off, and Immich
|
||||
descriptions are also where people keep private notes and whatever a decade of
|
||||
cataloguing software left behind ([#506](https://github.com/ralksta/immich-folio/issues/506)).
|
||||
It is a group like any other now.
|
||||
|
||||
`exifOnHover` predates the block and stays: it decides whether the grid shows
|
||||
its overlay at all, and it supplies the default for the three technical groups,
|
||||
so an existing `exifOnHover: false` still means "no technical EXIF anywhere". An
|
||||
explicit `exif` key always wins over it. Captions never followed that switch and
|
||||
still do not — set `caption: false` to turn them off.
|
||||
|
||||
With every group off, the lightbox withdraws its info button and the `i` key
|
||||
rather than opening an empty panel.
|
||||
|
||||
### Interface language
|
||||
|
||||
`lang` picks the language of the visitor-facing interface — navigation, the
|
||||
lightbox, the password gate, the proofing dialog, the legal notice, error pages
|
||||
— and is also emitted as `<html lang>` and used for date formatting.
|
||||
|
||||
| Value | Interface |
|
||||
| ----- | ----------------- |
|
||||
| `en` | English (default) |
|
||||
| `de` | German |
|
||||
|
||||
Any other value (`fr`, `ja`, …) still reaches `<html lang>` and
|
||||
`toLocaleDateString`, but the interface stays English: there is no dictionary
|
||||
for it yet. That is deliberate — claiming `lang="en"` on a French site would be
|
||||
worse for screen readers than an English interface on a page correctly marked
|
||||
as French.
|
||||
|
||||
Two things stay in one language by design:
|
||||
|
||||
- **The admin panel** is always English, whatever `lang` says.
|
||||
- **`/impressum`** keeps its German headings when `lang: de` and the § 5 TMG
|
||||
citation in both, because that is what the statute names. Under `lang: en`
|
||||
the footer link reads "Legal Notice" rather than "Impressum" — the page is
|
||||
optional (`legal.enabled`), and an unexplained German word in the footer of
|
||||
an English site is what drove this out of the backlog.
|
||||
|
||||
Adding a language means adding one file under `lib/i18n/locales/` and listing
|
||||
it in `SUPPORTED_LOCALES` (`lib/i18n/index.ts`). The dictionary is typed
|
||||
against the English one, so a missing key fails the type-check rather than
|
||||
falling back silently, and `lib/__tests__/i18n.test.ts` additionally catches
|
||||
keys that were copied over but never translated.
|
||||
|
||||
### Analytics
|
||||
|
||||
Page and album view counts, stored in `content/analytics.json` — no cookies, no
|
||||
@@ -447,10 +592,14 @@ protection:
|
||||
watermark:
|
||||
enabled: true
|
||||
text: '© Your Name'
|
||||
opacity: 0.5
|
||||
opacity: 0.5 # fraction, 0–1: 0.5 is 50%
|
||||
position: 'bottom-right' # bottom-right | bottom-left | center
|
||||
```
|
||||
|
||||
`opacity` is a fraction between 0 and 1, the same unit the admin slider writes.
|
||||
A value above 1 is read as a percentage (`90` means 0.9), so configs written
|
||||
that way before the unit was fixed keep working.
|
||||
|
||||
> [!NOTE]
|
||||
> Both are deterrents against casual copying, not protection. The image is in
|
||||
> the browser, and anyone who wants it can take a screenshot. The watermark is
|
||||
|
||||
@@ -10,9 +10,10 @@ A consolidated list of features and ideas for future versions, cleaned up from e
|
||||
| **Fullscreen API in lightbox** | Low | Medium — immersive (Chrome-free) |
|
||||
| **Download originals** | Low | High — client delivery (Button in lightbox) |
|
||||
| **Photo of the Day** | Low | Medium — return visits (Auto-rotate hero image) |
|
||||
| **Keyboard shortcut hints** | Low | Medium — `?` button for shortcuts (arrows, Esc, F) |
|
||||
| **Photo sharing** | Low | Medium — "Copy link" button in lightbox |
|
||||
|
||||
_Shipped: **Keyboard shortcut hints** — `?` opens the list in the lightbox, with a one-time nudge for first-time visitors._
|
||||
|
||||
---
|
||||
|
||||
## 🌍 Bigger Features & Enhancements
|
||||
@@ -23,7 +24,7 @@ A consolidated list of features and ideas for future versions, cleaned up from e
|
||||
- **EXIF analytics page** — Visual breakdown of shooting patterns: most-used focal lengths, aperture distribution.
|
||||
- **Before/After slider** — Drag-to-reveal comparison component for retouching showcases.
|
||||
- **Watermarking** — Server-side watermark overlay on proxied images.
|
||||
- **Internationalization (i18n)** — Multi-language UI support (DE/EN).
|
||||
- ~~**Internationalization (i18n)** — Multi-language UI support (DE/EN).~~ Shipped for the visitor UI; the admin panel is still English-only.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 92 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 523 KiB After Width: | Height: | Size: 405 KiB |
407
docs/superpowers/plans/2026-08-15-portfolio-features.md
Normal file
407
docs/superpowers/plans/2026-08-15-portfolio-features.md
Normal file
@@ -0,0 +1,407 @@
|
||||
# Portfolio-Features: Auffindbarkeit, Zoom, Sequenzierung, Ortsauflösung
|
||||
|
||||
> **For agentic workers:** Steps use checkbox (`- [ ]`) syntax for tracking. Die vier Tasks sind
|
||||
> **voneinander unabhängig** und können einzeln umgesetzt, getestet und gemerged werden.
|
||||
|
||||
**Goal:** Vier Vorschläge aus [`brainstorm-2026-08-15.md`](../../brainstorm-2026-08-15.md) umsetzen —
|
||||
E (Auffindbarkeit), B2 (1:1-Zoom), B1 (Sequenzierung), C1 (Ortsauflösung). Reihenfolge nach
|
||||
steigendem Risiko: erst zwei abgeschlossene Kleinigkeiten, dann der größte Eingriff, dann ein
|
||||
Datenschutz-Feature mit klarer Angriffsfläche.
|
||||
|
||||
**Architecture:** Vier getrennte Eingriffe ohne gemeinsame Dateien — bis auf `lib/config/schema.ts`
|
||||
und `lib/config/index.ts`, die von B1 und C1 beide angefasst werden (verschiedene Felder, kein
|
||||
inhaltlicher Konflikt). Jede neue Per-Album-Option folgt dem etablierten Muster: ein
|
||||
`Record<albumId, X>` in `AppConfig`, befüllt in `deriveGallery()`, genau wie `albumGrids` und
|
||||
`albumCoverPositions` es seit #431 vormachen.
|
||||
|
||||
**Tech Stack:** Next.js 16 (App Router, React 19), TypeScript strict, Vitest 4 (node environment),
|
||||
Playwright, Vanilla CSS, keine neuen Laufzeit-Dependencies.
|
||||
|
||||
---
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Keine neuen Runtime-Dependencies.** Das Projekt hat sieben; das bleibt so.
|
||||
- **TypeScript strict** — kein untypisiertes `any`; Suppressions nur als `@ts-expect-error` mit Begründung.
|
||||
- **Rohe Immich-UUIDs dürfen nie im Client-HTML/JS landen.** Immer `imageUrl()` / `encodeAssetId()`.
|
||||
- **Vanilla CSS**, Tokens aus `app/tokens.css`.
|
||||
- **Formatierung:** nur geänderte Dateien (`npx prettier --write <datei>`), nie `npm run format` im Root.
|
||||
- **Vor jedem Commit grün:** `npm run test:unit`, `npx tsc --noEmit`, `npm run lint`, `npm run build`.
|
||||
- **Baseline vor Beginn:** 443 Tests in 35 Dateien. Jede Task nennt die erwartete neue Summe.
|
||||
- **Commit-Stil:** Conventional Commits. Experimentelle Optionen als `EXPERIMENTAL` in YAML-Kommentar
|
||||
und Admin-Label markieren, so wie #431 es gemacht hat.
|
||||
- **Neue Env-Variablen** gehören in `lib/env.ts` **und** `.env.local.example`.
|
||||
- **Vitest läuft im `node`-Environment** — es gibt kein jsdom. Komponentenlogik ist deshalb nur
|
||||
testbar, wenn die _reine_ Berechnung in einem eigenen Modul liegt; die DOM-Verdrahtung deckt
|
||||
Playwright ab. Das ist bei B1 und B2 jeweils eingeplant und ausdrücklich **kein** Verstoß gegen
|
||||
die CLAUDE.md-Regel (die verbietet, _Route_-Logik nur der Testbarkeit wegen nach `lib/` zu ziehen —
|
||||
hier geht es um echte, wiederverwendbare Geometrie- bzw. Gruppierungsfunktionen).
|
||||
|
||||
---
|
||||
|
||||
## Task E — Auffindbarkeit: Site-URL, `robots.txt`, `sitemap.xml`, JSON-LD
|
||||
|
||||
**Aufwand:** S (die Vorbedingung E1 und die Admin-UI machen daraus mehr als das im Brainstorm
|
||||
geschätzte XS.)
|
||||
|
||||
### Befund vorab: es fehlt eine Site-URL
|
||||
|
||||
`lib/env.ts` kennt keine `SITE_URL`, `settings.yaml` keine `url:`, und `app/layout.tsx` setzt
|
||||
**keine `metadataBase`**. OG-Bilder werden heute als relativer Pfad angegeben
|
||||
(`app/layout.tsx:57`). Für `sitemap.xml` ist das ein hartes Hindernis: Der Sitemap-Standard
|
||||
verlangt absolute URLs, und `app/sitemap.ts` läuft ohne Request-Kontext — der Host lässt sich also
|
||||
nicht aus Headern ableiten.
|
||||
|
||||
**Entscheidung:** `url` in `settings.yaml`, überschreibbar durch `SITE_URL`. Env gewinnt — dasselbe
|
||||
Präzedenzmuster wie bei Titel und Credentials. In `settings.yaml`, **weil der Wert im Admin-Panel
|
||||
setzbar sein muss**: Wer per `docker run` startet, soll die Site-URL nicht in einer Env-Variable
|
||||
nachtragen und neu starten müssen, sondern sie dort eingeben, wo er ohnehin Titel und SEO pflegt.
|
||||
|
||||
### Die Falle dabei — und warum E1b im Plan steht
|
||||
|
||||
Env gewinnt über YAML. Setzt jemand `SITE_URL` **und** trägt im Admin etwas anderes ein, speichert
|
||||
das Panel brav, ändert aber nichts — der Wert wird stillschweigend überstimmt. Genau dieser Zustand
|
||||
existiert heute schon unbemerkt bei `SITE_TITLE`/`SITE_SUBTITLE`: `lib/env.ts:98-99` schlägt
|
||||
`settings.yaml` und **das Admin-Panel sagt es nirgends**. Eine repo-weite Suche findet in
|
||||
`SettingsEditor.tsx`, `AdminDashboard.tsx` und `/api/admin/status` keinen einzigen Hinweis auf
|
||||
Env-Überschreibungen.
|
||||
|
||||
Weil dieses Feld nun ausdrücklich über die UI bedienbar sein soll, wird die Anzeige mitgebaut —
|
||||
klein, generisch und für die bestehenden Felder gleich mitverwendet.
|
||||
|
||||
### Schritte
|
||||
|
||||
- [ ] **E1 — Site-URL einführen**
|
||||
- `SITE_URL` in `lib/env.ts` (`interface Env` + `parseEnv()`), validiert über `new URL()`,
|
||||
trailing Slash entfernt — analog zur bestehenden Behandlung von `IMMICH_API_URL:26-32`.
|
||||
- `url?: string` in `SettingsYaml` (`lib/config/schema.ts`) und `siteUrl: string` in `AppConfig`.
|
||||
- Auflösung in `lib/config/index.ts`: `env.SITE_URL || settings.url || ''`.
|
||||
- `metadataBase` in `app/layout.tsx` setzen, wenn `siteUrl` nicht leer ist.
|
||||
- In `settings.yaml.example` dokumentieren, inklusive des Hinweises, dass ohne diesen Wert
|
||||
Sitemap und JSON-LD entfallen.
|
||||
- [ ] **E1a — Feld im Admin-Panel** (`app/admin/components/SettingsEditor.tsx`, Sektion `seo`,
|
||||
`SETTINGS_SECTIONS:197`)
|
||||
- Textfeld **„Site URL"** als erstes Feld der SEO-Sektion, über „SEO Title" — es ist die
|
||||
Voraussetzung für alles andere dort. Anbindung wie die Nachbarfelder: `update('url', …)`.
|
||||
(Achtung: `url` liegt auf oberster Ebene, nicht unter `seo.` — sonst landet es beim Auflösen
|
||||
an der falschen Stelle.)
|
||||
- Placeholder `https://folio.example.com`, Hilfetext: wofür der Wert gebraucht wird (Sitemap,
|
||||
`robots.txt`, JSON-LD, absolute OG-Bild-URLs).
|
||||
- **Validierung im Feld, nicht erst beim Speichern:** muss mit `http://` oder `https://`
|
||||
beginnen und über `new URL()` parsebar sein; trailing Slash beim Speichern abschneiden, damit
|
||||
aus Nutzersicht egal ist, ob er einen setzt. Ungültige Eingabe blockiert das Speichern mit
|
||||
einer Meldung am Feld — eine kaputte Site-URL erzeugt sonst eine Sitemap voller kaputter Links.
|
||||
- Leeres Feld ist ein gültiger Zustand (= Funktionen aus, wie heute), keine Fehlermeldung.
|
||||
- Ein Hinweis in der SEO-Sektion, solange das Feld leer ist: „Ohne Site URL werden `sitemap.xml`
|
||||
und JSON-LD nicht ausgeliefert." Sonst sucht jemand den Fehler an der falschen Stelle.
|
||||
- [ ] **E1b — Env-Überschreibungen im Panel sichtbar machen** (klein, generisch)
|
||||
- `GET /api/admin/settings` (`app/api/admin/settings/route.ts:18-19`) gibt zusätzlich
|
||||
`envLocked: string[]` zurück — die Namen der Felder, die gerade aus der Umgebung kommen.
|
||||
Anfangs: `url` (wenn `SITE_URL` gesetzt), `title` (`SITE_TITLE`), `subtitle` (`SITE_SUBTITLE`).
|
||||
**Nur Feldnamen, keine Werte** — die Route soll keine Env-Inhalte spiegeln, die sonst nirgends
|
||||
im Panel stehen.
|
||||
- `SettingsEditor` rendert betroffene Felder deaktiviert mit dem Zusatz „Von der Umgebungsvariable
|
||||
`SITE_URL` gesetzt" statt eines editierbaren Eingabefelds. Damit ist der stille Fehlschlag
|
||||
weg — beim neuen Feld und bei Titel/Untertitel gleich mit.
|
||||
- Das ist die erste Stelle dieser Art im Projekt; entsprechend klein halten (eine Liste, ein
|
||||
`disabled`, ein Hinweistext) und nicht zu einem Mechanismus ausbauen.
|
||||
- [ ] **E2 — `app/robots.ts`**
|
||||
- `Disallow` für `/admin`, `/install`, `/api`.
|
||||
- `seo.noIndex: true` → `Disallow: /` für alle Agents.
|
||||
- `sitemap:`-Verweis nur, wenn `siteUrl` gesetzt ist.
|
||||
- [ ] **E3 — `app/sitemap.ts`**
|
||||
- Gibt `[]` zurück, wenn `siteUrl` leer ist oder `seo.noIndex` gilt — kein halber Zustand.
|
||||
- Enthalten: `/`, `/about` (nur wenn `aboutEnabled`), `/map` (nur wenn `config.map`),
|
||||
`/journal` und je Eintrag `/journal/<slug>`, Subpages und Alben.
|
||||
- **Ausschlussliste — der eigentliche Inhalt dieser Task:**
|
||||
| Ausgeschlossen | Quelle |
|
||||
| ----------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| Subpage mit `password` | `SubpageConfig.password` |
|
||||
| Subpage mit `hidden: true` | `SubpageConfig.hidden` |
|
||||
| Subpage mit `enabled: false` | `SubpageConfig.enabled` |
|
||||
| Album mit eigenem Passwort | `config.albumPasswords` |
|
||||
| Album unterhalb einer geschützten/versteckten Subpage | Vererbung, siehe unten |
|
||||
| Journal-Draft | `frontmatter.draft` |
|
||||
| Journal-Eintrag mit Passwort | Frontmatter |
|
||||
| `/impressum` | trägt bereits `robots: noindex` (`app/impressum/page.tsx:14`) |
|
||||
- **Vererbung nicht vergessen:** Ein öffentliches Album unter einer passwortgeschützten Subpage
|
||||
ist über `/<subpage>/<album>` nicht erreichbar. Die Prüfung muss den Pfad entlanglaufen, nicht
|
||||
nur das Album ansehen.
|
||||
- `lastModified` aus `album.updatedAt` bzw. dem Journal-Datum.
|
||||
- [ ] **E4 — JSON-LD**
|
||||
- `Person` bzw. `ProfilePage` auf `/about`, gespeist aus dem Frontmatter von `content/about.md`.
|
||||
- `ImageObject` mit `creator` und optional `license` auf Album-Detailseiten.
|
||||
- **CSP-Falle:** `proxy.ts` setzt eine CSP mit Per-Request-Nonce und reicht sie über den
|
||||
`x-nonce`-Header an die Seiten. Ein `<script type="application/ld+json">` **muss** diese Nonce
|
||||
tragen, sonst blockt der Browser es still. Im Browser mit gesetzter CSP verifizieren, nicht nur
|
||||
im Markup nachsehen.
|
||||
- `license` als optionales Feld in `settings.yaml` (`seo.license`), leer = weglassen.
|
||||
- [ ] **E5 — Tests**
|
||||
- `lib/__tests__/sitemap.test.ts`: **tabellengetrieben über die Ausschlusskategorien**, nicht als
|
||||
Einzelfälle — dann schlägt der Test auch bei einer künftig hinzugefügten Sichtbarkeitsregel an.
|
||||
Vorbild: `app/api/admin/__tests__/admin-guards.test.ts`. Zusätzlich: leere Sitemap bei fehlender
|
||||
`siteUrl`; `noIndex` unterdrückt alles.
|
||||
- `lib/__tests__/site-url.test.ts`: Präzedenz `SITE_URL` > `settings.url` > leer; trailing Slash
|
||||
wird entfernt; ungültige URL führt nicht zum Absturz, sondern zu „nicht gesetzt".
|
||||
- `app/api/admin/__tests__/settings-env-lock.test.ts`: `envLocked` enthält `url` genau dann, wenn
|
||||
`SITE_URL` gesetzt ist — und die Antwort enthält **nirgends den Env-Wert selbst**.
|
||||
- Erwartet: **+18 bis +24 Tests** (461–467 gesamt).
|
||||
|
||||
**Verifikation:**
|
||||
|
||||
1. **Ohne** `SITE_URL`: Im Admin unter SEO die Site URL eintragen, speichern, dann `/sitemap.xml`
|
||||
und `/robots.txt` abrufen — beide müssen ohne Neustart die neue Domain führen (`invalidateConfigCache()`
|
||||
läuft beim Speichern bereits). Feld leeren → Sitemap wird wieder leer, Hinweistext erscheint.
|
||||
2. Ungültige Eingaben gegenprüfen: `folio.example.com` ohne Schema und `https://` allein müssen am
|
||||
Feld abgewiesen werden, nicht erst beim Rendern der Sitemap auffallen.
|
||||
3. **Mit** gesetztem `SITE_URL`: Das Feld muss deaktiviert sein und die Herkunft nennen — dasselbe
|
||||
für Titel/Untertitel bei gesetztem `SITE_TITLE`.
|
||||
4. Eine passwortgeschützte Subpage anlegen und prüfen, dass weder ihr Slug noch ihre Alben in der
|
||||
Sitemap auftauchen.
|
||||
5. JSON-LD im Rich-Results-Test von Google gegenprüfen; Browser-Konsole auf CSP-Fehler ansehen.
|
||||
|
||||
**Commits:** `feat(seo): add a site URL setting, editable in the admin panel` ·
|
||||
`feat(seo): add robots.txt, sitemap.xml and JSON-LD`
|
||||
|
||||
---
|
||||
|
||||
## Task B2 — 1:1-Zoom im Lightbox
|
||||
|
||||
**Aufwand:** S
|
||||
|
||||
### Befund
|
||||
|
||||
Der Lightbox rendert `current.previewUrl` (`components/Lightbox.tsx:237`), während sein Dateikopf
|
||||
„Full-resolution image display" verspricht (`:5`). `imageUrl()` steht per Default auf `'preview'`
|
||||
(`lib/urls.ts:29`), und **keine Stelle in `app/`, `components/` oder `lib/urls.ts` fordert
|
||||
`'original'` an** — die oberste Stufe aus `lib/imageSize.ts` ist im Frontend unerreichbar.
|
||||
|
||||
Als Default ist das richtig. Für Schärfebeurteilung fehlt der Weg dorthin.
|
||||
|
||||
### Schritte
|
||||
|
||||
- [ ] **B2.1 — `originalUrl` durchreichen**
|
||||
- `originalUrl?: string` in `PhotoItem` (`app/[...path]/PhotoGrid.tsx:18-33`) — **optional**,
|
||||
damit `JournalStudio.tsx:616-617` (Admin-Thumbnails über eine andere Route) unverändert bleibt.
|
||||
- Befüllen an den zwei öffentlichen Stellen: `app/[...path]/page.tsx:117-118` und
|
||||
`app/journal/[slug]/page.tsx:179-180`, jeweils `imageUrl(a.id, 'original')`.
|
||||
- Fehlt `originalUrl`, wird der Zoom-Knopf nicht gerendert. Kein Fallback auf Preview — ein Zoom,
|
||||
der auf ein Preview zoomt, ist genau die Verwechslung, die es zu beheben gilt.
|
||||
- [ ] **B2.2 — Zoom-Geometrie als reines Modul** (`lib/zoom.ts`)
|
||||
- `clampPan(offset, scale, viewport, natural)` → begrenzt den Bildausschnitt auf die Bildkanten.
|
||||
- `zoomAt(point, scale, offset, factor)` → zoomt auf den Cursor/Pinch-Mittelpunkt statt auf die
|
||||
Bildmitte. Das ist der Unterschied zwischen brauchbar und ärgerlich.
|
||||
- Pure Funktionen, keine DOM-Abhängigkeit — direkt unit-testbar.
|
||||
- [ ] **B2.3 — Verdrahtung im Lightbox**
|
||||
- Zustand: `zoomed` (bool), `scale`, `offset`. Auslöser: Doppelklick/Doppeltipp, Pinch, Taste `z`;
|
||||
zusätzlich ein sichtbarer Knopf neben dem Info-Toggle.
|
||||
- **Das Original wird erst bei dieser Geste geladen**, nie vorher. Die bestehende
|
||||
Nachbar-Vorladung (`Lightbox.tsx:96-105`) bleibt auf `previewUrl` und wird **nicht** angefasst —
|
||||
sonst zieht sich jeder Besucher unbemerkt Originale.
|
||||
- Ladeindikator, solange das Original unterwegs ist; das Preview bleibt so lange skaliert stehen.
|
||||
- Verlassen: `Esc` (erste Stufe verlässt den Zoom, nicht den Lightbox), Doppelklick, Knopf.
|
||||
- Zurücksetzen bei jedem Bildwechsel — sonst hängt der Zoomfaktor des vorherigen Bildes am nächsten.
|
||||
- **Konflikte prüfen:** `useSwipe` (Pinch/Pan darf nicht als Wisch durchgehen) und
|
||||
`AssetProtection` (`dragstart` ist global unterdrückt — Pan muss über Pointer-Events laufen,
|
||||
nicht über natives Dragging).
|
||||
- Tastatur: `+` / `-` / `0`, `aria-label` am Knopf, Zoomzustand über `aria-pressed`.
|
||||
- [ ] **B2.4 — Kommentar korrigieren**
|
||||
- `components/Lightbox.tsx:5` beschreibt eine Absicht, kein Verhalten. Auf „Preview-tier image,
|
||||
with opt-in 1:1 zoom to the original" ändern.
|
||||
- [ ] **B2.5 — Tests**
|
||||
- `lib/__tests__/zoom.test.ts`: Clamping an allen vier Kanten, Zoom auf Punkt, Rückkehr auf
|
||||
`scale: 1` zentriert das Bild wieder. Erwartet: **+10 bis +12 Tests**.
|
||||
- `e2e/lightbox-zoom.spec.ts`: Zoom öffnet, lädt eine `?size=original`-URL (Request abfangen),
|
||||
Pan bleibt in den Grenzen, Bildwechsel setzt zurück.
|
||||
|
||||
**Verifikation:** Netzwerk-Tab — beim Öffnen des Lightbox darf **keine** `size=original`-Anfrage
|
||||
stehen, erst nach der Zoom-Geste. Auf einem Touchgerät gegenprüfen, dass Pinch nicht mehr als Wisch
|
||||
zum nächsten Bild interpretiert wird.
|
||||
|
||||
**Commit:** `feat(lightbox): add opt-in 1:1 zoom against the original asset`
|
||||
|
||||
---
|
||||
|
||||
## Task B1 — Sequenzierung wie im Fotobuch
|
||||
|
||||
**Aufwand:** M — der größte Eingriff hier, und der einzige mit UI-Anteil im Admin.
|
||||
|
||||
### Befund
|
||||
|
||||
`PhotoGrid` bildet die Assets flach auf ein einziges `.photo-grid`-Element ab
|
||||
(`app/[...path]/PhotoGrid.tsx:132-243`); das Layout gilt für alle gleich. `assetOrder`
|
||||
(`lib/config/schema.ts:224`) bestimmt nur die Reihenfolge. Die Essay-Ansicht kennt `fullbleed`,
|
||||
`wide` und Paare bereits (`lib/journal.ts`) — das Album nicht.
|
||||
|
||||
### Designentscheidung: Hinweise leben in `assetOrder`
|
||||
|
||||
Eine zweite, parallele Liste (`sequence:`) würde die Reihenfolge doppelt führen und damit
|
||||
zwangsläufig auseinanderlaufen. Stattdessen wird `assetOrder` erweitert — Sequenzierung setzt
|
||||
ohnehin `sort: manual` voraus:
|
||||
|
||||
```yaml
|
||||
- 'album-uuid':
|
||||
sort: manual
|
||||
assetOrder:
|
||||
- 'asset-opening': fullbleed # allein, volle Breite
|
||||
- 'asset-a': pair # diese beiden nebeneinander,
|
||||
- 'asset-b': pair # auf gleiche Höhe gebracht
|
||||
- 'asset-c' # unverändert im normalen Raster
|
||||
- break # bewusste Lücke
|
||||
```
|
||||
|
||||
`deriveGallery()` erzeugt daraus **zwei** Strukturen: `albumManualOrders` bleibt exakt wie heute
|
||||
eine reine UUID-Liste (`break` herausgefiltert), damit `lib/albumSort.ts` unverändert
|
||||
weiterfunktioniert — und neu `albumSequences: Record<albumId, SequenceEntry[]>` mit den Hinweisen.
|
||||
|
||||
### Die Invariante, an der dieses Feature scheitern kann
|
||||
|
||||
Der Lightbox-Index und die Permalinks `#photo-N` sind **positional** über `displayedAssets`
|
||||
(`app/[...path]/PhotoGrid.tsx:51-107`, `openLightbox(index)`). Blockbildung darf deshalb
|
||||
**ausschließlich gruppieren, niemals umsortieren, nichts auslassen und nichts doppeln**. Wird das
|
||||
verletzt, öffnet ein Klick das falsche Foto und jeder geteilte Permalink zeigt woanders hin — ein
|
||||
Fehler, der beim Entwickeln kaum auffällt und beim Kunden sofort.
|
||||
|
||||
### Schritte
|
||||
|
||||
- [ ] **B1.1 — Schema & Parser**
|
||||
- `SequenceHint = 'fullbleed' | 'pair' | 'break'`; `assetOrder?: Array<string | Record<string, string> | 'break'>`
|
||||
in `AlbumEntryObject`.
|
||||
- In `deriveGallery()` (`lib/config/index.ts:162-236`, direkt neben der `coverPosition`-Validierung):
|
||||
unbekannte Hinweise **werfen** mit nennender Meldung — dieselbe Linie wie beim `sort`-Wert, wo
|
||||
ein Tippfehler bewusst nicht still durchgeht.
|
||||
- `albumManualOrders` bleibt unverändert UUID-only; `albumSequences` kommt dazu (beides in
|
||||
`AppConfig`, `GalleryDerivation` und den drei Rückgabestellen in `lib/config/index.ts`).
|
||||
- Ein einzelnes `pair` ohne Partner fällt auf normales Verhalten zurück, statt zu werfen —
|
||||
beim Umsortieren im Admin ist dieser Zustand ein normaler Zwischenschritt.
|
||||
- [ ] **B1.2 — Blockbildung als reines Modul** (`lib/sequence.ts`)
|
||||
- `buildSequenceBlocks(assets, hints)` → `Array<{ kind: 'grid' | 'pair' | 'fullbleed' | 'break', items: Array<{ asset, index }> }>`.
|
||||
- **Jedes Item trägt seinen ursprünglichen Index mit.** Das ist die technische Absicherung der
|
||||
Invariante oben.
|
||||
- Aufeinanderfolgende Assets ohne Hinweis landen in einem gemeinsamen `grid`-Block, damit das
|
||||
bestehende Layout (Masonry, Justified …) darin unverändert greift.
|
||||
- [ ] **B1.3 — Rendering**
|
||||
- `PhotoGrid` rendert Blöcke statt einer flachen Liste; `openLightbox(item.index)` statt
|
||||
`openLightbox(index)`.
|
||||
- CSS: `.photo-sequence__pair` (zwei Spalten, auf gleiche Höhe normiert über die vorhandenen
|
||||
Aspect-Ratios), `.photo-sequence__fullbleed`, `.photo-sequence__break`. Mobil kollabieren Paare
|
||||
auf eine Spalte.
|
||||
- Ohne Hinweise entsteht genau ein `grid`-Block — das Markup bleibt dann identisch zu heute.
|
||||
**Das ist die Rückfallgarantie und gehört als Test festgeschrieben.**
|
||||
- [ ] **B1.4 — Admin**
|
||||
- `AssetOrderEditor.tsx` bekommt pro Foto eine Hinweis-Auswahl und einen Trenner-Einfügen-Knopf.
|
||||
- Eine Andeutung der Blöcke in der Sortierliste (Paare zusammengefasst, Trenner als Linie).
|
||||
**Ohne diese Vorschau ist das Feature im Admin unbenutzbar** — das ist die halbe Arbeit an der
|
||||
Task, nicht das YAML-Schema.
|
||||
- [ ] **B1.5 — Tests**
|
||||
- `lib/__tests__/sequence.test.ts`: Indizes bleiben lückenlos und in Reihenfolge (die Invariante,
|
||||
als expliziter Test über ein Album mit allen Hinweisarten); leere Hinweise → genau ein Block;
|
||||
verwaistes `pair`; `break` am Anfang und am Ende; Hinweis auf ein Asset, das gar nicht im Album
|
||||
ist.
|
||||
- `lib/__tests__/config-sequence.test.ts`: Parser akzeptiert die neue Form, wirft bei unbekanntem
|
||||
Hinweis, `albumManualOrders` bleibt UUID-only.
|
||||
- Erwartet: **+20 bis +25 Tests**.
|
||||
- `e2e/sequence.spec.ts`: Klick auf das dritte Foto in einem sequenzierten Album öffnet das dritte
|
||||
Foto; `#photo-3` landet auf demselben.
|
||||
|
||||
**Verifikation:** Ein Album mit `fullbleed`, einem Paar und einem `break` anlegen, im Browser
|
||||
ansehen, jedes Foto anklicken und den Lightbox-Zähler gegen die visuelle Position prüfen. Ein Album
|
||||
**ohne** Hinweise gegen `git stash` vergleichen: das Markup muss identisch sein.
|
||||
|
||||
**Commit:** `feat(album): sequence photos into pairs, spreads and breaks (EXPERIMENTAL)`
|
||||
|
||||
---
|
||||
|
||||
## Task C1 — Ortsauflösung pro Album
|
||||
|
||||
**Aufwand:** S
|
||||
|
||||
### Befund, präzise
|
||||
|
||||
Die Map ist besser gebaut als erwartet: Passwortgeschützte Alben werden **vor** der Aggregation
|
||||
gefiltert (`app/api/map/route.ts:63-90`), und `lib/mapService.ts:5-11` hält Alben ausdrücklich
|
||||
getrennt, damit genau das möglich ist. Die öffentliche EXIF-Route gibt Koordinaten gar nicht heraus
|
||||
— die Map ist die einzige öffentliche GPS-Fläche.
|
||||
|
||||
Die Lücke betrifft **öffentliche** Alben: Die Marker-Position ist der Mittelwert der tatsächlichen
|
||||
Koordinaten (`app/api/map/route.ts:97-104`). Bei einem Album von _einem_ Ort — Garten, Studio,
|
||||
Wohnung eines Kunden, empfindlicher Naturstandort — ist dieser Mittelwert genau dieser Ort. Der
|
||||
Marker heißt nach der Stadt und steht auf dem Grundstück.
|
||||
|
||||
### Zwei Entwurfsentscheidungen, die vorab feststehen müssen
|
||||
|
||||
**1. Keine Geodaten-Abhängigkeit.** Für „Stadtzentrum" bräuchte es einen Ortsdatensatz. Stattdessen
|
||||
wird die Koordinate **gerastert**: `city` auf 0,05° (~5 km), `country` auf 1° (~110 km). Das ist
|
||||
dependency-frei, erklärbar und ausreichend — 5 km trennen zuverlässig ein Grundstück von einem
|
||||
Stadtgebiet.
|
||||
|
||||
**2. Marker fassen mehrere Alben zusammen.** Liegen an einer Stadt ein `exact`- und ein
|
||||
`city`-Album, gibt es trotzdem nur einen Marker. Regel: **die strengste Stufe unter den
|
||||
beitragenden Alben gewinnt.** Sicherheitsentscheidungen fallen in die vorsichtige Richtung, und die
|
||||
Alternative — Marker aufspalten — würde über die Kartenansicht verraten, dass ein Album
|
||||
zurückhaltender eingestellt ist.
|
||||
|
||||
### Schritte
|
||||
|
||||
- [ ] **C1.1 — Konfiguration**
|
||||
- `location?: 'exact' | 'city' | 'country' | 'hidden'` in `AlbumEntryObject` und in
|
||||
`SubpageObjectValue`/`SubpageConfig`.
|
||||
- `albumLocationModes: Record<string, LocationMode>` in `AppConfig`, befüllt in `deriveGallery()`
|
||||
neben `albumCoverPositions`; unbekannter Wert wirft.
|
||||
- Präzedenz wie beim Grid: global (neu: `map.locationDefault` in `settings.yaml`, Default
|
||||
`exact` = heutiges Verhalten) < Subpage < Album.
|
||||
- [ ] **C1.2 — `lib/mapService.ts`**
|
||||
- Alben mit `hidden` überspringen, bevor gebucketet wird — das ist die einzige Stufe, die gar
|
||||
nicht erst in den Cache soll.
|
||||
- `locationMode` auf `MapAlbumEntry` mitführen. **Nicht** hier schon runden: gerundete Werte
|
||||
aufsummieren und danach mitteln ergibt wieder ungerundete Koordinaten.
|
||||
- [ ] **C1.3 — `app/api/map/route.ts`**
|
||||
- Strengste Stufe der sichtbaren Alben je Marker bestimmen.
|
||||
- Mittelwert bilden wie heute, **danach** rastern: `city` → 0,05°, `country` → 1°, `exact` → unverändert.
|
||||
- Bei `country` das Stadtlabel weglassen (die Popup-Überschrift wäre sonst genauer als die Position).
|
||||
- Ist ein Marker nach dem Ausschluss leer, entfällt er — der Pfad existiert bereits (`allowedAlbums.length === 0`).
|
||||
- [ ] **C1.4 — Admin & Doku**
|
||||
- Auswahlfeld pro Album und pro Subpage im `PageBuilder`, Standard in `SettingsEditor`.
|
||||
- `gallery.yaml.example` und `settings.yaml.example` ergänzen, mit einem Satz dazu, **warum** es
|
||||
das gibt.
|
||||
- [ ] **C1.5 — Tests** (`lib/__tests__/map-location.test.ts`)
|
||||
- Rasterung: eine Koordinate auf einem Grundstück landet bei `city` sichtbar daneben, bleibt aber
|
||||
in derselben Stadt; `exact` verändert nichts.
|
||||
- Strengste Stufe gewinnt (alle sechs Paarungen).
|
||||
- `hidden` erscheint in keiner Ausgabe — auch dann nicht, wenn ein anderes Album dieselbe Stadt beisteuert.
|
||||
- Präzedenz global < Subpage < Album.
|
||||
- Erwartet: **+14 bis +18 Tests**.
|
||||
|
||||
**Verifikation:** Ein Album mit GPS-Daten von einem einzigen Ort anlegen, `/api/map` einmal mit
|
||||
`exact` und einmal mit `city` abrufen und die Koordinaten vergleichen; auf der Karte gegenprüfen,
|
||||
dass der Marker die Stadt trifft, aber nicht mehr die Adresse. Zusätzlich sicherstellen, dass ein
|
||||
`hidden`-Album auch nach einem Cache-Durchlauf nicht auftaucht (`POST /api/admin/reload`).
|
||||
|
||||
**Commit:** `feat(map): make location precision configurable per album`
|
||||
|
||||
---
|
||||
|
||||
## Reihenfolge und Zusammenhänge
|
||||
|
||||
```
|
||||
E (Auffindbarkeit) ── unabhängig, bringt die Site-URL, die später auch ein Feed bräuchte
|
||||
B2 (Zoom) ── unabhängig, erste Stelle die Originale ausliefert
|
||||
B1 (Sequenzierung) ── größter Eingriff, berührt die Lightbox-Index-Invariante
|
||||
C1 (Ortsauflösung) ── unabhängig, Datenschutz
|
||||
↑ B1 und C1 fassen beide lib/config/{schema,index}.ts an,
|
||||
aber verschiedene Felder — nacheinander mergen, nicht parallel
|
||||
```
|
||||
|
||||
Vier PRs, in dieser Reihenfolge. E und B2 sind jeweils an einem Tag abgeschlossen und liefern
|
||||
sofort etwas Sichtbares; B1 braucht am meisten Sorgfalt und sollte nicht unter Zeitdruck; C1 ist
|
||||
klein, aber sicherheitsrelevant und verdient einen eigenen, ruhigen Review.
|
||||
|
||||
**Erwartete Testsumme am Ende:** 443 → etwa **516**.
|
||||
|
||||
## Was dieser Plan bewusst nicht enthält
|
||||
|
||||
Aus dem Brainstorm bleiben offen und sind hier **nicht** eingeplant: A1 (Client-Links), A2
|
||||
(Anfrage-Formular), A3 (Proofing-Notizen), B3 (Farbmanagement-Messung), C2 (terminierte
|
||||
Veröffentlichung) und D (RSS-Feed). Falls C2 und D später kommen: Beide brauchen dieselbe
|
||||
Sichtbarkeitsprüfung, die in **E3** entsteht. Sie sollte dort so geschnitten werden, dass ein
|
||||
zweiter Aufrufer sie wiederverwenden kann, statt sie zu kopieren.
|
||||
@@ -225,6 +225,30 @@ The same album in five of the grid layouts (theme `studio-modern`):
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### Album cover spacing
|
||||
|
||||
The album-cover grid on a subpage is sized by two CSS custom properties on
|
||||
`.subpage-grid`:
|
||||
|
||||
| Property | Set by |
|
||||
| ------------------- | -------------------------------------------------------------------- |
|
||||
| `--subpage-columns` | the app, from the resolved `grid.columns` (site-wide or per subpage) |
|
||||
| `--subpage-gap` | the **theme preset** — a per-subpage `grid.gap` overrides it |
|
||||
|
||||
The column count follows the configuration, the spacing belongs to the look.
|
||||
`monograph` sets 1px, `minimal` and `editorial` 2px, `studio-modern` 20px;
|
||||
`classic` and `noir` set nothing and get the 4px default from `globals.css`.
|
||||
A custom preset does the same:
|
||||
|
||||
```css
|
||||
[data-preset='mytheme'] .subpage-grid {
|
||||
--subpage-gap: 12px;
|
||||
}
|
||||
```
|
||||
|
||||
Do not set `gap` directly there — an explicit per-page gap is passed as an
|
||||
inline custom property and would no longer win.
|
||||
|
||||
## Examples
|
||||
|
||||
### Minimal portfolio
|
||||
|
||||
64
e2e/lightbox-fullscreen.spec.ts
Normal file
64
e2e/lightbox-fullscreen.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
55
e2e/lightbox-shortcuts.spec.ts
Normal file
55
e2e/lightbox-shortcuts.spec.ts
Normal 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
25
instrumentation.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
107
lib/__tests__/album-meta.test.ts
Normal file
107
lib/__tests__/album-meta.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
26
lib/__tests__/color-mode.test.ts
Normal file
26
lib/__tests__/color-mode.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
60
lib/__tests__/cover-grid.test.ts
Normal file
60
lib/__tests__/cover-grid.test.ts
Normal 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`);
|
||||
});
|
||||
});
|
||||
218
lib/__tests__/doctor.test.ts
Normal file
218
lib/__tests__/doctor.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
51
lib/__tests__/example-files.test.ts
Normal file
51
lib/__tests__/example-files.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
38
lib/__tests__/exif-camera.test.ts
Normal file
38
lib/__tests__/exif-camera.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
92
lib/__tests__/exif-display.test.ts
Normal file
92
lib/__tests__/exif-display.test.ts
Normal 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
110
lib/__tests__/i18n.test.ts
Normal 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));
|
||||
}
|
||||
});
|
||||
});
|
||||
123
lib/__tests__/immich-setup-state.test.ts
Normal file
123
lib/__tests__/immich-setup-state.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
71
lib/__tests__/install-hero.test.ts
Normal file
71
lib/__tests__/install-hero.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
191
lib/__tests__/site-password.test.ts
Normal file
191
lib/__tests__/site-password.test.ts
Normal 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 = [];
|
||||
});
|
||||
});
|
||||
37
lib/__tests__/watermark-opacity.test.ts
Normal file
37
lib/__tests__/watermark-opacity.test.ts
Normal 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
Reference in New Issue
Block a user