Files
mule-image/photovault-app-prompt.md
2026-04-06 23:30:19 +02:00

754 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PhotoVault — Full Application Spec Prompt
> A self-hosted, Docker-deployed photo management application inspired by Lightroom's workflow.
> Use this document as the complete specification to build the app from scratch.
---
## 1. Project Overview
Build **PhotoVault**, a self-hosted photo & video management web application optimized for a single-user homelab deployment. The user mounts one or more host folders containing photos/videos; the app indexes them, generates thumbnails, and provides a fast keyboard-driven interface to browse, organize, tag, and manage the library. The architecture must be forward-compatible with AI photo recognition features (face detection, scene classification, CLIP embeddings) to be added in a later phase.
---
## 2. Stack & Deployment
### 2.1 Docker Compose (single `docker-compose.yml`)
```
services:
frontend — React SPA (Nginx)
backend — Python FastAPI
db — SQLite (file-based, volume-mounted)
worker — Celery + Redis for background thumbnail/indexing tasks
redis — Redis (Celery broker)
```
All services declared in one `docker-compose.yml`. Use named volumes for:
- `/data/thumbs` — generated thumbnails (persistent)
- `/data/db` — SQLite database file
- `/data/trash` — files moved to trash
Photo source folders are mounted as **read-write** bind mounts via an environment variable:
```yaml
volumes:
- ${PHOTO_DIRS}:/photos:rw
```
`PHOTO_DIRS` supports multiple paths via a config file (`photovault.yml`) described in §4.
### 2.2 Frontend
- **React 18** + **Vite**
- **Tailwind CSS v4**
- **shadcn/ui** component library
- **TanStack Query** (React Query) for data fetching & cache
- **TanStack Virtual** for virtualized scrolling (critical for performance with thousands of photos)
- **Zustand** for global UI state (selection, active photo, heap, filters)
- **Framer Motion** for transitions
### 2.3 Backend
- **Python 3.12 + FastAPI**
- **SQLite** via **SQLAlchemy 2.0** (async) + **Alembic** for migrations
- **Celery + Redis** for background tasks (thumbnail generation, folder scanning, metadata extraction)
- **pyvips** (libvips) for fast thumbnail generation — preferred over Pillow for speed at scale
- **rawpy** for RAW format decoding (CR2, CR3, NEF, ARW, RAF, DNG, ORF, RW2, etc.)
- **pillow-heif** for HEIC/HEIF (iPhone photos)
- **ffmpeg** (via `ffmpeg-python`) for video thumbnail extraction and metadata
- **pyexiftool** (wraps ExifTool binary) for deep metadata extraction from all formats
- **Watchfiles** for inotify-based folder watching (auto-detect new/deleted files)
> **AI-readiness note**: The backend worker architecture is designed to add a `clip_embed` task later (using `open-clip-torch`) that stores 512-dim CLIP embeddings per photo in the DB. Reserve a `embeddings` table with a `photo_id` FK and a `BLOB` column for the vector. No AI code yet — just the schema placeholder.
---
## 3. Data Model (SQLite via SQLAlchemy)
```sql
-- Core tables
photos (
id TEXT PRIMARY KEY, -- UUID
filepath TEXT UNIQUE NOT NULL,
filename TEXT NOT NULL,
folder_id TEXT REFERENCES folders(id),
media_type TEXT NOT NULL, -- 'photo' | 'video' | 'raw' | 'heic'
original_format TEXT, -- 'CR3', 'NEF', 'HEIC', 'MP4', 'JPEG', etc.
width INTEGER,
height INTEGER,
file_size INTEGER,
taken_at DATETIME, -- from EXIF DateTimeOriginal, fallback to file mtime
taken_at_source TEXT, -- 'exif' | 'filesystem' | 'manual'
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME,
is_trashed BOOLEAN DEFAULT 0,
trashed_at DATETIME,
thumb_small TEXT, -- path to 240px thumb
thumb_medium TEXT, -- path to 640px thumb
thumb_large TEXT, -- path to 1280px thumb
exif_json TEXT, -- full EXIF/XMP blob as JSON
user_title TEXT, -- user-edited title
user_notes TEXT,
rating INTEGER DEFAULT 0, -- 0-5 stars
color_label TEXT, -- 'red'|'orange'|'yellow'|'green'|'blue'|'purple'|NULL
is_picked BOOLEAN DEFAULT 0,
is_rejected BOOLEAN DEFAULT 0
)
folders (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT UNIQUE NOT NULL,
parent_id TEXT REFERENCES folders(id),
source_root_id TEXT REFERENCES source_roots(id),
photo_count INTEGER DEFAULT 0,
last_scanned DATETIME
)
source_roots (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT UNIQUE NOT NULL,
is_active BOOLEAN DEFAULT 1,
added_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
tags (
id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
color TEXT
)
photo_tags (
photo_id TEXT REFERENCES photos(id) ON DELETE CASCADE,
tag_id TEXT REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (photo_id, tag_id)
)
heaps (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME
)
heap_photos (
heap_id TEXT REFERENCES heaps(id) ON DELETE CASCADE,
photo_id TEXT REFERENCES photos(id) ON DELETE CASCADE,
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
sort_order INTEGER DEFAULT 0,
PRIMARY KEY (heap_id, photo_id)
)
-- AI-readiness placeholder (no implementation yet)
embeddings (
photo_id TEXT PRIMARY KEY REFERENCES photos(id) ON DELETE CASCADE,
model TEXT, -- e.g. 'clip-vit-b32'
vector BLOB -- raw float32 bytes
)
```
**Indexes**: Create indexes on `photos.taken_at`, `photos.folder_id`, `photos.is_trashed`, `photos.rating`, `photos.color_label`, `photo_tags.tag_id`.
---
## 4. Configuration
App is configured via a `photovault.yml` file mounted into the backend container:
```yaml
source_roots:
- name: "Main Library"
path: /photos/main
- name: "iPhone Imports"
path: /photos/iphone
thumbnails:
small: 240 # px, longest edge
medium: 640
large: 1280
quality: 85 # JPEG quality
format: webp # output format for thumbs
scanner:
watch: true # use watchfiles inotify
initial_scan_on_start: true
trash:
path: /data/trash
```
---
## 5. Backend API (FastAPI)
All routes under `/api/v1/`. Authentication: none (single-user, homelab). Use async SQLAlchemy sessions.
### 5.1 Photos
```
GET /photos List photos (pagination + filters — see §5.5)
GET /photos/{id} Get single photo with full EXIF + tags
GET /photos/{id}/thumb/{size} Serve thumbnail (small|medium|large) — use X-Accel-Redirect for Nginx
GET /photos/{id}/original Serve original file (download)
PATCH /photos/{id} Update: user_title, user_notes, rating, color_label, is_picked, is_rejected, taken_at (manual override)
DELETE /photos/{id} Move to trash (sets is_trashed=1, moves file to /data/trash)
POST /photos/bulk Bulk actions: { ids: [], action: 'trash'|'restore'|'delete_permanent'|'move'|'copy'|'add_tag'|'remove_tag'|'set_rating'|'set_color'|'pick'|'reject' }
POST /photos/bulk/move Move files to a target folder_id
POST /photos/bulk/copy Copy files to a target folder_id
```
### 5.2 Folders
```
GET /folders Folder tree (nested, with photo_count)
GET /folders/{id}/photos Photos in folder (supports same filters as /photos)
POST /folders Create folder (creates directory on disk)
PATCH /folders/{id} Rename folder (renames directory on disk)
DELETE /folders/{id} Delete folder — requires folder to be empty
POST /folders/{id}/scan Trigger manual re-scan of folder
```
### 5.3 Heaps
```
GET /heaps List all heaps
POST /heaps Create heap { name }
GET /heaps/{id} Get heap with photos
PATCH /heaps/{id} Rename heap
DELETE /heaps/{id} Delete heap (does NOT delete photos)
POST /heaps/{id}/photos Add photos { photo_ids: [] }
DELETE /heaps/{id}/photos Remove photos { photo_ids: [] }
POST /heaps/{id}/convert Convert heap to folder on disk: { target_path, move: bool }
```
### 5.4 Tags
```
GET /tags List all tags with usage counts
POST /tags Create tag
PATCH /tags/{id} Rename / recolor tag
DELETE /tags/{id} Delete tag (removes from all photos)
GET /tags/{id}/photos Photos with this tag
```
### 5.5 Filters & Search
All list endpoints support these query parameters:
```
q Full-text search (filename, user_title, user_notes, EXIF JSON)
date_from ISO8601 datetime
date_to ISO8601 datetime
folder_id Filter by folder (recursive if include_subfolders=true)
tag_ids Comma-separated tag IDs (AND logic by default; mode=or for OR)
media_type photo|video|raw|heic (comma-separated for multiple)
rating_min 0-5
rating_max 0-5
color_label red|orange|yellow|green|blue|purple|none
is_picked true|false
is_rejected true|false
is_trashed true|false (default false)
heap_id Filter to photos in a specific heap
sort taken_at|added_at|filename|file_size|rating (default taken_at)
order asc|desc (default desc)
page integer (default 1)
per_page integer (default 100, max 500)
```
Full-text search uses SQLite FTS5. Create a virtual FTS table:
```sql
CREATE VIRTUAL TABLE photos_fts USING fts5(
photo_id UNINDEXED,
filename,
user_title,
user_notes,
exif_text -- denormalized key EXIF fields as plain text (camera make/model, GPS, lens, etc.)
);
```
### 5.6 Trash
```
GET /trash List trashed photos (same filters)
POST /trash/restore Restore { photo_ids: [] } — moves files back to original folder
DELETE /trash/empty Permanently delete all trashed photos + files
DELETE /trash/{id} Permanently delete single photo + file
```
### 5.7 Library Stats & Scanning
```
GET /library/stats { total_photos, total_videos, total_size, last_scan }
POST /library/scan Trigger full re-scan (Celery task)
GET /library/scan/status { status, progress, current_folder, queued, done }
```
### 5.8 Background Tasks (Celery)
- `scan_folder(folder_path)` — Walk directory tree, insert/update photos, detect deletions
- `generate_thumbs(photo_id)` — Generate small/medium/large WebP thumbnails via pyvips/rawpy/ffmpeg
- `extract_metadata(photo_id)` — Run ExifTool, parse EXIF/XMP/IPTC, update DB
- `watch_folders()` — Long-running Watchfiles task, dispatches scan_folder on changes
- `embed_photo(photo_id)` *(placeholder, no-op)* — Reserved for CLIP embeddings
**Priority queues**: Thumbnail generation for visible photos should be on a `high` queue; full library scans on a `low` queue.
---
## 6. Frontend Architecture
### 6.1 Layout
Three-pane layout (similar to Lightroom Library module):
```
┌─────────────────────────────────────────────────────────────┐
│ TOP BAR [Logo] [Search] [Filters bar] [View mode] [Heap] │
├──────────┬──────────────────────────────────┬───────────────┤
│ │ │ │
│ LEFT │ MAIN TIMELINE │ RIGHT │
│ SIDEBAR │ (continuous scroll, │ SIDEBAR │
│ │ sticky date headers, │ (metadata │
│ Folder │ virtualized thumbnail │ panel for │
│ tree │ grid) │ selected │
│ │ │ photo) │
│ Heaps │ │ │
│ list │ │ │
│ │ │ │
│ Tags │ │ │
└──────────┴──────────────────────────────────┴───────────────┘
```
- Left sidebar: resizable, collapsible (shortcut: `Tab`)
- Right sidebar: collapsible (shortcut: `I`), shows when ≥1 photo selected
- Main area: full virtualized scroll, single scroll region
### 6.2 Views
| View | Shortcut | Description |
|------|----------|-------------|
| Grid (Library) | `G` | Default timeline thumbnail grid |
| Loupe (Fullscreen) | `E` | Single photo full-viewport view |
| Compare | `C` | Side-by-side compare of 2 selected photos |
### 6.3 Timeline View (Grid)
- **Continuous vertical scroll** with **sticky date headers** that label each date group (Year / Month / Day — configurable via a "Group by" dropdown: Year, Month, Day, Week, Folder)
- Thumbnails rendered via **TanStack Virtual** — only DOM nodes in/near viewport are rendered
- Thumbnail grid is **responsive** — uses CSS grid with `auto-fill` and configurable thumbnail size (slider or `+/-` keys)
- Thumbnails show: image, hover overlay with filename, EXIF date, optional rating stars
- **Lazy thumbnail loading**: request `thumb_small` initially; upgrade to `thumb_medium` on hover/selection
- On initial scan, show a shimmer skeleton for photos without thumbnails yet; poll backend for thumb completion
### 6.4 Keyboard Shortcuts (Lightroom-style)
#### Navigation (Grid mode)
| Key | Action |
|-----|--------|
| `←` `→` `↑` `↓` | Move cursor one photo in direction |
| `Shift+←/→/↑/↓` | Extend selection |
| `Cmd/Ctrl+A` | Select all |
| `Cmd/Ctrl+Shift+A` | Deselect all |
| `Space` | Quick preview (fullscreen loupe, hold) |
| `Enter` | Open loupe view |
| `Esc` | Deselect / close loupe |
| `Home` / `End` | Jump to first / last photo |
| `Page Up/Down` | Scroll by screen height |
#### Navigation (Loupe mode)
| Key | Action |
|-----|--------|
| `←` `→` | Previous / next photo |
| `Esc` | Return to grid |
| `Z` | Toggle zoom (fit ↔ 100%) |
| `+` / `-` | Zoom in / out |
#### Flagging & Rating
| Key | Action |
|-----|--------|
| `P` | Pick (flag) |
| `X` | Reject |
| `U` | Unflag |
| `15` | Set star rating |
| `0` | Remove star rating |
| `6` | Red label |
| `7` | Orange label |
| `8` | Yellow label |
| `9` | Green label |
#### Actions
| Key | Action |
|-----|--------|
| `G` | Go to grid view |
| `E` | Go to loupe view |
| `C` | Compare view (2 selected) |
| `Tab` | Toggle left sidebar |
| `I` | Toggle right metadata sidebar |
| `\` | Toggle filter bar |
| `F` | Toggle fullscreen |
| `Delete` | Move selected to trash |
| `Shift+Delete` | Permanently delete (if in trash view) |
| `Cmd/Ctrl+Z` | Undo last action |
| `Cmd/Ctrl+Shift+Z` | Redo |
| `Cmd/Ctrl+C` | Copy selected to clipboard (for move/copy target) |
| `Cmd/Ctrl+X` | Cut selected (for move) |
| `Cmd/Ctrl+V` | Paste into current folder |
| `T` | Add/remove from active heap |
| `Cmd/Ctrl+F` | Focus search bar |
| `/` | Focus search bar |
| `?` | Show keyboard shortcut reference overlay |
All shortcuts must work without modifier unless noted. Shortcuts must be suppressed when focus is inside an input/textarea.
### 6.5 Bulk Selection
- **Click** — select single photo (deselects others)
- **Shift+Click** — range select from last selected to clicked
- **Cmd/Ctrl+Click** — toggle individual photo in selection
- **Cmd/Ctrl+A** — select all visible
- A **selection bar** appears at the top of the main area when ≥2 photos selected, showing count and bulk action buttons: Rate, Color Label, Tag, Add to Heap, Move, Copy, Trash, Export
- Bulk actions call `POST /api/v1/photos/bulk`
### 6.6 Metadata Sidebar (Right Panel)
When a photo is selected, the right sidebar shows:
**Section: Preview**
- Large thumbnail (clicking opens loupe)
- Filename (editable inline, renames file on disk)
- User title (editable)
- User notes (textarea)
- Rating (5-star widget, keyboard-interactive)
- Color label (color dot picker)
- Flags: Picked / Rejected toggles
**Section: Tags**
- Tag chips with remove button
- "Add tag" autocomplete input
- Create new tag inline
**Section: EXIF / Metadata**
Collapsible groups:
- *Camera*: Make, Model, Serial, Lens, Firmware
- *Capture*: Date Taken (editable override), Shutter Speed, Aperture, ISO, Focal Length, EV, Flash, White Balance, Metering Mode
- *File*: Format, Dimensions, File Size, Color Space, Bit Depth
- *Location*: GPS lat/lon shown on a small Leaflet.js map tile if available; altitude, country, city (reverse-geocoded via nominatim.openstreetmap.org on demand)
- *IPTC/XMP*: Copyright, Creator, Description, Keywords
**Section: Histogram** (stretch goal)
- Live RGB+Luminosity histogram rendered from a downsampled version of the photo
### 6.7 Filter Bar
A collapsible horizontal bar below the top bar (shortcut `\`). Contains:
| Control | Type |
|---------|------|
| Date range | Date range picker (from/to) |
| Media type | Multi-select chips: Photo / Video / RAW / HEIC |
| Rating | Min/max star slider |
| Color label | Color dot multi-select |
| Flags | Picked / Rejected / Unflagged toggle buttons |
| Tags | Multi-select tag dropdown (AND/OR mode toggle) |
| Camera make | Dropdown (populated from DB) |
| Lens | Dropdown (populated from DB) |
Active filters shown as removable chips in the filter bar. "Clear all" button. Filter state persists in URL query params for shareability/bookmarks.
### 6.8 Search
- Magnifier icon in top bar, shortcut `/` or `Cmd+F`
- Full-text search via FTS5 backend
- Search covers: filename, user title, user notes, camera make/model, lens, GPS place names, tags
- Results appear inline in the current view (no separate search results page)
- Search combined with active filters (additive)
### 6.9 Folder Tree (Left Sidebar)
- Hierarchical tree view of all source roots and their subfolder structure
- Each folder shows photo count badge
- Right-click context menu: New Subfolder, Rename, Move Photos Here, Scan Now, Copy Path
- Drag-and-drop folders to rearrange (moves directory on disk with confirmation)
- "All Photos" virtual root node at top
- "Trash" virtual node at bottom with count badge
### 6.10 Heaps Panel (Left Sidebar)
- List of named heaps below folder tree
- "+ New Heap" button (creates unnamed heap, prompts for name)
- Each heap shows photo count
- Click heap → main area shows heap contents in grid
- Right-click context menu: Rename, Convert to Folder (prompts for target path + move/copy choice), Delete Heap, Clear Heap
- **Active Heap indicator**: One heap can be set as "active" (bold + icon). Pressing `T` adds/removes the selected photo(s) from the active heap.
- A persistent "current heap" pill shown in the top bar when a heap is active
### 6.11 Loupe View
- Single photo, full-viewport
- Original-quality image (served from backend, format-agnostic — backend transcodes RAW/HEIC to JPEG/WebP on the fly for web display)
- Zoom: fit-to-window ↔ 100% (toggle `Z`), scroll wheel to zoom, drag to pan at 100%+
- Filmstrip at bottom: horizontally scrollable strip of thumbnails (current context — same folder or heap), keyboard navigable
- Left panel collapse, right metadata panel still accessible
- For videos: HTML5 `<video>` player with controls, muted autoplay of preview, unmute toggle
### 6.12 Trash View
- Accessible via "Trash" node in sidebar
- Same grid layout, same filters, same shortcuts
- Extra actions in bulk selection bar: Restore, Permanently Delete
- "Empty Trash" button at top with confirmation dialog showing count + total size
### 6.13 Library Scan Progress
- On first launch or manual scan trigger: a non-blocking progress bar in the top bar
- Shows: `Scanning… 1,234 / 12,456 photos indexed`
- Photos appear in the timeline as they are indexed (optimistic streaming via polling `GET /library/scan/status` every 2s)
---
## 7. Media Handling
### 7.1 Supported Formats
| Category | Formats |
|----------|---------|
| JPEG | `.jpg`, `.jpeg` |
| PNG | `.png` |
| TIFF | `.tif`, `.tiff` |
| WebP | `.webp` |
| HEIC/HEIF | `.heic`, `.heif` (via pillow-heif) |
| RAW — Canon | `.cr2`, `.cr3` |
| RAW — Nikon | `.nef`, `.nrw` |
| RAW — Sony | `.arw`, `.srf` |
| RAW — Fuji | `.raf` |
| RAW — Panasonic | `.rw2` |
| RAW — Olympus | `.orf` |
| RAW — Samsung | `.srw` |
| RAW — Pentax | `.pef` |
| RAW — Leica | `.rwl`, `.dng` |
| RAW — DNG (universal) | `.dng` |
| RAW — Others | via rawpy (libraw) fallback |
| Video | `.mp4`, `.mov`, `.avi`, `.mkv`, `.mts`, `.m2ts`, `.3gp` |
| Live Photos | `.heic` + `.mov` pair (detect by matching base filename) |
### 7.2 Thumbnail Generation Pipeline
For each photo during indexing:
1. Detect format by extension + magic bytes
2. Decode to in-memory RGB image:
- JPEG/PNG/TIFF/WebP → pyvips native
- HEIC/HEIF → pillow-heif → pyvips
- RAW → rawpy (half-size decode for speed) → numpy → pyvips
- Video → ffmpeg extract frame at 10% duration → pyvips
3. Auto-rotate by EXIF orientation
4. Generate 3 sizes: 240px, 640px, 1280px (longest edge, maintain AR)
5. Save as WebP (quality 85) to `/data/thumbs/{photo_id}/{size}.webp`
6. Update `thumb_small`, `thumb_medium`, `thumb_large` columns in DB
For web display of original RAW/HEIC in loupe view: generate a full-res WebP proxy on demand (cached). Serve via `GET /photos/{id}/proxy`.
### 7.3 Metadata Extraction
Run ExifTool (subprocess) on every file during indexing. Parse output JSON. Store:
- `taken_at` — prefer `DateTimeOriginal`, fallback: `CreateDate`, `MediaCreateDate`, file mtime
- GPS coordinates if present
- All EXIF/IPTC/XMP fields stored as JSON in `exif_json`
- Denormalize key fields to FTS table for search
For Live Photos: link the `.mov` sidecar to the `.heic` via a `live_photo_video_id` FK on the photos table.
---
## 8. File Operations
All file operations that touch disk must:
1. Validate target path is within a known source_root (prevent path traversal)
2. Execute atomically where possible (temp file + rename)
3. Update DB after successful disk operation (never before)
4. Emit a WebSocket event (or SSE) so the frontend can update optimistically
5. Be undoable via Undo stack (store reverse operation in memory, max 50 ops)
### Operations
| Operation | Disk action | DB action |
|-----------|-------------|-----------|
| Move photos | `shutil.move` | Update `filepath`, `folder_id` |
| Copy photos | `shutil.copy2` | Insert new photo record |
| Rename file | `os.rename` | Update `filepath`, `filename` |
| Rename folder | `os.rename` | Update folder `path` recursively |
| Create folder | `os.makedirs` | Insert folder record |
| Trash photo | Move to `/data/trash/{id}/original.{ext}` | Set `is_trashed=1`, `trashed_at` |
| Restore from trash | Move back to original path (or new path if original gone) | Clear `is_trashed` |
| Permanent delete | `os.unlink` | Delete photo record (cascade to tags, heaps) |
| Convert heap to folder | `os.makedirs(target)` + move/copy each photo | Insert folder, update photo folder_id |
---
## 9. Performance Requirements
- **Initial page load**: < 2s (LCP)
- **Timeline scroll** (10,000+ photos): 60 fps — enforced by TanStack Virtual (only ~20-30 DOM nodes rendered at any time)
- **Thumbnail serve**: < 50ms via Nginx X-Accel-Redirect (backend sets header, Nginx serves file directly)
- **Search**: < 200ms for FTS5 query on 100k photos
- **Thumbnail generation**: ≥ 10 photos/sec on typical homelab CPU (pyvips is ~10x faster than Pillow)
- **Scan throughput**: ≥ 500 files/sec metadata scan (ExifTool batch mode processes files in bulk)
- **Celery workers**: 4 concurrent workers by default (`CELERYD_CONCURRENCY=4` env var)
- Images not yet thumbnailed show a shimmer skeleton; thumbnails stream into view as they complete
---
## 10. UI Design System
### 10.1 Aesthetic
Dark-first application (photography tools are dark-themed to preserve color perception). Light mode available via toggle.
- **Dark mode primary surface**: Near-black warm dark `#111110`, not cold gray
- **Accent**: Desaturated teal — does not compete with photo colors
- **Typography**: `Geist` (body, UI chrome) + `Geist Mono` (metadata values, EXIF numbers)
- Dense UI — this is a power tool, not a consumer app. Compact spacing.
- Inspired by: Lightroom Classic, Linear, Darkroom (iOS)
### 10.2 Key UI Components (shadcn/ui)
Use these shadcn/ui primitives: `Button`, `ContextMenu`, `Dialog`, `DropdownMenu`, `Input`, `Label`, `Popover`, `ScrollArea`, `Separator`, `Sheet` (for mobile sidebar), `Skeleton`, `Slider`, `Switch`, `Tabs`, `Textarea`, `Toast`, `Tooltip`
Build custom components:
- `<PhotoThumbnail>` — thumbnail with selection state, pick/reject badges, rating overlay on hover
- `<TimelineGroup>` — sticky date header + grid of thumbnails
- `<VirtualTimeline>` — TanStack Virtual wrapper over TimelineGroups
- `<FilmStrip>` — horizontal scrollable strip for loupe view
- `<StarRating>` — interactive 0-5 stars
- `<ColorLabel>` — 7-state color dot picker
- `<MetadataRow>` — label + value pair with edit-in-place for editable fields
- `<FolderTreeNode>` — recursive folder tree item with context menu
- `<HeapItem>` — heap list item with active indicator
- `<FilterChip>` — removable active filter chip
- `<ProgressBar>` — scan progress in top bar
- `<ShortcutReference>``?` overlay showing all shortcuts in a modal
### 10.3 Color Scheme Variables
```css
/* Dark mode (default for photo apps) */
--color-bg: #111110;
--color-surface: #161615;
--color-surface-2: #1c1c1a;
--color-surface-offset: #222220;
--color-border: rgba(255,255,255,0.08);
--color-text: #e8e6e0;
--color-text-muted: #878580;
--color-text-faint: #4a4845;
--color-primary: #4f98a3; /* desaturated teal */
--color-pick: #4f9e5c; /* green for picked */
--color-reject: #c25a5a; /* red for rejected */
--color-star: #d4a340; /* amber for stars */
```
---
## 11. Error States & Edge Cases
- **File not found on disk** (moved externally): Show "missing file" badge on thumbnail. Offer "Locate File" action.
- **Corrupt/unreadable file**: Log error, show broken-image placeholder, never crash the scan worker.
- **Duplicate detection**: On scan, if a file with the same SHA-256 hash already exists in DB, mark as `is_duplicate=true` — do not create a second record. Show duplicate indicator in thumbnail.
- **Scan in progress + user navigates**: Show partial results immediately as photos are indexed.
- **Disk full**: Catch `OSError` on thumbnail write, log, continue scan.
- **RAW decode failure**: Fall back to extracting the embedded JPEG preview from the RAW file (ExifTool can extract it).
---
## 12. Stretch Goals (Phase 2 — Not in Initial Build)
These must not be built now but the architecture must not block them:
1. **AI Scene Classification** — CLIP embeddings per photo, semantic search ("find photos with mountains")
2. **Face Detection & Clustering** — face_recognition lib or InsightFace, cluster by identity
3. **Smart Albums** — saved filter presets that auto-populate (e.g., "5-star Canon shots from 2024")
4. **Duplicate Finder** — perceptual hash (pHash) across library
5. **Export Presets** — resize + watermark + format conversion on export
6. **Multi-user** — add FastAPI auth (JWT), per-user libraries
7. **Mobile PWA** — service worker, offline thumbnail caching
---
## 13. Docker Compose File Structure
```
photovault/
├── docker-compose.yml
├── photovault.yml ← user config
├── .env ← PHOTO_DIRS, REDIS_URL, etc.
├── frontend/
│ ├── Dockerfile
│ ├── package.json
│ ├── vite.config.ts
│ └── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── store/ ← Zustand stores
│ ├── components/
│ │ ├── layout/
│ │ ├── timeline/
│ │ ├── loupe/
│ │ ├── sidebar/
│ │ ├── metadata/
│ │ └── shared/
│ ├── hooks/
│ ├── api/ ← TanStack Query hooks + axios client
│ └── lib/
│ └── shortcuts.ts ← global keyboard shortcut registry
└── backend/
├── Dockerfile
├── requirements.txt
├── alembic/
├── app/
│ ├── main.py ← FastAPI app
│ ├── config.py ← pydantic settings
│ ├── database.py ← SQLAlchemy async engine
│ ├── models/ ← SQLAlchemy ORM models
│ ├── schemas/ ← Pydantic request/response schemas
│ ├── routers/ ← FastAPI routers per domain
│ │ ├── photos.py
│ │ ├── folders.py
│ │ ├── heaps.py
│ │ ├── tags.py
│ │ ├── trash.py
│ │ └── library.py
│ ├── services/ ← Business logic
│ │ ├── scanner.py
│ │ ├── thumbnailer.py
│ │ ├── metadata.py
│ │ └── file_ops.py
│ └── tasks/ ← Celery tasks
│ ├── celery.py
│ ├── scan.py
│ └── thumbs.py
└── nginx.conf ← X-Accel-Redirect for thumb serving
```
---
## 14. Implementation Priorities
Build in this order to get a working MVP as fast as possible:
1. **Docker Compose skeleton** — all services up, health checks passing
2. **DB schema + Alembic migration**
3. **Folder scanner + thumbnail generator** (Celery tasks) — the core engine
4. **`GET /photos` + `GET /photos/{id}/thumb/{size}`** — minimum API to display photos
5. **Frontend: VirtualTimeline + PhotoThumbnail** — display the library
6. **Frontend: keyboard navigation + selection**
7. **Frontend: left sidebar (folder tree + heaps)**
8. **Frontend: right sidebar (metadata panel) + EXIF display**
9. **Filter bar + search**
10. **Loupe view with filmstrip**
11. **File operations: move, copy, rename, trash, restore**
12. **Metadata editing: title, notes, rating, color label, tags**
13. **Heaps: create, populate, convert to folder**
14. **Trash view + permanent delete**
15. **Polish: undo/redo, bulk actions, duplicate detection, live scan progress**