diff --git a/.claude/skills/run-immich-folio/SKILL.md b/.claude/skills/run-immich-folio/SKILL.md new file mode 100644 index 0000000..2b74c3d --- /dev/null +++ b/.claude/skills/run-immich-folio/SKILL.md @@ -0,0 +1,138 @@ +--- +name: run-immich-folio +description: Build, run, and drive immich-folio. Use when asked to start the app, run its dev server, take a screenshot of the portfolio, interact with the running gallery, or test immich-folio. +--- + +Immich Folio is a Next.js 16 (App Router) photography portfolio that proxies images from an Immich server. Drive it by starting the Next.js dev server and running `.claude/skills/run-immich-folio/driver.mjs` from the repo root — the script uses Playwright's bundled Chromium to navigate the gallery, verify the health endpoint, and capture screenshots. + +All paths below are relative to the repo root (`immich-folio/`). + +## Prerequisites + +Node 20+ and npm are sufficient — no extra system packages needed. + +Playwright's Chromium is bundled in `node_modules` but must be installed once: + +```bash +npm install +node_modules/.bin/playwright install chromium +``` + +## Setup + +Copy the example files if `content/gallery.yaml` or `.env.local` don't exist yet: + +```bash +cp .env.local.example .env.local +# edit .env.local: set IMMICH_API_URL and IMMICH_API_KEY + +cp content/gallery.yaml.example content/gallery.yaml +# edit gallery.yaml: paste Immich album UUIDs +``` + +Required env vars (`.env.local`): + +```bash +IMMICH_API_URL=http://your-immich:2283 # base URL of your Immich instance +IMMICH_API_KEY=your-api-key-here # from Immich → Admin → API Keys +AUTH_SECRET=a-long-random-secret # required in production; auto-generated in dev +``` + +## Run (agent path) + +Start the dev server in the background, then run the driver: + +```bash +npm run dev &> /tmp/immich-folio-dev.log & +echo $! > /tmp/immich-folio.pid + +# Wait until the health endpoint answers +for i in {1..30}; do + curl -sf http://localhost:3001/api/health >/dev/null && echo "Ready" && break + sleep 1 +done +``` + +> **Port note:** If port 3000 is taken, Next.js auto-assigns the next free port (3001, 3002, …). Check the log: `grep "Local:" /tmp/immich-folio-dev.log` + +Run the driver (from repo root): + +```bash +node .claude/skills/run-immich-folio/driver.mjs http://localhost:3001 +``` + +Expected output: + +``` +[1/4] Homepage... + title: Ralfos Portfolio + → /tmp/shots/01-homepage.png +[2/4] Subpage: /deutschland + → /tmp/shots/02-subpage.png +[3/4] Album: /deutschland/an-der-ostsee + → /tmp/shots/03-album-detail.png +[4/4] API health... + {"status":"ok","immich":"connected","uptime":...} + +✓ All checks passed. Screenshots in /tmp/shots/ +``` + +Screenshots land in `/tmp/shots/`. Pass a different URL as the first argument to target any running instance: + +```bash +node .claude/skills/run-immich-folio/driver.mjs http://localhost:3002 +``` + +Stop the dev server: + +```bash +kill $(cat /tmp/immich-folio.pid) +# or: pkill -f "next dev" +``` + +## Run (human path) + +```bash +npm run dev # → http://localhost:3001 (or next free port). Ctrl-C to stop. +``` + +## Test + +```bash +# Unit tests (Vitest, 66 tests across 7 suites — runs in ~250ms) +npm run test:unit + +# Type-check +npx tsc --noEmit + +# E2E (Playwright, starts its own dev server automatically) +npm run test:e2e +``` + +--- + +## Gotchas + +- **Port auto-selection** — if 3000 is occupied, Next.js picks 3001 silently. The driver defaults to 3001; override with the first argument if needed. Check `grep "Local:" /tmp/immich-folio-dev.log` to find the actual port. + +- **Use `page.goto()` not `.click()` for album navigation** — Next.js RSC streaming causes `.click()` + `waitForLoadState('networkidle')` to capture the previous page's DOM before the new route has streamed in. Navigate by href directly. + +- **`eval()` console error in dev** — React emits `eval() is not supported in this environment` in dev mode due to the CSP middleware injecting a nonce. This is dev-only noise; production is unaffected. The driver filters it out. + +- **`⚠ The "middleware" file convention is deprecated`** — Next.js 16 warns about `middleware.ts` being renamed to `proxy.ts`. Harmless; the app still works. + +- **Immich must be reachable** — all pages are `dynamic = 'force-dynamic'` (no SSG). If `IMMICH_API_URL` is unreachable the health endpoint returns `{"immich":"error"}` and album pages return 404. The driver will fail at step 4. + +- **`content/gallery.yaml` must have at least one album or subpage** — `getConfig()` throws at startup if the file is empty or has no albums. If you only have the example file, fill in real UUIDs first. + +## Troubleshooting + +- **`Cannot find package '@playwright/test'`**: Run the driver from the repo root (`immich-folio/`), not from `/tmp` or another directory. `@playwright/test` is in `node_modules/` here. + +- **Health returns 404 (returns HTML from a different app)**: Port 3000 is another app. Check the actual port: `grep "Local:" /tmp/immich-folio-dev.log` and pass it to the driver. + +- **`EADDRINUSE` on restart**: The previous dev server is still running. `pkill -f "next dev"` before relaunching. + +- **`gallery.yaml must define at least one album or subpage`**: Your `content/gallery.yaml` is empty or only has comments. Add at least one real Immich album UUID under `albums:` or `subpages:`. + +- **Security warning about `AUTH_SECRET`**: Set `AUTH_SECRET` in `.env.local`. In development the app generates a temporary one per session (cookies break on restart). In production it hard-fails if unset. diff --git a/.claude/skills/run-immich-folio/driver.mjs b/.claude/skills/run-immich-folio/driver.mjs new file mode 100644 index 0000000..2a2ad30 --- /dev/null +++ b/.claude/skills/run-immich-folio/driver.mjs @@ -0,0 +1,89 @@ +/** + * Immich Folio smoke driver. + * + * Usage (run from repo root): + * node .claude/skills/run-immich-folio/driver.mjs [BASE_URL] + * + * BASE_URL defaults to http://localhost:3001. + * Screenshots land in /tmp/shots/. + * + * Exits 0 on success, 1 on any failure. + */ + +import { chromium } from '@playwright/test'; +import { mkdirSync } from 'fs'; + +const BASE = process.argv[2] ?? process.env.BASE_URL ?? 'http://localhost:3001'; +const SHOTS = '/tmp/shots'; +mkdirSync(SHOTS, { recursive: true }); + +let browser; +try { + browser = await chromium.launch({ args: ['--no-sandbox'] }); + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const page = await ctx.newPage(); + + const consoleErrors = []; + page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); }); + + // ── 1. Homepage ────────────────────────────────────────────── + console.log('[1/4] Homepage...'); + await page.goto(`${BASE}/`); + await page.waitForLoadState('networkidle'); + const title = await page.title(); + if (!title) throw new Error('Homepage has no title'); + console.log(` title: ${title}`); + await page.screenshot({ path: `${SHOTS}/01-homepage.png` }); + console.log(` → ${SHOTS}/01-homepage.png`); + + // ── 2. First nav subpage ───────────────────────────────────── + // Grab the first subpage link from the header nav (skip "Home" and "About") + const firstSubpageHref = await page.$eval( + '.header__nav a:not([href="/"]):not([href="/about"]):not([href="/map"])', + el => el.getAttribute('href'), + ); + if (!firstSubpageHref) throw new Error('No subpage nav link found'); + console.log(`[2/4] Subpage: ${firstSubpageHref}`); + await page.goto(`${BASE}${firstSubpageHref}`); + await page.waitForLoadState('networkidle'); + await page.screenshot({ path: `${SHOTS}/02-subpage.png` }); + console.log(` → ${SHOTS}/02-subpage.png`); + + // ── 3. Album detail ────────────────────────────────────────── + // Album cards on a subpage grid use class "subpage-grid__item". + // Navigate by href (not click) — click + networkidle can race with RSC streaming. + const albumCard = page.locator('a.subpage-grid__item').first(); + const albumHref = await albumCard.getAttribute('href').catch(() => null); + if (albumHref) { + console.log(`[3/4] Album: ${albumHref}`); + await page.goto(`${BASE}${albumHref}`); + await page.waitForLoadState('networkidle'); + await page.screenshot({ path: `${SHOTS}/03-album-detail.png` }); + console.log(` → ${SHOTS}/03-album-detail.png`); + } else { + // Subpage had only one album — we're already on the album detail + console.log('[3/4] Already on album detail (single-album subpage)'); + await page.screenshot({ path: `${SHOTS}/03-album-detail.png` }); + console.log(` → ${SHOTS}/03-album-detail.png`); + } + + // ── 4. Health endpoint ─────────────────────────────────────── + console.log('[4/4] API health...'); + const health = await page.evaluate(async (base) => { + const r = await fetch(`${base}/api/health`); + return r.json(); + }, BASE); + console.log(` ${JSON.stringify(health)}`); + if (health.status !== 'ok') throw new Error(`Health check failed: ${JSON.stringify(health)}`); + + // ── Summary ─────────────────────────────────────────────────── + const appErrors = consoleErrors.filter(e => !e.includes('eval() is not supported')); + if (appErrors.length > 0) { + console.warn(`\n⚠ Console errors (${appErrors.length}):`); + appErrors.forEach(e => console.warn(' ', e)); + } + + console.log(`\n✓ All checks passed. Screenshots in ${SHOTS}/`); +} finally { + await browser?.close(); +} diff --git a/docs/brainstorm.md b/docs/brainstorm.md new file mode 100644 index 0000000..e3694a9 --- /dev/null +++ b/docs/brainstorm.md @@ -0,0 +1,119 @@ +# Feature Brainstorm + +_Stand: 2026-05-29 — generiert aus Projekt-Review + ideas.md_ + +--- + +## ✅ Bereits implementiert + +| Feature | Datum | Notizen | +|---|---|---| +| **Album-Beschreibungen** | 2026-05-29 | `description:` in `gallery.yaml` (pro Album), eigenes `

` unter dem Titel in `AlbumDetailView` | +| **Webhook Cache-Invalidierung** | 2026-05-29 | `POST /api/webhook`, HMAC-SHA256, `WEBHOOK_SECRET` in `.env`, gezielte oder vollständige Cache-Invalidierung | +| **Video-Support** | 2026-05-29 | Proxy `GET /api/video/[token]` mit Range-Support, Play-Button im Grid, `