diff --git a/.claude/launch.json b/.claude/launch.json deleted file mode 100644 index 4432294..0000000 --- a/.claude/launch.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "next-dev", - "runtimeExecutable": "npm", - "runtimeArgs": ["run", "dev"], - "port": 3000, - "autoPort": true - }, - { - "name": "face-api", - "runtimeExecutable": "python", - "runtimeArgs": ["services/face-api/app.py"], - "port": 8080 - } - ] -} diff --git a/.claude/prompts/AUTOALBUMS.md b/.claude/prompts/AUTOALBUMS.md deleted file mode 100644 index fa3d459..0000000 --- a/.claude/prompts/AUTOALBUMS.md +++ /dev/null @@ -1,118 +0,0 @@ -# Feature: Auto-Albums via Embedding Clustering - -## Context - -EventLens stores 768-dim Gemini description embeddings in the `photos.description_embedding` column (pgvector). Each photo also has `scene_description`, `people_descriptions`, `visible_text`, `face_count`, and `folder`. Currently the only organizational axis is the Drive folder name. Users want to browse by *what's happening* — "stage talks", "networking", "food", "group shots" — not by which camera/folder it came from. - -## Goal - -Cluster photos by semantic similarity of their description embeddings to automatically generate thematic albums (e.g., "Stage & Presentations", "Networking & Conversations", "Group Photos", "Food & Drinks"). Surface these as filter chips in the gallery UI alongside the existing folder filters. This uses only data that already exists — zero new API calls. - -## Technical Approach - -- **Offline batch job** (Python script): pull all 768-dim embeddings from Supabase, run k-means (or DBSCAN) clustering, use Gemini to name each cluster from the combined scene descriptions of its members (one cheap API call per cluster, ~5-8 clusters total). -- Store the assigned `auto_tag` on each photo row. -- Frontend reads `auto_tag` as a filterable dimension alongside `folder`. - -## Commit Plan (molecular commits) - -### Commit 1: Add auto_tag column to photos table - -**Files:** -- `supabase/migrations/007_auto_tags.sql` - -**Changes:** -```sql -ALTER TABLE photos ADD COLUMN IF NOT EXISTS auto_tag text; -CREATE INDEX IF NOT EXISTS idx_photos_auto_tag ON photos (auto_tag) WHERE auto_tag IS NOT NULL; -``` - -Keep it simple — a single text tag per photo. No join table needed; events are bounded in size and a photo belongs to one primary theme. - -**Commit message:** `feat(db): add auto_tag column for thematic album clustering` - ---- - -### Commit 2: Python clustering script - -**Files:** -- `scripts/auto_tag_photos.py` - -**Changes:** -- New standalone script (not part of the main pipeline — run on-demand or after a full processing pass). -- Pulls all photos with non-null `description_embedding` from Supabase. -- Converts embeddings to numpy array. -- Runs **k-means** with k selected by silhouette score (try k=5 through k=12, pick best). Alternatively support a `--k` flag for manual override. -- For each cluster, gather the `scene_description` values, sample up to 20, and send to Gemini Flash with a prompt like: - -``` -These are descriptions of event photos in one group. Give this group a short, descriptive album name (2-4 words). Examples: "Stage & Keynotes", "Networking", "Food & Drinks", "Outdoor Activities", "Group Photos", "Expo Booths". - -Descriptions: -{descriptions} - -Respond with ONLY the album name, nothing else. -``` - -- Update each photo's `auto_tag` in Supabase. -- Print a summary: cluster name, photo count, sample filenames. -- Dependencies: `numpy`, `scikit-learn` (add to a `requirements-scripts.txt` or note in README). These are NOT needed by the Next.js app — only by the offline script. - -**Commit message:** `feat(scripts): auto-tag photos by embedding clustering with Gemini naming` - ---- - -### Commit 3: Return auto_tags in the photos API - -**Files:** -- `src/app/api/photos/route.ts` -- `src/lib/types.ts` - -**Changes:** -- Add `autoTag: string | null` to `PhotoRecord` type. -- Include `auto_tag` in the Supabase select query in `/api/photos`. -- Map it to `autoTag` in the response (matching existing camelCase convention). -- Include a new `tags: string[]` field in `PhotosResponse` (distinct non-null auto_tags, like the existing `folders` array). - -**Commit message:** `feat(api): include auto_tag in photos response` - ---- - -### Commit 4: Tag filter chips in the gallery UI - -**Files:** -- `src/app/page.tsx` - -**Changes:** -- Add a `activeTag` state (string | null) alongside the existing `activeFolder`. -- Render a row of tag filter chips below (or alongside) the folder filters. Style them identically but with a different accent color (e.g., `var(--el-amber)` or `var(--el-cyan)`) so users can distinguish folder vs. tag filters. -- When a tag chip is clicked, filter `filteredPhotos` to only photos with that `autoTag`. -- Tag and folder filters should be composable (AND logic): you can filter by folder "Day 1" AND tag "Stage & Keynotes". -- Show photo count per tag on the chip (e.g., `STAGE & KEYNOTES (42)`). -- Clear tag filter when "ALL" is clicked or when the tag chip is clicked again (toggle behavior). - -**Commit message:** `feat(ui): add auto-tag filter chips to gallery` - ---- - -### Commit 5: Admin trigger for re-clustering - -**Files:** -- `src/app/api/admin/autotag/route.ts` - -**Changes:** -- `POST /api/admin/autotag` — triggers the clustering job. Since the Python script is a separate process, this endpoint should either: - - (a) Call a serverless function / background job that runs the script, OR - - (b) For the MVP, simply document that the admin runs `python scripts/auto_tag_photos.py` manually after processing, and this endpoint returns the current tag distribution (`SELECT auto_tag, COUNT(*) FROM photos GROUP BY auto_tag`). -- Go with option (b) for now — the admin already runs the pipeline manually. - -**Commit message:** `feat(api): admin endpoint for auto-tag distribution stats` - ---- - -## Notes - -- Total Gemini cost: ~5-10 API calls (one per cluster for naming). Effectively free. -- scikit-learn k-means on 5k vectors with 768 dims takes <2 seconds. -- If the event has very distinct sub-events (Day 1, Day 2, workshops), the clusters will naturally separate by content, not by time — which is the point. -- For a future iteration: allow admins to rename auto-generated album names, and pin/reorder them. diff --git a/.claude/prompts/COLLAGE.md b/.claude/prompts/COLLAGE.md deleted file mode 100644 index d6acf2e..0000000 --- a/.claude/prompts/COLLAGE.md +++ /dev/null @@ -1,146 +0,0 @@ -# Feature: Collage from Selected Photos - -## Context - -EventLens has a batch selection system: users long-press or click to select photos, which activates a `FloatingActionBar` at the bottom of the screen showing the selection count and a "DOWNLOAD ZIP" button. The selection state is managed in `page.tsx` as `selectedIds: Set`. The existing ZIP download flow sends selected file IDs to `POST /api/download-zip`, which fetches images from Google Drive, bundles them with JSZip, and streams back a ZIP file. - -The retro terminal UI uses CSS variables: `--el-green`, `--el-magenta`, `--el-amber`, `--el-bg`, with mono fonts and uppercase labels. - -## Goal - -Add a "MAKE COLLAGE" button to the FloatingActionBar. When clicked, the server fetches the selected images, composites them into a single collage image (grid layout), and returns it as a downloadable JPEG/PNG. Optionally, use Gemini Flash to pick the best "hero" image (placed larger) when >4 photos are selected. - -## Technical Approach - -- **Server-side compositing** using Sharp (already a common Next.js dependency, works on Vercel serverless). Sharp can resize, crop, and composite images onto a canvas with no native binary issues on Vercel. -- **Grid layout algorithm**: Simple responsive grid. For N images: 1→full, 2→side-by-side, 3→1 top + 2 bottom, 4→2×2, 5-6→hero + grid, 7+→3-column grid. All images center-cropped to fill their grid cell. -- **Optional Gemini "hero pick"**: If >4 images selected, send thumbnails to Gemini Flash asking "which image is the most visually striking / best represents the group?" — one API call, returns an index. That image gets 2× cell size. Skip this for MVP if you want zero API cost. - -## Commit Plan (molecular commits) - -### Commit 1: Collage generation API endpoint - -**Files:** -- `src/app/api/collage/route.ts` - -**Changes:** -- `POST /api/collage` — accepts `{ files: Array<{ fileId: string; filename?: string }>, width?: number, format?: "jpeg" | "png" }`. -- Limit: 20 images max per collage (keeps it fast and within Vercel's 60s function limit). -- Fetches each image from Google Drive (same pattern as `download-zip/route.ts` — use the Google API key URL with `alt=media`). -- Uses **Sharp** to: - 1. Resize each image to fit its grid cell (center-crop with `sharp.resize({ fit: 'cover' })`). - 2. Create a canvas (`sharp.create()`) at the target width (default 2400px) with calculated height. - 3. Composite all cell images onto the canvas using `sharp.composite([...])`. - 4. Output as JPEG (quality 90) or PNG. -- Returns the collage as a binary response with `Content-Type: image/jpeg` and `Content-Disposition: attachment; filename="eventlens-collage.jpg"`. -- Grid layout logic (pure function, easy to test): - -```typescript -function calculateGrid(count: number, canvasWidth: number): { cols: number; rows: number; cellWidth: number; cellHeight: number; positions: Array<{ x: number; y: number; w: number; h: number }> } { - // 1: 1×1 full bleed - // 2: 2×1 - // 3: 1 top full-width + 2 bottom - // 4: 2×2 - // 5-6: 3×2 - // 7-9: 3×3 - // 10-12: 4×3 - // 13-16: 4×4 - // 17-20: 5×4 - // Gap: 4px between cells (retro grid look) -} -``` - -- Add `sharp` to `dependencies` in `package.json`. - -**Commit message:** `feat(api): collage generation endpoint with Sharp grid compositing` - ---- - -### Commit 2: Add MAKE COLLAGE button to FloatingActionBar - -**Files:** -- `src/components/FloatingActionBar.tsx` - -**Changes:** -- Add a new prop `onMakeCollage: () => void` and `collagePending: boolean`. -- Render a "COLLAGE" button next to the existing "DOWNLOAD ZIP" button. Same styling: `border border-[var(--el-green-99)] bg-[var(--el-green-11)]`, mono uppercase. -- Show a spinner when `collagePending` is true (reuse the existing crosshair-spin animation). -- Disable the button when `selectedCount > 20` and show a tooltip/title "Max 20 photos". -- Button label: `COLLAGE` (or `MAKING...` when pending). - -**Commit message:** `feat(ui): add COLLAGE button to FloatingActionBar` - ---- - -### Commit 3: Wire collage action in main page - -**Files:** -- `src/app/page.tsx` - -**Changes:** -- Add `collagePending` state (boolean). -- Implement `handleMakeCollage` function: - 1. Set `collagePending = true`. - 2. Build the `files` array from `selectedIds` (same as existing ZIP logic — map selected IDs to `{ fileId: photo.driveFileId, filename: photo.filename }`). - 3. `fetch('/api/collage', { method: 'POST', body: JSON.stringify({ files }) })`. - 4. On success: convert response to blob, create an object URL, trigger download via a temporary `` element (same pattern as ZIP download). - 5. On error: show a Toast with the error message. - 6. Set `collagePending = false`. -- Pass `onMakeCollage={handleMakeCollage}` and `collagePending` to `FloatingActionBar`. - -**Commit message:** `feat: wire collage generation from photo selection` - ---- - -### Commit 4: Collage preview in Lightbox before download - -**Files:** -- `src/components/CollagePreview.tsx` (new) -- `src/app/page.tsx` - -**Changes:** -- Instead of immediately downloading, show a modal/lightbox preview of the generated collage. -- `CollagePreview` component: - - Receives the collage blob URL. - - Displays the collage image at full width in a centered modal overlay. - - Two buttons: "DOWNLOAD" (triggers the download) and "CANCEL" (dismisses). - - Styled in the retro terminal theme. -- Update `handleMakeCollage` to set a `collagePreviewUrl` state instead of auto-downloading. -- When the user clicks DOWNLOAD in the preview, trigger the `` download. -- When dismissed, revoke the object URL. - -**Commit message:** `feat(ui): collage preview modal before download` - ---- - -### Commit 5: (Optional) Gemini hero image selection - -**Files:** -- `src/app/api/collage/route.ts` -- `src/lib/gemini.ts` - -**Changes:** -- When >4 images are provided, send their thumbnails (resized to 256px wide) to Gemini Flash with: - -``` -You are selecting the best "hero" image for a photo collage from an event. -Pick the ONE image that is most visually striking, best composed, and most representative of the event energy. -Respond with ONLY the 1-based index number of that image, nothing else. -``` - -- The hero image gets a 2× grid cell (spans 2 columns and 2 rows in the top-left). -- Add `pickHeroImage()` to `gemini.ts` — takes array of base64 thumbnails, returns index. -- Make this opt-in via a request body flag `{ hero: true }` so the collage works without any API calls by default. -- In the UI, add a toggle or just enable hero mode automatically when >4 photos are selected. - -**Commit message:** `feat: Gemini hero image selection for collages with 5+ photos` - ---- - -## Notes - -- Sharp on Vercel serverless: works out of the box (Next.js bundles it correctly). No Docker needed. -- 20-image collage at 2400px wide takes ~2-3 seconds to composite — well within the 60s Vercel limit. -- The grid layout function should be a pure utility — makes it trivial to unit test. -- For v2: add padding/border options, event title overlay, watermark, and aspect ratio presets (square for Instagram, 16:9 for stories). -- The collage is generated server-side (not client-side canvas) because the source images are on Google Drive and CORS prevents direct client-side fetching. diff --git a/.claude/prompts/DEDUP.md b/.claude/prompts/DEDUP.md deleted file mode 100644 index 4705912..0000000 --- a/.claude/prompts/DEDUP.md +++ /dev/null @@ -1,162 +0,0 @@ -# Feature: Perceptual Hash De-duplication - -## Context - -EventLens is a Next.js 15 / React 19 event photography app deployed on Vercel. Photos are stored on Google Drive and indexed in Supabase PostgreSQL (with pgvector). The processing pipeline (`scripts/process_photos.py`) already scans Drive, calls Gemini Vision for metadata, generates 768-dim description embeddings, and runs InsightFace for 512-dim face embeddings. The admin dashboard (`/admin`) shows pipeline status. - -Event photographers shoot bursts — the same moment captured 5-15 times. Re-uploads also happen when multiple organizers share overlapping folders. The gallery currently shows all of these redundant images. - -## Goal - -Add perceptual hashing (dHash, 64-bit) to detect near-duplicate and exact-duplicate photos. Surface duplicate clusters in the admin dashboard for review. Let admins mark duplicates as hidden (soft-delete) so they don't pollute the gallery or search results. - -## Technical Approach - -- **dHash (difference hash)**: Resize image to 9×8 grayscale, compare adjacent horizontal pixels → 64 bits. Hamming distance ≤ 10 = near-duplicate. This catches burst shots, re-uploads, minor crops, and JPEG re-compressions. -- Store the hash as a `bigint` column in the `photos` table. -- Use a Supabase RPC function to find clusters (self-join on Hamming distance). -- Compute hashes in the Python processing pipeline, alongside existing Gemini + face-embed phases. - -## Commit Plan (molecular commits) - -### Commit 1: Add phash column and RPC function - -**Files:** -- `supabase/migrations/006_phash.sql` - -**Changes:** -```sql --- Add perceptual hash column -ALTER TABLE photos ADD COLUMN IF NOT EXISTS phash bigint; -CREATE INDEX IF NOT EXISTS idx_photos_phash ON photos (phash) WHERE phash IS NOT NULL; - --- RPC: find duplicate clusters by Hamming distance -CREATE OR REPLACE FUNCTION find_duplicate_clusters( - hamming_threshold int DEFAULT 10 -) -RETURNS TABLE ( - group_id bigint, - photo_id uuid, - drive_file_id text, - filename text, - folder text, - phash bigint, - hamming_distance int -) -LANGUAGE sql AS $$ - WITH pairs AS ( - SELECT - a.id AS id_a, - b.id AS id_b, - a.phash AS phash_a, - b.phash AS phash_b, - a.drive_file_id AS drive_file_id_a, - a.filename AS filename_a, - a.folder AS folder_a, - b.drive_file_id AS drive_file_id_b, - b.filename AS filename_b, - b.folder AS folder_b, - bit_count((a.phash # b.phash)::bit(64))::int AS dist - FROM photos a - JOIN photos b ON a.id < b.id - WHERE a.phash IS NOT NULL - AND b.phash IS NOT NULL - AND a.status = 'completed' - AND b.status = 'completed' - AND bit_count((a.phash # b.phash)::bit(64))::int <= hamming_threshold - ) - SELECT - DENSE_RANK() OVER (ORDER BY LEAST(id_a, id_b)) AS group_id, - id_a AS photo_id, - drive_file_id_a AS drive_file_id, - filename_a AS filename, - folder_a AS folder, - phash_a AS phash, - dist AS hamming_distance - FROM pairs - UNION ALL - SELECT - DENSE_RANK() OVER (ORDER BY LEAST(id_a, id_b)) AS group_id, - id_b AS photo_id, - drive_file_id_b AS drive_file_id, - filename_b AS filename, - folder_b AS folder, - phash_b AS phash, - dist AS hamming_distance - FROM pairs - ORDER BY group_id, hamming_distance; -$$; -``` - -**Commit message:** `feat(db): add phash column and duplicate cluster RPC` - ---- - -### Commit 2: Compute dHash in the Python processing pipeline - -**Files:** -- `scripts/process_photos.py` - -**Changes:** -- Add a new `dhash()` function that takes base64 image data → returns a 64-bit integer. Use Pillow (already available via the Gemini/face pipeline deps): resize to 9×8 grayscale, compute horizontal gradient, pack into int. -- Add a new pipeline phase `"phash"` that runs after `"scan"` (it only needs the raw image bytes, not Gemini output). For each photo missing a phash, download the thumbnail, compute dHash, update the `phash` column in Supabase. -- Integrate the phase into the existing `argparse` choices and the phase runner. -- Add a `SupabaseStore.update_phash(drive_file_id, phash_value)` method. - -**Commit message:** `feat(pipeline): compute dHash perceptual hashes for all photos` - ---- - -### Commit 3: Admin API endpoint for duplicate clusters - -**Files:** -- `src/app/api/admin/duplicates/route.ts` - -**Changes:** -- `GET /api/admin/duplicates` — calls `find_duplicate_clusters` RPC, groups results by `group_id`, returns JSON array of clusters. Each cluster includes the photo records and thumbnail URLs. Requires admin auth (same pattern as other `/api/admin/*` routes). -- Accept optional query param `?threshold=10` to control Hamming distance. - -**Commit message:** `feat(api): add admin endpoint for duplicate photo clusters` - ---- - -### Commit 4: Admin dashboard duplicate review UI - -**Files:** -- `src/app/admin/page.tsx` (add a new tab/section) -- OR `src/app/admin/duplicates/page.tsx` (if admin uses sub-routes) - -**Changes:** -- Add a "Duplicates" section to the admin dashboard. -- Fetch from `/api/admin/duplicates` on mount. -- Display each cluster as a horizontal strip of thumbnails with Hamming distance labels. -- Each photo in a cluster gets a "Hide" button that sets a `hidden` boolean column on the `photos` table. -- Show total duplicate count and space savings estimate. -- Match the existing retro terminal UI theme (`var(--el-green)`, `var(--el-magenta)`, mono font, etc.). - -**Commit message:** `feat(admin): duplicate cluster review UI with hide action` - ---- - -### Commit 5: Filter hidden photos from gallery and search - -**Files:** -- `src/app/api/photos/route.ts` -- `supabase/migrations/006_phash.sql` (add `hidden` column in same migration or new 007) -- Any RPC functions that query `photos` (`search_photos`, `search_photos_semantic`) - -**Changes:** -- Add `hidden boolean DEFAULT false` to `photos` table. -- Add `WHERE hidden = false` to the photos API query and all search RPC functions. -- Add `POST /api/admin/photos/hide` endpoint that accepts `{ ids: string[] }` and sets `hidden = true`. - -**Commit message:** `feat: filter hidden/duplicate photos from gallery and search` - ---- - -## Notes - -- dHash is deterministic and stateless — safe to re-run on the full corpus. -- The self-join RPC is O(n²) but fine for <50k photos. For larger datasets, switch to a locality-sensitive hashing approach or pre-bucket by phash prefix. -- Pillow is the only new dependency (likely already installed for InsightFace env). -- No Gemini API calls needed — this is purely pixel math + SQL. diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 3e407e7..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(npx next lint)", - "Bash(npx next build)", - "Bash(git add src/app/api/match/route.ts src/app/page.tsx src/lib/photos.ts src/middleware.ts vercel.json src/app/api/search/route.ts supabase/migrations/002_search_photos.sql)", - "Bash(git commit:*)", - "Bash(find /Users/soniacookbroen/Development/eventlens -maxdepth 2 -type f \\\\\\( -name \"*.ts\" -o -name \"*.tsx\" -o -name \"*.mjs\" \\\\\\) ! -path '*/node_modules/*' ! -path '*/.next/*' -exec wc -l {} +)", - "Bash(find /Users/soniacookbroen/Development/eventlens/src -type f \\\\\\( -name \"*.ts\" -o -name \"*.tsx\" \\\\\\) -exec wc -l {} +)", - "Bash(grep -r \"3000\\\\|3001\\\\|8080\\\\|port\" /Users/soniacookbroen/Development/eventlens/.env*)", - "Bash(for f in src/components/Lightbox.tsx src/components/PhotoUpload.tsx src/components/FloatingActionBar.tsx src/components/Toast.tsx src/components/ErrorBoundary.tsx src/app/admin/page.tsx)", - "Bash(do if ! head -1 \"$f\")", - "Bash(then sed -i '' '1s/^/\\\\/\\\\/ @TheTechMargin 2026\\\\n/' \"$f\")", - "Bash(fi)", - "Bash(done)", - "Bash(do sed -i '' '1i// @TheTechMargin 2026:*)", - "Bash(npm install)", - "mcp__Claude_Preview__preview_start", - "WebSearch", - "WebFetch(domain:ai.google.dev)", - "Bash(curl -s \"http://localhost:3000/api/photos\")", - "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); p=[x for x in d[''photos''] if ''SONIA'' in x[''filename'']]; print\\(p[0][''driveFileId''] if p else ''none''\\)\")", - "Bash(curl -s -b \"auth=true\" \"http://localhost:3000/api/photos\")", - "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); p=[x for x in d[''photos''] if ''SONIA'' in x[''filename'']]; print\\(p[0][''driveFileId'']\\)\")", - "Bash(npx tsc --noEmit)", - "Bash(npm run build)", - "Bash(python3 -m pip install -r scripts/requirements.txt)", - "Bash(python3 -m venv scripts/.venv)", - "Bash(scripts/.venv/bin/pip install -r scripts/requirements.txt)", - "Bash(scripts/.venv/bin/python scripts/process_photos.py --help)", - "Bash(scripts/.venv/bin/python scripts/process_photos.py --only-scan --verbose)", - "Bash(scripts/.venv/bin/python scripts/process_photos.py --only-rename --dry-run --folder HARD_MODE_DAY_3 --verbose)", - "Bash(scripts/.venv/bin/python scripts/process_photos.py --only-describe --batch-size 3 --folder HARD_MODE_DAY_3 --verbose)", - "Bash(scripts/.venv/bin/python -c \":*)", - "Bash(scripts/.venv/bin/pip install psycopg2-binary)", - "Bash(scripts/.venv/bin/python scripts/process_photos.py --only-describe --batch-size 2 --folder \"Hard_Mode_26_Day_3_\" --verbose)", - "Bash(scripts/.venv/bin/python scripts/process_photos.py --only-embeddings --verbose)", - "Bash(source /Users/soniacookbroen/Development/eventlens/.env.local)", - "Bash(curl -s \"https://generativelanguage.googleapis.com/v1beta/models?key=$GEMINI_API_KEY\")", - "Bash(python3 -c \"import json,sys; data=json.load\\(sys.stdin\\); [print\\(m[''name'']\\) for m in data.get\\(''models'',[]\\) if ''embed'' in m[''name''].lower\\(\\)]\")", - "Bash(curl -s \"https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001?key=$GEMINI_API_KEY\")", - "Bash(python3 -c \"import json,sys; m=json.load\\(sys.stdin\\); print\\(json.dumps\\(m, indent=2\\)\\)\")", - "Bash(curl -s \"https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent?key=$GEMINI_API_KEY\" -H \"Content-Type: application/json\" -d '{\"\"model\"\":\"\"models/gemini-embedding-001\"\",\"\"content\"\":{\"\"parts\"\":[{\"\"text\"\":\"\"test\"\"}]}}')", - "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); e=d.get\\(''embedding'',{}\\); print\\(f''Dims: {len\\(e.get\\(\"\"values\"\",[]\\)\\)}''\\)\")", - "Bash(npx tsc --noEmit --pretty)", - "Bash(for file in src/app/page.tsx src/app/login/page.tsx src/components/Lightbox.tsx src/components/FloatingActionBar.tsx src/components/PhotoUpload.tsx src/components/Toast.tsx)", - "Bash(do sed -i '' -e 's/#ff00ff11/#ff00ff22/g' -e 's/#ff00ff15/#ff00ff28/g' -e 's/#ff00ff22/#ff00ff44/g' -e 's/#ff00ff33/#ff00ff55/g' -e 's/#ff00ff44/#ff00ff77/g' -e 's/#ff00ff55/#ff00ff88/g' -e 's/#ff00ff66/#ff00ff99/g' -e 's/#ff00ff77/#ff00ffbb/g' -e 's/#ff00ff88/#ff00ffcc/g' -e 's/#ff00ff99/#ff00ffdd/g' \"$file\")", - "Bash(grep -n \"#00ff41\" /Users/soniacookbroen/Development/eventlens/src/app/admin/page.tsx /Users/soniacookbroen/Development/eventlens/src/app/page.tsx /Users/soniacookbroen/Development/eventlens/src/components/*.tsx)", - "Bash(find src -name \"*.tsx\" -exec sed -i '' 's/var\\(--el-magenta-dd\\)/var\\(--el-flame-dd\\)/g' {} +)", - "Bash(xargs ls -1)", - "Bash(node -e \"console.log\\(require\\(''./package.json''\\).dependencies.next\\)\")", - "Bash(node -e \":*)", - "Bash(source .env.local)", - "Bash(curl -s \"http://localhost:3000/api/photos\" -H \"Cookie: auth=true\")", - "Bash(curl -s -X POST \"http://localhost:3000/api/auth/login\" -H \"Content-Type: application/json\" -d \"{\"\"password\"\": \"\"$APP_PASSWORD\"\"}\" -c -)", - "Bash(curl -s -X POST \"http://localhost:3000/api/auth/login\" -H \"Content-Type: application/json\" -d '{\"\"password\"\":\"\"hardMode26\"\"}' -v)", - "Bash(xargs ls:*)", - "Bash(curl -s -o /dev/null -w \"%{http_code}\" https://face-api-production-6b2e.up.railway.app/embed)", - "Bash(grep -E \"\\\\.\\(sql|json\\)$\")", - "Bash(find /Users/soniacookbroen/Development/eventlens -maxdepth 2 -type f -name *.md)", - "Bash(find /Users/soniacookbroen/Development/eventlens/src -name \"*.ts\" -exec grep -l \"photos\\\\|face_embeddings\" {})", - "Bash(python -c \"import ast; ast.parse\\(open\\(''scripts/process_photos.py''\\).read\\(\\)\\); print\\(''Syntax OK''\\)\")", - "Bash(python3 -c \"import ast; ast.parse\\(open\\(''scripts/process_photos.py''\\).read\\(\\)\\); print\\(''Syntax OK''\\)\")", - "Bash(curl -s --max-time 10 \"$FACE_API_URL/health\")", - "Bash(python3 -c \"import ast; ast.parse\\(open\\(''/Users/soniacookbroen/Development/eventlens/scripts/process_photos.py''\\).read\\(\\)\\); print\\(''OK''\\)\")", - "Bash(npm install:*)", - "Bash(npx tsc:*)", - "Bash(git -C /Users/soniacookbroen/Development/eventlens log --oneline -20)", - "Bash(ls -la /Users/soniacookbroen/Development/eventlens/CLAUDE-*.md /Users/soniacookbroen/Development/eventlens/PLAN.md /Users/soniacookbroen/Development/eventlens/EVENTLENS-MASTER-PLAN.md /Users/soniacookbroen/Development/eventlens/TEMPLATE-REVIEW.md)", - "Bash(grep -iE '\\\\.env|credentials|secret|\\\\.key$|\\\\.pem$')", - "Bash(git rm:*)", - "WebFetch(domain:mitodl.github.io)", - "Bash(sort -t'\t' -k2 -rn)", - "Bash(gh api:*)", - "Bash(gh search:*)", - "Bash(npx jest:*)", - "WebFetch(domain:api.github.com)", - "WebFetch(domain:raw.githubusercontent.com)", - "Bash(npm test:*)" - ] - } -} diff --git a/.gitignore b/.gitignore index 1a0af54..c220c10 100644 --- a/.gitignore +++ b/.gitignore @@ -32,8 +32,20 @@ yarn-error.log* # python (face-api service) __pycache__/ +services/face-api/__pycache__/ *.pyc +# claude code config +.claude/ + +# planning / prompt artifacts +CLAUDE-*.md +PROMPT-*.md +PERF-PROMPTS.md +PLAN.md +TEMPLATE-REVIEW.md +EVENTLENS-MASTER-PLAN.md + # vercel .vercel diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 83d36a0..fac83b1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -466,12 +466,12 @@ The gallery renders 13+ components, each responsible for a single visual concern This decomposition was not designed upfront on a whiteboard. The process was: -1. **Prototype fast** — get the feature working in a single component with AI pair programming +1. **Prototype fast** — get the feature working in a single component 2. **Verify the UX** — test interactions, iterate on behavior 3. **Recognize extraction points** — a component has multiple `useState` calls that don't interact, or the JSX has sections separated by comments. These are signals that abstractions want to exist. 4. **Extract and define interfaces** — pull the state into a hook, the JSX into a component, and define the props/return types that connect them -The architect's value in AI-assisted development is step 3: knowing where to cut and why. AI can generate a 600-line component or split it into 15 files — it doesn't know which decomposition reflects the actual domain boundaries without the human directing it. +The architect's value is step 3: knowing where to cut and why. Any tool can generate a 600-line component or split it into 15 files — but it takes domain understanding to know which decomposition reflects actual boundaries. ## 8. Tradeoffs and Open Questions diff --git a/CLAUDE-AUTOALBUMS.md b/CLAUDE-AUTOALBUMS.md deleted file mode 100644 index fa3d459..0000000 --- a/CLAUDE-AUTOALBUMS.md +++ /dev/null @@ -1,118 +0,0 @@ -# Feature: Auto-Albums via Embedding Clustering - -## Context - -EventLens stores 768-dim Gemini description embeddings in the `photos.description_embedding` column (pgvector). Each photo also has `scene_description`, `people_descriptions`, `visible_text`, `face_count`, and `folder`. Currently the only organizational axis is the Drive folder name. Users want to browse by *what's happening* — "stage talks", "networking", "food", "group shots" — not by which camera/folder it came from. - -## Goal - -Cluster photos by semantic similarity of their description embeddings to automatically generate thematic albums (e.g., "Stage & Presentations", "Networking & Conversations", "Group Photos", "Food & Drinks"). Surface these as filter chips in the gallery UI alongside the existing folder filters. This uses only data that already exists — zero new API calls. - -## Technical Approach - -- **Offline batch job** (Python script): pull all 768-dim embeddings from Supabase, run k-means (or DBSCAN) clustering, use Gemini to name each cluster from the combined scene descriptions of its members (one cheap API call per cluster, ~5-8 clusters total). -- Store the assigned `auto_tag` on each photo row. -- Frontend reads `auto_tag` as a filterable dimension alongside `folder`. - -## Commit Plan (molecular commits) - -### Commit 1: Add auto_tag column to photos table - -**Files:** -- `supabase/migrations/007_auto_tags.sql` - -**Changes:** -```sql -ALTER TABLE photos ADD COLUMN IF NOT EXISTS auto_tag text; -CREATE INDEX IF NOT EXISTS idx_photos_auto_tag ON photos (auto_tag) WHERE auto_tag IS NOT NULL; -``` - -Keep it simple — a single text tag per photo. No join table needed; events are bounded in size and a photo belongs to one primary theme. - -**Commit message:** `feat(db): add auto_tag column for thematic album clustering` - ---- - -### Commit 2: Python clustering script - -**Files:** -- `scripts/auto_tag_photos.py` - -**Changes:** -- New standalone script (not part of the main pipeline — run on-demand or after a full processing pass). -- Pulls all photos with non-null `description_embedding` from Supabase. -- Converts embeddings to numpy array. -- Runs **k-means** with k selected by silhouette score (try k=5 through k=12, pick best). Alternatively support a `--k` flag for manual override. -- For each cluster, gather the `scene_description` values, sample up to 20, and send to Gemini Flash with a prompt like: - -``` -These are descriptions of event photos in one group. Give this group a short, descriptive album name (2-4 words). Examples: "Stage & Keynotes", "Networking", "Food & Drinks", "Outdoor Activities", "Group Photos", "Expo Booths". - -Descriptions: -{descriptions} - -Respond with ONLY the album name, nothing else. -``` - -- Update each photo's `auto_tag` in Supabase. -- Print a summary: cluster name, photo count, sample filenames. -- Dependencies: `numpy`, `scikit-learn` (add to a `requirements-scripts.txt` or note in README). These are NOT needed by the Next.js app — only by the offline script. - -**Commit message:** `feat(scripts): auto-tag photos by embedding clustering with Gemini naming` - ---- - -### Commit 3: Return auto_tags in the photos API - -**Files:** -- `src/app/api/photos/route.ts` -- `src/lib/types.ts` - -**Changes:** -- Add `autoTag: string | null` to `PhotoRecord` type. -- Include `auto_tag` in the Supabase select query in `/api/photos`. -- Map it to `autoTag` in the response (matching existing camelCase convention). -- Include a new `tags: string[]` field in `PhotosResponse` (distinct non-null auto_tags, like the existing `folders` array). - -**Commit message:** `feat(api): include auto_tag in photos response` - ---- - -### Commit 4: Tag filter chips in the gallery UI - -**Files:** -- `src/app/page.tsx` - -**Changes:** -- Add a `activeTag` state (string | null) alongside the existing `activeFolder`. -- Render a row of tag filter chips below (or alongside) the folder filters. Style them identically but with a different accent color (e.g., `var(--el-amber)` or `var(--el-cyan)`) so users can distinguish folder vs. tag filters. -- When a tag chip is clicked, filter `filteredPhotos` to only photos with that `autoTag`. -- Tag and folder filters should be composable (AND logic): you can filter by folder "Day 1" AND tag "Stage & Keynotes". -- Show photo count per tag on the chip (e.g., `STAGE & KEYNOTES (42)`). -- Clear tag filter when "ALL" is clicked or when the tag chip is clicked again (toggle behavior). - -**Commit message:** `feat(ui): add auto-tag filter chips to gallery` - ---- - -### Commit 5: Admin trigger for re-clustering - -**Files:** -- `src/app/api/admin/autotag/route.ts` - -**Changes:** -- `POST /api/admin/autotag` — triggers the clustering job. Since the Python script is a separate process, this endpoint should either: - - (a) Call a serverless function / background job that runs the script, OR - - (b) For the MVP, simply document that the admin runs `python scripts/auto_tag_photos.py` manually after processing, and this endpoint returns the current tag distribution (`SELECT auto_tag, COUNT(*) FROM photos GROUP BY auto_tag`). -- Go with option (b) for now — the admin already runs the pipeline manually. - -**Commit message:** `feat(api): admin endpoint for auto-tag distribution stats` - ---- - -## Notes - -- Total Gemini cost: ~5-10 API calls (one per cluster for naming). Effectively free. -- scikit-learn k-means on 5k vectors with 768 dims takes <2 seconds. -- If the event has very distinct sub-events (Day 1, Day 2, workshops), the clusters will naturally separate by content, not by time — which is the point. -- For a future iteration: allow admins to rename auto-generated album names, and pin/reorder them. diff --git a/CLAUDE-COLLAGE.md b/CLAUDE-COLLAGE.md deleted file mode 100644 index d6acf2e..0000000 --- a/CLAUDE-COLLAGE.md +++ /dev/null @@ -1,146 +0,0 @@ -# Feature: Collage from Selected Photos - -## Context - -EventLens has a batch selection system: users long-press or click to select photos, which activates a `FloatingActionBar` at the bottom of the screen showing the selection count and a "DOWNLOAD ZIP" button. The selection state is managed in `page.tsx` as `selectedIds: Set`. The existing ZIP download flow sends selected file IDs to `POST /api/download-zip`, which fetches images from Google Drive, bundles them with JSZip, and streams back a ZIP file. - -The retro terminal UI uses CSS variables: `--el-green`, `--el-magenta`, `--el-amber`, `--el-bg`, with mono fonts and uppercase labels. - -## Goal - -Add a "MAKE COLLAGE" button to the FloatingActionBar. When clicked, the server fetches the selected images, composites them into a single collage image (grid layout), and returns it as a downloadable JPEG/PNG. Optionally, use Gemini Flash to pick the best "hero" image (placed larger) when >4 photos are selected. - -## Technical Approach - -- **Server-side compositing** using Sharp (already a common Next.js dependency, works on Vercel serverless). Sharp can resize, crop, and composite images onto a canvas with no native binary issues on Vercel. -- **Grid layout algorithm**: Simple responsive grid. For N images: 1→full, 2→side-by-side, 3→1 top + 2 bottom, 4→2×2, 5-6→hero + grid, 7+→3-column grid. All images center-cropped to fill their grid cell. -- **Optional Gemini "hero pick"**: If >4 images selected, send thumbnails to Gemini Flash asking "which image is the most visually striking / best represents the group?" — one API call, returns an index. That image gets 2× cell size. Skip this for MVP if you want zero API cost. - -## Commit Plan (molecular commits) - -### Commit 1: Collage generation API endpoint - -**Files:** -- `src/app/api/collage/route.ts` - -**Changes:** -- `POST /api/collage` — accepts `{ files: Array<{ fileId: string; filename?: string }>, width?: number, format?: "jpeg" | "png" }`. -- Limit: 20 images max per collage (keeps it fast and within Vercel's 60s function limit). -- Fetches each image from Google Drive (same pattern as `download-zip/route.ts` — use the Google API key URL with `alt=media`). -- Uses **Sharp** to: - 1. Resize each image to fit its grid cell (center-crop with `sharp.resize({ fit: 'cover' })`). - 2. Create a canvas (`sharp.create()`) at the target width (default 2400px) with calculated height. - 3. Composite all cell images onto the canvas using `sharp.composite([...])`. - 4. Output as JPEG (quality 90) or PNG. -- Returns the collage as a binary response with `Content-Type: image/jpeg` and `Content-Disposition: attachment; filename="eventlens-collage.jpg"`. -- Grid layout logic (pure function, easy to test): - -```typescript -function calculateGrid(count: number, canvasWidth: number): { cols: number; rows: number; cellWidth: number; cellHeight: number; positions: Array<{ x: number; y: number; w: number; h: number }> } { - // 1: 1×1 full bleed - // 2: 2×1 - // 3: 1 top full-width + 2 bottom - // 4: 2×2 - // 5-6: 3×2 - // 7-9: 3×3 - // 10-12: 4×3 - // 13-16: 4×4 - // 17-20: 5×4 - // Gap: 4px between cells (retro grid look) -} -``` - -- Add `sharp` to `dependencies` in `package.json`. - -**Commit message:** `feat(api): collage generation endpoint with Sharp grid compositing` - ---- - -### Commit 2: Add MAKE COLLAGE button to FloatingActionBar - -**Files:** -- `src/components/FloatingActionBar.tsx` - -**Changes:** -- Add a new prop `onMakeCollage: () => void` and `collagePending: boolean`. -- Render a "COLLAGE" button next to the existing "DOWNLOAD ZIP" button. Same styling: `border border-[var(--el-green-99)] bg-[var(--el-green-11)]`, mono uppercase. -- Show a spinner when `collagePending` is true (reuse the existing crosshair-spin animation). -- Disable the button when `selectedCount > 20` and show a tooltip/title "Max 20 photos". -- Button label: `COLLAGE` (or `MAKING...` when pending). - -**Commit message:** `feat(ui): add COLLAGE button to FloatingActionBar` - ---- - -### Commit 3: Wire collage action in main page - -**Files:** -- `src/app/page.tsx` - -**Changes:** -- Add `collagePending` state (boolean). -- Implement `handleMakeCollage` function: - 1. Set `collagePending = true`. - 2. Build the `files` array from `selectedIds` (same as existing ZIP logic — map selected IDs to `{ fileId: photo.driveFileId, filename: photo.filename }`). - 3. `fetch('/api/collage', { method: 'POST', body: JSON.stringify({ files }) })`. - 4. On success: convert response to blob, create an object URL, trigger download via a temporary `` element (same pattern as ZIP download). - 5. On error: show a Toast with the error message. - 6. Set `collagePending = false`. -- Pass `onMakeCollage={handleMakeCollage}` and `collagePending` to `FloatingActionBar`. - -**Commit message:** `feat: wire collage generation from photo selection` - ---- - -### Commit 4: Collage preview in Lightbox before download - -**Files:** -- `src/components/CollagePreview.tsx` (new) -- `src/app/page.tsx` - -**Changes:** -- Instead of immediately downloading, show a modal/lightbox preview of the generated collage. -- `CollagePreview` component: - - Receives the collage blob URL. - - Displays the collage image at full width in a centered modal overlay. - - Two buttons: "DOWNLOAD" (triggers the download) and "CANCEL" (dismisses). - - Styled in the retro terminal theme. -- Update `handleMakeCollage` to set a `collagePreviewUrl` state instead of auto-downloading. -- When the user clicks DOWNLOAD in the preview, trigger the `` download. -- When dismissed, revoke the object URL. - -**Commit message:** `feat(ui): collage preview modal before download` - ---- - -### Commit 5: (Optional) Gemini hero image selection - -**Files:** -- `src/app/api/collage/route.ts` -- `src/lib/gemini.ts` - -**Changes:** -- When >4 images are provided, send their thumbnails (resized to 256px wide) to Gemini Flash with: - -``` -You are selecting the best "hero" image for a photo collage from an event. -Pick the ONE image that is most visually striking, best composed, and most representative of the event energy. -Respond with ONLY the 1-based index number of that image, nothing else. -``` - -- The hero image gets a 2× grid cell (spans 2 columns and 2 rows in the top-left). -- Add `pickHeroImage()` to `gemini.ts` — takes array of base64 thumbnails, returns index. -- Make this opt-in via a request body flag `{ hero: true }` so the collage works without any API calls by default. -- In the UI, add a toggle or just enable hero mode automatically when >4 photos are selected. - -**Commit message:** `feat: Gemini hero image selection for collages with 5+ photos` - ---- - -## Notes - -- Sharp on Vercel serverless: works out of the box (Next.js bundles it correctly). No Docker needed. -- 20-image collage at 2400px wide takes ~2-3 seconds to composite — well within the 60s Vercel limit. -- The grid layout function should be a pure utility — makes it trivial to unit test. -- For v2: add padding/border options, event title overlay, watermark, and aspect ratio presets (square for Instagram, 16:9 for stories). -- The collage is generated server-side (not client-side canvas) because the source images are on Google Drive and CORS prevents direct client-side fetching. diff --git a/CLAUDE-DEDUP.md b/CLAUDE-DEDUP.md deleted file mode 100644 index 4705912..0000000 --- a/CLAUDE-DEDUP.md +++ /dev/null @@ -1,162 +0,0 @@ -# Feature: Perceptual Hash De-duplication - -## Context - -EventLens is a Next.js 15 / React 19 event photography app deployed on Vercel. Photos are stored on Google Drive and indexed in Supabase PostgreSQL (with pgvector). The processing pipeline (`scripts/process_photos.py`) already scans Drive, calls Gemini Vision for metadata, generates 768-dim description embeddings, and runs InsightFace for 512-dim face embeddings. The admin dashboard (`/admin`) shows pipeline status. - -Event photographers shoot bursts — the same moment captured 5-15 times. Re-uploads also happen when multiple organizers share overlapping folders. The gallery currently shows all of these redundant images. - -## Goal - -Add perceptual hashing (dHash, 64-bit) to detect near-duplicate and exact-duplicate photos. Surface duplicate clusters in the admin dashboard for review. Let admins mark duplicates as hidden (soft-delete) so they don't pollute the gallery or search results. - -## Technical Approach - -- **dHash (difference hash)**: Resize image to 9×8 grayscale, compare adjacent horizontal pixels → 64 bits. Hamming distance ≤ 10 = near-duplicate. This catches burst shots, re-uploads, minor crops, and JPEG re-compressions. -- Store the hash as a `bigint` column in the `photos` table. -- Use a Supabase RPC function to find clusters (self-join on Hamming distance). -- Compute hashes in the Python processing pipeline, alongside existing Gemini + face-embed phases. - -## Commit Plan (molecular commits) - -### Commit 1: Add phash column and RPC function - -**Files:** -- `supabase/migrations/006_phash.sql` - -**Changes:** -```sql --- Add perceptual hash column -ALTER TABLE photos ADD COLUMN IF NOT EXISTS phash bigint; -CREATE INDEX IF NOT EXISTS idx_photos_phash ON photos (phash) WHERE phash IS NOT NULL; - --- RPC: find duplicate clusters by Hamming distance -CREATE OR REPLACE FUNCTION find_duplicate_clusters( - hamming_threshold int DEFAULT 10 -) -RETURNS TABLE ( - group_id bigint, - photo_id uuid, - drive_file_id text, - filename text, - folder text, - phash bigint, - hamming_distance int -) -LANGUAGE sql AS $$ - WITH pairs AS ( - SELECT - a.id AS id_a, - b.id AS id_b, - a.phash AS phash_a, - b.phash AS phash_b, - a.drive_file_id AS drive_file_id_a, - a.filename AS filename_a, - a.folder AS folder_a, - b.drive_file_id AS drive_file_id_b, - b.filename AS filename_b, - b.folder AS folder_b, - bit_count((a.phash # b.phash)::bit(64))::int AS dist - FROM photos a - JOIN photos b ON a.id < b.id - WHERE a.phash IS NOT NULL - AND b.phash IS NOT NULL - AND a.status = 'completed' - AND b.status = 'completed' - AND bit_count((a.phash # b.phash)::bit(64))::int <= hamming_threshold - ) - SELECT - DENSE_RANK() OVER (ORDER BY LEAST(id_a, id_b)) AS group_id, - id_a AS photo_id, - drive_file_id_a AS drive_file_id, - filename_a AS filename, - folder_a AS folder, - phash_a AS phash, - dist AS hamming_distance - FROM pairs - UNION ALL - SELECT - DENSE_RANK() OVER (ORDER BY LEAST(id_a, id_b)) AS group_id, - id_b AS photo_id, - drive_file_id_b AS drive_file_id, - filename_b AS filename, - folder_b AS folder, - phash_b AS phash, - dist AS hamming_distance - FROM pairs - ORDER BY group_id, hamming_distance; -$$; -``` - -**Commit message:** `feat(db): add phash column and duplicate cluster RPC` - ---- - -### Commit 2: Compute dHash in the Python processing pipeline - -**Files:** -- `scripts/process_photos.py` - -**Changes:** -- Add a new `dhash()` function that takes base64 image data → returns a 64-bit integer. Use Pillow (already available via the Gemini/face pipeline deps): resize to 9×8 grayscale, compute horizontal gradient, pack into int. -- Add a new pipeline phase `"phash"` that runs after `"scan"` (it only needs the raw image bytes, not Gemini output). For each photo missing a phash, download the thumbnail, compute dHash, update the `phash` column in Supabase. -- Integrate the phase into the existing `argparse` choices and the phase runner. -- Add a `SupabaseStore.update_phash(drive_file_id, phash_value)` method. - -**Commit message:** `feat(pipeline): compute dHash perceptual hashes for all photos` - ---- - -### Commit 3: Admin API endpoint for duplicate clusters - -**Files:** -- `src/app/api/admin/duplicates/route.ts` - -**Changes:** -- `GET /api/admin/duplicates` — calls `find_duplicate_clusters` RPC, groups results by `group_id`, returns JSON array of clusters. Each cluster includes the photo records and thumbnail URLs. Requires admin auth (same pattern as other `/api/admin/*` routes). -- Accept optional query param `?threshold=10` to control Hamming distance. - -**Commit message:** `feat(api): add admin endpoint for duplicate photo clusters` - ---- - -### Commit 4: Admin dashboard duplicate review UI - -**Files:** -- `src/app/admin/page.tsx` (add a new tab/section) -- OR `src/app/admin/duplicates/page.tsx` (if admin uses sub-routes) - -**Changes:** -- Add a "Duplicates" section to the admin dashboard. -- Fetch from `/api/admin/duplicates` on mount. -- Display each cluster as a horizontal strip of thumbnails with Hamming distance labels. -- Each photo in a cluster gets a "Hide" button that sets a `hidden` boolean column on the `photos` table. -- Show total duplicate count and space savings estimate. -- Match the existing retro terminal UI theme (`var(--el-green)`, `var(--el-magenta)`, mono font, etc.). - -**Commit message:** `feat(admin): duplicate cluster review UI with hide action` - ---- - -### Commit 5: Filter hidden photos from gallery and search - -**Files:** -- `src/app/api/photos/route.ts` -- `supabase/migrations/006_phash.sql` (add `hidden` column in same migration or new 007) -- Any RPC functions that query `photos` (`search_photos`, `search_photos_semantic`) - -**Changes:** -- Add `hidden boolean DEFAULT false` to `photos` table. -- Add `WHERE hidden = false` to the photos API query and all search RPC functions. -- Add `POST /api/admin/photos/hide` endpoint that accepts `{ ids: string[] }` and sets `hidden = true`. - -**Commit message:** `feat: filter hidden/duplicate photos from gallery and search` - ---- - -## Notes - -- dHash is deterministic and stateless — safe to re-run on the full corpus. -- The self-join RPC is O(n²) but fine for <50k photos. For larger datasets, switch to a locality-sensitive hashing approach or pre-bucket by phash prefix. -- Pillow is the only new dependency (likely already installed for InsightFace env). -- No Gemini API calls needed — this is purely pixel math + SQL. diff --git a/EVENTLENS-MASTER-PLAN.md b/EVENTLENS-MASTER-PLAN.md deleted file mode 100644 index 0ffa909..0000000 --- a/EVENTLENS-MASTER-PLAN.md +++ /dev/null @@ -1,369 +0,0 @@ -# EventLens: Refactor & Portfolio Master Plan - -**Author:** Sonia / @TheTechMargin -**Date:** March 14, 2026 -**Updated:** March 15, 2026 -**Status:** Active - ---- - -## Purpose - -This document is the single source of truth for the EventLens refactor. It serves two simultaneous goals: - -1. **Portable tool** — Transform EventLens from a single-event hackathon project into a lightweight, customizable tool that any event organizer can deploy by providing their own keys and Drive folder. The app is intentionally ephemeral — an event gallery doesn't need to stay live forever, it just needs to work for the time the organizers decide to keep it up. - -2. **Portfolio piece** — Prepare the codebase as a code sample for a Senior Product Engineer interview at MIT Open Learning. The code, architecture, and documentation should demonstrate ownership, design thinking, and production-quality engineering. - -Every refactor decision should pass both filters: "Would an organizer deploying this benefit from this?" and "Can I explain the reasoning behind this in a technical interview?" - ---- - -## The Interview Target - -**Role:** Senior Product Engineer, MIT Open Learning -**Reports to:** Peter Pinch, Director of Application Development - -**Their stack (from mit-learn repo):** Django/Python (56%), TypeScript/React (41%), PostgreSQL, OpenSearch with vector embeddings, Docker Compose, Keycloak auth, Vercel/Heroku, GitHub Actions CI, Storybook, smoot-design shared component library, drf-spectacular OpenAPI generation, Playwright e2e, pytest, pre-commit hooks. - -**What they value (from their engineering handbook):** Ownership from concept to production. RFCs before building — architectural design docs capturing scope, approach, and tradeoffs. Documentation that explains "why." Agile without dogma. Open source culture. - -**What Peter asked for:** "Share some sample code you have written. Any language, any purpose. It just needs to represent you well." - -**Key job requirements that map to EventLens:** TypeScript, React, Next.js. Component architecture and state management. Server-side frameworks. Vector search and embeddings (pgvector maps to their OpenSearch). AI integration (Gemini maps to their AskTim). Relational databases and SQL. Accessibility, performance, mobile-first. Documentation, testing, design systems. - ---- - -## Architecture Overview - -``` -Google Drive (photo/video storage per event) - │ -Processing Pipeline (TypeScript on Vercel serverless, 300s max) - ├── Gemini 2.5 Flash Lite → scene/text/people analysis + auto-tags - ├── Gemini Embedding (gemini-embedding-001) → 768-dim description vectors - ├── InsightFace (buffalo_l via Flask on Railway) → 512-dim face vectors - └── Sharp → 64-bit dHash perceptual hashes - │ -Supabase (PostgreSQL + pgvector) - ├── photos table — metadata, description embeddings, tsvector, phash - ├── face_embeddings table — face vectors + bounding boxes (HNSW index) - ├── match_sessions table — face match analytics - └── RPC functions — match_faces, search_photos, search_photos_semantic - │ -Next.js 15 App (Vercel) - ├── Gallery — search, browse, match, download, collage - ├── Admin — pipeline control, status, moderation - └── API routes — photos, search, match, video proxy, ZIP, auth, admin -``` - ---- - -## Current State (Post-Hackathon) - -What works well and what doesn't, organized by the two goals. - -### What's Strong (Keep / Showcase) - -The component decomposition is already done. `page.tsx` is 17 lines — it delegates to `` which orchestrates via hooks (`usePhotos`, `useSearch`, `useFilters`, `useSelection`, `useCollage`, `useStats`, `useProgressiveRender`, `useUrlSync`). Gallery sub-components are well-separated: `PhotoGrid`, `PhotoCard`, `GalleryHeader`, `FolderTabs`, `TagTabs`, `FilterSortBar`, `AlbumGrid`, `HeroSection`, `Lightbox`, `SearchStatus`, etc. - -The AI pipeline is the differentiator. Six phases (sync → scan → describe → embed → face-embed → phash), each wall-clock guarded at 250s to fit Vercel's 300s timeout, with the client re-calling until `done: true`. Rate limiting, exponential backoff with jitter, Retry-After header respect, CDN-first download strategy. - -Search is genuinely sophisticated. Hybrid semantic (768-dim Gemini embeddings via pgvector cosine similarity) + full-text (tsvector) + trigram matching, merged and ranked. Face matching uses separate 512-dim InsightFace embeddings with tiered confidence. Co-occurrence recommendations surface photos where matched faces appear together. - -12 Supabase migrations show progressive schema evolution. Feature specs (CLAUDE-AUTOALBUMS.md, CLAUDE-COLLAGE.md, CLAUDE-DEDUP.md) document the development process. - -### What Needs Work - -**Auth is a security liability.** The auth cookie is literally `auth=true` — a boolean with no signature, no session token, no CSRF protection. `APP_PASSWORD` and `ADMIN_API_SECRET` are plaintext env vars compared via `timingSafeEqual`. This works for a hackathon but needs improvement. - -**No setup UI.** A client would need to manually edit `.env.local`, run 12 SQL migrations, deploy a Flask microservice, configure API keys across 4 services. Developer experience, not customer experience. - -**Theming is hardcoded.** `globals.css` has ~50 CSS custom properties all based on `#00ff41` (matrix green) and `#ff00ff` (magenta). The layout injects `--color-primary` from env vars, but almost nothing in the CSS actually uses those variables. - -**Admin dashboard** — ✅ Decomposed from 626-line monolith into 4 hooks + 8 components (93-line orchestrator). - -**Rate limiting is in-memory.** The `RateLimiter` class uses a `timestamps: number[]` array that dies with each serverless invocation. Adequate for single-event processing but not for concurrent use. - ---- - -## Phase Guide - -### Phase 0: Audit & Understanding (No Code Changes) - -**Goal:** Map every file, every data flow, every dependency. Identify hackathon shortcuts, security issues, and architectural debt. - -**Deliverable:** This document (done). Plus deep familiarity with every architectural decision so Sonia can speak to them fluently. - -**Key files to internalize:** - -| File | What it does | Interview angle | -|------|-------------|-----------------| -| `src/lib/config.ts` | Singleton config from env vars | How config works, what's customizable | -| `src/lib/pipeline/rate-limiter.ts` | In-memory sliding window — dies per invocation | Distributed rate limiting, token bucket algorithms | -| `src/lib/pipeline/phases/describe.ts` | Gemini vision + embedding with wall-clock guard | Serverless timeout management, batch processing | -| `src/lib/pipeline/phases/sync.ts` | Drive ↔ Supabase reconciliation | Data consistency, idempotent sync patterns | -| `src/lib/pipeline/retry.ts` | Exponential backoff with jitter, Retry-After | Resilience patterns, backoff algorithms | -| `src/lib/pipeline/gemini-client.ts` | Vision analysis + batch embeddings (100/request) | AI integration, structured output parsing | -| `src/app/api/search/route.ts` | Hybrid semantic + full-text search merge | Vector search architecture, ranking strategies | -| `src/app/api/match/route.ts` | Face embedding cosine similarity with tiers | pgvector, embedding similarity thresholds | -| `supabase/migrations/*` | 12 progressive migrations | Schema evolution, migration strategy | -| `src/components/gallery/PhotoGallery.tsx` | Main orchestrator using 8 custom hooks | Component composition, custom hooks, separation of concerns | - -**Status:** Complete. - ---- - -### Phase 1: Foundation (Repo Hygiene) - -**Goal:** Small, safe changes. Clean hardcoded values, verify env configuration, tighten .gitignore, remove dead code. Each change is a single logical commit. - -#### 1.1 Remove Dead Google Sheets References — DONE -- ✅ Deleted `sheetId` from `EventLensConfig` interface and `config` object in `config.ts` -- ✅ Removed `GOOGLE_SHEET_ID` from `.env.example` -- ✅ Removed `fetchPhotos()` Google Sheets function from `photos.ts` - -#### 1.2 Clean Up .env.example — DONE -- ✅ Removed `GOOGLE_SHEET_ID` -- ✅ Added missing `NEXT_PUBLIC_SECONDARY_COLOR` -- ✅ Added comments explaining which vars are required vs optional -- ✅ Grouped by service (Google, Gemini, Supabase, Auth, Face API, Branding) - -#### 1.3 Verify .gitignore Coverage -- Confirm `.env.local`, `.env`, `node_modules`, `.next`, any credential files are excluded -- Add `services/face-api/__pycache__/` if missing -- **Commit:** `chore: tighten .gitignore coverage` - -#### 1.4 TypeScript Strictness Check -- Verify `strict: true` in `tsconfig.json` -- Fix any `any` types that should be properly typed -- Add explicit return types on exported functions where missing -- **Commit:** `chore: tighten TypeScript strictness` - ---- - -### Phase 2: Architecture Refactor (Interview-Ready Code) - -**Goal:** Make the codebase demonstrate Senior Product Engineer quality. Consistent patterns, clean composition, meaningful naming, proper types. - -#### 2.1 Decompose Admin Dashboard -The admin page (`admin/page.tsx`) is 626 lines. Break into: -- `AdminDashboard.tsx` — orchestrator (like `PhotoGallery.tsx` pattern) -- `PipelineControls.tsx` — phase buttons and full pipeline trigger -- `StatusCards.tsx` — processing status overview -- `FolderBreakdown.tsx` — per-folder stats -- `ActivityLog.tsx` — pipeline activity feed -- `DuplicateManager.tsx` — phash duplicate review -- `useAdminPipeline.ts` — hook for pipeline state and actions -- `useAdminStatus.ts` — hook for status polling - -**MIT relevance:** This mirrors their smoot-design component library pattern. Each component is testable, documentable, and reusable. - -**Commit:** `refactor(admin): decompose dashboard into focused components + hooks` - -#### 2.2 Standardize API Response Patterns -Current API routes have inconsistent error handling and response shapes. Establish: -```typescript -// Consistent success response -{ data: T, meta?: { total, hasMore, cached } } - -// Consistent error response -{ error: string, code?: string, details?: unknown } -``` -Create a `src/lib/api-utils.ts` with `successResponse()`, `errorResponse()`, `withErrorHandler()` wrapper. - -**MIT relevance:** Maps to their drf-spectacular OpenAPI pattern — consistent, documentable API contracts. - -**Commit:** `refactor(api): standardize response shapes and error handling` - -#### 2.3 Improve Type Definitions -- Add JSDoc comments to all interfaces in `types.ts` explaining the domain model -- Add discriminated unions where appropriate (e.g., pipeline phase results) -- Create `src/lib/pipeline/types.ts` improvements with proper phase state types - -**MIT relevance:** Type annotations are explicitly called out in their engineering values. - -**Commit:** `refactor(types): add JSDoc documentation and improve type precision` - -#### 2.4 Extract Hardcoded Strings -Create `src/lib/strings.ts` for all user-facing copy: -```typescript -export function getStrings(config: EventLensConfig) { - return { - loginTitle: config.eventName || "Event Photos", - loginSubtitle: config.eventTagline || "Find your photos", - searchPlaceholder: `Search ${config.eventName} photos...`, - // ... - }; -} -``` -Replace hardcoded "PHOTO RECONNAISSANCE SYSTEM", "OPERATIVES", "VISUAL RECON" etc. - -**Commit:** `refactor: extract hardcoded UI strings to configurable strings module` - -#### 2.5 Add Error Boundaries and Loading States -- Verify `ErrorBoundary` is used at appropriate levels (it exists but check coverage) -- Add proper loading skeletons for admin dashboard -- Add empty state components where missing - -**Commit:** `feat(ui): improve error boundary coverage and loading states` - ---- - -### Phase 3: RFC & Documentation (The Interview Artifacts) - -**Goal:** Write the documents that demonstrate architectural thinking. These are as important as the code itself for the interview. - -#### 3.1 ARCHITECTURE.md — DONE -- ✅ 9-section RFC: Problem Statement, Goals & Non-Goals, Architecture Decisions, Data Model, Pipeline Design, Search Architecture, Component Architecture, Tradeoffs, Future. - -#### 3.2 Polish README — DONE -- ✅ Added Design Decisions section linking to ARCHITECTURE.md -- ✅ Added Development Process section on AI pair programming workflow -- ✅ Cleaned up env vars table (removed dead GOOGLE_SHEET_ID) - -#### 3.3 Inline Documentation -Add comments explaining "why" (not "what") at key architectural decision points: -- Why the wall-clock guard is 250s (not 280 or 290) -- Why face embeddings use a separate table (not a column on photos) -- Why the CDN URL pattern uses `=w640` sizing -- Why `timingSafeEqual` matters for password comparison -- Why we batch embeddings at 100 per request (Gemini's limit) - -**Commit:** `docs: add inline comments at key architectural decision points` - ---- - -### Phase 4: Product Hardening (Portable Tool Ready) - -**Goal:** Everything an organizer needs to deploy this for their event. - -#### 4.1 Auth Improvement - -**Current state:** Cookie is `auth=true` (forgeable). Passwords are plaintext env vars. - -**Target:** -- Signed session cookies using `iron-session` or `jose` JWT -- Rate limiting on login: 5 attempts/min per IP -- Keep env-var-based password (appropriate for a portable tool where the organizer controls the deployment) - -**Commit sequence:** -1. `feat(auth): replace boolean cookie with signed session tokens` -2. `feat(auth): add rate limiting on login endpoints` - -#### 4.2 Setup Wizard - -Build `/admin/setup` — a guided first-run experience that validates configuration. - -**Steps:** -1. **API key validation** — verify Google API Key, Drive Folder ID, Gemini API Key, Supabase credentials -2. **Event configuration** — name, year, tagline, colors (with live preview) -3. **Face-api health check** (optional) — verify Railway service is reachable -4. **First sync trigger** — run the pipeline from the setup flow - -**Commit sequence:** -1. `feat(admin): setup wizard UI with validation` -2. `feat(admin): first-run detection in middleware` - -#### 4.3 Dynamic Theming - -**Current:** ~50 hardcoded CSS vars based on green/magenta. - -**Target:** Generate full opacity palette from user's chosen colors at runtime. - -Global find-replace: `var(--el-green` → `var(--el-primary`, `var(--el-magenta` → `var(--el-secondary`. - -Fix inline SVG cursors and glow effects that use hardcoded rgba values. - -**Commit sequence:** -1. `refactor(css): replace hardcoded color palette with dynamic CSS custom properties` -2. `refactor(ui): update all component color references to use dynamic palette` - -#### 4.4 Rate Limiting for API Endpoints -- `/api/auth/login` — 5/min per IP -- `/api/match` — 10/min per IP (expensive: calls InsightFace) -- `/api/search` — 30/min per IP (calls Gemini embeddings) -- `/api/admin/*` — 60/min per token - -**Commit:** `feat(api): add rate limiting to auth, match, search, and admin endpoints` - ---- - -## Feature Backlog (Future) - -These are documented feature specs from the hackathon, ready to implement: - -### Auto-Albums (CLAUDE-AUTOALBUMS.md) -K-means clustering on existing 768-dim embeddings → thematic album filter chips. ~5-10 Gemini calls total (one per cluster for naming). Zero cost for basic version. - -### Collage Maker (CLAUDE-COLLAGE.md) -Sharp server-side compositing from selected photos. Grid layout algorithm, optional Gemini hero pick. Already partially implemented. - -### Perceptual Hash Deduplication (CLAUDE-DEDUP.md) -dHash 64-bit fingerprinting for burst/re-upload detection. Admin review UI for duplicate clusters. Zero API cost — pure pixel math + SQL. Already implemented (migration 010). - -### Performance Optimizations (PERF-PROMPTS.md) -Four targeted fixes: lazy-load images, progressive rendering via IntersectionObserver, cap stagger animations, paginate API. Already partially implemented (progressive render is live). - -### Four Corners Metadata Generation -Leverage the Gemini Vision pipeline to bulk-generate Four Corners metadata for photographers. Uses the same `visible_text`, `people_descriptions`, and `scene_description` output. See: [github.com/The-Tech-Margin/four-corners-metadata-generator](https://github.com/The-Tech-Margin/four-corners-metadata-generator) - -### Social Features -- Posting to social from the app -- Likes and share counts -- Shareable photo/collage links - ---- - -## Implementation Order - -### Sprint 1: Foundation + Documentation (Weekend — before Monday) -- ✅ Phase 1.1 (remove dead Google Sheets) -- ✅ Phase 1.2 (clean .env.example) -- ✅ Phase 1.3 (.gitignore verified — secrets excluded) -- ✅ Phase 2.1 (admin decomposed: 4 hooks + 8 components) -- ✅ Phase 3.1 (ARCHITECTURE.md — full 9-section RFC) -- ✅ Phase 3.2 (README polish) -- ✅ Pipeline tests (52 tests, 6 co-located test files, Jest) -- ✅ GitHub Actions CI workflow (test → typecheck → lint → build) -- ✅ Drive pagination verified (nextPageToken loop already in place) - -### Sprint 2: Interview-Ready Code (Week 2) -- Phase 2.2 (API response patterns) — 0.5 day -- Phase 2.3 (type improvements) — 0.5 day -- Phase 2.4 (extract strings) — 0.5 day -- Phase 2.5 (error boundaries) — 0.5 day -- Phase 3.3 (inline docs) — 0.5 day - -### Sprint 3: Product Hardening (Week 3-4) -- Phase 4.1 (auth improvement) — 1 day -- Phase 4.2 (setup wizard) — 2 days -- Phase 4.3 (dynamic theming) — 1 day -- Phase 4.4 (API rate limiting) — 0.5 day - ---- - -## Working Principles - -These apply to all phases: - -1. **Always explain before implementing.** Present current state, what's wrong, 2-3 options, tradeoffs. Let Sonia choose. - -2. **Teach as you go.** When refactoring, explain the principle so it can be discussed in interview context. - -3. **Flag MIT-relevant patterns.** When a refactor aligns with something in mit-learn (OpenAPI, design system, search architecture, component composition), call it out. - -4. **Commit-sized changes.** Each change is a logical unit that can be reviewed and understood independently. - -5. **Don't over-engineer.** This is a small, focused product. Keep it proportional. The goal is demonstrating good judgment about *when* to engineer and when to keep it simple. - ---- - -## Reference: Existing Planning Documents - -- `ARCHITECTURE.md` — RFC-style design document (the interview artifact) -- `CLAUDE-AUTOALBUMS.md` — Auto-albums feature spec (5 commits) -- `CLAUDE-COLLAGE.md` — Collage feature spec (5 commits) -- `CLAUDE-DEDUP.md` — Deduplication feature spec (5 commits) -- `PERF-PROMPTS.md` — Performance optimization prompts (4 targeted fixes) -- `TEMPLATE-REVIEW.md` — Full template conversion code review diff --git a/PERF-PROMPTS.md b/PERF-PROMPTS.md deleted file mode 100644 index 29edc9d..0000000 --- a/PERF-PROMPTS.md +++ /dev/null @@ -1,253 +0,0 @@ -# EventLens Photo Grid Performance Prompts - -Four targeted prompts to fix the rendering bottleneck in `src/app/page.tsx` where `filteredPhotos.map()` mounts every photo into the DOM simultaneously. - ---- - -## Prompt 1: Lazy-load non-priority images - -**File:** `src/app/page.tsx`, PhotoCard component (~line 1452) - -``` -In src/app/page.tsx, find the PhotoCard component's element (around line 1452). It currently looks like this: - -{photo.filename} setImgLoaded(true)} - onError={() => setImgError(true)} -/> - -Add `loading={index < 8 ? "eager" : "lazy"}` so the browser defers fetching off-screen images. The result should be: - -{photo.filename} setImgLoaded(true)} - onError={() => setImgError(true)} -/> - -Do not change anything else in PhotoCard. This is a one-line addition. -``` - ---- - -## Prompt 2: Progressive rendering via IntersectionObserver - -**File:** `src/app/page.tsx`, main gallery component - -``` -In src/app/page.tsx, implement progressive rendering so the photo grid doesn't mount all photos at once. Currently at line ~1054-1074 there's: - -{filteredPhotos.length > 0 && ( -
- {filteredPhotos.map((photo, index) => ( - - ))} -
-)} - -Make these changes: - -1. Add state and ref at the top of the component (the function that contains these renders already imports useState, useRef, useEffect from React): - -const BATCH_SIZE = 40; -const [visibleCount, setVisibleCount] = useState(BATCH_SIZE); -const sentinelRef = useRef(null); - -2. Add an IntersectionObserver effect. Place it with the other useEffect hooks: - -useEffect(() => { - const sentinel = sentinelRef.current; - if (!sentinel) return; - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) { - setVisibleCount(prev => Math.min(prev + BATCH_SIZE, filteredPhotos.length)); - } - }, - { rootMargin: '400px' } - ); - observer.observe(sentinel); - return () => observer.disconnect(); -}, [filteredPhotos.length]); - -3. Reset visibleCount when the filtered set changes (so switching folders or search doesn't show stale counts): - -useEffect(() => { - setVisibleCount(BATCH_SIZE); -}, [activeFolder, activeTag, debouncedQuery, sortOrder]); - -4. Replace the grid render block with: - -{filteredPhotos.length > 0 && ( - <> -
- {filteredPhotos.slice(0, visibleCount).map((photo, index) => ( - { - if (selectMode) { - togglePhotoSelection(photo.id); - } else { - setSelectedPhoto(photo); - } - }} - matchInfo={matchInfoMap?.get(photo.id)} - index={index} - selectMode={selectMode} - selected={selectedIds.has(photo.id)} - isHot={hotPhotoIds.has(photo.driveFileId)} - /> - ))} -
- {visibleCount < filteredPhotos.length && ( -
- )} - -)} - -This renders 40 photos initially and loads 40 more as the user scrolls within 400px of the bottom. Keep the existing filteredPhotos computation untouched — only the .map() call changes to use .slice(0, visibleCount). -``` - ---- - -## Prompt 3: Cap stagger animation to first batch - -**File:** `src/app/page.tsx`, PhotoCard component (~line 1434-1441) - -``` -In src/app/page.tsx, the PhotoCard component applies a stagger animation to every card via: - -className={`... animate-grid-reveal ...`} -style={{ '--delay': `${index * 0.03}s` } as React.CSSProperties} - -With 2,000 photos this creates 2,000 concurrent CSS animations. The compositor chokes. - -Make two changes in the PhotoCard component: - -1. Only apply the animate-grid-reveal class to the first batch (indices 0-39). Change the className on the outer