From 10ec68f49391b4a9f29b2700ffcd34ea6864ebba Mon Sep 17 00:00:00 2001 From: Ralksta <16941050+ralksta@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:18:14 +0200 Subject: [PATCH] release: v0.12.0 (#520) Forty-one commits since v0.11.0. See CHANGELOG.md for the full list and the upgrade notes. --- .env.local.example | 20 +- .gitignore | 3 + CHANGELOG.md | 234 +++++++- CLAUDE.md | 197 ------- README.md | 5 +- app/[...path]/AlbumDetailView.tsx | 41 +- app/[...path]/EssayView.tsx | 12 +- app/[...path]/PhotoGrid.tsx | 28 +- app/[...path]/SubpageGridView.tsx | 36 +- app/[...path]/loading.tsx | 4 +- app/[...path]/page.tsx | 47 +- app/about/page.tsx | 11 +- app/admin/admin.css | 317 ++++++++++ app/admin/components/AdminDashboard.tsx | 85 ++- app/admin/components/AlbumPicker.tsx | 9 + app/admin/components/DoctorModal.tsx | 131 +++++ app/admin/components/PageBuilder.tsx | 96 ++- app/admin/components/SettingsEditor.tsx | 553 ++++++++++++++---- app/api/__tests__/health-config.test.ts | 71 +++ app/api/__tests__/site-gate.test.ts | 137 +++++ app/api/admin/__tests__/admin-guards.test.ts | 9 + app/api/admin/__tests__/doctor-guards.test.ts | 91 +++ app/api/admin/__tests__/status.test.ts | 85 ++- .../admin/albums/[albumId]/assets/route.ts | 5 +- app/api/admin/albums/route.ts | 13 +- app/api/admin/assets/route.ts | 5 +- app/api/admin/doctor/route.ts | 147 +++++ app/api/admin/status/route.ts | 17 +- app/api/admin/thumbnail/[id]/route.ts | 5 +- app/api/auth/route.ts | 20 +- app/api/exif/[id]/route.ts | 71 ++- app/api/exif/__tests__/exif-route.test.ts | 73 ++- app/api/health/route.ts | 32 +- app/api/image/[id]/route.ts | 6 + app/api/install/route.ts | 40 ++ app/api/map/route.ts | 7 +- app/api/og/route.tsx | 6 + app/api/video/[id]/route.ts | 6 + app/error.tsx | 15 +- app/gate/page.tsx | 28 + app/global-error.tsx | 97 +++ app/globals.css | 23 +- app/impressum/page.tsx | 44 +- app/journal/[slug]/page.tsx | 13 +- app/journal/page.tsx | 36 +- app/layout.tsx | 117 ++-- app/loading.tsx | 4 +- app/map/page.tsx | 19 +- app/not-found.tsx | 10 +- app/page.tsx | 19 +- app/themes/editorial.css | 5 +- app/themes/minimal.css | 8 +- app/themes/monograph.css | 5 +- app/themes/studio-modern.css | 3 +- components/BackLink.tsx | 4 +- components/Footer.tsx | 4 +- components/I18nProvider.tsx | 29 + components/Lightbox.module.css | 143 ++++- components/Lightbox.tsx | 197 ++++++- components/MapView.tsx | 12 +- components/PasswordGate.tsx | 20 +- components/ProofingContext.tsx | 10 +- components/ProofingModal.tsx | 29 +- components/ScrollToTop.tsx | 6 +- components/SetupScreen.tsx | 8 +- components/SubpageNav.tsx | 4 +- components/ThemeToggle.tsx | 38 +- content/gallery.yaml.example | 28 +- content/settings.yaml.example | 36 +- docs/admin-panel.md | 3 +- docs/brainstorm-2026-08-05.md | 5 +- docs/brainstorm-2026-08-15.md | 396 +++++++++++++ docs/brainstorm.md | 2 +- docs/deployment.md | 3 +- docs/gallery-config.md | 191 +++++- docs/ideas.md | 5 +- docs/screenshots/admin-album-picker.png | Bin 79169 -> 94523 bytes docs/screenshots/admin-settings.png | Bin 35031 -> 38941 bytes docs/screenshots/lightbox-exif.png | Bin 535364 -> 414681 bytes .../plans/2026-08-15-portfolio-features.md | 407 +++++++++++++ docs/theming.md | 24 + e2e/lightbox-fullscreen.spec.ts | 64 ++ e2e/lightbox-shortcuts.spec.ts | 55 ++ instrumentation.ts | 25 + lib/__tests__/admin-auth.test.ts | 17 + lib/__tests__/album-meta.test.ts | 107 ++++ lib/__tests__/color-mode.test.ts | 26 + lib/__tests__/config-hero-uuid.test.ts | 12 +- lib/__tests__/cover-grid.test.ts | 60 ++ lib/__tests__/doctor.test.ts | 218 +++++++ lib/__tests__/example-files.test.ts | 51 ++ lib/__tests__/exif-camera.test.ts | 38 ++ lib/__tests__/exif-display.test.ts | 92 +++ lib/__tests__/i18n.test.ts | 110 ++++ lib/__tests__/immich-setup-state.test.ts | 123 ++++ lib/__tests__/install-hero.test.ts | 71 +++ lib/__tests__/proxy.test.ts | 116 +++- lib/__tests__/rate-limit.test.ts | 14 +- lib/__tests__/site-password.test.ts | 191 ++++++ lib/__tests__/watermark-opacity.test.ts | 37 ++ lib/admin/auth.ts | 16 + lib/admin/doctor.ts | 347 +++++++++++ lib/albumMeta.ts | 64 ++ lib/auth.ts | 124 +++- lib/config/index.ts | 97 ++- lib/config/parser.ts | 22 +- lib/config/schema.ts | 189 ++++++ lib/env.ts | 2 + lib/exif.ts | 33 ++ lib/i18n/index.ts | 55 ++ lib/i18n/locales/de.ts | 178 ++++++ lib/i18n/locales/en.ts | 178 ++++++ lib/i18n/server.ts | 19 + lib/immich.ts | 49 +- lib/install.ts | 8 +- lib/rate-limit.ts | 11 +- package-lock.json | 8 +- package.json | 4 +- proxy.ts | 90 ++- 119 files changed, 6888 insertions(+), 758 deletions(-) delete mode 100644 CLAUDE.md create mode 100644 app/admin/components/DoctorModal.tsx create mode 100644 app/api/__tests__/health-config.test.ts create mode 100644 app/api/__tests__/site-gate.test.ts create mode 100644 app/api/admin/__tests__/doctor-guards.test.ts create mode 100644 app/api/admin/doctor/route.ts create mode 100644 app/gate/page.tsx create mode 100644 app/global-error.tsx create mode 100644 components/I18nProvider.tsx create mode 100644 docs/brainstorm-2026-08-15.md create mode 100644 docs/superpowers/plans/2026-08-15-portfolio-features.md create mode 100644 e2e/lightbox-fullscreen.spec.ts create mode 100644 e2e/lightbox-shortcuts.spec.ts create mode 100644 instrumentation.ts create mode 100644 lib/__tests__/album-meta.test.ts create mode 100644 lib/__tests__/color-mode.test.ts create mode 100644 lib/__tests__/cover-grid.test.ts create mode 100644 lib/__tests__/doctor.test.ts create mode 100644 lib/__tests__/example-files.test.ts create mode 100644 lib/__tests__/exif-camera.test.ts create mode 100644 lib/__tests__/exif-display.test.ts create mode 100644 lib/__tests__/i18n.test.ts create mode 100644 lib/__tests__/immich-setup-state.test.ts create mode 100644 lib/__tests__/install-hero.test.ts create mode 100644 lib/__tests__/site-password.test.ts create mode 100644 lib/__tests__/watermark-opacity.test.ts create mode 100644 lib/admin/doctor.ts create mode 100644 lib/albumMeta.ts create mode 100644 lib/exif.ts create mode 100644 lib/i18n/index.ts create mode 100644 lib/i18n/locales/de.ts create mode 100644 lib/i18n/locales/en.ts create mode 100644 lib/i18n/server.ts diff --git a/.env.local.example b/.env.local.example index 6178a1d..a4bfb4d 100644 --- a/.env.local.example +++ b/.env.local.example @@ -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. diff --git a/.gitignore b/.gitignore index cffe548..9dfc779 100644 --- a/.gitignore +++ b/.gitignore @@ -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) ─────────────────────────── diff --git a/CHANGELOG.md b/CHANGELOG.md index 3216d35..9768591 100644 --- a/CHANGELOG.md +++ b/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 + `` 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 diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 8c39bf7..0000000 --- a/CLAUDE.md +++ /dev/null @@ -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 (`.md` → `/journal/`). `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` 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-`, `asset-`. -- **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:`. 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 `![:fullbleed|wide](caption)` and `![, ](caption)`. -- `lib/admin/journal-service.ts` — **server only**: reads/writes `content/journal/`, falls back to the legacy `content/essays/`, rotates backups. The client/server split is load-bearing — importing the service from a client component breaks the build. -- `essayFile` is a **slug**, not a path: `isValidSlug` rejects dots and slashes, so `content/essays/x.md` cannot resolve. -- Author markdown is escaped, never filtered — `sanitizeHtml` runs first and the renderer only emits tags it builds itself. -- Drafts (`draft: true`) are hidden from the index and the nav check, but remain visible to an authenticated admin. Entry passwords use the `lb_auth_journal_` 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_` (subpage), `lb_auth_album_` (album) and `lb_auth_journal_` (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 `` 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=`, 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. diff --git a/README.md b/README.md index 2f817f5..d608292 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/[...path]/AlbumDetailView.tsx b/app/[...path]/AlbumDetailView.tsx index 23c014a..995fcfa 100644 --- a/app/[...path]/AlbumDetailView.tsx +++ b/app/[...path]/AlbumDetailView.tsx @@ -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 &&

{album.description}

}

