chore: add CSP headers, fix mobile hero, clean up plans
- Add Content-Security-Policy + security headers to next.config.ts
- Fix hero image cutoff on mobile iOS (40vh → 50svh)
- Mark all plan checklists as complete
- Clean up IDEAS.md: remove implemented items, add ✅ table
- Add photo stories implementation plan draft
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# Immich Lightbox — Public Photo Gallery
|
||||
|
||||
> **Status: ✅ Complete** — All items implemented and shipped.
|
||||
|
||||
## Objective
|
||||
|
||||
Build a public-facing web gallery that connects to a private Immich server via its API, selects specific albums, and presents them as a beautiful, Lightroom-style portfolio website — similar in spirit to [ralfo.myportfolio.com](http://ralfo.myportfolio.com/). The Immich server stays behind the firewall; only curated album images flow outward through this app.
|
||||
@@ -30,31 +32,31 @@ The Lightbox Server sits between visitors and Immich. It proxies images, caches
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
- [ ] 1. **Scaffold the Next.js project** — Initialize a Next.js 15 app with TypeScript in the current workspace directory using `npx -y create-next-app@latest ./`. Configure project settings (app router, TypeScript, ESLint). This is the foundation everything else builds on.
|
||||
- [x] 1. **Scaffold the Next.js project** — Initialize a Next.js 15 app with TypeScript in the current workspace directory using `npx -y create-next-app@latest ./`. Configure project settings (app router, TypeScript, ESLint). This is the foundation everything else builds on.
|
||||
|
||||
- [ ] 2. **Create the Immich API client module** — Build a server-only module (`lib/immich.ts`) that wraps the Immich REST API. This module handles authentication (API key from env vars), album listing (`GET /api/albums`), album detail fetching (`GET /api/albums/{id}`), and asset thumbnail/original URL construction. All API calls happen server-side only — the API key never reaches the browser.
|
||||
- [x] 2. **Create the Immich API client module** — Build a server-only module (`lib/immich.ts`) that wraps the Immich REST API. This module handles authentication (API key from env vars), album listing (`GET /api/albums`), album detail fetching (`GET /api/albums/{id}`), and asset thumbnail/original URL construction. All API calls happen server-side only — the API key never reaches the browser.
|
||||
|
||||
- [ ] 3. **Create environment configuration** — Set up `.env.local.example` documenting required variables: `IMMICH_API_URL` (the Immich server base URL), `IMMICH_API_KEY` (service account key), and `LIGHTBOX_ALBUMS` (comma-separated album IDs to publish). Add a `lib/config.ts` module that validates and exports typed config at startup.
|
||||
- [x] 3. **Create environment configuration** — Set up `.env.local.example` documenting required variables: `IMMICH_API_URL` (the Immich server base URL), `IMMICH_API_KEY` (service account key), and `LIGHTBOX_ALBUMS` (comma-separated album IDs to publish). Add a `lib/config.ts` module that validates and exports typed config at startup.
|
||||
|
||||
- [ ] 4. **Build the image proxy API route** — Create `app/api/image/[id]/route.ts` that proxies image requests through to Immich. Accepts an asset ID and optional `size` query param (thumbnail vs. original). Streams the response from Immich to the client, setting appropriate cache headers (long `Cache-Control` since images are immutable). This keeps the Immich server unexposed while serving images to visitors.
|
||||
- [x] 4. **Build the image proxy API route** — Create `app/api/image/[id]/route.ts` that proxies image requests through to Immich. Accepts an asset ID and optional `size` query param (thumbnail vs. original). Streams the response from Immich to the client, setting appropriate cache headers (long `Cache-Control` since images are immutable). This keeps the Immich server unexposed while serving images to visitors.
|
||||
|
||||
- [ ] 5. **Design and implement the global layout and theme** — Create the root layout (`app/layout.tsx`) and global styles (`app/globals.css`). Establish a dark, minimal design system: near-black backgrounds, subtle borders, elegant typography (Google Fonts — Inter or similar). Include a minimal header/nav with the site title and responsive breakpoints. The aesthetic should feel premium and image-focused, not app-like.
|
||||
- [x] 5. **Design and implement the global layout and theme** — Create the root layout (`app/layout.tsx`) and global styles (`app/globals.css`). Establish a dark, minimal design system: near-black backgrounds, subtle borders, elegant typography (Google Fonts — Inter or similar). Include a minimal header/nav with the site title and responsive breakpoints. The aesthetic should feel premium and image-focused, not app-like.
|
||||
|
||||
- [ ] 6. **Build the homepage — album grid** — Create `app/page.tsx` as a server component that fetches the configured albums from Immich, displays them in a responsive grid layout. Each album card shows its cover image (fetched via the proxy), album title, and asset count. Clicking an album navigates to its detail page. Add hover effects (subtle scale, overlay with title) for an engaging, interactive feel.
|
||||
- [x] 6. **Build the homepage — album grid** — Create `app/page.tsx` as a server component that fetches the configured albums from Immich, displays them in a responsive grid layout. Each album card shows its cover image (fetched via the proxy), album title, and asset count. Clicking an album navigates to its detail page. Add hover effects (subtle scale, overlay with title) for an engaging, interactive feel.
|
||||
|
||||
- [ ] 7. **Build the album detail page — photo grid** — Create `app/album/[id]/page.tsx` as a server component. Fetches all assets in the selected album from Immich, displays them in a masonry or justified grid using CSS Grid. Shows thumbnails initially for fast loading. A back-to-home navigation link or breadcrumb provides context. Each image is clickable to open a fullscreen lightbox.
|
||||
- [x] 7. **Build the album detail page — photo grid** — Create `app/album/[id]/page.tsx` as a server component. Fetches all assets in the selected album from Immich, displays them in a masonry or justified grid using CSS Grid. Shows thumbnails initially for fast loading. A back-to-home navigation link or breadcrumb provides context. Each image is clickable to open a fullscreen lightbox.
|
||||
|
||||
- [ ] 8. **Build the client-side lightbox component** — Create a `components/Lightbox.tsx` client component. When a user clicks a photo in the album grid, an overlay opens showing the full-resolution image with smooth fade/slide transitions. Include previous/next navigation (arrow keys + swipe on mobile), close button (Esc key support), and an optional EXIF info overlay (camera, lens, ISO, aperture, shutter speed — data from Immich). Preload adjacent images for snappy navigation.
|
||||
- [x] 8. **Build the client-side lightbox component** — Create a `components/Lightbox.tsx` client component. When a user clicks a photo in the album grid, an overlay opens showing the full-resolution image with smooth fade/slide transitions. Include previous/next navigation (arrow keys + swipe on mobile), close button (Esc key support), and an optional EXIF info overlay (camera, lens, ISO, aperture, shutter speed — data from Immich). Preload adjacent images for snappy navigation.
|
||||
|
||||
- [ ] 9. **Add EXIF metadata display** — Enhance the Immich API client to fetch asset EXIF data (`GET /api/assets/{id}`). Display this in the lightbox as an optional info panel: camera model, lens, focal length, aperture, shutter speed, ISO. This adds the "photographer's portfolio" feel that distinguishes this from a basic gallery.
|
||||
- [x] 9. **Add EXIF metadata display** — Enhance the Immich API client to fetch asset EXIF data (`GET /api/assets/{id}`). Display this in the lightbox as an optional info panel: camera model, lens, focal length, aperture, shutter speed, ISO. This adds the "photographer's portfolio" feel that distinguishes this from a basic gallery.
|
||||
|
||||
- [ ] 10. **Implement image loading optimizations** — Use Next.js `<Image>` component with blur placeholder data-URLs (generated server-side from Immich thumbnails). Implement progressive loading: show tiny blurred thumbnails instantly, then swap to full thumbnails on load. Apply `loading="lazy"` on below-the-fold images. Set proper `sizes` attributes for responsive image delivery.
|
||||
- [x] 10. **Implement image loading optimizations** — Use Next.js `<Image>` component with blur placeholder data-URLs (generated server-side from Immich thumbnails). Implement progressive loading: show tiny blurred thumbnails instantly, then swap to full thumbnails on load. Apply `loading="lazy"` on below-the-fold images. Set proper `sizes` attributes for responsive image delivery.
|
||||
|
||||
- [ ] 11. **Add server-side caching layer** — Implement an in-memory LRU cache (or file-based cache in production) for Immich API responses. Album lists and metadata rarely change, so cache with a configurable TTL (default 5 minutes). Image proxy responses rely on HTTP cache headers; no server-side image caching needed. This dramatically reduces Immich API load.
|
||||
- [x] 11. **Add server-side caching layer** — Implement an in-memory LRU cache (or file-based cache in production) for Immich API responses. Album lists and metadata rarely change, so cache with a configurable TTL (default 5 minutes). Image proxy responses rely on HTTP cache headers; no server-side image caching needed. This dramatically reduces Immich API load.
|
||||
|
||||
- [ ] 12. **Create the Docker deployment setup** — Write a `Dockerfile` (multi-stage: build + runtime) and `docker-compose.yml`. The compose file references env vars for Immich connection details. Include health check and proper signal handling. Document deployment in `README.md`.
|
||||
- [x] 12. **Create the Docker deployment setup** — Write a `Dockerfile` (multi-stage: build + runtime) and `docker-compose.yml`. The compose file references env vars for Immich connection details. Include health check and proper signal handling. Document deployment in `README.md`.
|
||||
|
||||
- [ ] 13. **Write the README and configuration guide** — Create a comprehensive `README.md` covering: project overview, screenshots, setup instructions (how to get an Immich API key, how to find album IDs, env var reference), Docker deployment, and development workflow.
|
||||
- [x] 13. **Write the README and configuration guide** — Create a comprehensive `README.md` covering: project overview, screenshots, setup instructions (how to get an Immich API key, how to find album IDs, env var reference), Docker deployment, and development workflow.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Developer Experience & Testing
|
||||
|
||||
> **Status: ✅ Complete** — All sections implemented. CSS Modules migration was partial by design (Lightbox + PasswordGate migrated; shared layout styles kept in `globals.css`).
|
||||
|
||||
## Objective
|
||||
|
||||
Upgrade the developer tooling and quality infrastructure for immich-lightbox. This plan covers five features from IDEAS.md: migrating to CSS Modules, adding Zod-based env validation, configuring Prettier + stricter ESLint rules, adding Playwright end-to-end tests, and adding Vitest unit tests. The goal is a codebase that is easier to maintain, catches errors earlier, and has automated test coverage for critical paths.
|
||||
@@ -17,15 +19,15 @@ Upgrade the developer tooling and quality infrastructure for immich-lightbox. Th
|
||||
|
||||
**Approach — CSS Modules (not Tailwind):** CSS Modules are the better fit here because the project already uses well-structured BEM classes, the design system relies on custom properties, and Tailwind would require learning a new utility API and add a build dependency. CSS Modules give local scoping with zero config in Next.js.
|
||||
|
||||
- [ ] 1. Create a global design-tokens file `app/tokens.css` containing only CSS custom properties (`:root { ... }`), font imports, and base reset styles extracted from the top ~56 lines of `globals.css`
|
||||
- [ ] 2. Create `components/Lightbox.module.css` — extract all `.lightbox-*`, `.exif-panel-*` rules (~140 lines) from `globals.css`
|
||||
- [ ] 3. Create `components/PasswordGate.module.css` — extract `.password-gate-*` rules (~70 lines) from `globals.css`
|
||||
- [ ] 4. Create `components/SubpageNav.module.css` — extract `.subpage-nav-*` rules if any from `globals.css`
|
||||
- [ ] 5. Create `app/[...path]/path.module.css` — extract `.photo-grid-*`, `.album-card-*`, `.album-grid-*` rules (~120 lines) from `globals.css`
|
||||
- [ ] 6. Keep shared layout styles (`.header-*`, `.main`, `.hero-*`, `.section-label-*`, `.subpage-grid-*`) in a slimmed-down `globals.css` since they are used across multiple pages. Convert the remaining BEM classes to camelCase where they become module-scoped
|
||||
- [ ] 7. Update all TSX files to import from their co-located `.module.css` and use `styles.className` syntax instead of string class names
|
||||
- [ ] 8. Move responsive media queries into each module file alongside their component styles rather than keeping them in one block at the bottom of `globals.css`
|
||||
- [ ] 9. Verify the build succeeds and visually spot-check the homepage, a subpage, the about page, and the lightbox
|
||||
- [x] 1. Create a global design-tokens file `app/tokens.css` containing only CSS custom properties (`:root { ... }`), font imports, and base reset styles extracted from the top ~56 lines of `globals.css`
|
||||
- [x] 2. Create `components/Lightbox.module.css` — extract all `.lightbox-*`, `.exif-panel-*` rules (~140 lines) from `globals.css`
|
||||
- [x] 3. Create `components/PasswordGate.module.css` — extract `.password-gate-*` rules (~70 lines) from `globals.css`
|
||||
- [x] 4. Create `components/SubpageNav.module.css` — extract `.subpage-nav-*` rules if any from `globals.css`
|
||||
- [x] 5. Create `app/[...path]/path.module.css` — extract `.photo-grid-*`, `.album-card-*`, `.album-grid-*` rules (~120 lines) from `globals.css`
|
||||
- [x] 6. Keep shared layout styles (`.header-*`, `.main`, `.hero-*`, `.section-label-*`, `.subpage-grid-*`) in a slimmed-down `globals.css` since they are used across multiple pages. Convert the remaining BEM classes to camelCase where they become module-scoped
|
||||
- [x] 7. Update all TSX files to import from their co-located `.module.css` and use `styles.className` syntax instead of string class names
|
||||
- [x] 8. Move responsive media queries into each module file alongside their component styles rather than keeping them in one block at the bottom of `globals.css`
|
||||
- [x] 9. Verify the build succeeds and visually spot-check the homepage, a subpage, the about page, and the lightbox
|
||||
|
||||
---
|
||||
|
||||
@@ -33,12 +35,12 @@ Upgrade the developer tooling and quality infrastructure for immich-lightbox. Th
|
||||
|
||||
**Current state:** [config.ts](file:///Users/ralfo/git/immich-lightbox/lib/config.ts) uses a manual `requireEnv()` helper and `parseInt()` with defaults. Errors are thrown as plain strings with no type safety on the parsed result.
|
||||
|
||||
- [ ] 1. Install `zod` as a production dependency
|
||||
- [ ] 2. Create `lib/env.ts` — define a Zod schema for all environment variables (`IMMICH_API_URL`, `IMMICH_API_KEY`, `SITE_TITLE`, `SITE_SUBTITLE`, `CACHE_TTL`, `RATE_LIMIT_RPM`) with types, defaults, and transforms (e.g. `coerce.number().default(300)` for `CACHE_TTL`)
|
||||
- [ ] 3. Export a validated, typed `env` object from `lib/env.ts` that is parsed once at startup using `z.parse(process.env)`
|
||||
- [ ] 4. Replace all `requireEnv()` calls and manual `process.env` reads in `lib/config.ts` with imports from `lib/env.ts`. Remove the now-unused `requireEnv()` function
|
||||
- [ ] 5. Update `.env.local.example` comments to document the Zod-enforced constraints (required vs optional, types, defaults)
|
||||
- [ ] 6. Verify by running `npm run build` with a valid `.env.local` — should succeed. Then temporarily remove `IMMICH_API_KEY` and confirm the build fails with a clear Zod validation error
|
||||
- [x] 1. Install `zod` as a production dependency
|
||||
- [x] 2. Create `lib/env.ts` — define a Zod schema for all environment variables (`IMMICH_API_URL`, `IMMICH_API_KEY`, `SITE_TITLE`, `SITE_SUBTITLE`, `CACHE_TTL`, `RATE_LIMIT_RPM`) with types, defaults, and transforms (e.g. `coerce.number().default(300)` for `CACHE_TTL`)
|
||||
- [x] 3. Export a validated, typed `env` object from `lib/env.ts` that is parsed once at startup using `z.parse(process.env)`
|
||||
- [x] 4. Replace all `requireEnv()` calls and manual `process.env` reads in `lib/config.ts` with imports from `lib/env.ts`. Remove the now-unused `requireEnv()` function
|
||||
- [x] 5. Update `.env.local.example` comments to document the Zod-enforced constraints (required vs optional, types, defaults)
|
||||
- [x] 6. Verify by running `npm run build` with a valid `.env.local` — should succeed. Then temporarily remove `IMMICH_API_KEY` and confirm the build fails with a clear Zod validation error
|
||||
|
||||
---
|
||||
|
||||
@@ -46,14 +48,14 @@ Upgrade the developer tooling and quality infrastructure for immich-lightbox. Th
|
||||
|
||||
**Current state:** [eslint.config.mjs](file:///Users/ralfo/git/immich-lightbox/eslint.config.mjs) uses ESLint v9 flat config with `eslint-config-next` core-web-vitals and typescript presets. No Prettier config exists. No format-on-save enforcement.
|
||||
|
||||
- [ ] 1. Install Prettier and the ESLint-Prettier integration packages: `prettier`, `eslint-config-prettier`, `eslint-plugin-prettier`
|
||||
- [ ] 2. Create `.prettierrc` with project conventions: single quotes, trailing commas, 100 char print width, 2-space indent (matching the existing code style)
|
||||
- [ ] 3. Create `.prettierignore` to skip `node_modules`, `.next`, `build`, `out`, `*.yaml`
|
||||
- [ ] 4. Add `eslint-config-prettier` to the ESLint flat config to disable formatting rules that conflict with Prettier, and add `eslint-plugin-prettier` to surface Prettier violations as ESLint errors
|
||||
- [ ] 5. Add stricter custom ESLint rules: `no-console: warn`, `@typescript-eslint/no-unused-vars: error` (with underscore-prefix ignore pattern), `@typescript-eslint/no-explicit-any: warn`
|
||||
- [ ] 6. Add npm scripts: `"format": "prettier --write ."` and `"format:check": "prettier --check ."`
|
||||
- [ ] 7. Run `npx prettier --write .` to format the entire codebase in one commit, then run `npm run lint` to confirm no remaining lint errors
|
||||
- [ ] 8. Optionally add a `.vscode/settings.json` recommending format-on-save with Prettier
|
||||
- [x] 1. Install Prettier and the ESLint-Prettier integration packages: `prettier`, `eslint-config-prettier`, `eslint-plugin-prettier`
|
||||
- [x] 2. Create `.prettierrc` with project conventions: single quotes, trailing commas, 100 char print width, 2-space indent (matching the existing code style)
|
||||
- [x] 3. Create `.prettierignore` to skip `node_modules`, `.next`, `build`, `out`, `*.yaml`
|
||||
- [x] 4. Add `eslint-config-prettier` to the ESLint flat config to disable formatting rules that conflict with Prettier, and add `eslint-plugin-prettier` to surface Prettier violations as ESLint errors
|
||||
- [x] 5. Add stricter custom ESLint rules: `no-console: warn`, `@typescript-eslint/no-unused-vars: error` (with underscore-prefix ignore pattern), `@typescript-eslint/no-explicit-any: warn`
|
||||
- [x] 6. Add npm scripts: `"format": "prettier --write ."` and `"format:check": "prettier --check ."`
|
||||
- [x] 7. Run `npx prettier --write .` to format the entire codebase in one commit, then run `npm run lint` to confirm no remaining lint errors
|
||||
- [x] 8. Optionally add a `.vscode/settings.json` recommending format-on-save with Prettier
|
||||
|
||||
---
|
||||
|
||||
@@ -64,16 +66,16 @@ Upgrade the developer tooling and quality infrastructure for immich-lightbox. Th
|
||||
> [!WARNING]
|
||||
> E2E tests require a running dev server and a configured Immich instance (or mocked API responses). Tests will need environment setup documented in a test README or CI configuration.
|
||||
|
||||
- [ ] 1. Install Playwright as a dev dependency: `npm init playwright@latest` — select TypeScript, put tests in `e2e/`, skip GitHub Actions for now
|
||||
- [ ] 2. Configure `playwright.config.ts` — set `baseURL` to `http://localhost:3000`, configure `webServer` to run `npm run dev` automatically, set screenshot-on-failure
|
||||
- [ ] 3. Write `e2e/health.spec.ts` — test `GET /api/health` returns 200 with expected JSON shape
|
||||
- [ ] 4. Write `e2e/homepage.spec.ts` — test that the homepage loads, renders the header with site title, shows hero section, and displays album cards or subpage grid
|
||||
- [ ] 5. Write `e2e/about.spec.ts` — test that `/about` loads and shows the about page content
|
||||
- [ ] 6. Write `e2e/navigation.spec.ts` — test clicking a subpage card navigates to the correct URL, and the back button works
|
||||
- [ ] 7. Write `e2e/lightbox.spec.ts` — test clicking a photo opens the lightbox overlay, arrow keys navigate, Escape closes
|
||||
- [ ] 8. Write `e2e/password.spec.ts` — test that a password-protected subpage shows the gate, rejects wrong passwords, and unlocks on correct entry
|
||||
- [ ] 9. Add `"test:e2e": "playwright test"` to npm scripts
|
||||
- [ ] 10. Ensure all tests pass against the local dev server with the existing `gallery.yaml` and `.env.local`
|
||||
- [x] 1. Install Playwright as a dev dependency: `npm init playwright@latest` — select TypeScript, put tests in `e2e/`, skip GitHub Actions for now
|
||||
- [x] 2. Configure `playwright.config.ts` — set `baseURL` to `http://localhost:3000`, configure `webServer` to run `npm run dev` automatically, set screenshot-on-failure
|
||||
- [x] 3. Write `e2e/health.spec.ts` — test `GET /api/health` returns 200 with expected JSON shape
|
||||
- [x] 4. Write `e2e/homepage.spec.ts` — test that the homepage loads, renders the header with site title, shows hero section, and displays album cards or subpage grid
|
||||
- [x] 5. Write `e2e/about.spec.ts` — test that `/about` loads and shows the about page content
|
||||
- [x] 6. Write `e2e/navigation.spec.ts` — test clicking a subpage card navigates to the correct URL, and the back button works
|
||||
- [x] 7. Write `e2e/lightbox.spec.ts` — test clicking a photo opens the lightbox overlay, arrow keys navigate, Escape closes
|
||||
- [x] 8. Write `e2e/password.spec.ts` — test that a password-protected subpage shows the gate, rejects wrong passwords, and unlocks on correct entry
|
||||
- [x] 9. Add `"test:e2e": "playwright test"` to npm scripts
|
||||
- [x] 10. Ensure all tests pass against the local dev server with the existing `gallery.yaml` and `.env.local`
|
||||
|
||||
---
|
||||
|
||||
@@ -81,14 +83,14 @@ Upgrade the developer tooling and quality infrastructure for immich-lightbox. Th
|
||||
|
||||
**Current state:** No unit test framework installed. Key pure-logic modules suitable for unit testing: [tokens.ts](file:///Users/ralfo/git/immich-lightbox/lib/tokens.ts) (encode/decode), [config.ts](file:///Users/ralfo/git/immich-lightbox/lib/config.ts) (slugify, UUID validation, YAML parsing), [urls.ts](file:///Users/ralfo/git/immich-lightbox/lib/urls.ts) (URL generation), [thumbhash.ts](file:///Users/ralfo/git/immich-lightbox/lib/thumbhash.ts) (ThumbHash conversion).
|
||||
|
||||
- [ ] 1. Install Vitest and related packages: `vitest`, `@vitest/coverage-v8` as dev dependencies
|
||||
- [ ] 2. Create `vitest.config.ts` at project root — configure path aliases to match `tsconfig.json` (`@/*`), set `environment: 'node'`, include `lib/**/*.test.ts`
|
||||
- [ ] 3. Write `lib/__tests__/tokens.test.ts` — test `encodeAssetId` produces a stable base64url token for a given UUID, `decodeAssetId` round-trips correctly, invalid tokens return null, and short tokens return null
|
||||
- [ ] 4. Write `lib/__tests__/config.test.ts` — test `slugify()` with various inputs (diacritics, special chars, leading/trailing hyphens), test UUID validation rejects invalid UUIDs, test YAML parsing with a mock `gallery.yaml` fixture
|
||||
- [ ] 5. Write `lib/__tests__/urls.test.ts` — test `imageUrl()` generates correct proxy paths with encoded tokens, test `exifUrl()`, test `assetPlaceholder()` returns null for missing thumbhash
|
||||
- [ ] 6. Write `lib/__tests__/thumbhash.test.ts` — test `thumbHashToBlurDataUrl()` produces a valid data URL, test `thumbHashToDominantHex()` returns a valid hex color
|
||||
- [ ] 7. Add npm scripts: `"test": "vitest"`, `"test:unit": "vitest run"`, `"test:coverage": "vitest run --coverage"`
|
||||
- [ ] 8. Run `npm run test:unit` and confirm all tests pass
|
||||
- [x] 1. Install Vitest and related packages: `vitest`, `@vitest/coverage-v8` as dev dependencies
|
||||
- [x] 2. Create `vitest.config.ts` at project root — configure path aliases to match `tsconfig.json` (`@/*`), set `environment: 'node'`, include `lib/**/*.test.ts`
|
||||
- [x] 3. Write `lib/__tests__/tokens.test.ts` — test `encodeAssetId` produces a stable base64url token for a given UUID, `decodeAssetId` round-trips correctly, invalid tokens return null, and short tokens return null
|
||||
- [x] 4. Write `lib/__tests__/config.test.ts` — test `slugify()` with various inputs (diacritics, special chars, leading/trailing hyphens), test UUID validation rejects invalid UUIDs, test YAML parsing with a mock `gallery.yaml` fixture
|
||||
- [x] 5. Write `lib/__tests__/urls.test.ts` — test `imageUrl()` generates correct proxy paths with encoded tokens, test `exifUrl()`, test `assetPlaceholder()` returns null for missing thumbhash
|
||||
- [x] 6. Write `lib/__tests__/thumbhash.test.ts` — test `thumbHashToBlurDataUrl()` produces a valid data URL, test `thumbHashToDominantHex()` returns a valid hex color
|
||||
- [x] 7. Add npm scripts: `"test": "vitest"`, `"test:unit": "vitest run"`, `"test:coverage": "vitest run --coverage"`
|
||||
- [x] 8. Run `npm run test:unit` and confirm all tests pass
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Performance & Images
|
||||
|
||||
> **Status: ✅ Complete** — All items implemented and shipped.
|
||||
|
||||
## Objective
|
||||
|
||||
Replace all raw `<img>` tags with `next/image`, add ThumbHash blur-up placeholders from Immich metadata, and extract dominant-color placeholders — giving every image a near-instant perceived load with automatic WebP/AVIF optimization and responsive srcsets.
|
||||
@@ -19,33 +21,33 @@ Replace all raw `<img>` tags with `next/image`, add ThumbHash blur-up placeholde
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
- [ ] 1. **Extend the image proxy to accept width/quality params** — Modify [route.ts](file:///Users/ralfo/git/immich-lightbox/app/api/image/[id]/route.ts) to accept optional `w` (width) and `q` (quality) query parameters. When `w` is provided, map it to the closest Immich size tier (`thumbnail` ≤ 250, `preview` ≤ 1440, `original` otherwise). Keep backward compatibility so existing `?size=` calls continue to work unchanged.
|
||||
- [x] 1. **Extend the image proxy to accept width/quality params** — Modify [route.ts](file:///Users/ralfo/git/immich-lightbox/app/api/image/[id]/route.ts) to accept optional `w` (width) and `q` (quality) query parameters. When `w` is provided, map it to the closest Immich size tier (`thumbnail` ≤ 250, `preview` ≤ 1440, `original` otherwise). Keep backward compatibility so existing `?size=` calls continue to work unchanged.
|
||||
|
||||
- [ ] 2. **Create a custom `next/image` loader** — Add [immichLoader.ts](file:///Users/ralfo/git/immich-lightbox/lib/immichLoader.ts) exporting a loader function that generates `/api/image/:token?w=WIDTH&q=QUALITY` URLs. Must be a standalone file referenced via `loaderFile` in `next.config.ts` (required for App Router). Handles the `src → token → proxy URL` mapping.
|
||||
- [x] 2. **Create a custom `next/image` loader** — Add [immichLoader.ts](file:///Users/ralfo/git/immich-lightbox/lib/immichLoader.ts) exporting a loader function that generates `/api/image/:token?w=WIDTH&q=QUALITY` URLs. Must be a standalone file referenced via `loaderFile` in `next.config.ts` (required for App Router). Handles the `src → token → proxy URL` mapping.
|
||||
|
||||
- [ ] 3. **Configure Next.js for the custom loader** — Modify [next.config.ts](file:///Users/ralfo/git/immich-lightbox/next.config.ts) to add `images.loader: 'custom'` and `images.loaderFile: './lib/immichLoader.ts'` so `next/image` uses our proxy globally.
|
||||
- [x] 3. **Configure Next.js for the custom loader** — Modify [next.config.ts](file:///Users/ralfo/git/immich-lightbox/next.config.ts) to add `images.loader: 'custom'` and `images.loaderFile: './lib/immichLoader.ts'` so `next/image` uses our proxy globally.
|
||||
|
||||
- [ ] 4. **Add `thumbhash` dependency** — Modify [package.json](file:///Users/ralfo/git/immich-lightbox/package.json) to add the `thumbhash` npm package (~2 KB, pure JS, no native deps). Run `npm install`.
|
||||
- [x] 4. **Add `thumbhash` dependency** — Modify [package.json](file:///Users/ralfo/git/immich-lightbox/package.json) to add the `thumbhash` npm package (~2 KB, pure JS, no native deps). Run `npm install`.
|
||||
|
||||
- [ ] 5. **Create ThumbHash helper utilities** — Add [thumbhash.ts](file:///Users/ralfo/git/immich-lightbox/lib/thumbhash.ts) with two helpers: `thumbHashToDataUrl(base64)` converting Immich's base64-encoded ThumbHash into a tiny data URL for `next/image`'s `blurDataURL` prop, and `thumbHashToDominantColor(base64)` extracting the average/dominant color as a hex string from the ThumbHash's decoded RGBA data. Since ThumbHash encodes both shape and color, both blur placeholder and dominant color come from the same Immich field — no extra API calls.
|
||||
- [x] 5. **Create ThumbHash helper utilities** — Add [thumbhash.ts](file:///Users/ralfo/git/immich-lightbox/lib/thumbhash.ts) with two helpers: `thumbHashToDataUrl(base64)` converting Immich's base64-encoded ThumbHash into a tiny data URL for `next/image`'s `blurDataURL` prop, and `thumbHashToDominantColor(base64)` extracting the average/dominant color as a hex string from the ThumbHash's decoded RGBA data. Since ThumbHash encodes both shape and color, both blur placeholder and dominant color come from the same Immich field — no extra API calls.
|
||||
|
||||
- [ ] 6. **Add placeholder helper to URL utilities** — Modify [urls.ts](file:///Users/ralfo/git/immich-lightbox/lib/urls.ts) to add an `assetPlaceholder(asset)` helper returning `{ blurDataURL, dominantColor } | null`. This encapsulates ThumbHash → placeholder conversion server-side so page components just pass it through as props.
|
||||
- [x] 6. **Add placeholder helper to URL utilities** — Modify [urls.ts](file:///Users/ralfo/git/immich-lightbox/lib/urls.ts) to add an `assetPlaceholder(asset)` helper returning `{ blurDataURL, dominantColor } | null`. This encapsulates ThumbHash → placeholder conversion server-side so page components just pass it through as props.
|
||||
|
||||
- [ ] 7. **Migrate homepage hero to `next/image`** — Modify [page.tsx](file:///Users/ralfo/git/immich-lightbox/app/page.tsx) to replace the raw `<img>` (line 58) with `next/image` using `fill` layout and `priority` (above-the-fold LCP image). Add `blurDataURL` and `placeholder="blur"` from the hero asset's ThumbHash. Fetch ThumbHash via `immich.getAssetInfo()` for the hero image ID.
|
||||
- [x] 7. **Migrate homepage hero to `next/image`** — Modify [page.tsx](file:///Users/ralfo/git/immich-lightbox/app/page.tsx) to replace the raw `<img>` (line 58) with `next/image` using `fill` layout and `priority` (above-the-fold LCP image). Add `blurDataURL` and `placeholder="blur"` from the hero asset's ThumbHash. Fetch ThumbHash via `immich.getAssetInfo()` for the hero image ID.
|
||||
|
||||
- [ ] 8. **Migrate subpage album covers to `next/image`** — Modify [page.tsx](file:///Users/ralfo/git/immich-lightbox/app/[...path]/page.tsx) to replace album cover `<img>` tags (line 98) with `next/image` using `fill` layout. Pass `blurDataURL` and `placeholder="blur"` from each album's thumbnail asset ThumbHash. Batch-fetch ThumbHash data for `albumThumbnailAssetId` when loading subpage data.
|
||||
- [x] 8. **Migrate subpage album covers to `next/image`** — Modify [page.tsx](file:///Users/ralfo/git/immich-lightbox/app/[...path]/page.tsx) to replace album cover `<img>` tags (line 98) with `next/image` using `fill` layout. Pass `blurDataURL` and `placeholder="blur"` from each album's thumbnail asset ThumbHash. Batch-fetch ThumbHash data for `albumThumbnailAssetId` when loading subpage data.
|
||||
|
||||
- [ ] 9. **Migrate about page portrait to `next/image`** — Modify [about/page.tsx](file:///Users/ralfo/git/immich-lightbox/app/about/page.tsx) to replace the portrait `<img>` (line 33) with `next/image` using `fill` layout and ThumbHash blur placeholder.
|
||||
- [x] 9. **Migrate about page portrait to `next/image`** — Modify [about/page.tsx](file:///Users/ralfo/git/immich-lightbox/app/about/page.tsx) to replace the portrait `<img>` (line 33) with `next/image` using `fill` layout and ThumbHash blur placeholder.
|
||||
|
||||
- [ ] 10. **Migrate photo grid thumbnails to `next/image`** — Modify [PhotoGrid.tsx](file:///Users/ralfo/git/immich-lightbox/app/[...path]/PhotoGrid.tsx) to replace grid `<img>` tags (line 84) with `next/image` using `fill` layout and `sizes` attribute for responsive sizing. Extend the `PhotoItem` interface with optional `blurDataURL` and `dominantColor` fields, generated server-side and passed as props. Use `placeholder="blur"` when available. Use `dominantColor` as CSS `background-color` on the grid item container for an instant color flash.
|
||||
- [x] 10. **Migrate photo grid thumbnails to `next/image`** — Modify [PhotoGrid.tsx](file:///Users/ralfo/git/immich-lightbox/app/[...path]/PhotoGrid.tsx) to replace grid `<img>` tags (line 84) with `next/image` using `fill` layout and `sizes` attribute for responsive sizing. Extend the `PhotoItem` interface with optional `blurDataURL` and `dominantColor` fields, generated server-side and passed as props. Use `placeholder="blur"` when available. Use `dominantColor` as CSS `background-color` on the grid item container for an instant color flash.
|
||||
|
||||
- [ ] 11. **Add placeholders to the Lightbox** — Modify [Lightbox.tsx](file:///Users/ralfo/git/immich-lightbox/components/Lightbox.tsx) to keep using raw `<img>` for fullscreen preview but add dominant-color background on `.lightbox__image-container` and show the ThumbHash blur as a CSS `background-image` while the main image loads, fading it out with a CSS transition on the image's `onload` event.
|
||||
- [x] 11. **Add placeholders to the Lightbox** — Modify [Lightbox.tsx](file:///Users/ralfo/git/immich-lightbox/components/Lightbox.tsx) to keep using raw `<img>` for fullscreen preview but add dominant-color background on `.lightbox__image-container` and show the ThumbHash blur as a CSS `background-image` while the main image loads, fading it out with a CSS transition on the image's `onload` event.
|
||||
|
||||
- [ ] 12. **Add CSS for placeholder transitions** — Modify [globals.css](file:///Users/ralfo/git/immich-lightbox/app/globals.css) to add smooth blur-to-sharp opacity transitions on grid items (pseudo-element or background-image), dominant color background styling for grid items, lightbox container, and album covers.
|
||||
- [x] 12. **Add CSS for placeholder transitions** — Modify [globals.css](file:///Users/ralfo/git/immich-lightbox/app/globals.css) to add smooth blur-to-sharp opacity transitions on grid items (pseudo-element or background-image), dominant color background styling for grid items, lightbox container, and album covers.
|
||||
|
||||
- [ ] 13. **Verify build and lint** — Run `npm run build` and `npm run lint` to confirm no TypeScript or ESLint errors.
|
||||
- [x] 13. **Verify build and lint** — Run `npm run build` and `npm run lint` to confirm no TypeScript or ESLint errors.
|
||||
|
||||
- [ ] 14. **Manual visual verification** — Run `npm run dev`, test homepage hero blur, subpage album cover placeholders, photo grid blur-up, lightbox dominant color background, and about page portrait placeholder. Throttle to Slow 3G in DevTools to validate placeholder visibility.
|
||||
- [x] 14. **Manual visual verification** — Run `npm run dev`, test homepage hero blur, subpage album cover placeholders, photo grid blur-up, lightbox dominant color background, and about page portrait placeholder. Throttle to Slow 3G in DevTools to validate placeholder visibility.
|
||||
|
||||
## Verification Criteria
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Photography Features
|
||||
|
||||
> **Status: ✅ Complete** — All items implemented and shipped.
|
||||
|
||||
## Objective
|
||||
|
||||
Implement four photography-focused enhancements to the gallery: a hero image carousel with crossfade transitions, automatic full-bleed rendering when a subpage contains only one album, EXIF metadata on grid hover with a global toggle, and a configurable grid layout system supporting masonry vs. uniform modes, custom column counts, gaps, and aspect ratios.
|
||||
@@ -10,47 +12,47 @@ Implement four photography-focused enhancements to the gallery: a hero image car
|
||||
|
||||
### Feature 1 — Hero Carousel
|
||||
|
||||
- [ ] 1. **Add `hero` list support to `gallery.yaml` and config** — Extend the `GalleryYaml` interface in `lib/config.ts:32` so that `hero` accepts either a single UUID string (backward compatible) or an array of UUID strings. Add a validation step that normalizes the value to an array (`heroImages: string[]`) in `AppConfig` at `lib/config.ts:100`. Update `gallery.yaml.example` with a commented example showing the array syntax. This lets users define 3–5 hero images.
|
||||
- [x] 1. **Add `hero` list support to `gallery.yaml` and config** — Extend the `GalleryYaml` interface in `lib/config.ts:32` so that `hero` accepts either a single UUID string (backward compatible) or an array of UUID strings. Add a validation step that normalizes the value to an array (`heroImages: string[]`) in `AppConfig` at `lib/config.ts:100`. Update `gallery.yaml.example` with a commented example showing the array syntax. This lets users define 3–5 hero images.
|
||||
|
||||
- [ ] 2. **Create `HeroCarousel` client component** — Create a new `components/HeroCarousel.tsx` client component. It receives a list of `{ src, blurDataURL }` objects. It rotates through images on a timer (e.g. 6 seconds), crossfading by layering two `next/image` elements with CSS opacity transitions. Only two are mounted at any time (current + next). Use `priority` on the first image only. Clear/restart the interval on unmount. If only one image is provided, render a single static image with no timer — this maintains backward compatibility with the current single-hero setup.
|
||||
- [x] 2. **Create `HeroCarousel` client component** — Create a new `components/HeroCarousel.tsx` client component. It receives a list of `{ src, blurDataURL }` objects. It rotates through images on a timer (e.g. 6 seconds), crossfading by layering two `next/image` elements with CSS opacity transitions. Only two are mounted at any time (current + next). Use `priority` on the first image only. Clear/restart the interval on unmount. If only one image is provided, render a single static image with no timer — this maintains backward compatibility with the current single-hero setup.
|
||||
|
||||
- [ ] 3. **Update homepage to use `HeroCarousel`** — Modify `app/page.tsx:62-79` to replace the single `<Image>` with the `HeroCarousel` component. Fetch `getAssetInfo` for each hero image in `config.heroImages`, generate placeholder data via `assetPlaceholder`, and pass the array to the carousel. If no hero images are configured, fall back to the existing placeholder div.
|
||||
- [x] 3. **Update homepage to use `HeroCarousel`** — Modify `app/page.tsx:62-79` to replace the single `<Image>` with the `HeroCarousel` component. Fetch `getAssetInfo` for each hero image in `config.heroImages`, generate placeholder data via `assetPlaceholder`, and pass the array to the carousel. If no hero images are configured, fall back to the existing placeholder div.
|
||||
|
||||
- [ ] 4. **Add carousel CSS** — Add CSS rules in `app/globals.css` for the carousel: stack images with `position: absolute`, crossfade using `opacity` transitions with `var(--transition-slow)`, handle `z-index` layering. Ensure the `.hero__right` container already has `position: relative; overflow: hidden` (it does) so images fill correctly.
|
||||
- [x] 4. **Add carousel CSS** — Add CSS rules in `app/globals.css` for the carousel: stack images with `position: absolute`, crossfade using `opacity` transitions with `var(--transition-slow)`, handle `z-index` layering. Ensure the `.hero__right` container already has `position: relative; overflow: hidden` (it does) so images fill correctly.
|
||||
|
||||
---
|
||||
|
||||
### Feature 2 — Full-Bleed Single Albums
|
||||
|
||||
- [ ] 5. **Detect single-album subpages in the catch-all route** — In `app/[...path]/page.tsx:150-202` (the subpage rendering branch), after fetching `getSubpageAlbums`, check if `albums.length === 1`. If so, skip the subpage album grid entirely and instead render the album detail view (album header + `PhotoGrid`) directly, consistent with how a standalone album renders. Include the back-link pointing to `/` since the intermediate subpage grid is being bypassed.
|
||||
- [x] 5. **Detect single-album subpages in the catch-all route** — In `app/[...path]/page.tsx:150-202` (the subpage rendering branch), after fetching `getSubpageAlbums`, check if `albums.length === 1`. If so, skip the subpage album grid entirely and instead render the album detail view (album header + `PhotoGrid`) directly, consistent with how a standalone album renders. Include the back-link pointing to `/` since the intermediate subpage grid is being bypassed.
|
||||
|
||||
- [ ] 6. **Preserve metadata for single-album subpages** — In `generateMetadata` at `app/[...path]/page.tsx:46-48`, when the slug is a subpage, also check if only one album exists. If so, generate metadata using the album name and photo count instead of the subpage name, since the user lands directly in the album.
|
||||
- [x] 6. **Preserve metadata for single-album subpages** — In `generateMetadata` at `app/[...path]/page.tsx:46-48`, when the slug is a subpage, also check if only one album exists. If so, generate metadata using the album name and photo count instead of the subpage name, since the user lands directly in the album.
|
||||
|
||||
---
|
||||
|
||||
### Feature 3 — EXIF on Hover
|
||||
|
||||
- [ ] 7. **Extend `PhotoItem` with EXIF summary fields** — Add optional `camera` (string), `lens` (string), and `focalLength` (string) fields to the `PhotoItem` interface in `app/[...path]/PhotoGrid.tsx:12-20`. These are compact summary strings derived server-side from `ImmichExifInfo.model`, `ImmichExifInfo.lensModel`, and `ImmichExifInfo.focalLength` on each asset.
|
||||
- [x] 7. **Extend `PhotoItem` with EXIF summary fields** — Add optional `camera` (string), `lens` (string), and `focalLength` (string) fields to the `PhotoItem` interface in `app/[...path]/PhotoGrid.tsx:12-20`. These are compact summary strings derived server-side from `ImmichExifInfo.model`, `ImmichExifInfo.lensModel`, and `ImmichExifInfo.focalLength` on each asset.
|
||||
|
||||
- [ ] 8. **Pass EXIF summary data from server pages** — In `app/[...path]/page.tsx`, when building the `PhotoItem` array for `PhotoGrid`, extract the EXIF summary from `asset.exifInfo` (already present on `ImmichAsset`). Construct the compact strings (e.g. `"Leica M11"`, `"50mm Summilux"`, `"50mm"`) and include them in each `PhotoItem`. Only include fields that are non-null.
|
||||
- [x] 8. **Pass EXIF summary data from server pages** — In `app/[...path]/page.tsx`, when building the `PhotoItem` array for `PhotoGrid`, extract the EXIF summary from `asset.exifInfo` (already present on `ImmichAsset`). Construct the compact strings (e.g. `"Leica M11"`, `"50mm Summilux"`, `"50mm"`) and include them in each `PhotoItem`. Only include fields that are non-null.
|
||||
|
||||
- [ ] 9. **Add EXIF hover tooltip to `PhotoGrid`** — In `PhotoGrid.tsx`, render a small overlay div inside each `.photo-grid__item` when `camera` or `lens` is present. The overlay shows camera + lens + focal length in a compact one-line format. Use CSS `opacity: 0` by default and `opacity: 1` on `.photo-grid__item:hover`, matching the existing hover transition timing. Position at the bottom of the grid cell.
|
||||
- [x] 9. **Add EXIF hover tooltip to `PhotoGrid`** — In `PhotoGrid.tsx`, render a small overlay div inside each `.photo-grid__item` when `camera` or `lens` is present. The overlay shows camera + lens + focal length in a compact one-line format. Use CSS `opacity: 0` by default and `opacity: 1` on `.photo-grid__item:hover`, matching the existing hover transition timing. Position at the bottom of the grid cell.
|
||||
|
||||
- [ ] 10. **Add an `exifOnHover` toggle to `gallery.yaml`** — Add an optional boolean `exifOnHover` field (default `true`) to the `GalleryYaml` interface and `AppConfig` in `lib/config.ts`. When `false`, the server pages skip attaching the EXIF data to `PhotoItem`, and the overlay elements are not rendered. Pass the flag through to `PhotoGrid` as a prop.
|
||||
- [x] 10. **Add an `exifOnHover` toggle to `gallery.yaml`** — Add an optional boolean `exifOnHover` field (default `true`) to the `GalleryYaml` interface and `AppConfig` in `lib/config.ts`. When `false`, the server pages skip attaching the EXIF data to `PhotoItem`, and the overlay elements are not rendered. Pass the flag through to `PhotoGrid` as a prop.
|
||||
|
||||
- [ ] 11. **Add CSS for EXIF hover overlay** — Add styles in `app/globals.css` for `.photo-grid__item-exif`: positioned at the bottom of the grid cell using `position: absolute; bottom: 0`, with a dark gradient background, small white text (font-size ~0.7rem), `opacity: 0` transitioning to `1` on parent hover, with `pointer-events: none` so it doesn't interfere with click-to-lightbox.
|
||||
- [x] 11. **Add CSS for EXIF hover overlay** — Add styles in `app/globals.css` for `.photo-grid__item-exif`: positioned at the bottom of the grid cell using `position: absolute; bottom: 0`, with a dark gradient background, small white text (font-size ~0.7rem), `opacity: 0` transitioning to `1` on parent hover, with `pointer-events: none` so it doesn't interfere with click-to-lightbox.
|
||||
|
||||
---
|
||||
|
||||
### Feature 4 — Customizable Grid Layout
|
||||
|
||||
- [ ] 12. **Add grid configuration to `gallery.yaml`** — Extend `GalleryYaml` and `AppConfig` in `lib/config.ts` with a `grid` object containing: `columns` (number, default 3), `gap` (number in px, default 12), `aspectRatio` (string like `"1"`, `"3/2"`, `"auto"`, default `"1"`), and `layout` (enum `"masonry"` | `"uniform"`, default `"masonry"`). Parse and validate these in `getConfig()`. Update `gallery.yaml.example` with commented examples.
|
||||
- [x] 12. **Add grid configuration to `gallery.yaml`** — Extend `GalleryYaml` and `AppConfig` in `lib/config.ts` with a `grid` object containing: `columns` (number, default 3), `gap` (number in px, default 12), `aspectRatio` (string like `"1"`, `"3/2"`, `"auto"`, default `"1"`), and `layout` (enum `"masonry"` | `"uniform"`, default `"masonry"`). Parse and validate these in `getConfig()`. Update `gallery.yaml.example` with commented examples.
|
||||
|
||||
- [ ] 13. **Pass grid config as CSS custom properties** — In the server page components (`app/[...path]/page.tsx`), wrap `PhotoGrid` in a container div that sets CSS custom properties from the grid config: `--grid-columns`, `--grid-gap`, `--grid-aspect-ratio`. This keeps the client component simple — it just reads props and the CSS handles the rest.
|
||||
- [x] 13. **Pass grid config as CSS custom properties** — In the server page components (`app/[...path]/page.tsx`), wrap `PhotoGrid` in a container div that sets CSS custom properties from the grid config: `--grid-columns`, `--grid-gap`, `--grid-aspect-ratio`. This keeps the client component simple — it just reads props and the CSS handles the rest.
|
||||
|
||||
- [ ] 14. **Update `PhotoGrid` to accept grid layout prop** — Add a `layout` prop (`"masonry" | "uniform"`) to `PhotoGridProps`. When `"masonry"`, use the existing CSS `column-count` approach. When `"uniform"`, switch to CSS `display: grid` with `grid-template-columns: repeat(var(--grid-columns), 1fr)`. Apply different classnames: `.photo-grid--masonry` vs `.photo-grid--uniform`.
|
||||
- [x] 14. **Update `PhotoGrid` to accept grid layout prop** — Add a `layout` prop (`"masonry" | "uniform"`) to `PhotoGridProps`. When `"masonry"`, use the existing CSS `column-count` approach. When `"uniform"`, switch to CSS `display: grid` with `grid-template-columns: repeat(var(--grid-columns), 1fr)`. Apply different classnames: `.photo-grid--masonry` vs `.photo-grid--uniform`.
|
||||
|
||||
- [ ] 15. **Update CSS grid styles** — Replace the hardcoded `column-count: 3`, `column-gap: 12px`, and `aspect-ratio: 1` values in `app/globals.css:390-414` with CSS custom properties: `column-count: var(--grid-columns)`, `column-gap: var(--grid-gap)`, `aspect-ratio: var(--grid-aspect-ratio)`. Add `.photo-grid--uniform` styles that use CSS Grid instead of columns. Update responsive breakpoints to adjust `--grid-columns` accordingly. When `aspectRatio` is `"auto"`, remove the fixed aspect ratio from masonry items and restore `height: auto`.
|
||||
- [x] 15. **Update CSS grid styles** — Replace the hardcoded `column-count: 3`, `column-gap: 12px`, and `aspect-ratio: 1` values in `app/globals.css:390-414` with CSS custom properties: `column-count: var(--grid-columns)`, `column-gap: var(--grid-gap)`, `aspect-ratio: var(--grid-aspect-ratio)`. Add `.photo-grid--uniform` styles that use CSS Grid instead of columns. Update responsive breakpoints to adjust `--grid-columns` accordingly. When `aspectRatio` is `"auto"`, remove the fixed aspect ratio from masonry items and restore `height: auto`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
208
plans/2026-02-15-photo-stories-v1.md
Normal file
208
plans/2026-02-15-photo-stories-v1.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Photo Stories / Essays
|
||||
|
||||
Add a Markdown-driven photo essay system to the gallery, allowing the photographer to create long-form vertical scroll pages that intersperse Immich images with narrative text — like a photo essay or editorial blog post.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Content authoring via Markdown files** — follows the same pattern as the existing `content/about.md` (frontmatter + `gray-matter`). Each story lives in `content/stories/<slug>.md`. This keeps content in the repo, versioned with Git, and avoids any database or CMS dependency.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Image references use Immich asset UUIDs** — stories reference images by UUID (e.g. ``). The renderer resolves these to proxied URLs with ThumbHash placeholders. No images are stored locally — they flow through the existing `/api/image` proxy like everything else.
|
||||
|
||||
> [!WARNING]
|
||||
> **Stories are a separate content type from albums** — they don't live under subpages. They get their own top-level routes at `/stories/<slug>` and appear in the header nav and footer as a "Stories" link. This avoids polluting the album/subpage routing logic.
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### Content Layer
|
||||
|
||||
#### [NEW] [content/stories/](file:///Users/ralfo/git/immich-lightbox/content/stories/)
|
||||
|
||||
New directory for story Markdown files. Each story is a `.md` file with YAML frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: "A Weekend in Potsdam"
|
||||
date: 2026-02-10
|
||||
cover: <immich-asset-uuid> # cover image for the story card
|
||||
---
|
||||
|
||||
An opening paragraph of text. Sets the mood.
|
||||
|
||||

|
||||
|
||||
More narrative text explaining the moment, the light, the feeling.
|
||||
|
||||

|
||||

|
||||
|
||||
A closing paragraph.
|
||||
```
|
||||
|
||||
**Authoring rules:**
|
||||
- Standard Markdown for text (paragraphs, headings, blockquotes, emphasis)
|
||||
- Images use `` syntax — the custom renderer swaps these for proxied `next/image` with ThumbHash blur
|
||||
- Two `![...]` on consecutive lines → rendered as a **side-by-side pair** (2-column layout)
|
||||
- A single `![...]` → rendered **full-width** (edge-to-edge)
|
||||
- The `cover` frontmatter UUID is used for the story card thumbnail and OG image
|
||||
|
||||
---
|
||||
|
||||
### Configuration
|
||||
|
||||
#### [MODIFY] [gallery.yaml](file:///Users/ralfo/git/immich-lightbox/content/gallery.yaml)
|
||||
|
||||
No changes needed — stories are auto-discovered from `content/stories/*.md`. This keeps configuration minimal and avoids requiring UUID registration for every story.
|
||||
|
||||
#### [MODIFY] [config.ts](file:///Users/ralfo/git/immich-lightbox/lib/config.ts)
|
||||
|
||||
Add a `stories` property to `AppConfig`:
|
||||
|
||||
```typescript
|
||||
export interface StoryConfig {
|
||||
slug: string;
|
||||
title: string;
|
||||
date: string;
|
||||
cover: string; // Immich asset UUID
|
||||
}
|
||||
|
||||
// In AppConfig:
|
||||
stories: StoryConfig[];
|
||||
```
|
||||
|
||||
Add a `loadStories()` function that scans `content/stories/*.md`, reads frontmatter from each, validates the cover UUID, and returns a sorted array (newest first).
|
||||
|
||||
---
|
||||
|
||||
### Library
|
||||
|
||||
#### [NEW] [storyParser.ts](file:///Users/ralfo/git/immich-lightbox/lib/storyParser.ts)
|
||||
|
||||
Parses a story Markdown file into a structured block array for rendering:
|
||||
|
||||
```typescript
|
||||
type StoryBlock =
|
||||
| { type: 'text'; html: string }
|
||||
| { type: 'image'; assetId: string; caption?: string; blurDataURL?: string; aspectRatio?: number }
|
||||
| { type: 'image-pair'; images: [StoryImageData, StoryImageData] };
|
||||
```
|
||||
|
||||
**Logic:**
|
||||
1. Parse frontmatter with `gray-matter`
|
||||
2. Split the body into blocks separated by blank lines
|
||||
3. Detect `` patterns
|
||||
4. Two consecutive image lines → group as `image-pair`
|
||||
5. Text blocks → render to HTML with a lightweight Markdown renderer (use `marked` or inline custom parser since the about page already does line-splitting — but for stories we want headings, emphasis, blockquotes)
|
||||
6. For each image block, call `immich.getAssetInfo()` to fetch ThumbHash and aspect ratio
|
||||
|
||||
---
|
||||
|
||||
### Pages
|
||||
|
||||
#### [NEW] [app/stories/page.tsx](file:///Users/ralfo/git/immich-lightbox/app/stories/page.tsx)
|
||||
|
||||
**Stories index page** at `/stories` — lists all stories as cards in a masonry or vertical list.
|
||||
|
||||
Each card shows:
|
||||
- Cover image (proxied from Immich, with ThumbHash blur)
|
||||
- Title
|
||||
- Date
|
||||
- Click → navigates to `/stories/<slug>`
|
||||
|
||||
Server component. Reads the story configs from `getConfig().stories` and fetches ThumbHash for each cover.
|
||||
|
||||
#### [NEW] [app/stories/[slug]/page.tsx](file:///Users/ralfo/git/immich-lightbox/app/stories/%5Bslug%5D/page.tsx)
|
||||
|
||||
**Story detail page** at `/stories/<slug>`.
|
||||
|
||||
Server component that:
|
||||
1. Reads the Markdown file from `content/stories/<slug>.md`
|
||||
2. Parses it through `storyParser.ts`
|
||||
3. Fetches ThumbHash/aspect data for all referenced images
|
||||
4. Renders the block array as a vertical scroll layout with alternating text and full-bleed images
|
||||
5. Generates metadata (title, OG image from cover)
|
||||
|
||||
---
|
||||
|
||||
### Styling
|
||||
|
||||
#### [NEW] [app/stories/stories.css](file:///Users/ralfo/git/immich-lightbox/app/stories/stories.css)
|
||||
|
||||
Story index page styles — card grid similar to the subpage grid.
|
||||
|
||||
#### [NEW] [app/stories/[slug]/story.css](file:///Users/ralfo/git/immich-lightbox/app/stories/%5Bslug%5D/story.css)
|
||||
|
||||
Story detail page styles:
|
||||
|
||||
- `.story` — max-width container (680px for text, full-bleed for images)
|
||||
- `.story__header` — title + date, large serif typography
|
||||
- `.story__text` — paragraph styles, headings, blockquotes. Serif body text for editorial feel
|
||||
- `.story__image` — single full-width image with optional caption below
|
||||
- `.story__image-pair` — two images side-by-side with equal height (CSS Grid `1fr 1fr`)
|
||||
- `.story__caption` — small italic text below images
|
||||
- Responsive: image pairs stack vertically on mobile (< 640px)
|
||||
- Generous vertical spacing between blocks (48–64px) for a magazine feel
|
||||
- Smooth fade-in animations on scroll using existing `FadeIn` component
|
||||
|
||||
---
|
||||
|
||||
### Navigation
|
||||
|
||||
#### [MODIFY] [layout.tsx](file:///Users/ralfo/git/immich-lightbox/app/layout.tsx)
|
||||
|
||||
Add a "Stories" link to the header nav (between the subpage links and "About"), conditionally rendered only when `getConfig().stories.length > 0`.
|
||||
|
||||
---
|
||||
|
||||
### Dependencies
|
||||
|
||||
#### [MODIFY] [package.json](file:///Users/ralfo/git/immich-lightbox/package.json)
|
||||
|
||||
Add `marked` (lightweight Markdown → HTML renderer, ~40KB) for rendering story text blocks. The about page's basic `split('\n')` approach won't suffice for stories since they need headings, emphasis, blockquotes, and links.
|
||||
|
||||
---
|
||||
|
||||
## Summary of Files
|
||||
|
||||
| Action | File | Purpose |
|
||||
|--------|------|---------|
|
||||
| NEW | `content/stories/` | Story markdown files directory |
|
||||
| MODIFY | `lib/config.ts` | Add `StoryConfig` type, `loadStories()`, `stories` to `AppConfig` |
|
||||
| NEW | `lib/storyParser.ts` | Parse story MD → structured block array |
|
||||
| NEW | `app/stories/page.tsx` | Stories index page (list of cards) |
|
||||
| NEW | `app/stories/stories.css` | Styles for stories index |
|
||||
| NEW | `app/stories/[slug]/page.tsx` | Story detail page (essay reader) |
|
||||
| NEW | `app/stories/[slug]/story.css` | Styles for story reader |
|
||||
| MODIFY | `app/layout.tsx` | Add conditional "Stories" nav link |
|
||||
| MODIFY | `package.json` | Add `marked` dependency |
|
||||
|
||||
## Verification Plan
|
||||
|
||||
### Automated Tests
|
||||
|
||||
1. **Unit test for `storyParser`** — add `lib/__tests__/storyParser.test.ts`:
|
||||
- Parses a single image `` into an `image` block
|
||||
- Parses two consecutive images into an `image-pair` block
|
||||
- Parses plain text into a `text` block with rendered HTML
|
||||
- Handles mixed content (text → image → text → pair → text)
|
||||
- Rejects malformed `asset:` references gracefully
|
||||
- Run: `npx vitest run lib/__tests__/storyParser.test.ts`
|
||||
|
||||
2. **Unit test for `loadStories`** — add to `lib/__tests__/config.test.ts`:
|
||||
- Returns empty array when no stories directory exists
|
||||
- Sorts stories by date descending
|
||||
- Run: `npx vitest run lib/__tests__/config.test.ts`
|
||||
|
||||
3. **Build check** — `npm run build` passes with no TypeScript errors
|
||||
|
||||
4. **Lint check** — `npm run lint` passes clean
|
||||
|
||||
### Manual Verification
|
||||
|
||||
1. Create a sample story at `content/stories/test-story.md` with mixed text + images
|
||||
2. Run `npm run dev`, navigate to `/stories` — confirm the story card appears with cover image
|
||||
3. Click into the story — confirm text renders with proper typography, images load with blur-up placeholders, and image pairs display side-by-side on desktop
|
||||
4. Test mobile (375px) — confirm image pairs stack vertically and text is readable
|
||||
5. Check `/stories` link appears in header nav
|
||||
6. Check that removing all `content/stories/*.md` files makes the "Stories" nav link disappear
|
||||
@@ -4,10 +4,7 @@
|
||||
|
||||
- **Internationalization (i18n)** — Multi-language UI support (DE/EN). Extend YAML config with locale files
|
||||
- **ISR / Stale-While-Revalidate** — Replace `force-dynamic` with Incremental Static Regeneration + on-demand revalidation
|
||||
- **Image CDN / Edge caching** — Add `Cache-Control` headers on `/api/image` proxy responses to reduce Immich load
|
||||
- **Structured logging** — Replace console.log with `pino` for production observability (request IDs, timings, cache hits)
|
||||
- **CI/CD pipeline** — GitHub Actions: lint → typecheck → unit tests → Playwright E2E → Docker build → push to GHCR
|
||||
- **Content Security Policy** — CSP headers to lock down the public-facing site
|
||||
- **CI/CD pipeline** — GitHub Actions: lint → typecheck → unit tests → Playwright E2E → Docker build → push to GHCR _(Playwright tests exist, needs GH Actions workflow)_
|
||||
- **Privacy-friendly analytics** — Plausible or Umami (self-hosted) to track album/photo engagement
|
||||
|
||||
## 📸 Photographer
|
||||
@@ -15,18 +12,15 @@
|
||||
- **Photo stories / Essays** — Vertical scroll format with images + text interspersed, like a photo essay. Portfolio differentiator
|
||||
- **Map view** — Minimal world map from EXIF city/country data, each dot links to an album. Mapbox GL or Leaflet
|
||||
- **Monochrome / Film simulation toggle** — CSS filters for grayscale, Tri-X, Portra 400 looks across the grid
|
||||
- **Before/After slider** — Draggable comparison slider in lightbox for edit showcases
|
||||
- **Collections by camera/lens** — Auto-generate virtual albums from EXIF data ("Shot on Leica Q3", "Summilux 35mm")
|
||||
- **Photo of the day / Favorites** — Rotate a featured hero photo, driven by tag in Immich or `gallery.yaml`
|
||||
|
||||
## 👤 User / Visitor
|
||||
|
||||
- **Slideshow mode** — Auto-advance through photos (3s/5s/10s). Great for exhibitions and ambient display
|
||||
- **Photo sharing** — "Copy link" button in lightbox with toast + per-photo OG image generation
|
||||
- **Photo sharing** — "Copy link" button in lightbox with toast notification
|
||||
- **Keyboard shortcut hints** — `?` button revealing available shortcuts (arrows, Esc, F for fullscreen)
|
||||
- **Fullscreen API** — Chrome-free immersive viewing in lightbox
|
||||
- **Pinch-to-zoom** — Touch gesture zoom in lightbox for detail viewing
|
||||
- **Album / Subpage descriptions** — Markdown description per subpage in `gallery.yaml`, shown above the grid
|
||||
- **Progressive loading animation** — Ken Burns zoom/pan on ThumbHash placeholders while loading
|
||||
|
||||
## 🏆 Quick Wins (High Impact / Low Effort)
|
||||
@@ -34,10 +28,31 @@
|
||||
| Idea | Effort | Impact |
|
||||
|------|--------|--------|
|
||||
| Slideshow mode | Low | High — exhibition ready |
|
||||
| Copy link + per-photo OG | Low | High — shareability |
|
||||
| Album descriptions in YAML | Low | Medium — context |
|
||||
| Fullscreen API in lightbox | Low | Medium — immersive |
|
||||
| Monochrome toggle | Low | Medium — Leica vibes |
|
||||
| ISR caching | Medium | High — performance |
|
||||
| Photo stories/essays | High | Very high — differentiator |
|
||||
| Map view | Medium | High — wow factor |
|
||||
|
||||
## ✅ Already Implemented
|
||||
|
||||
| Feature | Notes |
|
||||
|---------|-------|
|
||||
| Image CDN / Edge caching | `Cache-Control: immutable` on `/api/image` proxy |
|
||||
| OG image generation | Dynamic OG images via `/api/og` |
|
||||
| Hero image carousel | Crossfade carousel from `gallery.yaml` hero images |
|
||||
| EXIF metadata display | Print-style captions on photo grid hover |
|
||||
| Per-album password protection | Cookie-based auth gate per subpage |
|
||||
| Asset ID obfuscation | AES-encrypted tokens in public URLs |
|
||||
| Rate limiting | On image proxy endpoint |
|
||||
| YAML-based config | `gallery.yaml` replaces env-var config |
|
||||
| Playwright E2E tests | Basic test suite in `e2e/` |
|
||||
| Fade-in animations | Intersection Observer based |
|
||||
| Theme toggle (dark/light) | Persisted via cookie |
|
||||
| Scroll-to-top button | Appears on scroll |
|
||||
| Footer with social links | Configurable via `gallery.yaml` |
|
||||
| Image count badges | On subpage cover tiles |
|
||||
| Custom cursor (crosshair) | On photo grid items |
|
||||
| About page | Markdown-driven with portrait + gear |
|
||||
| Content Security Policy | CSP + security headers in `next.config.ts` |
|
||||
| Album descriptions | Uses Immich's built-in album description field |
|
||||
|
||||
Reference in New Issue
Block a user