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:
2026-04-07 08:39:33 +02:00
parent 1096854553
commit f4fc15101e
10 changed files with 569 additions and 88 deletions

View File

@@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Timeline } from './components/timeline/Timeline' import { Timeline } from './components/timeline/Timeline'
import { LeftSidebar } from './components/layout/LeftSidebar' import { LeftSidebar } from './components/layout/LeftSidebar'
import { RightSidebar } from './components/layout/RightSidebar' import { RightSidebar } from './components/layout/RightSidebar'
@@ -7,26 +8,39 @@ import { ScanProgress } from './components/ScanProgress'
import { ToastContainer } from './components/ToastContainer' import { ToastContainer } from './components/ToastContainer'
import { KeyboardShortcuts } from './components/KeyboardShortcuts' import { KeyboardShortcuts } from './components/KeyboardShortcuts'
import { KeyboardHints } from './components/KeyboardHints' import { KeyboardHints } from './components/KeyboardHints'
import { LoupeView } from './components/loupe/LoupeView'
import { usePhotoStore } from './store/photoStore' import { usePhotoStore } from './store/photoStore'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts' import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
import type { Photo } from './types/photo'
function App() { function App() {
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true) const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
const [rightSidebarOpen, setRightSidebarOpen] = useState(false) const [rightSidebarOpen, setRightSidebarOpen] = useState(false)
const selectedPhotos = usePhotoStore((state) => state.selectedPhotos) const selectedPhotos = usePhotoStore((state) => state.selectedPhotos)
const viewMode = usePhotoStore((state) => state.viewMode)
const queryClient = useQueryClient()
// Set up global keyboard shortcuts // Set up global keyboard shortcuts
useKeyboardShortcuts({ useKeyboardShortcuts({
onToggleLeftSidebar: () => setLeftSidebarOpen(!leftSidebarOpen), onToggleLeftSidebar: () => setLeftSidebarOpen(!leftSidebarOpen),
onToggleRightSidebar: () => setRightSidebarOpen(!rightSidebarOpen), onToggleRightSidebar: () => setRightSidebarOpen(!rightSidebarOpen),
getFirstPhotoId: () => {
const photos = queryClient.getQueryData<Photo[]>(['photos'])
return photos && photos.length > 0 ? photos[0].id : null
},
}) })
// Show right sidebar when photos are selected // Auto-show right sidebar when photos are selected — but only in grid mode,
// so leaving loupe doesn't fight the user's prior sidebar state.
if (viewMode === 'grid') {
if (selectedPhotos.length > 0 && !rightSidebarOpen) { if (selectedPhotos.length > 0 && !rightSidebarOpen) {
setRightSidebarOpen(true) setRightSidebarOpen(true)
} else if (selectedPhotos.length === 0 && rightSidebarOpen) { } else if (selectedPhotos.length === 0 && rightSidebarOpen) {
setRightSidebarOpen(false) setRightSidebarOpen(false)
} }
}
const showRightSidebar = rightSidebarOpen && viewMode === 'grid'
return ( return (
<div className="flex flex-col h-screen bg-bg text-text"> <div className="flex flex-col h-screen bg-bg text-text">
@@ -50,7 +64,7 @@ function App() {
{/* Right Sidebar */} {/* Right Sidebar */}
<div <div
className={`transition-all duration-200 ${ className={`transition-all duration-200 ${
rightSidebarOpen ? 'w-80' : 'w-0' showRightSidebar ? 'w-80' : 'w-0'
} overflow-hidden border-l border-border bg-surface`} } overflow-hidden border-l border-border bg-surface`}
> >
<RightSidebar /> <RightSidebar />
@@ -68,6 +82,9 @@ function App() {
{/* Toast Notifications */} {/* Toast Notifications */}
<ToastContainer /> <ToastContainer />
{/* Loupe overlay — covers TopBar when active */}
{viewMode === 'loupe' && <LoupeView />}
</div> </div>
) )
} }

View File

@@ -0,0 +1,55 @@
import { useEffect, useRef } from 'react'
import clsx from 'clsx'
import type { Photo } from '../../types/photo'
import { photos as photosApi } from '../../services/api'
interface LoupeFilmstripProps {
photos: Photo[]
currentIndex: number
onSelect: (id: string) => void
}
const CELL_SIZE = 72
export function LoupeFilmstrip({ photos, currentIndex, onSelect }: LoupeFilmstripProps) {
const activeRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
activeRef.current?.scrollIntoView({
block: 'nearest',
inline: 'center',
behavior: 'smooth',
})
}, [currentIndex])
return (
<div className="flex h-24 shrink-0 items-center gap-1 overflow-x-auto border-t border-border bg-surface px-2 py-2">
{photos.map((photo, index) => {
const isActive = index === currentIndex
return (
<button
key={photo.id}
ref={isActive ? activeRef : null}
onClick={() => onSelect(photo.id)}
className={clsx(
'shrink-0 overflow-hidden rounded-sm transition-all',
'hover:opacity-100',
isActive
? 'ring-2 ring-primary opacity-100'
: 'opacity-60'
)}
style={{ width: CELL_SIZE, height: CELL_SIZE }}
title={photo.filename}
>
<img
src={photosApi.getThumbnailUrl(photo.id, 'small')}
alt={photo.filename}
loading="lazy"
className="h-full w-full object-cover"
/>
</button>
)
})}
</div>
)
}

