diff --git a/.gitignore b/.gitignore index 6087ca9..2d0c9da 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,12 @@ docker-compose.override.yml content/settings.yaml content/gallery.yaml content/about.md +# Von der App zur Laufzeit geschrieben: yaml-service legt bis zu 10 Backups je +# Datei ab, und die Backups von gallery.yaml/settings.yaml wuerden genau den +# Inhalt ins Repo tragen, den die drei Zeilen darueber heraushalten sollen. +content/.backups/ +content/analytics.json +content/*.screenshot-bak # ── Internal Dev Docs ──────────────────────────────── docs/dev/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3e5874c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,111 @@ +# Changelog + +All notable changes to this project are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +Releases up to and including v0.9.2 are documented in the +[GitHub releases](https://github.com/ralksta/immich-folio/releases). + +## [0.10.0] — 2026-08-10 + +### Added + +- **Photo Essay mode** — storytelling pages with an RSC markdown parser and + fullbleed/pair layouts, plus a visual essay block builder in the admin panel. +- **Client proofing** — favorite selection with compact URL bitmasking and an + export modal. +- **Subpage enable toggle** — deactivate a subpage without deleting it. +- **SEO** — configurable subpage title template, metadata descriptions, and + `generateMetadata` on the about page. +- **New theme preset `studio-modern`.** The Leica language of `studio` rebuilt + around the precision grotesque Archivo, with IBM Plex Mono for every piece of + photographic metadata. Hairline rules, zero radius, an indexed hero navigation + with album counts, an always-visible caption bar under album covers, and a + film-edge EXIF strip in the lightbox. Selectable in the admin panel, the dev + toolbar, and `content/settings.yaml`. See [docs/theming.md](docs/theming.md). + +### Security + +- `GET /api/admin/analytics` now requires an admin session. +- Docker images are scanned with Trivy on every publish; results land in the + GitHub Security tab. + +### Changed + +- **Album sort order now mirrors the Immich timeline** ([#350](https://github.com/ralksta/immich-folio/issues/350)). Albums were + sorted by `fileCreatedAt`, the capture instant in UTC. Immich sorts its + timeline primarily by `localDateTime` — the capture time in the + photographer's local zone — and uses `fileCreatedAt` only as a tie-breaker. + The two keys are identical for the vast majority of albums; they diverge for + albums spanning time zones, where the order shown in Immich Folio no longer + matched the Immich UI. The sort keys were changed accordingly, and + unparseable dates now fall back to `0` instead of `NaN` (a `NaN` comparator + makes `sort()` free to return any permutation). + +### Fixed + +- **Cache staleness** — `?fresh=1` bypass, an admin diagnostic banner for albums + Immich returns empty, and revalidation after admin saves. +- **Admin status panel reported two faults that were not faults** ([#341](https://github.com/ralksta/immich-folio/issues/341)). + `settings.yaml` was treated as mandatory although `getConfig()` falls back to + defaults without it, so anyone running with only a `gallery.yaml` saw a + permanent "Config Integrity: Degraded". Only a file that exists and cannot be + parsed counts as degraded now. A failed status request was also rendered as if + it were a result — an expired admin session looked identical to a real + outage — so there is now a third state for "the check did not run". +- **Lightbox showed its photo counter twice.** The counter renders a + screen-reader label ("Photo 3 of 53") alongside the compact display + ("3 / 53"), but `.sr-only` was not defined in any stylesheet, so both strings + were visible on top of each other. +- **Docker** — health check targets `127.0.0.1` instead of `localhost`, base + image moved to `node:22-alpine`. +- **Docs** — the "nature / travel journal" example in `docs/theming.md` used a + preset named `botanica` that does not exist; copying it produced an + `Unknown theme preset` error. It now uses `editorial`. +- **Map page ignored the theme.** `app/map/map.css` referenced custom properties + that never existed (`--font-heading`, `--font-body`, `--border-color`, + `--radius`), so the map title, popups, and container fell back to browser + defaults instead of the configured fonts, borders, and radius. This affects + every preset — the map now picks up the theme like the rest of the site. + +### Upgrade notes + +**No migration is required.** No configuration schema changed, and the six +existing presets render exactly as before. Pull the new image, restart, done. + +**Rolling back after switching presets needs one edit.** If you select +"Studio Modern" and later downgrade to a version that predates it, the +`theme.preset: studio-modern` left in `content/settings.yaml` is a preset the +older version does not know, and config loading fails with: + +``` +Unknown theme preset "studio-modern". Valid presets: studio, minimal, editorial, classic, noir, monograph +``` + +Set `theme.preset` back to one of the listed presets in `content/settings.yaml` +before downgrading. `/admin` stays reachable if you hit this, so the change can +also be made after the fact. + +**If you copied `docker-compose.override.yml.example`, update your health +check by hand.** The example now uses `wget -O /dev/null` instead of +`--spider`, but your own `docker-compose.override.yml` is not tracked by git, +so pulling the new version does not touch it — and a health check defined +there overrides the one baked into the image. `--spider` does not read the +response body and disconnects mid-RSC-stream; when that abort coincides with a +slow render after `CACHE_TTL` expiry, Node tears down the `TransformStream` +controller while Next.js is still writing into it: + +``` +TypeError: controller[kState].transformAlgorithm is not a function +``` + +Replace `"--spider"` with `"-O", "/dev/null"` in your override file: + +```yaml +healthcheck: + test: ['CMD', 'wget', '--no-verbose', '--tries=1', '-O', '/dev/null', 'http://127.0.0.1:7211/'] +``` + +A full GET checks the same thing without the race. Installations without an +own health check need no action — the image ships the fixed one. diff --git a/CLAUDE.md b/CLAUDE.md index 2cc3453..4ec15cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ Configuration is loaded once at startup (cached in a module-level singleton, re- - `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` — six built-in theme presets (`studio`, `minimal`, `editorial`, `classic`, `noir`, `monograph`) and `resolveTheme()` which merges partial overrides over a preset. +- `lib/config/theme.ts` — seven built-in theme presets (`studio`, `studio-modern`, `minimal`, `editorial`, `classic`, `noir`, `monograph`) and `resolveTheme()` which merges partial overrides over a preset. `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. diff --git a/Dockerfile b/Dockerfile index d040982..9c2e44c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-alpine AS base +FROM node:22-alpine AS base # ── Dependencies ────────────────────────────────── FROM base AS deps @@ -51,8 +51,12 @@ EXPOSE 7211 ENV PORT=7211 ENV HOSTNAME="0.0.0.0" +# 127.0.0.1 statt localhost: der Server bindet auf 0.0.0.0 (siehe HOSTNAME oben), +# lauscht also nur auf IPv4. In Alpine loest 'localhost' zuerst nach ::1 auf, und +# wget bleibt bei diesem ersten Ergebnis — der Check schlaegt dauerhaft mit +# "Connection refused" fehl und der Container gilt als unhealthy. HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:7211/api/health || exit 1 + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:7211/api/health || exit 1 # Run as root initially so entrypoint can fix bind-mount permissions, # then drops to nextjs user via su-exec diff --git a/README.md b/README.md index 939d4be..e8dfc5f 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,55 @@ A self-hosted photography portfolio powered by [Immich](https://immich.app). Tur Immich Folio acts as a **secure reverse proxy** between your visitors and your private Immich instance. Your Immich server stays on your local network, completely invisible to the outside world.

