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:
@@ -2,33 +2,21 @@ import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Star, Check, X, RefreshCw } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { photos as photosApi } from '../../services/api'
|
||||
import type { Photo } from '../../types/photo'
|
||||
|
||||
// Auto-retry schedule (ms). Backend generates thumbs on-demand via Celery, so
|
||||
// first hit often 404s. Try a few times with backoff before giving up.
|
||||
const AUTO_RETRY_DELAYS = [1500, 3500, 6000]
|
||||
|
||||
interface Photo {
|
||||
id: string
|
||||
filepath: string
|
||||
filename: string
|
||||
width: number | null
|
||||
height: number | null
|
||||
taken_at: string | null
|
||||
rating: number
|
||||
is_picked: boolean
|
||||
is_rejected: boolean
|
||||
file_hash: string
|
||||
media_type: string
|
||||
}
|
||||
|
||||
interface PhotoThumbnailProps {
|
||||
photo: Photo
|
||||
size: number
|
||||
isSelected: boolean
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
onDoubleClick?: (e: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
export function PhotoThumbnail({ photo, size, isSelected, onClick }: PhotoThumbnailProps) {
|
||||
export function PhotoThumbnail({ photo, size, isSelected, onClick, onDoubleClick }: PhotoThumbnailProps) {
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const [imageLoaded, setImageLoaded] = useState(false)
|
||||
const [retryCount, setRetryCount] = useState(0)
|
||||
@@ -108,7 +96,8 @@ export function PhotoThumbnail({ photo, size, isSelected, onClick }: PhotoThumbn
|
||||
height: displayHeight,
|
||||
}}
|
||||
onClick={onClick}
|
||||
title="Click to select • Shift+Click for range • Ctrl+Click to add"
|
||||
onDoubleClick={onDoubleClick}
|
||||
title="Click to select • Double-click to open • Shift+Click for range • Ctrl+Click to add"
|
||||
>
|
||||
{/* Thumbnail Image */}
|
||||
{!imageError ? (
|
||||
|
||||
@@ -4,21 +4,7 @@ import { usePhotoStore } from '../../store/photoStore'
|
||||
import { PhotoThumbnail } from './PhotoThumbnail'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import axios from 'axios'
|
||||
|
||||
|
||||
interface Photo {
|
||||
id: string
|
||||
filepath: string
|
||||
filename: string
|
||||
width: number | null
|
||||
height: number | null
|
||||
taken_at: string | null
|
||||
rating: number
|
||||
is_picked: boolean
|
||||
is_rejected: boolean
|
||||
file_hash: string
|
||||
media_type: string
|
||||
}
|
||||
import type { Photo } from '../../types/photo'
|
||||
|
||||
export function Timeline() {
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
@@ -31,7 +17,7 @@ export function Timeline() {
|
||||
selectPhoto,
|
||||
togglePhotoSelection,
|
||||
clearSelection,
|
||||
|
||||
openLoupe,
|
||||
} = usePhotoStore()
|
||||
|
||||
// Helper function for range selection
|
||||
@@ -247,6 +233,7 @@ export function Timeline() {
|
||||
selectPhoto(photo.id, globalIndex)
|
||||
}
|
||||
}}
|
||||
onDoubleClick={() => openLoupe(photo.id)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user