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>
247 lines
7.2 KiB
TypeScript
247 lines
7.2 KiB
TypeScript
import { useRef, useEffect, useMemo, useState } from 'react'
|
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
|
import { usePhotoStore } from '../../store/photoStore'
|
|
import { PhotoThumbnail } from './PhotoThumbnail'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import axios from 'axios'
|
|
import type { Photo } from '../../types/photo'
|
|
|
|
export function Timeline() {
|
|
const parentRef = useRef<HTMLDivElement>(null)
|
|
const [containerWidth, setContainerWidth] = useState(0)
|
|
|
|
const {
|
|
selectedPhotos,
|
|
lastSelectedIndex,
|
|
rangeStartIndex,
|
|
selectPhoto,
|
|
togglePhotoSelection,
|
|
clearSelection,
|
|
openLoupe,
|
|
} = usePhotoStore()
|
|
|
|
// Helper function for range selection
|
|
const selectRange = (endIndex: number) => {
|
|
const startIndex = rangeStartIndex ?? lastSelectedIndex ?? 0
|
|
const minIndex = Math.min(startIndex, endIndex)
|
|
const maxIndex = Math.max(startIndex, endIndex)
|
|
|
|
// Select all photos in the range
|
|
for (let i = minIndex; i <= maxIndex; i++) {
|
|
if (i < photos.length && !selectedPhotos.includes(photos[i].id)) {
|
|
togglePhotoSelection(photos[i].id, i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Thumbnail size configuration
|
|
const thumbnailSize = 200 // Base size for thumbnails
|
|
const gap = 4
|
|
const padding = 16
|
|
|
|
// Calculate number of columns based on container width
|
|
const columns = useMemo(() => {
|
|
if (containerWidth === 0) return 4
|
|
return Math.floor((containerWidth - padding * 2) / (thumbnailSize + gap))
|
|
}, [containerWidth, thumbnailSize, gap, padding])
|
|
|
|
// Fetch photos from backend
|
|
const { data: photos = [], isLoading } = useQuery({
|
|
queryKey: ['photos'],
|
|
queryFn: async () => {
|
|
const response = await axios.get<{photos: Photo[], total: number}>('http://localhost:8001/api/v1/photos', {
|
|
params: {
|
|
limit: 1000,
|
|
offset: 0,
|
|
},
|
|
})
|
|
return response.data.photos || []
|
|
},
|
|
staleTime: 30000,
|
|
})
|
|
|
|
// Group photos into rows for grid layout
|
|
const rows = useMemo(() => {
|
|
const result: Photo[][] = []
|
|
for (let i = 0; i < photos.length; i += columns) {
|
|
result.push(photos.slice(i, i + columns))
|
|
}
|
|
return result
|
|
}, [photos, columns])
|
|
|
|
// Virtual scrolling setup
|
|
const virtualizer = useVirtualizer({
|
|
count: rows.length,
|
|
getScrollElement: () => parentRef.current,
|
|
estimateSize: () => thumbnailSize + gap,
|
|
overscan: 5,
|
|
})
|
|
|
|
// Measure container width on mount and resize
|
|
useEffect(() => {
|
|
const measureWidth = () => {
|
|
if (parentRef.current) {
|
|
setContainerWidth(parentRef.current.clientWidth)
|
|
}
|
|
}
|
|
|
|
measureWidth()
|
|
window.addEventListener('resize', measureWidth)
|
|
return () => window.removeEventListener('resize', measureWidth)
|
|
}, [])
|
|
|
|
// Handle keyboard shortcuts for photo navigation
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (photos.length === 0) return
|
|
|
|
const currentIndex = lastSelectedIndex ?? -1
|
|
|
|
switch (e.key) {
|
|
case 'ArrowUp':
|
|
e.preventDefault()
|
|
if (currentIndex > columns - 1) {
|
|
const newIndex = currentIndex - columns
|
|
if (e.shiftKey) {
|
|
selectRange(newIndex)
|
|
} else {
|
|
selectPhoto(photos[newIndex].id, newIndex)
|
|
}
|
|
}
|
|
break
|
|
|
|
case 'ArrowDown':
|
|
e.preventDefault()
|
|
if (currentIndex < photos.length - columns) {
|
|
const newIndex = Math.min(currentIndex + columns, photos.length - 1)
|
|
if (e.shiftKey) {
|
|
selectRange(newIndex)
|
|
} else {
|
|
selectPhoto(photos[newIndex].id, newIndex)
|
|
}
|
|
}
|
|
break
|
|
|
|
case 'ArrowLeft':
|
|
e.preventDefault()
|
|
if (currentIndex > 0) {
|
|
const newIndex = currentIndex - 1
|
|
if (e.shiftKey) {
|
|
selectRange(newIndex)
|
|
} else {
|
|
selectPhoto(photos[newIndex].id, newIndex)
|
|
}
|
|
}
|
|
break
|
|
|
|
case 'ArrowRight':
|
|
e.preventDefault()
|
|
if (currentIndex < photos.length - 1) {
|
|
const newIndex = currentIndex + 1
|
|
if (e.shiftKey) {
|
|
selectRange(newIndex)
|
|
} else {
|
|
selectPhoto(photos[newIndex].id, newIndex)
|
|
}
|
|
}
|
|
break
|
|
|
|
case 'a':
|
|
if (e.ctrlKey || e.metaKey) {
|
|
e.preventDefault()
|
|
// Select all
|
|
photos.forEach((photo, index) => {
|
|
if (!selectedPhotos.includes(photo.id)) {
|
|
togglePhotoSelection(photo.id, index)
|
|
}
|
|
})
|
|
}
|
|
break
|
|
|
|
case 'Escape':
|
|
e.preventDefault()
|
|
clearSelection()
|
|
break
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
}, [photos, selectedPhotos, lastSelectedIndex, columns, selectPhoto, togglePhotoSelection, selectRange, clearSelection])
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full">
|
|
<div className="text-text-muted">Loading photos...</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (photos.length === 0) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full">
|
|
<div className="text-text-muted">No photos found. Add a source folder to get started.</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={parentRef}
|
|
className="h-full overflow-auto bg-bg"
|
|
style={{ padding: `${padding}px` }}
|
|
>
|
|
<div
|
|
style={{
|
|
height: `${virtualizer.getTotalSize()}px`,
|
|
width: '100%',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
|
const row = rows[virtualRow.index]
|
|
return (
|
|
<div
|
|
key={virtualRow.key}
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
height: `${virtualRow.size}px`,
|
|
transform: `translateY(${virtualRow.start}px)`,
|
|
}}
|
|
>
|
|
<div
|
|
className="flex"
|
|
style={{ gap: `${gap}px` }}
|
|
>
|
|
{row.map((photo, colIndex) => {
|
|
const globalIndex = virtualRow.index * columns + colIndex
|
|
return (
|
|
<PhotoThumbnail
|
|
key={photo.id}
|
|
photo={photo}
|
|
size={thumbnailSize}
|
|
isSelected={selectedPhotos.includes(photo.id)}
|
|
onClick={(e) => {
|
|
if (e.shiftKey && lastSelectedIndex !== null) {
|
|
selectRange(globalIndex)
|
|
} else if (e.ctrlKey || e.metaKey) {
|
|
togglePhotoSelection(photo.id, globalIndex)
|
|
} else {
|
|
selectPhoto(photo.id, globalIndex)
|
|
}
|
|
}}
|
|
onDoubleClick={() => openLoupe(photo.id)}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
} |