import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useHotkeys } from 'react-hotkeys-hook' import { X, Info } from 'lucide-react' import { usePhotoStore } from '../../store/photoStore' import { usePhotosQuery } from '../../hooks/usePhotosQuery' import type { Photo } from '../../types/photo' import { PreviewImage } from './PreviewImage' import { PreviewFilmstrip } from './PreviewFilmstrip' import { getPreviewImageSrc, isVideo } from './previewSrc' import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel' export function PreviewView() { const activePhotoId = usePhotoStore((s) => s.activePhotoId) const setActivePhoto = usePhotoStore((s) => s.setActivePhoto) const closePreview = usePhotoStore((s) => s.closePreview) const visiblePhotoIds = usePhotoStore((s) => s.visiblePhotoIds) const containerRef = useRef(null) const previouslyFocusedRef = useRef(null) const [infoPanelOpen, setInfoPanelOpen] = useState(false) // Same hook Timeline uses, so we share one cache entry rather than looking // it up by key (which broke when the key gained the filter params). const { data: rawPhotos = [] } = usePhotosQuery() // Walk the timeline's visible-order sequence (published by Timeline // into the photo store), which respects tag-grouping and any other // grid-layout rearrangement. Falls back to the raw photos list when // the sequence isn't populated yet — relevant on a fresh page load // where the user opened preview before the timeline mounted. const photos: Photo[] = useMemo(() => { if (visiblePhotoIds.length === 0) return rawPhotos const byId = new Map(rawPhotos.map((p) => [p.id, p])) const out: Photo[] = [] for (const id of visiblePhotoIds) { const p = byId.get(id) if (p) out.push(p) } return out }, [visiblePhotoIds, rawPhotos]) const currentIndex = activePhotoId ? photos.findIndex((p) => p.id === activePhotoId) : 0 const safeIndex = currentIndex < 0 ? 0 : currentIndex const currentPhoto: Photo | undefined = photos[safeIndex] // Keep the latest photos array + active id in a ref so the keyboard // handlers ALWAYS read the freshest state. Without this, react-hotkeys- // hook can fire a closure that captured an older photos array (e.g. // the empty initial render before visiblePhotoIds was applied) and // arrow nav lands on the wrong photo or no-ops. const navRef = useRef({ photos, activePhotoId }) navRef.current = { photos, activePhotoId } const goPrev = useCallback(() => { const { photos: ps, activePhotoId: aid } = navRef.current if (ps.length === 0) return const idx = aid ? ps.findIndex((p) => p.id === aid) : 0 const safe = idx < 0 ? 0 : idx const next = Math.max(0, safe - 1) setActivePhoto(ps[next].id) }, [setActivePhoto]) const goNext = useCallback(() => { const { photos: ps, activePhotoId: aid } = navRef.current if (ps.length === 0) return const idx = aid ? ps.findIndex((p) => p.id === aid) : 0 const safe = idx < 0 ? 0 : idx const next = Math.min(ps.length - 1, safe + 1) setActivePhoto(ps[next].id) }, [setActivePhoto]) // Preview-scoped hotkeys: only mounted while PreviewView is rendered. // The handlers themselves are stable (refs internally) so the deps // array stays empty — useHotkeys won't have to re-bind on every render. useHotkeys('escape', closePreview, { preventDefault: true }) useHotkeys('left', goPrev, { preventDefault: true }) useHotkeys('right', goNext, { preventDefault: true }) useHotkeys('i', () => setInfoPanelOpen((v) => !v), { preventDefault: true }) // 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 = getPreviewImageSrc(p) } }, [safeIndex, photos]) // Focus trap: focus the preview 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( '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 (
No photo to display
) } return (
{/* Main column — image + filmstrip */}
{/* Filename + counter */}
{currentPhoto.filename}
{safeIndex + 1} / {photos.length}
{/* Top-right action buttons */}
{/* Right info panel — slides in/out, mirrors the grid right sidebar * but lives inside the preview overlay so it isn't covered by it. */} {infoPanelOpen && ( )}
) }