- Homepage — Editorial theme - Photo grid — Editorial theme + Homepage — Studio Modern theme

+

+ Album photo grid + Collection overview grouping several albums +

+

+ Fullscreen lightbox with the EXIF panel open +

+ +## What's New in v0.10.0 + +The largest release so far — two new ways to present work, a seventh theme preset, and an admin panel rebuilt around visual controls. + +- **New theme preset: Studio Modern** — the Leica language of `studio` rebuilt around the Archivo grotesque, with IBM Plex Mono for all photographic metadata, an indexed hero navigation with album counts, caption bars under album covers, and a film-edge EXIF strip in the lightbox → [Theming guide](docs/theming.md) + +

+ Studio Modern in light mode + Studio Modern album grid in light mode +

+

Studio Modern in light mode — every preset ships a light and a dark variant.

+ +- **Photo Essay mode** — storytelling pages that alternate text with fullbleed and paired image layouts, written in Markdown or assembled in the new visual block editor +- **Client proofing** — let clients pick favorites and export the selection; the picks live in the URL as a compact bitmask, so nothing is stored server-side and a selection can be shared as a link +- **Lightbox watermark** — configurable overlay on fullscreen images +- **Privacy-friendly analytics** — page and album view counts without cookies or third parties, switchable off entirely +- **Subpage on/off toggle** — take a page offline without deleting it +- **Admin panel overhaul** — slide-over page-builder drawer with search, visual preview cards for grid, theme, photo frame and hero style, a Google SEO snippet preview, light/dark toggle, subpage live preview, and a backup manager with one-click restore + +

