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:
@@ -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