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

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