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:
2026-04-07 22:47:52 +02:00
parent ce2cda0565
commit 2679214cb9
10 changed files with 196 additions and 213 deletions

View File

@@ -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)}
/>
)
})}