The loupe view is now called "preview" everywhere — file paths, type
names, store actions, and the contextual hint pill. There's a single
preview action bound to E and Space (Enter is gone); double-click on a
thumbnail still works. Both shortcuts toggle: open from grid, close
from preview.
This commit also folds in the fix for the "preview shows nothing" bug
the user just hit:
- Extract usePhotosQuery into frontend/src/hooks/usePhotosQuery.ts so
Timeline, PreviewView, and App.tsx all share one query — and one
cache entry. Previously PreviewView and App.tsx looked the cache up
by ['photos'], but the Timeline query key gained the filter params
(['photos', filterParams]) when the filter bar shipped, so the
lookup returned undefined and the preview rendered "No photo to
display". App.tsx's getFirstPhotoId callback had the same bug.
- Harden PreviewImage: render the <img> immediately and overlay the
spinner with absolute positioning, instead of toggling opacity-0 →
opacity-100 on load. The previous opacity-toggle could leave the
image stuck invisible if the load event raced with a key change.
- Add { preventDefault: true } to every useHotkeys call so single
letter shortcuts (1-5, P, X, U) no longer leak into Firefox quick-
find, and Cmd/Ctrl+F no longer triggers the browser find toolbar.
Files renamed:
components/loupe/LoupeView.tsx -> components/preview/PreviewView.tsx
components/loupe/LoupeImage.tsx -> components/preview/PreviewImage.tsx
components/loupe/LoupeFilmstrip.tsx -> components/preview/PreviewFilmstrip.tsx
components/loupe/loupeSrc.ts -> components/preview/previewSrc.ts
Symbol renames: openLoupe→openPreview, closeLoupe→closePreview, the
viewMode 'loupe' tag → 'preview', and all the LoupeXxx component and
helper exports.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
155 lines
5.2 KiB
TypeScript
155 lines
5.2 KiB
TypeScript
import { useCallback, useEffect, useRef } from 'react'
|
|
import { useHotkeys } from 'react-hotkeys-hook'
|
|
import { X } 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'
|
|
|
|
export function PreviewView() {
|
|
const activePhotoId = usePhotoStore((s) => s.activePhotoId)
|
|
const setActivePhoto = usePhotoStore((s) => s.setActivePhoto)
|
|
const closePreview = usePhotoStore((s) => s.closePreview)
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const previouslyFocusedRef = useRef<HTMLElement | null>(null)
|
|
|
|
// 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: photos = [] } = usePhotosQuery()
|
|
|
|
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])
|
|
|
|
// Preview-scoped hotkeys: only mounted while PreviewView is rendered.
|
|
useHotkeys('escape', closePreview, { preventDefault: true })
|
|
useHotkeys('left', goPrev, { preventDefault: true }, [goPrev])
|
|
useHotkeys('right', goNext, { preventDefault: true }, [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 = 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<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 preview"
|
|
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={closePreview}
|
|
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 preview: ${currentPhoto.filename}`}
|
|
tabIndex={-1}
|
|
onKeyDown={handleKeyDown}
|
|
className="fixed inset-0 z-40 flex flex-col bg-black outline-none"
|
|
>
|
|
{/* Close button */}
|
|
<button
|
|
onClick={closePreview}
|
|
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 preview"
|
|
>
|
|
<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>
|
|
|
|
<PreviewImage photo={currentPhoto} />
|
|
|
|
<PreviewFilmstrip
|
|
photos={photos}
|
|
currentIndex={safeIndex}
|
|
onSelect={setActivePhoto}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|