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:
@@ -4,23 +4,23 @@ export function KeyboardHints() {
|
||||
const selectedCount = usePhotoStore((state) => state.selectedPhotos.length)
|
||||
const viewMode = usePhotoStore((state) => state.viewMode)
|
||||
|
||||
// In loupe mode the photo viewer has its own context, so the grid hints
|
||||
// In preview mode the viewer has its own context, so the grid hints
|
||||
// would just be confusing. Hide them.
|
||||
if (viewMode === 'loupe') return null
|
||||
if (viewMode === 'preview') return null
|
||||
|
||||
const hints = selectedCount > 0
|
||||
? [
|
||||
{ key: '1-5', action: 'Rate' },
|
||||
{ key: 'P', action: 'Pick' },
|
||||
{ key: 'X', action: 'Trash' },
|
||||
{ key: 'E', action: 'Loupe' },
|
||||
{ key: 'E / Space', action: 'Preview' },
|
||||
{ key: 'Esc', action: 'Deselect' },
|
||||
]
|
||||
: [
|
||||
{ key: '↑↓←→', action: 'Navigate' },
|
||||
{ key: 'Click', action: 'Select' },
|
||||
{ key: 'Shift+Click', action: 'Range' },
|
||||
{ key: 'Space', action: 'Preview' },
|
||||
{ key: 'E / Space', action: 'Preview' },
|
||||
{ key: '\\', action: 'Filters' },
|
||||
{ key: '/', action: 'Search' },
|
||||
]
|
||||
|
||||
@@ -3,7 +3,7 @@ import clsx from 'clsx'
|
||||
import type { Photo } from '../../types/photo'
|
||||
import { photos as photosApi } from '../../services/api'
|
||||
|
||||
interface LoupeFilmstripProps {
|
||||
interface PreviewFilmstripProps {
|
||||
photos: Photo[]
|
||||
currentIndex: number
|
||||
onSelect: (id: string) => void
|
||||
@@ -11,7 +11,7 @@ interface LoupeFilmstripProps {
|
||||
|
||||
const CELL_SIZE = 72
|
||||
|
||||
export function LoupeFilmstrip({ photos, currentIndex, onSelect }: LoupeFilmstripProps) {
|
||||
export function PreviewFilmstrip({ photos, currentIndex, onSelect }: PreviewFilmstripProps) {
|
||||
const activeRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import clsx from 'clsx'
|
||||
import type { Photo } from '../../types/photo'
|
||||
import {
|
||||
getLoupeImageSrc,
|
||||
getLoupeFallbackSrc,
|
||||
getPreviewImageSrc,
|
||||
getPreviewFallbackSrc,
|
||||
getVideoSrc,
|
||||
isVideo,
|
||||
} from './loupeSrc'
|
||||
} from './previewSrc'
|
||||
|
||||
interface LoupeImageProps {
|
||||
interface PreviewImageProps {
|
||||
photo: Photo
|
||||
}
|
||||
|
||||
@@ -17,14 +16,14 @@ const MIN_SCALE = 1
|
||||
const MAX_SCALE = 8
|
||||
const WHEEL_STEP = 1.15
|
||||
|
||||
export function LoupeImage({ photo }: LoupeImageProps) {
|
||||
export function PreviewImage({ photo }: PreviewImageProps) {
|
||||
if (isVideo(photo)) {
|
||||
return <LoupeVideo photo={photo} />
|
||||
return <PreviewVideo photo={photo} />
|
||||
}
|
||||
return <LoupeStillImage photo={photo} />
|
||||
return <PreviewStillImage photo={photo} />
|
||||
}
|
||||
|
||||
function LoupeVideo({ photo }: { photo: Photo }) {
|
||||
function PreviewVideo({ photo }: { photo: Photo }) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center bg-black">
|
||||
<video
|
||||
@@ -39,7 +38,7 @@ function LoupeVideo({ photo }: { photo: Photo }) {
|
||||
)
|
||||
}
|
||||
|
||||
function LoupeStillImage({ photo }: { photo: Photo }) {
|
||||
function PreviewStillImage({ photo }: { photo: Photo }) {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [usingFallback, setUsingFallback] = useState(false)
|
||||
|
||||
@@ -58,8 +57,8 @@ function LoupeStillImage({ photo }: { photo: Photo }) {
|
||||
setOffset({ x: 0, y: 0 })
|
||||
}, [photo.id])
|
||||
|
||||
const primarySrc = getLoupeImageSrc(photo)
|
||||
const fallbackSrc = getLoupeFallbackSrc(photo)
|
||||
const primarySrc = getPreviewImageSrc(photo)
|
||||
const fallbackSrc = getPreviewFallbackSrc(photo)
|
||||
const src = usingFallback ? fallbackSrc : primarySrc
|
||||
|
||||
const handleError = () => {
|
||||
@@ -83,10 +82,15 @@ function LoupeStillImage({ photo }: { photo: Photo }) {
|
||||
setScale(Math.min(ratio, MAX_SCALE))
|
||||
}, [scale])
|
||||
|
||||
useHotkeys('z', (e) => {
|
||||
e.preventDefault()
|
||||
toggleZoom()
|
||||
}, [toggleZoom])
|
||||
useHotkeys(
|
||||
'z',
|
||||
(e) => {
|
||||
e.preventDefault()
|
||||
toggleZoom()
|
||||
},
|
||||
{ preventDefault: true },
|
||||
[toggleZoom]
|
||||
)
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -140,11 +144,6 @@ function LoupeStillImage({ photo }: { photo: Photo }) {
|
||||
onMouseLeave={endDrag}
|
||||
style={{ cursor }}
|
||||
>
|
||||
{!loaded && (
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-text-muted">
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-2 border-primary/30 border-t-primary" />
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
ref={imgRef}
|
||||
key={`${photo.id}-${usingFallback}`}
|
||||
@@ -155,18 +154,18 @@ function LoupeStillImage({ photo }: { photo: Photo }) {
|
||||
draggable={false}
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={handleError}
|
||||
className={clsx(
|
||||
'max-h-full max-w-full object-contain transition-opacity duration-150',
|
||||
loaded ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
style={{
|
||||
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
|
||||
transformOrigin: 'center center',
|
||||
// Disable transition during pan/zoom — only fade-in is animated.
|
||||
transition: 'opacity 150ms',
|
||||
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 && (
|
||||
@@ -1,25 +1,24 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { X } from 'lucide-react'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||
import type { Photo } from '../../types/photo'
|
||||
import { LoupeImage } from './LoupeImage'
|
||||
import { LoupeFilmstrip } from './LoupeFilmstrip'
|
||||
import { getLoupeImageSrc, isVideo } from './loupeSrc'
|
||||
import { PreviewImage } from './PreviewImage'
|
||||
import { PreviewFilmstrip } from './PreviewFilmstrip'
|
||||
import { getPreviewImageSrc, isVideo } from './previewSrc'
|
||||
|
||||
export function LoupeView() {
|
||||
export function PreviewView() {
|
||||
const activePhotoId = usePhotoStore((s) => s.activePhotoId)
|
||||
const setActivePhoto = usePhotoStore((s) => s.setActivePhoto)
|
||||
const closeLoupe = usePhotoStore((s) => s.closeLoupe)
|
||||
const closePreview = usePhotoStore((s) => s.closePreview)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const previouslyFocusedRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
// Read photos from the TanStack Query cache populated by Timeline.
|
||||
// Same query key so we share the cache and never refetch.
|
||||
const queryClient = useQueryClient()
|
||||
const photos = queryClient.getQueryData<Photo[]>(['photos']) ?? []
|
||||
// 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)
|
||||
@@ -39,21 +38,10 @@ export function LoupeView() {
|
||||
setActivePhoto(photos[next].id)
|
||||
}, [photos, safeIndex, setActivePhoto])
|
||||
|
||||
// Loupe-scoped hotkeys: only mounted while LoupeView is rendered.
|
||||
useHotkeys('escape', (e) => {
|
||||
e.preventDefault()
|
||||
closeLoupe()
|
||||
})
|
||||
|
||||
useHotkeys('left', (e) => {
|
||||
e.preventDefault()
|
||||
goPrev()
|
||||
}, [goPrev])
|
||||
|
||||
useHotkeys('right', (e) => {
|
||||
e.preventDefault()
|
||||
goNext()
|
||||
}, [goNext])
|
||||
// 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.
|
||||
@@ -64,13 +52,13 @@ export function LoupeView() {
|
||||
for (const p of neighbors) {
|
||||
if (isVideo(p)) continue
|
||||
const img = new Image()
|
||||
img.src = getLoupeImageSrc(p)
|
||||
img.src = getPreviewImageSrc(p)
|
||||
}
|
||||
}, [safeIndex, photos])
|
||||
|
||||
// Focus trap: focus the loupe container on mount, restore focus on unmount.
|
||||
// The container is keyboard-focusable (tabIndex=-1) so screen readers and
|
||||
// tab navigation stay scoped here.
|
||||
// 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()
|
||||
@@ -111,13 +99,13 @@ export function LoupeView() {
|
||||
ref={containerRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Photo viewer"
|
||||
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={closeLoupe}
|
||||
onClick={closePreview}
|
||||
className="mt-4 rounded border border-border px-3 py-1 text-sm hover:bg-surface"
|
||||
>
|
||||
Close
|
||||
@@ -131,17 +119,17 @@ export function LoupeView() {
|
||||
ref={containerRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Photo viewer: ${currentPhoto.filename}`}
|
||||
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={closeLoupe}
|
||||
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 photo viewer"
|
||||
aria-label="Close preview"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -154,9 +142,9 @@ export function LoupeView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoupeImage photo={currentPhoto} />
|
||||
<PreviewImage photo={currentPhoto} />
|
||||
|
||||
<LoupeFilmstrip
|
||||
<PreviewFilmstrip
|
||||
photos={photos}
|
||||
currentIndex={safeIndex}
|
||||
onSelect={setActivePhoto}
|
||||
@@ -6,11 +6,11 @@ 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))
|
||||
return VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext))
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the best display URL for a still photo in the loupe view.
|
||||
* 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)
|
||||
@@ -18,13 +18,13 @@ export function isVideo(photo: Photo): boolean {
|
||||
*
|
||||
* Videos go through `getVideoSrc` instead and use /original directly.
|
||||
*/
|
||||
export function getLoupeImageSrc(photo: Photo): string {
|
||||
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 getLoupeFallbackSrc(photo: Photo): string {
|
||||
export function getPreviewFallbackSrc(photo: Photo): string {
|
||||
return photosApi.getThumbnailUrl(photo.id, 'large')
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useRef, useEffect, useMemo, useState } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { useFilterStore, filtersToParams } from '../../store/filterStore'
|
||||
import { PhotoThumbnail } from './PhotoThumbnail'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import axios from 'axios'
|
||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||
import type { Photo } from '../../types/photo'
|
||||
|
||||
export function Timeline() {
|
||||
@@ -18,7 +16,7 @@ export function Timeline() {
|
||||
selectPhoto,
|
||||
togglePhotoSelection,
|
||||
clearSelection,
|
||||
openLoupe,
|
||||
openPreview,
|
||||
} = usePhotoStore()
|
||||
|
||||
// Helper function for range selection
|
||||
@@ -46,51 +44,9 @@ export function Timeline() {
|
||||
return Math.floor((containerWidth - padding * 2) / (thumbnailSize + gap))
|
||||
}, [containerWidth, thumbnailSize, gap, padding])
|
||||
|
||||
// Filter state — included in the query key so the cache invalidates when
|
||||
// any filter changes. Subscribing field-by-field keeps re-renders cheap.
|
||||
const q = useFilterStore((s) => s.q)
|
||||
const dateFrom = useFilterStore((s) => s.dateFrom)
|
||||
const dateTo = useFilterStore((s) => s.dateTo)
|
||||
const mediaTypes = useFilterStore((s) => s.mediaTypes)
|
||||
const ratingMin = useFilterStore((s) => s.ratingMin)
|
||||
const colorLabel = useFilterStore((s) => s.colorLabel)
|
||||
const flag = useFilterStore((s) => s.flag)
|
||||
|
||||
const filterParams = useMemo(
|
||||
() =>
|
||||
filtersToParams({
|
||||
q,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
mediaTypes,
|
||||
ratingMin,
|
||||
colorLabel,
|
||||
flag,
|
||||
}),
|
||||
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag]
|
||||
)
|
||||
|
||||
// Fetch photos from backend. Note: backend uses page/per_page (max 500),
|
||||
// not limit/offset — sending limit/offset previously was a silent no-op.
|
||||
const { data: photos = [], isLoading } = useQuery({
|
||||
queryKey: ['photos', filterParams],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get<{ photos: Photo[]; total: number }>(
|
||||
'http://localhost:8001/api/v1/photos',
|
||||
{
|
||||
params: {
|
||||
page: 1,
|
||||
per_page: 500,
|
||||
sort: 'taken_at',
|
||||
order: 'desc',
|
||||
...filterParams,
|
||||
},
|
||||
}
|
||||
)
|
||||
return response.data.photos || []
|
||||
},
|
||||
staleTime: 30000,
|
||||
})
|
||||
// Shared photos query — both Timeline and PreviewView use the same hook so
|
||||
// they share one cache entry, regardless of filter state.
|
||||
const { data: photos = [], isLoading } = usePhotosQuery()
|
||||
|
||||
// Group photos into rows for grid layout
|
||||
const rows = useMemo(() => {
|
||||
@@ -265,7 +221,7 @@ export function Timeline() {
|
||||
selectPhoto(photo.id, globalIndex)
|
||||
}
|
||||
}}
|
||||
onDoubleClick={() => openLoupe(photo.id)}
|
||||
onDoubleClick={() => openPreview(photo.id)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user