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:
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user