+ Visual page builder + Settings editor with visual preview cards +

+ +- **SEO** — configurable subpage title template and metadata descriptions, plus proper metadata on the about page +- **Resilience** — the gallery keeps serving the last known albums during an Immich outage instead of erroring, with loading skeletons while the round-trip is in flight +- **Fixed:** the map page ignored the configured theme fonts and borders (all presets), album order now mirrors the Immich timeline across time zones, the admin status panel no longer reports faults that are not faults, and the Docker health check no longer fails on IPv6-first `localhost` resolution + +**Upgrading:** no migration, no configuration change — pull the new image and restart. One exception: if you copied `docker-compose.override.yml.example` in the past, its health check needs a one-line edit. See the [upgrade notes](CHANGELOG.md#upgrade-notes) for that and for the rollback caveat when switching presets. + +### Security releases + +Security fixes ship in normal releases, so **running the latest release is the recommended baseline**: + +- **v0.10.0** — `GET /api/admin/analytics` now requires an admin session; Docker images are scanned with Trivy on every publish +- **v0.9.2** — closed a timing side channel that leaked the admin password length ([#395](https://github.com/ralksta/immich-folio/pull/395)) +- **v0.9.0** — fixed pre-auth bypasses and restored Immich 3.x compatibility ([#378](https://github.com/ralksta/immich-folio/pull/378)) + +Full history in the [GitHub releases](https://github.com/ralksta/immich-folio/releases) and [CHANGELOG.md](CHANGELOG.md). ## Features @@ -29,13 +75,26 @@ Immich Folio acts as a **secure reverse proxy** between your visitors and your p - **Auto-generated slugs** — URL slugs derived from album names automatically - **YAML gallery config** — all gallery structure defined in a single `content/gallery.yaml` file - **Markdown about page** — `content/about.md` with frontmatter for portrait, name, location, and gear list +- **Photo Essay mode** — long-form storytelling pages alternating text with fullbleed and paired image layouts +- **Client proofing** — clients favorite photos and export the selection; picks are encoded in the URL, nothing is stored server-side +- **Lightbox watermark** — configurable overlay on fullscreen images +- **Privacy-friendly analytics** — cookieless view counts, no third parties, can be switched off - **Dynamic OG images** — auto-generated social preview images per album +

+ About page rendered from content/about.md + Map page clustering photo locations worldwide +

+

The Markdown about page and the GPS map, both generated from your Immich data.

+ ### Admin Panel - **Visual page builder** — drag & drop interface to manage hero images, standalone albums, subpages, and sections - **Album picker** — browse all shared Immich albums with search, see photo counts, and add them with one click - **Settings editor** — configure theme, grid layout, footer, legal/impressum, and SEO from a visual UI +- **Visual previews everywhere** — grid layout, theme, photo frame, hero style and the Google search snippet are picked from preview cards instead of text fields +- **Essay block editor** — assemble photo essays block by block without touching Markdown +- **Backup manager** — every save is backed up automatically; restore any of them with one click - **Live YAML sync** — changes are written directly to `gallery.yaml` and `settings.yaml` with automatic backups - **Password protected** — secured with its own admin password, separate from album passwords @@ -44,14 +103,14 @@ Immich Folio acts as a **secure reverse proxy** between your visitors and your p
-| Concern | Protection | -| ----------------------- | --------------------------------------------------------------------- | -| **Server exposure** | Immich URL never leaves your network — all requests proxy server-side | -| **API key** | Stored only in `.env.local`, never in client code | -| **Asset IDs** | Immich UUIDs encrypted (AES-256) into opaque tokens | -| **Album scope** | Only albums in `gallery.yaml` are accessible | -| **Password protection** | Per-subpage password support | -| **Rate limiting** | Per-IP sliding-window rate limiter (configurable RPM) | +| Concern | Protection | +| -------------------------- | ------------------------------------------------------------------------------------ | +| **Server exposure** | Immich URL never leaves your network — all requests proxy server-side | +| **API key** | Stored only in `.env.local`, never in client code | +| **Asset IDs** | Immich UUIDs encrypted (AES-256) into opaque tokens | +| **Album scope** | Only albums in `gallery.yaml` are accessible | +| **Password protection** | Per-subpage password support | +| **Rate limiting** | Per-IP sliding-window rate limiter (configurable RPM) | | **Vulnerability scanning** | Docker image scanned with Trivy on every release, results in the GitHub Security tab | - Health check endpoint at `GET /api/health` @@ -131,10 +190,10 @@ All gallery structure — hero images, albums, subpages, grid layout, footer — ### Theming -Six built-in presets with distinct visual identities — or mix and match with fine-grained control over colors, fonts, corners, photo frames, hero layout, and grid style. +Seven built-in presets with distinct visual identities — or mix and match with fine-grained control over colors, fonts, corners, photo frames, hero layout, and grid style. ```yaml -theme: studio # or: minimal, editorial, classic, noir, monograph +theme: studio # or: studio-modern, minimal, editorial, classic, noir, monograph ``` → **[View all Themes & Configuration Guide](docs/theming.md)** @@ -226,7 +285,19 @@ A built-in visual editor at `/admin` lets you manage your gallery without editin ADMIN_PASSWORD=your-secure-admin-password ``` -Then navigate to `http://your-site/admin` and log in. +Then navigate to `http://your-site/admin` and log in. The panel is protected by its +own password, separate from any album passwords, and writes straight to +`gallery.yaml` / `settings.yaml` with automatic backups. + +

+ Admin panel login screen + Visual page builder with hero images, standalone albums and subpages +

+

+ Album picker listing shared Immich albums with photo counts + Settings editor with site identity and feature toggles +

+

Login · page builder · album picker · settings editor

→ **[Admin Panel Guide](docs/admin-panel.md)** diff --git a/app/[...path]/AlbumDetailView.tsx b/app/[...path]/AlbumDetailView.tsx index 6c9dfea..111a9e9 100644 --- a/app/[...path]/AlbumDetailView.tsx +++ b/app/[...path]/AlbumDetailView.tsx @@ -20,6 +20,31 @@ interface AlbumDetailViewProps { watermark?: LightboxWatermark; } +/** + * 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, @@ -32,6 +57,8 @@ export function AlbumDetailView({ heroBlurDataURL, watermark, }: AlbumDetailViewProps) { + const metaDetail = albumMetaDetail(album); + return ( <> {heroImageUrl && ( @@ -49,16 +76,26 @@ export function AlbumDetailView({ )}
- -

{album.albumName}

- {subtitle && ( -

- {subtitle} -

- )} - {album.description &&

{album.description}

} +
+ +

{album.albumName}

+ {subtitle && ( +

+ {subtitle} +

+ )} + {album.description &&

{album.description}

} +

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

diff --git a/app/[...path]/SubpageGridView.tsx b/app/[...path]/SubpageGridView.tsx index 934e552..bccd0da 100644 --- a/app/[...path]/SubpageGridView.tsx +++ b/app/[...path]/SubpageGridView.tsx @@ -23,8 +23,13 @@ interface SubpageGridViewProps { albums: SubpageAlbum[]; coverPlaceholders: (Placeholder | null)[]; sections?: SubpageSectionConfig[]; + /** 1-based position among all subpages — feeds the "03 — Collection" kicker. */ + index?: number; } +const pad2 = (n: number) => String(n).padStart(2, '0'); +const photoCount = (n: number) => `${n} ${n === 1 ? 'photo' : 'photos'}`; + function AlbumGrid({ albums, placeholderMap, @@ -36,7 +41,7 @@ function AlbumGrid({ }) { return (
- {albums.map((album) => { + {albums.map((album, i) => { const ph = placeholderMap.get(album.id) ?? null; return ( - {album.albumThumbnailAssetId ? ( - - ) : ( -