refactor: rename loupe to preview, bind to E and Space, fix empty viewer
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>
This commit is contained in:
55
frontend/src/components/preview/PreviewFilmstrip.tsx
Normal file
55
frontend/src/components/preview/PreviewFilmstrip.tsx
Normal 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 PreviewFilmstripProps {
|
||||
photos: Photo[]
|
||||
currentIndex: number
|
||||
onSelect: (id: string) => void
|
||||
}
|
||||
|
||||
const CELL_SIZE = 72
|
||||
|
||||
export function PreviewFilmstrip({ photos, currentIndex, onSelect }: PreviewFilmstripProps) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
178
frontend/src/components/preview/PreviewImage.tsx
Normal file
178
frontend/src/components/preview/PreviewImage.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import type { Photo } from '../../types/photo'
|
||||
import {
|
||||
getPreviewImageSrc,
|
||||
getPreviewFallbackSrc,
|
||||
getVideoSrc,
|
||||
isVideo,
|
||||
} from './previewSrc'
|
||||
|
||||
interface PreviewImageProps {
|
||||
photo: Photo
|
||||
}
|
||||
|
||||
const MIN_SCALE = 1
|
||||
const MAX_SCALE = 8
|
||||
const WHEEL_STEP = 1.15
|
||||
|
||||
export function PreviewImage({ photo }: PreviewImageProps) {
|
||||
if (isVideo(photo)) {
|
||||
return <PreviewVideo photo={photo} />
|
||||
}
|
||||
return <PreviewStillImage photo={photo} />
|
||||
}
|
||||
|
||||
function PreviewVideo({ 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 PreviewStillImage({ 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 = getPreviewImageSrc(photo)
|
||||
const fallbackSrc = getPreviewFallbackSrc(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()
|
||||
},
|
||||
{ preventDefault: true },
|
||||
[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 }}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
key={`${photo.id}-${usingFallback}`}
|
||||
src={src}
|
||||
alt={photo.filename}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={handleError}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
style={{
|
||||
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
|
||||
transformOrigin: 'center center',
|
||||
willChange: 'transform',
|
||||
}}
|
||||
/>
|
||||
{!loaded && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-text-muted">
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-2 border-primary/30 border-t-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
154
frontend/src/components/preview/PreviewView.tsx
Normal file
154
frontend/src/components/preview/PreviewView.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
33
frontend/src/components/preview/previewSrc.ts
Normal file
33
frontend/src/components/preview/previewSrc.ts
Normal 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 preview 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 getPreviewImageSrc(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 getPreviewFallbackSrc(photo: Photo): string {
|
||||
return photosApi.getThumbnailUrl(photo.id, 'large')
|
||||
}
|
||||
|
||||
export function getVideoSrc(photo: Photo): string {
|
||||
return photosApi.getOriginalUrl(photo.id)
|
||||
}
|
||||
Reference in New Issue
Block a user