- - {images.length} {images.length === 1 ? 'photo' : 'photos'} - + {t.common.photos(images.length)} {metaDetail.date && · {metaDetail.date}} {metaDetail.gear && {metaDetail.gear}}

@@ -107,6 +89,7 @@ export function AlbumDetailView({ layout={layout} gridStyle={gridStyle} watermark={watermark} + showExifPanel={showExifPanel} proofing={proofing} allowMailto={allowMailto} /> diff --git a/app/[...path]/EssayView.tsx b/app/[...path]/EssayView.tsx index 65cf82e..642f126 100644 --- a/app/[...path]/EssayView.tsx +++ b/app/[...path]/EssayView.tsx @@ -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(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)} )} diff --git a/app/[...path]/PhotoGrid.tsx b/app/[...path]/PhotoGrid.tsx index 46de549..d2c4ca7 100644 --- a/app/[...path]/PhotoGrid.tsx +++ b/app/[...path]/PhotoGrid.tsx @@ -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(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 ); }); - }, [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)} )} @@ -314,6 +329,7 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph onNext={goNext} onPrev={goPrev} watermark={watermark} + showExifToggle={showExifPanel} /> )} diff --git a/app/[...path]/SubpageGridView.tsx b/app/[...path]/SubpageGridView.tsx index 4422078..102b49f 100644 --- a/app/[...path]/SubpageGridView.tsx +++ b/app/[...path]/SubpageGridView.tsx @@ -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; slug: string; + gridStyle?: React.CSSProperties; }) { return ( -
+
{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), + )} > {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({
{index !== undefined && ( )} {title &&

{title}

} {subtitle &&

{subtitle}

}
)} {/* Typographic Table of Contents */} {hasSections && ( -