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>
92 lines
3.1 KiB
TypeScript
92 lines
3.1 KiB
TypeScript
import { useState } from 'react'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import { Timeline } from './components/timeline/Timeline'
|
|
import { LeftSidebar } from './components/layout/LeftSidebar'
|
|
import { RightSidebar } from './components/layout/RightSidebar'
|
|
import { TopBar } from './components/layout/TopBar'
|
|
import { ScanProgress } from './components/ScanProgress'
|
|
import { ToastContainer } from './components/ToastContainer'
|
|
import { KeyboardShortcuts } from './components/KeyboardShortcuts'
|
|
import { KeyboardHints } from './components/KeyboardHints'
|
|
import { LoupeView } from './components/loupe/LoupeView'
|
|
import { usePhotoStore } from './store/photoStore'
|
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
|
import type { Photo } from './types/photo'
|
|
|
|
function App() {
|
|
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
|
|
const [rightSidebarOpen, setRightSidebarOpen] = useState(false)
|
|
const selectedPhotos = usePhotoStore((state) => state.selectedPhotos)
|
|
const viewMode = usePhotoStore((state) => state.viewMode)
|
|
const queryClient = useQueryClient()
|
|
|
|
// Set up global keyboard shortcuts
|
|
useKeyboardShortcuts({
|
|
onToggleLeftSidebar: () => setLeftSidebarOpen(!leftSidebarOpen),
|
|
onToggleRightSidebar: () => setRightSidebarOpen(!rightSidebarOpen),
|
|
getFirstPhotoId: () => {
|
|
const photos = queryClient.getQueryData<Photo[]>(['photos'])
|
|
return photos && photos.length > 0 ? photos[0].id : null
|
|
},
|
|
})
|
|
|
|
// 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) {
|
|
setRightSidebarOpen(true)
|
|
} else if (selectedPhotos.length === 0 && rightSidebarOpen) {
|
|
setRightSidebarOpen(false)
|
|
}
|
|
}
|
|
|
|
const showRightSidebar = rightSidebarOpen && viewMode === 'grid'
|
|
|
|
return (
|
|
<div className="flex flex-col h-screen bg-bg text-text">
|
|
<TopBar />
|
|
|
|
<div className="flex flex-1 overflow-hidden">
|
|
{/* Left Sidebar */}
|
|
<div
|
|
className={`transition-all duration-200 ${
|
|
leftSidebarOpen ? 'w-64' : 'w-0'
|
|
} overflow-hidden border-r border-border bg-surface`}
|
|
>
|
|
<LeftSidebar />
|
|
</div>
|
|
|
|
{/* Main Content - Timeline */}
|
|
<div className="flex-1 overflow-auto">
|
|
<Timeline />
|
|
</div>
|
|
|
|
{/* Right Sidebar */}
|
|
<div
|
|
className={`transition-all duration-200 ${
|
|
showRightSidebar ? 'w-80' : 'w-0'
|
|
} overflow-hidden border-l border-border bg-surface`}
|
|
>
|
|
<RightSidebar />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Contextual Keyboard Hints */}
|
|
<KeyboardHints />
|
|
|
|
{/* Keyboard Shortcuts Legend */}
|
|
<KeyboardShortcuts />
|
|
|
|
{/* Scan Progress Indicator */}
|
|
<ScanProgress />
|
|
|
|
{/* Toast Notifications */}
|
|
<ToastContainer />
|
|
|
|
{/* Loupe overlay — covers TopBar when active */}
|
|
{viewMode === 'loupe' && <LoupeView />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default App |