Commit Graph

337 Commits

Author SHA1 Message Date
532932057f style: mule ASCII placeholder in sidebar, bump muted text contrast
Replace Info icon with braille mule art in the right sidebar empty
state. Lighten text-muted (#a8997d → #c4b599) and text-faint
(#5e5448 → #7a6e5e) for better readability across the app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:13:54 +02:00
a4f64fad58 docs: update README for vision pipeline and Postgres stack
Reflect current state: Postgres+pgvector replaces SQLite, vision
pipeline (YOLO, CLIP, InsightFace, OCR) is shipped, card-grid browse
views for tags/colors/ratings/people, map view, duplicate detection,
and semantic search are all live. Remove completed items from future
features.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:50:28 +02:00
4bc6dc1dc8 feat: refactor grouping views into card-grid browse pattern
Replace Timeline-based grouped views (tags, colors, rated) with
dedicated card-grid components that drill into Timeline detail views
on click/Enter. Adds shared useCardGridNav hook for arrow-key
navigation across all four card grids (tags, colors, rated, people).

- TagsView, ColorsView, RatedView: card grid → inline Timeline detail
- PeopleView: migrated to same pattern (Timeline replaces custom grid)
- Tags endpoint: fall back to first associated photo for representative
- Filter store: add ratingMax for exact rating filtering in RatedView
- Timeline: remove tag/rating/color grouping; skip date headers when
  groupBy != 'date' so detail views render flat grids
- SettingsDialog: bump z-index above Leaflet map layers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:48:50 +02:00
fa9b21856f feat: replace face pipeline with InsightFace, add content classifier
Face detection/recognition:
- Replace YuNet + SFace with InsightFace buffalo_l (RetinaFace + ArcFace)
- 512-d ArcFace embeddings (was 128-d SFace), migration 0006 resizes column
- Remove YOLO person-bbox workaround — RetinaFace is accurate enough
- Detection threshold 0.65 cleanly separates real faces (0.72+) from
  false positives on dogs/paintings (0.56-0.61)

Content-type classification:
- CLIP zero-shot classifier using native PyTorch text encoder + ONNX
  image encoder for high-quality text-image similarity
- Categories: photograph, screenshot, document, receipt, meme, artwork
- Writes Tag(kind=content_type) per photo via photo_tags
- Margin-based confidence: top-1 vs top-2 score difference
- New ClassifierSettings in config (enabled, min_confidence)
- Wired into vision_fanout pipeline

Tested: 6 real faces from 4 photos (zero false positives), 11/13 photos
classified (8 photograph, 2 artwork, 1 meme).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:49:02 +02:00
f48e099bd2 fix: verify faces against YOLO person detections for precision
Cross-reference face detections with YOLO 'person' bounding boxes —
only keep faces that overlap >= 50% with a detected human body. This
eliminates false positives on dogs, paintings, and cartoons without
needing an aggressive score threshold.

Lower face detection threshold back to 0.6 since the person-overlap
check is now the primary precision filter.

Tested: 6 verified faces from 4 photos, zero false positives.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:13:01 +02:00
40d570f2c2 fix: raise face detection threshold to 0.85, tighten clustering
Eliminates false positives (dog faces, painting faces) by requiring
score >= 0.85. Tighten cluster eps to 0.25 for better separation.
Tested: 4 real faces from 2 photos, no false positives.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:09:43 +02:00
5f3fa5240e fix: search endpoint — correct column name and DISTINCT/ORDER BY
- Fix Photo.created_at → Photo.added_at (column doesn't exist)
- Fix Postgres DISTINCT + ORDER BY conflict by using a subquery for
  tag_id filtering instead of JOIN + DISTINCT on the outer query

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:55:55 +02:00
aba061dd43 fix: People view shows person's photos inline instead of navigating away
Clicking a person card now opens a detail sub-view within the People
section showing their photo grid. Back arrow returns to the card grid.
Photos are clickable to open the preview. Rename is available in both
the card grid and the detail header.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:51:22 +02:00
229611b4c3 fix: face clusters write photo_tags, raise detection threshold to 0.7
- recluster_faces now writes photo_tags rows for each face cluster so
  the tag count and tag_ids filter work (previously count was always 0)
- Old cluster tags and photo_tags are cleaned up before re-clustering
- Raise face detection threshold from 0.4 to 0.7 to reduce false
  positives (was detecting dog faces as people)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:48:19 +02:00
db20cbb7d8 fix: face detection — use OpenCV FaceDetectorYN and full-res originals
- Rewrite faces.py to use cv2.FaceDetectorYN instead of raw ONNX
  (handles multi-scale anchor decoding and NMS internally)
- Load original photo files at up to 4000px for face detection instead
  of 240px thumbnails — faces were too small to detect at thumbnail res
- Falls back to thumbnail if original is unavailable

Tested: 33 faces extracted from 13 photos, clustered into 1 person.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:43:29 +02:00
17a69a271e feat: add People view with person cards and click-to-filter
Replace the tag-grouping people section with a dedicated PeopleView:
- Grid of face cluster cards showing representative photo thumbnail,
  person name, and photo count
- Click a card → navigates to all-photos filtered by that person's tag
- Inline rename via pencil icon on hover
- Empty state when no faces have been clustered yet

Wired into App.tsx as a section-level route alongside Map and Duplicates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:32:20 +02:00
7558aeb5e6 fix: sync DB sessions in Celery, letterbox YuNet, dedupe detections
- Rewrite all vision tasks to use sync psycopg2 sessions instead of
  asyncpg — fixes 'another operation in progress' and event loop errors
  when Celery forks workers sharing the async connection pool
- Letterbox-pad images to exactly 640x640 for YuNet face detector
  (was crashing on non-square thumbnails)
- Deduplicate object detections per label per photo — keep highest
  confidence only to avoid photo_tags PK violation on multiple
  detections of the same class
- Add all queues (-Q default,high,low,vision) to worker command

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:28:17 +02:00
2a6661f779 fix: model weights setup — export scripts, ORT compat, bootstrap
- Add export_models.py for OpenCLIP ViT-B/32 and YOLOv8n ONNX export
- Fix ArgMax(13) ORT ARM64 incompatibility by passing eot_indices as a
  separate ONNX input (computed outside the graph in embed.py)
- Use legacy TorchScript exporter (dynamo=False) for IR version 9 compat
- Upgrade onnxruntime to 1.18.1
- Rewrite bootstrap_models.py with clear separation of auto-downloadable
  models (YuNet, SFace) vs manually-exported ones (OpenCLIP, YOLOv8n)
- Wire bootstrap into worker CMD (runs before Celery)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:41:25 +02:00
29177f0c1a feat: wire frontend to vision pipeline search and tags
- Add search API client (POST /photos/search) and useSearchQuery hook
  for hybrid FTS + semantic search with RRF ranking
- Extend Tag type with kind, source, representative_photo_id fields
- Add tags.merge() API method
- Update useTagsQuery to accept optional kind filter
- Add People section to sidebar (face clusters from GET /tags?kind=face_cluster)
- Sidebar Tags count now shows user tags only; People shows face clusters

The existing GET /photos?q= flow is preserved for browsing; the new
search hook activates when the search box has a non-empty query.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:19:47 +02:00
ad007e4cd4 feat: add face detection, recognition, and clustering
- Create face_embeddings table with pgvector Vector(128) + HNSW index
- Implement extract_faces task (YuNet detection + SFace recognition)
- Implement recluster_faces task (DBSCAN clustering → Tag(kind=face_cluster))
- Clusters are named "Person N" and get representative_photo_id
- cluster_id FK → tags.id, SET NULL on delete for merge/rename support

Migration 0005 creates the face_embeddings table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:13:41 +02:00
1ebc4bfe73 feat: add YOLO object detection writing to unified Tag model
Implement detect_objects Celery task:
- Runs YOLOv8n on 640px thumbnail via ONNX Runtime
- Creates Tag(kind=object) rows for each COCO class detected
- Writes photo_tags associations with confidence, bbox, and source
- Wipes previous detections per source model on re-run

No new tables/migrations — uses the unified Tag model from PR3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:11:26 +02:00
842a4fc864 feat: add OCR text extraction and Postgres full-text search
- Create ocr_text table for storing per-region OCR results
- Add tsvector search_vector column to photos with GIN index and
  auto-update trigger on filename/user_title/user_notes
- Implement ocr_photo Celery task using rapidocr-onnxruntime
- Add FTS leg to hybrid search: queries photos.search_vector and
  ocr_text via UNION, fused with semantic results via RRF (k=60)

Migration 0004 backfills search_vector for existing rows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:10:14 +02:00
649437dc85 feat: add embeddings pipeline and semantic search endpoint
Wire the full embedding flow:
- Rewrite Embedding model to use pgvector Vector(512) with HNSW index
- Add embed_photo, vision_fanout, backfill_vision Celery tasks on
  dedicated `vision` queue
- Hook vision_fanout into generate_thumbnails completion
- Add POST /api/v1/photos/search with hybrid RRF ranking (semantic-only
  for now; FTS leg added in PR5)
- Stub ocr_photo, detect_objects, extract_faces tasks for later PRs

Migration 0003 drops/recreates the embeddings table (was never populated).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:07:32 +02:00
b1c2bdf7f0 feat: extend Tag model for unified ML tagging
Unify object detections, scene labels, and face clusters with user tags
via new columns on the existing Tag model:
- kind (user|object|scene|face_cluster), source, representative_photo_id
- photo_tags gains confidence, bbox (JSONB), source per-association
- Uniqueness moves from (name) to (name, kind) so ML labels coexist
  with user tags without collision

Add Alembic migration 0002 with defensive IF NOT EXISTS guards.

Update tags router: kind filter on GET, merge endpoint for combining
auto-detected clusters/objects, include kind/source in list response.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:02:32 +02:00
9282a5c734 feat: add vision pipeline scaffolding with ONNX backend
Introduce the app/services/vision/ module with ABC interfaces, ONNX
Runtime backend, model registry, and per-task implementations:
- OpenCLIP ViT-B/32 embedder (image + text, 512-d)
- RapidOCR engine (PP-OCRv4 via ONNX, no PaddlePaddle)
- YOLOv8n object detector (raw ONNX, no ultralytics runtime)
- YuNet + SFace face processor (Apache 2.0, opencv_zoo, 128-d)
- DBSCAN face clustering helper

Add VisionSettings to config (mulita.yml + Pydantic), bootstrap_models.py
for first-boot weight downloads, models_data Docker volume, and ROCm
backend stub for future GPU acceleration.

No Celery tasks wired yet — models load but nothing invokes them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:00:06 +02:00
dea04ceed9 feat: migrate to Postgres + pgvector with Alembic scaffolding
Switch the default database from SQLite to Postgres + pgvector (via
pgvector/pgvector:pg16 Docker image) to support the upcoming vision
pipeline (embeddings, OCR, object detection, face clustering).

- Add `db` service to docker-compose.yml with healthcheck
- Wire `alembic upgrade head` into backend CMD before uvicorn
- Bootstrap empty 0001_baseline revision (schema still owned by create_all)
- Guard SQLite-only PRAGMAs and inline ALTERs behind _is_sqlite flag
- Run `CREATE EXTENSION IF NOT EXISTS vector` on Postgres init
- Add asyncpg, psycopg2-binary, pgvector to requirements
- Provide docker-compose.sqlite.yml escape hatch for legacy SQLite mode

Fresh DB + rescan assumed — no SQLite→Postgres data migration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:46:20 +02:00
f01b5ed77e feat: snappier timeline — instant discard, progressive load, restore preview origin
- Discard yanks photos from the grid optimistically (cache strip + active
  cursor advance) instead of waiting for the mutation round-trip; wired
  through the X hotkey, RightSidebar bulk discard, LeftSidebar discard
  drop, and DiscardActionBar restore/delete.
- usePhotosQuery resolves on the first 500-photo page and streams the
  remaining pages into the cache in the background, so the first
  thumbnails paint immediately on large libraries.
- Closing preview restores the photo it was opened on (snapshot ref in
  PreviewView, written directly to the store) and Timeline scrolls that
  row back into view. Escape is handled on the dialog with
  stopPropagation so Timeline's window-level Esc handler doesn't wipe
  the restored selection.
- Preview overlay bumped to z-[1000] so it covers Leaflet map tiles,
  and the right sidebar no longer collapses during preview — both fix
  visible layout shifts on close.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 00:37:33 +02:00
b2ebf401bb chore: text format 2026-04-09 23:48:16 +02:00
57788f60c5 chore: move Tags section directly under Color label
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 23:48:02 +02:00
7cf546af7a feat: map view with GPS extraction fix
Adds a new Map sidebar entry that plots photos by their EXIF GPS
coordinates on a clustered Leaflet map. While wiring this up, the
metadata extractor was reading unprefixed GPS keys that never exist
in `exiftool -G -j` output AND assumed coordinates were already
floats — every photo silently lost its GPS. The new extract_gps
helper handles Composite/EXIF group prefixes and parses DMS strings,
and lat/lon are stored as first-class indexed columns so the map
can query them without parsing exif_json on every request.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 23:44:29 +02:00
9c9f5bd899 chore: change text style 2026-04-09 20:43:33 +02:00
1c6383e5a0 feat: colored count badge on Colors sidebar entry
Adds a `colored` field to /library/stats counting non-discarded
photos with a color_label set, mirroring how `rated` is exposed.
The Colors sidebar entry now shows the live count and refreshes
through the existing LIBRARY_STATS_QUERY_KEY invalidations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:43:19 +02:00
c973ca0443 feat: colors view and color label thumbnail ornament
Adds a Colors entry under Views that buckets photos in canonical
R-O-Y-G-B-P order (plus an Uncolored tail), mirroring the rated/tags
grouping pattern. Surfaces the color label on each thumbnail as a
small swatch chip preceding the rating badge in the BL corner so
labels are visible everywhere, not just inside the new view. Also
types color_label on the Photo interface — the backend already
returned it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:41:06 +02:00
870e7dd3d3 fix: refresh sidebar counts after photo mutations
LIBRARY_STATS_QUERY_KEY was never invalidated by the bulk/single
mutation paths in RightSidebar and PhotoInfoPanel, so the All Photos
/ Rated / Discarded / Tags badges only updated on reload. Add it to
both shared invalidation helpers and the inline updateMutation, and
correct the stale docstring on useLibraryStatsQuery.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:35:17 +02:00
30cbcb8cd1 feat: group rated view by star rating
Mirrors the tags view: rated section now buckets photos 5★→1★ with
an "Unrated" tail group, instead of a flat ratingMin=1 stream.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:30:44 +02:00
03b99a40b0 fix: lock left sidebar row height on hover
Switch tree and heap rows from py-0.5 to a fixed h-[24px] + leading-none
so the hover-only kebab buttons can't stretch the row vertically as the
pointer crosses them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:22:13 +02:00
b993a188d3 style: tint topbar "Built with hubris" footer to black/50
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:22:13 +02:00
367d1277b8 fix: stronger drop shadow on heap card thumbnails
Bump the ActiveHeapCard stack thumbnails to a layered drop shadow and
a darker ring so they lift cleanly off the softened desert backdrop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:11:33 +02:00
7d1149aabd feat: themed scrollbars and chiseled thumbnail chips
- Replace browser scrollbars with slim 6px overlay pills that tint to
  primary on hover (transparent track, Firefox + WebKit).
- Rework the thumbnail badge family: drop the mismatched white ring
  halo for a 1px dark frame + inset top highlight, and swap rounded-
  full pills for rounded-sm chips so the ornaments match the pixel-art
  aesthetic used elsewhere in the app.
- Soften the ActiveHeapCard desert backdrop so the fanned thumbnails
  read on top.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:09:49 +02:00
8f25c58b74 refactor: tighten sidebars with eyebrow section headers
Compact the left/right sidebars and the photo info panel: shrink panel
widths, drop header height, switch top-level tree groups and metadata
sections to small uppercase eyebrow labels, and tighten row padding,
icon sizes, and count badges throughout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:03:22 +02:00
12a616d4b5 fix: align search input height with filter pills
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:53:17 +02:00
2e7158a5bb feat: desert dusk theme with animated mule and pixel-art scenery
Replaces the cool-grey neutral theme with a warm desert dusk palette
pulled from a new pixel-art TopBar: a tiled, right-to-left scrolling
desert under a sky gradient, a 6-frame walking mule sprite where the
logo used to sit, and an ASCII block-character title on a black plate.
The active heap card now uses a cactus/dune scene as its stack
backdrop, and the "Built with hubris" mark moves into the TopBar's
bottom-right corner (AppFooter component removed).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:49:13 +02:00
938c5dce68 refactor: unify thumbnail ornaments into one chip family
Every overlay on a thumbnail now composes the shared THUMB_BADGE_* classes
(one shape, one height, one ring, three semantic colour variants: primary
for user state, neutral for metadata, pick for auto-suggested best). RAW
badges, BEST pill, Keep-this button and the dimensions chip — previously
three different styles — join the family, is_duplicate moves to neutral
since it's file metadata not a user decision, and the selection check
shrinks to match the rest.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:57:55 +02:00
6d8e92940a feat: auto-focus first photo on timeline mount
Arrow-key navigation required a click to establish an active photo
first. Now the grid auto-selects photos[0] on initial mount when no
active photo is set, so the user can land on the app and immediately
walk the grid with arrows. Guarded on viewMode === 'grid' and
!activePhotoId so we never clobber an existing selection or fight
with PreviewView.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:47:16 +02:00
f4c51f6f92 fix: make keyboard hint pill legible over busy thumbnails
The bottom-center hint bar used bg-surface/40 + text-text-muted, which
left busy photos leaking through and washed the text out — users
couldn't actually read the shortcut labels when a bright thumbnail sat
directly behind the pill. Switched to a near-opaque bg-black/80 with
white-alpha text + white/15 key caps. Backdrop blur stays for the
subtle glass feel, but the contrast is now unconditional on the
photo underneath.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:44:04 +02:00
64b84e2069 feat: active heap card in left sidebar with subtle toasts
- New ActiveHeapCard pinned just below the Library header in the left
  sidebar. Renders null when no heap is active. When one is, shows the
  heap name + member count + a fan of the last 5 member thumbnails
  (newest front-and-center, older members rotated ±6°/±18px outward).
  Clicking the header navigates to the heap via the same
  navigateToSection pattern HeapsPanel uses.
- Stack animates with framer-motion (previously pinned in
  package.json but unused). New picks spring-slide into the front of
  the stack by subscribing to the ['heap-photo-ids', heapId] cache
  that the existing pick mutation already updates optimistically —
  no new event wiring. Unpicks run the exit transition and the
  remaining cards re-fan.
- Toasts are now subtle: glassy bg-surface/80 + backdrop-blur, thin
  2px left accent bar in the type color instead of a full tinted
  fill, smaller icons and text, tighter padding, truncation on
  overflow so they stay a single compact row. The colored alert
  block that was competing with the rest of the UI is gone.
- Card lives at the top of the sidebar specifically so the bottom-left
  toast stack can never occlude it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:35:06 +02:00
733c16bf82 feat: perceptual-hash duplicate detection + grouped picker view
The Duplicates section was useless: SHA-256-only detection only caught
byte-identical files, not the actual duplicates a real library
accumulates (re-encoded JPEGs, screenshots, resized exports), and the
view was a flat date-sorted list with no grouping or actions. This
replaces the whole flow.

Detection
- New phash + duplicate_group_id columns on Photo, added via an
  idempotent ALTER TABLE pass in init_db (the project has no Alembic).
- Thumbs worker computes a 64-bit pHash from the original-resolution
  decoded frame just before the destructive thumbnail loop. Falls back
  silently — phash is nice-to-have, not a blocker for thumbnails.
- backfill_phashes Celery task fills in phashes for photos that
  predated the column, reading the existing thumb_large rather than
  re-decoding the original.
- regroup_duplicates service runs union-find over Hamming distance
  (threshold 6), persists duplicate_group_id, and maintains is_duplicate
  as derived state so existing badges/counts keep working. Chained
  after scan_all_source_roots with a 60s countdown.

API
- GET /library/duplicates/groups returns all groups with members,
  bucketed in Python from one query. Each group has a reason ("exact"
  iff every member shares a SHA-256, "similar" otherwise).
- POST /library/maintenance/{regroup-duplicates,backfill-phashes}.

Frontend
- New DuplicatesView (sectioned grid, one section per cluster) replaces
  the timeline when the user is in the duplicates section. Each section
  shows a "Keep best, discard N" button that picks the highest-pixel
  copy and reuses the existing undoable bulk-discard so Cmd+Z works.
- Manual best override: hover any non-best thumbnail and click "Keep
  this" (Crown icon, top-right) to override the auto-pick. The header
  annotates "(manual)" so it's obvious which copy will be kept.
- Keyboard nav within the duplicates view walks the flat member list,
  with ↑/↓ jumping by the measured column count and scrollIntoView on
  every move. Timeline's keyboard handler now early-returns in the
  duplicates section so the two don't fight.
- BEST pill / Keep-this button live at top-right with a ring outline so
  they don't collide visually with the cyan selection ring around a
  selected cell. Dimensions chip moved to bottom-left to free both
  right corners for the keep affordances.
- New "Duplicates" section in SettingsDialog: shows group/member counts
  and exposes both backfill + re-detect actions, sharing a query cache
  with DuplicatesView via DUPLICATE_GROUPS_QUERY_KEY.
- PhotoInfoPanel "Basic Info" section now shows the photo's full file
  path in monospace below the size/dimensions/date grid.
- New imagehash==4.3.1 dep in requirements.txt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:19:08 +02:00
e51b93d59e perf: speed up settings dialog + relocate settings entry point
- Parallelize the six celery inspect.*() calls in /library/maintenance/
  worker-status via asyncio.gather + to_thread, and drop per-call
  timeout from 1.0s to 0.5s. Endpoint goes from ~6.1s to ~0.54s — it
  was the sole bottleneck on opening the Settings dialog.
- SettingsDialog now fetches through React Query with enabled:isOpen,
  so reopening shows cached data instantly while a background refetch
  updates. Worker polling moved to refetchInterval. Loading spinners
  only show when there's no cached data yet, so background refetches
  don't keep them spinning.
- Move the Settings entry point from the TopBar to a pinned row at the
  bottom of the LeftSidebar so it sits alongside the other library
  controls. TopBar no longer takes onOpenSettings.
- Remove the "Scan all folders" bottom action from LeftSidebar — the
  same control already lives in Settings → Library → Re-scan.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:31:52 +02:00
d6c667ae78 feat: persistent metadata panel, symmetric sidebar toggles, keyboard nav scroll
- Right sidebar stays open by default and shows an empty state when
  nothing is selected, instead of auto-hiding on deselect.
- Both sidebars now have a collapse button in their header and an
  expand button in the TopBar that only appears when collapsed, so
  each panel has a discoverable affordance in either state.
- Arrow-key navigation auto-scrolls the destination row into view
  with a ~35% peek margin, cueing the user that there's more content
  in the scroll direction.
- Fix: the width sentinel's measurement effect never installed its
  ResizeObserver when Timeline first rendered the loading state (ref
  was null, empty-dep effect didn't re-run), so containerWidth stuck
  at 0 and the grid fell back to 4 columns × 200px forever. Switched
  to a callback ref that attaches the observer the moment the
  sentinel actually mounts.
- KeyboardHints surface the Tab (library) and I (info) shortcuts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:20:22 +02:00
root
a4b1802657 fix: square timeline cells that fully fill each row
The grid was leaving horizontal space unused for two reasons:

1. Width measurement was based on parentRef.clientWidth - PADDING*2,
   which is fragile to padding/box-sizing/scrollbar mismatches and was
   off by enough pixels in practice to drop a column. Replace with a
   1px-tall normal-flow sentinel rendered inside the inner virtualizer
   wrapper at the exact horizontal extent rows render at. ResizeObserver
   on the sentinel gives the authoritative row width — no padding
   subtraction, no scrollbar guesswork.

2. The flex layout with explicit per-cell px widths accumulated floor()
   rounding error and let cells drift away from square. Switch to a CSS
   grid with fixed-px tracks (`repeat(cols, ${cellSize}px)` + matching
   `gridAutoRows`) so every track is exactly cellSize wide AND tall. By
   construction `cols × cellSize + (cols-1) × gap == measured width`,
   so the row fills edge-to-edge with cells that are guaranteed square.

PhotoThumbnail gains an opt-in `fill` prop the timeline uses to switch
its inline width/height to 100%, so the cell stretches to whatever the
parent grid track gives it. Heap sidebar / non-grid callers still get
explicit `size`-by-`size` square thumbnails as before.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:19:29 +02:00
root
ce25a4460e fix: never cache index.html so new bundle hashes are picked up
Vite content-hashes the JS/CSS bundle filenames, but the only thing
that tells the browser to fetch a new hash is a fresh index.html.
Without explicit no-cache headers nginx falls back to heuristic
caching, so users keep loading the old index.html → old bundle hash
until they hard-reload. Just hit this rolling out the timeline
pagination fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:42:16 +02:00
root
872be4e0cf fix: load full library + stretch grid to fill row width
Two unrelated bugs surfaced together because the symptom looked
similar ("missing photos in the grid"):

usePhotosQuery only ever fetched page 1 with per_page=500, so any
filter matching more than 500 photos silently truncated. With a
13k-photo library that meant the Timeline only showed ~3.8% of
matches and folders with many descendants looked broken. Walks all
pages now (capped at 200 = 100k photos as a sanity bound), keying
the React Query cache on the full filter set as before.

Timeline grid was leaving an unused horizontal strip on the right.
Two issues in the column math:
  - off-by-one: floor((W - 2P) / (T + G)) double-counts gaps. With N
    columns there are only N-1 inter-cell gaps, so the correct form
    is floor((W - 2P + G) / (T + G)). Reclaims a column whenever the
    remainder almost fits.
  - the floor remainder was discarded instead of distributed back
    into the cells. Treat THUMBNAIL_SIZE as a minimum and stretch
    each cell to (available - (cols-1)*gap) / cols so the row fills
    the container.

Also swap the resize listener for a ResizeObserver on the scroll
container so the grid re-flows when the sidebar collapses (window
resize alone misses that).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:22:06 +02:00
root
d27ec1af2e fix: prune orphaned folder rows alongside photo rows
prune_missing_photos() previously only deleted Photo rows whose files
were gone, leaving every folder row from the old library in the DB —
which made the sidebar tree wildly out of sync with the on-disk
structure (still showing /photos/2024/, /photos/2026/03/, etc. that
no longer exist).

Now also drops Folder rows whose path doesn't resolve under a mounted
source root, with the same defensive "skip if source root unmounted"
guard. The Settings orphan card surfaces both counts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:50:32 +02:00
root
697343646a feat: prune orphaned photo rows + retry-pending action
Adds /api/v1/library/maintenance/{missing-stats,prune-missing} backed
by a new cleanup helper that deletes Photo rows whose files no longer
exist on disk under a *mounted* source root. Skips photos under
unmounted roots so a temporarily-disconnected drive doesn't get
silently nuked.

Settings panel surfaces the orphan count with a destructive Prune
button, plus a "Kick pending" action that re-queues photos stuck in
processing_status='pending' (typically left behind when the scanner
created the row but the worker never picked up the thumbnail task).

Common trigger: PHOTO_DIRS in .env was repointed at a different
library root, leaving every old row dangling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:47:06 +02:00
root
3df8add3b6 fix: stop scan crashing on duplicate file hashes
scan.py used scalar_one_or_none() to test whether any other photo
shared the same file_hash, but that helper raises MultipleResultsFound
the moment 2+ rows match — i.e. exactly the duplicate case it was
trying to flag. Every file beyond the second copy bombed out with
"Multiple rows were found when one or none was required" and was
left in the failed bucket. Replace with a COUNT(*) > 0 check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:14:40 +02:00