30 KiB
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:
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_embedtask later (usingopen-clip-torch) that stores 512-dim CLIP embeddings per photo in the DB. Reserve aembeddingstable with aphoto_idFK and aBLOBcolumn for the vector. No AI code yet — just the schema placeholder.
3. Data Model (SQLite via SQLAlchemy)
-- 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:
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:
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 deletionsgenerate_thumbs(photo_id)— Generate small/medium/large WebP thumbnails via pyvips/rawpy/ffmpegextract_metadata(photo_id)— Run ExifTool, parse EXIF/XMP/IPTC, update DBwatch_folders()— Long-running Watchfiles task, dispatches scan_folder on changesembed_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-filland configurable thumbnail size (slider or+/-keys) - Thumbnails show: image, hover overlay with filename, EXIF date, optional rating stars
- Lazy thumbnail loading: request
thumb_smallinitially; upgrade tothumb_mediumon 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
/orCmd+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
Tadds/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/statusevery 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:
- Detect format by extension + magic bytes
- 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
- Auto-rotate by EXIF orientation
- Generate 3 sizes: 240px, 640px, 1280px (longest edge, maintain AR)
- Save as WebP (quality 85) to
/data/thumbs/{photo_id}/{size}.webp - Update
thumb_small,thumb_medium,thumb_largecolumns 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— preferDateTimeOriginal, 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:
- Validate target path is within a known source_root (prevent path traversal)
- Execute atomically where possible (temp file + rename)
- Update DB after successful disk operation (never before)
- Emit a WebSocket event (or SSE) so the frontend can update optimistically
- 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=4env 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
/* 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
OSErroron 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:
- AI Scene Classification — CLIP embeddings per photo, semantic search ("find photos with mountains")
- Face Detection & Clustering — face_recognition lib or InsightFace, cluster by identity
- Smart Albums — saved filter presets that auto-populate (e.g., "5-star Canon shots from 2024")
- Duplicate Finder — perceptual hash (pHash) across library
- Export Presets — resize + watermark + format conversion on export
- Multi-user — add FastAPI auth (JWT), per-user libraries
- 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:
- Docker Compose skeleton — all services up, health checks passing
- DB schema + Alembic migration
- Folder scanner + thumbnail generator (Celery tasks) — the core engine
GET /photos+GET /photos/{id}/thumb/{size}— minimum API to display photos- Frontend: VirtualTimeline + PhotoThumbnail — display the library
- Frontend: keyboard navigation + selection
- Frontend: left sidebar (folder tree + heaps)
- Frontend: right sidebar (metadata panel) + EXIF display
- Filter bar + search
- Loupe view with filmstrip
- File operations: move, copy, rename, trash, restore
- Metadata editing: title, notes, rating, color label, tags
- Heaps: create, populate, convert to folder
- Trash view + permanent delete
- Polish: undo/redo, bulk actions, duplicate detection, live scan progress