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,5 +1,4 @@
import { useState } from 'react' import { useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Timeline } from './components/timeline/Timeline' import { Timeline } from './components/timeline/Timeline'
import { LeftSidebar } from './components/layout/LeftSidebar' import { LeftSidebar } from './components/layout/LeftSidebar'
import { RightSidebar } from './components/layout/RightSidebar' import { RightSidebar } from './components/layout/RightSidebar'
@@ -7,36 +6,37 @@ import { TopBar } from './components/layout/TopBar'
import { ScanProgress } from './components/ScanProgress' import { ScanProgress } from './components/ScanProgress'
import { ToastContainer } from './components/ToastContainer' import { ToastContainer } from './components/ToastContainer'
import { KeyboardHints } from './components/KeyboardHints' import { KeyboardHints } from './components/KeyboardHints'
import { LoupeView } from './components/loupe/LoupeView' import { PreviewView } from './components/preview/PreviewView'
import { FilterBar } from './components/filter/FilterBar' import { FilterBar } from './components/filter/FilterBar'
import { ActiveFilterChips } from './components/filter/ActiveFilterChips' import { ActiveFilterChips } from './components/filter/ActiveFilterChips'
import { usePhotoStore } from './store/photoStore' import { usePhotoStore } from './store/photoStore'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts' import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
import { useFilterUrlSync } from './hooks/useFilterUrlSync' import { useFilterUrlSync } from './hooks/useFilterUrlSync'
import type { Photo } from './types/photo' import { usePhotosQuery } from './hooks/usePhotosQuery'
function App() { function App() {
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true) const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
const [rightSidebarOpen, setRightSidebarOpen] = useState(false) const [rightSidebarOpen, setRightSidebarOpen] = useState(false)
const selectedPhotos = usePhotoStore((state) => state.selectedPhotos) const selectedPhotos = usePhotoStore((state) => state.selectedPhotos)
const viewMode = usePhotoStore((state) => state.viewMode) const viewMode = usePhotoStore((state) => state.viewMode)
const queryClient = useQueryClient()
// Bidirectional sync of filter store with URL query params. // Bidirectional sync of filter store with URL query params.
useFilterUrlSync() useFilterUrlSync()
// Subscribe to the same photos query the Timeline uses, so the keyboard
// "open preview on first photo" path can read from the live cache regardless
// of what filter key it's stored under.
const { data: allPhotos } = usePhotosQuery()
// Set up global keyboard shortcuts // Set up global keyboard shortcuts
useKeyboardShortcuts({ useKeyboardShortcuts({
onToggleLeftSidebar: () => setLeftSidebarOpen(!leftSidebarOpen), onToggleLeftSidebar: () => setLeftSidebarOpen(!leftSidebarOpen),
onToggleRightSidebar: () => setRightSidebarOpen(!rightSidebarOpen), onToggleRightSidebar: () => setRightSidebarOpen(!rightSidebarOpen),
getFirstPhotoId: () => { getFirstPhotoId: () => allPhotos?.[0]?.id ?? null,
const photos = queryClient.getQueryData<Photo[]>(['photos'])
return photos && photos.length > 0 ? photos[0].id : null
},
}) })
// Auto-show right sidebar when photos are selected — but only in grid mode, // Auto-show right sidebar when photos are selected — but only in grid mode,
// so leaving loupe doesn't fight the user's prior sidebar state. // so leaving the preview doesn't fight the user's prior sidebar state.
if (viewMode === 'grid') { if (viewMode === 'grid') {
if (selectedPhotos.length > 0 && !rightSidebarOpen) { if (selectedPhotos.length > 0 && !rightSidebarOpen) {
setRightSidebarOpen(true) setRightSidebarOpen(true)
@@ -85,8 +85,8 @@ function App() {
{/* Toast Notifications */} {/* Toast Notifications */}
<ToastContainer /> <ToastContainer />
{/* Loupe overlay — covers TopBar when active */} {/* Preview overlay — covers TopBar when active */}
{viewMode === 'loupe' && <LoupeView />} {viewMode === 'preview' && <PreviewView />}
</div> </div>
) )
} }

View File

@@ -4,23 +4,23 @@ export function KeyboardHints() {
const selectedCount = usePhotoStore((state) => state.selectedPhotos.length) const selectedCount = usePhotoStore((state) => state.selectedPhotos.length)
const viewMode = usePhotoStore((state) => state.viewMode) 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. // would just be confusing. Hide them.
if (viewMode === 'loupe') return null if (viewMode === 'preview') return null
const hints = selectedCount > 0 const hints = selectedCount > 0
? [ ? [
{ key: '1-5', action: 'Rate' }, { key: '1-5', action: 'Rate' },
{ key: 'P', action: 'Pick' }, { key: 'P', action: 'Pick' },
{ key: 'X', action: 'Trash' }, { key: 'X', action: 'Trash' },
{ key: 'E', action: 'Loupe' }, { key: 'E / Space', action: 'Preview' },
{ key: 'Esc', action: 'Deselect' }, { key: 'Esc', action: 'Deselect' },
] ]
: [ : [
{ key: '↑↓←→', action: 'Navigate' }, { key: '↑↓←→', action: 'Navigate' },
{ key: 'Click', action: 'Select' }, { key: 'Click', action: 'Select' },
{ key: 'Shift+Click', action: 'Range' }, { key: 'Shift+Click', action: 'Range' },
{ key: 'Space', action: 'Preview' }, { key: 'E / Space', action: 'Preview' },
{ key: '\\', action: 'Filters' }, { key: '\\', action: 'Filters' },
{ key: '/', action: 'Search' }, { key: '/', action: 'Search' },
] ]

View File

@@ -3,7 +3,7 @@ import clsx from 'clsx'
import type { Photo } from '../../types/photo' import type { Photo } from '../../types/photo'
import { photos as photosApi } from '../../services/api' import { photos as photosApi } from '../../services/api'
interface LoupeFilmstripProps { interface PreviewFilmstripProps {
photos: Photo[] photos: Photo[]
currentIndex: number currentIndex: number
onSelect: (id: string) => void onSelect: (id: string) => void
@@ -11,7 +11,7 @@ interface LoupeFilmstripProps {
const CELL_SIZE = 72 const CELL_SIZE = 72
export function LoupeFilmstrip({ photos, currentIndex, onSelect }: LoupeFilmstripProps) { export function PreviewFilmstrip({ photos, currentIndex, onSelect }: PreviewFilmstripProps) {
const activeRef = useRef<HTMLButtonElement>(null) const activeRef = useRef<HTMLButtonElement>(null)
useEffect(() => { useEffect(() => {

View File

@@ -1,15 +1,14 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import clsx from 'clsx'
import type { Photo } from '../../types/photo' import type { Photo } from '../../types/photo'
import { import {
getLoupeImageSrc, getPreviewImageSrc,
getLoupeFallbackSrc, getPreviewFallbackSrc,
getVideoSrc, getVideoSrc,
isVideo, isVideo,
} from './loupeSrc' } from './previewSrc'
interface LoupeImageProps { interface PreviewImageProps {
photo: Photo photo: Photo
} }
@@ -17,14 +16,14 @@ const MIN_SCALE = 1
const MAX_SCALE = 8 const MAX_SCALE = 8
const WHEEL_STEP = 1.15 const WHEEL_STEP = 1.15
export function LoupeImage({ photo }: LoupeImageProps) { export function PreviewImage({ photo }: PreviewImageProps) {
if (isVideo(photo)) { 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 ( return (
<div className="flex flex-1 items-center justify-center bg-black"> <div className="flex flex-1 items-center justify-center bg-black">
<video <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 [loaded, setLoaded] = useState(false)
const [usingFallback, setUsingFallback] = useState(false) const [usingFallback, setUsingFallback] = useState(false)
@@ -58,8 +57,8 @@ function LoupeStillImage({ photo }: { photo: Photo }) {
setOffset({ x: 0, y: 0 }) setOffset({ x: 0, y: 0 })
}, [photo.id]) }, [photo.id])
const primarySrc = getLoupeImageSrc(photo) const primarySrc = getPreviewImageSrc(photo)
const fallbackSrc = getLoupeFallbackSrc(photo) const fallbackSrc = getPreviewFallbackSrc(photo)
const src = usingFallback ? fallbackSrc : primarySrc const src = usingFallback ? fallbackSrc : primarySrc
const handleError = () => { const handleError = () => {
@@ -83,10 +82,15 @@ function LoupeStillImage({ photo }: { photo: Photo }) {
setScale(Math.min(ratio, MAX_SCALE)) setScale(Math.min(ratio, MAX_SCALE))
}, [scale]) }, [scale])
useHotkeys('z', (e) => { useHotkeys(
e.preventDefault() 'z',
toggleZoom() (e) => {
}, [toggleZoom]) e.preventDefault()
toggleZoom()
},
{ preventDefault: true },
[toggleZoom]
)
const handleWheel = (e: React.WheelEvent) => { const handleWheel = (e: React.WheelEvent) => {
e.preventDefault() e.preventDefault()
@@ -140,11 +144,6 @@ function LoupeStillImage({ photo }: { photo: Photo }) {
onMouseLeave={endDrag} onMouseLeave={endDrag}
style={{ cursor }} 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 <img
ref={imgRef} ref={imgRef}
key={`${photo.id}-${usingFallback}`} key={`${photo.id}-${usingFallback}`}
@@ -155,18 +154,18 @@ function LoupeStillImage({ photo }: { photo: Photo }) {
draggable={false} draggable={false}
onLoad={() => setLoaded(true)} onLoad={() => setLoaded(true)}
onError={handleError} onError={handleError}
className={clsx( className="max-h-full max-w-full object-contain"
'max-h-full max-w-full object-contain transition-opacity duration-150',
loaded ? 'opacity-100' : 'opacity-0'
)}
style={{ style={{
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`, transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
transformOrigin: 'center center', transformOrigin: 'center center',
// Disable transition during pan/zoom — only fade-in is animated.
transition: 'opacity 150ms',
willChange: 'transform', 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 */} {/* Zoom indicator */}
{isZoomed && ( {isZoomed && (

View File

@@ -1,25 +1,24 @@
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { useQueryClient } from '@tanstack/react-query'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { usePhotoStore } from '../../store/photoStore' import { usePhotoStore } from '../../store/photoStore'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import type { Photo } from '../../types/photo' import type { Photo } from '../../types/photo'
import { LoupeImage } from './LoupeImage' import { PreviewImage } from './PreviewImage'
import { LoupeFilmstrip } from './LoupeFilmstrip' import { PreviewFilmstrip } from './PreviewFilmstrip'
import { getLoupeImageSrc, isVideo } from './loupeSrc' import { getPreviewImageSrc, isVideo } from './previewSrc'
export function LoupeView() { export function PreviewView() {
const activePhotoId = usePhotoStore((s) => s.activePhotoId) const activePhotoId = usePhotoStore((s) => s.activePhotoId)
const setActivePhoto = usePhotoStore((s) => s.setActivePhoto) const setActivePhoto = usePhotoStore((s) => s.setActivePhoto)
const closeLoupe = usePhotoStore((s) => s.closeLoupe) const closePreview = usePhotoStore((s) => s.closePreview)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const previouslyFocusedRef = useRef<HTMLElement | null>(null) const previouslyFocusedRef = useRef<HTMLElement | null>(null)
// Read photos from the TanStack Query cache populated by Timeline. // Same hook Timeline uses, so we share one cache entry rather than looking
// Same query key so we share the cache and never refetch. // it up by key (which broke when the key gained the filter params).
const queryClient = useQueryClient() const { data: photos = [] } = usePhotosQuery()
const photos = queryClient.getQueryData<Photo[]>(['photos']) ?? []
const currentIndex = activePhotoId const currentIndex = activePhotoId
? photos.findIndex((p) => p.id === activePhotoId) ? photos.findIndex((p) => p.id === activePhotoId)
@@ -39,21 +38,10 @@ export function LoupeView() {
setActivePhoto(photos[next].id) setActivePhoto(photos[next].id)
}, [photos, safeIndex, setActivePhoto]) }, [photos, safeIndex, setActivePhoto])
// Loupe-scoped hotkeys: only mounted while LoupeView is rendered. // Preview-scoped hotkeys: only mounted while PreviewView is rendered.
useHotkeys('escape', (e) => { useHotkeys('escape', closePreview, { preventDefault: true })
e.preventDefault() useHotkeys('left', goPrev, { preventDefault: true }, [goPrev])
closeLoupe() useHotkeys('right', goNext, { preventDefault: true }, [goNext])
})
useHotkeys('left', (e) => {
e.preventDefault()
goPrev()
}, [goPrev])
useHotkeys('right', (e) => {
e.preventDefault()
goNext()
}, [goNext])
// Preload the immediate neighbors so arrow nav feels instant. Skip videos // Preload the immediate neighbors so arrow nav feels instant. Skip videos
// (browsers can't preload them via Image()) and skip when at the edges. // (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) { for (const p of neighbors) {
if (isVideo(p)) continue if (isVideo(p)) continue
const img = new Image() const img = new Image()
img.src = getLoupeImageSrc(p) img.src = getPreviewImageSrc(p)
} }
}, [safeIndex, photos]) }, [safeIndex, photos])
// Focus trap: focus the loupe container on mount, restore focus on unmount. // Focus trap: focus the preview container on mount, restore focus on
// The container is keyboard-focusable (tabIndex=-1) so screen readers and // unmount. The container is keyboard-focusable (tabIndex=-1) so screen
// tab navigation stay scoped here. // readers and tab navigation stay scoped here.
useEffect(() => { useEffect(() => {
previouslyFocusedRef.current = document.activeElement as HTMLElement | null previouslyFocusedRef.current = document.activeElement as HTMLElement | null
containerRef.current?.focus() containerRef.current?.focus()
@@ -111,13 +99,13 @@ export function LoupeView() {
ref={containerRef} ref={containerRef}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Photo viewer" aria-label="Photo preview"
tabIndex={-1} tabIndex={-1}
className="fixed inset-0 z-40 flex flex-col items-center justify-center bg-black text-text-muted outline-none" 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> <div>No photo to display</div>
<button <button
onClick={closeLoupe} onClick={closePreview}
className="mt-4 rounded border border-border px-3 py-1 text-sm hover:bg-surface" className="mt-4 rounded border border-border px-3 py-1 text-sm hover:bg-surface"
> >
Close Close
@@ -131,17 +119,17 @@ export function LoupeView() {
ref={containerRef} ref={containerRef}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={`Photo viewer: ${currentPhoto.filename}`} aria-label={`Photo preview: ${currentPhoto.filename}`}
tabIndex={-1} tabIndex={-1}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className="fixed inset-0 z-40 flex flex-col bg-black outline-none" className="fixed inset-0 z-40 flex flex-col bg-black outline-none"
> >
{/* Close button */} {/* Close button */}
<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" 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)" title="Close (Esc)"
aria-label="Close photo viewer" aria-label="Close preview"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
@@ -154,9 +142,9 @@ export function LoupeView() {
</div> </div>
</div> </div>
<LoupeImage photo={currentPhoto} /> <PreviewImage photo={currentPhoto} />
<LoupeFilmstrip <PreviewFilmstrip
photos={photos} photos={photos}
currentIndex={safeIndex} currentIndex={safeIndex}
onSelect={setActivePhoto} onSelect={setActivePhoto}

View File

@@ -6,11 +6,11 @@ const VIDEO_EXTENSIONS = ['.mp4', '.mov', '.webm', '.mkv', '.m4v']
export function isVideo(photo: Photo): boolean { export function isVideo(photo: Photo): boolean {
if (photo.media_type === 'video') return true if (photo.media_type === 'video') return true
const lower = photo.filepath.toLowerCase() 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: * Always uses the /proxy endpoint, which the backend resolves to:
* - the original file for web-safe formats (JPEG/PNG/WebP/GIF) * - 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. * 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) return photosApi.getProxyUrl(photo.id)
} }
/** Fallback used when the proxy endpoint fails or 404s shows the 1280px /** Fallback used when the proxy endpoint fails or 404s shows the 1280px
* large thumbnail so the user still sees something. */ * 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') return photosApi.getThumbnailUrl(photo.id, 'large')
} }

View File

@@ -1,10 +1,8 @@
import { useRef, useEffect, useMemo, useState } from 'react' import { useRef, useEffect, useMemo, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual' import { useVirtualizer } from '@tanstack/react-virtual'
import { usePhotoStore } from '../../store/photoStore' import { usePhotoStore } from '../../store/photoStore'
import { useFilterStore, filtersToParams } from '../../store/filterStore'
import { PhotoThumbnail } from './PhotoThumbnail' import { PhotoThumbnail } from './PhotoThumbnail'
import { useQuery } from '@tanstack/react-query' import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import axios from 'axios'
import type { Photo } from '../../types/photo' import type { Photo } from '../../types/photo'
export function Timeline() { export function Timeline() {
@@ -18,7 +16,7 @@ export function Timeline() {
selectPhoto, selectPhoto,
togglePhotoSelection, togglePhotoSelection,
clearSelection, clearSelection,
openLoupe, openPreview,
} = usePhotoStore() } = usePhotoStore()
// Helper function for range selection // Helper function for range selection
@@ -46,51 +44,9 @@ export function Timeline() {
return Math.floor((containerWidth - padding * 2) / (thumbnailSize + gap)) return Math.floor((containerWidth - padding * 2) / (thumbnailSize + gap))
}, [containerWidth, thumbnailSize, gap, padding]) }, [containerWidth, thumbnailSize, gap, padding])
// Filter state — included in the query key so the cache invalidates when // Shared photos query — both Timeline and PreviewView use the same hook so
// any filter changes. Subscribing field-by-field keeps re-renders cheap. // they share one cache entry, regardless of filter state.
const q = useFilterStore((s) => s.q) const { data: photos = [], isLoading } = usePhotosQuery()
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,
})
// Group photos into rows for grid layout // Group photos into rows for grid layout
const rows = useMemo(() => { const rows = useMemo(() => {
@@ -265,7 +221,7 @@ export function Timeline() {
selectPhoto(photo.id, globalIndex) selectPhoto(photo.id, globalIndex)
} }
}} }}
onDoubleClick={() => openLoupe(photo.id)} onDoubleClick={() => openPreview(photo.id)}
/> />
) )
})} })}

View File

@@ -26,15 +26,20 @@ const COLOR_LABELS: Record<string, string> = {
'9': 'green', '9': 'green',
} }
// Default options shared by every shortcut: preventDefault stops the browser
// from claiming the event (Firefox quick-find on letter keys, Cmd+F search,
// `/` quick-find, Tab focus traversal). enableOnFormTags is left default-off
// so typing in inputs doesn't fire culling shortcuts.
const HK_OPTS = { preventDefault: true } as const
export function useKeyboardShortcuts(props: KeyboardShortcutsProps) { export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
const { onToggleLeftSidebar, onToggleRightSidebar, getFirstPhotoId } = props const { onToggleLeftSidebar, onToggleRightSidebar, getFirstPhotoId } = props
const viewMode = usePhotoStore((s) => s.viewMode) const viewMode = usePhotoStore((s) => s.viewMode)
const openLoupe = usePhotoStore((s) => s.openLoupe) const openPreview = usePhotoStore((s) => s.openPreview)
const closeLoupe = usePhotoStore((s) => s.closeLoupe) const closePreview = usePhotoStore((s) => s.closePreview)
const isGrid = viewMode === 'grid' const isPreview = viewMode === 'preview'
const isLoupe = viewMode === 'loupe'
// Photo mutation shared by every culling shortcut. Reads the active photo // Photo mutation shared by every culling shortcut. Reads the active photo
// id from the store at fire time so the closure stays fresh without forcing // id from the store at fire time so the closure stays fresh without forcing
@@ -55,98 +60,78 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
updateMutation.mutate({ id, data }) updateMutation.mutate({ id, data })
} }
// Toggle sidebars (allowed in both modes; right sidebar is hidden in loupe // Toggle sidebars
// by App-level CSS so toggling it is effectively grid-only.) useHotkeys('tab', onToggleLeftSidebar, HK_OPTS)
useHotkeys('tab', (e) => { useHotkeys('i', onToggleRightSidebar, HK_OPTS)
e.preventDefault()
onToggleLeftSidebar()
})
useHotkeys('i', (e) => {
e.preventDefault()
onToggleRightSidebar()
})
// Filter bar toggle (\) and search focus (/ or Cmd/Ctrl+F). // Filter bar toggle (\) and search focus (/ or Cmd/Ctrl+F).
useHotkeys('\\', (e) => { useHotkeys('\\', () => useFilterStore.getState().toggleFilterBar(), HK_OPTS)
e.preventDefault()
useFilterStore.getState().toggleFilterBar()
})
const focusSearch = (e: KeyboardEvent) => { const focusSearch = () => {
e.preventDefault()
const el = document.getElementById('topbar-search') as HTMLInputElement | null const el = document.getElementById('topbar-search') as HTMLInputElement | null
el?.focus() el?.focus()
el?.select() el?.select()
} }
useHotkeys('/', focusSearch) useHotkeys('/', focusSearch, HK_OPTS)
useHotkeys('mod+f', focusSearch) useHotkeys('mod+f', focusSearch, HK_OPTS)
// G always returns to grid (closes loupe if open). // E and Space both toggle the preview view (open from grid, close from
useHotkeys('g', () => { // preview). Double-click on a thumbnail does the same.
closeLoupe() const openPreviewFromGrid = () => {
})
// E toggles loupe (open from grid, close from loupe). Enter opens from grid.
const openLoupeFromGrid = () => {
const id = usePhotoStore.getState().activePhotoId ?? getFirstPhotoId?.() ?? null const id = usePhotoStore.getState().activePhotoId ?? getFirstPhotoId?.() ?? null
if (id) openLoupe(id) if (id) openPreview(id)
} }
useHotkeys( const togglePreview = () => {
'e', if (isPreview) closePreview()
() => { else openPreviewFromGrid()
if (isLoupe) { }
closeLoupe()
} else {
openLoupeFromGrid()
}
},
[isLoupe, getFirstPhotoId]
)
useHotkeys( useHotkeys('e', togglePreview, HK_OPTS, [isPreview, getFirstPhotoId])
'enter', useHotkeys('space', togglePreview, HK_OPTS, [isPreview, getFirstPhotoId])
(e) => {
if (isGrid) {
e.preventDefault()
openLoupeFromGrid()
}
},
{ enabled: isGrid },
[isGrid, getFirstPhotoId]
)
// ── Culling shortcuts (work in both grid and loupe) ────────────────────── // ── Culling shortcuts (work in both grid and preview) ────────────────────
// Star rating: 1-5 set, 0 clears. // Star rating: 1-5 set, 0 clears.
useHotkeys('1,2,3,4,5', (_e, handler) => { useHotkeys(
const rating = parseInt(handler.keys![0]) '1,2,3,4,5',
if (Number.isFinite(rating)) updateActive({ rating }) (_e, handler) => {
}) const rating = parseInt(handler.keys![0])
if (Number.isFinite(rating)) updateActive({ rating })
},
HK_OPTS
)
useHotkeys('0', () => { useHotkeys('0', () => updateActive({ rating: 0 }), HK_OPTS)
updateActive({ rating: 0 })
})
// Pick / trash / unflag. Trash is the merged "rejected" concept — a soft // Pick / trash / unflag. Trash is the merged "rejected" concept — a soft
// flag that hides the photo from the default timeline view; restore via // flag that hides the photo from the default timeline view; restore via
// the trash view (or the U shortcut). // the trash view (or the U shortcut).
useHotkeys('p', () => { useHotkeys(
updateActive({ is_picked: true, is_trashed: false }) 'p',
}) () => updateActive({ is_picked: true, is_trashed: false }),
HK_OPTS
)
useHotkeys('x', () => { useHotkeys(
updateActive({ is_trashed: true, is_picked: false }) 'x',
}) () => updateActive({ is_trashed: true, is_picked: false }),
HK_OPTS
)
useHotkeys('u', () => { useHotkeys(
updateActive({ is_picked: false, is_trashed: false }) 'u',
}) () => updateActive({ is_picked: false, is_trashed: false }),
HK_OPTS
)
// Color labels 6-9 (red/orange/yellow/green per spec §6.4). // Color labels 6-9 (red/orange/yellow/green per spec §6.4).
useHotkeys('6,7,8,9', (_e, handler) => { useHotkeys(
const label = COLOR_LABELS[handler.keys![0]] '6,7,8,9',
if (label) updateActive({ color_label: label }) (_e, handler) => {
}) const label = COLOR_LABELS[handler.keys![0]]
if (label) updateActive({ color_label: label })
},
HK_OPTS
)
} }

View File

@@ -0,0 +1,55 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import axios from 'axios'
import { useFilterStore, filtersToParams } from '../store/filterStore'
import type { Photo } from '../types/photo'
/**
* Single source of truth for the timeline photos query. Both Timeline and
* PreviewView call this so they share one cache entry — previously
* PreviewView looked the cache up by key directly, which broke the moment
* Timeline's key gained the filter params.
*/
export function usePhotosQuery() {
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]
)
return 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: 30_000,
})
}

View File

@@ -1,7 +1,7 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { Photo } from '../types/photo' import type { Photo } from '../types/photo'
type ViewMode = 'grid' | 'loupe' type ViewMode = 'grid' | 'preview'
interface PhotoStore { interface PhotoStore {
photos: Photo[] photos: Photo[]
@@ -19,8 +19,8 @@ interface PhotoStore {
clearSelection: () => void clearSelection: () => void
setActivePhoto: (id: string | null) => void setActivePhoto: (id: string | null) => void
setViewMode: (mode: ViewMode) => void setViewMode: (mode: ViewMode) => void
openLoupe: (id: string) => void openPreview: (id: string) => void
closeLoupe: () => void closePreview: () => void
} }
export const usePhotoStore = create<PhotoStore>((set) => ({ export const usePhotoStore = create<PhotoStore>((set) => ({
@@ -73,7 +73,7 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
setViewMode: (mode) => set({ viewMode: mode }), setViewMode: (mode) => set({ viewMode: mode }),
openLoupe: (id) => set({ viewMode: 'loupe', activePhotoId: id }), openPreview: (id) => set({ viewMode: 'preview', activePhotoId: id }),
closeLoupe: () => set({ viewMode: 'grid' }), closePreview: () => set({ viewMode: 'grid' }),
})) }))