ui: rework selection/heap visuals, contextual shortcut hints, inline scan activity
- Selection now reads as a blue ring + tint with a springy scale-down, hover stays a subtle gray ring so keyboard-driven and mouse-driven states are tellable apart. - Heap membership is signalled with a green tint only (no badge, no ring, no scale). - Discard/restore is optimistic and non-yanking: photos stay in the grid greyed out until the next reload, X toggles based on the current state, and the same treatment applies in preview. - Filmstrip mirrors the grid styling (selection blue, heap green, discarded grey). - Preview close restores the LAST viewed photo as the focused/selected one in the grid. - Right sidebar collapses on view change and re-opens when a photo is in focus; Esc clears active selection so the panel collapses too. - Keyboard hints panel is context-aware (grid / preview / discarded section), collapsible with H, persisted, and rendered inside the preview column above the filmstrip. - "Pick (P)" renamed to "Select (S)" everywhere. - Needs review moved into the Flag pill dropdown. - Fixed vertical videos overflowing the preview column (min-h-0). - Replaced the bottom-right ScanProgress popover with an inline spinner next to the FOLDERS sidebar header (and on the specific folder row being scanned). ScanProgress is now a headless invalidator; useScanActivity exposes the live status. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,62 +1,143 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
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((state) => state.selectedPhotos.length)
|
||||
const viewMode = usePhotoStore((state) => state.viewMode)
|
||||
const selectedCount = usePhotoStore((s) => s.selectedPhotos.length)
|
||||
const viewMode = usePhotoStore((s) => s.viewMode)
|
||||
const currentSection = useFilterStore((s) => s.currentSection)
|
||||
|
||||
// In preview mode the viewer has its own context, so the grid hints
|
||||
// would just be confusing. Hide them.
|
||||
if (viewMode === 'preview') return null
|
||||
const [collapsed, setCollapsed] = useState(
|
||||
() => typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1'
|
||||
)
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, collapsed ? '1' : '0')
|
||||
}, [collapsed])
|
||||
|
||||
const hints = selectedCount > 0
|
||||
? [
|
||||
{ key: '1-5', action: 'Rate' },
|
||||
{ key: 'P', action: 'Pick → heap' },
|
||||
{ key: 'X', action: 'Discard' },
|
||||
{ key: 'Space', action: 'Preview' },
|
||||
{ key: 'I', action: 'Info panel' },
|
||||
{ key: 'Esc', action: 'Deselect' },
|
||||
]
|
||||
: [
|
||||
{ key: '↑↓←→', action: 'Navigate' },
|
||||
{ key: 'Space', action: 'Preview' },
|
||||
{ key: 'Tab', action: 'Library panel' },
|
||||
{ key: 'I', action: 'Info panel' },
|
||||
{ key: '/', action: 'Search' },
|
||||
]
|
||||
// `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 (
|
||||
// Absolute (not fixed) so the parent's flex/position context can
|
||||
// center it relative to the timeline area, not the viewport. Mount
|
||||
// inside the main column in App.tsx so it isn't offset by the
|
||||
// sidebar widths.
|
||||
<div className="pointer-events-none absolute bottom-4 left-1/2 z-30 -translate-x-1/2">
|
||||
{/* Near-opaque dark pill so the hints stay legible against busy
|
||||
* thumbnails. The previous bg-surface/40 + 5% white ring left
|
||||
* text washed out when a bright photo sat directly behind it. */}
|
||||
<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>
|
||||
{i < hints.length - 1 && (
|
||||
<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={clsx(
|
||||
'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>
|
||||
))}
|
||||
{selectedCount > 0 && (
|
||||
<>
|
||||
<span className="text-white/30">•</span>
|
||||
<span className="whitespace-nowrap text-xs font-semibold text-primary">
|
||||
{selectedCount} selected
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user