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>
139 lines
4.8 KiB
TypeScript
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>
|
|
)
|
|
}
|