feat: loupe view with zoom, pan, video, and filmstrip
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>
This commit is contained in:
@@ -1,20 +1,7 @@
|
||||
import { create } from 'zustand'
|
||||
import type { Photo } from '../types/photo'
|
||||
|
||||
interface Photo {
|
||||
id: string
|
||||
filename: string
|
||||
filepath: string
|
||||
media_type: string
|
||||
width?: number
|
||||
height?: number
|
||||
taken_at?: string
|
||||
thumb_small?: string
|
||||
thumb_medium?: string
|
||||
thumb_large?: string
|
||||
rating: number
|
||||
is_picked: boolean
|
||||
is_rejected: boolean
|
||||
}
|
||||
type ViewMode = 'grid' | 'loupe'
|
||||
|
||||
interface PhotoStore {
|
||||
photos: Photo[]
|
||||
@@ -22,7 +9,8 @@ interface PhotoStore {
|
||||
activePhotoId: string | null
|
||||
lastSelectedIndex: number | null
|
||||
rangeStartIndex: number | null
|
||||
|
||||
viewMode: ViewMode
|
||||
|
||||
setPhotos: (photos: Photo[]) => void
|
||||
selectPhoto: (id: string, index: number) => void
|
||||
togglePhotoSelection: (id: string, index: number) => void
|
||||
@@ -30,6 +18,9 @@ interface PhotoStore {
|
||||
deselectPhoto: (id: string) => void
|
||||
clearSelection: () => void
|
||||
setActivePhoto: (id: string | null) => void
|
||||
setViewMode: (mode: ViewMode) => void
|
||||
openLoupe: (id: string) => void
|
||||
closeLoupe: () => void
|
||||
}
|
||||
|
||||
export const usePhotoStore = create<PhotoStore>((set) => ({
|
||||
@@ -38,7 +29,8 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
|
||||
activePhotoId: null,
|
||||
lastSelectedIndex: null,
|
||||
rangeStartIndex: null,
|
||||
|
||||
viewMode: 'grid',
|
||||
|
||||
setPhotos: (photos) => set({ photos }),
|
||||
|
||||
selectPhoto: (id, index) => set({
|
||||
@@ -78,4 +70,10 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
|
||||
}),
|
||||
|
||||
setActivePhoto: (id) => set({ activePhotoId: id }),
|
||||
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
|
||||
openLoupe: (id) => set({ viewMode: 'loupe', activePhotoId: id }),
|
||||
|
||||
closeLoupe: () => set({ viewMode: 'grid' }),
|
||||
}))
|
||||
Reference in New Issue
Block a user