feat: photography features & image optimizations

- Hero carousel: gallery.yaml hero accepts single UUID or array, crossfade component
- Full-bleed single albums: subpages with 1 album skip grid, render photos directly
- EXIF on hover: camera/lens/focal length overlay on grid items (toggle via exifOnHover)
- Customizable grid: columns/gap/aspectRatio/layout (masonry|uniform) via gallery.yaml
- Per-subpage grid overrides: each subpage can override global grid config
- True masonry: per-image aspect ratios from EXIF dimensions
- next/image migration: homepage hero, album covers, grid thumbnails, about portrait
- ThumbHash blur placeholders and dominant color backgrounds
- Custom next/image loader via image proxy with width/quality params
- Lightbox fade-in transition with dominant color background
- Password-protected subpages, health check, rate limiting, OG images
This commit is contained in:
ralksta
2026-02-14 10:30:27 +01:00
parent 3c69860a64
commit b7217c4a76
31 changed files with 1998 additions and 162 deletions

View File

@@ -0,0 +1,145 @@
# Developer Experience & Testing
## 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.
---
## Implementation Plan
### 1 · CSS Modules Migration
> [!IMPORTANT]
> This is the highest-effort item. The 832-line `globals.css` will be split into co-located module files, which is a broad refactor touching every component and page.
**Current state:** One monolithic [globals.css](file:///Users/ralfo/git/immich-lightbox/app/globals.css) (832 lines) containing all component styles via BEM-like class names, plus [page.module.css](file:///Users/ralfo/git/immich-lightbox/app/page.module.css) and [about.css](file:///Users/ralfo/git/immich-lightbox/app/about/about.css). Next.js already supports CSS Modules natively — no config needed.
**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
---
### 2 · Zod Env Validation
**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
---
### 3 · Prettier + ESLint Hardening
**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
---
### 4 · Playwright E2E Tests
**Current state:** No test framework is installed. The app has 3 page routes (homepage `/`, catch-all `[...path]`, about `/about`) and 5 API routes (`/api/auth`, `/api/exif`, `/api/health`, `/api/image`, `/api/og`).
> [!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`
---
### 5 · Vitest Unit Tests
**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
---
## Verification Criteria
### Automated Tests
1. **Vitest unit tests** — `npm run test:unit` must pass all tests in `lib/__tests__/`
2. **Playwright e2e tests** — `npm run test:e2e` must pass all tests in `e2e/`, requires dev server and valid Immich connection
3. **Lint check** — `npm run lint` must produce zero errors
4. **Format check** — `npm run format:check` must produce zero violations
5. **Build** — `npm run build` must succeed (catches CSS Modules import errors, Zod schema problems, TypeScript issues)
### Manual Verification
1. **Visual regression** — After CSS Modules migration, open the gallery in a browser and visually check: homepage hero, subpage grid, album cards, photo grid, lightbox with EXIF panel, password gate, about page, and all responsive breakpoints (mobile, tablet, desktop)
2. **Zod errors** — Temporarily remove `IMMICH_API_KEY` from `.env.local` and run `npm run dev` — should print a clear, typed Zod validation error instead of a generic "missing env" error
---
## Potential Risks and Mitigations
1. **CSS Modules migration breaks styling**
Mitigation: Migrate one component at a time, verify visually after each. Keep a slimmed `globals.css` for truly shared styles to avoid duplication.
2. **Playwright tests are flaky without a stable Immich backend**
Mitigation: Start with the `/api/health` endpoint test which is self-contained. For other tests, document the required Immich setup. Consider adding API mocking with Playwright's `route.fulfill()` in a future iteration.
3. **Prettier reformats the entire codebase in one commit**
Mitigation: Apply Prettier in a dedicated "format" commit with no logic changes, making `git blame` easy to filter with `git blame --ignore-rev`.
4. **Zod adds a runtime dependency**
Mitigation: Zod is ~13KB gzipped and only runs server-side at startup. No client bundle impact since `lib/env.ts` is server-only.
---
## Alternative Approaches
1. **Tailwind instead of CSS Modules** — Would provide utility-first rapid styling, but the existing design system relies heavily on custom properties and BEM. Tailwind migration would be a bigger rewrite and add a PostCSS dependency. CSS Modules is the lower-risk choice here.
2. **`@t3-oss/env-nextjs` instead of raw Zod** — A higher-level wrapper around Zod specifically for Next.js env vars with client/server separation. Worth considering if more env vars are added later, but raw Zod is simpler and sufficient for the current 6 variables.
3. **Jest instead of Vitest** — Jest works fine but Vitest is faster, has native ESM support, and shares Vite's config conventions which align better with modern Next.js tooling.
4. **Cypress instead of Playwright** — Playwright is faster, has better multi-browser support, and is increasingly the standard for Next.js projects.
---
## Suggested Implementation Order
| Phase | Feature | Dependency |
|-------|---------|------------|
| 1 | Prettier + ESLint | None — clean baseline first |
| 2 | Vitest unit tests | None — tests existing code |
| 3 | Zod env validation | Unit tests can verify schemas |
| 4 | CSS Modules migration | Lint/format tools catch issues |
| 5 | Playwright e2e tests | All other changes landed first |

View File

@@ -0,0 +1,87 @@
# Performance & Images
## 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.
## User Review Required
> [!IMPORTANT]
> **`next/image` with custom proxy** — because images are served through our own `/api/image/:token` proxy, `next/image` cannot optimize them server-side by default. Two approaches:
>
> 1. **Custom loader** — tell `next/image` to build URLs that point back to our own proxy, passing `width` and `quality` as query params. The proxy then asks Immich for the appropriate size. This is the recommended approach and what this plan proposes.
> 2. **`unoptimized` prop** — skip Next.js image optimization entirely and only get the blur-up / lazy-load benefits. Simpler but loses WebP/AVIF transcoding and responsive srcsets.
>
> This plan uses approach 1 (custom loader) for maximum benefit.
> [!WARNING]
> **Lightbox images stay as raw `<img>`** — the Lightbox component is a client-side fullscreen overlay that loads the preview-quality image into a `<img>`. Converting this to `next/image` would add unnecessary overhead (srcset negotiation, layout shifts) for an image that's already viewport-sized. The plan leaves Lightbox images as raw `<img>` but still adds ThumbHash blur placeholders to them.
## 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.
- [ ] 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.
- [ ] 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.
- [ ] 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.
- [ ] 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.
- [ ] 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.
- [ ] 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.
- [ ] 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
### Automated Tests
- `npm run build` — verify the project builds successfully with all changes (no TypeScript / ESLint errors)
- `npm run lint` — verify ESLint passes
### Manual Verification
1. **Run `npm run dev`** and open [http://localhost:3000](http://localhost:3000) in the browser
2. **Homepage hero**: Verify the hero image loads with a visible blur placeholder that transitions to the sharp image. Open DevTools Network tab and confirm the image request goes through `/api/image/` and the response Content-Type is `image/webp` or `image/avif`
3. **Subpage grid**: Navigate to a subpage, verify album cover cards show a colored/blurred placeholder before the cover image loads
4. **Photo grid**: Open an album, verify grid thumbnails show blur-up placeholders. Inspect a thumbnail `<img>` in DevTools and confirm it has `srcset` with multiple widths
5. **Lightbox**: Click a photo, verify the lightbox shows a dominant color background before the full preview loads
6. **About page**: Navigate to `/about`, verify the portrait image uses blur-up placeholder
7. **Slow connection test**: In DevTools, throttle network to "Slow 3G" and reload an album page — confirm placeholder behavior is clearly visible (blur → sharp transition)
## Potential Risks and Mitigations
1. **`next/image` with custom loader may not support all optimization features**
Mitigation: The custom loader approach is officially documented by Next.js. Worst case, we fall back to `unoptimized` mode and still get blur placeholders.
2. **ThumbHash decoding adds server-side compute per page render**
Mitigation: ThumbHash decoding is extremely fast (microseconds per hash). The data is already cached in the album response. We decode once per render, and albums are cached via the existing TTL cache.
3. **Missing ThumbHash data for some assets**
Mitigation: All helpers return `null` when ThumbHash is unavailable. Components fall back gracefully — no placeholder shown, image loads normally (current behavior).
4. **Lightbox image loading UX regression**
Mitigation: We're adding placeholders to the lightbox (improvement), not changing the image loading mechanism. The raw `<img>` stays, so no layout shift or srcset complexity.
## Alternative Approaches
1. **BlurHash instead of ThumbHash**: Immich uses ThumbHash (not BlurHash) internally. ThumbHash is the newer format and encodes both blur shape + color info, making it strictly better for our use case. No reason to use the older BlurHash format.
2. **Server-side image optimization (Sharp)**: Instead of the custom loader approach, we could add Sharp to the proxy route and actually resize/transcode images server-side. This would give true optimization but adds significant CPU and memory load to the server. Not recommended for a self-hosted gallery.
3. **`plaiceholder` package**: A popular Next.js package for generating blur placeholders. However, it requires fetching the actual image server-side to compute the placeholder. Since Immich already provides ThumbHash data, using it directly is much more efficient.

View File

@@ -0,0 +1,90 @@
# Photography Features
## 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.
## Implementation Plan
---
### 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.
- [ ] 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.
- [ ] 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.
- [ ] 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.
- [ ] 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.
- [ ] 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.
---
### 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.
- [ ] 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`.
- [ ] 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`.
---
## Verification Criteria
- `npx tsc --noEmit` passes with zero errors
- `npm run lint` passes with zero warnings
- Single hero image config still works identically (backward compatible)
- Array hero config shows crossfade carousel with smooth transitions
- Subpages with 1 album go straight to PhotoGrid (no intermediate album card grid)
- Subpages with 2+ albums still show the album card grid
- EXIF overlay appears on grid hover when `exifOnHover: true` or when omitted (default)
- EXIF overlay does not appear when `exifOnHover: false`
- Grid respects `columns`, `gap`, `aspectRatio`, and `layout` from `gallery.yaml`
- Responsive breakpoints still reduce columns on small viewports
## Potential Risks and Mitigations
1. **Hero carousel timer causes memory leaks on fast navigation**
Mitigation: Clear interval on component unmount via `useEffect` cleanup. Only mount the timer when the component is visible.
2. **EXIF data bloats server-rendered HTML for large albums**
Mitigation: Only pass 3 short strings per asset (camera, lens, focal length), not the full EXIF object. This adds ~50-100 bytes per image to the serialized props — negligible even for 200+ photo albums.
3. **Grid layout changes break existing CSS overrides or custom styles**
Mitigation: Use CSS custom properties with sensible defaults so existing deployments work without any `gallery.yaml` changes. The defaults match the current hardcoded values.
4. **Full-bleed single albums lose the subpage name context**
Mitigation: Include the subpage name in the back-link text and update metadata to use the album name directly.
## Alternative Approaches
1. **Hero carousel via CSS-only (no client component)**: Could use CSS animations with `animation-delay` on stacked images, avoiding React state. Trade-off: harder to control timing, no pause-on-hover, and can't preload images intelligently. Rejected in favor of the React component approach for better control.
2. **EXIF on hover via client-side fetch (lazy)**: Instead of passing EXIF data server-side, could fetch on first hover per image. Trade-off: adds latency on hover and requires per-image API calls. Since `exifInfo` is already on `ImmichAsset`, passing it server-side is both simpler and faster.
3. **Grid config via a separate `lightbox.config.ts` file**: Could create a dedicated TypeScript config file instead of extending `gallery.yaml`. Trade-off: adds another config surface. Since `gallery.yaml` already serves as the central configuration point and the grid settings are gallery-specific, extending it keeps things consolidated.