# 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 |
| `1–5` | 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**