View File

@@ -0,0 +1,179 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import clsx from 'clsx'
import type { Photo } from '../../types/photo'
import {
getLoupeImageSrc,
getLoupeFallbackSrc,
getVideoSrc,
isVideo,
} from './loupeSrc'
interface LoupeImageProps {
photo: Photo
}
const MIN_SCALE = 1
const MAX_SCALE = 8
const WHEEL_STEP = 1.15
export function LoupeImage({ photo }: LoupeImageProps) {
if (isVideo(photo)) {
return <LoupeVideo photo={photo} />
}
return <LoupeStillImage photo={photo} />
}
function LoupeVideo({ photo }: { photo: Photo }) {
return (
<div className="flex flex-1 items-center justify-center bg-black">
<video
key={photo.id}
src={getVideoSrc(photo)}
controls
autoPlay
muted
className="max-h-full max-w-full"
/>
</div>
)
}
function LoupeStillImage({ photo }: { photo: Photo }) {
const [loaded, setLoaded] = useState(false)
const [usingFallback, setUsingFallback] = useState(false)
// scale=1 means "fit to viewport". Anything >1 zooms in; we don't allow <1
// because the fit size already fills the viewport.
const [scale, setScale] = useState(1)
const [offset, setOffset] = useState({ x: 0, y: 0 })
const dragStateRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null)
const imgRef = useRef<HTMLImageElement>(null)
// Reset everything when the photo changes.
useEffect(() => {
setLoaded(false)
setUsingFallback(false)
setScale(1)
setOffset({ x: 0, y: 0 })
}, [photo.id])
const primarySrc = getLoupeImageSrc(photo)
const fallbackSrc = getLoupeFallbackSrc(photo)
const src = usingFallback ? fallbackSrc : primarySrc
const handleError = () => {
if (!usingFallback && primarySrc !== fallbackSrc) {
setUsingFallback(true)
}
}
// Z key: toggle between fit (scale=1) and actual size (natural/displayed).
// If we're already zoomed (manual wheel zoom), Z snaps back to fit.
const toggleZoom = useCallback(() => {
if (scale !== 1) {
setScale(1)
setOffset({ x: 0, y: 0 })
return
}
const img = imgRef.current
if (!img) return
const ratio = img.naturalWidth / img.clientWidth
if (!isFinite(ratio) || ratio <= 1) return
setScale(Math.min(ratio, MAX_SCALE))
}, [scale])
useHotkeys('z', (e) => {
e.preventDefault()
toggleZoom()
}, [toggleZoom])
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault()
const delta = e.deltaY < 0 ? WHEEL_STEP : 1 / WHEEL_STEP
setScale((prev) => {
const next = Math.min(MAX_SCALE, Math.max(MIN_SCALE, prev * delta))
// Snapping back to 1 also resets pan offset.
if (next === 1) setOffset({ x: 0, y: 0 })
return next
})
}
const handleMouseDown = (e: React.MouseEvent) => {
if (scale === 1) return
e.preventDefault()
dragStateRef.current = {
x: e.clientX,
y: e.clientY,
ox: offset.x,
oy: offset.y,
}
}
const handleMouseMove = (e: React.MouseEvent) => {
const drag = dragStateRef.current
if (!drag) return
setOffset({
x: drag.ox + (e.clientX - drag.x),
y: drag.oy + (e.clientY - drag.y),
})
}
const endDrag = () => {
dragStateRef.current = null
}
const isZoomed = scale > 1
const cursor = isZoomed
? dragStateRef.current
? 'grabbing'
: 'grab'
: 'zoom-in'
return (
<div
className="relative flex flex-1 select-none items-center justify-center overflow-hidden bg-black"
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={endDrag}
onMouseLeave={endDrag}
style={{ cursor }}
>
{!loaded && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-text-muted">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-primary/30 border-t-primary" />
</div>
)}
<img
ref={imgRef}
key={`${photo.id}-${usingFallback}`}
src={src}
alt={photo.filename}
loading="eager"
decoding="async"
draggable={false}
onLoad={() => setLoaded(true)}
onError={handleError}
className={clsx(
'max-h-full max-w-full object-contain transition-opacity duration-150',
loaded ? 'opacity-100' : 'opacity-0'
)}
style={{
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
transformOrigin: 'center center',
// Disable transition during pan/zoom — only fade-in is animated.
transition: 'opacity 150ms',
willChange: 'transform',
}}
/>
{/* Zoom indicator */}
{isZoomed && (
<div className="pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 rounded bg-black/60 px-2 py-1 text-xs font-mono text-white">
{Math.round(scale * 100)}%
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,166 @@
import { useCallback, useEffect, useRef } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useQueryClient } from '@tanstack/react-query'
import { X } from 'lucide-react'
import { usePhotoStore } from '../../store/photoStore'
import type { Photo } from '../../types/photo'
import { LoupeImage } from './LoupeImage'
import { LoupeFilmstrip } from './LoupeFilmstrip'
import { getLoupeImageSrc, isVideo } from './loupeSrc'
export function LoupeView() {
const activePhotoId = usePhotoStore((s) => s.activePhotoId)
const setActivePhoto = usePhotoStore((s) => s.setActivePhoto)
const closeLoupe = usePhotoStore((s) => s.closeLoupe)
const containerRef = useRef<HTMLDivElement>(null)
const previouslyFocusedRef = useRef<HTMLElement | null>(null)
// Read photos from the TanStack Query cache populated by Timeline.
// Same query key so we share the cache and never refetch.
const queryClient = useQueryClient()
const photos = queryClient.getQueryData<Photo[]>(['photos']) ?? []
const currentIndex = activePhotoId
? photos.findIndex((p) => p.id === activePhotoId)
: 0
const safeIndex = currentIndex < 0 ? 0 : currentIndex
const currentPhoto: Photo | undefined = photos[safeIndex]
const goPrev = useCallback(() => {
if (photos.length === 0) return
const next = Math.max(0, safeIndex - 1)
setActivePhoto(photos[next].id)
}, [photos, safeIndex, setActivePhoto])
const goNext = useCallback(() => {
if (photos.length === 0) return
const next = Math.min(photos.length - 1, safeIndex + 1)
setActivePhoto(photos[next].id)
}, [photos, safeIndex, setActivePhoto])
// Loupe-scoped hotkeys: only mounted while LoupeView is rendered.
useHotkeys('escape', (e) => {
e.preventDefault()
closeLoupe()
})
useHotkeys('left', (e) => {
e.preventDefault()
goPrev()
}, [goPrev])
useHotkeys('right', (e) => {
e.preventDefault()
goNext()
}, [goNext])
// Preload the immediate neighbors so arrow nav feels instant. Skip videos
// (browsers can't preload them via Image()) and skip when at the edges.
useEffect(() => {
const neighbors: Photo[] = []
if (safeIndex > 0) neighbors.push(photos[safeIndex - 1])
if (safeIndex < photos.length - 1) neighbors.push(photos[safeIndex + 1])
for (const p of neighbors) {
if (isVideo(p)) continue
const img = new Image()
img.src = getLoupeImageSrc(p)
}
}, [safeIndex, photos])
// Focus trap: focus the loupe container on mount, restore focus on unmount.
// The container is keyboard-focusable (tabIndex=-1) so screen readers and
// tab navigation stay scoped here.
useEffect(() => {
previouslyFocusedRef.current = document.activeElement as HTMLElement | null
containerRef.current?.focus()
return () => {
previouslyFocusedRef.current?.focus?.()
}
}, [])
// Trap Tab inside the dialog so users can't accidentally tab into the
// hidden grid behind. Simple cycle implementation.
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== 'Tab') return
const root = containerRef.current
if (!root) return
const focusable = root.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (focusable.length === 0) {
e.preventDefault()
root.focus()
return
}
const first = focusable[0]
const last = focusable[focusable.length - 1]
const active = document.activeElement as HTMLElement | null
if (e.shiftKey && active === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && active === last) {
e.preventDefault()
first.focus()
}
}
if (!currentPhoto) {
return (
<div
ref={containerRef}
role="dialog"
aria-modal="true"
aria-label="Photo viewer"
tabIndex={-1}
className="fixed inset-0 z-40 flex flex-col items-center justify-center bg-black text-text-muted outline-none"
>
<div>No photo to display</div>
<button
onClick={closeLoupe}
className="mt-4 rounded border border-border px-3 py-1 text-sm hover:bg-surface"
>
Close
</button>
</div>
)
}
return (
<div
ref={containerRef}
role="dialog"
aria-modal="true"
aria-label={`Photo viewer: ${currentPhoto.filename}`}
tabIndex={-1}
onKeyDown={handleKeyDown}
className="fixed inset-0 z-40 flex flex-col bg-black outline-none"
>
{/* Close button */}
<button
onClick={closeLoupe}
className="absolute right-3 top-3 z-10 flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white transition hover:bg-black/80"
title="Close (Esc)"
aria-label="Close photo viewer"
>
<X className="h-5 w-5" />
</button>
{/* Filename + counter */}
<div className="absolute left-3 top-3 z-10 rounded bg-black/60 px-3 py-1.5 text-xs text-white">
<div className="font-mono">{currentPhoto.filename}</div>
<div className="text-text-muted">
{safeIndex + 1} / {photos.length}
</div>
</div>
<LoupeImage photo={currentPhoto} />
<LoupeFilmstrip
photos={photos}
currentIndex={safeIndex}
onSelect={setActivePhoto}
/>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { photos as photosApi } from '../../services/api'
import type { Photo } from '../../types/photo'
const VIDEO_EXTENSIONS = ['.mp4', '.mov', '.webm', '.mkv', '.m4v']
export function isVideo(photo: Photo): boolean {
if (photo.media_type === 'video') return true
const lower = photo.filepath.toLowerCase()
return VIDEO_EXTENSIONS.some(ext => lower.endsWith(ext))
}
/**
* Pick the best display URL for a still photo in the loupe view.
*
* Always uses the /proxy endpoint, which the backend resolves to:
* - the original file for web-safe formats (JPEG/PNG/WebP/GIF)
* - a transcoded full-res WebP for RAW/HEIC/TIFF (cached on first hit)
*
* Videos go through `getVideoSrc` instead and use /original directly.
*/
export function getLoupeImageSrc(photo: Photo): string {
return photosApi.getProxyUrl(photo.id)
}
/** Fallback used when the proxy endpoint fails or 404s — shows the 1280px
* large thumbnail so the user still sees something. */
export function getLoupeFallbackSrc(photo: Photo): string {
return photosApi.getThumbnailUrl(photo.id, 'large')
}
export function getVideoSrc(photo: Photo): string {
return photosApi.getOriginalUrl(photo.id)
}

View File

@@ -2,33 +2,21 @@ import { useState, useEffect, useCallback, useRef } from 'react'
import { Star, Check, X, RefreshCw } from 'lucide-react' import { Star, Check, X, RefreshCw } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import { photos as photosApi } from '../../services/api' 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 // 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. // first hit often 404s. Try a few times with backoff before giving up.
const AUTO_RETRY_DELAYS = [1500, 3500, 6000] 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 { interface PhotoThumbnailProps {
photo: Photo photo: Photo
size: number size: number
isSelected: boolean isSelected: boolean
onClick: (e: React.MouseEvent) => void 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 [imageError, setImageError] = useState(false)
const [imageLoaded, setImageLoaded] = useState(false) const [imageLoaded, setImageLoaded] = useState(false)
const [retryCount, setRetryCount] = useState(0) const [retryCount, setRetryCount] = useState(0)
@@ -108,7 +96,8 @@ export function PhotoThumbnail({ photo, size, isSelected, onClick }: PhotoThumbn
height: displayHeight, height: displayHeight,
}} }}
onClick={onClick} 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 */} {/* Thumbnail Image */}
{!imageError ? ( {!imageError ? (

View File

@@ -4,21 +4,7 @@ import { usePhotoStore } from '../../store/photoStore'
import { PhotoThumbnail } from './PhotoThumbnail' import { PhotoThumbnail } from './PhotoThumbnail'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import axios from 'axios' import axios from 'axios'
import type { Photo } from '../../types/photo'
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
}
export function Timeline() { export function Timeline() {
const parentRef = useRef<HTMLDivElement>(null) const parentRef = useRef<HTMLDivElement>(null)
@@ -31,7 +17,7 @@ export function Timeline() {
selectPhoto, selectPhoto,
togglePhotoSelection, togglePhotoSelection,
clearSelection, clearSelection,
openLoupe,
} = usePhotoStore() } = usePhotoStore()
// Helper function for range selection // Helper function for range selection
@@ -247,6 +233,7 @@ export function Timeline() {
selectPhoto(photo.id, globalIndex) selectPhoto(photo.id, globalIndex)
} }
}} }}
onDoubleClick={() => openLoupe(photo.id)}
/> />
) )
})} })}

