release: v0.11.0 (#459)
Ships `dev` as **v0.11.0**. `main` contains nothing that `dev` does not, so this is a clean sync. ## Headline changes since v0.10.0 - **Journal & Journal Studio** — photo essays at `/journal`, split-screen block editor with live preview, drafts, per-entry passwords, cover images - **First-run setup wizard at `/install`** — configure a fresh deployment from the browser; credentials are verified against Immich before anything is written, and the wizard is gated by a one-time token - **About page editor and toggle** in the admin panel - **Per-album photo order** independent of Immich, with a drag & drop editor - **Custom favicon upload** - **Experimental portfolio features** — justified grid, `cover` hero, unlisted subpages, per-album grid overrides, cover focal points, external nav links - **Security** — asset tokens length-capped before decoding (#423), journal author markdown escaped rather than filtered, journal file paths contained, admin session key derived with scrypt (#432), Next.js 16.3.0 for CVE-2025-13465 - **Fixed** — client proofing honours its own configuration again (#454); journal photo rendering (#432) Full detail in [CHANGELOG.md](CHANGELOG.md#0110--2026-08-15). ## Verified `npx tsc --noEmit` clean · `npx vitest run` 435 tests pass · `npm run build` succeeds · lint 0 errors · `prettier --check` clean · local `docker build` of the release tree. ## After merge Tag `v0.11.0` on `main` and publish the release — that triggers `docker-publish.yml`, which pushes the image and moves `latest`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
148
CHANGELOG.md
148
CHANGELOG.md
@@ -9,8 +9,43 @@ Releases up to and including v0.9.2 are documented in the
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.11.0] — 2026-08-15
|
||||
|
||||
### Added
|
||||
|
||||
- **Journal** ([#432](https://github.com/ralksta/immich-folio/pull/432)). A
|
||||
section for photo essays and travel stories at `/journal`, with its own index,
|
||||
cover images, reading times and drafts. Entries are plain Markdown in
|
||||
`content/journal/`, written either by hand or in the new **Journal Studio** —
|
||||
a split-screen block editor with a resizable divider and a live preview of the
|
||||
real page. Photos are inserted through the asset picker rather than by typing
|
||||
UUIDs. A `/journal` link appears in the navigation as soon as one published
|
||||
entry exists; drafts stay visible to a logged-in admin only. Entries can carry
|
||||
their own password. See [docs/journal.md](docs/journal.md).
|
||||
- **About page editor and toggle**
|
||||
([#446](https://github.com/ralksta/immich-folio/pull/446)). Portrait, name,
|
||||
location, gear list and biography are edited under admin Settings › About,
|
||||
which writes `content/about.md`. `about.enabled: false` in `settings.yaml`
|
||||
takes the page offline without deleting it.
|
||||
- **Per-album photo order, independent of Immich**
|
||||
([#414](https://github.com/ralksta/immich-folio/pull/414)). A `sort` key per
|
||||
album — `immich`, `newest`, `oldest`, `filename` or `manual` — so a curated
|
||||
series can have a narrative order without changing the archive. `manual` is a
|
||||
pinned prefix: only the photos you place by hand are listed, everything else
|
||||
follows in the Immich order, and the admin panel has a drag & drop editor
|
||||
for it.
|
||||
- **Experimental portfolio features**
|
||||
([#431](https://github.com/ralksta/immich-folio/pull/431)). Marked
|
||||
experimental in the schema and the UI:
|
||||
- `justified` grid layout — every row fills the width at one shared height,
|
||||
aspect ratios intact
|
||||
- `cover` hero style — a fullscreen splash with the site title and a single
|
||||
**Enter** link
|
||||
- `hidden: true` subpages — reachable by direct link, absent from the
|
||||
navigation (unlisting, not access control)
|
||||
- per-album `grid` overrides, merged over the subpage and global grid
|
||||
- per-album `coverPosition` — the focal point for the cover crop
|
||||
- `navLinks` — external `http(s)` links appended to the header navigation
|
||||
- **First-run setup wizard at `/install`**
|
||||
([#419](https://github.com/ralksta/immich-folio/pull/419)). A fresh deployment
|
||||
can now be configured from the browser: connect to Immich, optionally pick
|
||||
@@ -30,6 +65,50 @@ Releases up to and including v0.9.2 are documented in the
|
||||
|
||||
### Changed
|
||||
|
||||
- **`studio-modern` is the default theme preset**, replacing `studio`. It is
|
||||
now listed first in the docs, the setup wizard and the admin preset cards,
|
||||
and it is what a site renders with when no preset is configured.
|
||||
|
||||
**This changes the appearance of an existing site only if it never picked a
|
||||
theme** — that is, no `content/settings.yaml`, or one without a
|
||||
`theme.preset` key. Anything with an explicit preset is untouched. To keep
|
||||
the previous look, set it explicitly:
|
||||
|
||||
```yaml
|
||||
# content/settings.yaml
|
||||
theme:
|
||||
preset: studio
|
||||
```
|
||||
|
||||
Three code paths fell back to a hard-coded `'studio'` (no settings file at
|
||||
all, a settings file without a `theme` key, and a `theme` object that
|
||||
overrides properties without naming a preset). They now share one
|
||||
`DEFAULT_PRESET` constant, with a test covering all three so they cannot
|
||||
drift apart again.
|
||||
|
||||
- **Every admin area has its own URL**
|
||||
([#432](https://github.com/ralksta/immich-folio/pull/432)) — `/admin/pages`,
|
||||
`/admin/journal`, `/admin/settings/<section>`, `/admin/analytics` — so a
|
||||
section can be bookmarked and the back button behaves. The auth gate and the
|
||||
panel chrome moved into the layout, and no longer re-run on every tab switch.
|
||||
A floating save bar keeps Save reachable without scrolling
|
||||
([#426](https://github.com/ralksta/immich-folio/pull/426)).
|
||||
- **Design system gaps in the admin panel are closed**
|
||||
([#432](https://github.com/ralksta/immich-folio/pull/432)). Several classes
|
||||
were referenced but never defined — every input used a class no stylesheet
|
||||
had, the Analytics bars had no fill, and all grid previews collapsed to the
|
||||
same height. The album sort dropdown is now a themed listbox rendered in a
|
||||
portal, so it no longer clips inside modals.
|
||||
- **The last emojis in the public frontend are SVG icons**
|
||||
([#445](https://github.com/ralksta/immich-folio/pull/445)), matching the admin
|
||||
panel ([#415](https://github.com/ralksta/immich-folio/pull/415),
|
||||
[#416](https://github.com/ralksta/immich-folio/pull/416),
|
||||
[#418](https://github.com/ralksta/immich-folio/pull/418)).
|
||||
- **The sample journal story ships as a template, not a live entry**
|
||||
([#443](https://github.com/ralksta/immich-folio/pull/443)). It is
|
||||
`content/journal/sample-story.md.example`, so a fresh install does not publish
|
||||
someone else's story — and since the asset IDs in it belong to no server, the
|
||||
template ships without any.
|
||||
- **A gallery with no albums is now a valid, rendered state**
|
||||
([#421](https://github.com/ralksta/immich-folio/pull/421)) instead of an error
|
||||
— the setup wizard can finish without picking one, and albums can be added
|
||||
@@ -46,9 +125,73 @@ Releases up to and including v0.9.2 are documented in the
|
||||
`content/` read-only: the wizard, the admin panel and the backup rotation all
|
||||
write into it, and `:ro` silently breaks all three.
|
||||
- **Contributors are credited in the README.**
|
||||
- **The documentation was brought back in line with `dev`**
|
||||
([#452](https://github.com/ralksta/immich-folio/pull/452),
|
||||
[#453](https://github.com/ralksta/immich-folio/pull/453)). The README is
|
||||
restructured and `studio-modern` is documented as the default preset, which it
|
||||
had already become in the code.
|
||||
- **The journal guide has screenshots**
|
||||
([#455](https://github.com/ralksta/immich-folio/pull/455),
|
||||
[#456](https://github.com/ralksta/immich-folio/pull/456),
|
||||
[#457](https://github.com/ralksta/immich-folio/pull/457)). The one guide
|
||||
describing a visual editor had no picture of it. `scripts/screenshots.ts`
|
||||
gained a `journal` section that writes throwaway entries from real albums,
|
||||
shoots the index, a rendered entry, the entry list and the studio — including
|
||||
its draft and password states and the Story Settings form — and deletes them
|
||||
again, so a run never leaves a demo story on a live site.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Client proofing ignored its own configuration**
|
||||
([#454](https://github.com/ralksta/immich-folio/pull/454)). The photo grid
|
||||
mounted the proofing provider unconditionally, so the favourite hearts, the
|
||||
selection bar and the export modal appeared on every album on every site.
|
||||
`proofing.enabled` in `settings.yaml`, a subpage's own `proofing:` flag and
|
||||
`allowMailto` were all parsed, validated — and never read.
|
||||
|
||||
They are honoured now, with precedence subpage → global: a subpage's flag
|
||||
wins in either direction, and a page reached without one follows
|
||||
`proofing.enabled`.
|
||||
|
||||
**What changes for an existing site.** `proofing.enabled` defaults to `true`,
|
||||
so albums keep their proofing controls and most sites see no difference. Two
|
||||
cases do change: a site that set `proofing.enabled: false` finally gets what
|
||||
it asked for, and **photo essays no longer show the controls unless their
|
||||
subpage sets `proofing: true` explicitly** — a published story is not an
|
||||
album handover, so the global default deliberately does not reach into
|
||||
essays. Journal entries never show them.
|
||||
|
||||
- **Journal photos did not render**
|
||||
([#432](https://github.com/ralksta/immich-folio/pull/432)). Photo blocks
|
||||
resolved to nothing, headings sat further left than the body text, fullbleed
|
||||
photos bled only to the left, photo pairs forced both images to equal width
|
||||
regardless of their real shapes, the preview claimed 3:2 for every photo, and
|
||||
the lightbox had neither keyboard control nor navigation inside a journal
|
||||
entry.
|
||||
|
||||
### Security
|
||||
|
||||
- **Journal author markdown is escaped, not filtered**
|
||||
([#432](https://github.com/ralksta/immich-folio/pull/432)). The previous pass
|
||||
stripped `<script>` tags, `on*="…"` handlers and the literal `javascript:` —
|
||||
a denylist, and trivially bypassable: an unquoted `onerror=`, single-quoted
|
||||
handlers, `<svg onload=…>` and `javasjavascript:cript:` (the replacement
|
||||
recombines) all reached `dangerouslySetInnerHTML`. With escaping there is
|
||||
nothing left to enumerate.
|
||||
- **Journal file paths are contained**
|
||||
([#432](https://github.com/ralksta/immich-folio/pull/432)), frontmatter
|
||||
escaping is fixed, and two regexes with polynomial backtracking were replaced
|
||||
by linear scans.
|
||||
- **The admin session signing key is derived with scrypt**
|
||||
([#432](https://github.com/ralksta/immich-folio/pull/432)) rather than a plain
|
||||
digest, and the key cache is keyed on its inputs instead of on a digest of
|
||||
them.
|
||||
- **Asset tokens are length-capped before decoding**
|
||||
([#423](https://github.com/ralksta/immich-folio/pull/423)). `decodeAssetId()`
|
||||
passed arbitrary-length URL input straight to `Buffer.from(…, 'base64url')`,
|
||||
so a huge crafted token could exhaust memory or throw `RangeError` despite the
|
||||
surrounding `try`/`catch`. A real v2 token is ~110 characters; anything over
|
||||
256 is now rejected outright.
|
||||
- **Next.js 16.3.0** — picks up the fix for CVE-2025-13465 in Next's vendored
|
||||
lodash ([#402](https://github.com/ralksta/immich-folio/pull/402)).
|
||||
|
||||
@@ -74,6 +217,11 @@ Nothing user-facing; recorded so the next release notes are complete.
|
||||
transitively through Next, and 0.35 moved from `export =` to ESM: the module
|
||||
namespace stopped being callable and the factory moved to `.default`. The
|
||||
Next bump above would otherwise have broken `npx tsc --noEmit`.
|
||||
- **Prettier was run across the tree**
|
||||
([#450](https://github.com/ralksta/immich-folio/pull/450)), and the eslint
|
||||
errors that pass uncovered were cleared
|
||||
([#451](https://github.com/ralksta/immich-folio/pull/451)). The formatting
|
||||
churn is in one commit of its own, so it does not sit inside a feature diff.
|
||||
- Dev-dependency bumps: prettier 3.9.6, eslint 9.39.5, `@types/leaflet` 1.9.22,
|
||||
and `github/codeql-action` v4.
|
||||
|
||||
|
||||
111
CLAUDE.md
111
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` — seven built-in theme presets (`studio`, `studio-modern`, `minimal`, `editorial`, `classic`, `noir`, `monograph`) and `resolveTheme()` which merges partial overrides over a preset.
|
||||
- `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.
|
||||
|
||||
@@ -45,6 +45,14 @@ Configuration is loaded once at startup (cached in a module-level singleton, re-
|
||||
- `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`)
|
||||
|
||||
@@ -61,19 +69,41 @@ Singleton `ImmichClient` class exported as `immich`. All Immich API calls are se
|
||||
|
||||
### API routes (`app/api/`)
|
||||
|
||||
| Route | Purpose |
|
||||
| --------------------------------- | ------------------------------------------------------------- |
|
||||
| `GET /api/image/[id]` | Image proxy — decodes token, rate-limits, streams from Immich |
|
||||
| `GET /api/exif/[id]` | EXIF data for lightbox panel |
|
||||
| `POST /api/auth` | Password submission → sets `HttpOnly` cookie |
|
||||
| `GET /api/og` | Dynamic OG image generation (rate-limited) |
|
||||
| `GET /api/map` | Aggregated GPS coordinates for map view |
|
||||
| `GET /api/health` | Health check |
|
||||
| `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 /api/admin/albums` | Browse all shared Immich albums (admin-only) |
|
||||
| `POST /api/admin/reload` | Invalidate config + Immich cache |
|
||||
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`).
|
||||
|
||||
@@ -87,15 +117,41 @@ Single catch-all route handles three cases:
|
||||
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 and per-album password gating using HMAC tokens in `HttpOnly` cookies (no database). Cookie names: `lb_auth_<slug>` (subpage) and `lb_auth_album_<slug>` (album). Password storage supports plaintext (deprecated, logs a warning with recommended scrypt hash), `scrypt:salt:hash` format, and rejects legacy bcrypt. Token expiry: 24 hours.
|
||||
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. Configurable via `RATE_LIMIT_RPM`. `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.
|
||||
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
|
||||
|
||||
@@ -107,18 +163,21 @@ A custom loader (`lib/immichLoader.ts`) maps `next/image` requests to `/api/imag
|
||||
|
||||
### Admin Panel (`app/admin/`, `lib/admin/`, `app/api/admin/`)
|
||||
|
||||
Visual page builder and settings editor at `/admin`. Enabled by setting `ADMIN_PASSWORD` env var.
|
||||
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`).
|
||||
|
||||
- `lib/admin/auth.ts` — HMAC-signed session tokens (HttpOnly cookie `folio_admin_session`), constant-time password verification, 24h expiry.
|
||||
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.
|
||||
- `app/api/admin/auth` — POST login, GET session check, DELETE logout.
|
||||
- `app/api/admin/gallery` — GET/PUT for `gallery.yaml`.
|
||||
- `app/api/admin/settings` — GET/PUT for `settings.yaml`.
|
||||
- `app/api/admin/albums` — GET lists ALL shared Immich albums (bypasses allowlist, admin-only).
|
||||
- `app/api/admin/reload` — POST invalidates config cache + Immich cache.
|
||||
- `app/admin/components/PageBuilder.tsx` — Tree editor for hero, standalone albums, subpages, and sections.
|
||||
- `app/admin/components/AlbumPicker.tsx` — Modal to browse/search all Immich albums.
|
||||
- `app/admin/components/SettingsEditor.tsx` — Sidebar-nav settings form (theme, grid, footer, legal, SEO).
|
||||
- `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.
|
||||
|
||||
|
||||
212
README.md
212
README.md
@@ -1,59 +1,30 @@
|
||||
# Immich Folio
|
||||
|
||||
A self-hosted photography portfolio powered by [Immich](https://immich.app). Turns your Immich albums into a beautiful, public-facing gallery — without ever exposing your Immich server to the internet.
|
||||
**Turn your Immich albums into a public photography portfolio — without ever exposing your Immich server to the internet.**
|
||||
|
||||
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.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/theme-studio-modern-home.png" width="98%" alt="Homepage — Studio Modern theme" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/theme-studio-modern-grid.png" width="49%" alt="Album photo grid" />
|
||||
<img src="docs/screenshots/page-collection.png" width="49%" alt="Collection overview grouping several albums" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/lightbox-exif.png" width="98%" alt="Fullscreen lightbox with the EXIF panel open" />
|
||||
<p>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/ralksta/immich-folio" alt="MIT License" /></a>
|
||||
<a href="https://github.com/ralksta/immich-folio/releases"><img src="https://img.shields.io/github/v/release/ralksta/immich-folio" alt="Latest release" /></a>
|
||||
</p>
|
||||
|
||||
## 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)
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/theme-studio-modern-home-light.png" width="49%" alt="Studio Modern in light mode" />
|
||||
<img src="docs/screenshots/theme-studio-modern-grid-light.png" width="49%" alt="Studio Modern album grid in light mode" />
|
||||
</p>
|
||||
<p align="center"><em>Studio Modern in light mode — every preset ships a light and a dark variant.</em></p>
|
||||
|
||||
- **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
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/admin-page-builder.png" width="49%" alt="Visual page builder" />
|
||||
<img src="docs/screenshots/admin-settings.png" width="49%" alt="Settings editor with visual preview cards" />
|
||||
<img src="docs/screenshots/header.png" width="100%" alt="Immich Folio — the public gallery, an album grid, and the admin page builder" />
|
||||
</p>
|
||||
|
||||
- **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
|
||||
A self-hosted portfolio powered by [Immich](https://immich.app). It 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, while your albums are published as a gallery you control.
|
||||
|
||||
**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.
|
||||
## Contents
|
||||
|
||||
### Security releases
|
||||
- [Features](#features)
|
||||
- [Requirements](#requirements)
|
||||
- [Quick Start](#quick-start)
|
||||
- [First-Run Setup](#first-run-setup)
|
||||
- [Configuration](#configuration)
|
||||
- [Docker](#docker)
|
||||
- [Admin Panel](#admin-panel)
|
||||
- [What's New](#whats-new)
|
||||
|
||||
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).
|
||||
**Guides:** [Gallery Configuration](docs/gallery-config.md) · [Theming](docs/theming.md) · [Journal & Photo Essays](docs/journal.md) · [Admin Panel](docs/admin-panel.md) · [Deployment](docs/deployment.md)
|
||||
|
||||
## Features
|
||||
|
||||
@@ -64,23 +35,45 @@ Full history in the [GitHub releases](https://github.com/ralksta/immich-folio/re
|
||||
- **Masonry photo grid** — responsive layout with natural aspect ratios and configurable columns, gap, and aspect ratio
|
||||
- **Uniform grid mode** — switch to a fixed-aspect uniform grid per-subpage or globally
|
||||
- **Showcase / filmstrip / editorial-flow layouts** — featured hero + grid, horizontal scroll strips, or alternating full-width and paired images
|
||||
- **Per-subpage grid overrides** — each subpage can define its own columns, gap, aspect ratio, and layout mode
|
||||
- **Justified rows** _(experimental)_ — every row fills the width at one shared height, aspect ratios intact, nothing cropped
|
||||
- **Per-subpage grid overrides** — each subpage can define its own columns, gap, aspect ratio, and layout mode, and individual albums can override that again _(experimental)_
|
||||
- **Cover focal points** _(experimental)_ — decide which part of a cover survives the crop
|
||||
- **Fullscreen lightbox** — keyboard and swipe navigation, EXIF panel, adjacent image preloading
|
||||
- **EXIF metadata on hover** — camera body, lens, focal length, aperture, shutter speed, ISO shown directly on the grid
|
||||
- **ThumbHash placeholders** — instant blurred previews while full images load
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/grid-masonry.png" width="49%" alt="Masonry grid layout" />
|
||||
<img src="docs/screenshots/grid-showcase.png" width="49%" alt="Showcase grid layout" />
|
||||
</p>
|
||||
<p align="center"><em>Masonry and showcase, both in Studio Modern — the same album, a different grid layout.</em></p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/lightbox-exif.png" width="98%" alt="Fullscreen lightbox with the EXIF panel open" />
|
||||
</p>
|
||||
<p align="center"><em>The fullscreen lightbox with the EXIF panel — camera, lens and exposure for the frame on screen.</em></p>
|
||||
|
||||
### Content & Organization
|
||||
|
||||
- **Subpage grouping** — organize albums into named collections (e.g. `/japan/tokyo-2023`)
|
||||
- **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
|
||||
- **Markdown about page** — `content/about.md` with frontmatter for portrait, name, location, and gear list, editable from the admin panel
|
||||
- **Journal** — photo essays and travel stories at `/journal`, with drafts, per-entry passwords, cover images and reading times
|
||||
- **Photo Essay mode** — long-form storytelling pages alternating text with fullbleed and paired image layouts
|
||||
- **Unlisted subpages** _(experimental)_ — reachable by direct link, absent from the navigation
|
||||
- **Subpage on/off toggle** — take a page offline without deleting it
|
||||
- **External navigation links** _(experimental)_ — point the header at a shop, a blog, or a social profile
|
||||
- **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
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/page-collection.png" width="98%" alt="Collection overview grouping several albums under one subpage" />
|
||||
</p>
|
||||
<p align="center"><em>A subpage grouping several albums into one collection.</em></p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/page-about.png" width="49%" alt="About page rendered from content/about.md" />
|
||||
<img src="docs/screenshots/page-map.png" width="49%" alt="Map page clustering photo locations worldwide" />
|
||||
@@ -91,9 +84,12 @@ Full history in the [GitHub releases](https://github.com/ralksta/immich-folio/re
|
||||
|
||||
- **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
|
||||
- **Settings editor** — configure theme, grid layout, footer, legal/impressum, SEO, image protection and the about page 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
|
||||
- **Journal Studio** — split-screen block editor with a live preview of the real page
|
||||
- **Essay block editor** — assemble photo essays block by block without touching Markdown
|
||||
- **Photo order editor** — drag & drop a hand-picked opening sequence for any album
|
||||
- **Favicon upload** — give the site its own icon in the browser tab
|
||||
- **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
|
||||
@@ -187,25 +183,12 @@ The token is stored in `content/.setup-token` (mode `0600`) so it survives a
|
||||
restart, and is deleted once setup completes. From then on `/install` redirects
|
||||
to the gallery and its API routes refuse to run again.
|
||||
|
||||
### What the wizard writes
|
||||
The wizard writes `gallery.yaml`, `settings.yaml` and `content/install.json` —
|
||||
the last of which **holds your Immich API key**. Environment variables override
|
||||
everything in it, so credentials can be rotated without touching the file, and
|
||||
setting them all up front means the wizard never appears at all.
|
||||
|
||||
| File | Contents |
|
||||
| ----------------------- | -------------------------------------------------------------------- |
|
||||
| `content/gallery.yaml` | The albums you picked |
|
||||
| `content/settings.yaml` | Site title, subtitle, theme — only if it does not exist yet |
|
||||
| `content/install.json` | Immich URL and API key, a generated site secret, admin password hash |
|
||||
|
||||
> **`content/install.json` holds credentials.** It is written with mode `0600`,
|
||||
> and it is the reason a backup of your `content/` directory is now a backup of
|
||||
> your Immich API key as well — treat it accordingly. The admin password is
|
||||
> stored as an scrypt hash, not as you typed it; the API key and site secret have
|
||||
> to stay readable to be usable.
|
||||
|
||||
**Environment variables always win.** `IMMICH_API_URL`, `IMMICH_API_KEY`,
|
||||
`AUTH_SECRET` and `ADMIN_PASSWORD` override anything in `install.json`, so you
|
||||
can rotate any of them by setting the variable — no need to touch the file. Set
|
||||
all of them up front and the wizard never appears, which is the usual choice for
|
||||
an infrastructure-as-code deployment.
|
||||
→ **[What the wizard writes, and environment precedence](docs/deployment.md#what-the-setup-wizard-writes)**
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -223,12 +206,15 @@ CACHE_TTL=300 # seconds, default: 300
|
||||
STALE_MAX_AGE=86400 # seconds an expired cache entry survives during an outage, default: 86400 (24h), 0 disables
|
||||
IMMICH_TIMEOUT_MS=15000 # Immich response wait, default: 15000
|
||||
IMAGE_CACHE_VERSION=1 # bump to bust browser image caches, default: off
|
||||
RATE_LIMIT_RPM=1500 # requests/min/IP, default: 1500
|
||||
RATE_LIMIT_RPM=1500 # requests/min/IP for images, default: 1500
|
||||
AUTH_SECRET=long-random-string # required in production
|
||||
TRUSTED_PROXY_HOPS=1 # reverse proxies in front, default: 0
|
||||
ADMIN_PASSWORD=your-secure-password # enables /admin panel
|
||||
WEBHOOK_SECRET=long-random-string # enables POST /api/webhook cache invalidation
|
||||
```
|
||||
|
||||
> Login and setup endpoints have their own, much lower limits that `RATE_LIMIT_RPM` does not raise — see [Rate Limiting](docs/gallery-config.md#rate-limiting).
|
||||
|
||||
> Behind a reverse proxy, set `TRUSTED_PROXY_HOPS` to the number of proxies in front of the app (nginx/Traefik/Caddy = 1; Cloudflare in front of nginx = 2). Without it the client IP is read from a header the client itself can set, which defeats the brute-force limits on the password endpoints. See [Trusted Proxies](docs/gallery-config.md#trusted-proxies).
|
||||
|
||||
### Immich API Key Permissions
|
||||
@@ -251,12 +237,26 @@ All gallery structure — hero images, albums, subpages, grid layout, footer —
|
||||
|
||||
→ **[Gallery Configuration Guide](docs/gallery-config.md)**
|
||||
|
||||
### Journal & Photo Essays
|
||||
|
||||
Long-form storytelling with fullbleed photos, side-by-side pairs, quotes and captions — as a standalone `/journal` section, or as a single essay on one subpage.
|
||||
|
||||
→ **[Journal & Photo Essays Guide](docs/journal.md)**
|
||||
|
||||
### Theming
|
||||
|
||||
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.
|
||||
|
||||
**Studio Modern** is the default: Leica precision rebuilt around the Archivo grotesque, with IBM Plex Mono for every piece of photographic metadata, hairline rules, zero radius, and red as signal only.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/theme-studio-modern-home.png" width="49%" alt="Studio Modern in dark mode" />
|
||||
<img src="docs/screenshots/theme-studio-modern-home-light.png" width="49%" alt="Studio Modern in light mode" />
|
||||
</p>
|
||||
<p align="center"><em>Studio Modern, dark and light — every preset ships both, and visitors switch with a toggle in the navigation bar.</em></p>
|
||||
|
||||
```yaml
|
||||
theme: studio # or: studio-modern, minimal, editorial, classic, noir, monograph
|
||||
theme: studio-modern # or: studio, minimal, editorial, classic, noir, monograph
|
||||
```
|
||||
|
||||
→ **[View all Themes & Configuration Guide](docs/theming.md)**
|
||||
@@ -287,59 +287,14 @@ docker compose up -d
|
||||
|
||||
The gallery will be available at `http://localhost:7211`.
|
||||
|
||||
<details>
|
||||
<summary><strong>Advanced Docker (Standalone, Health Check, Proxy)</strong></summary>
|
||||
> The `content/` volume must be **read-write** — the setup wizard, the admin
|
||||
> panel, the journal and the backup rotation all write into it. A `:ro` mount
|
||||
> leaves the wizard unable to complete and the admin panel unable to save.
|
||||
|
||||
### Standalone Docker
|
||||
For standalone `docker run`, the health check, TLS behind a reverse proxy, and
|
||||
what the gallery does while Immich is unreachable:
|
||||
|
||||
```bash
|
||||
# Build
|
||||
docker build -t immich-folio .
|
||||
|
||||
# Run
|
||||
docker run -d \
|
||||
--name immich-folio \
|
||||
--restart unless-stopped \
|
||||
-p 7211:7211 \
|
||||
--env-file .env.local \
|
||||
-v ./content:/app/content \
|
||||
immich-folio
|
||||
```
|
||||
|
||||
> **Note:** The `content/` volume mount lets you update `gallery.yaml` and
|
||||
> `about.md` without rebuilding the image. It must be **read-write**: the setup
|
||||
> wizard, the admin panel and the backup rotation all write into it. A `:ro`
|
||||
> mount leaves the wizard unable to complete and the admin panel unable to save.
|
||||
|
||||
### Health Check
|
||||
|
||||
The container includes a built-in health check at `/api/health`:
|
||||
|
||||
```bash
|
||||
curl http://localhost:7211/api/health
|
||||
```
|
||||
|
||||
### Behaviour when Immich is unreachable
|
||||
|
||||
Immich Folio buffers your gallery rather than merely proxying it:
|
||||
|
||||
- Album and asset pages keep serving the last known good data for up to `STALE_MAX_AGE`, so a restarting or briefly unreachable Immich does not take the public site down with it.
|
||||
- Once nothing cached is left, they return `503`, never `404` — a `404` would tell search engines to drop a URL for content that still exists.
|
||||
- Outages are never cached, so the gallery recovers as soon as Immich does.
|
||||
|
||||
The cache lives in the process, so it is empty right after a container restart.
|
||||
|
||||
### Reverse Proxy
|
||||
|
||||
Put Immich Folio behind nginx / Caddy / Traefik with TLS. Example Caddy config:
|
||||
|
||||
```
|
||||
photos.example.com {
|
||||
reverse_proxy localhost:7211
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
→ **[Deployment Guide](docs/deployment.md)**
|
||||
|
||||
## Admin Panel
|
||||
|
||||
@@ -371,6 +326,25 @@ own password, separate from any album passwords, and writes straight to
|
||||
|
||||
→ **[Admin Panel Guide](docs/admin-panel.md)**
|
||||
|
||||
## What's New
|
||||
|
||||
v0.11.0 — a first-run wizard, a journal, and a set of experimental portfolio
|
||||
features:
|
||||
|
||||
- **First-run setup wizard at `/install`** — configure a fresh deployment from the browser instead of writing config files → [First-Run Setup](#first-run-setup)
|
||||
- **Journal** — photo essays and travel stories at `/journal`, with a split-screen block editor, drafts, per-entry passwords and cover images → [Journal guide](docs/journal.md)
|
||||
- **About page editor** — portrait, name, location, gear and bio edited in the admin panel, plus a toggle to take the page offline
|
||||
- **Custom favicon upload** — SVG, PNG, ICO or JPEG, stored in the content volume
|
||||
- **Experimental portfolio features** — justified grid layout, `cover` hero splash screen, unlisted subpages, per-album grid overrides, cover focal points, external navigation links
|
||||
- **Admin panel** — every area has its own URL, a floating save bar, and a drag & drop photo-order editor
|
||||
|
||||
No migration and no configuration change are required to upgrade.
|
||||
|
||||
Security fixes ship in normal releases, so **running the latest release is the
|
||||
recommended baseline**.
|
||||
|
||||
→ Full history in [CHANGELOG.md](CHANGELOG.md) and the [GitHub releases](https://github.com/ralksta/immich-folio/releases)
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Next.js 16** (App Router, standalone output)
|
||||
|
||||
@@ -18,6 +18,10 @@ interface AlbumDetailViewProps {
|
||||
heroImageUrl?: string;
|
||||
heroBlurDataURL?: string;
|
||||
watermark?: LightboxWatermark;
|
||||
/** Client proofing — favorite hearts, selection bar and send-off modal. */
|
||||
proofing?: boolean;
|
||||
/** Offer the "send by email" button in the proofing modal. */
|
||||
allowMailto?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +60,8 @@ export function AlbumDetailView({
|
||||
heroImageUrl,
|
||||
heroBlurDataURL,
|
||||
watermark,
|
||||
proofing,
|
||||
allowMailto,
|
||||
}: AlbumDetailViewProps) {
|
||||
const metaDetail = albumMetaDetail(album);
|
||||
|
||||
@@ -70,7 +76,9 @@ export function AlbumDetailView({
|
||||
priority
|
||||
sizes="100vw"
|
||||
style={{ objectFit: 'cover' }}
|
||||
{...(heroBlurDataURL ? { placeholder: 'blur' as const, blurDataURL: heroBlurDataURL } : {})}
|
||||
{...(heroBlurDataURL
|
||||
? { placeholder: 'blur' as const, blurDataURL: heroBlurDataURL }
|
||||
: {})}
|
||||
/>
|
||||
<div className="album-hero__overlay" />
|
||||
</div>
|
||||
@@ -90,15 +98,18 @@ export function AlbumDetailView({
|
||||
<span className="album-header__meta-count">
|
||||
{images.length} {images.length === 1 ? 'photo' : 'photos'}
|
||||
</span>
|
||||
{metaDetail.date && (
|
||||
<span className="album-header__meta-date"> · {metaDetail.date}</span>
|
||||
)}
|
||||
{metaDetail.gear && (
|
||||
<span className="album-header__meta-gear">{metaDetail.gear}</span>
|
||||
)}
|
||||
{metaDetail.date && <span className="album-header__meta-date"> · {metaDetail.date}</span>}
|
||||
{metaDetail.gear && <span className="album-header__meta-gear">{metaDetail.gear}</span>}
|
||||
</p>
|
||||
</div>
|
||||
<PhotoGrid assets={images} layout={layout} gridStyle={gridStyle} watermark={watermark} />
|
||||
<PhotoGrid
|
||||
assets={images}
|
||||
layout={layout}
|
||||
gridStyle={gridStyle}
|
||||
watermark={watermark}
|
||||
proofing={proofing}
|
||||
allowMailto={allowMailto}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ interface EssayViewProps {
|
||||
* published story is not one. Album views opt in.
|
||||
*/
|
||||
proofing?: boolean;
|
||||
/** Offer the "send by email" button in the proofing modal. */
|
||||
allowMailto?: boolean;
|
||||
}
|
||||
|
||||
function EssayViewContent({
|
||||
@@ -384,7 +386,7 @@ export function EssayView(props: EssayViewProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<ProofingProvider albumTokens={albumTokens}>
|
||||
<ProofingProvider albumTokens={albumTokens} allowMailto={props.allowMailto ?? true}>
|
||||
<EssayViewContent {...props} />
|
||||
</ProofingProvider>
|
||||
);
|
||||
|
||||
@@ -35,15 +35,17 @@ export interface PhotoItem {
|
||||
interface PhotoGridProps {
|
||||
assets: PhotoItem[];
|
||||
layout?:
|
||||
| 'masonry'
|
||||
| 'uniform'
|
||||
| 'showcase'
|
||||
| 'filmstrip'
|
||||
| 'editorial-flow'
|
||||
| 'essay'
|
||||
| 'justified';
|
||||
'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay' | 'justified';
|
||||
gridStyle?: React.CSSProperties;
|
||||
watermark?: LightboxWatermark;
|
||||
/**
|
||||
* Client proofing — favorite hearts, the selection bar and the send-off
|
||||
* modal. Off unless the caller opts in, so a grid rendered outside the
|
||||
* album routes never grows a delivery workflow by accident.
|
||||
*/
|
||||
proofing?: boolean;
|
||||
/** Offer the "send by email" button in the proofing modal. */
|
||||
allowMailto?: boolean;
|
||||
}
|
||||
|
||||
/** Parse `#photo-N` from a hash string. Returns index or null. */
|
||||
@@ -120,7 +122,9 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph
|
||||
}, [displayedAssets.length]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setLightboxIndex((prev) => (prev !== null ? (prev - 1 + displayedAssets.length) % displayedAssets.length : null));
|
||||
setLightboxIndex((prev) =>
|
||||
prev !== null ? (prev - 1 + displayedAssets.length) % displayedAssets.length : null,
|
||||
);
|
||||
}, [displayedAssets.length]);
|
||||
|
||||
// Keyboard navigation and the scroll lock live in <Lightbox/>.
|
||||
@@ -268,7 +272,9 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph
|
||||
type="button"
|
||||
onClick={() => proofing.setIsFilterActive((prev) => !prev)}
|
||||
style={{
|
||||
background: proofing.isFilterActive ? 'var(--accent, #e60012)' : 'rgba(255,255,255,0.15)',
|
||||
background: proofing.isFilterActive
|
||||
? 'var(--accent, #e60012)'
|
||||
: 'rgba(255,255,255,0.15)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
padding: '6px 14px',
|
||||
@@ -298,7 +304,7 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProofingModal />
|
||||
{proofing && <ProofingModal />}
|
||||
|
||||
{lightboxIndex !== null && (
|
||||
<Lightbox
|
||||
@@ -317,8 +323,15 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph
|
||||
export function PhotoGrid(props: PhotoGridProps) {
|
||||
const albumTokens = useMemo(() => props.assets.map((a) => a.id), [props.assets]);
|
||||
|
||||
// Without the provider useProofing() returns null, and every proofing control
|
||||
// (hearts in the grid and in the lightbox, selection bar, modal) drops out on
|
||||
// its own.
|
||||
if (!props.proofing) {
|
||||
return <PhotoGridInner {...props} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ProofingProvider albumTokens={albumTokens}>
|
||||
<ProofingProvider albumTokens={albumTokens} allowMailto={props.allowMailto ?? true}>
|
||||
<PhotoGridInner {...props} />
|
||||
</ProofingProvider>
|
||||
);
|
||||
|
||||
@@ -61,7 +61,9 @@ function AlbumGrid({
|
||||
fill
|
||||
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw"
|
||||
loading="lazy"
|
||||
{...(album.coverPosition ? { style: { objectPosition: album.coverPosition } } : {})}
|
||||
{...(album.coverPosition
|
||||
? { style: { objectPosition: album.coverPosition } }
|
||||
: {})}
|
||||
{...(ph ? { placeholder: 'blur' as const, blurDataURL: ph.blurDataURL } : {})}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
assetAspectRatio,
|
||||
} from '@/lib/urls';
|
||||
import { encodeAssetId } from '@/lib/tokens';
|
||||
import { getConfig, type GridConfig } from '@/lib/config';
|
||||
import { getConfig, resolveProofing, type GridConfig } from '@/lib/config';
|
||||
import { isProtected, isAuthenticated } from '@/lib/auth';
|
||||
import { isAdminAuthenticated } from '@/lib/admin/auth';
|
||||
import PasswordGate from '@/components/PasswordGate';
|
||||
@@ -182,6 +182,12 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
const resolveLayout = (overrides?: Partial<GridConfig>) =>
|
||||
overrides?.layout ?? config.grid.layout;
|
||||
|
||||
// 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.
|
||||
const proofingFor = (subpage?: { proofing?: boolean }) =>
|
||||
resolveProofing(subpage, config.proofing.enabled);
|
||||
|
||||
// EXPERIMENTAL: per-album grid override — merged over the subpage grid so
|
||||
// the precedence is global < subpage < album.
|
||||
const mergeAlbumGrid = (
|
||||
@@ -241,6 +247,8 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
backLinkHref={`/${subpageSlug}`}
|
||||
backLinkLabel={`Back to ${subpageName}`}
|
||||
watermark={config.watermark}
|
||||
proofing={proofingFor(subpageData?.subpage)}
|
||||
allowMailto={config.proofing.allowMailto}
|
||||
{...heroData}
|
||||
/>
|
||||
);
|
||||
@@ -282,12 +290,12 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
let essayParsed = result.subpage.essayText
|
||||
? parseEssayMarkdown(result.subpage.essayText)
|
||||
: result.subpage.essayFile
|
||||
? loadEssayFromFile(result.subpage.essayFile)
|
||||
: null;
|
||||
? loadEssayFromFile(result.subpage.essayFile)
|
||||
: null;
|
||||
|
||||
// Fetch assets from all subpage albums
|
||||
const allAlbums = await Promise.all(
|
||||
albums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh))
|
||||
albums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh)),
|
||||
);
|
||||
const allAssets = allAlbums.flatMap((a) => (a ? a.assets : []));
|
||||
const images = toPhotoItems(allAssets, config.exifOnHover);
|
||||
@@ -312,6 +320,11 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
};
|
||||
}
|
||||
|
||||
// A published story is not an album handover, so an essay only gets the
|
||||
// proofing controls when its subpage asks for them explicitly — the
|
||||
// global default does not reach in here.
|
||||
const essayProofing = result.subpage.proofing === true;
|
||||
|
||||
return (
|
||||
<EssayView
|
||||
essay={essayParsed}
|
||||
@@ -319,6 +332,8 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
title={result.subpage.title || result.subpage.name}
|
||||
subtitle={result.subpage.subtitle}
|
||||
watermark={config.watermark}
|
||||
proofing={essayProofing}
|
||||
allowMailto={config.proofing.allowMailto}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -358,6 +373,8 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
backLinkHref="/"
|
||||
backLinkLabel="Back to Gallery"
|
||||
watermark={config.watermark}
|
||||
proofing={proofingFor(result.subpage)}
|
||||
allowMailto={config.proofing.allowMailto}
|
||||
{...heroData}
|
||||
/>
|
||||
);
|
||||
@@ -435,6 +452,8 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
backLinkHref="/"
|
||||
backLinkLabel="Back to Gallery"
|
||||
watermark={config.watermark}
|
||||
proofing={proofingFor()}
|
||||
allowMailto={config.proofing.allowMailto}
|
||||
{...heroData}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -56,7 +56,9 @@ export default function AnalyticsView() {
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="admin-panel" style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<p style={{ color: 'var(--admin-text-muted)', marginBottom: '1rem' }}>{error || 'No analytics data'}</p>
|
||||
<p style={{ color: 'var(--admin-text-muted)', marginBottom: '1rem' }}>
|
||||
{error || 'No analytics data'}
|
||||
</p>
|
||||
<button className="admin-btn admin-btn-primary" onClick={fetchAnalytics}>
|
||||
<Icons.IconRefresh size={14} /> Retry
|
||||
</button>
|
||||
@@ -71,17 +73,22 @@ export default function AnalyticsView() {
|
||||
.slice(-14); // Last 14 days
|
||||
const maxDayViews = Math.max(...daysList.map(([, d]) => d.pageviews || 0), 1);
|
||||
|
||||
const desktopRatio = data.devices.desktop + data.devices.mobile > 0
|
||||
? Math.round((data.devices.desktop / (data.devices.desktop + data.devices.mobile)) * 100)
|
||||
: 50;
|
||||
const desktopRatio =
|
||||
data.devices.desktop + data.devices.mobile > 0
|
||||
? Math.round((data.devices.desktop / (data.devices.desktop + data.devices.mobile)) * 100)
|
||||
: 50;
|
||||
|
||||
return (
|
||||
<div className="analytics-view">
|
||||
{/* Header */}
|
||||
<div className="analytics-header">
|
||||
<div>
|
||||
<h2><Icons.IconSparkles size={20} /> Visitor Insights & Analytics</h2>
|
||||
<p className="analytics-subtitle">Privacy-first aggregate traffic stats. No personal data collected.</p>
|
||||
<h2>
|
||||
<Icons.IconSparkles size={20} /> Visitor Insights & Analytics
|
||||
</h2>
|
||||
<p className="analytics-subtitle">
|
||||
Privacy-first aggregate traffic stats. No personal data collected.
|
||||
</p>
|
||||
</div>
|
||||
<button className="admin-btn admin-btn-ghost admin-btn-sm" onClick={fetchAnalytics}>
|
||||
<Icons.IconRefresh size={14} /> Refresh
|
||||
@@ -89,10 +96,15 @@ export default function AnalyticsView() {
|
||||
</div>
|
||||
|
||||
{data.trackingEnabled === false && (
|
||||
<div className="backup-status-alert error" style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<div
|
||||
className="backup-status-alert error"
|
||||
style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
<Icons.IconLock size={16} />
|
||||
<span>
|
||||
<strong>Analytics Tracking is Currently Disabled.</strong> No new visitor views are being recorded. You can re-enable tracking anytime under <em>Settings → General</em>.
|
||||
<strong>Analytics Tracking is Currently Disabled.</strong> No new visitor views are
|
||||
being recorded. You can re-enable tracking anytime under{' '}
|
||||
<em>Settings → General</em>.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -111,7 +123,9 @@ export default function AnalyticsView() {
|
||||
</div>
|
||||
<div className="metric-card">
|
||||
<span className="metric-label">Desktop vs Mobile</span>
|
||||
<span className="metric-value">{desktopRatio}% / {100 - desktopRatio}%</span>
|
||||
<span className="metric-value">
|
||||
{desktopRatio}% / {100 - desktopRatio}%
|
||||
</span>
|
||||
<span className="metric-sub">Device distribution</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,17 +133,25 @@ export default function AnalyticsView() {
|
||||
{/* Daily Trends Chart */}
|
||||
<div className="analytics-panel">
|
||||
<div className="analytics-section-title">
|
||||
<h3><Icons.IconGrid size={16} /> Daily Traffic Trend (Last 14 Days)</h3>
|
||||
<h3>
|
||||
<Icons.IconGrid size={16} /> Daily Traffic Trend (Last 14 Days)
|
||||
</h3>
|
||||
</div>
|
||||
{daysList.length === 0 ? (
|
||||
<p className="analytics-empty">No pageviews recorded yet. Visit portfolio pages to start tracking!</p>
|
||||
<p className="analytics-empty">
|
||||
No pageviews recorded yet. Visit portfolio pages to start tracking!
|
||||
</p>
|
||||
) : (
|
||||
<div className="analytics-bar-chart">
|
||||
{daysList.map(([dateStr, dayData]) => {
|
||||
const heightPct = Math.round((dayData.pageviews / maxDayViews) * 100);
|
||||
const formattedDate = dateStr.slice(5); // MM-DD
|
||||
return (
|
||||
<div key={dateStr} className="bar-col" title={`${dateStr}: ${dayData.pageviews} views`}>
|
||||
<div
|
||||
key={dateStr}
|
||||
className="bar-col"
|
||||
title={`${dateStr}: ${dayData.pageviews} views`}
|
||||
>
|
||||
<div className="bar-wrapper">
|
||||
<div className="bar-fill" style={{ height: `${Math.max(heightPct, 8)}%` }}>
|
||||
<span className="bar-val">{dayData.pageviews}</span>
|
||||
@@ -146,7 +168,9 @@ export default function AnalyticsView() {
|
||||
{/* Top Pages List */}
|
||||
<div className="analytics-panel">
|
||||
<div className="analytics-section-title">
|
||||
<h3><Icons.IconTarget size={16} /> Top Visited Pages & Subpages</h3>
|
||||
<h3>
|
||||
<Icons.IconTarget size={16} /> Top Visited Pages & Subpages
|
||||
</h3>
|
||||
</div>
|
||||
{data.topPages.length === 0 ? (
|
||||
<p className="analytics-empty">No pages recorded yet.</p>
|
||||
|
||||
@@ -137,8 +137,8 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
||||
<div className="backup-confirm-content">
|
||||
<strong>Confirm Restoration</strong>
|
||||
<p>
|
||||
Are you sure you want to restore <code>{confirmFilename}</code>?
|
||||
A safety snapshot of your current state will be created automatically before reverting.
|
||||
Are you sure you want to restore <code>{confirmFilename}</code>? A safety snapshot
|
||||
of your current state will be created automatically before reverting.
|
||||
</p>
|
||||
<div className="backup-confirm-actions">
|
||||
<button
|
||||
@@ -170,12 +170,13 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
||||
<div className="backup-list">
|
||||
{visibleList.map((item) => {
|
||||
const dateObj = item.timestamp ? new Date(item.timestamp) : null;
|
||||
const formattedDate = dateObj && !isNaN(dateObj.getTime())
|
||||
? dateObj.toLocaleString('de-DE', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
})
|
||||
: item.filename;
|
||||
const formattedDate =
|
||||
dateObj && !isNaN(dateObj.getTime())
|
||||
? dateObj.toLocaleString('de-DE', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
})
|
||||
: item.filename;
|
||||
|
||||
return (
|
||||
<div key={item.filename} className="backup-item">
|
||||
@@ -183,7 +184,10 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
||||
<div className="backup-item-title">
|
||||
<span className="backup-filename">{formattedDate}</span>
|
||||
{item.isPreRestore && (
|
||||
<span className="backup-badge pre-restore" title="Safety snapshot taken before a restore">
|
||||
<span
|
||||
className="backup-badge pre-restore"
|
||||
title="Safety snapshot taken before a restore"
|
||||
>
|
||||
Pre-Restore Snapshot
|
||||
</span>
|
||||
)}
|
||||
@@ -208,7 +212,13 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
||||
className="backup-toggle-btn"
|
||||
onClick={() => setShowAllBackups(!showAllBackups)}
|
||||
>
|
||||
<Icons.IconChevronDown size={14} style={{ transform: showAllBackups ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s ease' }} />
|
||||
<Icons.IconChevronDown
|
||||
size={14}
|
||||
style={{
|
||||
transform: showAllBackups ? 'rotate(180deg)' : 'none',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
{showAllBackups
|
||||
? 'Ältere Einträge einklappen'
|
||||
: `Ältere Einträge anzeigen (${hiddenCount} weitere)`}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
parseEssayMarkdown,
|
||||
serializeEssayMarkdown,
|
||||
@@ -28,16 +28,19 @@ interface EssayBlockEditorProps {
|
||||
|
||||
export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlockEditorProps) {
|
||||
const [essay, setEssay] = useState<ParsedEssay>(() =>
|
||||
parseEssayMarkdown(markdown || '# Title\n\nWrite your story here...')
|
||||
parseEssayMarkdown(markdown || '# Title\n\nWrite your story here...'),
|
||||
);
|
||||
|
||||
// Sync internal state when prop changes from outside (e.g. initial load)
|
||||
useEffect(() => {
|
||||
const currentSerialized = serializeEssayMarkdown(essay);
|
||||
if (markdown !== currentSerialized && markdown) {
|
||||
// Sync internal state when the prop changes from outside (e.g. initial load).
|
||||
// Adjusting during render rather than in an effect: an effect would commit the
|
||||
// stale essay first and re-render, which flashes the previous story's blocks.
|
||||
const [lastMarkdown, setLastMarkdown] = useState(markdown);
|
||||
if (markdown !== lastMarkdown) {
|
||||
setLastMarkdown(markdown);
|
||||
if (markdown && markdown !== serializeEssayMarkdown(essay)) {
|
||||
setEssay(parseEssayMarkdown(markdown));
|
||||
}
|
||||
}, [markdown]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}
|
||||
|
||||
const updateBlocks = useCallback(
|
||||
(newBlocks: EssayBlock[]) => {
|
||||
@@ -50,15 +53,15 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
if (b.type === 'photo') return [b.assetId];
|
||||
if (b.type === 'photo-pair') return b.assetIds;
|
||||
return [];
|
||||
})
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
setEssay(updatedEssay);
|
||||
onChange(serializeEssayMarkdown(updatedEssay));
|
||||
},
|
||||
[essay, onChange]
|
||||
[essay, onChange],
|
||||
);
|
||||
|
||||
// Block manipulation helpers
|
||||
@@ -171,7 +174,10 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
{/* Card Header */}
|
||||
<div className="essay-block-card-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<IconGripVertical size={14} style={{ color: 'var(--admin-text-muted)', cursor: 'grab' }} />
|
||||
<IconGripVertical
|
||||
size={14}
|
||||
style={{ color: 'var(--admin-text-muted)', cursor: 'grab' }}
|
||||
/>
|
||||
<BlockBadge type={block.type} index={idx + 1} />
|
||||
</div>
|
||||
|
||||
@@ -227,7 +233,10 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
value={block.text}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, text: e.target.value })}
|
||||
placeholder="Section heading..."
|
||||
style={{ flex: 1, fontWeight: block.level === 1 ? 700 : block.level === 2 ? 600 : 500 }}
|
||||
style={{
|
||||
flex: 1,
|
||||
fontWeight: block.level === 1 ? 700 : block.level === 2 ? 600 : 500,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -237,7 +246,7 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
<textarea
|
||||
rows={3}
|
||||
value={block.html.replace(/<[^>]+>/g, (t) =>
|
||||
t.startsWith('<strong>') ? '**' : t.startsWith('</strong>') ? '**' : ''
|
||||
t.startsWith('<strong>') ? '**' : t.startsWith('</strong>') ? '**' : '',
|
||||
)}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, html: e.target.value })}
|
||||
placeholder="Write text paragraph... (**bold**, *italic* markdown supported)"
|
||||
@@ -246,7 +255,15 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
|
||||
{/* 3. PULLQUOTE BLOCK */}
|
||||
{block.type === 'quote' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', borderLeft: '3px solid var(--admin-accent)', paddingLeft: '10px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
borderLeft: '3px solid var(--admin-accent)',
|
||||
paddingLeft: '10px',
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={block.text}
|
||||
@@ -293,7 +310,7 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
className="admin-btn admin-btn-sm admin-btn-primary"
|
||||
onClick={() =>
|
||||
onSelectPhoto((pickedId) =>
|
||||
handleUpdateBlock(idx, { ...block, assetId: pickedId })
|
||||
handleUpdateBlock(idx, { ...block, assetId: pickedId }),
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -354,7 +371,13 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||
{/* Photo 1 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--admin-text-secondary)' }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
color: 'var(--admin-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Photo 1
|
||||
</span>
|
||||
<div className="essay-photo-preview-container">
|
||||
@@ -380,7 +403,7 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [pickedId, block.assetIds[1]],
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -405,8 +428,20 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
|
||||
{/* Photo 2 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--admin-text-secondary)' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
color: 'var(--admin-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Photo 2
|
||||
</span>
|
||||
<button
|
||||
@@ -447,7 +482,7 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [block.assetIds[0], pickedId],
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,9 @@ import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import * as Icons from './Icons';
|
||||
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';
|
||||
|
||||
interface Settings {
|
||||
title?: string;
|
||||
@@ -73,7 +76,7 @@ interface Settings {
|
||||
about?: { enabled?: boolean };
|
||||
}
|
||||
|
||||
const PRESETS = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
||||
const PRESETS = ['studio-modern', 'studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
||||
const LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow', 'justified'];
|
||||
const PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];
|
||||
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic', 'cover'];
|
||||
@@ -234,7 +237,8 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
if (typeof window !== 'undefined') {
|
||||
const mode = (document.documentElement.getAttribute('data-theme') as 'dark' | 'light') || 'dark';
|
||||
const mode =
|
||||
(document.documentElement.getAttribute('data-theme') as 'dark' | 'light') || 'dark';
|
||||
setCurrentMode(mode);
|
||||
}
|
||||
}, []);
|
||||
@@ -497,8 +501,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
{activeSection === 'general' && (
|
||||
<div className="settings-panel">
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconGear size={18} /> General Site Settings</h3>
|
||||
<p className="settings-section-sub">Configure basic site identity, language, and core feature toggles.</p>
|
||||
<h3>
|
||||
<Icons.IconGear size={18} /> General Site Settings
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Configure basic site identity, language, and core feature toggles.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
@@ -534,8 +542,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<div className="settings-section-divider" />
|
||||
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconSparkles size={18} /> Portfolio Features & Modules</h3>
|
||||
<p className="settings-section-sub">Enable or disable optional portfolio modules, privacy analytics, and map widgets.</p>
|
||||
<h3>
|
||||
<Icons.IconSparkles size={18} /> Portfolio Features & Modules
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Enable or disable optional portfolio modules, privacy analytics, and map widgets.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-toggle-cards-grid">
|
||||
@@ -545,8 +557,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
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>
|
||||
<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" />
|
||||
@@ -559,8 +575,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
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>
|
||||
<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" />
|
||||
@@ -573,8 +593,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
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>
|
||||
<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" />
|
||||
@@ -587,8 +611,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
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>
|
||||
<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" />
|
||||
@@ -601,8 +629,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
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>
|
||||
<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" />
|
||||
@@ -615,10 +647,17 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
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>
|
||||
<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' : ''}`}>
|
||||
<div
|
||||
className={`switch-toggle ${settings.proofing?.enabled !== false ? 'on' : ''}`}
|
||||
>
|
||||
<span className="switch-slider" />
|
||||
</div>
|
||||
</button>
|
||||
@@ -632,7 +671,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<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>
|
||||
<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" />
|
||||
@@ -655,7 +696,10 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
try {
|
||||
const res = await fetch('/api/admin/favicon', { method: 'PUT', body: form });
|
||||
const res = await fetch('/api/admin/favicon', {
|
||||
method: 'PUT',
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json();
|
||||
setFaviconMessage(res.ok ? data.message : `Error: ${data.error}`);
|
||||
} catch {
|
||||
@@ -689,7 +733,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
{faviconUploading && <div className="admin-spinner" />}
|
||||
</div>
|
||||
{faviconMessage && (
|
||||
<p className={`save-message ${faviconMessage.startsWith('Error') ? 'error' : 'success'}`}>
|
||||
<p
|
||||
className={`save-message ${faviconMessage.startsWith('Error') ? 'error' : 'success'}`}
|
||||
>
|
||||
{faviconMessage}
|
||||
</p>
|
||||
)}
|
||||
@@ -704,8 +750,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
{activeSection === 'theme' && (
|
||||
<div className="settings-panel theme-settings-panel">
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconPalette size={18} /> Theme Presets & Color Mode</h3>
|
||||
<p className="settings-section-sub">Choose a typography preset and preview in Light or Dark mode.</p>
|
||||
<h3>
|
||||
<Icons.IconPalette size={18} /> Theme Presets & Color Mode
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Choose a typography preset and preview in Light or Dark mode.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
@@ -724,7 +774,7 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
frame: 'none' as const,
|
||||
gap: 4,
|
||||
};
|
||||
const isActive = (settings.theme?.preset || 'studio') === p;
|
||||
const isActive = (settings.theme?.preset || DEFAULT_PRESET) === p;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
@@ -767,8 +817,17 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
|
||||
<div className="admin-field">
|
||||
<label>Color Mode</label>
|
||||
<p style={{ fontSize: '0.78rem', color: 'var(--admin-text-muted)', margin: '0.25rem 0 0.6rem', lineHeight: '1.4' }}>
|
||||
Presets like <strong>Editorial</strong>, <strong>Minimal</strong> & <strong>Classic</strong> feature warm light/cream backgrounds in Light Mode and charcoal in Dark Mode.
|
||||
<p
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
color: 'var(--admin-text-muted)',
|
||||
margin: '0.25rem 0 0.6rem',
|
||||
lineHeight: '1.4',
|
||||
}}
|
||||
>
|
||||
Presets like <strong>Editorial</strong>, <strong>Minimal</strong> &{' '}
|
||||
<strong>Classic</strong> feature warm light/cream backgrounds in Light Mode and
|
||||
charcoal in Dark Mode.
|
||||
</p>
|
||||
<div className="segmented-control">
|
||||
<button
|
||||
@@ -791,8 +850,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<div className="settings-section-divider" />
|
||||
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconTarget size={18} /> Accent Color</h3>
|
||||
<p className="settings-section-sub">Pick a primary accent color for links, buttons, and highlights.</p>
|
||||
<h3>
|
||||
<Icons.IconTarget size={18} /> Accent Color
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Pick a primary accent color for links, buttons, and highlights.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
@@ -807,7 +870,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
{ hex: '#ffffff', name: 'Monochrome White' },
|
||||
{ hex: '#000000', name: 'Obsidian Black' },
|
||||
].map((swatch) => {
|
||||
const isSelected = (settings.theme?.accent || '#e60012').toLowerCase() === swatch.hex.toLowerCase();
|
||||
const isSelected =
|
||||
(settings.theme?.accent || '#e60012').toLowerCase() ===
|
||||
swatch.hex.toLowerCase();
|
||||
return (
|
||||
<button
|
||||
key={swatch.hex}
|
||||
@@ -839,8 +904,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<div className="settings-section-divider" />
|
||||
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconFrame size={18} /> Photo Frame & Layout</h3>
|
||||
<p className="settings-section-sub">Customize image presentation borders and hero layouts.</p>
|
||||
<h3>
|
||||
<Icons.IconFrame size={18} /> Photo Frame & Layout
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Customize image presentation borders and hero layouts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
@@ -849,7 +918,10 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
{PHOTO_FRAMES.map((f) => {
|
||||
const info = {
|
||||
none: { label: 'None', desc: 'Flush image with crisp edges' },
|
||||
passepartout: { label: 'Passepartout', desc: 'Classic gallery matting border' },
|
||||
passepartout: {
|
||||
label: 'Passepartout',
|
||||
desc: 'Classic gallery matting border',
|
||||
},
|
||||
shadow: { label: 'Shadow', desc: 'Soft floating drop shadow' },
|
||||
}[f] || { label: f, desc: '' };
|
||||
const isActive = (settings.theme?.photoFrame || 'none') === f;
|
||||
@@ -887,7 +959,10 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
stacked: { label: 'Stacked', desc: 'Title stacked directly over photo' },
|
||||
typographic: { label: 'Typographic', desc: 'Oversized magazine masthead' },
|
||||
mosaic: { label: 'Mosaic', desc: 'Dynamic photo collage layout' },
|
||||
cover: { label: 'Cover (Experimental)', desc: 'Fullscreen splash with a single Enter link' },
|
||||
cover: {
|
||||
label: 'Cover (Experimental)',
|
||||
desc: 'Fullscreen splash with a single Enter link',
|
||||
},
|
||||
}[s] || { label: s, desc: '' };
|
||||
const isActive = (settings.theme?.heroStyle || 'split') === s;
|
||||
|
||||
@@ -963,8 +1038,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<div className="settings-section-divider" />
|
||||
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconSparkles size={18} /> Finishing Touches</h3>
|
||||
<p className="settings-section-sub">Enable optional visual effects and indicators.</p>
|
||||
<h3>
|
||||
<Icons.IconSparkles size={18} /> Finishing Touches
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Enable optional visual effects and indicators.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-toggle-cards-grid">
|
||||
@@ -974,8 +1053,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
onClick={() => update('theme.grain', !settings.theme?.grain)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title"><Icons.IconFilm size={16} /> Film Grain Texture</span>
|
||||
<span className="toggle-card-desc">Adds analog noise overlay across portfolio background</span>
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconFilm size={16} /> Film Grain Texture
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Adds analog noise overlay across portfolio background
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.theme?.grain === true ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
@@ -988,10 +1071,16 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
onClick={() => update('theme.headerDot', settings.theme?.headerDot === false)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title"><Icons.IconTarget size={16} /> Header Accent Dot</span>
|
||||
<span className="toggle-card-desc">Displays accent dot next to active section header</span>
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconTarget size={16} /> Header Accent Dot
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Displays accent dot next to active section header
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.theme?.headerDot !== false ? 'on' : ''}`}>
|
||||
<div
|
||||
className={`switch-toggle ${settings.theme?.headerDot !== false ? 'on' : ''}`}
|
||||
>
|
||||
<span className="switch-slider" />
|
||||
</div>
|
||||
</button>
|
||||
@@ -1002,8 +1091,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
{activeSection === 'grid' && (
|
||||
<div className="settings-panel">
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconGrid size={18} /> Grid & Layout Engine</h3>
|
||||
<p className="settings-section-sub">Configure photography gallery column structures and thumbnail aspect ratios.</p>
|
||||
<h3>
|
||||
<Icons.IconGrid size={18} /> Grid & Layout Engine
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Configure photography gallery column structures and thumbnail aspect ratios.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
@@ -1011,12 +1104,27 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<div className="preset-card-grid">
|
||||
{LAYOUTS.map((l) => {
|
||||
const info = {
|
||||
masonry: { label: 'Masonry', desc: 'Dynamic pinterest-style staggered columns' },
|
||||
masonry: {
|
||||
label: 'Masonry',
|
||||
desc: 'Dynamic pinterest-style staggered columns',
|
||||
},
|
||||
uniform: { label: 'Uniform Grid', desc: 'Clean equal aspect ratio grid' },
|
||||
showcase: { label: 'Showcase', desc: 'Featured hero photos mixed with smaller tiles' },
|
||||
filmstrip: { label: 'Filmstrip', desc: 'Horizontal scrollable film strip timeline' },
|
||||
'editorial-flow': { label: 'Editorial Flow', desc: 'Magazine story layout with varying photo sizes' },
|
||||
justified: { label: 'Justified (Experimental)', desc: 'Equal-height rows that fill the full width' },
|
||||
showcase: {
|
||||
label: 'Showcase',
|
||||
desc: 'Featured hero photos mixed with smaller tiles',
|
||||
},
|
||||
filmstrip: {
|
||||
label: 'Filmstrip',
|
||||
desc: 'Horizontal scrollable film strip timeline',
|
||||
},
|
||||
'editorial-flow': {
|
||||
label: 'Editorial Flow',
|
||||
desc: 'Magazine story layout with varying photo sizes',
|
||||
},
|
||||
justified: {
|
||||
label: 'Justified (Experimental)',
|
||||
desc: 'Equal-height rows that fill the full width',
|
||||
},
|
||||
}[l] || { label: l, desc: '' };
|
||||
const isActive = (settings.grid?.layout || 'masonry') === l;
|
||||
|
||||
@@ -1031,38 +1139,65 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<div className={`mini-layout-demo layout-demo-${l}`}>
|
||||
{l === 'masonry' && (
|
||||
<div className="demo-masonry-col-group">
|
||||
<div className="demo-col"><div className="demo-tile h-high" /><div className="demo-tile h-low" /></div>
|
||||
<div className="demo-col"><div className="demo-tile h-low" /><div className="demo-tile h-high" /></div>
|
||||
<div className="demo-col"><div className="demo-tile h-med" /><div className="demo-tile h-med" /></div>
|
||||
<div className="demo-col">
|
||||
<div className="demo-tile h-high" />
|
||||
<div className="demo-tile h-low" />
|
||||
</div>
|
||||
<div className="demo-col">
|
||||
<div className="demo-tile h-low" />
|
||||
<div className="demo-tile h-high" />
|
||||
</div>
|
||||
<div className="demo-col">
|
||||
<div className="demo-tile h-med" />
|
||||
<div className="demo-tile h-med" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{l === 'uniform' && (
|
||||
<div className="demo-uniform-grid">
|
||||
<div className="demo-tile" /><div className="demo-tile" /><div className="demo-tile" />
|
||||
<div className="demo-tile" /><div className="demo-tile" /><div className="demo-tile" />
|
||||
<div className="demo-tile" />
|
||||
<div className="demo-tile" />
|
||||
<div className="demo-tile" />
|
||||
<div className="demo-tile" />
|
||||
<div className="demo-tile" />
|
||||
<div className="demo-tile" />
|
||||
</div>
|
||||
)}
|
||||
{l === 'showcase' && (
|
||||
<div className="demo-showcase-grid">
|
||||
<div className="demo-tile demo-hero" />
|
||||
<div className="demo-col"><div className="demo-tile" /><div className="demo-tile" /></div>
|
||||
<div className="demo-col">
|
||||
<div className="demo-tile" />
|
||||
<div className="demo-tile" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{l === 'filmstrip' && (
|
||||
<div className="demo-filmstrip-row">
|
||||
<div className="demo-tile strip" /><div className="demo-tile strip" /><div className="demo-tile strip" />
|
||||
<div className="demo-tile strip" />
|
||||
<div className="demo-tile strip" />
|
||||
<div className="demo-tile strip" />
|
||||
</div>
|
||||
)}
|
||||
{l === 'editorial-flow' && (
|
||||
<div className="demo-editorial-flow">
|
||||
<div className="demo-tile wide" />
|
||||
<div className="demo-row"><div className="demo-tile" /><div className="demo-tile" /></div>
|
||||
<div className="demo-row">
|
||||
<div className="demo-tile" />
|
||||
<div className="demo-tile" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{l === 'justified' && (
|
||||
<div className="demo-editorial-flow">
|
||||
<div className="demo-row"><div className="demo-tile wide" /><div className="demo-tile" /></div>
|
||||
<div className="demo-row"><div className="demo-tile" /><div className="demo-tile wide" /></div>
|
||||
<div className="demo-row">
|
||||
<div className="demo-tile wide" />
|
||||
<div className="demo-tile" />
|
||||
</div>
|
||||
<div className="demo-row">
|
||||
<div className="demo-tile" />
|
||||
<div className="demo-tile wide" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1086,7 +1221,10 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
'3/2': { label: 'Landscape (3:2)', desc: 'Standard 35mm DSLR landscape' },
|
||||
'2/3': { label: 'Portrait (2:3)', desc: 'Vertical portrait orientation' },
|
||||
'16/9': { label: 'Cinema (16:9)', desc: 'Widescreen 16:9 cinematic ratio' },
|
||||
auto: { label: 'Original Auto', desc: 'Uncropped original image proportions' },
|
||||
auto: {
|
||||
label: 'Original Auto',
|
||||
desc: 'Uncropped original image proportions',
|
||||
},
|
||||
}[r] || { label: r, desc: '' };
|
||||
const isActive = (settings.grid?.aspectRatio || '1') === r;
|
||||
|
||||
@@ -1115,7 +1253,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<div className="settings-section-divider" />
|
||||
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconColumns size={18} /> Spacing & Columns</h3>
|
||||
<h3>
|
||||
<Icons.IconColumns size={18} /> Spacing & Columns
|
||||
</h3>
|
||||
<p className="settings-section-sub">Adjust column counts and grid gap spacing.</p>
|
||||
</div>
|
||||
|
||||
@@ -1147,8 +1287,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
{activeSection === 'footer' && (
|
||||
<div className="settings-panel">
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconLink size={18} /> Footer & Social Links</h3>
|
||||
<p className="settings-section-sub">Display branding, Instagram, email and website links in portfolio footer.</p>
|
||||
<h3>
|
||||
<Icons.IconLink size={18} /> Footer & Social Links
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Display branding, Instagram, email and website links in portfolio footer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
@@ -1187,8 +1331,13 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
</div>
|
||||
|
||||
<div className="settings-section-header" style={{ marginTop: '2rem' }}>
|
||||
<h3><Icons.IconLink size={18} /> Header Navigation Links (Experimental)</h3>
|
||||
<p className="settings-section-sub">External links shown after your pages in the header menu. Only http(s) URLs are allowed; they open in a new tab.</p>
|
||||
<h3>
|
||||
<Icons.IconLink size={18} /> Header Navigation Links (Experimental)
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
External links shown after your pages in the header menu. Only http(s) URLs are
|
||||
allowed; they open in a new tab.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(settings.navLinks || []).map((link, i) => (
|
||||
@@ -1234,7 +1383,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-sm"
|
||||
onClick={() => update('navLinks', [...(settings.navLinks || []), { label: '', url: '' }])}
|
||||
onClick={() =>
|
||||
update('navLinks', [...(settings.navLinks || []), { label: '', url: '' }])
|
||||
}
|
||||
>
|
||||
+ Add external link
|
||||
</button>
|
||||
@@ -1244,8 +1395,13 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
{activeSection === 'legal' && (
|
||||
<div className="settings-panel">
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconScale size={18} /> Legal Notice & Impressum</h3>
|
||||
<p className="settings-section-sub">Configure required legal disclosure page for EU / German Telemediengesetz compliance.</p>
|
||||
<h3>
|
||||
<Icons.IconScale size={18} /> Legal Notice & Impressum
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Configure required legal disclosure page for EU / German Telemediengesetz
|
||||
compliance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-toggle-cards-grid" style={{ marginBottom: '1.25rem' }}>
|
||||
@@ -1255,8 +1411,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
onClick={() => update('legal.enabled', !settings.legal?.enabled)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title"><Icons.IconFileText size={16} /> Enable Impressum Page (/impressum)</span>
|
||||
<span className="toggle-card-desc">Automatically generates and links /impressum in footer</span>
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconFileText size={16} /> Enable Impressum Page (/impressum)
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Automatically generates and links /impressum in footer
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.legal?.enabled === true ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
@@ -1337,14 +1497,20 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
{activeSection === 'seo' && (
|
||||
<div className="settings-panel">
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconSearch size={18} /> Search Engine Optimization (SEO)</h3>
|
||||
<p className="settings-section-sub">Customize search engine metadata, OpenGraph tags, and indexing rules.</p>
|
||||
<h3>
|
||||
<Icons.IconSearch size={18} /> Search Engine Optimization (SEO)
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Customize search engine metadata, OpenGraph tags, and indexing rules.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Live Google Search Result Snippet Card */}
|
||||
<div className="google-snippet-preview">
|
||||
<div className="google-snippet-header">
|
||||
<span><Icons.IconGlobe size={14} /> Google Search Result Preview</span>
|
||||
<span>
|
||||
<Icons.IconGlobe size={14} /> Google Search Result Preview
|
||||
</span>
|
||||
</div>
|
||||
<div className="google-snippet-card">
|
||||
<div className="google-snippet-url">
|
||||
@@ -1354,7 +1520,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
{settings.seo?.title || settings.title || 'My Photography Portfolio'}
|
||||
</div>
|
||||
<div className="google-snippet-desc">
|
||||
{settings.seo?.description || settings.subtitle || 'A curated selection of photography work.'}
|
||||
{settings.seo?.description ||
|
||||
settings.subtitle ||
|
||||
'A curated selection of photography work.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1376,7 +1544,8 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
placeholder={`%s | ${settings.seo?.title || settings.title || 'My Portfolio'}`}
|
||||
/>
|
||||
<p style={{ fontSize: '0.8rem', opacity: 0.7, marginTop: '4px' }}>
|
||||
Template for subpages & albums. Use <code>%s</code> as placeholder for the page title.
|
||||
Template for subpages & albums. Use <code>%s</code> as placeholder for the
|
||||
page title.
|
||||
<br />
|
||||
<strong>Preview:</strong>{' '}
|
||||
{(
|
||||
@@ -1399,8 +1568,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<div className="settings-section-divider" />
|
||||
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconGlobe size={18} /> Search Crawler Directives</h3>
|
||||
<p className="settings-section-sub">Control how Googlebot and other web crawlers index your site.</p>
|
||||
<h3>
|
||||
<Icons.IconGlobe size={18} /> Search Crawler Directives
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Control how Googlebot and other web crawlers index your site.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-toggle-cards-grid">
|
||||
@@ -1410,8 +1583,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
onClick={() => update('seo.noIndex', !settings.seo?.noIndex)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title"><Icons.IconBan size={16} /> noindex (Hide from Google)</span>
|
||||
<span className="toggle-card-desc">Instructs search engines NOT to index this site in search results</span>
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconBan size={16} /> noindex (Hide from Google)
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Instructs search engines NOT to index this site in search results
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.seo?.noIndex === true ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
@@ -1424,8 +1601,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
onClick={() => update('seo.noFollow', !settings.seo?.noFollow)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title"><Icons.IconLink size={16} /> nofollow (Block Link Following)</span>
|
||||
<span className="toggle-card-desc">Instructs search engine crawlers not to follow outgoing links</span>
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconLink size={16} /> nofollow (Block Link Following)
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Instructs search engine crawlers not to follow outgoing links
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.seo?.noFollow === true ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
@@ -1438,21 +1619,33 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
{activeSection === 'security' && (
|
||||
<div className="settings-panel">
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconShieldCheck size={18} /> Asset Protection & Watermark</h3>
|
||||
<p className="settings-section-sub">Configure image protection rules, right-click prevention, and watermark overlay.</p>
|
||||
<h3>
|
||||
<Icons.IconShieldCheck size={18} /> Asset Protection & Watermark
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Configure image protection rules, right-click prevention, and watermark overlay.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-toggle-cards-grid">
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-toggle-card ${settings.protection?.disableRightClick === true ? 'active' : ''}`}
|
||||
onClick={() => update('protection.disableRightClick', !settings.protection?.disableRightClick)}
|
||||
onClick={() =>
|
||||
update('protection.disableRightClick', !settings.protection?.disableRightClick)
|
||||
}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title"><Icons.IconLock size={16} /> Disable Right-Click Menu</span>
|
||||
<span className="toggle-card-desc">Prevents context menu on portfolio images to hinder unauthorized downloads</span>
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconLock size={16} /> Disable Right-Click Menu
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Prevents context menu on portfolio images to hinder unauthorized downloads
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.protection?.disableRightClick === true ? 'on' : ''}`}>
|
||||
<div
|
||||
className={`switch-toggle ${settings.protection?.disableRightClick === true ? 'on' : ''}`}
|
||||
>
|
||||
<span className="switch-slider" />
|
||||
</div>
|
||||
</button>
|
||||
@@ -1460,13 +1653,21 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-toggle-card ${settings.protection?.disableImageDrag === true ? 'active' : ''}`}
|
||||
onClick={() => update('protection.disableImageDrag', !settings.protection?.disableImageDrag)}
|
||||
onClick={() =>
|
||||
update('protection.disableImageDrag', !settings.protection?.disableImageDrag)
|
||||
}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title"><Icons.IconBan size={16} /> Disable Image Dragging</span>
|
||||
<span className="toggle-card-desc">Prevents visitors from dragging images off the portfolio page</span>
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconBan size={16} /> Disable Image Dragging
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Prevents visitors from dragging images off the portfolio page
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.protection?.disableImageDrag === true ? 'on' : ''}`}>
|
||||
<div
|
||||
className={`switch-toggle ${settings.protection?.disableImageDrag === true ? 'on' : ''}`}
|
||||
>
|
||||
<span className="switch-slider" />
|
||||
</div>
|
||||
</button>
|
||||
@@ -1475,8 +1676,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<div className="settings-section-divider" />
|
||||
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconSparkles size={18} /> Dynamic Watermark Overlay</h3>
|
||||
<p className="settings-section-sub">Overlay copyright branding text on Lightbox images.</p>
|
||||
<h3>
|
||||
<Icons.IconSparkles size={18} /> Dynamic Watermark Overlay
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Overlay copyright branding text on Lightbox images.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-toggle-cards-grid" style={{ marginBottom: '1.25rem' }}>
|
||||
@@ -1486,10 +1691,16 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
onClick={() => update('watermark.enabled', !settings.watermark?.enabled)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title"><Icons.IconFrame size={16} /> Enable Watermark</span>
|
||||
<span className="toggle-card-desc">Overlay copyright text on portfolio image views</span>
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconFrame size={16} /> Enable Watermark
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Overlay copyright text on portfolio image views
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.watermark?.enabled === true ? 'on' : ''}`}>
|
||||
<div
|
||||
className={`switch-toggle ${settings.watermark?.enabled === true ? 'on' : ''}`}
|
||||
>
|
||||
<span className="switch-slider" />
|
||||
</div>
|
||||
</button>
|
||||
@@ -1519,7 +1730,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Opacity ({Math.round((settings.watermark?.opacity ?? 0.3) * 100)}%)</label>
|
||||
<label>
|
||||
Opacity ({Math.round((settings.watermark?.opacity ?? 0.3) * 100)}%)
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
|
||||
@@ -83,7 +83,11 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
// Security check: prevent directory traversal
|
||||
if (backupFilename.includes('..') || backupFilename.includes('/') || backupFilename.includes('\\')) {
|
||||
if (
|
||||
backupFilename.includes('..') ||
|
||||
backupFilename.includes('/') ||
|
||||
backupFilename.includes('\\')
|
||||
) {
|
||||
return NextResponse.json({ error: 'Invalid backup filename' }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import {
|
||||
isValidSlug,
|
||||
sanitizeSlug,
|
||||
parseJournalMarkdown,
|
||||
} from '@/lib/journal';
|
||||
import { isValidSlug, sanitizeSlug, parseJournalMarkdown } from '@/lib/journal';
|
||||
import {
|
||||
readJournalEntry,
|
||||
writeJournalEntry,
|
||||
@@ -65,9 +61,7 @@ export async function PUT(request: Request, context: RouteContext) {
|
||||
parseJournalMarkdown(rawMarkdown);
|
||||
|
||||
const targetSlug =
|
||||
typeof body.newSlug === 'string' && body.newSlug
|
||||
? sanitizeSlug(body.newSlug)
|
||||
: slug;
|
||||
typeof body.newSlug === 'string' && body.newSlug ? sanitizeSlug(body.newSlug) : slug;
|
||||
|
||||
if (!isValidSlug(targetSlug)) {
|
||||
return NextResponse.json({ error: 'Invalid target slug' }, { status: 400 });
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import {
|
||||
sanitizeSlug,
|
||||
isValidSlug,
|
||||
type JournalFrontmatter,
|
||||
} from '@/lib/journal';
|
||||
import { sanitizeSlug, isValidSlug, type JournalFrontmatter } from '@/lib/journal';
|
||||
import {
|
||||
listJournalEntries,
|
||||
writeJournalEntry,
|
||||
@@ -47,7 +43,10 @@ export async function POST(request: Request) {
|
||||
|
||||
const existing = await readJournalEntry(slug);
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: 'A journal entry with this slug already exists' }, { status: 409 });
|
||||
return NextResponse.json(
|
||||
{ error: 'A journal entry with this slug already exists' },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const frontmatter: JournalFrontmatter = {
|
||||
@@ -56,7 +55,9 @@ export async function POST(request: Request) {
|
||||
draft: true,
|
||||
...(typeof body.subtitle === 'string' && body.subtitle ? { subtitle: body.subtitle } : {}),
|
||||
...(typeof body.author === 'string' && body.author ? { author: body.author } : {}),
|
||||
...(typeof body.coverAssetId === 'string' && body.coverAssetId ? { coverAssetId: body.coverAssetId } : {}),
|
||||
...(typeof body.coverAssetId === 'string' && body.coverAssetId
|
||||
? { coverAssetId: body.coverAssetId }
|
||||
: {}),
|
||||
};
|
||||
|
||||
let initialMarkdown = body.content;
|
||||
|
||||
@@ -40,7 +40,10 @@ export async function PUT(request: Request) {
|
||||
invalidateConfigCache();
|
||||
immich.invalidateAll();
|
||||
revalidatePath('/', 'layout');
|
||||
return NextResponse.json({ success: true, message: 'Saved successfully. Backup of previous version created.' });
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Saved successfully. Backup of previous version created.',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Admin] Failed to write settings.yaml:', err);
|
||||
return NextResponse.json({ error: 'Failed to save settings' }, { status: 500 });
|
||||
|
||||
@@ -69,7 +69,7 @@ export async function POST(req: Request) {
|
||||
|
||||
await saveAnalytics(data);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed to record tracking' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,8 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (!isProtected(slug, type)) {
|
||||
const typeLabel = type === 'journal' ? 'Journal entry' : type === 'subpage' ? 'Subpage' : 'Album';
|
||||
const typeLabel =
|
||||
type === 'journal' ? 'Journal entry' : type === 'subpage' ? 'Subpage' : 'Album';
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `${typeLabel} is not password-protected`,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { completeInstall, isInstalled, normalizeApiBase, validateSetupToken } from '@/lib/install';
|
||||
import { invalidateConfigCache } from '@/lib/config';
|
||||
import { DEFAULT_PRESET } from '@/lib/config/theme';
|
||||
import { immich } from '@/lib/immich';
|
||||
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
|
||||
|
||||
@@ -9,8 +10,8 @@ import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit
|
||||
const INSTALL_RPM = 10;
|
||||
|
||||
const THEME_PRESETS = [
|
||||
'studio',
|
||||
'studio-modern',
|
||||
'studio',
|
||||
'minimal',
|
||||
'editorial',
|
||||
'classic',
|
||||
@@ -58,7 +59,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Invalid Immich URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
const theme = THEME_PRESETS.includes(body.theme) ? body.theme : 'studio';
|
||||
const theme = THEME_PRESETS.includes(body.theme) ? body.theme : DEFAULT_PRESET;
|
||||
|
||||
// Album selection is optional: an empty gallery is a valid install.
|
||||
const rawAlbums: unknown[] = Array.isArray(body.albums) ? (body.albums as unknown[]) : [];
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useMemo, useState } from 'react';
|
||||
// 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 '../admin/admin.css';
|
||||
import './install.css';
|
||||
|
||||
@@ -20,8 +23,12 @@ interface Props {
|
||||
const STEPS = ['Connect', 'Albums', 'Site', 'Finish'] as const;
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{
|
||||
value: 'studio-modern',
|
||||
label: 'Studio Modern',
|
||||
description: 'Precision grotesque, hairline rules',
|
||||
},
|
||||
{ value: 'studio', label: 'Studio', description: 'Classic portfolio, red accent' },
|
||||
{ value: 'studio-modern', label: 'Studio Modern', description: 'Clean with soft corners' },
|
||||
{ value: 'minimal', label: 'Minimal', description: 'Bare-bones, monochrome' },
|
||||
{ value: 'editorial', label: 'Editorial', description: 'Magazine-style serif' },
|
||||
{ value: 'classic', label: 'Classic', description: 'Warm gold, traditional' },
|
||||
@@ -47,7 +54,7 @@ export function InstallWizard({ initialApiUrl, setupToken }: Props) {
|
||||
// Step 3 — site settings
|
||||
const [siteTitle, setSiteTitle] = useState('');
|
||||
const [siteSubtitle, setSiteSubtitle] = useState('');
|
||||
const [theme, setTheme] = useState('studio');
|
||||
const [theme, setTheme] = useState(DEFAULT_PRESET);
|
||||
const [adminPassword, setAdminPassword] = useState('');
|
||||
const [adminConfirm, setAdminConfirm] = useState('');
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
|
||||
@@ -19,7 +19,6 @@ import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
|
||||
import type { ImmichAsset } from '@/lib/immich';
|
||||
import PasswordGate from '@/components/PasswordGate';
|
||||
import { BackLink } from '@/components/BackLink';
|
||||
import { verifyScrypt, isScryptHash } from '@/lib/password';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -13,7 +13,13 @@ export default function AssetProtection({ disableRightClick, disableImageDrag }:
|
||||
|
||||
function handleContextMenu(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (disableRightClick && (target.tagName === 'IMG' || target.closest('img') || target.classList.contains('photo-tile') || target.classList.contains('lightbox-img'))) {
|
||||
if (
|
||||
disableRightClick &&
|
||||
(target.tagName === 'IMG' ||
|
||||
target.closest('img') ||
|
||||
target.classList.contains('photo-tile') ||
|
||||
target.classList.contains('lightbox-img'))
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -328,9 +328,7 @@ export function Lightbox({
|
||||
</div>
|
||||
) : exifData ? (
|
||||
<>
|
||||
{exifData.description && (
|
||||
<p className={styles.exifCaption}>{exifData.description}</p>
|
||||
)}
|
||||
{exifData.description && <p className={styles.exifCaption}>{exifData.description}</p>}
|
||||
{exifData.model && (
|
||||
<div className={styles.exifRow}>
|
||||
<span className={styles.exifLabel}>Camera</span>
|
||||
|
||||
@@ -36,7 +36,9 @@ export function ProofingProvider({
|
||||
|
||||
const storageKey = `folio_fav_${albumName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
|
||||
|
||||
// Hydrate from localStorage and URL query params on mount
|
||||
// Hydrate from localStorage and URL query params on mount. This has to stay in
|
||||
// an effect: both sources are browser-only, so reading them during render would
|
||||
// break SSR and produce a hydration mismatch.
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
@@ -60,6 +62,9 @@ export function ProofingProvider({
|
||||
});
|
||||
}
|
||||
|
||||
// Mount-time hydration from an external store, not a render cascade:
|
||||
// the state lands in the same commit and never re-triggers this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setFavorites(initialSet);
|
||||
} catch (e) {
|
||||
console.error('Failed to load favorites', e);
|
||||
|
||||
@@ -12,7 +12,6 @@ export function ProofingModal() {
|
||||
|
||||
const {
|
||||
favorites,
|
||||
isModalOpen,
|
||||
setIsModalOpen,
|
||||
getProofingUrl,
|
||||
getFormattedList,
|
||||
|
||||
@@ -92,11 +92,20 @@ subpages:
|
||||
- "album-uuid-osaka-2"
|
||||
|
||||
# ── Photo Essay / Storytelling Subpage Example ───────────────────
|
||||
# Render editorial Markdown essays with fullbleed & side-by-side images
|
||||
# Render editorial Markdown essays with fullbleed & side-by-side images.
|
||||
#
|
||||
# essayFile is a SLUG, not a path: "sample-story" resolves to
|
||||
# content/journal/sample-story.md (or content/essays/sample-story.md).
|
||||
# Slashes and dots are rejected. Copy the shipped template to get started:
|
||||
# cp content/journal/sample-story.md.example content/journal/sample-story.md
|
||||
#
|
||||
# Leave essayFile out and keep `layout: essay`, and the essay is generated
|
||||
# from the albums below — one heading per album, followed by its photos.
|
||||
# See docs/journal.md for the Markdown block syntax.
|
||||
- name: Highlands
|
||||
title: "Icelandic Highlands"
|
||||
subtitle: "A Photo Essay"
|
||||
essayFile: "content/essays/sample-story.md"
|
||||
essayFile: "sample-story"
|
||||
grid:
|
||||
layout: essay
|
||||
albums:
|
||||
|
||||
@@ -33,9 +33,10 @@ scrollToTop: true
|
||||
# allowMailto: true
|
||||
|
||||
# ── Theme configuration ──────────────────────────────────────────
|
||||
# Presets: studio, studio-modern, minimal, editorial, classic, noir, monograph
|
||||
# Presets: studio-modern (default), studio, minimal, editorial, classic,
|
||||
# noir, monograph
|
||||
theme:
|
||||
preset: "studio"
|
||||
preset: "studio-modern"
|
||||
# accent: "#e60012"
|
||||
# photoFrame: "passepartout" # none, passepartout, shadow
|
||||
# grain: true
|
||||
|
||||
30
docs/README.md
Normal file
30
docs/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Documentation
|
||||
|
||||
Start at the [project README](../README.md) for the pitch, the feature list and
|
||||
a quick start.
|
||||
|
||||
## Guides
|
||||
|
||||
| Guide | Covers |
|
||||
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| **[Gallery Configuration](gallery-config.md)** | `gallery.yaml` and `settings.yaml` — albums, subpages, sections, photo order, passwords, grid, SEO, proofing, watermark |
|
||||
| **[Theming](theming.md)** | The seven presets, custom colours and fonts, hero styles, grid layouts, with screenshots |
|
||||
| **[Journal & Photo Essays](journal.md)** | Long-form storytelling — file format, block syntax, drafts, per-entry passwords, Journal Studio |
|
||||
| **[Admin Panel](admin-panel.md)** | The visual editor at `/admin` — pages, journal, settings, analytics, backups, security |
|
||||
| **[Deployment](deployment.md)** | Setup wizard internals, Docker, health check, reverse proxy, behaviour during an Immich outage |
|
||||
|
||||
Contributing? See [CONTRIBUTING.md](../CONTRIBUTING.md). Security policy:
|
||||
[SECURITY.md](../SECURITY.md). Release history: [CHANGELOG.md](../CHANGELOG.md).
|
||||
|
||||
## Design notes
|
||||
|
||||
Working documents kept for context. They record thinking at a point in time and
|
||||
are **not maintained** — where they disagree with the guides above, the guides
|
||||
are correct.
|
||||
|
||||
- [brainstorm.md](brainstorm.md), [brainstorm-2026-08-05.md](brainstorm-2026-08-05.md) — feature brainstorming
|
||||
- [deep-dive-2026-08-05.md](deep-dive-2026-08-05.md) — architecture review
|
||||
- [ideas.md](ideas.md) — unsorted ideas backlog
|
||||
- [journal-studio-design.md](journal-studio-design.md) — design notes for the journal editor
|
||||
|
||||
`screenshots/` holds the images used by the guides and the README.
|
||||
@@ -1,13 +1,16 @@
|
||||
# Admin Panel
|
||||
|
||||
Immich Folio includes a built-in visual admin panel at `/admin` that lets you manage your gallery structure and site settings without editing YAML files by hand.
|
||||
Immich Folio includes a built-in visual admin panel at `/admin` that lets you manage your gallery structure, journal entries and site settings without editing YAML files by hand.
|
||||
|
||||
**Contents:**
|
||||
|
||||
- [Enabling the Admin Panel](#enabling-the-admin-panel)
|
||||
- [Page Builder](#page-builder)
|
||||
- [Settings Editor](#settings-editor)
|
||||
- [Album Picker](#album-picker)
|
||||
- [Layout](#layout)
|
||||
- [Pages](#pages)
|
||||
- [Journal](#journal)
|
||||
- [Settings](#settings)
|
||||
- [Analytics](#analytics)
|
||||
- [Album & Asset Pickers](#album--asset-pickers)
|
||||
- [Security](#security)
|
||||
- [Backups](#backups)
|
||||
- [Docker Usage](#docker-usage)
|
||||
@@ -22,97 +25,147 @@ ADMIN_PASSWORD=your-secure-admin-password
|
||||
|
||||
Then navigate to `http://your-site/admin`. The panel is completely disabled if no password is set — it won't even render the login page.
|
||||
|
||||
The [setup wizard](../README.md#first-run-setup) can set the password instead, storing it as an scrypt hash in `content/install.json`. An `ADMIN_PASSWORD` environment variable takes precedence if both are present.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The admin password is separate from any album or subpage passwords. It controls access to the entire gallery configuration.
|
||||
|
||||
## Page Builder
|
||||
## Layout
|
||||
|
||||
The **Pages** tab provides a visual tree view of your gallery structure:
|
||||
Each area has its own URL, so a section can be bookmarked and the browser's back button works as expected:
|
||||
|
||||
| Tab | URL | Writes to |
|
||||
| ------------- | ------------------ | ------------------------------------------- |
|
||||
| **Pages** | `/admin/pages` | `content/gallery.yaml` |
|
||||
| **Journal** | `/admin/journal` | `content/journal/<slug>.md` |
|
||||
| **Settings** | `/admin/settings` | `content/settings.yaml`, `content/about.md` |
|
||||
| **Analytics** | `/admin/analytics` | nothing — read-only |
|
||||
|
||||
Unsaved changes raise a save bar pinned to the bottom of the viewport, so the Save button is reachable without scrolling back up. Saving applies immediately — no server restart.
|
||||
|
||||
## Pages
|
||||
|
||||
A visual tree of your gallery structure. Each item opens a slide-over drawer with its settings.
|
||||
|
||||
### Hero Images
|
||||
|
||||
Add Immich asset UUIDs for the homepage hero carousel. Supports single or multiple images that crossfade automatically.
|
||||
Immich asset UUIDs for the homepage hero. One image, or several that crossfade as a carousel. Pick them from your library with the [asset picker](#album--asset-pickers) instead of typing UUIDs.
|
||||
|
||||
### Standalone Albums
|
||||
|
||||
Albums displayed directly on the homepage. Click **+ Add Album** to open the Album Picker and select from your Immich library.
|
||||
Albums displayed directly on the homepage. **+ Add Album** opens the [Album Picker](#album--asset-pickers).
|
||||
|
||||
Each album card supports:
|
||||
- **Title override** — display a custom name instead of the Immich album name
|
||||
- **Description** — shown below the album title on the subpage
|
||||
- **Password** — protect individual albums with a password
|
||||
Each album supports:
|
||||
|
||||
- **Title override** — a display name instead of the Immich album name
|
||||
- **Description** — shown below the album title
|
||||
- **Password** — protect a single album (written as an scrypt hash)
|
||||
- **Cover image** — override the album cover with any asset
|
||||
- **Photo order** — Immich order, newest, oldest, filename, or **Manual** with a drag & drop editor
|
||||
- **Grid override** _(experimental)_ — a different grid layout for this album alone (the panel exposes the layout; columns, gap and aspect ratio can be set in `gallery.yaml`)
|
||||
- **Cover focal point** _(experimental)_ — which part of the cover survives the crop
|
||||
|
||||
### Subpages
|
||||
|
||||
Group albums under custom URL paths (e.g., `/japan`, `/wedding-smith`).
|
||||
Group albums under custom URL paths (e.g. `/japan`, `/wedding-smith`).
|
||||
|
||||
Each subpage has:
|
||||
- **Name** — used for navigation and URL generation (auto-slugified)
|
||||
- **Title** — optional display title (defaults to name)
|
||||
- **Subtitle** — optional text shown below the page heading
|
||||
- **Password** — optional protection for the entire subpage
|
||||
- **Name** — navigation label; the URL slug is derived from it
|
||||
- **Title / Subtitle** — page heading and subline
|
||||
- **Password** — protect the whole subpage
|
||||
- **Grid override** — layout settings for this page
|
||||
- **Enabled** — take the page offline without deleting it
|
||||
- **Hidden** _(experimental)_ — unlisted: reachable by direct link, absent from the navigation
|
||||
- **Essay** — turn the page into a photo essay, with a block editor for the text
|
||||
|
||||
A live preview shows the page as visitors will see it.
|
||||
|
||||
### Sections
|
||||
|
||||
Within a subpage, you can organize albums into named sections. This generates a table of contents with anchor links. Add sections with **+ Add Section** and fill each with albums from the picker.
|
||||
Within a subpage, albums can be grouped into named sections, which renders a table of contents with anchor links.
|
||||
|
||||
### Reordering
|
||||
|
||||
Use the **↑/↓** buttons on subpages to change their display order. The order in the admin panel matches the order on your site.
|
||||
Subpages, albums and sections are reordered in place; the order in the panel is the order on the site.
|
||||
|
||||
## Settings Editor
|
||||
## Journal
|
||||
|
||||
The **Settings** tab lets you configure global site behavior:
|
||||
The **Journal Studio** — a split-screen editor with the blocks on the left and a live preview of the real page on the right, separated by a divider you can drag with mouse, keyboard or touch.
|
||||
|
||||
| Section | Controls |
|
||||
| --------- | ---------------------------------------------------------------- |
|
||||
| General | Site title, subtitle, language, EXIF on hover, map, transitions, scroll-to-top |
|
||||
| Theme | Preset, accent color, photo frame, hero style, grain, header dot |
|
||||
| Grid | Layout mode, columns, gap, aspect ratio |
|
||||
| Footer | Name, Instagram, email, website |
|
||||
| Legal | Impressum toggle and all legal fields |
|
||||
| SEO | Meta title, description, noindex, nofollow |
|
||||
Blocks are added from a menu (heading, text, quote, photo, photo pair), reordered, and deleted; each carries a type chip so long entries stay scannable. Photos are inserted through the asset picker, which fills in the Immich asset UUID for you. The frontmatter — cover image, title, subtitle, author, date, draft flag, password — is edited as form fields.
|
||||
|
||||
Changes are applied immediately after saving — no server restart required.
|
||||
Saving writes plain Markdown to `content/journal/<slug>.md`, which can equally be edited by hand.
|
||||
|
||||
## Album Picker
|
||||
→ **[Journal & Photo Essays](journal.md)** for the file format.
|
||||
|
||||
When adding albums, a modal overlay shows **all shared albums** from your Immich instance (not just the ones already configured). Features:
|
||||
## Settings
|
||||
|
||||
Eight sections, each at its own URL (`/admin/settings/theme`, …):
|
||||
|
||||
| Section | Controls |
|
||||
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **General** | Site title, subtitle, language, favicon upload, and the feature toggles (EXIF on hover, map, transitions, scroll-to-top, analytics) |
|
||||
| **Theme** | Preset, colour mode, accent, photo frame, hero style, grain, header dot |
|
||||
| **Grid** | Layout algorithm, columns, gap, aspect ratio |
|
||||
| **Footer** | Name, Instagram, email, website, and the header navigation links |
|
||||
| **Legal** | Impressum toggle and all legal fields |
|
||||
| **SEO** | Meta title, subpage title template, description, noindex, nofollow |
|
||||
| **Security & Protection** | Right-click and image-drag deterrents, lightbox watermark (text, position, opacity) |
|
||||
| **About** | Portrait asset, name, location, gear list, and the biography — written to `content/about.md` |
|
||||
|
||||
Theme presets, grid layouts, photo frames, hero styles and the Google search snippet are picked from **preview cards** rather than text fields, so the effect of a choice is visible before saving. A light/dark toggle previews both variants of a preset.
|
||||
|
||||
### Favicon
|
||||
|
||||
**General → Favicon** accepts an SVG, PNG, ICO or JPEG. It is stored in the writable `content/` volume and served through `/api/favicon` under a policy that stops an uploaded SVG from executing script. **Reset** restores the bundled default.
|
||||
|
||||
## Analytics
|
||||
|
||||
View counts per page and album, read from `content/analytics.json`. No cookies, no third party, nothing leaving your server. Switch the collection off entirely in **Settings → General**; the tracking endpoint then refuses to record.
|
||||
|
||||
## Album & Asset Pickers
|
||||
|
||||
**Album Picker** — a modal listing **all shared albums** from your Immich instance, not just the configured ones:
|
||||
|
||||
- **Search** by album name or UUID
|
||||
- **Photo count** shown for each album
|
||||
- **Photo count** per album
|
||||
- **"In use" badge** for albums already assigned somewhere
|
||||
- Albums are sorted: configured first, then alphabetically
|
||||
- Sorted configured-first, then alphabetically
|
||||
|
||||
**Asset Picker** — browses your full Immich library for hero images, album covers, journal photos and the about-page portrait. It uses `POST /search/metadata`, which is covered by the `asset.read` permission your API key already needs.
|
||||
|
||||
## Security
|
||||
|
||||
| Aspect | Implementation |
|
||||
| -------------- | ----------------------------------------------------------- |
|
||||
| Authentication | HMAC-signed session token in an `HttpOnly` cookie |
|
||||
| Session expiry | 24 hours (automatic logout) |
|
||||
| Password check | Constant-time comparison to prevent timing attacks |
|
||||
| Cookie flags | `HttpOnly`, `Secure` (in production), `SameSite=Strict` |
|
||||
| Rate limiting | Admin endpoints share the global rate limiter |
|
||||
| Robots | `/admin` is marked `noindex, nofollow` in metadata |
|
||||
| Aspect | Implementation |
|
||||
| -------------- | --------------------------------------------------------------------- |
|
||||
| Authentication | HMAC-signed session token in an `HttpOnly` cookie |
|
||||
| Signing key | Derived from `AUTH_SECRET` with scrypt |
|
||||
| Session expiry | 24 hours (automatic logout) |
|
||||
| Password check | Constant-time comparison to prevent timing attacks |
|
||||
| Cookie flags | `HttpOnly`, `Secure` (in production), `SameSite=Strict` |
|
||||
| Rate limiting | `POST /api/admin/auth` is capped at 5 attempts per minute per IP |
|
||||
| Authorization | Every `/api/admin/*` route re-checks the session, including analytics |
|
||||
| Robots | `/admin` is marked `noindex, nofollow` in metadata |
|
||||
|
||||
> [!NOTE]
|
||||
> The admin panel uses its own session management, completely separate from album password protection. Admin sessions use `AUTH_SECRET` for token signing.
|
||||
> The admin panel uses its own session management, completely separate from album password protection. Rotating `AUTH_SECRET` invalidates every admin session along with every unlocked gallery.
|
||||
|
||||
## Backups
|
||||
|
||||
Every time you save changes, the previous YAML file is automatically backed up to `content/.backups/`:
|
||||
Every time you save, the previous file is backed up automatically:
|
||||
|
||||
```
|
||||
content/.backups/
|
||||
├── gallery.yaml.2026-05-29T14-30-00-000Z.bak
|
||||
├── gallery.yaml.2026-05-29T15-45-00-000Z.bak
|
||||
├── settings.yaml.2026-05-29T14-30-00-000Z.bak
|
||||
└── ...
|
||||
content/journal/.backups/
|
||||
└── my-story.md.2026-05-29T14-30-00-000Z.bak
|
||||
```
|
||||
|
||||
- Up to **10 backups** per file are retained (oldest are pruned)
|
||||
- The **Backup Manager** lists them and restores any one with a single click
|
||||
- Before a restore, a `*.pre-restore.bak` snapshot is created
|
||||
- All writes are **atomic** (write to temp file, then rename) — no risk of corrupted YAML
|
||||
- All writes are **atomic** (write to temp file, then rename) — no risk of a half-written YAML
|
||||
|
||||
## Docker Usage
|
||||
|
||||
@@ -125,17 +178,24 @@ services:
|
||||
environment:
|
||||
- ADMIN_PASSWORD=your-secure-password
|
||||
volumes:
|
||||
- ./content:/app/content # Must be writable for admin panel
|
||||
- ./content:/app/content # Must be writable for admin panel
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> When using the admin panel, the content volume must be **read-write** (not `:ro`). Remove the `:ro` flag if you had it set previously.
|
||||
> The content volume must be **read-write** (not `:ro`). The admin panel, the journal, the favicon upload, the setup wizard and the backup rotation all write into it.
|
||||
|
||||
### Reload Button
|
||||
|
||||
The admin header includes a **↻ Reload** button that:
|
||||
|
||||
1. Invalidates the in-memory config cache
|
||||
2. Clears the Immich album/asset cache
|
||||
3. Forces the next request to re-read all YAML files
|
||||
|
||||
This is useful after making external changes to config files or when Immich data has changed.
|
||||
|
||||
### Diagnostics
|
||||
|
||||
The panel reports the health of the installation: whether Immich answers, whether `gallery.yaml` and `settings.yaml` parse, the number of cached entries, and the backup count with the timestamp of the most recent one. `settings.yaml` is optional, so its absence is not a fault — only a file that exists and cannot be parsed counts as invalid. A check that could not run (an expired session, for example) is reported as such rather than as an outage.
|
||||
|
||||
Separately, while you are logged in, the public site shows a diagnostic banner on any album Immich returns empty — the case that otherwise looks like a broken page to you and to nobody else.
|
||||
|
||||
@@ -51,7 +51,7 @@ wahrgenommenen Reifegrad.
|
||||
Default 300s). Läuft ein Eintrag ab und Immich antwortet nicht, gibt es keinen
|
||||
Fallback — die Seite ist tot.
|
||||
|
||||
**Idee:** Abgelaufene Einträge nicht wegwerfen, sondern als *stale* markieren.
|
||||
**Idee:** Abgelaufene Einträge nicht wegwerfen, sondern als _stale_ markieren.
|
||||
Bei Upstream-Fehler den veralteten Eintrag trotzdem ausliefern, im Hintergrund
|
||||
erneut versuchen, und (optional konfigurierbar) einen dezenten Hinweis
|
||||
einblenden. Das öffentliche Portfolio überlebt damit ein Immich-Update,
|
||||
|
||||
@@ -6,23 +6,23 @@ _Stand: 2026-05-29 — generiert aus Projekt-Review + ideas.md_
|
||||
|
||||
## ✅ Bereits implementiert
|
||||
|
||||
| Feature | Datum | Notizen |
|
||||
|---|---|---|
|
||||
| **Album-Beschreibungen** | 2026-05-29 | `description:` in `gallery.yaml` (pro Album), eigenes `<p>` unter dem Titel in `AlbumDetailView` |
|
||||
| Feature | Datum | Notizen |
|
||||
| ------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| **Album-Beschreibungen** | 2026-05-29 | `description:` in `gallery.yaml` (pro Album), eigenes `<p>` unter dem Titel in `AlbumDetailView` |
|
||||
| **Webhook Cache-Invalidierung** | 2026-05-29 | `POST /api/webhook`, HMAC-SHA256, `WEBHOOK_SECRET` in `.env`, gezielte oder vollständige Cache-Invalidierung |
|
||||
| **Video-Support** | 2026-05-29 | Proxy `GET /api/video/[token]` mit Range-Support, Play-Button im Grid, `<video controls>` in Lightbox |
|
||||
| **Video-Support** | 2026-05-29 | Proxy `GET /api/video/[token]` mit Range-Support, Play-Button im Grid, `<video controls>` in Lightbox |
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Quick Wins (offen)
|
||||
|
||||
| Idee | Effort | Warum interessant |
|
||||
|---|---|---|
|
||||
| **Sequentielle Album-Navigation** | XS | „← Vorheriges / Nächstes Album →" am Ende einer Detailseite |
|
||||
| **Alt-Text aus Immich-Description** | XS | Immich-Assets haben ein `description`-Feld → als `alt`-Text → bessere Accessibility & SEO |
|
||||
| **RSS/Atom Feed** | S | `/feed.xml` mit neuen Alben — Follower können „neue Arbeit" abonnieren |
|
||||
| **Globaler Passwortschutz** | S | Eine einzige `site_password` für die gesamte Site (jetzt: nur per Subpage/Album) |
|
||||
| **Privacy-Analytics-Integration** | S | Plausible / Umami / GoatCounter via Script-Tag in `settings.yaml` konfigurieren (kein Tracking-Framework nötig) |
|
||||
| Idee | Effort | Warum interessant |
|
||||
| ----------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------- |
|
||||
| **Sequentielle Album-Navigation** | XS | „← Vorheriges / Nächstes Album →" am Ende einer Detailseite |
|
||||
| **Alt-Text aus Immich-Description** | XS | Immich-Assets haben ein `description`-Feld → als `alt`-Text → bessere Accessibility & SEO |
|
||||
| **RSS/Atom Feed** | S | `/feed.xml` mit neuen Alben — Follower können „neue Arbeit" abonnieren |
|
||||
| **Globaler Passwortschutz** | S | Eine einzige `site_password` für die gesamte Site (jetzt: nur per Subpage/Album) |
|
||||
| **Privacy-Analytics-Integration** | S | Plausible / Umami / GoatCounter via Script-Tag in `settings.yaml` konfigurieren (kein Tracking-Framework nötig) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -30,12 +30,12 @@ unterschiedliche Antworten brauchen:
|
||||
|
||||
**Modus A — die Konfiguration wirft.** `getConfig()` wirft an mehreren Stellen hart:
|
||||
|
||||
| Stelle | Auslöser |
|
||||
|---|---|
|
||||
| Stelle | Auslöser |
|
||||
| ------------------------- | ------------------------------------------ |
|
||||
| `lib/config/index.ts:136` | `gallery.yaml` ohne Album und ohne Subpage |
|
||||
| `lib/config/index.ts:144` | Subpage ohne `name` |
|
||||
| `lib/config/index.ts:170` | Subpage ohne Alben/Sections |
|
||||
| `lib/secret.ts:20` | `AUTH_SECRET` fehlt in Production |
|
||||
| `lib/config/index.ts:144` | Subpage ohne `name` |
|
||||
| `lib/config/index.ts:170` | Subpage ohne Alben/Sections |
|
||||
| `lib/secret.ts:20` | `AUTH_SECRET` fehlt in Production |
|
||||
|
||||
Nicht in dieser Liste: `validateUuid()` (`lib/config/parser.ts`) wirft **nicht** —
|
||||
es warnt und liefert bewusst eine Dummy-UUID zurück, damit der Build nicht bricht.
|
||||
@@ -85,8 +85,8 @@ Immich-Antwort warten, bevor irgendetwas erscheint.
|
||||
Heute kann die App nicht unterscheiden zwischen „dieses Album existiert nicht"
|
||||
und „ich kann Immich gerade nicht fragen". Beides endet in `notFound()`.
|
||||
|
||||
Das ist nicht nur kosmetisch: Ein 404 sagt Suchmaschinen *„diese URL löschen"*,
|
||||
ein 503 sagt *„später nochmal versuchen"*. Ein Immich-Update während eines
|
||||
Das ist nicht nur kosmetisch: Ein 404 sagt Suchmaschinen _„diese URL löschen"_,
|
||||
ein 503 sagt _„später nochmal versuchen"_. Ein Immich-Update während eines
|
||||
Google-Crawls kann so die Sichtbarkeit des Portfolios kosten.
|
||||
|
||||
**Vorschlag:** Bevor `app/[...path]/page.tsx` `notFound()` aufruft, den
|
||||
@@ -108,12 +108,12 @@ erst mit der Statusunterscheidung werden sie ein Feature.
|
||||
### Befund: das Hindernis
|
||||
|
||||
`components/SetupScreen.tsx` erklärt vier Dateikopien und endet mit
|
||||
*„restart your server"*. Ein Assistent müsste stattdessen selbst schreiben — und
|
||||
_„restart your server"_. Ein Assistent müsste stattdessen selbst schreiben — und
|
||||
stößt sofort auf ein Problem:
|
||||
|
||||
```ts
|
||||
// lib/env.ts, letzte Zeile
|
||||
export const env = parseEnv(); // läuft genau einmal beim Modul-Load
|
||||
export const env = parseEnv(); // läuft genau einmal beim Modul-Load
|
||||
```
|
||||
|
||||
`IMMICH_API_URL` und `IMMICH_API_KEY` kommen aus der Umgebung. Ein Browser-Formular
|
||||
@@ -145,14 +145,14 @@ aufgelöst werden (Zeilen 53–54). Ein Eingriff, eine Stelle.
|
||||
|
||||
Bemerkenswert viel — der Assistent ist eher Verdrahtung als Neubau:
|
||||
|
||||
| Baustein | Wo |
|
||||
|---|---|
|
||||
| Baustein | Wo |
|
||||
| --------------------------------------------- | --------------------------------------------------------- |
|
||||
| `/admin` bleibt während des Setups erreichbar | `app/layout.tsx:68–79`, `isAdminPath()`, Commit `8f0bb94` |
|
||||
| Atomares YAML-Schreiben mit Backups | `lib/admin/yaml-service.ts` |
|
||||
| Alle Shared Albums listen (ohne Allowlist) | `GET /api/admin/albums` |
|
||||
| Visuelle Album-/Asset-Auswahl | `AlbumPicker.tsx`, `AssetPicker.tsx` |
|
||||
| Theme-Vorschaubilder | `docs/screenshots/theme-*.png` |
|
||||
| Cache nach dem Speichern invalidieren | `invalidateConfigCache()` |
|
||||
| Atomares YAML-Schreiben mit Backups | `lib/admin/yaml-service.ts` |
|
||||
| Alle Shared Albums listen (ohne Allowlist) | `GET /api/admin/albums` |
|
||||
| Visuelle Album-/Asset-Auswahl | `AlbumPicker.tsx`, `AssetPicker.tsx` |
|
||||
| Theme-Vorschaubilder | `docs/screenshots/theme-*.png` |
|
||||
| Cache nach dem Speichern invalidieren | `invalidateConfigCache()` |
|
||||
|
||||
Dass `/admin` während `needsSetup` offen bleibt, wurde in `#392` bewusst gebaut —
|
||||
mit genau dieser Begründung („it is the tool that completes the setup").
|
||||
@@ -199,12 +199,12 @@ Credentials-Entscheidung (c) sinnvoll anzufangen, weil sie den Rest bestimmt.
|
||||
Meine ursprüngliche Formulierung war ungenau. `request()` wirft nicht, es gibt
|
||||
`null` zurück. Wenn Immich weg ist, passiert Folgendes:
|
||||
|
||||
| Aufruf | Ergebnis | Für den Besucher |
|
||||
|---|---|---|
|
||||
| `getAlbums()` | `[]` | Startseite ohne Alben |
|
||||
| `getAlbum(id)` | `null` → `notFound()` | **HTTP 404 auf echter URL** |
|
||||
| `getAssetInfo()` | `null` | Kein EXIF, keine Map-Punkte |
|
||||
| `/api/image/[id]` | 404 pro Bild | Kaputte Bildplätze |
|
||||
| Aufruf | Ergebnis | Für den Besucher |
|
||||
| ----------------- | --------------------- | --------------------------- |
|
||||
| `getAlbums()` | `[]` | Startseite ohne Alben |
|
||||
| `getAlbum(id)` | `null` → `notFound()` | **HTTP 404 auf echter URL** |
|
||||
| `getAssetInfo()` | `null` | Kein EXIF, keine Map-Punkte |
|
||||
| `/api/image/[id]` | 404 pro Bild | Kaputte Bildplätze |
|
||||
|
||||
Immerhin sauber gelöst: das leere Ergebnis wird **nicht** gecacht
|
||||
(`lib/immich.ts:214`, `if (!all) return []` steht vor `cache.set`) — es brennt
|
||||
@@ -230,7 +230,7 @@ zuerst `cache.getStale()` versuchen. Die Rückgabe sollte mitführen, ob die Dat
|
||||
frisch oder stale sind — sonst kann die Seite es nicht anzeigen.
|
||||
|
||||
**3. Konsequenz ziehen: nicht `notFound()`, wenn der Upstream tot ist.**
|
||||
Gibt es weder frische noch stale Daten *und* ist Immich nachweislich unerreichbar,
|
||||
Gibt es weder frische noch stale Daten _und_ ist Immich nachweislich unerreichbar,
|
||||
dann werfen statt 404. Das ist derselbe Eingriff wie in Thema 1 — die beiden
|
||||
Punkte sind eigentlich ein Feature von zwei Seiten. Den Ping nicht neu bauen:
|
||||
`app/api/health/route.ts` hat bereits einen 10-Sekunden-gecachten, gegen
|
||||
@@ -286,8 +286,8 @@ Der Einstieg ist also billiger, als es aussieht.
|
||||
Ein Album aus `gallery.yaml` entfernen → Route muss 404 liefern.
|
||||
|
||||
**Token-Grenze.** `/api/image/[id]` mit roher UUID statt Token → 400. Mit einem
|
||||
Token, das unter einem *anderen* `AUTH_SECRET` erzeugt wurde → 400. `tokens.ts`
|
||||
ist unit-getestet; hier geht es darum, dass die *Route* das durchsetzt.
|
||||
Token, das unter einem _anderen_ `AUTH_SECRET` erzeugt wurde → 400. `tokens.ts`
|
||||
ist unit-getestet; hier geht es darum, dass die _Route_ das durchsetzt.
|
||||
|
||||
**Die Lücke, die dokumentiert gehört.** Der Bild-Proxy dekodiert den Token und
|
||||
streamt (`app/api/image/[id]/route.ts:78`) — er prüft **nicht**, ob das Asset noch
|
||||
@@ -313,7 +313,7 @@ Secret signierten Token. Das ist genau die Fläche von Commit `37f7f62`.
|
||||
|
||||
Entscheidend ist die Bauform: **tabellengetrieben über die Liste der
|
||||
Admin-Routen**, nicht sieben Einzeltests. Dann schlägt die Suite auch bei einer
|
||||
*künftig hinzugefügten* Route ohne Guard an — sie testet dann die Regel statt der
|
||||
_künftig hinzugefügten_ Route ohne Guard an — sie testet dann die Regel statt der
|
||||
sieben bekannten Fälle. Das ist der Unterschied zwischen einer Suite, die alte
|
||||
Bugs festhält, und einer, die neue verhindert.
|
||||
|
||||
|
||||
128
docs/deployment.md
Normal file
128
docs/deployment.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Deployment
|
||||
|
||||
Operational detail for running Immich Folio: what the setup wizard writes, the
|
||||
Docker options beyond the basic Compose recipe, and what happens when Immich
|
||||
goes away.
|
||||
|
||||
For the quick path — clone, `npm run dev`, open `/install` — see the
|
||||
[README](../README.md#quick-start).
|
||||
|
||||
**Contents:**
|
||||
|
||||
- [What the Setup Wizard Writes](#what-the-setup-wizard-writes)
|
||||
- [Environment Variables Always Win](#environment-variables-always-win)
|
||||
- [Docker Compose](#docker-compose)
|
||||
- [Standalone Docker](#standalone-docker)
|
||||
- [Health Check](#health-check)
|
||||
- [Behaviour when Immich is Unreachable](#behaviour-when-immich-is-unreachable)
|
||||
- [Reverse Proxy](#reverse-proxy)
|
||||
|
||||
## What the Setup Wizard Writes
|
||||
|
||||
The wizard at `/install` writes three files into `content/`:
|
||||
|
||||
| File | Contents |
|
||||
| ----------------------- | -------------------------------------------------------------------- |
|
||||
| `content/gallery.yaml` | The albums you picked |
|
||||
| `content/settings.yaml` | Site title, subtitle, theme — only if it does not exist yet |
|
||||
| `content/install.json` | Immich URL and API key, a generated site secret, admin password hash |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **`content/install.json` holds credentials.** It is written with mode `0600`,
|
||||
> and it is the reason a backup of your `content/` directory is also a backup of
|
||||
> your Immich API key — treat it accordingly. The admin password is stored as an
|
||||
> scrypt hash, not as you typed it; the API key and site secret have to stay
|
||||
> readable to be usable.
|
||||
|
||||
Nothing is written until your credentials have been verified against your Immich
|
||||
server, so a typo cannot leave you with an "installed" site that loads no photos.
|
||||
|
||||
## Environment Variables Always Win
|
||||
|
||||
`IMMICH_API_URL`, `IMMICH_API_KEY`, `AUTH_SECRET` and `ADMIN_PASSWORD` override
|
||||
anything in `install.json`, so any of them can be rotated by setting the variable
|
||||
— no need to touch the file.
|
||||
|
||||
Set all of them up front and the wizard never appears, which is the usual choice
|
||||
for an infrastructure-as-code deployment.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
lightbox:
|
||||
build: .
|
||||
container_name: immich-folio
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '7211:7211'
|
||||
env_file:
|
||||
- .env.local
|
||||
volumes:
|
||||
- ./content:/app/content
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The gallery will be available at `http://localhost:7211`.
|
||||
|
||||
## Standalone Docker
|
||||
|
||||
```bash
|
||||
# Build
|
||||
docker build -t immich-folio .
|
||||
|
||||
# Run
|
||||
docker run -d \
|
||||
--name immich-folio \
|
||||
--restart unless-stopped \
|
||||
-p 7211:7211 \
|
||||
--env-file .env.local \
|
||||
-v ./content:/app/content \
|
||||
immich-folio
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The `content/` volume mount lets you update `gallery.yaml` and `about.md`
|
||||
> without rebuilding the image. It must be **read-write**: the setup wizard, the
|
||||
> admin panel, the journal, the favicon upload and the backup rotation all write
|
||||
> into it. A `:ro` mount leaves the wizard unable to complete and the admin panel
|
||||
> unable to save.
|
||||
|
||||
## Health Check
|
||||
|
||||
The container includes a built-in health check at `/api/health`:
|
||||
|
||||
```bash
|
||||
curl http://localhost:7211/api/health
|
||||
```
|
||||
|
||||
## Behaviour when Immich is Unreachable
|
||||
|
||||
Immich Folio buffers your gallery rather than merely proxying it:
|
||||
|
||||
- Album and asset pages keep serving the last known good data for up to `STALE_MAX_AGE`, so a restarting or briefly unreachable Immich does not take the public site down with it.
|
||||
- Once nothing cached is left, they return `503`, never `404` — a `404` would tell search engines to drop a URL for content that still exists.
|
||||
- Outages are never cached, so the gallery recovers as soon as Immich does.
|
||||
|
||||
The cache lives in the process, so it is empty right after a container restart.
|
||||
|
||||
## Reverse Proxy
|
||||
|
||||
Put Immich Folio behind nginx / Caddy / Traefik with TLS. Example Caddy config:
|
||||
|
||||
```
|
||||
photos.example.com {
|
||||
reverse_proxy localhost:7211
|
||||
}
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Set `TRUSTED_PROXY_HOPS` to the number of proxies in front of the app, and
|
||||
> make sure they forward `X-Forwarded-For` correctly — otherwise the client IP
|
||||
> comes from a header the client itself can set, which defeats the brute-force
|
||||
> limits on the password endpoints. See
|
||||
> [Trusted Proxies](gallery-config.md#trusted-proxies) for the setting and a
|
||||
> matching nginx config.
|
||||
@@ -1,24 +1,50 @@
|
||||
# Gallery Configuration
|
||||
|
||||
All gallery structure is defined in `content/gallery.yaml`. Copy the example to get started:
|
||||
Configuration is split across two files in `content/`:
|
||||
|
||||
| File | Contains |
|
||||
| --------------- | ---------------------------------------------------------------------------- |
|
||||
| `gallery.yaml` | **Structure** — hero assets, albums, subpages, sections, per-album settings |
|
||||
| `settings.yaml` | **Behaviour and identity** — title, theme, grid defaults, footer, legal, SEO |
|
||||
|
||||
Copy the examples to get started:
|
||||
|
||||
```bash
|
||||
cp content/gallery.yaml.example content/gallery.yaml
|
||||
cp content/settings.yaml.example content/settings.yaml
|
||||
```
|
||||
|
||||
`settings.yaml` is optional — without it, everything falls back to defaults.
|
||||
|
||||
> [!TIP]
|
||||
> Prefer a visual interface? Enable the **[Admin Panel](admin-panel.md)** by setting `ADMIN_PASSWORD` in your environment. It provides a drag-and-drop page builder that writes to these same YAML files automatically.
|
||||
|
||||
**Contents:**
|
||||
|
||||
`gallery.yaml`
|
||||
|
||||
- [Hero Images](#hero-images)
|
||||
- [Standalone Albums](#standalone-albums)
|
||||
- [Subpages & Categories](#subpages)
|
||||
- [Per-Album Options](#per-album-options)
|
||||
- [Photo Order Within an Album](#photo-order-within-an-album)
|
||||
|
||||
`settings.yaml`
|
||||
|
||||
- [Grid Layout](#grid-layout)
|
||||
- [EXIF on Hover](#exif-on-hover)
|
||||
- [Site Behaviour](#site-behaviour)
|
||||
- [Navigation Links](#navigation-links)
|
||||
- [Client Proofing](#client-proofing)
|
||||
- [Image Protection & Watermark](#image-protection--watermark)
|
||||
- [SEO](#seo)
|
||||
- [Footer](#footer)
|
||||
|
||||
Other
|
||||
|
||||
- [About Page](#about-page)
|
||||
- [Journal & Photo Essays](journal.md)
|
||||
- [Finding UUIDs](#finding-uuids)
|
||||
- [System & Security](#system--security)
|
||||
|
||||
## Hero Images
|
||||
|
||||
@@ -82,6 +108,43 @@ subpages:
|
||||
- 33333333-3333-3333-3333-333333333333
|
||||
```
|
||||
|
||||
### Subpage Options
|
||||
|
||||
| Key | Type | Description |
|
||||
| ----------- | ------- | ------------------------------------------------------------------------------------ |
|
||||
| `name` | string | Navigation label; the URL slug is derived from it |
|
||||
| `title` | string | Page heading, defaults to `name` |
|
||||
| `subtitle` | string | Subline under the heading |
|
||||
| `password` | string | Gates the whole subpage — see [Password Protection](#password-protection) |
|
||||
| `albums` | list | Album UUIDs, optionally with [per-album options](#per-album-options) |
|
||||
| `sections` | list | Named groups of albums — see [Sections](#sections) |
|
||||
| `grid` | object | Grid override for this page — see [Grid Layout](#grid-layout) |
|
||||
| `proofing` | boolean | Enables [client proofing](#client-proofing) on this page |
|
||||
| `essayFile` | string | Render a Markdown essay instead of a grid — see [Journal & Photo Essays](journal.md) |
|
||||
| `essayText` | string | Essay Markdown inline; written by the admin block editor |
|
||||
| `enabled` | boolean | `false` takes the page offline without deleting it |
|
||||
| `hidden` | boolean | **Experimental.** Reachable by direct link, but hidden from the navigation |
|
||||
|
||||
```yaml
|
||||
subpages:
|
||||
- name: Client Preview
|
||||
hidden: true # unlisted: not in the nav, still reachable at /client-preview
|
||||
proofing: true # visitors can mark favourites and export the selection
|
||||
password: 'clientpass123'
|
||||
albums:
|
||||
- 55555555-5555-5555-5555-555555555555
|
||||
|
||||
- name: Old Series
|
||||
enabled: false # offline, keeps its configuration
|
||||
albums:
|
||||
- 66666666-6666-6666-6666-666666666666
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> `hidden` is unlisting, not access control. The URL is guessable — a slug is
|
||||
> derived from the name — and nothing stops a visitor who has it. Combine it
|
||||
> with `password` for anything that must stay private.
|
||||
|
||||
### Sections
|
||||
|
||||
When a subpage contains many albums you can split them into **named sections**. A typographic table of contents with anchor links is automatically rendered above the albums. Sections are fully optional — omit them and you get the standard flat grid.
|
||||
@@ -94,18 +157,7 @@ Each section can have:
|
||||
| `description` | string | ➖ | Optional subline under the heading |
|
||||
| `albums` | list | ✅ | Album UUIDs (same format as regular albums) |
|
||||
|
||||
Within the `albums` list you can use a plain UUID, a simple title override, or an object with any of `title`, `description`, `password`, `heroImage`, `sort` and `assetOrder`:
|
||||
|
||||
```yaml
|
||||
albums:
|
||||
- 'album-uuid' # → uses the Immich album name
|
||||
- 'album-uuid': My Title # → displays "My Title" instead
|
||||
- 'album-uuid':
|
||||
title: My Private Title
|
||||
password: 'secure-password' # → adds a password gate to this specific album
|
||||
heroImage: 'asset-uuid' # → overrides the album cover
|
||||
sort: filename # → see "Photo Order Within an Album" below
|
||||
```
|
||||
Within the `albums` list you can use a plain UUID, a simple title override, or an object — see [Per-Album Options](#per-album-options).
|
||||
|
||||
**Full example:**
|
||||
|
||||
@@ -135,6 +187,86 @@ subpages:
|
||||
> [!NOTE]
|
||||
> Each section title is automatically converted to a URL-safe anchor (`#tokyo`, `#kyoto`, …). The TOC appearance (separator character, numbering style, section rule) is fully controlled by the active theme.
|
||||
|
||||
## Per-Album Options
|
||||
|
||||
Every album entry — standalone, on a subpage, or inside a section — accepts the
|
||||
same three notations:
|
||||
|
||||
```yaml
|
||||
albums:
|
||||
- 'album-uuid' # → uses the Immich album name
|
||||
- 'album-uuid': My Title # → displays "My Title" instead
|
||||
- 'album-uuid':
|
||||
title: My Private Title
|
||||
description: 'Shot on 35mm film in Vienna, spring 2025.'
|
||||
password: 'secure-password'
|
||||
heroImage: 'asset-uuid'
|
||||
sort: filename
|
||||
```
|
||||
|
||||
| Key | Description |
|
||||
| --------------- | ----------------------------------------------------------------------------- |
|
||||
| `title` | Display name instead of the Immich album name |
|
||||
| `description` | Shown below the album title |
|
||||
| `password` | Gates this album alone — see [Password Protection](#password-protection) |
|
||||
| `heroImage` | Asset UUID overriding the album cover |
|
||||
| `sort` | Photo order — see [Photo Order Within an Album](#photo-order-within-an-album) |
|
||||
| `assetOrder` | Pinned asset UUIDs for `sort: manual` |
|
||||
| `grid` | **Experimental.** Grid override for this album only |
|
||||
| `coverPosition` | **Experimental.** Focal point for the cover crop |
|
||||
|
||||
### Per-album grid (experimental)
|
||||
|
||||
An album can override the grid it is shown in. It is merged over the subpage
|
||||
grid, which is merged over the global default — so an album only has to state
|
||||
what differs:
|
||||
|
||||
```yaml
|
||||
albums:
|
||||
- 'album-uuid':
|
||||
title: Panoramas
|
||||
grid:
|
||||
layout: justified
|
||||
columns: 2
|
||||
```
|
||||
|
||||
### Cover focal point (experimental)
|
||||
|
||||
Album covers are cropped to the card. `coverPosition` decides which part
|
||||
survives the crop — the same values CSS `object-position` takes:
|
||||
|
||||
```yaml
|
||||
albums:
|
||||
- 'album-uuid':
|
||||
coverPosition: '50% 25%' # or: "top", "center bottom", "left"
|
||||
```
|
||||
|
||||
Useful when the automatic centre crop cuts off a horizon or a face.
|
||||
|
||||
## Password Protection
|
||||
|
||||
A password can be set on a subpage, on a single album, and on a
|
||||
[journal entry](journal.md#password-protected-entries). Unlocking sets an
|
||||
`HttpOnly` cookie that is valid for 24 hours; nothing is stored server-side.
|
||||
|
||||
Three storage formats are accepted:
|
||||
|
||||
| Format | Status |
|
||||
| ------------------ | --------------------------------------------------------------------- |
|
||||
| `scrypt:salt:hash` | **Recommended.** The password is not recoverable from `gallery.yaml`. |
|
||||
| Plaintext | Works, but deprecated — logs a warning naming the recommended hash. |
|
||||
| bcrypt (`$2a$…`) | Rejected. |
|
||||
|
||||
To get the hash for an existing plaintext password, unlock the gallery once and
|
||||
read the server log: the successful unlock prints the matching `scrypt:…` line
|
||||
to paste back into `gallery.yaml`. The admin panel writes the hashed form
|
||||
directly.
|
||||
|
||||
> [!NOTE]
|
||||
> Album and subpage gates protect the **page**. Image URLs handed out while a
|
||||
> gallery was unlocked keep working — see
|
||||
> [Image Token Revocation](#image-token-revocation).
|
||||
|
||||
## Photo Order Within an Album
|
||||
|
||||
By default a Folio album mirrors the Immich timeline. That is usually right, but a curated series has a narrative order that has nothing to do with capture time — and changing the sort on the Immich album would change it for the archive too. The optional `sort` key decouples the two.
|
||||
@@ -178,37 +310,169 @@ The `/admin` page builder has a drag & drop editor for this (album → **Photo o
|
||||
|
||||
## Grid Layout
|
||||
|
||||
Configure the photo grid globally or per-subpage:
|
||||
The global default lives in `settings.yaml`; subpages and individual albums can
|
||||
override it in `gallery.yaml`. The three levels merge, most specific winning:
|
||||
|
||||
**global (`settings.yaml`) → subpage → album**
|
||||
|
||||
```yaml
|
||||
# Global defaults
|
||||
# settings.yaml — global defaults
|
||||
grid:
|
||||
columns: 3 # number of columns (default: 3)
|
||||
gap: 12 # gap in pixels (default: 12)
|
||||
aspectRatio: '1' # "1", "3/2", "auto", etc. (default: "1")
|
||||
layout: masonry # "masonry" | "uniform" | "showcase" | "filmstrip" | "editorial-flow"
|
||||
aspectRatio: '1' # "1", "3/2", "2/3", "16/9", "auto" (default: "1")
|
||||
layout: masonry
|
||||
```
|
||||
|
||||
```yaml
|
||||
# gallery.yaml — per-subpage override
|
||||
subpages:
|
||||
- name: Japan
|
||||
grid: # per-subpage override
|
||||
grid:
|
||||
columns: 4
|
||||
layout: uniform
|
||||
aspectRatio: '3/2'
|
||||
albums:
|
||||
- '33333333-3333-3333-3333-333333333333'
|
||||
```
|
||||
|
||||
Available layouts: `masonry`, `uniform`, `showcase`, `filmstrip`,
|
||||
`editorial-flow`, `justified` (experimental), and `essay`
|
||||
(see [Journal & Photo Essays](journal.md)). Each is illustrated in the
|
||||
**[Theming guide](theming.md#gridlayout)**.
|
||||
|
||||
## Site Behaviour
|
||||
|
||||
Feature toggles in `settings.yaml`:
|
||||
|
||||
```yaml
|
||||
title: 'My Portfolio'
|
||||
subtitle: 'A visual journal'
|
||||
lang: 'en'
|
||||
|
||||
exifOnHover: true # camera/lens/settings on photo grid hover
|
||||
map: true # the interactive world map at /map
|
||||
transitions: true # smooth page transitions between routes
|
||||
scrollToTop: true # floating back-to-top arrow on long pages
|
||||
analytics: true # cookieless view counts, see below
|
||||
|
||||
about:
|
||||
enabled: true # the /about page, rendered from content/about.md
|
||||
```
|
||||
|
||||
| Key | Default | Notes |
|
||||
| --------------- | ------- | ------------------------------------------- |
|
||||
| `lang` | `en` | `<html lang>` and date formatting |
|
||||
| `exifOnHover` | on | |
|
||||
| `map` | **off** | Opt-in: must be set to `true` explicitly |
|
||||
| `transitions` | on | |
|
||||
| `scrollToTop` | on | |
|
||||
| `analytics` | on | |
|
||||
| `about.enabled` | on | Needs a `content/about.md` to show anything |
|
||||
|
||||
### Analytics
|
||||
|
||||
Page and album view counts, stored in `content/analytics.json` — no cookies, no
|
||||
third party, nothing leaving your server. The numbers are shown in the admin
|
||||
panel's **Analytics** tab.
|
||||
|
||||
```yaml
|
||||
analytics: false # stop counting entirely
|
||||
```
|
||||
|
||||
With `analytics: false` the tracking endpoint refuses to record, so no data is
|
||||
collected regardless of what the browser sends.
|
||||
|
||||
## Navigation Links
|
||||
|
||||
**Experimental.** External links appended to the header navigation. Only
|
||||
`http(s)` URLs are accepted, and they open in a new tab:
|
||||
|
||||
```yaml
|
||||
navLinks:
|
||||
- label: 'Shop'
|
||||
url: 'https://shop.example.com'
|
||||
- label: 'Instagram'
|
||||
url: 'https://instagram.com/your-handle'
|
||||
```
|
||||
|
||||
## Client Proofing
|
||||
|
||||
Lets a visitor mark favourites and export the selection — for handing a client a
|
||||
gallery and getting their picks back. A heart button sits on every photo in the
|
||||
grid; once something is picked, a bar appears with a filter and an export
|
||||
dialog.
|
||||
|
||||
The selection is encoded into the URL as a compact bitmask and mirrored into
|
||||
`localStorage`, so **nothing is stored server-side** and a set of picks can be
|
||||
shared, bookmarked, or sent back as a plain link.
|
||||
|
||||
Configured in `settings.yaml`:
|
||||
|
||||
```yaml
|
||||
proofing:
|
||||
enabled: true # hearts, selection bar and export modal (default: true)
|
||||
allowMailto: true # offer "send by email" alongside the copyable list (default: true)
|
||||
```
|
||||
|
||||
A subpage overrides `enabled` in either direction — useful to keep proofing off
|
||||
across a public portfolio and switch it on for a single client handover:
|
||||
|
||||
```yaml
|
||||
# gallery.yaml
|
||||
subpages:
|
||||
- name: Wedding – Smith
|
||||
password: clientpass123
|
||||
proofing: true # or `false` to disable it on this subpage only
|
||||
albums:
|
||||
- ...
|
||||
```
|
||||
|
||||
## EXIF on Hover
|
||||
Precedence is **subpage → global**: a subpage's own `proofing:` wins, and an
|
||||
album reached without a subpage follows `proofing.enabled`.
|
||||
|
||||
Show camera/lens/settings when hovering over photos (enabled by default):
|
||||
> [!NOTE]
|
||||
> Photo essays are the exception. A published story is not an album handover,
|
||||
> so an essay (`layout: essay`, `essayFile`, `essayText`) shows the proofing
|
||||
> controls only when its subpage sets `proofing: true` **explicitly** — the
|
||||
> global default does not reach into essays. Journal entries never show them.
|
||||
|
||||
## Image Protection & Watermark
|
||||
|
||||
```yaml
|
||||
exifOnHover: false # set to false to disable
|
||||
protection:
|
||||
disableRightClick: true # suppress the context menu on images
|
||||
disableImageDrag: true # block drag-to-desktop
|
||||
|
||||
watermark:
|
||||
enabled: true
|
||||
text: '© Your Name'
|
||||
opacity: 0.5
|
||||
position: 'bottom-right' # bottom-right | bottom-left | center
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Both are deterrents against casual copying, not protection. The image is in
|
||||
> the browser, and anyone who wants it can take a screenshot. The watermark is
|
||||
> an overlay on the lightbox, not burned into the file.
|
||||
|
||||
## SEO
|
||||
|
||||
```yaml
|
||||
seo:
|
||||
title: 'My Portfolio | Photography'
|
||||
description: 'A curated selection of my best photography work.'
|
||||
titleTemplate: '%s | My Portfolio' # %s is the subpage or album title
|
||||
noIndex: false # true → ask search engines not to index the site
|
||||
noFollow: false # true → ask search engines not to follow links
|
||||
```
|
||||
|
||||
`titleTemplate` shapes the browser and search-result title of every subpage and
|
||||
album; the homepage uses `title` unchanged.
|
||||
|
||||
## Footer
|
||||
|
||||
Optional minimal footer with social links:
|
||||
Optional minimal footer with social links, in `settings.yaml`:
|
||||
|
||||
```yaml
|
||||
footer:
|
||||
@@ -218,6 +482,9 @@ footer:
|
||||
website: https://example.com
|
||||
```
|
||||
|
||||
A `legal:` block with `enabled: true` adds an Impressum page at `/impressum` and
|
||||
links it in the footer — see `content/settings.yaml.example` for every field.
|
||||
|
||||
## About Page
|
||||
|
||||
Create `content/about.md` with YAML frontmatter:
|
||||
@@ -235,6 +502,26 @@ gear:
|
||||
Your bio text here. Supports full Markdown.
|
||||
```
|
||||
|
||||
The page is served at `/about` and linked in the navigation. Turn it off in
|
||||
`settings.yaml` without deleting the file:
|
||||
|
||||
```yaml
|
||||
about:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
The admin panel's **Settings → About** section edits the same file — portrait,
|
||||
name, location, gear list and bio — so `about.md` does not have to be written by
|
||||
hand.
|
||||
|
||||
## Journal & Photo Essays
|
||||
|
||||
Long-form storytelling with fullbleed photos, side-by-side pairs, quotes and
|
||||
captions lives in `content/journal/`, served at `/journal`. A subpage can also
|
||||
be turned into a single photo essay.
|
||||
|
||||
→ **[Journal & Photo Essays](journal.md)**
|
||||
|
||||
## Finding UUIDs
|
||||
|
||||
- **Album UUIDs**: In Immich, go to Albums → click an album → the UUID is in the URL bar
|
||||
@@ -246,9 +533,25 @@ Advanced system settings are configured via environment variables in your `.env`
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
To protect against brute-force attacks and resource exhaustion, Immich Folio includes an in-memory rate limiter.
|
||||
To protect against brute-force attacks and resource exhaustion, Immich Folio includes an in-memory rate limiter. Each endpoint has its own bucket, so heavy image traffic cannot exhaust the budget for the password endpoints.
|
||||
|
||||
- `RATE_LIMIT_RPM`: Maximum requests per minute per IP (default: `1500` for general API, `120` for map data).
|
||||
- `RATE_LIMIT_RPM`: requests per minute per IP for image, video, EXIF and health requests (default: `1500`).
|
||||
|
||||
The endpoints where a high limit would be the wrong default are fixed and not configurable:
|
||||
|
||||
| Endpoint | Limit (req/min/IP) |
|
||||
| ---------------------- | ------------------ |
|
||||
| `POST /api/admin/auth` | 5 |
|
||||
| `POST /api/auth` | 10 |
|
||||
| `/api/install` | 10 |
|
||||
| `/api/og` | 30 |
|
||||
| `/api/install/albums` | 30 |
|
||||
| `/api/webhook` | 60 |
|
||||
| `/api/map` | 120 |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The limiter lives in the process memory of a single instance. It does not
|
||||
> coordinate across replicas, and it starts empty after a restart.
|
||||
|
||||
### Trusted Proxies
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ _Status: Entwurf genehmigt (Brainstorming abgeschlossen) · Datum: 2026-08-14_
|
||||
## 1. Understanding Summary
|
||||
|
||||
- **Ziel & Vision:** Bereitstellung eines intuitiven, erstklassigen **Journal Studios** für Fotografen, um Bild-Text-Reportagen, Reisetagebücher und Ausstellungsberichte (im Stil von Leica Journal / National Geographic) zu verfassen und zu veröffentlichen.
|
||||
- **Zielgruppe:**
|
||||
- **Zielgruppe:**
|
||||
- **Besucher:** Immersives, typografisch elegantes Leseerlebnis mit progressiv ladenden Bildern (ThumbHash), responsiven Layouts und Lightbox-Interaktion.
|
||||
- **Fotograf:** Mühelose Redaktion im Admin-Panel mit Live-Split-Screen-Vorschau und universeller Immich-Bildauswahl.
|
||||
- **Konzept:** Reines Markdown mit Frontmatter im Content-Volume (`content/journal/*.md`), verwaltet über eine dedizierte Admin-UI und ausgeliefert über saubere Next.js Server Components.
|
||||
- **Kern-Vorteile:**
|
||||
- **Kern-Vorteile:**
|
||||
- Trennung von Galerie-Konfiguration (`gallery.yaml`) und redaktionellen Inhalten.
|
||||
- Volle Git- & Backup-Kompatibilität durch Standard-Markdown.
|
||||
- Schnelle Erstellung ohne manuelle UUID-Eingabe.
|
||||
@@ -29,30 +29,31 @@ _Status: Entwurf genehmigt (Brainstorming abgeschlossen) · Datum: 2026-08-14_
|
||||
|
||||
## 3. Decision Log
|
||||
|
||||
| # | Entscheidung | Alternativen | Begründung |
|
||||
|---|---|---|---|
|
||||
| 1 | **Nomenklatur "Journal"** | Stories, Photo Essays, Magazin | "Stories" wirkt wie flüchtige Social-Media-Inhalte. "Journal" spiegelt zeitlose, fotografische Wertigkeit (wie Leica Journal) wider. |
|
||||
| 2 | **Architektur-Ansatz 1 (Eigenständiges Studio)** | Subpage-Drawer, Quick-Fix | Maximale Schreib- und Layout-Ergonomie durch echten Split-Screen und Entkopplung von `gallery.yaml`. |
|
||||
| 3 | **Persistenz als `.md`-Dateien** | Strings in `gallery.yaml`, SQLite/DB | Behält das dateibasierte, zustandsarme Self-Hosting-Paradigma bei; einfache Versionierung via Git. |
|
||||
| 4 | **Bidirektionaler Editor (Blöcke ↔ Markdown)** | Nur WYSIWYG, Nur Markdown | Fotografen können visuell Blöcke anordnen oder direkt mit Markdown-Code arbeiten, ohne Datenverlust. |
|
||||
| 5 | **Sicherheit via Slug-Sanitisierung** | Ungeprüfte Dateinamen | Verhindert Path Traversal (`../`) im Docker-Container/Dateisystem. |
|
||||
| # | Entscheidung | Alternativen | Begründung |
|
||||
| --- | ------------------------------------------------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 1 | **Nomenklatur "Journal"** | Stories, Photo Essays, Magazin | "Stories" wirkt wie flüchtige Social-Media-Inhalte. "Journal" spiegelt zeitlose, fotografische Wertigkeit (wie Leica Journal) wider. |
|
||||
| 2 | **Architektur-Ansatz 1 (Eigenständiges Studio)** | Subpage-Drawer, Quick-Fix | Maximale Schreib- und Layout-Ergonomie durch echten Split-Screen und Entkopplung von `gallery.yaml`. |
|
||||
| 3 | **Persistenz als `.md`-Dateien** | Strings in `gallery.yaml`, SQLite/DB | Behält das dateibasierte, zustandsarme Self-Hosting-Paradigma bei; einfache Versionierung via Git. |
|
||||
| 4 | **Bidirektionaler Editor (Blöcke ↔ Markdown)** | Nur WYSIWYG, Nur Markdown | Fotografen können visuell Blöcke anordnen oder direkt mit Markdown-Code arbeiten, ohne Datenverlust. |
|
||||
| 5 | **Sicherheit via Slug-Sanitisierung** | Ungeprüfte Dateinamen | Verhindert Path Traversal (`../`) im Docker-Container/Dateisystem. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Technische Architektur & Komponenten
|
||||
|
||||
### A. Dateisystem & Datenmodell
|
||||
|
||||
Jeder Eintrag ist eine `.md`-Datei mit folgendem Schema:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "Expedition Nordkap"
|
||||
subtitle: "Mit dem Bulli durch die Fjorde"
|
||||
date: "2026-08-14"
|
||||
author: "Ralf"
|
||||
coverAssetId: "b8c2d111-0000-4000-8000-000000000000"
|
||||
password: "" # Optionaler Passwortschutz (scrypt)
|
||||
draft: false # Entwurf oder öffentlich
|
||||
title: 'Expedition Nordkap'
|
||||
subtitle: 'Mit dem Bulli durch die Fjorde'
|
||||
date: '2026-08-14'
|
||||
author: 'Ralf'
|
||||
coverAssetId: 'b8c2d111-0000-4000-8000-000000000000'
|
||||
password: '' # Optionaler Passwortschutz (scrypt)
|
||||
draft: false # Entwurf oder öffentlich
|
||||
---
|
||||
|
||||
Hier beginnt der Text der Reportage...
|
||||
@@ -61,6 +62,7 @@ Hier beginnt der Text der Reportage...
|
||||
```
|
||||
|
||||
Unterstützte Block-Typen:
|
||||
|
||||
- **Heading:** `# H1`, `## H2`, `### H3`
|
||||
- **Paragraph:** Fliesstext mit Inline-Markdown (`**fett**`, `*kursiv*`, `[Link](url)`)
|
||||
- **Pullquote:** `> Zitattext` mit optionalem `— Autor`
|
||||
@@ -81,7 +83,7 @@ Unterstützte Block-Typen:
|
||||
### C. UI: Das Journal Studio (`/admin` ➔ Tab "Journal")
|
||||
|
||||
1. **Journal Dashboard:**
|
||||
- Übersicht aller Einträge als Karten mit Thumbnail, Status (*Draft*, *Live*, *Protected*), Erstellungsdatum.
|
||||
- Übersicht aller Einträge als Karten mit Thumbnail, Status (_Draft_, _Live_, _Protected_), Erstellungsdatum.
|
||||
- Button `+ New Journal Entry`.
|
||||
2. **Studio Split-Screen Editor:**
|
||||
- **Top Navigation:** Titel-Eingabe, Slug, Status-Schalter, Metadaten-Modal (Autor, Datum, Passwort, Cover-Foto) und Save-Button (`Cmd+S`).
|
||||
|
||||
244
docs/journal.md
Normal file
244
docs/journal.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# Journal & Photo Essays
|
||||
|
||||
Immich Folio has two ways to publish long-form, text-and-image storytelling. They
|
||||
share one Markdown format and one renderer, and differ only in where the result
|
||||
appears:
|
||||
|
||||
| Feature | Lives in | URL | Use for |
|
||||
| --------------- | ---------------------- | ----------------- | ---------------------------------------------------------------- |
|
||||
| **Journal** | `content/journal/*.md` | `/journal/<slug>` | A running series of stories, listed on a `/journal` index page |
|
||||
| **Photo Essay** | `content/essays/*.md` | the subpage's URL | Turning one subpage of your gallery into a single narrative page |
|
||||
|
||||
If you are unsure: use the **Journal**. A photo essay is the older mechanism and
|
||||
is tied to a subpage; the journal is a standalone section with its own index,
|
||||
cover images, reading times, and drafts.
|
||||
|
||||
**Contents:**
|
||||
|
||||
- [Journal Entries](#journal-entries)
|
||||
- [Frontmatter](#frontmatter)
|
||||
- [Block Syntax](#block-syntax)
|
||||
- [Drafts](#drafts)
|
||||
- [Password-protected Entries](#password-protected-entries)
|
||||
- [Journal Studio](#journal-studio)
|
||||
- [Photo Essays on a Subpage](#photo-essays-on-a-subpage)
|
||||
|
||||
## Journal Entries
|
||||
|
||||
Each entry is one Markdown file in `content/journal/`. The filename is the URL
|
||||
slug — `content/journal/iceland-highlands.md` is served at
|
||||
`/journal/iceland-highlands`.
|
||||
|
||||
A `/journal` link appears in the site navigation automatically as soon as at
|
||||
least one **published** (non-draft) entry exists. There is no setting to turn
|
||||
the journal on: an empty `content/journal/` directory means no journal.
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/journal-index.png" width="98%" alt="The journal index listing entries with cover images, dates and reading times" />
|
||||
</p>
|
||||
<p align="center"><em>The index at <code>/journal</code> — cover image, title, date and reading time per entry.</em></p>
|
||||
|
||||
A template ships with the project:
|
||||
|
||||
```bash
|
||||
cp content/journal/sample-story.md.example content/journal/my-first-story.md
|
||||
```
|
||||
|
||||
The template is an `.example` file on purpose — it references no asset IDs
|
||||
(those are specific to your Immich server), so copying it gives you a working
|
||||
skeleton rather than a page full of broken images.
|
||||
|
||||
## Frontmatter
|
||||
|
||||
Optional YAML block at the top of the file:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: 'The Icelandic Highlands'
|
||||
subtitle: 'A visual journey across volcanic deserts and arctic silence'
|
||||
author: 'Your Name'
|
||||
date: '2026-08-05'
|
||||
coverAssetId: 'asset-uuid-for-the-cover'
|
||||
draft: false
|
||||
password: 'clients-only'
|
||||
---
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
| -------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `title` | Entry heading and browser/OG title. Defaults to the slug. |
|
||||
| `subtitle` | Subline under the heading; also used as the meta description. |
|
||||
| `author` | Shown in the entry byline. |
|
||||
| `date` | Publication date shown on the index and the entry. |
|
||||
| `coverAssetId` | Immich asset UUID used as the index card image and the OG preview image. |
|
||||
| `draft` | `true` hides the entry from visitors — see [Drafts](#drafts). |
|
||||
| `password` | Gates this single entry — see [Password-protected Entries](#password-protected-entries). |
|
||||
|
||||
Reading time and the index excerpt are computed from the text; you do not
|
||||
configure them.
|
||||
|
||||
Every one of these fields is also a form control in the
|
||||
[Journal Studio](#journal-studio), behind **Story Settings**:
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/admin-journal-settings.png" width="98%" alt="The Story Settings dialog: subtitle, author, publish date, cover photo, password and draft toggle" />
|
||||
</p>
|
||||
<p align="center"><em>The same frontmatter, as a form — including the cover picker, the password field and the draft toggle.</em></p>
|
||||
|
||||
## Block Syntax
|
||||
|
||||
The parser is deliberately small. It understands headings, paragraphs, quotes
|
||||
and photos — one block per blank-line-separated chunk.
|
||||
|
||||
### Headings
|
||||
|
||||
```markdown
|
||||
# Into the Wilderness
|
||||
|
||||
## Landmannalaugar
|
||||
```
|
||||
|
||||
`#` through `######` are supported.
|
||||
|
||||
### Text
|
||||
|
||||
Plain paragraphs. Inline `**bold**`, `*italic*` and `[links](https://example.com)`
|
||||
work; links open in a new tab. Everything else is escaped rather than passed
|
||||
through, so a paragraph can never inject HTML into the page.
|
||||
|
||||
### Quotes
|
||||
|
||||
```markdown
|
||||
> Silence in the highlands is a physical weight. -- Anonymous
|
||||
```
|
||||
|
||||
Text after a `--` separator becomes the attribution line.
|
||||
|
||||
### Photos
|
||||
|
||||
Photos are referenced by their **Immich asset UUID**, not by a file path:
|
||||
|
||||
```markdown
|
||||

|
||||

|
||||

|
||||
```
|
||||
|
||||
| Suffix | Width |
|
||||
| ------------ | --------------------------------------------- |
|
||||
| `:fullbleed` | Edge to edge, breaking out of the text column |
|
||||
| `:wide` | Wider than the text column, but still inset |
|
||||
| _(none)_ | Contained — same width as the text |
|
||||
|
||||
Two UUIDs separated by a comma render as a side-by-side pair, sized to their
|
||||
real aspect ratios rather than forced equal:
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
Photos in an entry open in the same lightbox as the rest of the site, with
|
||||
keyboard and swipe navigation.
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/journal-entry.png" width="98%" alt="A rendered journal entry with heading, body text, a fullbleed photo and a quote" />
|
||||
</p>
|
||||
<p align="center"><em>A rendered entry — headings, body text, a fullbleed photo and a pull quote, from the blocks above.</em></p>
|
||||
|
||||
> [!TIP]
|
||||
> Writing asset UUIDs by hand is tedious. In the [Journal Studio](#journal-studio),
|
||||
> **Add Block → Photo** opens the asset picker and fills the UUID in for you.
|
||||
|
||||
## Drafts
|
||||
|
||||
`draft: true` in the frontmatter keeps an entry out of the `/journal` index and
|
||||
out of the navigation link check — but a logged-in admin still sees it, both in
|
||||
the index and at its URL. That makes the draft a preview of the real page rather
|
||||
than a separate preview mode.
|
||||
|
||||
Drafts are not secret: the entry stays reachable at its URL for anyone who knows
|
||||
the slug. Use a `password` if it must not be readable.
|
||||
|
||||
## Password-protected Entries
|
||||
|
||||
A `password` in the frontmatter puts the entry behind the same gate used for
|
||||
subpages and albums. The unlock cookie is `lb_auth_journal_<slug>` and expires
|
||||
after 24 hours.
|
||||
|
||||
Store a hash rather than the plaintext where possible — the `scrypt:salt:hash`
|
||||
format described in [Gallery Configuration](gallery-config.md) applies here too.
|
||||
A plaintext password works but is logged as a warning at startup.
|
||||
|
||||
## Journal Studio
|
||||
|
||||
The admin panel's **Journal** tab lists every entry — drafts included, marked as
|
||||
such — with its cover, date and password state:
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/admin-journal-list.png" width="98%" alt="The Journal tab listing entries with covers, dates and draft badges" />
|
||||
</p>
|
||||
<p align="center"><em>Published and draft entries side by side; the lock marks an entry with a password.</em></p>
|
||||
|
||||
Opening one gives a split-screen editor: blocks on the left, a live preview of
|
||||
the real page on the right, with a draggable divider between them (mouse,
|
||||
keyboard, and touch).
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/admin-journal-studio.png" width="98%" alt="The Journal Studio: block editor on the left, live preview of the entry on the right" />
|
||||
</p>
|
||||
<p align="center"><em>The Journal Studio — every block on the left, the real page on the right.</em></p>
|
||||
|
||||
- **Add Block** — heading, text, quote, photo, or photo pair
|
||||
- **Photo picker** — browse your Immich library and insert the asset UUID
|
||||
- **Cover image, title, subtitle, author, date, draft, password** — the
|
||||
frontmatter fields, as form controls
|
||||
- **Reorder and delete** blocks; each block carries a type chip so a long entry
|
||||
stays scannable
|
||||
- **Save** writes `content/journal/<slug>.md` in exactly the format documented
|
||||
above, with a backup of the previous version in `content/journal/.backups/`
|
||||
|
||||
Everything the studio writes can be edited by hand afterwards, and vice versa —
|
||||
the studio parses the same files.
|
||||
|
||||
> [!NOTE]
|
||||
> Entries written before the current photo syntax may contain position-based
|
||||
> photo references. The studio marks those blocks instead of silently
|
||||
> misrendering them, so they can be repointed at an asset UUID.
|
||||
|
||||
## Photo Essays on a Subpage
|
||||
|
||||
A subpage can render a Markdown essay instead of an album grid. Point it at a
|
||||
file, inline the text, or let it build itself from the albums:
|
||||
|
||||
```yaml
|
||||
subpages:
|
||||
- name: Highlands
|
||||
title: 'Icelandic Highlands'
|
||||
subtitle: 'A Photo Essay'
|
||||
essayFile: 'sample-story' # content/journal/sample-story.md
|
||||
albums:
|
||||
- 'album-uuid-iceland-1'
|
||||
```
|
||||
|
||||
| Key | Description |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| `essayFile` | A **slug**, not a path — resolved as `content/journal/<slug>.md`, then `content/essays/<slug>.md` |
|
||||
| `essayText` | The essay Markdown inline in `gallery.yaml` — what the admin block editor writes |
|
||||
| `grid.layout` | `essay` — renders as an essay even without `essayFile` or `essayText` |
|
||||
|
||||
Any one of the three switches the page into essay mode; setting `essayFile` or
|
||||
`essayText` implies it, so `grid.layout: essay` is only needed on its own.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `essayFile` takes a bare slug — letters, digits, `-` and `_`. A path such as
|
||||
> `content/essays/sample-story.md` is rejected (dots and slashes are not valid
|
||||
> slug characters) and the page falls back to the generated essay below.
|
||||
|
||||
The Markdown format is identical to a journal entry, including the photo block
|
||||
syntax. The admin panel has a visual block editor for `essayText` under the
|
||||
subpage's settings.
|
||||
|
||||
The album list is used either way: it supplies the photos the lightbox pages
|
||||
through. And with `grid.layout: essay` but no Markdown at all, the essay is
|
||||
generated from those albums — one heading per album, followed by its photos,
|
||||
using each photo's Immich description as the caption.
|
||||
BIN
docs/screenshots/admin-journal-list.png
Normal file
BIN
docs/screenshots/admin-journal-list.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 197 KiB |
BIN
docs/screenshots/admin-journal-settings.png
Normal file
BIN
docs/screenshots/admin-journal-settings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
BIN
docs/screenshots/admin-journal-studio.png
Normal file
BIN
docs/screenshots/admin-journal-studio.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
BIN
docs/screenshots/header.png
Normal file
BIN
docs/screenshots/header.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 453 KiB |
BIN
docs/screenshots/journal-entry.png
Normal file
BIN
docs/screenshots/journal-entry.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
BIN
docs/screenshots/journal-index.png
Normal file
BIN
docs/screenshots/journal-index.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
@@ -14,17 +14,17 @@
|
||||
|
||||
Der ursprüngliche Plan (v1) entstand gegen v0.9.1. PR #394 hat davon einen großen Teil umgesetzt — teils anders und besser als geplant. Ersatzlos gestrichen:
|
||||
|
||||
| Ehemalige Task | Status in v0.9.2 |
|
||||
|---|---|
|
||||
| Upstream-Statusmodul (`lib/upstream.ts`) | **Überholt.** Statt eines Seiten-Pings unterscheidet `request()` jetzt an der Quelle: `ImmichUnavailableError` für Ausfälle, `null` nur für 404/410. Das ist die bessere Lösung — kein Nebenkanal, keine Race zwischen Ping und Abruf. |
|
||||
| 404-vs-503-Politik in `app/[...path]/page.tsx` | **Erledigt.** Der Fehler propagiert, Seiten liefern 5xx statt 404. Zusätzlich geben `/api/exif` und `/api/map` 503 mit `Retry-After`. |
|
||||
| `app/error.tsx`, `app/not-found.tsx` | **Erledigt**, mit `.empty-state` aus `globals.css` und 8 Tests in `lib/__tests__/error-pages.test.ts`. |
|
||||
| `app/global-error.tsx` | **Nicht mehr nötig.** Das Layout wirft nicht mehr: `getConfigOrNull()` (`lib/config/index.ts:39`) fängt ab und rendert den SetupScreen, wobei `/admin` erreichbar bleibt. Das war der einzige konkrete Anlass für die Boundary. |
|
||||
| Bild-Größen-Tiers | **Erledigt**, nach `lib/imageSize.ts` ausgelagert und getestet. |
|
||||
| Ehemalige Task | Status in v0.9.2 |
|
||||
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Upstream-Statusmodul (`lib/upstream.ts`) | **Überholt.** Statt eines Seiten-Pings unterscheidet `request()` jetzt an der Quelle: `ImmichUnavailableError` für Ausfälle, `null` nur für 404/410. Das ist die bessere Lösung — kein Nebenkanal, keine Race zwischen Ping und Abruf. |
|
||||
| 404-vs-503-Politik in `app/[...path]/page.tsx` | **Erledigt.** Der Fehler propagiert, Seiten liefern 5xx statt 404. Zusätzlich geben `/api/exif` und `/api/map` 503 mit `Retry-After`. |
|
||||
| `app/error.tsx`, `app/not-found.tsx` | **Erledigt**, mit `.empty-state` aus `globals.css` und 8 Tests in `lib/__tests__/error-pages.test.ts`. |
|
||||
| `app/global-error.tsx` | **Nicht mehr nötig.** Das Layout wirft nicht mehr: `getConfigOrNull()` (`lib/config/index.ts:39`) fängt ab und rendert den SetupScreen, wobei `/admin` erreichbar bleibt. Das war der einzige konkrete Anlass für die Boundary. |
|
||||
| Bild-Größen-Tiers | **Erledigt**, nach `lib/imageSize.ts` ausgelagert und getestet. |
|
||||
|
||||
Obendrein neu und relevant: `IMMICH_TIMEOUT_MS`, ein `MISSING`-Sentinel, der definitive 404-Antworten cacht, `middleware.ts` → `proxy.ts`, sowie 210 statt 98 Unit-Tests.
|
||||
|
||||
**Was das für Task 1 bedeutet:** Die vitest-Grenze wurde in #394 zweimal *umgangen* statt gehoben — `lib/imageSize.ts` wurde eigens ausgelagert, damit die Größenlogik aus `lib/__tests__/` erreichbar ist, und `error-pages.test.ts` benutzt `createElement` statt JSX, weil eine `.tsx`-Datei dort nie ausgeführt würde (so im Commit-Text vermerkt). Die Sicherheitslogik, die sich nicht auslagern lässt — Admin-Guards und Content-Type-Sanitisierung — ist deshalb weiterhin ungetestet.
|
||||
**Was das für Task 1 bedeutet:** Die vitest-Grenze wurde in #394 zweimal _umgangen_ statt gehoben — `lib/imageSize.ts` wurde eigens ausgelagert, damit die Größenlogik aus `lib/__tests__/` erreichbar ist, und `error-pages.test.ts` benutzt `createElement` statt JSX, weil eine `.tsx`-Datei dort nie ausgeführt würde (so im Commit-Text vermerkt). Die Sicherheitslogik, die sich nicht auslagern lässt — Admin-Guards und Content-Type-Sanitisierung — ist deshalb weiterhin ungetestet.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
@@ -44,25 +44,25 @@ Obendrein neu und relevant: `IMMICH_TIMEOUT_MS`, ein `MISSING`-Sentinel, der def
|
||||
|
||||
**Neu:**
|
||||
|
||||
| Datei | Verantwortung |
|
||||
|---|---|
|
||||
| Datei | Verantwortung |
|
||||
| ---------------------------------------------- | ---------------------------------------------------------- |
|
||||
| `app/api/admin/__tests__/admin-guards.test.ts` | Tabellengetrieben: jede Admin-Route lehnt ohne Session ab. |
|
||||
| `app/api/image/__tests__/image-route.test.ts` | Token-Grenze, Content-Type-Sanitisierung, ETag/304. |
|
||||
| `lib/__tests__/cache-stale.test.ts` | Stale-Fenster und harte Obergrenze des Caches. |
|
||||
| `app/loading.tsx` | Skeleton für die Startseite. |
|
||||
| `app/[...path]/loading.tsx` | Skeleton für Album-/Subpage-Routen. |
|
||||
| `app/loading.module.css` | Skeleton-Styles. |
|
||||
| `app/api/image/__tests__/image-route.test.ts` | Token-Grenze, Content-Type-Sanitisierung, ETag/304. |
|
||||
| `lib/__tests__/cache-stale.test.ts` | Stale-Fenster und harte Obergrenze des Caches. |
|
||||
| `app/loading.tsx` | Skeleton für die Startseite. |
|
||||
| `app/[...path]/loading.tsx` | Skeleton für Album-/Subpage-Routen. |
|
||||
| `app/loading.module.css` | Skeleton-Styles. |
|
||||
|
||||
**Geändert:**
|
||||
|
||||
| Datei | Änderung |
|
||||
|---|---|
|
||||
| `vitest.config.ts` | `include` um `app/**/__tests__/**/*.test.ts` erweitern. |
|
||||
| `lib/cache.ts` | Zwei Deadlines pro Eintrag, `getStale()`. |
|
||||
| `lib/immich.ts` | `ImmichUnavailableError` abfangen, Stale-Fallback, sonst weiterwerfen. |
|
||||
| `lib/env.ts`, `.env.local.example` | `STALE_MAX_AGE`. |
|
||||
| `lib/config/schema.ts`, `lib/config/index.ts` | `staleMaxAge` in `AppConfig` und beide Rückgabepfade. |
|
||||
| `README.md`, `CLAUDE.md` | Stale-Verhalten und Token-Widerruf dokumentieren. |
|
||||
| Datei | Änderung |
|
||||
| --------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `vitest.config.ts` | `include` um `app/**/__tests__/**/*.test.ts` erweitern. |
|
||||
| `lib/cache.ts` | Zwei Deadlines pro Eintrag, `getStale()`. |
|
||||
| `lib/immich.ts` | `ImmichUnavailableError` abfangen, Stale-Fallback, sonst weiterwerfen. |
|
||||
| `lib/env.ts`, `.env.local.example` | `STALE_MAX_AGE`. |
|
||||
| `lib/config/schema.ts`, `lib/config/index.ts` | `staleMaxAge` in `AppConfig` und beide Rückgabepfade. |
|
||||
| `README.md`, `CLAUDE.md` | Stale-Verhalten und Token-Widerruf dokumentieren. |
|
||||
|
||||
**Abhängigkeiten:**
|
||||
|
||||
@@ -79,14 +79,16 @@ Task 4 (Doku) ── nach Task 2
|
||||
|
||||
**Warum:** `vitest.config.ts` schließt alles außerhalb von `lib/__tests__/` aus. Zwei Stellen sind deshalb ungetestet und lassen sich auch nicht auslagern: die Guards der acht Admin-Routen und die Content-Type-Normalisierung in `app/api/image/[id]/route.ts:99-107`, die aus einem Stored-XSS-Fix stammt (`c2fa8e7`).
|
||||
|
||||
**Erwartungshaltung:** Beide Mechanismen funktionieren heute korrekt. Diese Tests finden keinen Bug — sie sind Regressionsbremsen. Der Wert der Admin-Tabelle liegt darin, dass eine *künftig* ergänzte Route ohne Guard die Suite rot macht.
|
||||
**Erwartungshaltung:** Beide Mechanismen funktionieren heute korrekt. Diese Tests finden keinen Bug — sie sind Regressionsbremsen. Der Wert der Admin-Tabelle liegt darin, dass eine _künftig_ ergänzte Route ohne Guard die Suite rot macht.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `vitest.config.ts`
|
||||
- Create: `app/api/admin/__tests__/admin-guards.test.ts`
|
||||
- Create: `app/api/image/__tests__/image-route.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `isAdminAuthenticated()`, `isAdminEnabled()` (`lib/admin/auth.ts`, gemockt); `encodeAssetId()` (`lib/tokens.ts`); `immich.streamAsset()` (gemockt).
|
||||
- Produces: nichts für spätere Tasks.
|
||||
|
||||
@@ -414,6 +416,7 @@ Run: `npm run test:unit -- app/api/image/__tests__/image-route.test.ts`
|
||||
Erwartung: 12 Tests PASS.
|
||||
|
||||
Zwei mögliche Stolperstellen — beide zuerst gegen den Code prüfen, bevor der Test angepasst wird:
|
||||
|
||||
- Falls der Rate-Limiter über die Aufrufe hinweg greift, in `vitest.setup.ts` `process.env.RATE_LIMIT_RPM = '100000'` ergänzen.
|
||||
- Falls die 503-Header anders heißen als erwartet, den tatsächlichen Wert aus `app/api/image/[id]/route.ts` übernehmen — der Test soll das reale Verhalten festschreiben.
|
||||
|
||||
@@ -451,9 +454,10 @@ git commit -m "test: enable route-handler tests and cover admin guards and image
|
||||
|
||||
**Warum:** v0.9.2 unterscheidet Ausfall und Nichtexistenz sauber und liefert bei Ausfall 5xx statt 404 — das war die dringendere Hälfte. Die andere Hälfte fehlt: Ein Besucher sieht während eines Immich-Neustarts eine Fehlerseite, obwohl die Alben Sekunden zuvor noch im Speicher lagen. `lib/cache.ts` löscht abgelaufene Einträge beim Zugriff (Zeile 23).
|
||||
|
||||
**Wichtig zur Abgrenzung:** v0.9.2 cacht Ausfälle bewusst *nicht* — ein `ImmichUnavailableError` wird nie gespeichert, damit die Galerie nach der Erholung nicht kaputt bleibt. Diese Task ändert daran nichts. Sie sorgt nur dafür, dass ein früherer **Erfolg** länger verfügbar bleibt, als seine TTL erlaubt. Beides zusammen ergibt: Ausfälle werden nicht eingebrannt, Erfolge überdauern sie.
|
||||
**Wichtig zur Abgrenzung:** v0.9.2 cacht Ausfälle bewusst _nicht_ — ein `ImmichUnavailableError` wird nie gespeichert, damit die Galerie nach der Erholung nicht kaputt bleibt. Diese Task ändert daran nichts. Sie sorgt nur dafür, dass ein früherer **Erfolg** länger verfügbar bleibt, als seine TTL erlaubt. Beides zusammen ergibt: Ausfälle werden nicht eingebrannt, Erfolge überdauern sie.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `lib/cache.ts`
|
||||
- Create: `lib/__tests__/cache-stale.test.ts`
|
||||
- Modify: `lib/env.ts`, `.env.local.example`
|
||||
@@ -461,6 +465,7 @@ git commit -m "test: enable route-handler tests and cover admin guards and image
|
||||
- Modify: `lib/immich.ts`, `lib/__tests__/immich.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `ImmichUnavailableError` aus `lib/immich.ts` (in v0.9.2 eingeführt, Zeile 97).
|
||||
- Produces:
|
||||
- `cache.get<T>(key): T | null` — unverändert, nur frische Daten.
|
||||
@@ -664,19 +669,17 @@ Erwartung: alle 218 Tests grün. Schlägt `config-cache.test.ts` oder `immich.te
|
||||
In `lib/env.ts` im `Env`-Interface direkt nach `CACHE_TTL: number;` ergänzen:
|
||||
|
||||
```ts
|
||||
STALE_MAX_AGE: number;
|
||||
STALE_MAX_AGE: number;
|
||||
```
|
||||
|
||||
In `parseEnv()` nach dem `cacheTtl`-Block einfügen:
|
||||
|
||||
```ts
|
||||
// How long an expired cache entry may still be served while Immich is
|
||||
// unreachable. 0 disables the fallback.
|
||||
const staleMaxAgeStr = process.env.STALE_MAX_AGE;
|
||||
const staleMaxAge =
|
||||
staleMaxAgeStr && !isNaN(parseInt(staleMaxAgeStr, 10))
|
||||
? parseInt(staleMaxAgeStr, 10)
|
||||
: 86400; // 24 hours
|
||||
// How long an expired cache entry may still be served while Immich is
|
||||
// unreachable. 0 disables the fallback.
|
||||
const staleMaxAgeStr = process.env.STALE_MAX_AGE;
|
||||
const staleMaxAge =
|
||||
staleMaxAgeStr && !isNaN(parseInt(staleMaxAgeStr, 10)) ? parseInt(staleMaxAgeStr, 10) : 86400; // 24 hours
|
||||
```
|
||||
|
||||
Im Rückgabeobjekt nach `CACHE_TTL: Math.max(0, cacheTtl),` einfügen:
|
||||
@@ -688,7 +691,7 @@ Im Rückgabeobjekt nach `CACHE_TTL: Math.max(0, cacheTtl),` einfügen:
|
||||
In `lib/config/schema.ts` im `AppConfig`-Interface neben `cacheTtl` ergänzen:
|
||||
|
||||
```ts
|
||||
staleMaxAge: number;
|
||||
staleMaxAge: number;
|
||||
```
|
||||
|
||||
In `lib/config/index.ts` **beide** Rückgabepfade ergänzen — jeweils direkt nach `cacheTtl: env.CACHE_TTL * 1000,` (Zeilen 278 und 358):
|
||||
@@ -924,11 +927,13 @@ git commit -m "feat: serve stale cache entries while Immich is unavailable"
|
||||
**Warum:** Alle Galerieseiten sind `force-dynamic` und warten vollständig auf Immich. Mit `IMMICH_TIMEOUT_MS` (Default 15 s) kann diese Wartezeit im Störungsfall lang werden — ohne `loading.tsx` sieht der Besucher währenddessen nichts.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/loading.module.css`
|
||||
- Create: `app/loading.tsx`
|
||||
- Create: `app/[...path]/loading.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: Tokens aus `app/tokens.css`; die Grid-Variablen, die `app/[...path]/page.tsx` setzt.
|
||||
- Produces: nichts.
|
||||
|
||||
@@ -1060,6 +1065,7 @@ git commit -m "feat: add loading skeletons for dynamic gallery routes"
|
||||
## Task 4: Dokumentation
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `README.md`
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
|
||||
@@ -1,38 +1,39 @@
|
||||
# Theming
|
||||
|
||||
Control the visual identity of your gallery from `content/gallery.yaml`. Choose a built-in preset or customize individual properties — no CSS editing required.
|
||||
Control the visual identity of your gallery from `content/settings.yaml`. Choose a built-in preset or customize individual properties — no CSS editing required.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Add a single line to your `gallery.yaml`:
|
||||
Add a single line to your `settings.yaml`:
|
||||
|
||||
```yaml
|
||||
theme: minimal
|
||||
```
|
||||
|
||||
That's it. Your entire gallery switches to the minimal theme.
|
||||
That's it. Your entire gallery switches to the minimal theme. Leave it out and
|
||||
you get `studio-modern`, the default.
|
||||
|
||||
## Built-in Presets
|
||||
|
||||
| Preset | Style | Accent | Fonts | Hero | Frame | Grain |
|
||||
| ----------------- | ------------------------------ | ------------ | --------------------------------- | ----------- | ------------ | ----- |
|
||||
| **studio** | Leica-inspired, editorial | 🔴 `#e60012` | Playfair Display + DM Sans | Split | Passepartout | ✓ |
|
||||
| **studio-modern** | Leica precision, grotesque | 🔴 `#e60012` | Archivo + IBM Plex Mono | Split | None | ✗ |
|
||||
| **studio** | Leica-inspired, editorial | 🔴 `#e60012` | Playfair Display + DM Sans | Split | Passepartout | ✓ |
|
||||
| **minimal** | Swiss brutalist, high contrast | ⚫ `#000000` | Geist + IBM Plex Mono | Fullbleed | None | ✗ |
|
||||
| **editorial** | Cinematic magazine, warm tones | 🟤 `#8B2500` | Bodoni Moda + Newsreader | Split | Shadow | ✗ |
|
||||
| **classic** | Gilded gallery, warm luxury | 🟡 `#c49a3c` | Cinzel + Crimson Pro | Minimal | Passepartout | ✗ |
|
||||
| **noir** | Darkroom analog, film noir | 🟠 `#ff6b35` | Libre Baskerville + Source Sans 3 | Fullbleed | Passepartout | ✓ |
|
||||
| **monograph** | Typographic, book-like | ⬛ `#333333` | Instrument Serif + Inter | Typographic | None | ✗ |
|
||||
|
||||
Default is `studio` if no theme is specified.
|
||||
**`studio-modern` is the default** if no theme is specified.
|
||||
|
||||
### Theme Gallery
|
||||
|
||||
_From left to right, top to bottom: Studio, Studio Modern, Minimal, Editorial, Classic, Noir, Monograph_
|
||||
_From left to right, top to bottom: Studio Modern, Studio, Minimal, Editorial, Classic, Noir, Monograph_
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/theme-studio-home.png" width="49%" alt="Studio theme" />
|
||||
<img src="screenshots/theme-studio-modern-home.png" width="49%" alt="Studio Modern theme" />
|
||||
<img src="screenshots/theme-studio-home.png" width="49%" alt="Studio theme" />
|
||||
<img src="screenshots/theme-minimal-home.png" width="49%" alt="Minimal theme" />
|
||||
<img src="screenshots/theme-editorial-home.png" width="49%" alt="Editorial theme" />
|
||||
<img src="screenshots/theme-classic-home.png" width="49%" alt="Classic theme" />
|
||||
@@ -45,8 +46,8 @@ _From left to right, top to bottom: Studio, Studio Modern, Minimal, Editorial, C
|
||||
_Same order as above._
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/theme-studio-grid.png" width="49%" alt="Studio grid" />
|
||||
<img src="screenshots/theme-studio-modern-grid.png" width="49%" alt="Studio Modern grid" />
|
||||
<img src="screenshots/theme-studio-grid.png" width="49%" alt="Studio grid" />
|
||||
<img src="screenshots/theme-minimal-grid.png" width="49%" alt="Minimal grid" />
|
||||
<img src="screenshots/theme-editorial-grid.png" width="49%" alt="Editorial grid" />
|
||||
<img src="screenshots/theme-classic-grid.png" width="49%" alt="Classic grid" />
|
||||
@@ -67,9 +68,9 @@ their accent, fonts and framing in both modes.
|
||||
|
||||
### What makes each theme unique
|
||||
|
||||
**Studio** (default) — Leica-inspired with a red nav dot, passepartout photo frames, film grain, and a split hero layout. Clean editorial feel with Playfair Display headings.
|
||||
**Studio Modern** (default) — The Leica language of `studio` rebuilt around the precision grotesque Archivo, with IBM Plex Mono for every piece of photographic metadata (EXIF chips, counters, indices). Hairline rules at 8% opacity, zero radius, an indexed hero nav with album counts, an always-visible caption bar under album covers, and a film-edge EXIF strip in the lightbox. Red is signal only — nav dot, active state, hover, indices, map markers.
|
||||
|
||||
**Studio Modern** — The Leica language of `studio` rebuilt around the precision grotesque Archivo, with IBM Plex Mono for every piece of photographic metadata (EXIF chips, counters, indices). Hairline rules at 8% opacity, zero radius, an indexed hero nav with album counts, an always-visible caption bar under album covers, and a film-edge EXIF strip in the lightbox. Red is signal only — nav dot, active state, hover, indices, map markers.
|
||||
**Studio** — Leica-inspired with a red nav dot, passepartout photo frames, film grain, and a split hero layout. Clean editorial feel with Playfair Display headings.
|
||||
|
||||
**Minimal** — Swiss Brutalist aesthetic inspired by Müller-Brockmann and Dieter Rams. Fullbleed hero image, true black/white palette, zero-gap photo grid with no hover effects, ultra-tiny navigation text. Every pixel earns its place.
|
||||
|
||||
@@ -87,7 +88,7 @@ Start from any preset and override individual properties:
|
||||
|
||||
```yaml
|
||||
theme:
|
||||
preset: studio # base preset to extend (default: studio)
|
||||
preset: studio # base preset to extend (default: studio-modern)
|
||||
accent: '#2563eb' # brand/accent color (any hex)
|
||||
fonts:
|
||||
heading: 'Inter' # Google Fonts name for headings
|
||||
@@ -97,7 +98,7 @@ theme:
|
||||
photoFrame: none # "none" | "passepartout" | "shadow"
|
||||
grain: false # film grain overlay on photos
|
||||
headerDot: false # accent-colored dot in the nav bar
|
||||
heroStyle: split # "split" | "fullbleed" | "minimal" | "stacked" | "typographic" | "mosaic"
|
||||
heroStyle: split # "split" | "fullbleed" | "minimal" | "stacked" | "typographic" | "mosaic" | "cover"
|
||||
```
|
||||
|
||||
All properties are optional — omitted values fall back to the preset defaults.
|
||||
@@ -150,6 +151,7 @@ Controls the homepage hero layout:
|
||||
- **`stacked`** — full-viewport hero image with title gradient-overlaid at the bottom, horizontal thumbnail navigation strip below
|
||||
- **`typographic`** — no hero image; massive centered title with numbered album navigation list (used by Monograph)
|
||||
- **`mosaic`** — asymmetric multi-image grid with frosted-glass title overlay centered on top
|
||||
- **`cover`** _(experimental)_ — a splash screen: one fullscreen image, the site title and subtitle, and a single **Enter** link into the first navigation entry. The rest of the homepage is not rendered — visitors arrive at a title page and click through.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -183,15 +185,18 @@ Controls the photo grid layout on album pages:
|
||||
- **`showcase`** — first image displayed at full width (16:9), rest in standard grid
|
||||
- **`filmstrip`** — horizontal scroll of tall vertical image strips with scroll snapping
|
||||
- **`editorial-flow`** — alternating full-width (21:9) and side-by-side (4:3) image pairs
|
||||
- **`justified`** _(experimental)_ — row-based: every row fills the container width and all images in a row share one height, so aspect ratios stay intact and nothing is cropped. `columns` sets the density — more columns give shorter rows. The last row keeps its natural sizes rather than stretching.
|
||||
- **`essay`** — not a grid: renders the page as a photo essay. See **[Journal & Photo Essays](journal.md)**.
|
||||
|
||||
Set it globally in `settings.yaml`, or per subpage in `gallery.yaml`:
|
||||
Set it globally in `settings.yaml`, per subpage in `gallery.yaml`, or on a
|
||||
single album (experimental) — the three merge, most specific winning:
|
||||
|
||||
```yaml
|
||||
grid:
|
||||
layout: showcase
|
||||
```
|
||||
|
||||
The same album in all five layouts (theme `studio-modern`):
|
||||
The same album in five of the grid layouts (theme `studio-modern`):
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseEssayMarkdown, parseFrontmatter, sanitizeHtml, renderInlineMarkdown, serializeEssayMarkdown } from '../essay';
|
||||
import {
|
||||
parseEssayMarkdown,
|
||||
parseFrontmatter,
|
||||
sanitizeHtml,
|
||||
renderInlineMarkdown,
|
||||
serializeEssayMarkdown,
|
||||
} from '../essay';
|
||||
|
||||
describe('Essay Parser', () => {
|
||||
it('sanitizes script tags to prevent XSS', () => {
|
||||
@@ -20,7 +26,9 @@ describe('Essay Parser', () => {
|
||||
const rendered = renderInlineMarkdown(text);
|
||||
expect(rendered).toContain('<strong>bold</strong>');
|
||||
expect(rendered).toContain('<em>italic</em>');
|
||||
expect(rendered).toContain('<a href="https://example.com" target="_blank" rel="noopener noreferrer">link</a>');
|
||||
expect(rendered).toContain(
|
||||
'<a href="https://example.com" target="_blank" rel="noopener noreferrer">link</a>',
|
||||
);
|
||||
});
|
||||
|
||||
it('parses frontmatter correctly', () => {
|
||||
@@ -111,8 +119,17 @@ A closing paragraph with **bold** details.`;
|
||||
{ type: 'heading' as const, level: 1, text: 'Chapter One' },
|
||||
{ type: 'paragraph' as const, html: 'This is <strong>bold</strong> text.' },
|
||||
{ type: 'quote' as const, text: 'A great quote', author: 'Person' },
|
||||
{ type: 'photo' as const, assetId: 'photo-1', caption: 'Caption', layout: 'fullbleed' as const },
|
||||
{ type: 'photo-pair' as const, assetIds: ['p1', 'p2'] as [string, string], caption: 'Pair caption' },
|
||||
{
|
||||
type: 'photo' as const,
|
||||
assetId: 'photo-1',
|
||||
caption: 'Caption',
|
||||
layout: 'fullbleed' as const,
|
||||
},
|
||||
{
|
||||
type: 'photo-pair' as const,
|
||||
assetIds: ['p1', 'p2'] as [string, string],
|
||||
caption: 'Pair caption',
|
||||
},
|
||||
],
|
||||
referencedAssetIds: ['photo-1', 'p1', 'p2'],
|
||||
};
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
serializeJournalMarkdown,
|
||||
sanitizeHtml,
|
||||
renderInlineMarkdown,
|
||||
calculateReadingTime,
|
||||
extractExcerpt,
|
||||
isValidSlug,
|
||||
sanitizeSlug,
|
||||
} from '../journal';
|
||||
@@ -95,8 +93,15 @@ Es war ein stürmischer Tag am Fjord.
|
||||
expect(parsed.frontmatter.coverAssetId).toBe('cover-uuid-1');
|
||||
|
||||
expect(parsed.blocks).toHaveLength(5);
|
||||
expect(parsed.blocks[0]).toEqual({ type: 'heading', level: 1, text: 'Kapitel 1: Die Reise beginnt' });
|
||||
expect(parsed.blocks[1]).toEqual({ type: 'paragraph', html: 'Es war ein stürmischer Tag am Fjord.' });
|
||||
expect(parsed.blocks[0]).toEqual({
|
||||
type: 'heading',
|
||||
level: 1,
|
||||
text: 'Kapitel 1: Die Reise beginnt',
|
||||
});
|
||||
expect(parsed.blocks[1]).toEqual({
|
||||
type: 'paragraph',
|
||||
html: 'Es war ein stürmischer Tag am Fjord.',
|
||||
});
|
||||
expect(parsed.blocks[2]).toEqual({
|
||||
type: 'quote',
|
||||
text: 'Das Licht im Norden ist unvergleichlich.',
|
||||
|
||||
50
lib/__tests__/proofing-resolution.test.ts
Normal file
50
lib/__tests__/proofing-resolution.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Mock env.ts so importing config.ts doesn't trigger validation
|
||||
vi.mock('@/lib/env', () => ({
|
||||
env: {
|
||||
IMMICH_API_URL: 'http://localhost:2283',
|
||||
IMMICH_API_KEY: 'test-key',
|
||||
SITE_TITLE: 'Test Gallery',
|
||||
SITE_SUBTITLE: '',
|
||||
CACHE_TTL: 300,
|
||||
IMMICH_TIMEOUT_MS: 15000,
|
||||
RATE_LIMIT_RPM: 120,
|
||||
TRUSTED_PROXY_HOPS: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
import { resolveProofing } from '@/lib/config';
|
||||
|
||||
/**
|
||||
* Client proofing hands a gallery to a client and takes their picks back, so
|
||||
* whether it is on is a delivery decision, not a cosmetic one. The controls
|
||||
* were previously mounted unconditionally and these settings were inert; these
|
||||
* cover the precedence now that they are honoured.
|
||||
*/
|
||||
describe('resolveProofing', () => {
|
||||
it('falls back to the global setting when the subpage says nothing', () => {
|
||||
expect(resolveProofing(undefined, true)).toBe(true);
|
||||
expect(resolveProofing(undefined, false)).toBe(false);
|
||||
expect(resolveProofing({}, true)).toBe(true);
|
||||
expect(resolveProofing({}, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('lets a subpage switch proofing on against a global off', () => {
|
||||
// The client-handover case: proofing off across a public portfolio, on for
|
||||
// the one subpage that is a delivery.
|
||||
expect(resolveProofing({ proofing: true }, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('lets a subpage switch proofing off against a global on', () => {
|
||||
// The regression guard: `||` instead of `??` would silently discard this,
|
||||
// leaving hearts on a page that explicitly asked for none.
|
||||
expect(resolveProofing({ proofing: false }, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats an explicit false as a decision, not as absence', () => {
|
||||
// Same distinction stated directly — `proofing: false` must not read as
|
||||
// "unset" and inherit the global default.
|
||||
expect(resolveProofing({ proofing: false }, true)).not.toBe(resolveProofing(undefined, true));
|
||||
});
|
||||
});
|
||||
47
lib/__tests__/theme-default.test.ts
Normal file
47
lib/__tests__/theme-default.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveTheme, DEFAULT_PRESET, THEME_PRESETS } from '@/lib/config/theme';
|
||||
|
||||
/**
|
||||
* The preset a site gets when it never picked one. Three separate code paths
|
||||
* fall back to it — no settings.yaml at all, a settings.yaml with no `theme`
|
||||
* key, and a `theme` object that overrides properties without naming a preset
|
||||
* — and each used to carry its own hard-coded literal. These lock the three to
|
||||
* one constant so a future change cannot move some of them and leave the rest.
|
||||
*/
|
||||
describe('default theme preset', () => {
|
||||
it('names a preset that exists', () => {
|
||||
expect(THEME_PRESETS[DEFAULT_PRESET]).toBeDefined();
|
||||
expect(THEME_PRESETS[DEFAULT_PRESET].preset).toBe(DEFAULT_PRESET);
|
||||
});
|
||||
|
||||
it('applies when no theme is configured at all', () => {
|
||||
expect(resolveTheme(undefined).preset).toBe(DEFAULT_PRESET);
|
||||
});
|
||||
|
||||
it('applies when a theme object omits the preset', () => {
|
||||
// Overriding a property must not silently switch the base preset.
|
||||
const theme = resolveTheme({ accent: '#123456' });
|
||||
expect(theme.preset).toBe(DEFAULT_PRESET);
|
||||
expect(theme.accent).toBe('#123456');
|
||||
expect(theme.fonts.heading).toBe(THEME_PRESETS[DEFAULT_PRESET].fonts.heading);
|
||||
});
|
||||
|
||||
it('is still overridable by an explicit preset', () => {
|
||||
expect(resolveTheme('noir').preset).toBe('noir');
|
||||
expect(resolveTheme({ preset: 'studio' }).preset).toBe('studio');
|
||||
});
|
||||
|
||||
it('keeps every documented preset resolvable', () => {
|
||||
for (const name of [
|
||||
'studio-modern',
|
||||
'studio',
|
||||
'minimal',
|
||||
'editorial',
|
||||
'classic',
|
||||
'noir',
|
||||
'monograph',
|
||||
]) {
|
||||
expect(resolveTheme(name).preset).toBe(name);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -80,7 +80,10 @@ function findPassword(key: string, type: 'subpage' | 'album' | 'journal'): strin
|
||||
/**
|
||||
* Check if a subpage slug, album ID, or journal entry is password-protected.
|
||||
*/
|
||||
export function isProtected(key: string, type: 'subpage' | 'album' | 'journal' = 'subpage'): boolean {
|
||||
export function isProtected(
|
||||
key: string,
|
||||
type: 'subpage' | 'album' | 'journal' = 'subpage',
|
||||
): boolean {
|
||||
return !!findPassword(key, type);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { env } from '../env';
|
||||
import { resolveAuthSecret } from '../secret';
|
||||
import { getInstallCredentials } from '../install';
|
||||
import { loadYaml, clearYamlCache, validateUuid } from './parser';
|
||||
import { resolveTheme, VALID_LAYOUTS } from './theme';
|
||||
import { resolveTheme, VALID_LAYOUTS, DEFAULT_PRESET } from './theme';
|
||||
import { ALBUM_SORT_MODES, isAlbumSortMode, type AlbumSortMode } from '../albumSort';
|
||||
import {
|
||||
slugify,
|
||||
@@ -76,6 +76,26 @@ export function buildSubpageGrid(raw?: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether client proofing is on for a page.
|
||||
*
|
||||
* Precedence is subpage → global, the same shape `buildSubpageGrid` gives the
|
||||
* grid: a subpage's own `proofing:` wins in either direction, and a page
|
||||
* reached without one follows `proofing.enabled` from settings.yaml. `??`
|
||||
* rather than `||` is load-bearing — `proofing: false` on a subpage has to
|
||||
* override a global `true`, which `||` would discard.
|
||||
*
|
||||
* Photo essays deliberately do not use this: a published story is not an album
|
||||
* handover, so it requires an explicit `proofing: true` and ignores the global
|
||||
* default. That exception lives at its call site in app/[...path]/page.tsx.
|
||||
*/
|
||||
export function resolveProofing(
|
||||
subpage: { proofing?: boolean } | undefined,
|
||||
globalEnabled: boolean,
|
||||
): boolean {
|
||||
return subpage?.proofing ?? globalEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* EXPERIMENTAL: keep only nav links that are safe to render as header <a>
|
||||
* tags. Non-http(s) schemes (javascript:, data:) and incomplete entries are
|
||||
@@ -359,7 +379,7 @@ export function getConfig(): AppConfig {
|
||||
heroImages: [],
|
||||
exifOnHover: true,
|
||||
grid: { columns: 3, gap: 12, aspectRatio: '1', layout: 'masonry' },
|
||||
theme: resolveTheme('studio'),
|
||||
theme: resolveTheme(DEFAULT_PRESET),
|
||||
footer: null,
|
||||
legal: { enabled: false, name: '', address: '', zipCity: '', country: '' },
|
||||
map: false,
|
||||
|
||||
@@ -88,13 +88,7 @@ export interface GridConfig {
|
||||
gap: number;
|
||||
aspectRatio: string;
|
||||
layout:
|
||||
| 'masonry'
|
||||
| 'uniform'
|
||||
| 'showcase'
|
||||
| 'filmstrip'
|
||||
| 'editorial-flow'
|
||||
| 'essay'
|
||||
| 'justified';
|
||||
'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay' | 'justified';
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import type { ThemeConfig, SettingsYaml } from './schema';
|
||||
|
||||
/** The preset used when none is configured. */
|
||||
export const DEFAULT_PRESET = 'studio-modern';
|
||||
|
||||
export const THEME_PRESETS: Record<string, ThemeConfig> = {
|
||||
studio: {
|
||||
preset: 'studio',
|
||||
accent: '#e60012',
|
||||
fonts: { heading: 'Playfair Display', body: 'DM Sans', caption: 'EB Garamond' },
|
||||
radius: 0,
|
||||
photoFrame: 'passepartout',
|
||||
grain: true,
|
||||
headerDot: true,
|
||||
heroStyle: 'split',
|
||||
},
|
||||
'studio-modern': {
|
||||
preset: 'studio-modern',
|
||||
accent: '#e60012',
|
||||
@@ -21,6 +14,16 @@ export const THEME_PRESETS: Record<string, ThemeConfig> = {
|
||||
headerDot: true,
|
||||
heroStyle: 'split',
|
||||
},
|
||||
studio: {
|
||||
preset: 'studio',
|
||||
accent: '#e60012',
|
||||
fonts: { heading: 'Playfair Display', body: 'DM Sans', caption: 'EB Garamond' },
|
||||
radius: 0,
|
||||
photoFrame: 'passepartout',
|
||||
grain: true,
|
||||
headerDot: true,
|
||||
heroStyle: 'split',
|
||||
},
|
||||
minimal: {
|
||||
preset: 'minimal',
|
||||
accent: '#000000',
|
||||
@@ -94,7 +97,7 @@ export const VALID_LAYOUTS = [
|
||||
];
|
||||
|
||||
export function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig {
|
||||
if (!raw) return { ...THEME_PRESETS.studio };
|
||||
if (!raw) return { ...THEME_PRESETS[DEFAULT_PRESET] };
|
||||
|
||||
if (typeof raw === 'string') {
|
||||
const preset = THEME_PRESETS[raw];
|
||||
@@ -106,7 +109,7 @@ export function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig {
|
||||
return { ...preset };
|
||||
}
|
||||
|
||||
const baseName = raw.preset ?? 'studio';
|
||||
const baseName = raw.preset ?? DEFAULT_PRESET;
|
||||
const base = THEME_PRESETS[baseName];
|
||||
if (!base) {
|
||||
throw new Error(
|
||||
|
||||
@@ -21,8 +21,6 @@ export interface Env {
|
||||
}
|
||||
|
||||
function parseEnv(): Env {
|
||||
const errors: string[] = [];
|
||||
|
||||
const urlRaw = process.env.IMMICH_API_URL;
|
||||
let apiUrl = '';
|
||||
if (urlRaw) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import yaml from 'js-yaml';
|
||||
import { env } from './env';
|
||||
import { generateScryptHash } from './password';
|
||||
import type { GalleryYaml, SettingsYaml } from './config/schema';
|
||||
import { DEFAULT_PRESET } from './config/theme';
|
||||
|
||||
/** The directory user content is written into. Overridable for tests. */
|
||||
function installContentDir(): string {
|
||||
@@ -251,7 +252,7 @@ export async function completeInstall(input: InstallInput): Promise<void> {
|
||||
const settings: SettingsYaml = {
|
||||
title: input.siteTitle?.trim() || 'Gallery',
|
||||
subtitle: input.siteSubtitle?.trim() || '',
|
||||
theme: { preset: input.theme || 'studio' },
|
||||
theme: { preset: input.theme || DEFAULT_PRESET },
|
||||
};
|
||||
|
||||
const data: InstallFileData = {
|
||||
|
||||
@@ -27,10 +27,7 @@ export function encodeProofBitmask(indices: number[]): string {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "immich-lightbox",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "immich-lightbox",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-lightbox",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
168
scripts/header.ts
Normal file
168
scripts/header.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* README Header Collage Generator
|
||||
*
|
||||
* Composes the single hero image at the top of README.md out of screenshots
|
||||
* that already exist in docs/screenshots/. One picture that answers "what is
|
||||
* this?" in the three ways a visitor cares about: the public gallery, the
|
||||
* photo grid, and the admin panel behind it.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/header.ts
|
||||
*
|
||||
* Run scripts/screenshots.ts first if the source shots are missing or stale —
|
||||
* this script only composes, it never captures.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const SCREENSHOT_DIR = path.join(process.cwd(), 'docs', 'screenshots');
|
||||
const OUTPUT = path.join(SCREENSHOT_DIR, 'header.png');
|
||||
|
||||
/** Canvas and spacing. Sized so the result stays legible at README width. */
|
||||
const CANVAS = { width: 2400, height: 1000 };
|
||||
const PADDING = 28;
|
||||
const GAP = 20;
|
||||
|
||||
/** Near-black, matching the studio-modern preset the shots are taken in. */
|
||||
const BACKGROUND = { r: 13, g: 13, b: 13, alpha: 1 };
|
||||
/** Hairline around each tile, so neighbouring dark screenshots stay distinct. */
|
||||
const BORDER = { r: 42, g: 42, b: 42, alpha: 1 };
|
||||
|
||||
interface Tile {
|
||||
file: string;
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Which part survives the crop — screenshots read best from the top. */
|
||||
position: 'top' | 'centre';
|
||||
/**
|
||||
* Optional region of the source to use instead of the whole page. A dense
|
||||
* UI shrunk to tile size turns into unreadable mush; taking a smaller region
|
||||
* and scaling it less keeps the interface recognisable.
|
||||
*/
|
||||
crop?: { left: number; top: number; width: number; height: number };
|
||||
}
|
||||
|
||||
function layout(): Tile[] {
|
||||
const innerHeight = CANVAS.height - PADDING * 2;
|
||||
// One large tile carrying the gallery, two stacked beside it. The large one
|
||||
// takes roughly the golden share of the width; the rest goes to the column.
|
||||
const largeWidth = 1480;
|
||||
const columnLeft = PADDING + largeWidth + GAP;
|
||||
const columnWidth = CANVAS.width - columnLeft - PADDING;
|
||||
const columnTileHeight = Math.floor((innerHeight - GAP) / 2);
|
||||
|
||||
return [
|
||||
{
|
||||
file: 'theme-studio-modern-home.png',
|
||||
left: PADDING,
|
||||
top: PADDING,
|
||||
width: largeWidth,
|
||||
height: innerHeight,
|
||||
position: 'top',
|
||||
},
|
||||
{
|
||||
file: 'theme-studio-modern-grid.png',
|
||||
left: columnLeft,
|
||||
top: PADDING,
|
||||
width: columnWidth,
|
||||
height: columnTileHeight,
|
||||
position: 'top',
|
||||
},
|
||||
{
|
||||
file: 'admin-page-builder.png',
|
||||
left: columnLeft,
|
||||
top: PADDING + columnTileHeight + GAP,
|
||||
width: columnWidth,
|
||||
height: columnTileHeight,
|
||||
position: 'top',
|
||||
// The page builder is the densest shot in the set. Full width, but only
|
||||
// the upper region: tab bar, save bar and the hero thumbnails. Scaling
|
||||
// ~1000px of source instead of 1440 keeps the labels readable.
|
||||
crop: { left: 0, top: 0, width: 1030, height: 564 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* sharp ships with Next.js but is not a declared dependency, and 0.35 moved
|
||||
* from `export =` to ESM — the namespace stopped being callable and the
|
||||
* factory moved to `.default`. Derive the shape from the installed package
|
||||
* rather than pinning it, exactly as scripts/screenshots.ts does.
|
||||
*/
|
||||
async function loadSharp() {
|
||||
type SharpFactory = typeof import('sharp') extends { default: infer Factory }
|
||||
? Factory
|
||||
: typeof import('sharp');
|
||||
const mod = (await import('sharp')) as unknown as { default?: SharpFactory };
|
||||
return mod.default ?? (mod as unknown as SharpFactory);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const tiles = layout();
|
||||
|
||||
const missing = tiles
|
||||
.map((t) => t.file)
|
||||
.filter((f) => !fs.existsSync(path.join(SCREENSHOT_DIR, f)));
|
||||
if (missing.length > 0) {
|
||||
console.error(
|
||||
`\n❌ Missing source screenshots:\n ${missing.join('\n ')}\n\n` +
|
||||
` Run \`npx tsx scripts/screenshots.ts\` against a running site first.\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let sharp: Awaited<ReturnType<typeof loadSharp>>;
|
||||
try {
|
||||
sharp = await loadSharp();
|
||||
} catch {
|
||||
console.error('\n❌ sharp is not available — cannot compose the header.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const composites = await Promise.all(
|
||||
tiles.map(async (tile) => {
|
||||
// Reserve a pixel on every side for the hairline, so the visible
|
||||
// screenshot lands exactly on the tile geometry above.
|
||||
const pipeline = sharp(path.join(SCREENSHOT_DIR, tile.file));
|
||||
if (tile.crop) pipeline.extract(tile.crop);
|
||||
|
||||
const inner = await pipeline
|
||||
.resize(tile.width - 2, tile.height - 2, {
|
||||
fit: 'cover',
|
||||
position: tile.position === 'top' ? 'top' : 'centre',
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
const bordered = await sharp(inner)
|
||||
.extend({ top: 1, bottom: 1, left: 1, right: 1, background: BORDER })
|
||||
.toBuffer();
|
||||
|
||||
return { input: bordered, left: tile.left, top: tile.top };
|
||||
}),
|
||||
);
|
||||
|
||||
await sharp({
|
||||
create: {
|
||||
width: CANVAS.width,
|
||||
height: CANVAS.height,
|
||||
channels: 4,
|
||||
background: BACKGROUND,
|
||||
},
|
||||
})
|
||||
.composite(composites)
|
||||
.png({ compressionLevel: 9, effort: 10 })
|
||||
.toFile(OUTPUT);
|
||||
|
||||
const kb = Math.round(fs.statSync(OUTPUT).size / 1024);
|
||||
console.log(`\n🎉 Header written to docs/screenshots/header.png (${kb} KB)`);
|
||||
console.log(` ${CANVAS.width}x${CANVAS.height}, composed from:`);
|
||||
for (const tile of tiles) console.log(` · ${tile.file}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Documentation Screenshot Generator
|
||||
*
|
||||
* Captures every screenshot referenced by README.md and docs/theming.md:
|
||||
* theme presets, hero styles, grid layouts, light mode, the standard pages
|
||||
* (subpage overview, about, map), the lightbox with its EXIF panel, a mobile
|
||||
* viewport and — if ADMIN_PASSWORD is set — the admin panel.
|
||||
* Captures every screenshot referenced by README.md, docs/theming.md and
|
||||
* docs/journal.md: theme presets, hero styles, grid layouts, light mode, the
|
||||
* standard pages (subpage overview, about, map), the lightbox with its EXIF
|
||||
* panel, a mobile viewport and — if ADMIN_PASSWORD is set — the admin panel
|
||||
* and the journal.
|
||||
*
|
||||
* Uses Playwright to wait for images to fully load (not just blurhash placeholders).
|
||||
*
|
||||
@@ -13,6 +14,21 @@
|
||||
* SCREENSHOT_GRID_PATH=/japan/koyasan-2023 npx tsx scripts/screenshots.ts
|
||||
* SCREENSHOT_PORT=3100 npx tsx scripts/screenshots.ts
|
||||
* SCREENSHOT_ONLY=admin,lightbox npx tsx scripts/screenshots.ts
|
||||
* SCREENSHOT_ONLY=journal SCREENSHOT_JOURNAL_ALBUM="Yufuin" npx tsx scripts/screenshots.ts
|
||||
*
|
||||
* The journal section writes a throwaway entry built from a real album's
|
||||
* photos, shoots the public index, the entry and the studio, then deletes it.
|
||||
* SCREENSHOT_JOURNAL_ALBUM picks the album by name; without it the largest one
|
||||
* is used — which is usually an import dump rather than a presentable set, so
|
||||
* set it whenever the output is going to be committed. Whatever it names ends
|
||||
* up in a published screenshot at full size: pick an album you would show a
|
||||
* stranger, not one that happens to be large.
|
||||
*
|
||||
* Three further entries are written from albums matched by name, so the index
|
||||
* shot shows a list rather than one lonely card. One of them is a draft and
|
||||
* one carries a password, so the admin list shows the states the guide
|
||||
* describes. They are skipped on an installation without those albums — see
|
||||
* EXTRA_JOURNAL_ENTRIES.
|
||||
*
|
||||
* The album used for the grid shots is discovered by walking the running site,
|
||||
* since album slugs come from Immich album names. SCREENSHOT_GRID_PATH picks a
|
||||
@@ -83,6 +99,44 @@ const ABOUT_MD = path.join(CONTENT_DIR, 'about.md');
|
||||
const MANAGED_FILES = [SETTINGS_YAML, GALLERY_YAML, ABOUT_MD];
|
||||
const backupPath = (file: string) => `${file}.screenshot-bak`;
|
||||
|
||||
const JOURNAL_DIR = path.join(CONTENT_DIR, 'journal');
|
||||
/**
|
||||
* Slug of the throwaway entry written for the journal screenshots. It is
|
||||
* created before the shots and deleted afterwards, so a run never leaves a
|
||||
* demo story published on a real site. Namespaced to make an orphan from a
|
||||
* killed run obvious.
|
||||
*/
|
||||
const DEMO_JOURNAL_SLUG = 'screenshot-demo-entry';
|
||||
/**
|
||||
* Album to pull demo photos from, matched against the Immich album name.
|
||||
* Unset picks the album with the most photos, which reliably gives the entry
|
||||
* enough frames to fill a fullbleed, a pair and a contained block — but the
|
||||
* largest album on a real library is typically an unsorted import, so a run
|
||||
* whose output gets committed should always name one. The photos are published
|
||||
* at full size in the screenshots; nothing about them is anonymised, unlike the
|
||||
* site identity in DEMO_SETTINGS.
|
||||
*/
|
||||
const JOURNAL_ALBUM = process.env.SCREENSHOT_JOURNAL_ALBUM;
|
||||
/**
|
||||
* Companion entries, so the index shot shows a list of stories rather than a
|
||||
* single card on an otherwise empty page. Unlike the main entry — whose album
|
||||
* is chosen at run time and whose prose is therefore deliberately about
|
||||
* photographing rather than about a place — each of these is bound to one
|
||||
* album and its text describes that place, which only works while the two are
|
||||
* kept together. An installation without a matching album simply skips them
|
||||
* and the index shows the main entry alone.
|
||||
*/
|
||||
const EXTRA_JOURNAL_ENTRIES: {
|
||||
slug: string;
|
||||
/** Matched against the Immich album name, case-insensitive substring. */
|
||||
album: string;
|
||||
markdown: (ids: string[]) => string;
|
||||
}[] = [
|
||||
{ slug: 'screenshot-demo-kurokawa', album: 'kurokawa', markdown: kurokawaJournalMarkdown },
|
||||
{ slug: 'screenshot-demo-kada', album: 'kada', markdown: kadaJournalMarkdown },
|
||||
{ slug: 'screenshot-demo-koyasan', album: 'koyasan', markdown: koyasanJournalMarkdown },
|
||||
];
|
||||
|
||||
/** Pages that never carry a photo grid — skipped while looking for an album. */
|
||||
const EXCLUDED_PATHS = new Set(['/', '/about', '/map', '/impressum', '/admin']);
|
||||
const VIEWPORT = { width: 1440, height: 900 };
|
||||
@@ -237,6 +291,12 @@ async function main() {
|
||||
await applySettings({ 'theme.preset': SHOWCASE_THEME });
|
||||
await captureAdmin(browser);
|
||||
}
|
||||
|
||||
// ── Journal (public views + studio) ──
|
||||
if (wanted('journal')) {
|
||||
await applySettings({ 'theme.preset': SHOWCASE_THEME });
|
||||
await captureJournal(browser);
|
||||
}
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await stopDevServer();
|
||||
@@ -417,9 +477,7 @@ async function captureAdmin(browser: Browser): Promise<void> {
|
||||
await page.locator('.admin-login-card').waitFor({ timeout: 15000 });
|
||||
await capture(page, 'admin-login');
|
||||
|
||||
await page.locator('.admin-login-card input[type="password"]').fill(ADMIN_PASSWORD);
|
||||
await page.locator('.admin-login-card button[type="submit"]').click();
|
||||
await page.locator('.admin-tabs').waitFor({ timeout: 20000 });
|
||||
await adminLogin(page);
|
||||
await page.waitForTimeout(2500);
|
||||
await waitForImages(page);
|
||||
await capture(page, 'admin-page-builder');
|
||||
@@ -436,6 +494,363 @@ async function captureAdmin(browser: Browser): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Journal: the public index and entry, plus the studio behind them.
|
||||
*
|
||||
* Needs an admin session for two separate reasons — the studio shots are
|
||||
* behind the login, and the demo entry can only be written with *raw* Immich
|
||||
* asset UUIDs, which the public site never exposes (it hands out encrypted
|
||||
* tokens). The admin API is the only place those UUIDs are reachable, so the
|
||||
* entry is generated from a real album after logging in.
|
||||
*
|
||||
* The entry is deleted again in the `finally`, including when a capture throws.
|
||||
*/
|
||||
async function captureJournal(browser: Browser): Promise<void> {
|
||||
section('Journal: index, entry, studio');
|
||||
if (!ADMIN_PASSWORD) {
|
||||
console.log(' ⏭ ADMIN_PASSWORD not set — skipping the journal screenshots.');
|
||||
return;
|
||||
}
|
||||
|
||||
const entryPath = path.join(JOURNAL_DIR, `${DEMO_JOURNAL_SLUG}.md`);
|
||||
if (fs.existsSync(entryPath)) {
|
||||
console.log(` ⏭ ${DEMO_JOURNAL_SLUG}.md already exists — leaving it alone.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const written: string[] = [];
|
||||
try {
|
||||
await withPage(browser, VIEWPORT, async (page) => {
|
||||
await adminLogin(page);
|
||||
|
||||
const assetIds = await demoAssetIds(page);
|
||||
if (assetIds.length < 4) {
|
||||
console.log(' ⏭ Not enough photos found for a demo entry — skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(JOURNAL_DIR, { recursive: true });
|
||||
fs.writeFileSync(entryPath, demoJournalMarkdown(assetIds));
|
||||
written.push(entryPath);
|
||||
await writeExtraEntries(page, written);
|
||||
await sleep(CONFIG_RELOAD_DELAY);
|
||||
|
||||
// Studio first: the session is already open on this page.
|
||||
await capturePage(page, '/admin/journal', 'admin-journal-list', 1500);
|
||||
await capturePage(page, `/admin/journal/${DEMO_JOURNAL_SLUG}`, 'admin-journal-studio', 3000);
|
||||
await captureStudioModals(page);
|
||||
});
|
||||
|
||||
// Public views in a fresh context, so the admin cookie cannot leak in and
|
||||
// reveal draft-only chrome in a shot meant to show what a visitor sees.
|
||||
await withPage(browser, VIEWPORT, async (page) => {
|
||||
await capturePage(page, '/journal', 'journal-index', 2500);
|
||||
await captureJournalEntry(page);
|
||||
});
|
||||
} finally {
|
||||
for (const file of written) {
|
||||
if (fs.existsSync(file)) {
|
||||
fs.rmSync(file, { force: true });
|
||||
console.log(` 🧹 Removed the demo entry (${path.basename(file)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The frontmatter form, which the studio hides behind its Story Settings
|
||||
* button and which no other shot reaches. Best effort: a renamed button costs
|
||||
* this one shot, not the run.
|
||||
*
|
||||
* The studio's other dialog, the photo picker, is deliberately not captured.
|
||||
* Its tabs are Favorites and All Photos — both show the operator's own library
|
||||
* rather than a chosen album, and these screenshots are published.
|
||||
*/
|
||||
async function captureStudioModals(page: Page): Promise<void> {
|
||||
try {
|
||||
await page
|
||||
.getByRole('button', { name: /story settings/i })
|
||||
.first()
|
||||
.click();
|
||||
await page.locator('.journal-modal-card').waitFor({ timeout: 5000 });
|
||||
await waitForImages(page);
|
||||
await page.waitForTimeout(800);
|
||||
await capture(page, 'admin-journal-settings');
|
||||
await page
|
||||
.getByRole('button', { name: /^done$/i })
|
||||
.first()
|
||||
.click();
|
||||
await page.locator('.journal-modal-card').waitFor({ state: 'hidden', timeout: 5000 });
|
||||
} catch {
|
||||
console.log(' ⏭ Story Settings dialog did not open — skipping that shot.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The album-bound companion entries. Each is skipped without failing the run
|
||||
* when its album is missing (another installation) or when a file with that
|
||||
* slug already exists (an author's own entry is never overwritten).
|
||||
*/
|
||||
async function writeExtraEntries(page: Page, written: string[]): Promise<void> {
|
||||
for (const extra of EXTRA_JOURNAL_ENTRIES) {
|
||||
const file = path.join(JOURNAL_DIR, `${extra.slug}.md`);
|
||||
if (fs.existsSync(file)) {
|
||||
console.log(` ⏭ ${extra.slug}.md already exists — leaving it alone.`);
|
||||
continue;
|
||||
}
|
||||
const photos = await demoAlbumPhotos(page, extra.album);
|
||||
if (!photos || photos.ids.length < 4) continue;
|
||||
const picked = spreadSample(photos.ids, 4);
|
||||
if (photos.coverId) picked[0] = photos.coverId;
|
||||
fs.writeFileSync(file, extra.markdown(picked));
|
||||
written.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rendered entry, scrolled to the quote so the shot shows the block types
|
||||
* the guide describes. The top of the page is only a title and a cover image,
|
||||
* which says nothing about the journal that the index shot does not already
|
||||
* show; the quote sits between the prose and a photo block, so framing it puts
|
||||
* three of the block types in one viewport.
|
||||
*/
|
||||
async function captureJournalEntry(page: Page): Promise<void> {
|
||||
await page.goto(`${BASE_URL}/journal/${DEMO_JOURNAL_SLUG}`, { waitUntil: 'networkidle' });
|
||||
await revealDeferredContent(page);
|
||||
await waitForImages(page);
|
||||
const quote = page.locator('.essay-quote').first();
|
||||
if ((await quote.count()) > 0) {
|
||||
await quote.scrollIntoViewIfNeeded();
|
||||
// Lift the quote to the upper third, so the photo block below it is in frame.
|
||||
await page.evaluate(() => window.scrollBy(0, -180));
|
||||
await waitForImages(page);
|
||||
}
|
||||
await page.waitForTimeout(3000);
|
||||
await capture(page, 'journal-entry');
|
||||
}
|
||||
|
||||
/** Log in and wait for the panel chrome. Shared by the admin and journal runs. */
|
||||
async function adminLogin(page: Page): Promise<void> {
|
||||
await page.goto(`${BASE_URL}/admin`, { waitUntil: 'networkidle' });
|
||||
await page.locator('.admin-login-card input[type="password"]').fill(ADMIN_PASSWORD!);
|
||||
await page.locator('.admin-login-card button[type="submit"]').click();
|
||||
await page.locator('.admin-tabs').waitFor({ timeout: 20000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw asset UUIDs for the demo entry, via the admin API. `page.request` shares
|
||||
* the context's cookies, so the session from adminLogin() carries over.
|
||||
*/
|
||||
async function demoAssetIds(page: Page, albumName = JOURNAL_ALBUM): Promise<string[]> {
|
||||
return (await demoAlbumPhotos(page, albumName))?.ids ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The matched album's photos plus the cover its owner picked in Immich, which
|
||||
* is a better opening image for an entry than whatever happens to be first in
|
||||
* capture order.
|
||||
*/
|
||||
async function demoAlbumPhotos(
|
||||
page: Page,
|
||||
albumName = JOURNAL_ALBUM,
|
||||
): Promise<{ ids: string[]; coverId?: string } | null> {
|
||||
type AdminAlbum = {
|
||||
id: string;
|
||||
albumName: string;
|
||||
assetCount: number;
|
||||
thumbnailAssetId?: string | null;
|
||||
};
|
||||
const albumsRes = await page.request.get(`${BASE_URL}/api/admin/albums`);
|
||||
if (!albumsRes.ok()) return null;
|
||||
const albums = (await albumsRes.json()) as { albums?: AdminAlbum[] } | AdminAlbum[];
|
||||
const list = Array.isArray(albums) ? albums : (albums.albums ?? []);
|
||||
if (list.length === 0) return null;
|
||||
|
||||
const wantedName = albumName?.toLowerCase();
|
||||
const album = wantedName
|
||||
? list.find((a) => a.albumName.toLowerCase().includes(wantedName))
|
||||
: [...list].sort((a, b) => (b.assetCount ?? 0) - (a.assetCount ?? 0))[0];
|
||||
if (!album) {
|
||||
console.log(` ⏭ No album matching "${albumName}" — skipping that entry.`);
|
||||
return null;
|
||||
}
|
||||
console.log(` 🔎 Demo entry photos from: ${album.albumName}`);
|
||||
|
||||
const assetsRes = await page.request.get(`${BASE_URL}/api/admin/albums/${album.id}/assets`);
|
||||
if (!assetsRes.ok()) return null;
|
||||
const payload = (await assetsRes.json()) as
|
||||
{ assets?: { id: string; type?: string }[] } | { id: string; type?: string }[];
|
||||
const assets = Array.isArray(payload) ? payload : (payload.assets ?? []);
|
||||
// Videos render a player, not a photo block.
|
||||
const ids = assets.filter((a) => (a.type ?? 'IMAGE').toUpperCase() === 'IMAGE').map((a) => a.id);
|
||||
return { ids, coverId: album.thumbnailAssetId ?? undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* A demo entry exercising every block type the parser knows — heading,
|
||||
* paragraph, quote with attribution, fullbleed photo, photo pair, contained
|
||||
* photo — so the studio and the rendered page both show something
|
||||
* representative rather than a single paragraph.
|
||||
*
|
||||
* The prose is deliberately about *photographing* rather than about any
|
||||
* particular place. The source album is chosen with SCREENSHOT_JOURNAL_ALBUM
|
||||
* and could be a coastline, a city or a mountain range; text naming a
|
||||
* landscape would contradict the photos beside it in whatever album the
|
||||
* operator picks. Captions are written the same way.
|
||||
*/
|
||||
function demoJournalMarkdown(ids: string[]): string {
|
||||
const [cover, fullbleed, pairA, pairB, ...rest] = ids;
|
||||
const contained = rest[0];
|
||||
return `---
|
||||
title: "Notes from a Week of Photographing"
|
||||
subtitle: "Seven days, one camera, and what came back from it"
|
||||
author: "Jane Doe"
|
||||
date: "2026-03-14"
|
||||
coverAssetId: "${cover}"
|
||||
---
|
||||
|
||||
# Setting Out
|
||||
|
||||
Packing for a week means deciding in advance what kind of pictures you intend
|
||||
to make. One body, two lenses, and the quiet certainty that both choices will
|
||||
turn out to have been slightly wrong by the second afternoon.
|
||||
|
||||

|
||||
|
||||
## Finding the Light
|
||||
|
||||
The early hours were the only reliable ones. By midday the contrast had
|
||||
collapsed and every exposure came back flatter than it had looked through the
|
||||
viewfinder — so the middle of each day became the part spent walking, looking,
|
||||
and not raising the camera at all.
|
||||
|
||||
> A photograph does not record what you saw. It records what was there, which
|
||||
> is a slower and more honest thing. -- A note from the second evening
|
||||
|
||||

|
||||
|
||||
### What Stayed
|
||||
|
||||
Not the wide views, in the end, but the repetitions — the same twenty minutes
|
||||
of light, the same short walk back, the same handful of frames worth keeping
|
||||
out of a few hundred.
|
||||
${contained ? `\n\n` : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `count` photos spread over the album rather than its first few. Albums are
|
||||
* in capture order, so the opening frames are the journey there — a station,
|
||||
* a train window — which makes a poor cover for an entry about the place. The
|
||||
* first fifth is skipped for the same reason.
|
||||
*/
|
||||
function spreadSample(ids: string[], count: number): string[] {
|
||||
const start = Math.floor(ids.length / 5);
|
||||
const usable = ids.slice(start);
|
||||
const step = Math.max(1, Math.floor(usable.length / count));
|
||||
return Array.from({ length: count }, (_, i) => usable[Math.min(i * step, usable.length - 1)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Companion entry for the Kurokawa Onsen album. Shorter than the main one: it
|
||||
* exists to fill the index, and only its card is ever screenshot. The prose
|
||||
* stays at the level of the place and the season, since which frames it gets
|
||||
* depends on the album's order.
|
||||
*/
|
||||
function kurokawaJournalMarkdown(ids: string[]): string {
|
||||
const [cover, fullbleed, pairA, pairB] = ids;
|
||||
return `---
|
||||
title: "Three Days in Kurokawa Onsen"
|
||||
subtitle: "A village built into a river gorge, photographed between baths"
|
||||
author: "Jane Doe"
|
||||
date: "2025-11-22"
|
||||
coverAssetId: "${cover}"
|
||||
---
|
||||
|
||||
# Down to the River
|
||||
|
||||
The village sits in the cut of a small river, dark wood and tiled roofs
|
||||
stacked up both banks, and every path eventually leads back down to the water.
|
||||
Late November had the maples going over at different speeds — one tree still
|
||||
green, the next one already halfway to bare.
|
||||
|
||||

|
||||
|
||||
## Between Baths
|
||||
|
||||
You buy a wooden pass and it lets you into three of the baths, so the days
|
||||
arrange themselves around walking from one to the next. Most of the frames here
|
||||
were made on those walks, in the hour before it got dark and the lanterns came
|
||||
on along the river.
|
||||
|
||||

|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Companion entry for the Kada album — see kurokawaJournalMarkdown(). Carries
|
||||
* a password so the admin list shows the lock marker its documentation
|
||||
* describes. `demo` is plaintext on purpose: the entry lives for the length of
|
||||
* one run, and a scrypt hash here would only hide what the shot is meant to
|
||||
* show. Real entries should store a hash.
|
||||
*/
|
||||
function kadaJournalMarkdown(ids: string[]): string {
|
||||
const [cover, fullbleed, pairA, pairB] = ids;
|
||||
return `---
|
||||
title: "Kada, Out of Season"
|
||||
subtitle: "A fishing harbour on the Wakayama coast, mostly at 200mm"
|
||||
author: "Jane Doe"
|
||||
date: "2023-10-19"
|
||||
password: "demo"
|
||||
coverAssetId: "${cover}"
|
||||
---
|
||||
|
||||
# The Harbour Road
|
||||
|
||||
Kada is a working harbour before it is anything else: crates stacked against
|
||||
the sea wall, rope coiled where it was dropped, boats tied up in the middle of
|
||||
a weekday. Nothing is arranged for a visitor, which is most of why it is worth
|
||||
photographing.
|
||||
|
||||

|
||||
|
||||
## At 200mm
|
||||
|
||||
A long lens turned out to be the right choice here. It kept the distance
|
||||
honest — the roof tiles, the bare tree behind them, the cats asleep on warm
|
||||
concrete all stayed where they were instead of being walked up to.
|
||||
|
||||

|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Companion entry for the Koyasan album, kept as a draft. The admin list
|
||||
* documents a Draft pill and the public index documents that drafts are hidden
|
||||
* from it — neither is visible while every demo entry is published. Being a
|
||||
* draft it never reaches the public index shot, so it also does not crowd it.
|
||||
*/
|
||||
function koyasanJournalMarkdown(ids: string[]): string {
|
||||
const [cover, fullbleed] = ids;
|
||||
return `---
|
||||
title: "Koyasan, First Notes"
|
||||
subtitle: "Rough cut — still choosing which frames survive"
|
||||
author: "Jane Doe"
|
||||
date: "2023-10-08"
|
||||
draft: true
|
||||
coverAssetId: "${cover}"
|
||||
---
|
||||
|
||||
# Under the Cedars
|
||||
|
||||
The old graveyard runs for two kilometres under cedars big enough to make the
|
||||
path feel indoors, and the stone figures along it are dressed and looked after
|
||||
by someone. Notes for now — the light changed every few minutes and I want to
|
||||
see the frames on a proper screen before deciding what this is about.
|
||||
|
||||

|
||||
`;
|
||||
}
|
||||
|
||||
/** Best effort: the picker is a modal behind a button whose label may change. */
|
||||
async function captureAlbumPicker(page: Page): Promise<void> {
|
||||
const trigger = page.getByRole('button', { name: /add album/i }).first();
|
||||
|
||||
Reference in New Issue
Block a user