chore(release): v0.10.0 (#400)
Photo Essay mode, client proofing, studio-modern preset, admin panel overhaul, watermark, cookieless analytics, subpage toggle, SEO title templates. Analytics API now admin-only, Trivy scan on publish. Album order mirrors the Immich timeline; map theme, admin status panel, lightbox counter and Docker health check fixed. See CHANGELOG.md for the full list and the health-check upgrade note.
6
.gitignore
vendored
@@ -62,6 +62,12 @@ docker-compose.override.yml
|
||||
content/settings.yaml
|
||||
content/gallery.yaml
|
||||
content/about.md
|
||||
# Von der App zur Laufzeit geschrieben: yaml-service legt bis zu 10 Backups je
|
||||
# Datei ab, und die Backups von gallery.yaml/settings.yaml wuerden genau den
|
||||
# Inhalt ins Repo tragen, den die drei Zeilen darueber heraushalten sollen.
|
||||
content/.backups/
|
||||
content/analytics.json
|
||||
content/*.screenshot-bak
|
||||
|
||||
# ── Internal Dev Docs ────────────────────────────────
|
||||
docs/dev/
|
||||
|
||||
111
CHANGELOG.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented here.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
Releases up to and including v0.9.2 are documented in the
|
||||
[GitHub releases](https://github.com/ralksta/immich-folio/releases).
|
||||
|
||||
## [0.10.0] — 2026-08-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Photo Essay mode** — storytelling pages with an RSC markdown parser and
|
||||
fullbleed/pair layouts, plus a visual essay block builder in the admin panel.
|
||||
- **Client proofing** — favorite selection with compact URL bitmasking and an
|
||||
export modal.
|
||||
- **Subpage enable toggle** — deactivate a subpage without deleting it.
|
||||
- **SEO** — configurable subpage title template, metadata descriptions, and
|
||||
`generateMetadata` on the about page.
|
||||
- **New theme preset `studio-modern`.** The Leica language of `studio` rebuilt
|
||||
around the precision grotesque Archivo, with IBM Plex Mono for every piece of
|
||||
photographic metadata. Hairline rules, zero radius, an indexed hero navigation
|
||||
with album counts, an always-visible caption bar under album covers, and a
|
||||
film-edge EXIF strip in the lightbox. Selectable in the admin panel, the dev
|
||||
toolbar, and `content/settings.yaml`. See [docs/theming.md](docs/theming.md).
|
||||
|
||||
### Security
|
||||
|
||||
- `GET /api/admin/analytics` now requires an admin session.
|
||||
- Docker images are scanned with Trivy on every publish; results land in the
|
||||
GitHub Security tab.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Album sort order now mirrors the Immich timeline** ([#350](https://github.com/ralksta/immich-folio/issues/350)). Albums were
|
||||
sorted by `fileCreatedAt`, the capture instant in UTC. Immich sorts its
|
||||
timeline primarily by `localDateTime` — the capture time in the
|
||||
photographer's local zone — and uses `fileCreatedAt` only as a tie-breaker.
|
||||
The two keys are identical for the vast majority of albums; they diverge for
|
||||
albums spanning time zones, where the order shown in Immich Folio no longer
|
||||
matched the Immich UI. The sort keys were changed accordingly, and
|
||||
unparseable dates now fall back to `0` instead of `NaN` (a `NaN` comparator
|
||||
makes `sort()` free to return any permutation).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Cache staleness** — `?fresh=1` bypass, an admin diagnostic banner for albums
|
||||
Immich returns empty, and revalidation after admin saves.
|
||||
- **Admin status panel reported two faults that were not faults** ([#341](https://github.com/ralksta/immich-folio/issues/341)).
|
||||
`settings.yaml` was treated as mandatory although `getConfig()` falls back to
|
||||
defaults without it, so anyone running with only a `gallery.yaml` saw a
|
||||
permanent "Config Integrity: Degraded". Only a file that exists and cannot be
|
||||
parsed counts as degraded now. A failed status request was also rendered as if
|
||||
it were a result — an expired admin session looked identical to a real
|
||||
outage — so there is now a third state for "the check did not run".
|
||||
- **Lightbox showed its photo counter twice.** The counter renders a
|
||||
screen-reader label ("Photo 3 of 53") alongside the compact display
|
||||
("3 / 53"), but `.sr-only` was not defined in any stylesheet, so both strings
|
||||
were visible on top of each other.
|
||||
- **Docker** — health check targets `127.0.0.1` instead of `localhost`, base
|
||||
image moved to `node:22-alpine`.
|
||||
- **Docs** — the "nature / travel journal" example in `docs/theming.md` used a
|
||||
preset named `botanica` that does not exist; copying it produced an
|
||||
`Unknown theme preset` error. It now uses `editorial`.
|
||||
- **Map page ignored the theme.** `app/map/map.css` referenced custom properties
|
||||
that never existed (`--font-heading`, `--font-body`, `--border-color`,
|
||||
`--radius`), so the map title, popups, and container fell back to browser
|
||||
defaults instead of the configured fonts, borders, and radius. This affects
|
||||
every preset — the map now picks up the theme like the rest of the site.
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
**No migration is required.** No configuration schema changed, and the six
|
||||
existing presets render exactly as before. Pull the new image, restart, done.
|
||||
|
||||
**Rolling back after switching presets needs one edit.** If you select
|
||||
"Studio Modern" and later downgrade to a version that predates it, the
|
||||
`theme.preset: studio-modern` left in `content/settings.yaml` is a preset the
|
||||
older version does not know, and config loading fails with:
|
||||
|
||||
```
|
||||
Unknown theme preset "studio-modern". Valid presets: studio, minimal, editorial, classic, noir, monograph
|
||||
```
|
||||
|
||||
Set `theme.preset` back to one of the listed presets in `content/settings.yaml`
|
||||
before downgrading. `/admin` stays reachable if you hit this, so the change can
|
||||
also be made after the fact.
|
||||
|
||||
**If you copied `docker-compose.override.yml.example`, update your health
|
||||
check by hand.** The example now uses `wget -O /dev/null` instead of
|
||||
`--spider`, but your own `docker-compose.override.yml` is not tracked by git,
|
||||
so pulling the new version does not touch it — and a health check defined
|
||||
there overrides the one baked into the image. `--spider` does not read the
|
||||
response body and disconnects mid-RSC-stream; when that abort coincides with a
|
||||
slow render after `CACHE_TTL` expiry, Node tears down the `TransformStream`
|
||||
controller while Next.js is still writing into it:
|
||||
|
||||
```
|
||||
TypeError: controller[kState].transformAlgorithm is not a function
|
||||
```
|
||||
|
||||
Replace `"--spider"` with `"-O", "/dev/null"` in your override file:
|
||||
|
||||
```yaml
|
||||
healthcheck:
|
||||
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '-O', '/dev/null', 'http://127.0.0.1:7211/']
|
||||
```
|
||||
|
||||
A full GET checks the same thing without the race. Installations without an
|
||||
own health check need no action — the image ships the fixed one.
|
||||
@@ -36,7 +36,7 @@ Configuration is loaded once at startup (cached in a module-level singleton, re-
|
||||
- `lib/env.ts` — parses and validates all environment variables into a typed `Env` object. All env access in the codebase must go through this module.
|
||||
- `lib/config/schema.ts` — TypeScript types for `AppConfig`, `GalleryYaml`, `SettingsYaml`, `SubpageConfig`, `GridConfig`, `ThemeConfig`, etc., plus the `slugify()` utility.
|
||||
- `lib/config/index.ts` — `getConfig()`: reads `content/gallery.yaml` and `content/settings.yaml`, merges them into a single `AppConfig`. Also exports `buildSubpageGrid()` and re-exports from schema/theme.
|
||||
- `lib/config/theme.ts` — six built-in theme presets (`studio`, `minimal`, `editorial`, `classic`, `noir`, `monograph`) and `resolveTheme()` which merges partial overrides over a preset.
|
||||
- `lib/config/theme.ts` — seven built-in theme presets (`studio`, `studio-modern`, `minimal`, `editorial`, `classic`, `noir`, `monograph`) and `resolveTheme()` which merges partial overrides over a preset.
|
||||
|
||||
`getConfig()` returns a `needsSetup: true` dummy config (instead of throwing) when `gallery.yaml` or credentials are missing — this lets the app render a `SetupScreen` instead of crashing.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine AS base
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
# ── Dependencies ──────────────────────────────────
|
||||
FROM base AS deps
|
||||
@@ -51,8 +51,12 @@ EXPOSE 7211
|
||||
ENV PORT=7211
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# 127.0.0.1 statt localhost: der Server bindet auf 0.0.0.0 (siehe HOSTNAME oben),
|
||||
# lauscht also nur auf IPv4. In Alpine loest 'localhost' zuerst nach ::1 auf, und
|
||||
# wget bleibt bei diesem ersten Ergebnis — der Check schlaegt dauerhaft mit
|
||||
# "Connection refused" fehl und der Container gilt als unhealthy.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:7211/api/health || exit 1
|
||||
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:7211/api/health || exit 1
|
||||
|
||||
# Run as root initially so entrypoint can fix bind-mount permissions,
|
||||
# then drops to nextjs user via su-exec
|
||||
|
||||
83
README.md
@@ -5,9 +5,55 @@ A self-hosted photography portfolio powered by [Immich](https://immich.app). Tur
|
||||
Immich Folio acts as a **secure reverse proxy** between your visitors and your private Immich instance. Your Immich server stays on your local network, completely invisible to the outside world.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/theme-editorial-home.png" width="49%" alt="Homepage — Editorial theme" />
|
||||
<img src="docs/screenshots/theme-editorial-grid.png" width="49%" alt="Photo grid — Editorial theme" />
|
||||
<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>
|
||||
|
||||
## 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" />
|
||||
</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
|
||||
|
||||
**Upgrading:** no migration, no configuration change — pull the new image and restart. One exception: if you copied `docker-compose.override.yml.example` in the past, its health check needs a one-line edit. See the [upgrade notes](CHANGELOG.md#upgrade-notes) for that and for the rollback caveat when switching presets.
|
||||
|
||||
### Security releases
|
||||
|
||||
Security fixes ship in normal releases, so **running the latest release is the recommended baseline**:
|
||||
|
||||
- **v0.10.0** — `GET /api/admin/analytics` now requires an admin session; Docker images are scanned with Trivy on every publish
|
||||
- **v0.9.2** — closed a timing side channel that leaked the admin password length ([#395](https://github.com/ralksta/immich-folio/pull/395))
|
||||
- **v0.9.0** — fixed pre-auth bypasses and restored Immich 3.x compatibility ([#378](https://github.com/ralksta/immich-folio/pull/378))
|
||||
|
||||
Full history in the [GitHub releases](https://github.com/ralksta/immich-folio/releases) and [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
## Features
|
||||
|
||||
@@ -29,13 +75,26 @@ Immich Folio acts as a **secure reverse proxy** between your visitors and your p
|
||||
- **Auto-generated slugs** — URL slugs derived from album names automatically
|
||||
- **YAML gallery config** — all gallery structure defined in a single `content/gallery.yaml` file
|
||||
- **Markdown about page** — `content/about.md` with frontmatter for portrait, name, location, and gear list
|
||||
- **Photo Essay mode** — long-form storytelling pages alternating text with fullbleed and paired image layouts
|
||||
- **Client proofing** — clients favorite photos and export the selection; picks are encoded in the URL, nothing is stored server-side
|
||||
- **Lightbox watermark** — configurable overlay on fullscreen images
|
||||
- **Privacy-friendly analytics** — cookieless view counts, no third parties, can be switched off
|
||||
- **Dynamic OG images** — auto-generated social preview images per album
|
||||
|
||||
<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" />
|
||||
</p>
|
||||
<p align="center"><em>The Markdown about page and the GPS map, both generated from your Immich data.</em></p>
|
||||
|
||||
### Admin Panel
|
||||
|
||||
- **Visual page builder** — drag & drop interface to manage hero images, standalone albums, subpages, and sections
|
||||
- **Album picker** — browse all shared Immich albums with search, see photo counts, and add them with one click
|
||||
- **Settings editor** — configure theme, grid layout, footer, legal/impressum, and SEO from a visual UI
|
||||
- **Visual previews everywhere** — grid layout, theme, photo frame, hero style and the Google search snippet are picked from preview cards instead of text fields
|
||||
- **Essay block editor** — assemble photo essays block by block without touching Markdown
|
||||
- **Backup manager** — every save is backed up automatically; restore any of them with one click
|
||||
- **Live YAML sync** — changes are written directly to `gallery.yaml` and `settings.yaml` with automatic backups
|
||||
- **Password protected** — secured with its own admin password, separate from album passwords
|
||||
|
||||
@@ -45,7 +104,7 @@ Immich Folio acts as a **secure reverse proxy** between your visitors and your p
|
||||
<br>
|
||||
|
||||
| Concern | Protection |
|
||||
| ----------------------- | --------------------------------------------------------------------- |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| **Server exposure** | Immich URL never leaves your network — all requests proxy server-side |
|
||||
| **API key** | Stored only in `.env.local`, never in client code |
|
||||
| **Asset IDs** | Immich UUIDs encrypted (AES-256) into opaque tokens |
|
||||
@@ -131,10 +190,10 @@ All gallery structure — hero images, albums, subpages, grid layout, footer —
|
||||
|
||||
### Theming
|
||||
|
||||
Six built-in presets with distinct visual identities — or mix and match with fine-grained control over colors, fonts, corners, photo frames, hero layout, and grid style.
|
||||
Seven built-in presets with distinct visual identities — or mix and match with fine-grained control over colors, fonts, corners, photo frames, hero layout, and grid style.
|
||||
|
||||
```yaml
|
||||
theme: studio # or: minimal, editorial, classic, noir, monograph
|
||||
theme: studio # or: studio-modern, minimal, editorial, classic, noir, monograph
|
||||
```
|
||||
|
||||
→ **[View all Themes & Configuration Guide](docs/theming.md)**
|
||||
@@ -226,7 +285,19 @@ A built-in visual editor at `/admin` lets you manage your gallery without editin
|
||||
ADMIN_PASSWORD=your-secure-admin-password
|
||||
```
|
||||
|
||||
Then navigate to `http://your-site/admin` and log in.
|
||||
Then navigate to `http://your-site/admin` and log in. The panel is protected by its
|
||||
own password, separate from any album passwords, and writes straight to
|
||||
`gallery.yaml` / `settings.yaml` with automatic backups.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/admin-login.png" width="49%" alt="Admin panel login screen" />
|
||||
<img src="docs/screenshots/admin-page-builder.png" width="49%" alt="Visual page builder with hero images, standalone albums and subpages" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/admin-album-picker.png" width="49%" alt="Album picker listing shared Immich albums with photo counts" />
|
||||
<img src="docs/screenshots/admin-settings.png" width="49%" alt="Settings editor with site identity and feature toggles" />
|
||||
</p>
|
||||
<p align="center"><em>Login · page builder · album picker · settings editor</em></p>
|
||||
|
||||
→ **[Admin Panel Guide](docs/admin-panel.md)**
|
||||
|
||||
|
||||
@@ -20,6 +20,31 @@ interface AlbumDetailViewProps {
|
||||
watermark?: LightboxWatermark;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra detail for the album header — shooting date and camera/lens, read off
|
||||
* the first asset that carries EXIF. Both are rendered in their own spans and
|
||||
* hidden by default; only presets that ask for them show them, so the header
|
||||
* keeps reading "86 photos" everywhere else.
|
||||
*/
|
||||
function albumMetaDetail(album: ImmichAlbum): { date?: string; gear?: string } {
|
||||
const first = album.assets.find((a) => a.exifInfo);
|
||||
const exif = first?.exifInfo;
|
||||
|
||||
const taken = exif?.dateTimeOriginal || first?.fileCreatedAt;
|
||||
const gear = [exif?.model, exif?.lensModel].filter(Boolean).join(' · ');
|
||||
|
||||
return {
|
||||
...(taken ? { date: formatMonthYear(taken) } : {}),
|
||||
...(gear ? { gear } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function formatMonthYear(iso: string): string | undefined {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return undefined;
|
||||
return `${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
export function AlbumDetailView({
|
||||
album,
|
||||
images,
|
||||
@@ -32,6 +57,8 @@ export function AlbumDetailView({
|
||||
heroBlurDataURL,
|
||||
watermark,
|
||||
}: AlbumDetailViewProps) {
|
||||
const metaDetail = albumMetaDetail(album);
|
||||
|
||||
return (
|
||||
<>
|
||||
{heroImageUrl && (
|
||||
@@ -49,6 +76,7 @@ export function AlbumDetailView({
|
||||
</div>
|
||||
)}
|
||||
<div className={`album-header${heroImageUrl ? ' album-header--has-hero' : ''}`}>
|
||||
<div className="album-header__main">
|
||||
<BackLink href={backLinkHref} label={backLinkLabel} />
|
||||
<h1 className="album-header__title">{album.albumName}</h1>
|
||||
{subtitle && (
|
||||
@@ -57,8 +85,17 @@ export function AlbumDetailView({
|
||||
</p>
|
||||
)}
|
||||
{album.description && <p className="album-header__description">{album.description}</p>}
|
||||
</div>
|
||||
<p className="album-header__meta">
|
||||
<span className="album-header__meta-count">
|
||||
{images.length} {images.length === 1 ? 'photo' : 'photos'}
|
||||
</span>
|
||||
{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} />
|
||||
|
||||
@@ -23,8 +23,13 @@ interface SubpageGridViewProps {
|
||||
albums: SubpageAlbum[];
|
||||
coverPlaceholders: (Placeholder | null)[];
|
||||
sections?: SubpageSectionConfig[];
|
||||
/** 1-based position among all subpages — feeds the "03 — Collection" kicker. */
|
||||
index?: number;
|
||||
}
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, '0');
|
||||
const photoCount = (n: number) => `${n} ${n === 1 ? 'photo' : 'photos'}`;
|
||||
|
||||
function AlbumGrid({
|
||||
albums,
|
||||
placeholderMap,
|
||||
@@ -36,7 +41,7 @@ function AlbumGrid({
|
||||
}) {
|
||||
return (
|
||||
<div className="subpage-grid">
|
||||
{albums.map((album) => {
|
||||
{albums.map((album, i) => {
|
||||
const ph = placeholderMap.get(album.id) ?? null;
|
||||
return (
|
||||
<Link
|
||||
@@ -44,8 +49,9 @@ function AlbumGrid({
|
||||
href={`/${slug}/${album.slug}`}
|
||||
className="subpage-grid__item"
|
||||
style={ph?.dominantColor ? { backgroundColor: ph.dominantColor } : undefined}
|
||||
aria-label={`${album.albumName}, ${album.assetCount} ${album.assetCount === 1 ? 'photo' : 'photos'}`}
|
||||
aria-label={`${album.albumName}, ${photoCount(album.assetCount)}`}
|
||||
>
|
||||
<span className="subpage-grid__item-media">
|
||||
{album.albumThumbnailAssetId ? (
|
||||
<Image
|
||||
src={imageUrl(album.albumThumbnailAssetId, 'preview')}
|
||||
@@ -56,21 +62,29 @@ function AlbumGrid({
|
||||
{...(ph ? { placeholder: 'blur' as const, blurDataURL: ph.blurDataURL } : {})}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
<span
|
||||
className="skeleton"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
style={{ display: 'block', width: '100%', height: '100%' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="subpage-grid__item-badge" aria-hidden="true">
|
||||
{album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'}
|
||||
{photoCount(album.assetCount)}
|
||||
</span>
|
||||
<div className="subpage-grid__item-overlay" aria-hidden="true">
|
||||
<span className="subpage-grid__item-overlay" aria-hidden="true">
|
||||
<span className="subpage-grid__item-title">{album.albumName}</span>
|
||||
<span className="subpage-grid__item-count">
|
||||
{album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'}
|
||||
<span className="subpage-grid__item-count">{photoCount(album.assetCount)}</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Always-visible caption bar — shown by presets that use it. */}
|
||||
<span className="subpage-grid__item-caption" aria-hidden="true">
|
||||
<span className="subpage-grid__item-index">{pad2(i + 1)}</span>
|
||||
<span className="subpage-grid__item-caption-title">{album.albumName}</span>
|
||||
<span className="subpage-grid__item-caption-count">
|
||||
{photoCount(album.assetCount)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@@ -85,19 +99,31 @@ export function SubpageGridView({
|
||||
albums,
|
||||
coverPlaceholders,
|
||||
sections,
|
||||
index,
|
||||
}: SubpageGridViewProps) {
|
||||
// Build lookup maps once
|
||||
const albumMap = new Map(albums.map((a) => [a.id, a]));
|
||||
const placeholderMap = new Map(albums.map((a, i) => [a.id, coverPlaceholders[i] ?? null]));
|
||||
|
||||
const hasSections = sections && sections.length > 0;
|
||||
const totalPhotos = albums.reduce((sum, a) => sum + a.assetCount, 0);
|
||||
|
||||
return (
|
||||
<div className="subpage-container">
|
||||
{(title || subtitle) && (
|
||||
<header className="subpage-header">
|
||||
<div className="subpage-header__main">
|
||||
{index !== undefined && (
|
||||
<p className="subpage-header__kicker" aria-hidden="true">
|
||||
{pad2(index)} — Collection
|
||||
</p>
|
||||
)}
|
||||
{title && <h1 className="subpage-title">{title}</h1>}
|
||||
{subtitle && <p className="subpage-subtitle">{subtitle}</p>}
|
||||
</div>
|
||||
<p className="subpage-header__meta" aria-hidden="true">
|
||||
{albums.length} {albums.length === 1 ? 'album' : 'albums'} · {photoCount(totalPhotos)}
|
||||
</p>
|
||||
</header>
|
||||
)}
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ import { cookies } from 'next/headers';
|
||||
import type { Metadata } from 'next';
|
||||
import { immich, type ImmichAsset } from '@/lib/immich';
|
||||
import { notFound } from 'next/navigation';
|
||||
// Only the type: rendering moved to AlbumDetailView/SubpageGridView, which
|
||||
// import the component themselves.
|
||||
import type { PhotoItem } from './PhotoGrid';
|
||||
import {
|
||||
imageUrl,
|
||||
@@ -28,7 +26,9 @@ import {
|
||||
import { encodeAssetId } from '@/lib/tokens';
|
||||
import { getConfig, type GridConfig } from '@/lib/config';
|
||||
import { isProtected, isAuthenticated } from '@/lib/auth';
|
||||
import { isAdminAuthenticated } from '@/lib/admin/auth';
|
||||
import PasswordGate from '@/components/PasswordGate';
|
||||
import { AdminDiagnosticBanner } from '@/components/AdminDiagnosticBanner';
|
||||
import { AlbumDetailView } from './AlbumDetailView';
|
||||
import { SubpageGridView } from './SubpageGridView';
|
||||
import { EssayView } from './EssayView';
|
||||
@@ -39,6 +39,7 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
interface PathPageProps {
|
||||
params: Promise<{ path: string[] }>;
|
||||
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PathPageProps): Promise<Metadata> {
|
||||
@@ -110,8 +111,6 @@ function toPhotoItems(assets: ImmichAsset[], showExif: boolean): PhotoItem[] {
|
||||
const exif = showExif && a.type === 'IMAGE' ? assetExifSummary(a) : undefined;
|
||||
const isVideo = a.type === 'VIDEO';
|
||||
return {
|
||||
// Opaque token, never the raw Immich UUID — this object is serialized
|
||||
// into the RSC payload and shipped to the browser. Used only as a React key.
|
||||
id: encodeAssetId(a.id),
|
||||
type: isVideo ? 'video' : 'image',
|
||||
thumbUrl: imageUrl(a.id, 'preview'),
|
||||
@@ -163,8 +162,11 @@ async function getAlbumHeroData(
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PathPage({ params }: PathPageProps) {
|
||||
export default async function PathPage({ params, searchParams }: PathPageProps) {
|
||||
const { path } = await params;
|
||||
const sParams = (await searchParams) || {};
|
||||
const forceFresh = sParams.fresh === '1' || sParams.preview === 'true';
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
// Build grid CSS custom properties, optionally merging subpage overrides
|
||||
@@ -191,14 +193,22 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
const gate = await gateIfProtected(subpageSlug);
|
||||
if (gate) return gate;
|
||||
|
||||
const album = await immich.getAlbumBySlug(albumSlug, subpageSlug);
|
||||
const album = await immich.getAlbumBySlug(albumSlug, subpageSlug, forceFresh);
|
||||
|
||||
if (!album) {
|
||||
if (await isAdminAuthenticated()) {
|
||||
return (
|
||||
<AdminDiagnosticBanner
|
||||
slug={`${subpageSlug}/${albumSlug}`}
|
||||
reason={`Album "${albumSlug}" could not be found or returned no assets from Immich.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Look up subpage config for grid overrides and back link
|
||||
const subpageData = await immich.getSubpageAlbums(subpageSlug);
|
||||
const subpageData = await immich.getSubpageAlbums(subpageSlug, forceFresh);
|
||||
const spGrid = subpageData?.subpage.grid;
|
||||
const subpageName = subpageData?.subpage.name ?? subpageSlug;
|
||||
|
||||
@@ -233,8 +243,18 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
const gate = await gateIfProtected(slug);
|
||||
if (gate) return gate;
|
||||
|
||||
const result = await immich.getSubpageAlbums(slug);
|
||||
const result = await immich.getSubpageAlbums(slug, forceFresh);
|
||||
if (!result || result.albums.length === 0) {
|
||||
if (await isAdminAuthenticated()) {
|
||||
const spConfig = config.subpages.find((sp) => sp.slug === slug);
|
||||
return (
|
||||
<AdminDiagnosticBanner
|
||||
slug={slug}
|
||||
subpageName={spConfig?.name}
|
||||
configuredAlbumCount={spConfig?.albumIds.length}
|
||||
/>
|
||||
);
|
||||
}
|
||||
notFound();
|
||||
}
|
||||
|
||||
@@ -255,7 +275,7 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
|
||||
// Fetch assets from all subpage albums
|
||||
const allAlbums = await Promise.all(
|
||||
albums.map((a) => immich.getAlbumBySlug(a.slug, slug))
|
||||
albums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh))
|
||||
);
|
||||
const allAssets = allAlbums.flatMap((a) => (a ? a.assets : []));
|
||||
const images = toPhotoItems(allAssets, config.exifOnHover);
|
||||
@@ -293,8 +313,20 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
|
||||
// ── Single album → full-bleed (skip album grid) ──────────
|
||||
if (albums.length === 1) {
|
||||
const album = await immich.getAlbumBySlug(albums[0].slug, slug);
|
||||
if (!album) notFound();
|
||||
const album = await immich.getAlbumBySlug(albums[0].slug, slug, forceFresh);
|
||||
if (!album) {
|
||||
if (await isAdminAuthenticated()) {
|
||||
return (
|
||||
<AdminDiagnosticBanner
|
||||
slug={slug}
|
||||
subpageName={result.subpage.name}
|
||||
configuredAlbumCount={result.subpage.albumIds.length}
|
||||
reason={`Single album "${albums[0].albumName}" was not found or returned 0 assets.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
notFound();
|
||||
}
|
||||
|
||||
const images = toPhotoItems(album.assets, config.exifOnHover);
|
||||
|
||||
@@ -336,6 +368,10 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
}),
|
||||
);
|
||||
|
||||
// 1-based position among the enabled subpages — drives the header kicker.
|
||||
const enabledSubpages = config.subpages.filter((sp) => sp.enabled !== false);
|
||||
const subpageIndex = enabledSubpages.findIndex((sp) => sp.slug === slug);
|
||||
|
||||
return (
|
||||
<SubpageGridView
|
||||
slug={slug}
|
||||
@@ -344,13 +380,22 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
albums={albumsWithHero}
|
||||
coverPlaceholders={coverPlaceholders}
|
||||
sections={result.subpage.sections}
|
||||
{...(subpageIndex >= 0 ? { index: subpageIndex + 1 } : {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise treat as a standalone album slug
|
||||
const album = await immich.getAlbumBySlug(slug);
|
||||
const album = await immich.getAlbumBySlug(slug, undefined, forceFresh);
|
||||
if (!album) {
|
||||
if (await isAdminAuthenticated()) {
|
||||
return (
|
||||
<AdminDiagnosticBanner
|
||||
slug={slug}
|
||||
reason={`Standalone album slug "${slug}" could not be found in published Immich albums.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,11 @@
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
/* Kicker above the name — opt-in per preset. */
|
||||
.about__kicker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.about__name {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 2.4rem;
|
||||
|
||||
@@ -96,6 +96,9 @@ export default async function AboutPage() {
|
||||
|
||||
{/* ── Text ─────────────────────────────────────── */}
|
||||
<div className="about__text-col">
|
||||
<p className="about__kicker" aria-hidden="true">
|
||||
About
|
||||
</p>
|
||||
{meta.name && <h1 className="about__name">{meta.name}</h1>}
|
||||
{meta.location && <p className="about__location">{meta.location}</p>}
|
||||
|
||||
|
||||
@@ -539,7 +539,9 @@
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.album-tile:hover {
|
||||
@@ -706,7 +708,10 @@
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.1s;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s,
|
||||
transform 0.1s;
|
||||
}
|
||||
|
||||
.subpage-tile:hover {
|
||||
@@ -771,8 +776,14 @@
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.subpage-detail-header {
|
||||
@@ -1118,8 +1129,11 @@
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.2s, box-shadow 0.2s;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
transition:
|
||||
transform 0.2s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.preset-card:hover {
|
||||
@@ -1243,6 +1257,12 @@
|
||||
animation: pulse-red 1.5s infinite;
|
||||
}
|
||||
|
||||
/* The diagnostics request itself did not run — deliberately not red, and not
|
||||
animated: an unanswered check is not a fault report. */
|
||||
.status-badge-btn.unknown .status-dot {
|
||||
background-color: var(--admin-text-secondary);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
@@ -1341,6 +1361,10 @@
|
||||
color: var(--admin-error, #ef4444);
|
||||
}
|
||||
|
||||
.status-val.unknown {
|
||||
color: var(--admin-text-secondary);
|
||||
}
|
||||
|
||||
/* ── Search Bar ────────────────────────────────────────────── */
|
||||
.builder-search-container {
|
||||
padding: 0.75rem 1.5rem;
|
||||
@@ -1399,15 +1423,27 @@
|
||||
|
||||
/* Keyframes */
|
||||
@keyframes pulse-green {
|
||||
0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-red {
|
||||
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Color Field */
|
||||
@@ -1530,7 +1566,9 @@
|
||||
cursor: pointer;
|
||||
background: var(--admin-bg-elevated);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.15s, transform 0.1s;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
transform 0.1s;
|
||||
}
|
||||
|
||||
.asset-picker-tile:hover:not(.used) {
|
||||
@@ -1739,7 +1777,9 @@
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
stroke-width: 2px;
|
||||
transition: transform 0.2s ease, color 0.15s ease;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.svg-drag {
|
||||
@@ -2147,7 +2187,9 @@ h3 .svg-icon {
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.section-card:hover {
|
||||
@@ -2805,7 +2847,10 @@ h3 .svg-icon {
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.color-swatch-btn:hover {
|
||||
@@ -2978,11 +3023,27 @@ h3 .svg-icon {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mini-aspect-box.ratio-1 { width: 34px; height: 34px; }
|
||||
.mini-aspect-box.ratio-3-2 { width: 46px; height: 30px; }
|
||||
.mini-aspect-box.ratio-2-3 { width: 30px; height: 44px; }
|
||||
.mini-aspect-box.ratio-16-9 { width: 50px; height: 28px; }
|
||||
.mini-aspect-box.ratio-auto { width: 42px; height: 32px; border-style: solid; }
|
||||
.mini-aspect-box.ratio-1 {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
.mini-aspect-box.ratio-3-2 {
|
||||
width: 46px;
|
||||
height: 30px;
|
||||
}
|
||||
.mini-aspect-box.ratio-2-3 {
|
||||
width: 30px;
|
||||
height: 44px;
|
||||
}
|
||||
.mini-aspect-box.ratio-16-9 {
|
||||
width: 50px;
|
||||
height: 28px;
|
||||
}
|
||||
.mini-aspect-box.ratio-auto {
|
||||
width: 42px;
|
||||
height: 32px;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.mini-aspect-inner {
|
||||
width: 100%;
|
||||
@@ -3021,9 +3082,15 @@ h3 .svg-icon {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.demo-tile.h-high { flex: 2; }
|
||||
.demo-tile.h-low { flex: 1; }
|
||||
.demo-tile.h-med { flex: 1.5; }
|
||||
.demo-tile.h-high {
|
||||
flex: 2;
|
||||
}
|
||||
.demo-tile.h-low {
|
||||
flex: 1;
|
||||
}
|
||||
.demo-tile.h-med {
|
||||
flex: 1.5;
|
||||
}
|
||||
|
||||
.demo-uniform-grid {
|
||||
display: grid;
|
||||
@@ -3038,7 +3105,9 @@ h3 .svg-icon {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.demo-tile.hero-tile { flex: 2; }
|
||||
.demo-tile.hero-tile {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.demo-filmstrip-row {
|
||||
display: flex;
|
||||
@@ -3154,8 +3223,12 @@ h3 .svg-icon {
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.subpage-drawer-header {
|
||||
@@ -3249,7 +3322,9 @@ h3 .svg-icon {
|
||||
transform: translateX(-50%);
|
||||
background: var(--admin-bg-raised, #1f1f1f);
|
||||
border: 1px solid var(--admin-border-hover, #444);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
box-shadow:
|
||||
0 10px 30px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
padding: 0.75rem 1.25rem;
|
||||
z-index: 900;
|
||||
@@ -3272,9 +3347,18 @@ h3 .svg-icon {
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(255, 152, 0, 0.7); }
|
||||
70% { transform: scale(1); box-shadow: 0 0 0 8px rgba(255, 152, 0, 0); }
|
||||
100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(255, 152, 0, 0); }
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 0 0 rgba(255, 152, 0, 0.7);
|
||||
}
|
||||
70% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 8px rgba(255, 152, 0, 0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 0 0 rgba(255, 152, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Analytics View ────────────────────────────────────────── */
|
||||
@@ -3546,9 +3630,194 @@ h3 .svg-icon {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Photo Essay & Storytelling Builder ───────────────────────── */
|
||||
|
||||
.essay-block-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.essay-toolbar-sticky {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--admin-bg-raised, #1a1a1a);
|
||||
border: 1px solid var(--admin-border, #2e2e2e);
|
||||
border-radius: 10px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.essay-toolbar-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--admin-text, #f0f0f0);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.essay-toolbar-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--admin-border, #2e2e2e);
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
.essay-block-card {
|
||||
background: var(--admin-bg-raised, #161616);
|
||||
border: 1px solid var(--admin-border, #2e2e2e);
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.essay-block-card:hover {
|
||||
border-color: var(--admin-border-hover, #444444);
|
||||
}
|
||||
|
||||
.essay-block-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--admin-border, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
.essay-block-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: var(--admin-bg-elevated, #222);
|
||||
color: var(--admin-text-secondary, #a0a0a0);
|
||||
border: 1px solid var(--admin-border, #2e2e2e);
|
||||
}
|
||||
|
||||
.essay-block-badge.badge-photo {
|
||||
background: rgba(10, 132, 255, 0.15);
|
||||
color: #64d2ff;
|
||||
border-color: rgba(10, 132, 255, 0.3);
|
||||
}
|
||||
|
||||
.essay-block-badge.badge-heading {
|
||||
background: rgba(191, 90, 242, 0.15);
|
||||
color: #da8fff;
|
||||
border-color: rgba(191, 90, 242, 0.3);
|
||||
}
|
||||
|
||||
.essay-block-badge.badge-quote {
|
||||
background: rgba(255, 159, 10, 0.15);
|
||||
color: #ffd60a;
|
||||
border-color: rgba(255, 159, 10, 0.3);
|
||||
}
|
||||
|
||||
.essay-block-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Polished dark inputs for Essay Editor */
|
||||
.essay-block-editor input[type='text'],
|
||||
.essay-block-editor textarea,
|
||||
.essay-block-editor select {
|
||||
background: var(--admin-bg-input, #111111) !important;
|
||||
border: 1px solid var(--admin-border, #2e2e2e) !important;
|
||||
border-radius: 6px !important;
|
||||
color: var(--admin-text, #f0f0f0) !important;
|
||||
font-size: 0.85rem !important;
|
||||
padding: 0.55rem 0.75rem !important;
|
||||
outline: none !important;
|
||||
transition: border-color 0.15s !important;
|
||||
}
|
||||
|
||||
.essay-block-editor input[type='text']:focus,
|
||||
.essay-block-editor textarea:focus,
|
||||
.essay-block-editor select:focus {
|
||||
border-color: var(--admin-border-focus, #888888) !important;
|
||||
}
|
||||
|
||||
.essay-photo-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
background: var(--admin-bg, #111111);
|
||||
padding: 0.85rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--admin-border, #2e2e2e);
|
||||
}
|
||||
|
||||
.essay-photo-preview-container {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.essay-photo-thumbnail {
|
||||
width: 120px;
|
||||
height: 80px;
|
||||
border-radius: 6px;
|
||||
background: var(--admin-bg-elevated, #222);
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--admin-border, #333);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.essay-photo-empty {
|
||||
width: 120px;
|
||||
height: 80px;
|
||||
border-radius: 6px;
|
||||
background: var(--admin-bg-elevated, #1a1a1a);
|
||||
border: 1px dashed var(--admin-border, #333);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--admin-text-muted, #707070);
|
||||
font-size: 0.75rem;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.essay-photo-meta {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.essay-pill-selector {
|
||||
display: inline-flex;
|
||||
background: var(--admin-bg-input, #000);
|
||||
padding: 2px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--admin-border, #2e2e2e);
|
||||
}
|
||||
|
||||
.essay-pill-btn {
|
||||
padding: 0.25rem 0.65rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--admin-text-muted, #888);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.essay-pill-btn.active {
|
||||
background: var(--admin-btn-primary-bg, #ffffff);
|
||||
color: var(--admin-btn-primary-text, #000000);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
const [showBackupModal, setShowBackupModal] = useState(false);
|
||||
const [status, setStatus] = useState<any>(null);
|
||||
const [statusLoading, setStatusLoading] = useState(false);
|
||||
// Distinct from `status === null`: the check itself did not run. Without this
|
||||
// an expired session or a 500 rendered exactly like a real outage — every
|
||||
// indicator flipped to its alarming value at once (#341).
|
||||
const [statusError, setStatusError] = useState(false);
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch('/api/admin/auth', { method: 'DELETE' });
|
||||
@@ -46,20 +50,41 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setStatus(data);
|
||||
setStatusError(false);
|
||||
} else {
|
||||
setStatus(null);
|
||||
setStatusError(true);
|
||||
}
|
||||
} catch {
|
||||
setStatus(null);
|
||||
setStatusError(true);
|
||||
} finally {
|
||||
setStatusLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Render one diagnostic as ok / bad / unknown. "Unknown" covers both the
|
||||
* in-flight check and a check that failed to run — neither is evidence that
|
||||
* the thing being checked is broken.
|
||||
*/
|
||||
const indicator = (healthy: boolean, okLabel: string, badLabel: string) => {
|
||||
if (statusLoading) return { className: 'unknown', label: 'Checking…' };
|
||||
if (statusError || !status) return { className: 'unknown', label: 'Unknown' };
|
||||
return healthy ? { className: 'ok', label: okLabel } : { className: 'error', label: badLabel };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
}, []);
|
||||
|
||||
const immichIndicator = indicator(
|
||||
status?.immich?.status === 'connected',
|
||||
'Connected',
|
||||
'Disconnected',
|
||||
);
|
||||
const configIndicator = indicator(status?.config?.status === 'valid', 'Valid', 'Degraded');
|
||||
|
||||
return (
|
||||
<div className="admin-dashboard">
|
||||
<header className="admin-header">
|
||||
@@ -90,7 +115,7 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
{/* Diagnostics Badge */}
|
||||
<div className="status-indicator-container">
|
||||
<button
|
||||
className={`status-badge-btn ${status?.immich?.status === 'connected' ? 'connected' : 'disconnected'}`}
|
||||
className={`status-badge-btn ${immichIndicator.className === 'ok' ? 'connected' : immichIndicator.className === 'error' ? 'disconnected' : 'unknown'}`}
|
||||
onClick={() => {
|
||||
setShowStatus(!showStatus);
|
||||
if (!showStatus) fetchStatus();
|
||||
@@ -99,7 +124,13 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
>
|
||||
<span className="status-dot"></span>
|
||||
<span className="status-text">
|
||||
{statusLoading ? 'Checking...' : status?.immich?.status === 'connected' ? 'System OK' : 'System Degraded'}
|
||||
{statusLoading
|
||||
? 'Checking...'
|
||||
: immichIndicator.className === 'ok'
|
||||
? 'System OK'
|
||||
: immichIndicator.className === 'error'
|
||||
? 'System Degraded'
|
||||
: 'Status Unknown'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -121,14 +152,14 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
<div className="status-dropdown-body">
|
||||
<div className="status-item">
|
||||
<span className="status-label">Immich Connection</span>
|
||||
<span className={`status-val ${status?.immich?.status === 'connected' ? 'ok' : 'error'}`}>
|
||||
{status?.immich?.status === 'connected' ? 'Connected' : 'Disconnected'}
|
||||
<span className={`status-val ${immichIndicator.className}`}>
|
||||
{immichIndicator.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="status-item">
|
||||
<span className="status-label">Config Integrity</span>
|
||||
<span className={`status-val ${status?.config?.status === 'valid' ? 'ok' : 'error'}`}>
|
||||
{status?.config?.status === 'valid' ? 'Valid' : 'Degraded'}
|
||||
<span className={`status-val ${configIndicator.className}`}>
|
||||
{configIndicator.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="status-item">
|
||||
@@ -138,7 +169,9 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
<div className="status-item">
|
||||
<span className="status-label">Latest Backup</span>
|
||||
<span className="status-val" title={status?.backups?.lastBackup || 'None'}>
|
||||
{status?.backups?.lastBackup ? new Date(status.backups.lastBackup).toLocaleDateString() : 'None'}
|
||||
{status?.backups?.lastBackup
|
||||
? new Date(status.backups.lastBackup).toLocaleDateString()
|
||||
: 'None'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="status-item">
|
||||
|
||||
@@ -7,7 +7,17 @@ import {
|
||||
type ParsedEssay,
|
||||
type EssayBlock,
|
||||
} from '@/lib/essay';
|
||||
import * as Icons from './Icons';
|
||||
import {
|
||||
IconCamera,
|
||||
IconChevronUp,
|
||||
IconChevronDown,
|
||||
IconTrash,
|
||||
IconGripVertical,
|
||||
IconQuote,
|
||||
IconFileText,
|
||||
IconSparkles,
|
||||
IconArrowLeftRight,
|
||||
} from './Icons';
|
||||
|
||||
interface EssayBlockEditorProps {
|
||||
markdown: string;
|
||||
@@ -83,21 +93,21 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
newBlock = { type: 'paragraph', html: 'Enter text paragraph here...' };
|
||||
break;
|
||||
case 'quote':
|
||||
newBlock = { type: 'quote', text: 'Enter quote text here...', author: 'Author' };
|
||||
newBlock = { type: 'quote', text: 'Enter quote text here...', author: '' };
|
||||
break;
|
||||
case 'photo':
|
||||
newBlock = {
|
||||
type: 'photo',
|
||||
assetId: 'sample-asset',
|
||||
caption: 'Photo caption',
|
||||
assetId: '',
|
||||
caption: '',
|
||||
layout: 'contained',
|
||||
};
|
||||
break;
|
||||
case 'photo-pair':
|
||||
newBlock = {
|
||||
type: 'photo-pair',
|
||||
assetIds: ['asset-1', 'asset-2'],
|
||||
caption: 'Side by side caption',
|
||||
assetIds: ['', ''],
|
||||
caption: '',
|
||||
};
|
||||
break;
|
||||
}
|
||||
@@ -106,57 +116,45 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="essay-block-editor" style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
<div
|
||||
className="essay-block-toolbar"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '10px 14px',
|
||||
background: 'var(--admin-card-bg, rgba(255,255,255,0.04))',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--admin-border, rgba(255,255,255,0.1))',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '0.85rem', fontWeight: 600, marginRight: 'auto' }}>
|
||||
✨ Add Story Block:
|
||||
</span>
|
||||
<div className="essay-block-editor">
|
||||
{/* Sticky Dark Glass Toolbar */}
|
||||
<div className="essay-toolbar-sticky">
|
||||
<span className="essay-toolbar-label">✨ Add Story Block:</span>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() => handleAddBlock('paragraph')}
|
||||
>
|
||||
+ Paragraph
|
||||
<IconFileText size={13} /> + Paragraph
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() => handleAddBlock('heading')}
|
||||
>
|
||||
+ Heading
|
||||
<IconSparkles size={13} /> + Heading
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() => handleAddBlock('quote')}
|
||||
>
|
||||
+ Pullquote
|
||||
<IconQuote size={13} /> + Pullquote
|
||||
</button>
|
||||
<div className="essay-toolbar-divider" />
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs admin-btn-primary"
|
||||
onClick={() => handleAddBlock('photo')}
|
||||
>
|
||||
+ Photo
|
||||
<IconCamera size={13} /> + Photo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs admin-btn-primary"
|
||||
onClick={() => handleAddBlock('photo-pair')}
|
||||
>
|
||||
+ 2-Photo Pair
|
||||
<IconCamera size={13} /> + 2-Photo Pair
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -166,51 +164,37 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Render Blocks */}
|
||||
{essay.blocks.map((block, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="essay-block-item"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
padding: '14px',
|
||||
background: 'var(--admin-card-bg, rgba(255,255,255,0.03))',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--admin-border, rgba(255,255,255,0.08))',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Block Header / Action Controls */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
<div key={idx} className="essay-block-card">
|
||||
{/* 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' }} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
className={`essay-block-badge ${
|
||||
block.type === 'photo' || block.type === 'photo-pair'
|
||||
? 'badge-photo'
|
||||
: block.type === 'heading'
|
||||
? 'badge-heading'
|
||||
: block.type === 'quote'
|
||||
? 'badge-quote'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
Block {idx + 1}: {block.type}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
</div>
|
||||
|
||||
<div className="essay-block-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon"
|
||||
onClick={() => handleMoveBlock(idx, 'up')}
|
||||
disabled={idx === 0}
|
||||
title="Move Up"
|
||||
style={{ padding: '2px 6px' }}
|
||||
>
|
||||
▲
|
||||
<IconChevronUp size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -218,31 +202,32 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
onClick={() => handleMoveBlock(idx, 'down')}
|
||||
disabled={idx === essay.blocks.length - 1}
|
||||
title="Move Down"
|
||||
style={{ padding: '2px 6px' }}
|
||||
>
|
||||
▼
|
||||
<IconChevronDown size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon text-danger"
|
||||
className="admin-btn-icon"
|
||||
onClick={() => handleDeleteBlock(idx)}
|
||||
title="Delete Block"
|
||||
style={{ padding: '2px 6px', color: '#ff4d4f' }}
|
||||
style={{ color: 'var(--admin-error)' }}
|
||||
>
|
||||
<Icons.IconTrash size={14} />
|
||||
<IconTrash size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Render Block Form Controls */}
|
||||
{/* Block Form Fields */}
|
||||
|
||||
{/* 1. HEADING BLOCK */}
|
||||
{block.type === 'heading' && (
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<select
|
||||
value={block.level}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, { ...block, level: Number(e.target.value) })
|
||||
}
|
||||
style={{ width: '80px', padding: '6px', borderRadius: '4px' }}
|
||||
style={{ width: '70px' }}
|
||||
>
|
||||
<option value={1}>H1</option>
|
||||
<option value={2}>H2</option>
|
||||
@@ -252,12 +237,13 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
type="text"
|
||||
value={block.text}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, text: e.target.value })}
|
||||
placeholder="Section title..."
|
||||
style={{ flex: 1, padding: '6px 10px', borderRadius: '4px' }}
|
||||
placeholder="Section heading..."
|
||||
style={{ flex: 1, fontWeight: block.level === 1 ? 700 : block.level === 2 ? 600 : 500 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2. PARAGRAPH BLOCK */}
|
||||
{block.type === 'paragraph' && (
|
||||
<textarea
|
||||
rows={3}
|
||||
@@ -265,19 +251,19 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
t.startsWith('<strong>') ? '**' : t.startsWith('</strong>') ? '**' : ''
|
||||
)}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, html: e.target.value })}
|
||||
placeholder="Write text paragraph... (**bold**, *italic* allowed)"
|
||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', resize: 'vertical' }}
|
||||
placeholder="Write text paragraph... (**bold**, *italic* markdown supported)"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 3. PULLQUOTE BLOCK */}
|
||||
{block.type === 'quote' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
<input
|
||||
type="text"
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', borderLeft: '3px solid var(--admin-accent)', paddingLeft: '10px' }}>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={block.text}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, text: e.target.value })}
|
||||
placeholder="Quote text..."
|
||||
style={{ padding: '6px 10px', borderRadius: '4px' }}
|
||||
style={{ fontStyle: 'italic', fontFamily: 'serif', fontSize: '1rem' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
@@ -285,78 +271,81 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, { ...block, author: e.target.value || undefined })
|
||||
}
|
||||
placeholder="Author / Attribution (optional)"
|
||||
style={{ padding: '6px 10px', borderRadius: '4px', fontSize: '0.85rem' }}
|
||||
placeholder="— Author / Attribution (optional)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 4. PHOTO BLOCK */}
|
||||
{block.type === 'photo' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
background: '#222',
|
||||
borderRadius: '6px',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div className="essay-photo-card">
|
||||
<div className="essay-photo-preview-container">
|
||||
{block.assetId ? (
|
||||
<img
|
||||
src={`/api/admin/thumbnail/${block.assetId}`}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
className="essay-photo-thumbnail"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="essay-photo-empty">
|
||||
<IconCamera size={20} />
|
||||
<span>No Photo</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={block.assetId}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, assetId: e.target.value })}
|
||||
placeholder="Immich Asset ID or Index"
|
||||
style={{ flex: 1, padding: '6px 10px', borderRadius: '4px' }}
|
||||
/>
|
||||
<div className="essay-photo-meta">
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
{onSelectPhoto && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
className="admin-btn admin-btn-sm admin-btn-primary"
|
||||
onClick={() =>
|
||||
onSelectPhoto((pickedId) =>
|
||||
handleUpdateBlock(idx, { ...block, assetId: pickedId })
|
||||
)
|
||||
}
|
||||
>
|
||||
Pick Photo
|
||||
<IconCamera size={14} />
|
||||
{block.assetId ? 'Change Photo' : 'Select Photo'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '0.8rem', opacity: 0.8 }}>Display Width:</span>
|
||||
<select
|
||||
value={block.layout}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
layout: e.target.value as 'contained' | 'wide' | 'fullbleed',
|
||||
})
|
||||
}
|
||||
style={{ padding: '4px 8px', borderRadius: '4px', fontSize: '0.85rem' }}
|
||||
|
||||
<div className="essay-pill-selector">
|
||||
<button
|
||||
type="button"
|
||||
className={`essay-pill-btn ${block.layout === 'contained' ? 'active' : ''}`}
|
||||
onClick={() => handleUpdateBlock(idx, { ...block, layout: 'contained' })}
|
||||
>
|
||||
<option value="contained">Contained (68ch)</option>
|
||||
<option value="wide">Wide (1100px)</option>
|
||||
<option value="fullbleed">Fullbleed (100vw)</option>
|
||||
</select>
|
||||
Contained (68ch)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`essay-pill-btn ${block.layout === 'wide' ? 'active' : ''}`}
|
||||
onClick={() => handleUpdateBlock(idx, { ...block, layout: 'wide' })}
|
||||
>
|
||||
Wide (1100px)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`essay-pill-btn ${block.layout === 'fullbleed' ? 'active' : ''}`}
|
||||
onClick={() => handleUpdateBlock(idx, { ...block, layout: 'fullbleed' })}
|
||||
>
|
||||
Fullbleed (100vw)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={block.assetId}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, assetId: e.target.value })}
|
||||
placeholder="Immich Asset UUID..."
|
||||
style={{ fontSize: '0.75rem', color: 'var(--admin-text-secondary)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
@@ -366,27 +355,33 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
|
||||
}
|
||||
placeholder="Photo caption (optional)"
|
||||
style={{ padding: '6px 10px', borderRadius: '4px', fontSize: '0.85rem' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 5. PHOTO-PAIR BLOCK */}
|
||||
{block.type === 'photo-pair' && (
|
||||
<div className="essay-photo-card">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||
{/* Photo 1 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={block.assetIds[0]}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [e.target.value, block.assetIds[1]],
|
||||
})
|
||||
}
|
||||
placeholder="Photo 1 ID"
|
||||
style={{ flex: 1, padding: '6px' }}
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--admin-text-secondary)' }}>
|
||||
Photo 1
|
||||
</span>
|
||||
<div className="essay-photo-preview-container">
|
||||
{block.assetIds[0] ? (
|
||||
<img
|
||||
src={`/api/admin/thumbnail/${block.assetIds[0]}`}
|
||||
alt=""
|
||||
className="essay-photo-thumbnail"
|
||||
style={{ width: '80px', height: '60px' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="essay-photo-empty" style={{ width: '80px', height: '60px' }}>
|
||||
<IconCamera size={16} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{onSelectPhoto && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -400,24 +395,60 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
)
|
||||
}
|
||||
>
|
||||
Pick
|
||||
<IconCamera size={12} /> Select Photo 1
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={block.assetIds[1]}
|
||||
value={block.assetIds[0]}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [block.assetIds[0], e.target.value],
|
||||
assetIds: [e.target.value, block.assetIds[1]],
|
||||
})
|
||||
}
|
||||
placeholder="Photo 2 ID"
|
||||
style={{ flex: 1, padding: '6px' }}
|
||||
placeholder="UUID 1..."
|
||||
style={{ fontSize: '0.75rem' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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)' }}>
|
||||
Photo 2
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon"
|
||||
title="Swap Photo 1 & Photo 2"
|
||||
onClick={() =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [block.assetIds[1], block.assetIds[0]],
|
||||
})
|
||||
}
|
||||
>
|
||||
<IconArrowLeftRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="essay-photo-preview-container">
|
||||
{block.assetIds[1] ? (
|
||||
<img
|
||||
src={`/api/admin/thumbnail/${block.assetIds[1]}`}
|
||||
alt=""
|
||||
className="essay-photo-thumbnail"
|
||||
style={{ width: '80px', height: '60px' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="essay-photo-empty" style={{ width: '80px', height: '60px' }}>
|
||||
<IconCamera size={16} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{onSelectPhoto && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -431,9 +462,23 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
)
|
||||
}
|
||||
>
|
||||
Pick
|
||||
<IconCamera size={12} /> Select Photo 2
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={block.assetIds[1]}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [block.assetIds[0], e.target.value],
|
||||
})
|
||||
}
|
||||
placeholder="UUID 2..."
|
||||
style={{ fontSize: '0.75rem' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -444,7 +489,6 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
|
||||
}
|
||||
placeholder="Side-by-side caption (optional)"
|
||||
style={{ padding: '6px 10px', borderRadius: '4px', fontSize: '0.85rem' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -265,3 +265,45 @@ export function IconBarChart({ size = 16, className = 'svg-icon', strokeWidth =
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconGripVertical({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<circle cx="9" cy="12" r="1" fill="currentColor" />
|
||||
<circle cx="9" cy="5" r="1" fill="currentColor" />
|
||||
<circle cx="9" cy="19" r="1" fill="currentColor" />
|
||||
<circle cx="15" cy="12" r="1" fill="currentColor" />
|
||||
<circle cx="15" cy="5" r="1" fill="currentColor" />
|
||||
<circle cx="15" cy="19" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconQuote({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.75-2-2-2H4c-1.25 0-2 .75-2 2v6c0 1.25.75 2 2 2h3c0 4-2 6-4 6" />
|
||||
<path d="M15 21c3 0 7-1 7-8V5c0-1.25-.75-2-2-2h-4c-1.25 0-2 .75-2 2v6c0 1.25.75 2 2 2h3c0 4-2 6-4 6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconChevronUp({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="m18 15-6-6-6 6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconArrowLeftRight({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="m16 3 4 4-4 4" />
|
||||
<path d="M20 7H4" />
|
||||
<path d="m8 21-4-4 4-4" />
|
||||
<path d="M4 17h16" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -908,11 +908,11 @@ export default function PageBuilder() {
|
||||
</div>
|
||||
<div className="save-bar-right">
|
||||
<a
|
||||
href="/"
|
||||
href="/?fresh=1"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-btn admin-btn-ghost admin-btn-preview"
|
||||
title="Open site in new tab"
|
||||
title="Open site in new tab (bypassing cache)"
|
||||
>
|
||||
↗ Preview Site
|
||||
</a>
|
||||
@@ -1120,11 +1120,11 @@ export default function PageBuilder() {
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={`/${slugify(sp.name)}`}
|
||||
href={`/${slugify(sp.name)}?fresh=1`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-btn admin-btn-xs admin-btn-ghost"
|
||||
title="Open live page in new tab"
|
||||
title="Open live page in new tab (bypassing cache)"
|
||||
>
|
||||
/{slugify(sp.name)} ↗
|
||||
</a>
|
||||
@@ -1315,8 +1315,15 @@ export default function PageBuilder() {
|
||||
markdown={sp.essayText || ''}
|
||||
onChange={(newMarkdown) => updateSubpage(spIndex, { essayText: newMarkdown })}
|
||||
onSelectPhoto={(callback) => {
|
||||
setPickerTarget({ type: 'subpage', subpageIndex: spIndex });
|
||||
// We can pass a photo picker handler if needed
|
||||
const firstAlbumId = sp.albums[0]?.id;
|
||||
setHeroPickerTarget({
|
||||
albumId: firstAlbumId,
|
||||
title: 'Select Photo for Photo Essay',
|
||||
onSelect: (assetId) => {
|
||||
callback(assetId);
|
||||
setHeroPickerTarget(null);
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -67,7 +67,7 @@ interface Settings {
|
||||
};
|
||||
}
|
||||
|
||||
const PRESETS = ['studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
||||
const PRESETS = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
||||
const LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow'];
|
||||
const PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];
|
||||
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic'];
|
||||
@@ -81,6 +81,13 @@ const THEME_INFO: Record<string, { desc: string; label: string; accent: string;
|
||||
tile: '#242424',
|
||||
accent: '#e60012',
|
||||
},
|
||||
'studio-modern': {
|
||||
label: 'Studio Modern',
|
||||
desc: 'Leica precision: Archivo grotesque, mono EXIF, red signal accents.',
|
||||
bg: '#121212',
|
||||
tile: '#191919',
|
||||
accent: '#e60012',
|
||||
},
|
||||
minimal: {
|
||||
label: 'Minimal',
|
||||
desc: 'Pure raw layouts with tiny gaps and high whitespace.',
|
||||
|
||||
@@ -100,6 +100,27 @@ const ROUTES: {
|
||||
{ params: Promise.resolve({ albumId: 'a1' }) },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'GET /api/admin/analytics',
|
||||
path: 'analytics',
|
||||
load: () => import('../analytics/route'),
|
||||
method: 'GET',
|
||||
args: () => [],
|
||||
},
|
||||
{
|
||||
name: 'GET /api/admin/backups',
|
||||
path: 'backups',
|
||||
load: () => import('../backups/route'),
|
||||
method: 'GET',
|
||||
args: () => [],
|
||||
},
|
||||
{
|
||||
name: 'POST /api/admin/backups',
|
||||
path: 'backups',
|
||||
load: () => import('../backups/route'),
|
||||
method: 'POST',
|
||||
args: () => [new Request('http://localhost/api/admin/backups', { method: 'POST', body: '{}' })],
|
||||
},
|
||||
];
|
||||
|
||||
describe('admin route guards', () => {
|
||||
|
||||
67
app/api/admin/__tests__/status.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/admin/auth', () => ({
|
||||
isAdminEnabled: () => true,
|
||||
isAdminAuthenticated: async () => true,
|
||||
COOKIE_NAME: 'folio_admin_session',
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/immich', () => ({
|
||||
immich: { ping: vi.fn(async () => true) },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/cache', () => ({
|
||||
cache: { size: 0 },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/admin/yaml-service', () => ({
|
||||
readGalleryYaml: vi.fn(),
|
||||
readSettingsYaml: vi.fn(),
|
||||
listBackups: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
import { GET } from '../status/route';
|
||||
import { readGalleryYaml, readSettingsYaml } from '@/lib/admin/yaml-service';
|
||||
|
||||
const mockGallery = readGalleryYaml as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockSettings = readSettingsYaml as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
async function status() {
|
||||
return (await GET()).json();
|
||||
}
|
||||
|
||||
describe('GET /api/admin/status — config integrity', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGallery.mockResolvedValue({ albums: ['a'] });
|
||||
mockSettings.mockResolvedValue({ title: 'Site' });
|
||||
});
|
||||
|
||||
it('reports a fully configured install as valid', async () => {
|
||||
expect((await status()).config).toMatchObject({ status: 'valid', settings: 'valid' });
|
||||
});
|
||||
|
||||
/**
|
||||
* settings.yaml is optional — getConfig() falls back to defaults when it is
|
||||
* absent. Reporting that install as "Degraded" sent people hunting for a
|
||||
* fault that did not exist (#341).
|
||||
*/
|
||||
it('treats a missing settings.yaml as valid, not as a fault', async () => {
|
||||
mockSettings.mockResolvedValue(null); // what readSettingsYaml returns on ENOENT
|
||||
|
||||
expect((await status()).config).toMatchObject({ status: 'valid', settings: 'valid' });
|
||||
});
|
||||
|
||||
it('still reports a settings.yaml that cannot be parsed', async () => {
|
||||
mockSettings.mockRejectedValue(new Error('bad indentation'));
|
||||
|
||||
expect((await status()).config).toMatchObject({ status: 'invalid', settings: 'invalid' });
|
||||
});
|
||||
|
||||
// gallery.yaml, unlike settings.yaml, is genuinely required.
|
||||
it('reports a missing gallery.yaml as invalid', async () => {
|
||||
mockGallery.mockResolvedValue(null);
|
||||
|
||||
expect((await status()).config).toMatchObject({ status: 'invalid', gallery: 'invalid' });
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,19 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { getConfigOrNull } from '@/lib/config';
|
||||
|
||||
const ANALYTICS_FILE = path.join(process.cwd(), 'content', 'analytics.json');
|
||||
|
||||
export async function GET() {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const config = getConfigOrNull();
|
||||
let data;
|
||||
@@ -45,6 +53,9 @@ export async function GET() {
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message || 'Failed to fetch analytics' }, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'Failed to fetch analytics' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ export async function PUT(request: Request) {
|
||||
immich.invalidateAll();
|
||||
// Revalidate all pages so the homepage picks up new hero images immediately
|
||||
revalidatePath('/', 'layout');
|
||||
revalidatePath('/[...path]', 'page');
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Saved successfully. Backup of previous version created.',
|
||||
|
||||
@@ -22,7 +22,12 @@ export async function GET() {
|
||||
|
||||
// 2. Config Health Check
|
||||
let galleryValid = false;
|
||||
let settingsValid = false;
|
||||
// settings.yaml is optional — getConfig() falls back to defaults when it is
|
||||
// absent, so a missing file is a valid installation, not a fault. Only a file
|
||||
// that exists and cannot be parsed counts as degraded: readSettingsYaml()
|
||||
// returns null for ENOENT and rethrows everything else.
|
||||
let settingsValid = true;
|
||||
|
||||
try {
|
||||
const gallery = await readGalleryYaml();
|
||||
galleryValid = !!gallery;
|
||||
@@ -31,10 +36,10 @@ export async function GET() {
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await readSettingsYaml();
|
||||
settingsValid = !!settings;
|
||||
await readSettingsYaml();
|
||||
} catch (err) {
|
||||
console.error('[Admin API] Settings YAML parse failed:', err);
|
||||
settingsValid = false;
|
||||
}
|
||||
|
||||
// 3. Backup Info
|
||||
@@ -60,7 +65,7 @@ export async function GET() {
|
||||
status: immichOk ? 'connected' : 'disconnected',
|
||||
},
|
||||
config: {
|
||||
status: (galleryValid && settingsValid) ? 'valid' : 'invalid',
|
||||
status: galleryValid && settingsValid ? 'valid' : 'invalid',
|
||||
gallery: galleryValid ? 'valid' : 'invalid',
|
||||
settings: settingsValid ? 'valid' : 'invalid',
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@import url('./themes/classic.css');
|
||||
@import url('./themes/noir.css');
|
||||
@import url('./themes/monograph.css');
|
||||
@import url('./themes/studio-modern.css');
|
||||
|
||||
@import url('./tokens.css');
|
||||
|
||||
@@ -28,6 +29,22 @@
|
||||
outline: 2px solid var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Screen-reader-only text ──────────────────────── */
|
||||
/* Used by the lightbox counter, which announces "Photo 3 of 53" while showing
|
||||
the compact "3 / 53". Without this rule both strings render on top of each
|
||||
other. */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ── Header / Navigation ──────────────────────────── */
|
||||
.header {
|
||||
position: fixed;
|
||||
@@ -61,6 +78,11 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Brand wordmark — only presets that ask for it render it visibly. */
|
||||
.header__wordmark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header__nav-link {
|
||||
padding: 18px 16px;
|
||||
font-family: var(--font-sans);
|
||||
@@ -210,6 +232,11 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Camera chip over the hero image — opt-in per preset. */
|
||||
.hero__exif-chip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Hero: Fullbleed Layout ─────────────────────── */
|
||||
.hero--fullbleed {
|
||||
position: relative;
|
||||
@@ -466,7 +493,10 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Index + album count ship with every hero nav link; presets opt in to
|
||||
showing them. Only the indexed nav shows the index by default. */
|
||||
.hero__nav-index {
|
||||
display: none;
|
||||
font-family: var(--font-caption, monospace);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 400;
|
||||
@@ -475,6 +505,15 @@
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.hero__nav--indexed .hero__nav-index {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.hero__nav-count,
|
||||
.hero__thumbnail-index {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Hero: Mosaic Layout ────────────────────────── */
|
||||
.hero--mosaic {
|
||||
position: relative;
|
||||
@@ -605,6 +644,13 @@
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
/* Kicker + meta block are rendered for every preset but stay hidden
|
||||
unless a preset opts in (studio-modern uses both). */
|
||||
.subpage-header__kicker,
|
||||
.subpage-header__meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.subpage-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 2.5rem;
|
||||
@@ -718,6 +764,20 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Media wrapper keeps the cover square-on while presets that want a
|
||||
caption bar below the image can grow the card. */
|
||||
.subpage-grid__item-media {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.subpage-grid__item-caption {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.subpage-grid__item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -905,6 +965,13 @@
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
/* Date + gear ship with every album header but stay hidden unless a preset
|
||||
opts in, so the meta line reads "86 photos" as it always has. */
|
||||
.album-header__meta-date,
|
||||
.album-header__meta-gear {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.photo-grid {
|
||||
column-count: var(--grid-columns, 3);
|
||||
column-gap: var(--grid-gap, 12px);
|
||||
|
||||
@@ -130,6 +130,10 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
</a>
|
||||
<header className="header">
|
||||
<nav className="header__nav">
|
||||
{/* Brand wordmark — presets that show it pair it with the dot. */}
|
||||
<span className="header__wordmark" aria-hidden="true">
|
||||
{config.siteTitle}
|
||||
</span>
|
||||
<Link href="/" className="header__nav-link">
|
||||
Home
|
||||
</Link>
|
||||
|
||||
@@ -10,8 +10,15 @@
|
||||
padding: 2rem var(--page-padding, 2rem) 1rem;
|
||||
}
|
||||
|
||||
/* Kicker + meta block are rendered for every preset but stay hidden
|
||||
unless a preset opts in (studio-modern uses both). */
|
||||
.map-page__kicker,
|
||||
.map-page__meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.map-page__title {
|
||||
font-family: var(--font-heading);
|
||||
font-family: var(--font-serif);
|
||||
font-size: clamp(1.5rem, 4vw, 2.5rem);
|
||||
font-weight: 600;
|
||||
margin: 0.5rem 0 0.25rem;
|
||||
@@ -19,7 +26,7 @@
|
||||
}
|
||||
|
||||
.map-page__subtitle {
|
||||
font-family: var(--font-body);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
@@ -31,9 +38,9 @@
|
||||
flex: 1;
|
||||
min-height: 500px;
|
||||
margin: 0 var(--page-padding, 2rem) 2rem;
|
||||
border-radius: var(--radius, 0);
|
||||
border-radius: var(--radius-sm, 0);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15));
|
||||
border: 1px solid var(--border-subtle, rgba(128, 128, 128, 0.15));
|
||||
}
|
||||
|
||||
.map-container__loading {
|
||||
@@ -42,7 +49,7 @@
|
||||
justify-content: center;
|
||||
height: 500px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-body);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@@ -51,16 +58,16 @@
|
||||
.leaflet-popup-content-wrapper {
|
||||
background: var(--bg-primary, #1a1a1a) !important;
|
||||
color: var(--text-primary, #fff) !important;
|
||||
border-radius: var(--radius, 4px) !important;
|
||||
border-radius: var(--radius-md, 4px) !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important;
|
||||
border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15)) !important;
|
||||
border: 1px solid var(--border-subtle, rgba(128, 128, 128, 0.15)) !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.leaflet-popup-tip {
|
||||
background: var(--bg-primary, #1a1a1a) !important;
|
||||
border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15)) !important;
|
||||
border: 1px solid var(--border-subtle, rgba(128, 128, 128, 0.15)) !important;
|
||||
border-top: none !important;
|
||||
border-left: none !important;
|
||||
}
|
||||
@@ -71,7 +78,7 @@
|
||||
}
|
||||
|
||||
.map-popup {
|
||||
font-family: var(--font-body);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.map-popup__cover {
|
||||
@@ -86,7 +93,7 @@
|
||||
}
|
||||
|
||||
.map-popup__city {
|
||||
font-family: var(--font-heading);
|
||||
font-family: var(--font-serif);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.125rem;
|
||||
@@ -166,7 +173,7 @@
|
||||
.leaflet-control-zoom a {
|
||||
background: var(--bg-primary, #1a1a1a) !important;
|
||||
color: var(--text-primary, #fff) !important;
|
||||
border-color: var(--border-color, rgba(128, 128, 128, 0.15)) !important;
|
||||
border-color: var(--border-subtle, rgba(128, 128, 128, 0.15)) !important;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a:hover {
|
||||
|
||||
@@ -23,6 +23,12 @@ export default function MapPage() {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const enabledSubpages = config.subpages.filter((sp) => sp.enabled !== false);
|
||||
const collectionCount = enabledSubpages.length;
|
||||
const albumCount =
|
||||
enabledSubpages.reduce((sum, sp) => sum + sp.albumIds.length, 0) +
|
||||
config.standaloneAlbums.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Leaflet CSS from CDN */}
|
||||
@@ -35,10 +41,19 @@ export default function MapPage() {
|
||||
/>
|
||||
<div className="map-page">
|
||||
<div className="map-page__header">
|
||||
<div className="map-page__header-main">
|
||||
<BackLink href="/" label="Back to Gallery" />
|
||||
<p className="map-page__kicker" aria-hidden="true">
|
||||
Where
|
||||
</p>
|
||||
<h1 className="map-page__title">Map</h1>
|
||||
<p className="map-page__subtitle">Where the photos were taken</p>
|
||||
</div>
|
||||
<p className="map-page__meta" aria-hidden="true">
|
||||
{collectionCount} {collectionCount === 1 ? 'collection' : 'collections'} · {albumCount}{' '}
|
||||
{albumCount === 1 ? 'album' : 'albums'}
|
||||
</p>
|
||||
</div>
|
||||
<MapView />
|
||||
</div>
|
||||
</>
|
||||
|
||||
109
app/page.tsx
@@ -6,7 +6,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { immich } from '@/lib/immich';
|
||||
import { immich, type ImmichAsset } from '@/lib/immich';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { imageUrl, assetPlaceholder } from '@/lib/urls';
|
||||
import { HeroCarousel } from '@/components/HeroCarousel';
|
||||
@@ -15,24 +15,63 @@ import { FadeIn } from '@/components/FadeIn';
|
||||
// Render at request time — requires live Immich connection
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type HeroSubpage = { slug: string; name: string; albumCount: number };
|
||||
type HeroAlbum = { id: string; slug: string; albumName: string; assetCount: number };
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, '0');
|
||||
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
||||
|
||||
/**
|
||||
* Flatten subpages + standalone albums into one indexed nav list.
|
||||
* Every entry carries an index and a count; presets decide what to show.
|
||||
*/
|
||||
function heroNavEntries(subpages: HeroSubpage[], albums: HeroAlbum[]) {
|
||||
return [
|
||||
...subpages.map((sp) => ({
|
||||
key: `sp-${sp.slug}`,
|
||||
href: `/${sp.slug}`,
|
||||
label: sp.name,
|
||||
count: plural(sp.albumCount, 'album'),
|
||||
})),
|
||||
...albums.map((a) => ({
|
||||
key: `al-${a.id}`,
|
||||
href: `/${a.slug}`,
|
||||
label: a.albumName,
|
||||
count: plural(a.assetCount, 'photo'),
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera line for the hero chip: "Q3 · 28MM · ƒ/5.6 · 1/250 · ISO 200".
|
||||
* Returns undefined when the asset carries no usable EXIF.
|
||||
*/
|
||||
function heroExifLine(asset: ImmichAsset): string | undefined {
|
||||
const e = asset.exifInfo;
|
||||
if (!e) return undefined;
|
||||
const parts = [
|
||||
e.city || e.model || undefined,
|
||||
e.focalLength ? `${Math.round(e.focalLength)}mm` : undefined,
|
||||
e.fNumber ? `ƒ/${e.fNumber}` : undefined,
|
||||
e.exposureTime ? `${e.exposureTime}s` : undefined,
|
||||
e.iso ? `ISO ${e.iso}` : undefined,
|
||||
].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(' · ') : undefined;
|
||||
}
|
||||
|
||||
/** Shared nav links for hero sections. */
|
||||
function HeroNavLinks({
|
||||
subpages,
|
||||
albums,
|
||||
}: {
|
||||
subpages: { slug: string; name: string }[];
|
||||
albums: { id: string; slug: string; albumName: string }[];
|
||||
}) {
|
||||
function HeroNavLinks({ subpages, albums }: { subpages: HeroSubpage[]; albums: HeroAlbum[] }) {
|
||||
return (
|
||||
<>
|
||||
{subpages.map((sp) => (
|
||||
<Link key={sp.slug} href={`/${sp.slug}`} className="hero__nav-link">
|
||||
{sp.name}
|
||||
</Link>
|
||||
))}
|
||||
{albums.map((album) => (
|
||||
<Link key={album.id} href={`/${album.slug}`} className="hero__nav-link">
|
||||
{album.albumName}
|
||||
{heroNavEntries(subpages, albums).map((entry, i) => (
|
||||
<Link key={entry.key} href={entry.href} className="hero__nav-link">
|
||||
<span className="hero__nav-index" aria-hidden="true">
|
||||
{pad2(i + 1)}
|
||||
</span>
|
||||
<span className="hero__nav-label">{entry.label}</span>
|
||||
<span className="hero__nav-count" aria-hidden="true">
|
||||
{entry.count}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
@@ -48,8 +87,8 @@ function HeroTextContent({
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
subpages: { slug: string; name: string }[];
|
||||
albums: { id: string; slug: string; albumName: string }[];
|
||||
subpages: HeroSubpage[];
|
||||
albums: HeroAlbum[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -82,14 +121,16 @@ export default async function HomePage() {
|
||||
immich.getStandaloneAlbums(),
|
||||
]);
|
||||
|
||||
// Fetch ThumbHash for all hero images
|
||||
// Fetch ThumbHash + camera line for all hero images
|
||||
const heroData = await Promise.all(
|
||||
config.heroImages.map(async (id) => {
|
||||
const asset = await immich.getAssetInfo(id);
|
||||
const ph = asset ? assetPlaceholder(asset) : null;
|
||||
const exif = asset ? heroExifLine(asset) : undefined;
|
||||
return {
|
||||
src: imageUrl(id, 'preview'),
|
||||
...(ph ? { blurDataURL: ph.blurDataURL } : {}),
|
||||
...(exif ? { exif } : {}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -114,20 +155,7 @@ export default async function HomePage() {
|
||||
</FadeIn>
|
||||
<FadeIn delay={300}>
|
||||
<nav className="hero__nav hero__nav--indexed">
|
||||
{subpages.map((sp, i) => (
|
||||
<Link key={sp.slug} href={`/${sp.slug}`} className="hero__nav-link">
|
||||
<span className="hero__nav-index">{String(i + 1).padStart(2, '0')}</span>
|
||||
{sp.name}
|
||||
</Link>
|
||||
))}
|
||||
{albums.map((album, i) => (
|
||||
<Link key={album.id} href={`/${album.slug}`} className="hero__nav-link">
|
||||
<span className="hero__nav-index">
|
||||
{String(subpages.length + i + 1).padStart(2, '0')}
|
||||
</span>
|
||||
{album.albumName}
|
||||
</Link>
|
||||
))}
|
||||
<HeroNavLinks subpages={subpages} albums={albums} />
|
||||
</nav>
|
||||
</FadeIn>
|
||||
</div>
|
||||
@@ -137,11 +165,7 @@ export default async function HomePage() {
|
||||
|
||||
// ── Stacked: fullbleed image + text at bottom + thumbnail strip ─
|
||||
if (heroStyle === 'stacked') {
|
||||
// Fetch album thumbnails for the strip
|
||||
const allEntries = [
|
||||
...subpages.map((sp) => ({ slug: sp.slug, name: sp.name })),
|
||||
...albums.map((a) => ({ slug: a.slug, name: a.albumName })),
|
||||
];
|
||||
const allEntries = heroNavEntries(subpages, albums);
|
||||
|
||||
return (
|
||||
<div className="hero hero--stacked">
|
||||
@@ -160,9 +184,12 @@ export default async function HomePage() {
|
||||
</div>
|
||||
<FadeIn delay={200}>
|
||||
<nav className="hero__thumbnail-strip">
|
||||
{allEntries.map((entry) => (
|
||||
<Link key={entry.slug} href={`/${entry.slug}`} className="hero__thumbnail-item">
|
||||
<span className="hero__thumbnail-label">{entry.name}</span>
|
||||
{allEntries.map((entry, i) => (
|
||||
<Link key={entry.key} href={entry.href} className="hero__thumbnail-item">
|
||||
<span className="hero__thumbnail-index" aria-hidden="true">
|
||||
{pad2(i + 1)}
|
||||
</span>
|
||||
<span className="hero__thumbnail-label">{entry.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
1187
app/themes/studio-modern.css
Normal file
@@ -54,6 +54,55 @@
|
||||
--accent-dim: rgba(200, 0, 15, 0.08);
|
||||
}
|
||||
|
||||
/* ── Studio Modern: Leica Precision Palette ──────── */
|
||||
[data-preset='studio-modern'] {
|
||||
--bg-primary: #121212;
|
||||
--bg-secondary: #0d0d0d;
|
||||
--bg-card: #191919;
|
||||
--bg-card-hover: #202020;
|
||||
--bg-overlay: rgba(10, 10, 10, 0.92);
|
||||
|
||||
--text-primary: #f4f4f2;
|
||||
--text-secondary: #9c9c96;
|
||||
--text-muted: #6b6b66;
|
||||
|
||||
--border-subtle: rgba(255, 255, 255, 0.08);
|
||||
--border-hover: rgba(255, 255, 255, 0.2);
|
||||
|
||||
--radius-sm: 0px;
|
||||
--radius-md: 0px;
|
||||
--radius-lg: 0px;
|
||||
|
||||
--max-width: 1440px;
|
||||
--header-height: 64px;
|
||||
|
||||
/* Signal red. Light mode darkens whatever accent is configured so it
|
||||
still carries on a near-white background (#e60012 -> ~#c4000f). */
|
||||
--sm-accent: var(--accent);
|
||||
|
||||
/* Gutter + hairline, reused across app/themes/studio-modern.css */
|
||||
--sm-gutter: 48px;
|
||||
--sm-hairline: 1px solid var(--border-subtle);
|
||||
--sm-chip-bg: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
[data-preset='studio-modern'][data-theme='light'] {
|
||||
--sm-accent: color-mix(in srgb, var(--accent) 86%, #000);
|
||||
|
||||
--bg-primary: #fafaf8;
|
||||
--bg-secondary: #f0f0ec;
|
||||
--bg-card: #ffffff;
|
||||
--bg-card-hover: #f5f5f1;
|
||||
--bg-overlay: rgba(250, 250, 248, 0.96);
|
||||
|
||||
--text-primary: #161614;
|
||||
--text-secondary: #5a5a55;
|
||||
--text-muted: #8a8a84;
|
||||
|
||||
--border-subtle: rgba(0, 0, 0, 0.09);
|
||||
--border-hover: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* ── Minimal: Swiss Brutalist Palette ────────────── */
|
||||
[data-preset='minimal'] {
|
||||
--bg-primary: #000000;
|
||||
|
||||
133
components/AdminDiagnosticBanner.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
interface AdminDiagnosticBannerProps {
|
||||
slug: string;
|
||||
subpageName?: string;
|
||||
configuredAlbumCount?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function AdminDiagnosticBanner({
|
||||
slug,
|
||||
subpageName,
|
||||
configuredAlbumCount = 0,
|
||||
reason,
|
||||
}: AdminDiagnosticBannerProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '680px',
|
||||
margin: '60px auto',
|
||||
padding: '24px',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: '#1c1c1e',
|
||||
border: '1px solid #333336',
|
||||
color: '#f2f2f7',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '16px' }}>
|
||||
<span style={{ fontSize: '24px' }}>⚠️</span>
|
||||
<h2 style={{ margin: 0, fontSize: '18px', fontWeight: 600, color: '#ffd60a' }}>
|
||||
Admin Diagnostic: Subpage loaded with 0 photos
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: '14px', lineHeight: 1.6, color: '#aeaeb2', margin: '0 0 16px 0' }}>
|
||||
The subpage <strong>/{slug}</strong> {subpageName ? `("${subpageName}")` : ''} is active in{' '}
|
||||
<code style={{ background: '#2c2c2e', padding: '2px 6px', borderRadius: '4px' }}>
|
||||
gallery.yaml
|
||||
</code>
|
||||
{configuredAlbumCount > 0
|
||||
? `, and configured with ${configuredAlbumCount} album(s)`
|
||||
: ''}
|
||||
, but Immich returned no accessible albums or assets.
|
||||
</p>
|
||||
|
||||
{reason && (
|
||||
<div
|
||||
style={{
|
||||
background: '#2c2c2e',
|
||||
padding: '12px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '13px',
|
||||
color: '#ff9f0a',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
{reason}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#f2f2f7' }}>
|
||||
Possible Causes & Checklist:
|
||||
</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px', fontSize: '13px', color: '#d1d1d6', lineHeight: 1.8 }}>
|
||||
<li>
|
||||
<strong>Album Shared Status in Immich:</strong> Ensure the album in Immich is marked as{' '}
|
||||
<strong>Shared (Geteilt)</strong>. Unshared albums are excluded by Immich Folio for security.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Cache Lag:</strong> If you just added the album or changed permissions, click{' '}
|
||||
<em>Force Reload Fresh</em> below.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Album UUID:</strong> Check if the album UUID in your Page Builder matches the exact Immich Album ID.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||||
<a
|
||||
href={`/${slug}?fresh=1`}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: '#0a84ff',
|
||||
color: '#ffffff',
|
||||
textDecoration: 'none',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
🔄 Force Reload Fresh (?fresh=1)
|
||||
</a>
|
||||
<Link
|
||||
href="/admin"
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: '#2c2c2e',
|
||||
color: '#f2f2f7',
|
||||
border: '1px solid #3a3a3c',
|
||||
textDecoration: 'none',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
⚙️ Open Page Builder (/admin)
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '6px',
|
||||
color: '#8e8e93',
|
||||
textDecoration: 'none',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
Back to Gallery
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import React, { useState, useEffect, useCallback, useLayoutEffect } from 'react';
|
||||
|
||||
const PRESETS = ['studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
||||
const PRESETS = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
||||
const FRAMES = ['none', 'passepartout', 'shadow'];
|
||||
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic'];
|
||||
const GRID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow'];
|
||||
@@ -27,6 +27,7 @@ type Tab = 'theme' | 'grid' | 'server';
|
||||
// Font stacks for each preset (must match THEME_PRESETS in config.ts)
|
||||
const PRESET_FONTS: Record<string, { heading: string; body: string; caption: string }> = {
|
||||
studio: { heading: 'Playfair Display', body: 'DM Sans', caption: 'EB Garamond' },
|
||||
'studio-modern': { heading: 'Archivo', body: 'Archivo', caption: 'IBM Plex Mono' },
|
||||
minimal: { heading: 'Geist', body: 'Geist', caption: 'IBM Plex Mono' },
|
||||
editorial: { heading: 'Bodoni Moda', body: 'Newsreader', caption: 'Spectral' },
|
||||
classic: { heading: 'Cinzel', body: 'Crimson Pro', caption: 'Crimson Pro' },
|
||||
@@ -36,6 +37,7 @@ const PRESET_FONTS: Record<string, { heading: string; body: string; caption: str
|
||||
|
||||
const PRESET_ACCENTS: Record<string, string> = {
|
||||
studio: 'var(--accent-studio, #e60012)',
|
||||
'studio-modern': 'var(--accent-studio-modern, #e60012)',
|
||||
minimal: 'var(--accent-minimal, #000000)',
|
||||
editorial: 'var(--accent-editorial, #8B2500)',
|
||||
classic: 'var(--accent-classic, #c49a3c)',
|
||||
|
||||
@@ -13,6 +13,8 @@ import Image from 'next/image';
|
||||
interface HeroImage {
|
||||
src: string;
|
||||
blurDataURL?: string;
|
||||
/** Pre-formatted camera line, shown as a chip by presets that use it. */
|
||||
exif?: string;
|
||||
}
|
||||
|
||||
interface HeroCarouselProps {
|
||||
@@ -42,6 +44,7 @@ export function HeroCarousel({ images }: HeroCarouselProps) {
|
||||
if (images.length === 1) {
|
||||
const img = images[0];
|
||||
return (
|
||||
<>
|
||||
<Image
|
||||
src={img.src}
|
||||
alt=""
|
||||
@@ -49,8 +52,12 @@ export function HeroCarousel({ images }: HeroCarouselProps) {
|
||||
fill
|
||||
sizes="50vw"
|
||||
priority
|
||||
{...(img.blurDataURL ? { placeholder: 'blur' as const, blurDataURL: img.blurDataURL } : {})}
|
||||
{...(img.blurDataURL
|
||||
? { placeholder: 'blur' as const, blurDataURL: img.blurDataURL }
|
||||
: {})}
|
||||
/>
|
||||
<HeroExifChip text={img.exif} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,6 +77,17 @@ export function HeroCarousel({ images }: HeroCarouselProps) {
|
||||
: {})}
|
||||
/>
|
||||
))}
|
||||
<HeroExifChip text={images[currentIndex]?.exif} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Camera line over the hero image. Hidden unless the preset shows it. */
|
||||
function HeroExifChip({ text }: { text?: string }) {
|
||||
if (!text) return null;
|
||||
return (
|
||||
<span className="hero__exif-chip" aria-hidden="true">
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Proofing favourite button — sits left of the info toggle. */
|
||||
.favToggle {
|
||||
right: 6.5rem;
|
||||
}
|
||||
|
||||
.exifPanel {
|
||||
position: fixed;
|
||||
bottom: 60px;
|
||||
@@ -216,6 +221,12 @@
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Marks the ISO row so a preset can keep its label — "200" alone reads
|
||||
as nothing once the labels are hidden. */
|
||||
.exifRowIso {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
.nav {
|
||||
@@ -300,3 +311,194 @@
|
||||
.image {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ══ Studio Modern ═══════════════════════════════════════════════
|
||||
Topbar with a mono counter, square outlined controls and a
|
||||
film-edge EXIF strip along the bottom instead of a popup panel. */
|
||||
|
||||
:global([data-preset='studio-modern']) .overlay {
|
||||
background: rgba(10, 10, 10, 0.92);
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
:global([data-preset='studio-modern']) .imageContainer,
|
||||
:global([data-preset='studio-modern']) .image {
|
||||
max-width: 88vw;
|
||||
max-height: 78vh;
|
||||
}
|
||||
|
||||
/* Counter → top left, monospace */
|
||||
:global([data-preset='studio-modern']) .counter {
|
||||
top: 20px;
|
||||
bottom: auto;
|
||||
left: 32px;
|
||||
transform: none;
|
||||
font-family: var(--font-caption);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.15em;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
:global([data-preset='studio-modern']) .close {
|
||||
top: 20px;
|
||||
right: 32px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
:global([data-preset='studio-modern']) .close:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
:global([data-preset='studio-modern']) .close svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* Square outlined navigation */
|
||||
:global([data-preset='studio-modern']) .nav {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
:global([data-preset='studio-modern']) .nav:hover {
|
||||
background: none;
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
:global([data-preset='studio-modern']) .navPrev {
|
||||
left: 24px;
|
||||
}
|
||||
|
||||
:global([data-preset='studio-modern']) .navNext {
|
||||
right: 24px;
|
||||
}
|
||||
|
||||
/* Info + favourite toggles move up into the topbar */
|
||||
:global([data-preset='studio-modern']) .infoToggle {
|
||||
top: 20px;
|
||||
bottom: auto;
|
||||
right: 76px;
|
||||
background: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 0;
|
||||
padding: 8px 14px;
|
||||
font-family: var(--font-caption);
|
||||
font-size: 0.625rem;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
:global([data-preset='studio-modern']) .infoToggle:hover {
|
||||
background: none;
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
:global([data-preset='studio-modern']) .favToggle {
|
||||
right: 176px;
|
||||
}
|
||||
|
||||
/* Film-edge strip replaces the popup panel */
|
||||
:global([data-preset='studio-modern']) .exifPanel {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0 12px;
|
||||
min-width: 0;
|
||||
padding: 18px 32px;
|
||||
background: rgba(10, 10, 10, 0.9);
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
border: none;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
:global([data-preset='studio-modern']) .exifPanel::before {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
flex: 0 0 auto;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
:global([data-preset='studio-modern']) .exifRow {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
padding: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:global([data-preset='studio-modern']) .exifRow + .exifRow::before {
|
||||
content: '·';
|
||||
margin-right: 12px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
:global([data-preset='studio-modern']) .exifLabel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global([data-preset='studio-modern']) .exifRowIso .exifLabel {
|
||||
display: inline;
|
||||
margin-right: 6px;
|
||||
font-family: var(--font-caption);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.06em;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
:global([data-preset='studio-modern']) .exifValue {
|
||||
font-family: var(--font-caption);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
/* The "Loading…" / "no EXIF" fallback keeps its own label visible */
|
||||
:global([data-preset='studio-modern']) .exifRow:only-child .exifLabel {
|
||||
display: inline;
|
||||
font-family: var(--font-caption);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.06em;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
/* No room next to the info toggle — park it in the opposite corner. */
|
||||
:global([data-preset='studio-modern']) .favToggle {
|
||||
top: auto;
|
||||
bottom: 76px;
|
||||
right: auto;
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
:global([data-preset='studio-modern']) .navPrev {
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
:global([data-preset='studio-modern']) .navNext {
|
||||
right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,9 +254,8 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev, waterm
|
||||
{/* Proofing favorite button */}
|
||||
{proofing && current && (
|
||||
<button
|
||||
className={styles.infoToggle}
|
||||
className={`${styles.infoToggle} ${styles.favToggle}`}
|
||||
style={{
|
||||
right: '6.5rem',
|
||||
color: isFav ? '#ff4d4f' : 'inherit',
|
||||
fontWeight: isFav ? 600 : 400,
|
||||
}}
|
||||
@@ -322,7 +321,7 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev, waterm
|
||||
</div>
|
||||
)}
|
||||
{exifData.iso && (
|
||||
<div className={styles.exifRow}>
|
||||
<div className={`${styles.exifRow} ${styles.exifRowIso}`}>
|
||||
<span className={styles.exifLabel}>ISO</span>
|
||||
<span className={styles.exifValue}>{exifData.iso}</span>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ transitions: true
|
||||
# allowMailto: true
|
||||
|
||||
# ── Theme configuration ──────────────────────────────────────────
|
||||
# Presets: studio, minimal, editorial, classic, noir, monograph
|
||||
# Presets: studio, studio-modern, minimal, editorial, classic, noir, monograph
|
||||
theme:
|
||||
preset: "studio"
|
||||
# accent: "#e60012"
|
||||
|
||||
@@ -9,7 +9,13 @@ services:
|
||||
- default
|
||||
- immich_network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:7211/"]
|
||||
# -O /dev/null statt --spider: --spider liest den Body nicht und trennt
|
||||
# mitten im RSC-Stream von '/'. Faellt der Abbruch mit einem langsamen
|
||||
# Render nach CACHE_TTL-Ablauf zusammen, raeumt Node den
|
||||
# TransformStream-Controller ab, waehrend Next noch hineinschreibt:
|
||||
# TypeError: controller[kState].transformAlgorithm is not a function
|
||||
# Ein vollstaendiges GET prueft dasselbe ohne dieses Rennen.
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "-O", "/dev/null", "http://127.0.0.1:7211/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
BIN
docs/screenshots/admin-album-picker.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
docs/screenshots/admin-login.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
docs/screenshots/admin-page-builder.png
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
docs/screenshots/admin-settings.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
docs/screenshots/grid-editorial-flow.png
Normal file
|
After Width: | Height: | Size: 402 KiB |
BIN
docs/screenshots/grid-filmstrip.png
Normal file
|
After Width: | Height: | Size: 340 KiB |
BIN
docs/screenshots/grid-masonry.png
Normal file
|
After Width: | Height: | Size: 576 KiB |
BIN
docs/screenshots/grid-showcase.png
Normal file
|
After Width: | Height: | Size: 329 KiB |
BIN
docs/screenshots/grid-uniform.png
Normal file
|
After Width: | Height: | Size: 460 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 389 KiB |
|
Before Width: | Height: | Size: 924 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 316 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 20 KiB |
BIN
docs/screenshots/lightbox-exif.png
Normal file
|
After Width: | Height: | Size: 523 KiB |
BIN
docs/screenshots/page-about.png
Normal file
|
After Width: | Height: | Size: 181 KiB |
BIN
docs/screenshots/page-collection.png
Normal file
|
After Width: | Height: | Size: 466 KiB |
BIN
docs/screenshots/page-map.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 596 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 539 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 651 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 371 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 513 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 606 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 600 KiB |
|
Before Width: | Height: | Size: 924 KiB After Width: | Height: | Size: 180 KiB |
BIN
docs/screenshots/theme-studio-modern-grid-light.png
Normal file
|
After Width: | Height: | Size: 569 KiB |
BIN
docs/screenshots/theme-studio-modern-grid.png
Normal file
|
After Width: | Height: | Size: 577 KiB |
BIN
docs/screenshots/theme-studio-modern-home-light.png
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
docs/screenshots/theme-studio-modern-home.png
Normal file
|
After Width: | Height: | Size: 196 KiB |
@@ -15,8 +15,9 @@ That's it. Your entire gallery switches to the minimal theme.
|
||||
## 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 | ✗ |
|
||||
| **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 | ✗ |
|
||||
@@ -27,10 +28,11 @@ Default is `studio` if no theme is specified.
|
||||
|
||||
### Theme Gallery
|
||||
|
||||
_From top to bottom: Studio, Minimal, Editorial, Classic, Noir, Monograph_
|
||||
_From left to right, top to bottom: Studio, Studio Modern, 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-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" />
|
||||
@@ -40,8 +42,11 @@ _From top to bottom: Studio, Minimal, Editorial, Classic, Noir, Monograph_
|
||||
|
||||
### Grid View Examples
|
||||
|
||||
_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-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" />
|
||||
@@ -49,10 +54,23 @@ _From top to bottom: Studio, Minimal, Editorial, Classic, Noir, Monograph_
|
||||
<img src="screenshots/theme-monograph-grid.png" width="49%" alt="Monograph grid" />
|
||||
</p>
|
||||
|
||||
### Light Mode
|
||||
|
||||
Every preset ships with a light variant. Visitors switch with the toggle in the
|
||||
navigation bar; the choice is remembered in `localStorage`, and the presets keep
|
||||
their accent, fonts and framing in both modes.
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/theme-studio-modern-home-light.png" width="49%" alt="Studio Modern in light mode" />
|
||||
<img src="screenshots/theme-studio-modern-grid-light.png" width="49%" alt="Studio Modern grid in light mode" />
|
||||
</p>
|
||||
|
||||
### 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** — 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.
|
||||
|
||||
**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.
|
||||
|
||||
**Editorial** — Cinematic magazine feel inspired by Aperture and Magnum Photos. Oversized Bodoni Moda serif titles, warm charcoal backgrounds, generous grid spacing, slow cinematic transitions with desaturation-on-idle photos, and pull-quote section labels.
|
||||
@@ -166,6 +184,42 @@ Controls the photo grid layout on album pages:
|
||||
- **`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
|
||||
|
||||
Set it globally in `settings.yaml`, or per subpage in `gallery.yaml`:
|
||||
|
||||
```yaml
|
||||
grid:
|
||||
layout: showcase
|
||||
```
|
||||
|
||||
The same album in all five layouts (theme `studio-modern`):
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><strong>Masonry</strong> (default)</td>
|
||||
<td align="center"><strong>Uniform</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="screenshots/grid-masonry.png" width="420" alt="Masonry grid layout" /></td>
|
||||
<td><img src="screenshots/grid-uniform.png" width="420" alt="Uniform grid layout" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><strong>Showcase</strong></td>
|
||||
<td align="center"><strong>Filmstrip</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="screenshots/grid-showcase.png" width="420" alt="Showcase grid layout" /></td>
|
||||
<td><img src="screenshots/grid-filmstrip.png" width="420" alt="Filmstrip grid layout" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2"><strong>Editorial Flow</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" align="center">
|
||||
<img src="screenshots/grid-editorial-flow.png" width="840" alt="Editorial flow grid layout" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Examples
|
||||
|
||||
### Minimal portfolio
|
||||
@@ -234,8 +288,37 @@ theme:
|
||||
|
||||
```yaml
|
||||
theme:
|
||||
preset: botanica
|
||||
preset: editorial
|
||||
heroStyle: mosaic
|
||||
grid:
|
||||
layout: editorial-flow
|
||||
```
|
||||
|
||||
## Upgrading & rolling back
|
||||
|
||||
Adding a preset never requires a migration. Presets are additive, the
|
||||
`gallery.yaml` / `settings.yaml` schema is unchanged, and existing presets keep
|
||||
rendering exactly as before — pull the new version and restart.
|
||||
|
||||
The one case that needs attention is a **rollback after switching presets**. The
|
||||
selected preset is stored in `content/settings.yaml`, so if you pick a preset
|
||||
that was added later and then downgrade to a version that predates it, config
|
||||
loading fails:
|
||||
|
||||
```
|
||||
Unknown theme preset "studio-modern". Valid presets: studio, minimal, editorial, classic, noir, monograph
|
||||
```
|
||||
|
||||
The message lists the presets the running version accepts. Set `theme.preset` to
|
||||
one of them:
|
||||
|
||||
```yaml
|
||||
theme:
|
||||
preset: studio # any preset the older version knows
|
||||
```
|
||||
|
||||
`/admin` stays reachable when this happens, so you can also fix it from the
|
||||
settings editor after the downgrade rather than beforehand.
|
||||
|
||||
The same error appears if you typo a preset name or copy an example for a preset
|
||||
that does not exist — it is not specific to downgrades.
|
||||
|
||||
@@ -495,6 +495,90 @@ describe('ImmichClient', () => {
|
||||
album = await immich.getAlbum('album-1');
|
||||
expect(album?.assets.map((a) => a.id)).toEqual(['new', 'mid', 'old']);
|
||||
});
|
||||
|
||||
/**
|
||||
* Immich's timeline sorts on localDateTime, not on the UTC instant. A
|
||||
* 09:00 shot in Tokyo and a 09:00 shot in Berlin sit next to each other in
|
||||
* the Immich UI even though they are eight hours apart in UTC.
|
||||
*/
|
||||
it('sorts by local capture time, matching the Immich timeline', async () => {
|
||||
const items = [
|
||||
{
|
||||
id: 'berlin',
|
||||
isTrashed: false,
|
||||
localDateTime: '2024-05-01T09:00:00.000Z',
|
||||
fileCreatedAt: '2024-05-01T07:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'tokyo',
|
||||
isTrashed: false,
|
||||
localDateTime: '2024-05-01T08:00:00.000Z',
|
||||
fileCreatedAt: '2024-04-30T23:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
|
||||
const album = await immich.getAlbum('album-1');
|
||||
|
||||
// UTC order would be tokyo → berlin; local capture order is the reverse.
|
||||
expect(album?.assets.map((a) => a.id)).toEqual(['tokyo', 'berlin']);
|
||||
});
|
||||
|
||||
it('falls back to fileCreatedAt when localDateTime is missing', async () => {
|
||||
const items = [
|
||||
asset('no-local-late', '2024-01-03T00:00:00Z'),
|
||||
{
|
||||
id: 'has-local',
|
||||
isTrashed: false,
|
||||
localDateTime: '2024-01-02T00:00:00Z',
|
||||
fileCreatedAt: '2024-01-02T00:00:00Z',
|
||||
},
|
||||
asset('no-local-early', '2024-01-01T00:00:00Z'),
|
||||
];
|
||||
|
||||
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
|
||||
const album = await immich.getAlbum('album-1');
|
||||
|
||||
// Missing localDateTime sorts as 0, so those two lead — but they stay
|
||||
// ordered among themselves by fileCreatedAt rather than at random.
|
||||
expect(album?.assets.map((a) => a.id)).toEqual([
|
||||
'no-local-early',
|
||||
'no-local-late',
|
||||
'has-local',
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Date.parse on an unusable value yields NaN, and a comparator returning
|
||||
* NaN leaves sort() free to produce any permutation — including dropping
|
||||
* the valid assets out of order.
|
||||
*/
|
||||
it('keeps a coherent order when a date is unparseable', async () => {
|
||||
const items = [
|
||||
asset('broken', 'not-a-date'),
|
||||
asset('late', '2024-01-03T00:00:00Z'),
|
||||
asset('early', '2024-01-01T00:00:00Z'),
|
||||
];
|
||||
|
||||
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
|
||||
const album = await immich.getAlbum('album-1');
|
||||
const ids = album?.assets.map((a) => a.id) ?? [];
|
||||
|
||||
expect(ids).toHaveLength(3);
|
||||
expect(ids.indexOf('early')).toBeLessThan(ids.indexOf('late'));
|
||||
});
|
||||
|
||||
it('preserves sub-second precision within a burst', async () => {
|
||||
const items = [
|
||||
asset('frame-2', '2024-01-01T14:36:53.500Z'),
|
||||
asset('frame-3', '2024-01-01T14:36:53.900Z'),
|
||||
asset('frame-1', '2024-01-01T14:36:53.000Z'),
|
||||
];
|
||||
|
||||
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
|
||||
const album = await immich.getAlbum('album-1');
|
||||
expect(album?.assets.map((a) => a.id)).toEqual(['frame-1', 'frame-2', 'frame-3']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,16 @@ export const THEME_PRESETS: Record<string, ThemeConfig> = {
|
||||
headerDot: true,
|
||||
heroStyle: 'split',
|
||||
},
|
||||
'studio-modern': {
|
||||
preset: 'studio-modern',
|
||||
accent: '#e60012',
|
||||
fonts: { heading: 'Archivo', body: 'Archivo', caption: 'IBM Plex Mono' },
|
||||
radius: 0,
|
||||
photoFrame: 'none',
|
||||
grain: false,
|
||||
headerDot: true,
|
||||
heroStyle: 'split',
|
||||
},
|
||||
minimal: {
|
||||
preset: 'minimal',
|
||||
accent: '#000000',
|
||||
|
||||
@@ -250,18 +250,18 @@ export function serializeEssayMarkdown(essay: ParsedEssay): string {
|
||||
}
|
||||
case 'quote': {
|
||||
const authorSuffix = block.author ? ` -- ${block.author}` : '';
|
||||
const text = block.text.replace(/<[^>]+>/g, '');
|
||||
const text = block.text.replace(/[\r\n]+/g, ' ');
|
||||
lines.push(`> ${text}${authorSuffix}`);
|
||||
break;
|
||||
}
|
||||
case 'photo': {
|
||||
const layoutSuffix = block.layout !== 'contained' ? `:${block.layout}` : '';
|
||||
const caption = block.caption ? block.caption.replace(/<[^>]+>/g, '') : '';
|
||||
const caption = block.caption ? block.caption.replace(/[\r\n]+/g, ' ') : '';
|
||||
lines.push(``);
|
||||
break;
|
||||
}
|
||||
case 'photo-pair': {
|
||||
const caption = block.caption ? block.caption.replace(/<[^>]+>/g, '') : '';
|
||||
const caption = block.caption ? block.caption.replace(/[\r\n]+/g, ' ') : '';
|
||||
lines.push(`![${block.assetIds[0]}, ${block.assetIds[1]}](${caption})`);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface ImmichAsset {
|
||||
originalMimeType: string;
|
||||
thumbhash: string | null;
|
||||
fileCreatedAt: string;
|
||||
/** Capture time in the photographer's local zone — Immich's timeline sort key. */
|
||||
localDateTime?: string;
|
||||
exifInfo?: ImmichExifInfo;
|
||||
isTrashed: boolean;
|
||||
}
|
||||
@@ -362,10 +364,14 @@ class ImmichClient {
|
||||
* Get ALL configured albums (filtered by the full allowlist).
|
||||
* Uses ?shared=true to only fetch albums that have been shared in Immich.
|
||||
*/
|
||||
async getAlbums(): Promise<ImmichAlbum[]> {
|
||||
async getAlbums(forceFresh = false): Promise<ImmichAlbum[]> {
|
||||
const cacheKey = 'albums-list';
|
||||
if (forceFresh) {
|
||||
cache.delete(cacheKey);
|
||||
} else {
|
||||
const cached = cache.get<ImmichAlbum[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
if (this.pendingAlbumsPromise) {
|
||||
return this.pendingAlbumsPromise;
|
||||
@@ -439,8 +445,8 @@ class ImmichClient {
|
||||
/**
|
||||
* Get only standalone albums (not in any subpage) — for the homepage.
|
||||
*/
|
||||
async getStandaloneAlbums(): Promise<ImmichAlbum[]> {
|
||||
const albums = await this.getAlbums();
|
||||
async getStandaloneAlbums(forceFresh = false): Promise<ImmichAlbum[]> {
|
||||
const albums = await this.getAlbums(forceFresh);
|
||||
// The API returns albums in its own order; gallery.yaml order wins.
|
||||
const orderIdx = new Map(this.config.standaloneAlbums.map((id, i) => [id, i]));
|
||||
return albums
|
||||
@@ -451,11 +457,11 @@ class ImmichClient {
|
||||
/**
|
||||
* Get enriched subpage summaries for the homepage.
|
||||
*/
|
||||
async getSubpages(): Promise<SubpageSummary[]> {
|
||||
async getSubpages(forceFresh = false): Promise<SubpageSummary[]> {
|
||||
const activeSubpages = this.config.subpages.filter((sp) => sp.enabled !== false);
|
||||
if (activeSubpages.length === 0) return [];
|
||||
|
||||
const albums = await this.getAlbums();
|
||||
const albums = await this.getAlbums(forceFresh);
|
||||
const albumMap = new Map(albums.map((a) => [a.id, a]));
|
||||
|
||||
return activeSubpages.map((sp) => {
|
||||
@@ -478,13 +484,14 @@ class ImmichClient {
|
||||
*/
|
||||
async getSubpageAlbums(
|
||||
subpageSlug: string,
|
||||
forceFresh = false,
|
||||
): Promise<{ subpage: SubpageConfig; albums: ImmichAlbum[] } | null> {
|
||||
const subpage = this.config.subpages.find(
|
||||
(sp) => sp.slug === subpageSlug && sp.enabled !== false,
|
||||
);
|
||||
if (!subpage) return null;
|
||||
|
||||
const allAlbums = await this.getAlbums();
|
||||
const allAlbums = await this.getAlbums(forceFresh);
|
||||
const subpageAlbumIds = new Set(subpage.albumIds);
|
||||
const albums = allAlbums.filter((a) => subpageAlbumIds.has(a.id));
|
||||
|
||||
@@ -537,7 +544,7 @@ class ImmichClient {
|
||||
return assets;
|
||||
}
|
||||
|
||||
async getAlbum(albumId: string): Promise<ImmichAlbum | null> {
|
||||
async getAlbum(albumId: string, forceFresh = false): Promise<ImmichAlbum | null> {
|
||||
// Security: only serve configured albums
|
||||
if (!this.config.albums.includes(albumId)) {
|
||||
console.warn(`[Immich] Album ${albumId} is not in LIGHTBOX_ALBUMS`);
|
||||
@@ -545,8 +552,12 @@ class ImmichClient {
|
||||
}
|
||||
|
||||
const cacheKey = `album-${albumId}`;
|
||||
if (forceFresh) {
|
||||
cache.delete(cacheKey);
|
||||
} else {
|
||||
const cached = cache.get<ImmichAlbum | Missing>(cacheKey);
|
||||
if (cached) return cached === MISSING ? null : (cached as ImmichAlbum);
|
||||
}
|
||||
|
||||
if (this.pendingAlbumPromises.has(albumId)) {
|
||||
return this.pendingAlbumPromises.get(albumId)!;
|
||||
@@ -581,9 +592,25 @@ class ImmichClient {
|
||||
// The album endpoint used to return assets in the album's configured
|
||||
// order. Metadata search happens to default to fileCreatedAt desc, but
|
||||
// that is not contractual — sort explicitly so 'asc' albums are right.
|
||||
//
|
||||
// Mirrors Immich's own timeline query: primary key `localDateTime`
|
||||
// (capture time in the photographer's local zone), tie-broken by
|
||||
// `fileCreatedAt` (the same instant in UTC). The two only diverge for
|
||||
// albums spanning time zones, and there local time is what the Immich
|
||||
// UI shows. Sorting on `exifInfo.dateTimeOriginal` instead would be
|
||||
// near-pointless: Immich already derives `fileCreatedAt` from that
|
||||
// same EXIF chain, and it is null for assets without EXIF.
|
||||
//
|
||||
// Compare parsed instants, not strings — string order breaks on
|
||||
// fractional seconds ('.' collates before '+'), which misorders bursts
|
||||
// where only some frames carry sub-second precision. Unparseable dates
|
||||
// fall back to 0 rather than NaN, which would make sort() incoherent.
|
||||
const dir = album.order === 'asc' ? 1 : -1;
|
||||
const ts = (value: string | undefined) => Date.parse(value ?? '') || 0;
|
||||
album.assets.sort(
|
||||
(a, b) => dir * (Date.parse(a.fileCreatedAt) - Date.parse(b.fileCreatedAt)),
|
||||
(a, b) =>
|
||||
dir * (ts(a.localDateTime) - ts(b.localDateTime)) ||
|
||||
dir * (ts(a.fileCreatedAt) - ts(b.fileCreatedAt)),
|
||||
);
|
||||
|
||||
const name = this.config.albumOverrides[album.id] ?? album.albumName;
|
||||
@@ -609,8 +636,12 @@ class ImmichClient {
|
||||
* Find an album by its URL slug.
|
||||
* If subpageSlug is provided, only search within that subpage's albums.
|
||||
*/
|
||||
async getAlbumBySlug(slug: string, subpageSlug?: string): Promise<ImmichAlbum | null> {
|
||||
const albums = await this.getAlbums();
|
||||
async getAlbumBySlug(
|
||||
slug: string,
|
||||
subpageSlug?: string,
|
||||
forceFresh = false,
|
||||
): Promise<ImmichAlbum | null> {
|
||||
const albums = await this.getAlbums(forceFresh);
|
||||
|
||||
let searchSet = albums;
|
||||
if (subpageSlug) {
|
||||
@@ -622,7 +653,7 @@ class ImmichClient {
|
||||
|
||||
const match = searchSet.find((a) => a.slug === slug);
|
||||
if (!match) return null;
|
||||
return this.getAlbum(match.id);
|
||||
return this.getAlbum(match.id, forceFresh);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "immich-lightbox",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "immich-lightbox",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-lightbox",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -1,201 +1,728 @@
|
||||
/**
|
||||
* Theme Screenshot Generator
|
||||
* 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 homepage and grid view screenshots for each theme preset.
|
||||
* Uses Playwright to wait for images to fully load (not just blurhash placeholders).
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/screenshots.ts
|
||||
* 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
|
||||
*
|
||||
* 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
|
||||
* specific one instead, SCREENSHOT_SUBPAGE_PATH the collection overview.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - Dev server NOT running (script manages its own server lifecycle)
|
||||
* - Nothing else listening on SCREENSHOT_PORT (the script manages its own
|
||||
* server; a foreign server on that port would be screenshot instead)
|
||||
* - Playwright browsers installed: npx playwright install chromium
|
||||
*
|
||||
* Note: this rewrites content/settings.yaml, content/gallery.yaml and
|
||||
* content/about.md while it runs and restores all three afterwards — including
|
||||
* on Ctrl+C. On a host serving live traffic from the same files, the public site
|
||||
* changes with every capture. Run it against a copy of the repo instead.
|
||||
*
|
||||
* Because the screenshots end up in a public repository, the run replaces the
|
||||
* site identity (title, footer, watermark, legal block, about page) with neutral
|
||||
* demo values and blanks out every stored password — see DEMO_SETTINGS.
|
||||
*
|
||||
* Output:
|
||||
* docs/screenshots/theme-{preset}-{page}.png
|
||||
* docs/screenshots/*.png
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import yaml from 'js-yaml';
|
||||
import type { Browser, Page } from '@playwright/test';
|
||||
|
||||
// Next reads .env.local itself; this script runs outside Next, so ADMIN_PASSWORD
|
||||
// and friends have to be pulled in explicitly. Must run before the constants below.
|
||||
loadEnvLocal();
|
||||
|
||||
const THEMES = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
||||
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic'];
|
||||
const GRID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow'];
|
||||
/** Preset used for everything that is not itself a theme comparison. */
|
||||
const SHOWCASE_THEME = 'studio-modern';
|
||||
|
||||
const PORT = process.env.SCREENSHOT_PORT ?? '3000';
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
/**
|
||||
* Album detail page used for the grid screenshots. Discovered by walking the
|
||||
* running site, because album slugs come from Immich album names and differ per
|
||||
* installation. Set SCREENSHOT_GRID_PATH to pick a specific album instead.
|
||||
*/
|
||||
const GRID_PATH_OVERRIDE = process.env.SCREENSHOT_GRID_PATH;
|
||||
/** Collection overview page. Derived from the grid path when not set. */
|
||||
const SUBPAGE_PATH_OVERRIDE = process.env.SCREENSHOT_SUBPAGE_PATH;
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
|
||||
/**
|
||||
* Comma-separated section keys to capture, e.g. `SCREENSHOT_ONLY=admin,lightbox`.
|
||||
* A full pass takes the better part of an hour, so re-shooting one section after
|
||||
* a layout change should not mean redoing all of it. Empty means everything.
|
||||
*/
|
||||
const ONLY_SECTIONS = (process.env.SCREENSHOT_ONLY ?? '')
|
||||
.split(',')
|
||||
.map((key) => key.trim())
|
||||
.filter(Boolean);
|
||||
const wanted = (key: string) => ONLY_SECTIONS.length === 0 || ONLY_SECTIONS.includes(key);
|
||||
|
||||
const THEMES = ['studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
||||
const BASE_URL = 'http://localhost:3000';
|
||||
const GRID_PATH = '/deutschland/kloster-chorin'; // public subpage with photos
|
||||
const OUTPUT_DIR = path.join(process.cwd(), 'docs', 'screenshots');
|
||||
const SETTINGS_YAML = path.join(process.cwd(), 'content', 'settings.yaml');
|
||||
const CONTENT_DIR = path.join(process.cwd(), 'content');
|
||||
const SETTINGS_YAML = path.join(CONTENT_DIR, 'settings.yaml');
|
||||
const GALLERY_YAML = path.join(CONTENT_DIR, 'gallery.yaml');
|
||||
const ABOUT_MD = path.join(CONTENT_DIR, 'about.md');
|
||||
/** Every file the run rewrites, and therefore has to restore. */
|
||||
const MANAGED_FILES = [SETTINGS_YAML, GALLERY_YAML, ABOUT_MD];
|
||||
const backupPath = (file: string) => `${file}.screenshot-bak`;
|
||||
|
||||
/** 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 };
|
||||
const MOBILE_VIEWPORT = { width: 375, height: 812 };
|
||||
const IMAGE_LOAD_TIMEOUT = 8000; // ms to wait for images after networkidle
|
||||
/** Settled once the dev server has re-read the changed YAML (mtime-based cache). */
|
||||
const CONFIG_RELOAD_DELAY = 400;
|
||||
|
||||
/**
|
||||
* Neutral stand-ins for everything identifying in settings.yaml. The screenshots
|
||||
* are published, so the site must not show the operator's name, handle, postal
|
||||
* address or watermark. Dotted keys are merged into the parsed YAML.
|
||||
*/
|
||||
const DEMO_SETTINGS: Record<string, unknown> = {
|
||||
// A run loads every page dozens of times; without this it would write a few
|
||||
// hundred fake page views into content/analytics.json.
|
||||
analytics: false,
|
||||
title: 'My Portfolio Gallery',
|
||||
'seo.title': 'My Portfolio Gallery',
|
||||
'footer.name': 'My Portfolio Gallery',
|
||||
'footer.instagram': 'myportfolio',
|
||||
'watermark.text': 'Folio ',
|
||||
'legal.name': 'Jane Doe',
|
||||
'legal.address': '1 Example Street',
|
||||
'legal.zipCity': '12345 Example City',
|
||||
'legal.country': 'Germany',
|
||||
'legal.extraInfo': '',
|
||||
};
|
||||
|
||||
async function main() {
|
||||
// Ensure output directory exists
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
|
||||
// Read original settings.yaml to restore later
|
||||
const originalYaml = fs.readFileSync(SETTINGS_YAML, 'utf8');
|
||||
// Read the originals to restore later. The on-disk backups are what the signal
|
||||
// handlers and the top-level catch restore from — an in-memory copy is gone the
|
||||
// moment the process is killed, and this script rewrites live content files.
|
||||
const originals = new Map(MANAGED_FILES.map((f) => [f, fs.readFileSync(f, 'utf8')]));
|
||||
for (const [file, content] of originals) fs.writeFileSync(backupPath(file), content);
|
||||
installRestoreHandlers();
|
||||
|
||||
// Dynamic import for Playwright (may not be installed globally)
|
||||
const { chromium } = await import('@playwright/test');
|
||||
const browser = await chromium.launch();
|
||||
const originalSettings = originals.get(SETTINGS_YAML)!;
|
||||
const originalGallery = originals.get(GALLERY_YAML)!;
|
||||
|
||||
/** Writes settings.yaml with the demo identity plus the given overrides. */
|
||||
const applySettings = async (overrides: Record<string, unknown> = {}) => {
|
||||
fs.writeFileSync(SETTINGS_YAML, settingsWith(originalSettings, overrides));
|
||||
await sleep(CONFIG_RELOAD_DELAY);
|
||||
};
|
||||
|
||||
// Passwords live in gallery.yaml in plain text and are rendered by the admin
|
||||
// page builder — blank them for the whole run rather than per screenshot.
|
||||
fs.writeFileSync(GALLERY_YAML, withRedactedPasswords(originalGallery));
|
||||
// The about page carries a real name, location and portrait. Swap in demo text
|
||||
// and use a gallery photo as the portrait so no face ends up in the docs.
|
||||
fs.writeFileSync(ABOUT_MD, demoAboutMarkdown(originalGallery));
|
||||
|
||||
await applySettings();
|
||||
|
||||
console.log(`\n⏳ Starting dev server on ${BASE_URL} ...`);
|
||||
let browser: Browser | undefined;
|
||||
|
||||
try {
|
||||
await ensureServerHealthy();
|
||||
|
||||
const { chromium } = await import('@playwright/test');
|
||||
browser = await chromium.launch();
|
||||
|
||||
// ── Resolve the pages used throughout the run ──
|
||||
const scout = await browser.newContext({ viewport: VIEWPORT });
|
||||
const scoutPage = await scout.newPage();
|
||||
const gridPath = GRID_PATH_OVERRIDE ?? (await discoverGridPath(scoutPage));
|
||||
const subpagePath = SUBPAGE_PATH_OVERRIDE ?? (await resolveSubpagePath(scoutPage, gridPath));
|
||||
await scout.close();
|
||||
console.log(`🔎 Album page: ${gridPath}`);
|
||||
console.log(`🔎 Collection page: ${subpagePath ?? '(none found — skipping)'}`);
|
||||
|
||||
// ── Theme presets: homepage + grid ──
|
||||
if (wanted('themes')) {
|
||||
for (const theme of THEMES) {
|
||||
console.log(`\n🎨 Theme: ${theme}`);
|
||||
console.log('─'.repeat(50));
|
||||
|
||||
// Update settings.yaml with the theme
|
||||
const yamlWithTheme = setThemeInSettingsYaml(originalYaml, theme);
|
||||
fs.writeFileSync(SETTINGS_YAML, yamlWithTheme);
|
||||
|
||||
// Start dev server
|
||||
console.log(' ⏳ Starting dev server...');
|
||||
const server = startDevServer();
|
||||
await waitForServer(BASE_URL, 15000);
|
||||
console.log(' ✅ Server ready');
|
||||
|
||||
const context = await browser.newContext({ viewport: VIEWPORT });
|
||||
const page = await context.newPage();
|
||||
|
||||
// ── Homepage screenshot ──
|
||||
console.log(' 📸 Capturing homepage...');
|
||||
await page.goto(BASE_URL, { waitUntil: 'networkidle' });
|
||||
await waitForImages(page);
|
||||
await page.screenshot({
|
||||
path: path.join(OUTPUT_DIR, `theme-${theme}-home.png`),
|
||||
fullPage: false,
|
||||
section(`Theme: ${theme}`);
|
||||
await applySettings({ 'theme.preset': theme });
|
||||
await withPage(browser, VIEWPORT, async (page) => {
|
||||
await captureHome(page, `theme-${theme}-home`);
|
||||
await captureGrid(page, gridPath, `theme-${theme}-grid`);
|
||||
});
|
||||
console.log(` ✅ theme-${theme}-home.png`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Grid view screenshot ──
|
||||
console.log(' 📸 Capturing grid view...');
|
||||
await page.goto(`${BASE_URL}${GRID_PATH}`, { waitUntil: 'networkidle' });
|
||||
// Force all fade-in elements visible (don't scroll — keep album title in view)
|
||||
// ── Light mode ──
|
||||
if (wanted('light')) {
|
||||
section('Light mode');
|
||||
await applySettings({ 'theme.preset': SHOWCASE_THEME });
|
||||
await withPage(
|
||||
browser,
|
||||
VIEWPORT,
|
||||
async (page) => {
|
||||
await captureHome(page, `theme-${SHOWCASE_THEME}-home-light`);
|
||||
await captureGrid(page, gridPath, `theme-${SHOWCASE_THEME}-grid-light`);
|
||||
},
|
||||
// ThemeToggle reads this on module load and sets data-theme on <html>.
|
||||
() => window.localStorage.setItem('theme', 'light'),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hero styles ──
|
||||
if (wanted('hero')) {
|
||||
for (const hero of HERO_STYLES) {
|
||||
section(`Hero style: ${hero} (theme: studio)`);
|
||||
await applySettings({ 'theme.preset': 'studio', 'theme.heroStyle': hero });
|
||||
await withPage(browser, VIEWPORT, (page) => captureHome(page, `hero-${hero}-home`));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Grid layouts ──
|
||||
if (wanted('grid')) {
|
||||
for (const layout of GRID_LAYOUTS) {
|
||||
section(`Grid layout: ${layout}`);
|
||||
await applySettings({ 'theme.preset': SHOWCASE_THEME, 'grid.layout': layout });
|
||||
await withPage(browser, VIEWPORT, (page) => captureGrid(page, gridPath, `grid-${layout}`));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Standard pages ──
|
||||
if (wanted('pages')) {
|
||||
section('Pages: collection, about, map');
|
||||
await applySettings({ 'theme.preset': SHOWCASE_THEME });
|
||||
await withPage(browser, VIEWPORT, async (page) => {
|
||||
if (subpagePath) await captureGrid(page, subpagePath, 'page-collection');
|
||||
await captureHome(page, 'page-about', '/about');
|
||||
await capturePage(page, '/map', 'page-map', 6000);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Lightbox with EXIF panel ──
|
||||
if (wanted('lightbox')) {
|
||||
section('Lightbox');
|
||||
await applySettings({ 'theme.preset': SHOWCASE_THEME });
|
||||
await withPage(browser, VIEWPORT, (page) => captureLightbox(page, gridPath));
|
||||
}
|
||||
|
||||
// ── Mobile viewport ──
|
||||
if (wanted('mobile')) {
|
||||
section('Mobile (375x812)');
|
||||
await applySettings({ 'theme.preset': SHOWCASE_THEME });
|
||||
await withPage(browser, MOBILE_VIEWPORT, async (page) => {
|
||||
await captureHome(page, 'mobile-home');
|
||||
await captureGrid(page, gridPath, 'mobile-grid');
|
||||
});
|
||||
}
|
||||
|
||||
// ── Admin panel ──
|
||||
if (wanted('admin')) {
|
||||
await applySettings({ 'theme.preset': SHOWCASE_THEME });
|
||||
await captureAdmin(browser);
|
||||
}
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await stopDevServer();
|
||||
restoreManagedFiles(originals);
|
||||
}
|
||||
|
||||
await optimizePngs();
|
||||
|
||||
if (failedSections.length > 0) {
|
||||
console.log(`\n⚠️ Incomplete sections: ${failedSections.join(', ')}`);
|
||||
}
|
||||
|
||||
console.log(`\n🎉 Done! Screenshots saved to ${OUTPUT_DIR}/`);
|
||||
for (const file of fs
|
||||
.readdirSync(OUTPUT_DIR)
|
||||
.filter((f) => f.endsWith('.png'))
|
||||
.sort()) {
|
||||
const kb = Math.round(fs.statSync(path.join(OUTPUT_DIR, file)).size / 1024);
|
||||
console.log(` ${file} (${kb} KB)`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-encode the PNGs losslessly. Playwright optimises for capture speed, not
|
||||
* file size — a plain re-encode takes a full-page photo grid from ~2 MB to
|
||||
* ~700 KB with identical pixels. sharp ships with Next.js but is not a declared
|
||||
* dependency here, so a missing install just skips this step.
|
||||
*/
|
||||
async function optimizePngs(): Promise<void> {
|
||||
let sharp: typeof import('sharp');
|
||||
try {
|
||||
sharp = (await import('sharp')).default;
|
||||
} catch {
|
||||
console.log('\n⏭ sharp not available — screenshots left as captured.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n🗜 Optimising PNGs...');
|
||||
let before = 0;
|
||||
let after = 0;
|
||||
for (const file of fs.readdirSync(OUTPUT_DIR).filter((f) => f.endsWith('.png'))) {
|
||||
const filePath = path.join(OUTPUT_DIR, file);
|
||||
before += fs.statSync(filePath).size;
|
||||
const optimised = await sharp(filePath).png({ compressionLevel: 9, effort: 10 }).toBuffer();
|
||||
// Never grow a file — some captures are already minimal.
|
||||
if (optimised.length < fs.statSync(filePath).size) fs.writeFileSync(filePath, optimised);
|
||||
after += fs.statSync(filePath).size;
|
||||
}
|
||||
console.log(` ${Math.round(before / 1024)} KB → ${Math.round(after / 1024)} KB`);
|
||||
}
|
||||
|
||||
// ── Capture helpers ──────────────────────────────────────────────
|
||||
|
||||
let currentSection = '';
|
||||
/** Sections that threw, reported again at the end of the run. */
|
||||
const failedSections: string[] = [];
|
||||
|
||||
function section(title: string): void {
|
||||
currentSection = title;
|
||||
console.log(`\n📷 ${title}`);
|
||||
console.log('─'.repeat(50));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a capture block in a fresh browser context, so localStorage (light mode)
|
||||
* and cookies (admin session) never leak into the next block.
|
||||
*
|
||||
* A block is retried once — a wedged dev server is replaced in between — and
|
||||
* then reported and skipped rather than aborting the run: a full pass takes many
|
||||
* minutes, and losing the remaining thirty screenshots because one page
|
||||
* misbehaved is worse than a gap in the output. Re-running a block simply
|
||||
* overwrites the screenshots it had already taken.
|
||||
*/
|
||||
async function withPage(
|
||||
browser: Browser,
|
||||
viewport: { width: number; height: number },
|
||||
fn: (page: Page) => Promise<void>,
|
||||
initScript?: () => void,
|
||||
): Promise<void> {
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
await ensureServerHealthy();
|
||||
const context = await browser.newContext({ viewport });
|
||||
// The dev server compiles routes on first hit, which can exceed Playwright's
|
||||
// 30s default right after a settings change.
|
||||
context.setDefaultNavigationTimeout(90000);
|
||||
context.setDefaultTimeout(30000);
|
||||
if (initScript) await context.addInitScript(initScript);
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await fn(page);
|
||||
return;
|
||||
} catch (err) {
|
||||
const reason = String(err).split('\n')[0];
|
||||
if (attempt === 1) {
|
||||
console.error(` ↻ ${currentSection} failed (${reason}) — retrying`);
|
||||
} else {
|
||||
failedSections.push(currentSection);
|
||||
console.error(` ⚠️ ${currentSection} failed: ${reason}`);
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function captureHome(page: Page, name: string, urlPath = '/'): Promise<void> {
|
||||
await page.goto(`${BASE_URL}${urlPath}`, { waitUntil: 'networkidle' });
|
||||
await revealDeferredContent(page);
|
||||
await waitForImages(page);
|
||||
await capture(page, name);
|
||||
}
|
||||
|
||||
async function captureGrid(page: Page, urlPath: string, name: string): Promise<void> {
|
||||
await capturePage(page, urlPath, name, 3000);
|
||||
}
|
||||
|
||||
/** Load a page, reveal everything that fades or lazy-loads in, then shoot it. */
|
||||
async function capturePage(
|
||||
page: Page,
|
||||
urlPath: string,
|
||||
name: string,
|
||||
settleMs: number,
|
||||
): Promise<void> {
|
||||
await page.goto(`${BASE_URL}${urlPath}`, { waitUntil: 'networkidle' });
|
||||
await revealDeferredContent(page);
|
||||
await waitForImages(page);
|
||||
// Extra settle time for late-loading images in lower columns
|
||||
await page.waitForTimeout(settleMs);
|
||||
await capture(page, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the first still image of an album in the lightbox and expand the EXIF
|
||||
* panel. Videos are skipped — their tile opens a player, not a photo.
|
||||
*/
|
||||
async function captureLightbox(page: Page, gridPath: string): Promise<void> {
|
||||
await page.goto(`${BASE_URL}${gridPath}`, { waitUntil: 'networkidle' });
|
||||
await revealDeferredContent(page);
|
||||
await waitForImages(page);
|
||||
|
||||
const tiles = page.locator('.photo-grid__item');
|
||||
const count = await tiles.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const tile = tiles.nth(i);
|
||||
if ((await tile.locator('.photo-grid__item-play').count()) > 0) continue;
|
||||
await tile.click();
|
||||
break;
|
||||
}
|
||||
|
||||
await page.keyboard.press('i');
|
||||
await page.locator('#exif-panel').waitFor({ state: 'visible', timeout: 5000 });
|
||||
await waitForImages(page);
|
||||
await page.waitForTimeout(1500);
|
||||
await capture(page, 'lightbox-exif');
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin panel: login screen, page builder, album picker and settings editor.
|
||||
* Skipped with a warning when ADMIN_PASSWORD is unset — the panel is optional
|
||||
* and a missing password should not fail the whole run.
|
||||
*/
|
||||
async function captureAdmin(browser: Browser): Promise<void> {
|
||||
section('Admin panel');
|
||||
if (!ADMIN_PASSWORD) {
|
||||
console.log(' ⏭ ADMIN_PASSWORD not set — skipping the admin screenshots.');
|
||||
return;
|
||||
}
|
||||
|
||||
await withPage(browser, VIEWPORT, async (page) => {
|
||||
await page.goto(`${BASE_URL}/admin`, { waitUntil: 'networkidle' });
|
||||
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 page.waitForTimeout(2500);
|
||||
await waitForImages(page);
|
||||
await capture(page, 'admin-page-builder');
|
||||
|
||||
await captureAlbumPicker(page);
|
||||
|
||||
// Reload rather than trusting Escape to have closed the picker — a modal
|
||||
// still on screen swallows the click on the settings tab.
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
await page.locator('.admin-tabs').waitFor({ timeout: 20000 });
|
||||
await page.locator('.admin-tab', { hasText: 'Settings' }).first().click();
|
||||
await page.waitForTimeout(1500);
|
||||
await capture(page, 'admin-settings');
|
||||
});
|
||||
}
|
||||
|
||||
/** 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();
|
||||
if ((await trigger.count()) === 0) {
|
||||
console.log(' ⏭ No "Add album" button found — skipping the album picker shot.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await trigger.click();
|
||||
await page.locator('input[placeholder*="Search" i]').first().waitFor({ timeout: 10000 });
|
||||
await page.waitForTimeout(2000);
|
||||
await waitForImages(page);
|
||||
await capture(page, 'admin-album-picker');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(500);
|
||||
} catch {
|
||||
console.log(' ⏭ Album picker did not open in time — skipping that shot.');
|
||||
}
|
||||
}
|
||||
|
||||
async function capture(page: Page, name: string): Promise<void> {
|
||||
await hideDevOverlays(page);
|
||||
await page.screenshot({ path: path.join(OUTPUT_DIR, `${name}.png`), fullPage: false });
|
||||
console.log(` ✅ ${name}.png`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal content that only appears on scroll or interaction: fade-in wrappers
|
||||
* stay transparent until their observer fires, and lazy images never load
|
||||
* below the fold. Both would otherwise be captured empty.
|
||||
*/
|
||||
async function revealDeferredContent(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('.fade-in').forEach((el) => el.classList.add('fade-in--visible'));
|
||||
// Trigger lazy images by switching to eager loading
|
||||
document.querySelectorAll('img[loading="lazy"]').forEach((img) => {
|
||||
(img as HTMLImageElement).loading = 'eager';
|
||||
});
|
||||
});
|
||||
await waitForImages(page);
|
||||
// Extra settle time for late-loading images in lower columns
|
||||
await page.waitForTimeout(3000);
|
||||
await page.screenshot({
|
||||
path: path.join(OUTPUT_DIR, `theme-${theme}-grid.png`),
|
||||
fullPage: false,
|
||||
}
|
||||
|
||||
/** Next.js' dev indicator and the in-app dev toolbar are not part of the product. */
|
||||
async function hideDevOverlays(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('nextjs-portal').forEach((el) => el.remove());
|
||||
document.querySelectorAll('[aria-label="Dev Toolbar"]').forEach((el) => el.remove());
|
||||
});
|
||||
console.log(` ✅ theme-${theme}-grid.png`);
|
||||
}
|
||||
|
||||
await context.close();
|
||||
// ── Environment ──────────────────────────────────────────────────
|
||||
|
||||
// Stop dev server
|
||||
if (server.pid) {
|
||||
process.kill(-server.pid, 'SIGTERM');
|
||||
/**
|
||||
* Minimal .env.local reader. Next loads the file for the dev server it spawns,
|
||||
* but this process needs ADMIN_PASSWORD too, and real environment variables
|
||||
* must keep winning over the file.
|
||||
*/
|
||||
function loadEnvLocal(): void {
|
||||
const envFile = path.join(process.cwd(), '.env.local');
|
||||
if (!fs.existsSync(envFile)) return;
|
||||
|
||||
for (const line of fs.readFileSync(envFile, 'utf8').split('\n')) {
|
||||
const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
||||
if (!match || line.trimStart().startsWith('#')) continue;
|
||||
const [, key, rawValue] = match;
|
||||
if (process.env[key] !== undefined) continue;
|
||||
process.env[key] = rawValue.trim().replace(/^(["'])(.*)\1$/, '$2');
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
const timeout = setTimeout(resolve, 3000);
|
||||
server.on('close', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
console.log(' 🛑 Server stopped');
|
||||
}
|
||||
|
||||
// ── Content rewriting ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse settings.yaml, merge the demo identity plus `overrides` (dotted keys)
|
||||
* and dump it back. Comments are lost in the temporary file, which is fine —
|
||||
* the original is restored verbatim from its backup, never re-serialised.
|
||||
*/
|
||||
function settingsWith(original: string, overrides: Record<string, unknown>): string {
|
||||
const doc = (yaml.load(original) ?? {}) as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries({ ...DEMO_SETTINGS, ...overrides })) {
|
||||
setPath(doc, key, value);
|
||||
}
|
||||
return yaml.dump(doc, { lineWidth: 0 });
|
||||
}
|
||||
|
||||
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic'];
|
||||
for (const hero of HERO_STYLES) {
|
||||
console.log(`\n🖼 Hero Style: ${hero} (Theme: studio)`);
|
||||
console.log('─'.repeat(50));
|
||||
function setPath(target: Record<string, unknown>, dottedKey: string, value: unknown): void {
|
||||
const keys = dottedKey.split('.');
|
||||
let node = target;
|
||||
for (const key of keys.slice(0, -1)) {
|
||||
if (typeof node[key] !== 'object' || node[key] === null) node[key] = {};
|
||||
node = node[key] as Record<string, unknown>;
|
||||
}
|
||||
node[keys[keys.length - 1]] = value;
|
||||
}
|
||||
|
||||
const yamlWithTheme = setThemeInSettingsYaml(originalYaml, 'studio');
|
||||
const yamlWithHero = setHeroStyleInSettingsYaml(yamlWithTheme, hero);
|
||||
fs.writeFileSync(SETTINGS_YAML, yamlWithHero);
|
||||
/** Replace every `password:` value in gallery.yaml with a throwaway one. */
|
||||
function withRedactedPasswords(original: string): string {
|
||||
const doc = yaml.load(original);
|
||||
const walk = (node: unknown): void => {
|
||||
if (Array.isArray(node)) return node.forEach(walk);
|
||||
if (typeof node !== 'object' || node === null) return;
|
||||
const record = node as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (key === 'password' && typeof value === 'string') record[key] = 'demo';
|
||||
else walk(value);
|
||||
}
|
||||
};
|
||||
walk(doc);
|
||||
return yaml.dump(doc, { lineWidth: 0 });
|
||||
}
|
||||
|
||||
console.log(' ⏳ Starting dev server...');
|
||||
const server = startDevServer();
|
||||
await waitForServer(BASE_URL, 15000);
|
||||
console.log(' ✅ Server ready');
|
||||
/**
|
||||
* A demo about page. The portrait is taken from the gallery's hero images so the
|
||||
* layout is shown with a real photo without publishing the operator's face, and
|
||||
* without hardcoding an asset UUID into the repository.
|
||||
*/
|
||||
function demoAboutMarkdown(galleryYaml: string): string {
|
||||
const gallery = (yaml.load(galleryYaml) ?? {}) as { hero?: unknown };
|
||||
const hero = Array.isArray(gallery.hero) ? gallery.hero : [];
|
||||
const portrait = typeof hero[0] === 'string' ? hero[0] : '';
|
||||
|
||||
const context = await browser.newContext({ viewport: VIEWPORT });
|
||||
const page = await context.newPage();
|
||||
const frontmatter = [
|
||||
portrait ? `portrait: ${portrait}` : null,
|
||||
'name: Alex Rivera',
|
||||
'location: Kyoto, Japan',
|
||||
'gear:',
|
||||
' - Leica Q3',
|
||||
' - Summilux 35mm f/1.4',
|
||||
' - Summicron 50mm f/2',
|
||||
].filter((line): line is string => line !== null);
|
||||
|
||||
return [
|
||||
'---',
|
||||
...frontmatter,
|
||||
'---',
|
||||
'',
|
||||
'Photographer and traveller.',
|
||||
'Obsessed with light, geometry, and the quiet in-between.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ── Page discovery ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find an album detail page by walking the running site.
|
||||
*
|
||||
* Album slugs are derived from Immich album names, so no path can be hardcoded
|
||||
* without tying the script to one installation. Start at the homepage, follow
|
||||
* internal links, and take the first page that renders a photo grid — either a
|
||||
* standalone album, or an album reached through a subpage's cover grid.
|
||||
* Password-gated pages are skipped.
|
||||
*/
|
||||
async function discoverGridPath(page: Page): Promise<string> {
|
||||
const seen = new Set<string>();
|
||||
|
||||
const internalLinks = async (selector: string): Promise<string[]> =>
|
||||
(
|
||||
await page.$$eval(selector, (els) =>
|
||||
els.map((el) => el.getAttribute('href')).filter((h): h is string => !!h),
|
||||
)
|
||||
).filter((href) => href.startsWith('/') && !EXCLUDED_PATHS.has(href) && !seen.has(href));
|
||||
|
||||
/** A photo grid means we arrived at an album; a password field means we cannot. */
|
||||
const pageKind = async (): Promise<'grid' | 'gated' | 'other'> => {
|
||||
if ((await page.locator('input[type="password"]').count()) > 0) return 'gated';
|
||||
if ((await page.locator('.photo-grid, .essay').count()) > 0) return 'grid';
|
||||
return 'other';
|
||||
};
|
||||
|
||||
console.log(' 📸 Capturing homepage hero...');
|
||||
await page.goto(BASE_URL, { waitUntil: 'networkidle' });
|
||||
await waitForImages(page);
|
||||
await page.screenshot({
|
||||
path: path.join(OUTPUT_DIR, `hero-${hero}-home.png`),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(` ✅ hero-${hero}-home.png`);
|
||||
const entryPoints = await internalLinks('a[href^="/"]');
|
||||
|
||||
await context.close();
|
||||
for (const entry of entryPoints) {
|
||||
seen.add(entry);
|
||||
await page.goto(`${BASE_URL}${entry}`, { waitUntil: 'networkidle' });
|
||||
|
||||
if (server.pid) {
|
||||
process.kill(-server.pid, 'SIGTERM');
|
||||
const kind = await pageKind();
|
||||
if (kind === 'gated') continue;
|
||||
if (kind === 'grid') return entry;
|
||||
|
||||
// A subpage listing — descend into its album covers.
|
||||
for (const cover of await internalLinks('.subpage-grid__item, .album-card')) {
|
||||
seen.add(cover);
|
||||
await page.goto(`${BASE_URL}${cover}`, { waitUntil: 'networkidle' });
|
||||
if ((await pageKind()) === 'grid') return cover;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
const timeout = setTimeout(resolve, 3000);
|
||||
server.on('close', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
console.log(' 🛑 Server stopped');
|
||||
}
|
||||
} finally {
|
||||
// Always restore original settings.yaml
|
||||
fs.writeFileSync(SETTINGS_YAML, originalYaml);
|
||||
console.log('\n✅ Restored original gallery.yaml');
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log(`\n🎉 Done! Screenshots saved to ${OUTPUT_DIR}/`);
|
||||
console.log(
|
||||
` ${fs
|
||||
.readdirSync(OUTPUT_DIR)
|
||||
.filter((f) => f.endsWith('.png'))
|
||||
.join(', ')}`,
|
||||
throw new Error(
|
||||
'Could not find a public album page to screenshot. Every reachable page was ' +
|
||||
'password-protected or empty. Point the script at a specific album with ' +
|
||||
'SCREENSHOT_GRID_PATH=/my-subpage/my-album npx tsx scripts/screenshots.ts',
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Replace the theme preset in settings.yaml.
|
||||
* Handles both `theme: preset` shorthand and `theme:\n preset: "x"` block.
|
||||
* The collection overview belonging to the album, i.e. `/japan` for
|
||||
* `/japan/koyasan-2023`. Standalone albums have no parent, so fall back to the
|
||||
* first navigation entry that renders a subpage grid; null if there is none.
|
||||
*/
|
||||
function setThemeInSettingsYaml(yaml: string, theme: string): string {
|
||||
// Handle ` preset: "studio"` or ` preset: studio` (block format)
|
||||
if (/^ preset:\s*/m.test(yaml)) {
|
||||
return yaml.replace(/^ preset:\s*.+$/m, ` preset: "${theme}"`);
|
||||
async function resolveSubpagePath(page: Page, gridPath: string): Promise<string | null> {
|
||||
const segments = gridPath.split('/').filter(Boolean);
|
||||
if (segments.length > 1) return `/${segments[0]}`;
|
||||
|
||||
const links = await page.$$eval('nav a[href^="/"]', (els) =>
|
||||
els.map((el) => el.getAttribute('href')).filter((h): h is string => !!h),
|
||||
);
|
||||
for (const href of links) {
|
||||
if (EXCLUDED_PATHS.has(href) || href === gridPath) continue;
|
||||
await page.goto(`${BASE_URL}${href}`, { waitUntil: 'networkidle' });
|
||||
if ((await page.locator('.subpage-grid').count()) > 0) return href;
|
||||
}
|
||||
// Handle `theme: studio` shorthand
|
||||
if (/^theme:\s*\w+\s*$/m.test(yaml)) {
|
||||
return yaml.replace(/^theme:\s*\w+\s*$/m, `theme: ${theme}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Content restore ──────────────────────────────────────────────
|
||||
|
||||
function restoreManagedFiles(originals: Map<string, string>): void {
|
||||
for (const [file, content] of originals) {
|
||||
fs.writeFileSync(file, content);
|
||||
fs.rmSync(backupPath(file), { force: true });
|
||||
}
|
||||
// Fallback: insert block after the theme comment
|
||||
const marker = '# Presets: studio, minimal, editorial, classic, noir, monograph';
|
||||
if (yaml.includes(marker)) {
|
||||
return yaml.replace(marker, `${marker}\ntheme: ${theme}`);
|
||||
}
|
||||
return yaml + `\ntheme: ${theme}\n`;
|
||||
console.log('\n✅ Restored original content files');
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the heroStyle in settings.yaml.
|
||||
* Restore the content files when the process is killed. Without this, Ctrl+C in
|
||||
* the middle of a run leaves the site on whatever theme was being captured —
|
||||
* which matters because these files are the live configuration.
|
||||
*/
|
||||
function setHeroStyleInSettingsYaml(yaml: string, style: string): string {
|
||||
if (/^ heroStyle:\s*/m.test(yaml)) {
|
||||
return yaml.replace(/^ heroStyle:\s*.+$/m, ` heroStyle: "${style}"`);
|
||||
function installRestoreHandlers(): void {
|
||||
let restored = false;
|
||||
const restore = () => {
|
||||
if (restored) return;
|
||||
restored = true;
|
||||
for (const file of MANAGED_FILES) {
|
||||
try {
|
||||
if (!fs.existsSync(backupPath(file))) continue;
|
||||
fs.writeFileSync(file, fs.readFileSync(backupPath(file), 'utf8'));
|
||||
fs.rmSync(backupPath(file), { force: true });
|
||||
} catch {
|
||||
console.error(
|
||||
`\n⚠️ Could not restore ${file} automatically. Copy it back from ${backupPath(file)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log('\n✅ Restored original content files');
|
||||
};
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
|
||||
process.on(signal, () => {
|
||||
restore();
|
||||
process.exit(130);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Server lifecycle ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One dev server for the whole run. The YAML loader caches by mtime and the
|
||||
* pages are force-dynamic, so a rewritten settings.yaml is picked up by the next
|
||||
* request — restarting the server per theme would only cost startup time.
|
||||
*
|
||||
* It does not always survive a full pass though: after a few dozen recompiles
|
||||
* the dev server occasionally stops answering, which used to turn into a run of
|
||||
* navigation timeouts. ensureServerHealthy() replaces it when that happens.
|
||||
*/
|
||||
let devServer: ChildProcess | undefined;
|
||||
|
||||
async function ensureServerHealthy(): Promise<void> {
|
||||
if (devServer && (await serverResponds())) return;
|
||||
if (devServer) {
|
||||
console.log(' ♻️ Dev server stopped responding — restarting it');
|
||||
await stopDevServer();
|
||||
}
|
||||
devServer = startDevServer();
|
||||
await waitForServer(BASE_URL, 90000);
|
||||
console.log(' ✅ Server ready');
|
||||
}
|
||||
|
||||
async function serverResponds(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/api/health`, { signal: AbortSignal.timeout(20000) });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return yaml;
|
||||
}
|
||||
|
||||
function startDevServer(): ChildProcess {
|
||||
const server = spawn('npm', ['run', 'dev'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'pipe',
|
||||
env: { ...process.env },
|
||||
env: { ...process.env, PORT },
|
||||
detached: true,
|
||||
});
|
||||
|
||||
@@ -208,11 +735,34 @@ function startDevServer(): ChildProcess {
|
||||
return server;
|
||||
}
|
||||
|
||||
async function stopDevServer(): Promise<void> {
|
||||
const server = devServer;
|
||||
devServer = undefined;
|
||||
if (!server) return;
|
||||
if (server.pid) {
|
||||
try {
|
||||
process.kill(-server.pid, 'SIGTERM');
|
||||
} catch {
|
||||
// Already gone
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
const timeout = setTimeout(resolve, 3000);
|
||||
server.on('close', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
console.log('🛑 Server stopped');
|
||||
}
|
||||
|
||||
async function waitForServer(url: string, timeoutMs: number): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
// /api/health does not touch Immich, so it answers as soon as the server
|
||||
// is up rather than waiting on the first album fetch.
|
||||
const res = await fetch(`${url}/api/health`, { signal: AbortSignal.timeout(10000) });
|
||||
if (res.ok) return;
|
||||
} catch {
|
||||
// Server not ready yet
|
||||
@@ -226,7 +776,7 @@ async function waitForServer(url: string, timeoutMs: number): Promise<void> {
|
||||
* Wait for all <img> elements to finish loading their full-resolution images.
|
||||
* This prevents capturing blurhash placeholders instead of real photos.
|
||||
*/
|
||||
async function waitForImages(page: import('@playwright/test').Page): Promise<void> {
|
||||
async function waitForImages(page: Page): Promise<void> {
|
||||
await page.waitForTimeout(1000); // Initial settle
|
||||
|
||||
await page.evaluate((timeout) => {
|
||||
@@ -240,15 +790,17 @@ async function waitForImages(page: import('@playwright/test').Page): Promise<voi
|
||||
return;
|
||||
}
|
||||
|
||||
const done = () => {
|
||||
pending--;
|
||||
if (pending <= 0) setTimeout(resolve, 500);
|
||||
};
|
||||
|
||||
// Every callback below is inline and anonymous on purpose: tsx compiles
|
||||
// this file with esbuild's keepNames, which rewrites *named* functions
|
||||
// into `__name(fn, '…')` — and that helper does not exist in the page.
|
||||
images.forEach((img) => {
|
||||
if (!img.complete) {
|
||||
img.addEventListener('load', done);
|
||||
img.addEventListener('error', done);
|
||||
img.addEventListener('load', () => {
|
||||
if (--pending <= 0) setTimeout(resolve, 500);
|
||||
});
|
||||
img.addEventListener('error', () => {
|
||||
if (--pending <= 0) setTimeout(resolve, 500);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -265,12 +817,16 @@ function sleep(ms: number): Promise<void> {
|
||||
// ── Run ──────────────────────────────────────────────────────────
|
||||
main().catch((err) => {
|
||||
console.error('❌ Screenshot generation failed:', err);
|
||||
// Try to restore gallery.yaml on error
|
||||
// The finally block normally restores; this covers a throw before it is reached.
|
||||
for (const file of MANAGED_FILES) {
|
||||
try {
|
||||
const original = fs.readFileSync(SETTINGS_YAML + '.bak', 'utf8');
|
||||
fs.writeFileSync(SETTINGS_YAML, original);
|
||||
if (!fs.existsSync(backupPath(file))) continue;
|
||||
fs.writeFileSync(file, fs.readFileSync(backupPath(file), 'utf8'));
|
||||
fs.rmSync(backupPath(file), { force: true });
|
||||
console.log(`✅ Restored ${path.basename(file)}`);
|
||||
} catch {
|
||||
// Backup may not exist
|
||||
console.error(`⚠️ Restore failed — copy ${file} back from ${backupPath(file)}`);
|
||||
}
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||