View File

@@ -1,14 +1,26 @@
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { usePhotoStore } from '../store/photoStore'
interface KeyboardShortcutsProps { interface KeyboardShortcutsProps {
onToggleLeftSidebar: () => void onToggleLeftSidebar: () => void
onToggleRightSidebar: () => void onToggleRightSidebar: () => void
/** Returns the first photo id in the current timeline, or null if empty. */
getFirstPhotoId?: () => string | null
} }
export function useKeyboardShortcuts(props: KeyboardShortcutsProps) { export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
const { onToggleLeftSidebar, onToggleRightSidebar } = props const { onToggleLeftSidebar, onToggleRightSidebar, getFirstPhotoId } = props
// Toggle sidebars const viewMode = usePhotoStore((s) => s.viewMode)
const activePhotoId = usePhotoStore((s) => s.activePhotoId)
const openLoupe = usePhotoStore((s) => s.openLoupe)
const closeLoupe = usePhotoStore((s) => s.closeLoupe)
const isGrid = viewMode === 'grid'
const isLoupe = viewMode === 'loupe'
// Toggle sidebars (allowed in both modes; right sidebar is hidden in loupe
// by App-level CSS so toggling it is effectively grid-only.)
useHotkeys('tab', (e) => { useHotkeys('tab', (e) => {
e.preventDefault() e.preventDefault()
onToggleLeftSidebar() onToggleLeftSidebar()
@@ -19,37 +31,66 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
onToggleRightSidebar() onToggleRightSidebar()
}) })
// Navigation shortcuts // Grid view: G always returns to grid (closes loupe if open).
useHotkeys('g', () => { useHotkeys('g', () => {
// Go to grid view closeLoupe()
console.log('Grid view')
}) })
useHotkeys('e', () => { // Loupe view: E toggles loupe (open from grid, close from loupe).
// Go to loupe view // Enter also opens loupe from grid.
console.log('Loupe view') const openLoupeFromGrid = () => {
}) const id = activePhotoId ?? getFirstPhotoId?.() ?? null
if (id) openLoupe(id)
}
// Rating shortcuts useHotkeys(
useHotkeys('1,2,3,4,5', (_e, handler) => { 'e',
() => {
if (isLoupe) {
closeLoupe()
} else {
openLoupeFromGrid()
}
},
[isLoupe, activePhotoId, getFirstPhotoId]
)
useHotkeys(
'enter',
(e) => {
if (isGrid) {
e.preventDefault()
openLoupeFromGrid()
}
},
{ enabled: isGrid },
[isGrid, activePhotoId, getFirstPhotoId]
)
// Rating shortcuts (grid only — stubs from a different phase)
useHotkeys(
'1,2,3,4,5',
(_e, handler) => {
const rating = parseInt(handler.keys![0]) const rating = parseInt(handler.keys![0])
console.log('Set rating:', rating) console.log('Set rating:', rating)
}) },
{ enabled: isGrid }
)
useHotkeys('0', () => { useHotkeys('0', () => {
console.log('Remove rating') console.log('Remove rating')
}) }, { enabled: isGrid })
// Flag shortcuts // Flag shortcuts (grid only)
useHotkeys('p', () => { useHotkeys('p', () => {
console.log('Pick photo') console.log('Pick photo')
}) }, { enabled: isGrid })
useHotkeys('x', () => { useHotkeys('x', () => {
console.log('Reject photo') console.log('Reject photo')
}) }, { enabled: isGrid })
useHotkeys('u', () => { useHotkeys('u', () => {
console.log('Unflag photo') console.log('Unflag photo')
}) }, { enabled: isGrid })
} }

