From 4f1b8cfe8aac29ef9e0e317da2d22ec4eca993c6 Mon Sep 17 00:00:00 2001 From: ralksta Date: Sun, 8 Mar 2026 18:30:59 +0100 Subject: [PATCH] chore: ignore docs/dev/ in git, remove from tracking --- .gitignore | 2 +- docs/dev/2026-02-13-immich-lightbox-v1.md | 117 ---------- ...6-02-14-developer-experience-testing-v1.md | 147 ------------- docs/dev/2026-02-14-performance-images-v1.md | 89 -------- .../dev/2026-02-14-photography-features-v1.md | 92 -------- docs/dev/2026-02-15-photo-stories-v1.md | 208 ------------------ docs/dev/IDEAS.md | 58 ----- docs/dev/theming-system.md | 168 -------------- 8 files changed, 1 insertion(+), 880 deletions(-) delete mode 100644 docs/dev/2026-02-13-immich-lightbox-v1.md delete mode 100644 docs/dev/2026-02-14-developer-experience-testing-v1.md delete mode 100644 docs/dev/2026-02-14-performance-images-v1.md delete mode 100644 docs/dev/2026-02-14-photography-features-v1.md delete mode 100644 docs/dev/2026-02-15-photo-stories-v1.md delete mode 100644 docs/dev/IDEAS.md delete mode 100644 docs/dev/theming-system.md diff --git a/.gitignore b/.gitignore index 5841f26..ad82904 100644 --- a/.gitignore +++ b/.gitignore @@ -44,7 +44,7 @@ yarn-error.log* next-env.d.ts # internal dev docs -dev-docs/ +docs/dev/ # Build cache tsconfig.tsbuildinfo diff --git a/docs/dev/2026-02-13-immich-lightbox-v1.md b/docs/dev/2026-02-13-immich-lightbox-v1.md deleted file mode 100644 index eb99775..0000000 --- a/docs/dev/2026-02-13-immich-lightbox-v1.md +++ /dev/null @@ -1,117 +0,0 @@ -# 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. - -### Key Goals - -- **Album curation** — Admin picks which Immich albums to publish; only those appear on the public site -- **Proxy architecture** — The gallery server talks to Immich; visitors never touch Immich directly. API key stays server-side -- **Portfolio-grade design** — Clean, dark, image-first aesthetic with masonry/grid layout, smooth lightbox, and responsive behavior -- **Low operational overhead** — Single Docker container, minimal config, no database needed - ---- - -## Architecture Overview - -**Visitor (Browser)** → HTTPS → **Lightbox Server (Next.js)** → HTTP → **Immich Server (Private API)** - -The Lightbox Server sits between visitors and Immich. It proxies images, caches API responses, and holds the album selection config. The Immich server stays private and serves album data and original images only to the Lightbox Server. - -**Stack:** - -- **Next.js 15** (App Router) — SSR for fast first paint, API routes for proxying -- **TypeScript** — End-to-end type safety -- **CSS Modules + CSS custom properties** — Clean, maintainable dark-theme styling -- **Docker** — Single-container deployment - ---- - -## Implementation Plan - -- [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. - -- [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. - -- [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. - -- [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. - -- [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. - -- [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. - -- [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. - -- [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. - -- [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. - -- [x] 10. **Implement image loading optimizations** — Use Next.js `` 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] 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] 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] 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. - ---- - -## Verification Criteria - -- **Album selection**: Only albums specified in `LIGHTBOX_ALBUMS` appear on the homepage; other Immich albums are hidden -- **Image proxying**: Browser Network tab shows all image requests going to the Lightbox server, never directly to Immich. The `x-api-key` header never appears in browser-visible requests -- **Responsive layout**: Gallery looks correct on mobile (375px), tablet (768px), and desktop (1440px) -- **Lightbox navigation**: Arrow keys, swipe gestures, and on-screen buttons all work for prev/next. Esc closes the lightbox -- **Performance**: Homepage loads in under 2 seconds with cached data. Lighthouse performance score ≥ 85 -- **Docker deployment**: `docker compose up` brings the app up and serves the gallery - -### Automated Tests - -- Run `npm run build` to validate TypeScript compilation and Next.js build succeeds -- Run `npm run lint` to check for code quality issues - -### Manual Verification - -> [!IMPORTANT] -> You will need a running Immich instance with at least 2 albums to test against. Please confirm your Immich server URL and provide an API key before we begin implementation. - -1. **Homepage check** — Open `http://localhost:3000` in a browser, confirm only the configured albums appear as cards with cover images -2. **Album detail check** — Click an album card, confirm all photos from that album load in a grid -3. **Lightbox check** — Click a photo, confirm the lightbox opens with full-res image, arrow navigation works, Esc closes -4. **Security check** — Open browser DevTools → Network tab, confirm no requests go directly to the Immich server URL, and the API key does not appear in any request headers or URLs -5. **Mobile check** — Use browser responsive mode at 375px width, confirm the layout adapts cleanly - ---- - -## Potential Risks and Mitigations - -1. **Immich API changes between versions** - Mitigation: Pin to a specific Immich API version in documentation. Use defensive coding with optional chaining and fallbacks for missing fields. The OpenAPI spec is stable for core album/asset endpoints. - -2. **Large albums (1000+ images) causing slow page loads** - Mitigation: Implement pagination or virtual scrolling in the album detail view. Initially load 50 thumbnails and lazy-load more on scroll. - -3. **Immich server unreachable causes gallery downtime** - Mitigation: The server-side cache ensures the gallery continues serving recently-fetched data even if Immich is temporarily down. Add graceful error states showing "Gallery temporarily unavailable." - -4. **Image proxy becomes a bandwidth bottleneck** - Mitigation: Set aggressive `Cache-Control` headers (1 year for immutable images) so browsers and CDNs cache aggressively. Consider optional CDN (Cloudflare) in front. - -5. **API key exposure** - Mitigation: API key is only used in server-side code (API routes, server components). It never appears in client bundles or browser requests. Environment variables are validated at startup. - ---- - -## Alternative Approaches - -1. **Static Site Generation (SSG) instead of SSR** — Pre-render all pages at build time, deploy to a static host (Netlify, Vercel). Eliminates the need for a running server. Trade-off: requires a rebuild whenever album contents change; no real-time sync with Immich. - -2. **Use Immich Shared Links instead of API** — Create shared links for each album in Immich and embed/proxy those. Trade-off: less control over presentation, relies on Immich's built-in sharing UI, and shared link URLs may expire. - -3. **Client-side only (SPA)** — Fetch everything from the browser via CORS. Trade-off: exposes the Immich API key to the browser, which is a security risk. Not recommended. - -4. **Sync + Static Files** — A cron job syncs selected album images to local disk, then a static site serves them. Trade-off: duplicates storage, adds operational complexity, but provides complete decoupling from Immich availability. diff --git a/docs/dev/2026-02-14-developer-experience-testing-v1.md b/docs/dev/2026-02-14-developer-experience-testing-v1.md deleted file mode 100644 index c946513..0000000 --- a/docs/dev/2026-02-14-developer-experience-testing-v1.md +++ /dev/null @@ -1,147 +0,0 @@ -# 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. - ---- - -## 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. - -- [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 - ---- - -### 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. - -- [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 - ---- - -### 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. - -- [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 - ---- - -### 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. - -- [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` - ---- - -### 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). - -- [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 - ---- - -## 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 | diff --git a/docs/dev/2026-02-14-performance-images-v1.md b/docs/dev/2026-02-14-performance-images-v1.md deleted file mode 100644 index 38eff74..0000000 --- a/docs/dev/2026-02-14-performance-images-v1.md +++ /dev/null @@ -1,89 +0,0 @@ -# Performance & Images - -> **Status: ✅ Complete** — All items implemented and shipped. - -## Objective - -Replace all raw `` 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 ``** — the Lightbox component is a client-side fullscreen overlay that loads the preview-quality image into a ``. 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 `` but still adds ThumbHash blur placeholders to them. - -## Implementation Plan - -- [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. - -- [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. - -- [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. - -- [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`. - -- [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. - -- [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. - -- [x] 7. **Migrate homepage hero to `next/image`** — Modify [page.tsx](file:///Users/ralfo/git/immich-lightbox/app/page.tsx) to replace the raw `` (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] 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 `` 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] 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 `` (line 33) with `next/image` using `fill` layout and ThumbHash blur placeholder. - -- [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 `` 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] 11. **Add placeholders to the Lightbox** — Modify [Lightbox.tsx](file:///Users/ralfo/git/immich-lightbox/components/Lightbox.tsx) to keep using raw `` 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] 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] 13. **Verify build and lint** — Run `npm run build` and `npm run lint` to confirm no TypeScript or ESLint errors. - -- [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 - -### 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 `` 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 `` 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. diff --git a/docs/dev/2026-02-14-photography-features-v1.md b/docs/dev/2026-02-14-photography-features-v1.md deleted file mode 100644 index 0d24328..0000000 --- a/docs/dev/2026-02-14-photography-features-v1.md +++ /dev/null @@ -1,92 +0,0 @@ -# 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. - -## Implementation Plan - ---- - -### Feature 1 — Hero Carousel - -- [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. - -- [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. - -- [x] 3. **Update homepage to use `HeroCarousel`** — Modify `app/page.tsx:62-79` to replace the single `` 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] 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 - -- [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. - -- [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 - -- [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. - -- [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. - -- [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. - -- [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. - -- [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 - -- [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. - -- [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. - -- [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`. - -- [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`. - ---- - -## 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. diff --git a/docs/dev/2026-02-15-photo-stories-v1.md b/docs/dev/2026-02-15-photo-stories-v1.md deleted file mode 100644 index 5bbe4c8..0000000 --- a/docs/dev/2026-02-15-photo-stories-v1.md +++ /dev/null @@ -1,208 +0,0 @@ -# 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/.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. `![caption](asset:UUID)`). 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/` 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: # cover image for the story card ---- - -An opening paragraph of text. Sets the mood. - -![Sanssouci Palace at dawn](asset:) - -More narrative text explaining the moment, the light, the feeling. - -![Two images side by side](asset:) -![](asset:) - -A closing paragraph. -``` - -**Authoring rules:** -- Standard Markdown for text (paragraphs, headings, blockquotes, emphasis) -- Images use `![caption](asset:)` 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 `![caption](asset:UUID)` 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/` - -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/`. - -Server component that: -1. Reads the Markdown file from `content/stories/.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 `![caption](asset:UUID)` 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 diff --git a/docs/dev/IDEAS.md b/docs/dev/IDEAS.md deleted file mode 100644 index 16b51a7..0000000 --- a/docs/dev/IDEAS.md +++ /dev/null @@ -1,58 +0,0 @@ -# Portfolio Ideas - -## 🧑‍💻 Developer - -- **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 -- **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 - -- **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 -- **Collections by camera/lens** — Auto-generate virtual albums from EXIF data ("Shot on Leica Q3", "Summilux 35mm") - -## 👤 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 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 -- **Progressive loading animation** — Ken Burns zoom/pan on ThumbHash placeholders while loading - -## 🏆 Quick Wins (High Impact / Low Effort) - -| Idea | Effort | Impact | -|------|--------|--------| -| Slideshow mode | Low | High — exhibition ready | -| 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 | diff --git a/docs/dev/theming-system.md b/docs/dev/theming-system.md deleted file mode 100644 index a56f536..0000000 --- a/docs/dev/theming-system.md +++ /dev/null @@ -1,168 +0,0 @@ -# Theming System — Feature Plan - -Make the app themeable so photographers with different aesthetic preferences can fully customize the look without touching component code. - -## Current State - -The CSS architecture is already 80% of the way there: -- **Design tokens** in `tokens.css` — colors, fonts, radii, transitions, spacing -- **Dark/light toggle** via `[data-theme]` attribute -- All components reference tokens exclusively (`var(--text-primary)`, `var(--font-serif)`, etc.) - -**What's NOT themeable yet:** -- The "Studio/Leica" aesthetic is hardcoded into `globals.css` (lines 607–783) — red dot, film grain, passepartout borders, uppercase titles, sharp corners -- Header glass effect colors are hardcoded (`rgba(26, 26, 26, 0.9)`) -- Font imports are hardcoded in `tokens.css` -- The OG image generator has hardcoded colors - ---- - -## Proposed Architecture - -### Theme as a YAML config - -Add a `theme` section to `gallery.yaml`. Photographers configure their visual identity alongside their gallery structure — no CSS editing required. - -```yaml -# gallery.yaml - -theme: editorial # built-in preset (or "studio", "minimal", "classic") - -# OR fully custom: -theme: - preset: minimal # start from a base - accent: "#2563eb" # brand color - fonts: - heading: "Inter" # Google Fonts name - body: "Inter" - caption: "JetBrains Mono" - radius: 8 # border-radius in px (0 = sharp) - photoFrame: none # "none" | "passepartout" | "shadow" - grain: false # film grain overlay - headerDot: false # red dot in nav - heroStyle: split # "split" | "fullbleed" | "minimal" -``` - -### How it works - -``` -gallery.yaml (theme config) - │ - ▼ - getConfig() ← reads theme key - │ - ▼ - RootLayout ← injects CSS custom properties as inline style on - │ + conditionally loads Google Fonts - ▼ - tokens.css ← contains fallback defaults only - │ - ▼ - globals.css ← component styles use var() tokens - conditional classes for optional features -``` - -### Implementation Layers - -#### Layer 1: Extract theme tokens from config - -**Files:** `lib/config.ts`, `app/layout.tsx` - -1. Add `ThemeConfig` type to `config.ts`: -```ts -interface ThemeConfig { - accent: string; - fonts: { heading: string; body: string; caption: string }; - radius: number; - photoFrame: 'none' | 'passepartout' | 'shadow'; - grain: boolean; - headerDot: boolean; - heroStyle: 'split' | 'fullbleed' | 'minimal'; -} -``` - -2. In `layout.tsx`, inject theme values as CSS custom properties: -```tsx - -``` - -3. Dynamically build the Google Fonts `` URL from the font names. - -#### Layer 2: Make component CSS conditional - -**Files:** `globals.css`, `tokens.css` - -1. Move the "Studio-Inspired Defaults" block (lines 607–783) into a separate file `themes/studio.css` -2. Create `themes/minimal.css`, `themes/classic.css`, `themes/editorial.css` with different aesthetic overrides -3. The base `globals.css` becomes theme-agnostic — clean, neutral component styles -4. Conditional features controlled by data attributes: - -```css -/* Only show if theme.grain is true */ -[data-grain="true"] .photo-grid__item::after { /* grain overlay */ } - -/* Only show if theme.headerDot is true */ -[data-header-dot="true"] .header__nav::before { /* red dot */ } - -/* Passepartout frame */ -[data-photo-frame="passepartout"] .photo-grid__item { padding: 8px; border: 1px solid ... } - -/* Shadow frame */ -[data-photo-frame="shadow"] .photo-grid__item { box-shadow: 0 2px 20px rgba(0,0,0,0.3); } -``` - -#### Layer 3: Built-in presets - -Ship 4 presets that photographers can pick with a single word: - -| Preset | Aesthetic | Accent | Fonts | Radius | Frame | Grain | -|--------|-----------|--------|-------|--------|-------|-------| -| `studio` | Current Leica look | `#e60012` | Playfair+DM Sans | 0 | passepartout | ✓ | -| `minimal` | Clean, no distractions | `#666` | Inter | 0 | none | ✗ | -| `editorial` | Magazine feel | `#1a1a1a` | Libre Baskerville+Source Sans | 4 | shadow | ✗ | -| `classic` | Warm, inviting | `#8b6914` | Cormorant+Lato | 8 | none | ✗ | - -Presets are just default values — every property can be overridden individually. - -#### Layer 4: OG image theming - -Update `/api/og/route.tsx` to read accent color and fonts from config, replacing the hardcoded `#0a0a0a` / `#d4af37`. - ---- - -## File Changes Summary - -| File | Change | -|------|--------| -| `lib/config.ts` | Add `ThemeConfig` type + parsing from YAML | -| `app/layout.tsx` | Inject CSS vars inline, dynamic Google Fonts link | -| `app/tokens.css` | Tokens become fallback defaults only | -| `app/globals.css` | Remove hardcoded "Studio" overrides, use data attributes | -| `themes/studio.css` | [NEW] Extracted studio/Leica aesthetic | -| `themes/minimal.css` | [NEW] Clean minimal preset | -| `themes/editorial.css` | [NEW] Magazine preset | -| `themes/classic.css` | [NEW] Warm classic preset | -| `app/api/og/route.tsx` | Read accent color from config | -| `content/gallery.yaml.example` | Add `theme:` section | -| `README.md` | Document theme customization | - ---- - -## Migration Path - -**Zero breaking changes.** If no `theme` key exists in `gallery.yaml`, the app defaults to `studio` (current look). Existing deployments see no visual change. - ---- - -## Not In Scope (Future) - -- **Live theme preview** — a `/theme-editor` page where you tweak and see changes in real time -- **Custom CSS injection** — a `customCss` field in YAML for power users to inject arbitrary CSS -- **Per-album themes** — different themes for different subpages -- **Background texture/pattern** options beyond grain