The bottom-left KeyboardShortcuts drawer duplicated information that
the contextual KeyboardHints pill already shows for the current
selection state. Removing it in favor of the contextual hints alone.
KeyboardHints was previously a fixed top-14 overlay, which collided
with the FilterBar when it was opened — the hints panel covered the
filter controls. Refactored it to render inline in the App header
stack (TopBar / FilterBar / ActiveFilterChips / KeyboardHints /
Timeline) so it flows naturally and never overlaps.
Also:
- Hide hints in loupe mode (the loupe has its own context)
- Replace the deleted shortcuts (Ctrl+A, Trash) with the newly wired
ones (\\ Filters, / Search, E Loupe) so the hints surface them
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PhotoThumbnail computed cell height as size * min(aspectRatio, 1.5),
so portrait photos overflowed their row. The TanStack Virtual row
estimate is a single fixed value (thumbnailSize + gap), so any cell
taller than that pushed into the row below — visible as overlapping
thumbnails whenever a portrait shared a row with landscapes.
Switching to square cells (Lightroom Library default) means every row
is exactly the estimated height. The image still fills via object-cover,
just cropped on the long axis.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds the spec §6.7 filter bar to the top of the timeline with:
- Date range (native date inputs)
- Media type chips (Photo / Video / RAW / HEIC, multi-select)
- Min star rating (click to set, click again to clear)
- Color label dots (red/orange/yellow/green/blue/purple, single)
- Flag (any / picked / rejected / unflagged)
- Clear-all button
Active filters surface as removable chips below the bar so they're
visible whether the bar is collapsed or open. The TopBar search input
is now wired to the same filter store with a 300ms debounce, and shows
a clear button when populated.
Filter state is the source of truth in a Zustand store and round-trips
through the URL via history.replaceState — bookmarkable and shareable
per spec §6.7. Hydrate happens once on mount; subsequent store changes
write back to ?q=&date_from=&… without navigation.
Timeline reads filter state, builds the backend params via
filtersToParams, and includes them in the React Query key so the cache
invalidates on every filter change. Also fixes a latent bug: Timeline
was sending limit=1000&offset=0, which the backend silently ignores —
swapped to page=1&per_page=500 with explicit sort=taken_at&order=desc.
New keyboard shortcuts:
- \\ toggles the filter bar
- / focuses the TopBar search input
- Cmd/Ctrl+F same as /
Filter button in the TopBar now lights up when filters are active or
the bar is open.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the console.log stubs in useKeyboardShortcuts with real
PATCH /photos/{id} mutations against the active photo, so 1-5 / 0 / P /
X / U / 6-9 actually rate, flag, and color-label photos. Mutations
invalidate both the photo detail query and the timeline list query, so
the RightSidebar and grid update immediately.
Shortcuts now work in BOTH grid and loupe modes (the previous
{ enabled: isGrid } gate is removed) so the user can cull while
browsing in the loupe — the Lightroom workflow.
Color labels 6-9 are wired to red/orange/yellow/green per spec §6.4.
Active photo id is read fresh via usePhotoStore.getState() inside each
handler, so we don't re-bind hotkeys on every selection change.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the mock photo data with a TanStack Query fetch driven by
activePhotoId, so the metadata panel reflects the photo currently
selected (or being viewed in the loupe). Parses exif_json defensively
and renders ExifTool fields with sensible aliases (Make/Model,
LensModel/Lens, FNumber, ExposureTime/ShutterSpeedValue, FocalLength,
GPSLatitude/Longitude). Falls back to '—' for missing fields.
Rating stars and Pick/Reject buttons now fire useMutation against
PATCH /photos/{id} and invalidate both the photo detail query and the
['photos'] list query so the timeline grid reflects the change too.
Multi-select keeps its bulk-action footer and shows the selection
count instead of metadata.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds the Lightroom-style full-screen single-photo viewer (spec §6.11).
Open with E / Enter / double-click; navigate with arrow keys; Esc returns
to grid. Filmstrip at the bottom auto-scrolls the active cell into view.
Display:
- Stills source from /photos/{id}/proxy so RAW/HEIC are decoded server-
side; large thumbnail is the onError fallback only.
- Videos render in a <video controls> sourced from /original.
- Continuous wheel zoom (1×–8×, ~15% per tick) with click-drag pan when
zoomed past fit. Z toggles between fit and natural-resolution
(computed from naturalWidth / clientWidth); a second Z snaps back.
- Live percentage indicator in the bottom-center while zoomed.
Polish:
- Neighbor preloading via new Image() when currentIndex changes so arrow
nav feels instant (skips videos).
- Focus trap with role=dialog, aria-modal, focus-on-mount, restore-on-
unmount, and Tab cycling among focusable children.
Plumbing:
- New canonical Photo TS interface in types/photo.ts; removes the three
duplicated definitions in PhotoThumbnail/Timeline/photoStore.
- photoStore gains viewMode + openLoupe/closeLoupe.
- useKeyboardShortcuts wires E/Enter/G to open/close, gates rating and
flag stubs with { enabled: viewMode === 'grid' } so they're inert in
loupe.
- App.tsx mounts <LoupeView/> as a z-40 overlay covering TopBar, gates
the auto-open right sidebar effect on viewMode === 'grid' so leaving
loupe doesn't fight the user's prior sidebar state.
- PhotoThumbnail gains an onDoubleClick prop wired to openLoupe.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds GET /photos/{id}/proxy that decodes RAW (rawpy), HEIC (pillow-heif),
and TIFF to a cached full-resolution WebP at /data/proxies/{id}.webp.
Web-safe formats (JPEG/PNG/WebP/GIF) pass through to the original to
avoid pointless transcoding. RAW failures fall back to extracting the
embedded JPEG preview. Mirrors the X-Accel-Redirect pattern from the
existing thumb endpoint.
Also fixes GET /photos/{id}/original to return the correct image/jpeg,
image/png, video/mp4, etc. content types instead of always serving
application/octet-stream, so <img> and <video> tags can render the
file inline rather than triggering a download.
Frontend: adds photos.getProxyUrl() helper in services/api.ts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Backend generates thumbnails on-demand via Celery, so the first request
often 404s while the worker runs. Auto-retry with 1.5s/3.5s/6s backoff,
manual retry fallback, and proper timer cleanup so fast-scrolling a
virtualized timeline doesn't setState on unmounted components.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>