Files
mule-image/frontend/src/components/KeyboardHints.tsx
dtoro e65e798021 perf+ux: cut grid re-renders, coalesce discard, dedup bulk mutations
Frontend cleanup pass driven by the post-shadcn review.

Performance
- Memoize PhotoThumbnail and route cell click/double-click through
  stable handlers so heap-membership invalidation no longer re-renders
  every visible thumbnail.
- Cap usePhotosQuery's eager background page-walk at 20 pages with a
  50ms inter-page yield — was unbounded (up to 100k photos cold).
- Drop the per-thumbnail loading spinner in favour of the existing
  pulse skeleton; only retry state still surfaces a spinner.

UX
- Coalesce rapid X/U presses into a single undo entry + one toast
  (1.2s window) so accidental bursts are easy to back out.
- Optimistic rating/color updates with per-id snapshot rollback on
  error, matching the existing discard pattern.
- Section-aware empty timeline state with a Clear-all-filters CTA.
- Carry the search-match chip from the grid into the preview header.
- Add a basket-icon badge for active heap membership so the green
  tint isn't the only signal (colorblind-safe).
- Standardise error toasts via formatApiError(): FastAPI detail,
  validation arrays, axios message, with a 'Network Error' filter.

Architecture
- Extract useBulkPhotoMutations and stop duplicating
  bulkRating/bulkColor across RightSidebar and useKeyboardShortcuts.
- Split RightSidebar (714 -> 448 LOC) and PhotoInfoPanel (952 -> 716)
  into co-located sub-components: BulkTakenAtEditor, BulkTagsEditor,
  TagsEditor, TakenAtEditor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:13:39 +02:00

139 lines
4.8 KiB
TypeScript

import { useEffect, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { usePhotoStore } from '../store/photoStore'
import { useFilterStore } from '../store/filterStore'
const STORAGE_KEY = 'keyboard-hints-collapsed'
interface Hint {
key: string
action: string
}
/** Build the hint list for the current context. Returns an empty array
* when no shortcuts apply, which lets the caller hide the panel
* entirely instead of rendering an empty pill. */
function getHints(opts: {
selectedCount: number
currentSection: string
viewMode: string
}): Hint[] {
const { selectedCount, currentSection, viewMode } = opts
// Preview mode: culling shortcuts apply to the photo on screen, plus
// arrow nav between photos and Esc to close.
if (viewMode === 'preview') {
const preview: Hint[] = [
{ key: '←→', action: 'Navigate' },
{ key: '1-5', action: 'Rate' },
{ key: 'S', action: 'Select → heap' },
]
if (currentSection === 'discarded') {
preview.push({ key: 'U', action: 'Restore' })
} else {
preview.push({ key: 'X', action: 'Discard' })
}
preview.push(
{ key: 'I', action: 'Info panel' },
{ key: 'Space', action: 'Close' },
{ key: 'Esc', action: 'Close' }
)
return preview
}
if (selectedCount > 0) {
const base: Hint[] = [
{ key: '1-5', action: 'Rate' },
{ key: 'S', action: 'Select → heap' },
]
if (currentSection === 'discarded') {
base.push({ key: 'U', action: 'Restore' })
} else {
base.push({ key: 'X', action: 'Discard' })
}
base.push(
{ key: 'Space', action: 'Preview' },
{ key: 'I', action: 'Info panel' },
{ key: 'Esc', action: 'Deselect' }
)
return base
}
return [
{ key: '↑↓←→', action: 'Navigate' },
{ key: 'Space', action: 'Preview' },
{ key: 'Tab', action: 'Library panel' },
{ key: 'I', action: 'Info panel' },
{ key: '/', action: 'Search' },
]
}
export function KeyboardHints() {
const selectedCount = usePhotoStore((s) => s.selectedPhotos.length)
const viewMode = usePhotoStore((s) => s.viewMode)
const currentSection = useFilterStore((s) => s.currentSection)
const [collapsed, setCollapsed] = useState(
() => typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1'
)
useEffect(() => {
localStorage.setItem(STORAGE_KEY, collapsed ? '1' : '0')
}, [collapsed])
// `H` toggles the panel. `?` (shift+/) collides with the global `/`
// search shortcut, so we use a plain letter instead.
useHotkeys('h', () => setCollapsed((c) => !c), { preventDefault: true })
const hints = getHints({ selectedCount, currentSection, viewMode })
// Nothing relevant to show — hide entirely.
if (hints.length === 0) return null
return (
<div className="pointer-events-none absolute bottom-0 left-1/2 z-30 -translate-x-1/2 pb-4">
{collapsed ? (
// Collapsed handle: a small pill peeking from the bottom so the
// user can re-open the panel without remembering the shortcut.
<button
type="button"
onClick={() => setCollapsed(false)}
className="pointer-events-auto flex items-center gap-1.5 rounded-full border border-white/15 bg-black/80 px-3 py-1 text-[11px] text-white/80 shadow-xl backdrop-blur-md transition-colors hover:bg-black/90 hover:text-white"
title="Show shortcuts (H)"
>
<ChevronUp className="h-3 w-3" />
Shortcuts
<kbd className="rounded bg-white/15 px-1 py-0.5 text-[10px] font-medium text-white">
H
</kbd>
</button>
) : (
<div className="pointer-events-auto flex items-center gap-3 whitespace-nowrap rounded-full border border-white/15 bg-black/80 px-4 py-1.5 shadow-xl ring-1 ring-black/40 backdrop-blur-md">
{hints.map((hint, i) => (
<div key={i} className="flex items-center gap-1.5">
<kbd className="rounded bg-white/15 px-1.5 py-0.5 text-[11px] font-medium text-white shadow-sm">
{hint.key}
</kbd>
<span className="whitespace-nowrap text-xs text-white/85">
{hint.action}
</span>
<span className="ml-1 text-white/30"></span>
</div>
))}
<button
type="button"
onClick={() => setCollapsed(true)}
className="-mr-1 flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[11px] text-white/60 transition-colors hover:bg-white/10 hover:text-white"
title="Hide shortcuts (H)"
>
<kbd className="rounded bg-white/15 px-1 py-0.5 text-[10px] font-medium text-white">
H
</kbd>
<ChevronDown className="h-3 w-3" />
</button>
</div>
)}
</div>
)
}