View File

@@ -1,20 +1,7 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { Photo } from '../types/photo'
interface Photo { type ViewMode = 'grid' | 'loupe'
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
}
interface PhotoStore { interface PhotoStore {
photos: Photo[] photos: Photo[]
@@ -22,6 +9,7 @@ interface PhotoStore {
activePhotoId: string | null activePhotoId: string | null
lastSelectedIndex: number | null lastSelectedIndex: number | null
rangeStartIndex: number | null rangeStartIndex: number | null
viewMode: ViewMode
setPhotos: (photos: Photo[]) => void setPhotos: (photos: Photo[]) => void
selectPhoto: (id: string, index: number) => void selectPhoto: (id: string, index: number) => void
@@ -30,6 +18,9 @@ interface PhotoStore {
deselectPhoto: (id: string) => void deselectPhoto: (id: string) => void
clearSelection: () => void clearSelection: () => void
setActivePhoto: (id: string | null) => void setActivePhoto: (id: string | null) => void
setViewMode: (mode: ViewMode) => void
openLoupe: (id: string) => void
closeLoupe: () => void
} }
export const usePhotoStore = create<PhotoStore>((set) => ({ export const usePhotoStore = create<PhotoStore>((set) => ({
@@ -38,6 +29,7 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
activePhotoId: null, activePhotoId: null,
lastSelectedIndex: null, lastSelectedIndex: null,
rangeStartIndex: null, rangeStartIndex: null,
viewMode: 'grid',
setPhotos: (photos) => set({ photos }), setPhotos: (photos) => set({ photos }),
@@ -78,4 +70,10 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
}), }),
setActivePhoto: (id) => set({ activePhotoId: id }), setActivePhoto: (id) => set({ activePhotoId: id }),
setViewMode: (mode) => set({ viewMode: mode }),
openLoupe: (id) => set({ viewMode: 'loupe', activePhotoId: id }),
closeLoupe: () => set({ viewMode: 'grid' }),
})) }))

View File

@@ -0,0 +1,16 @@
export interface Photo {
id: string
filepath: string
filename: string
media_type: string
width: number | null
height: number | null
taken_at: string | null
rating: number
is_picked: boolean
is_rejected: boolean
file_hash: string
thumb_small?: string
thumb_medium?: string
thumb_large?: string
}