From 8529771122e20e8476a9f73da33455014544e04e Mon Sep 17 00:00:00 2001 From: dtoro Date: Tue, 14 Apr 2026 23:45:36 +0200 Subject: [PATCH] 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) --- frontend/src/App.tsx | 20 +- frontend/src/components/KeyboardHints.tsx | 183 +++++++++++++----- frontend/src/components/ScanProgress.tsx | 183 +----------------- frontend/src/components/filter/FilterBar.tsx | 72 ++++--- .../src/components/heaps/ActiveHeapCard.tsx | 10 +- frontend/src/components/heaps/HeapsPanel.tsx | 4 +- .../src/components/layout/LeftSidebar.tsx | 27 +++ .../src/components/layout/RightSidebar.tsx | 4 +- .../components/preview/PreviewFilmstrip.tsx | 23 ++- .../src/components/preview/PreviewImage.tsx | 8 +- .../src/components/preview/PreviewView.tsx | 26 +-- .../src/components/sidebar/PhotoInfoPanel.tsx | 4 +- .../components/timeline/PhotoThumbnail.tsx | 84 ++++---- frontend/src/components/timeline/Timeline.tsx | 24 ++- frontend/src/hooks/useKeyboardShortcuts.ts | 172 +++++++++------- frontend/src/hooks/useScanActivity.ts | 64 ++++++ frontend/src/store/photoStore.ts | 1 + 17 files changed, 501 insertions(+), 408 deletions(-) create mode 100644 frontend/src/hooks/useScanActivity.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1fe3bed..3c236bd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Timeline } from './components/timeline/Timeline' import { DuplicatesView } from './components/duplicates/DuplicatesView' import { MapView } from './components/map/MapView' @@ -29,8 +29,24 @@ function MainApp() { const [leftSidebarOpen, setLeftSidebarOpen] = useState(true) const [rightSidebarOpen, setRightSidebarOpen] = useState(true) const viewMode = usePhotoStore((state) => state.viewMode) + const activePhotoId = usePhotoStore((state) => state.activePhotoId) const currentSection = useFilterStore((s) => s.currentSection) + // Close the metadata panel when the user switches between sections so + // it doesn't carry over a now-irrelevant selection. It re-opens once a + // photo gains focus in the new section (effect below). + const prevSectionRef = useRef(currentSection) + useEffect(() => { + if (prevSectionRef.current !== currentSection) { + prevSectionRef.current = currentSection + setRightSidebarOpen(false) + } + }, [currentSection]) + + useEffect(() => { + setRightSidebarOpen(!!activePhotoId) + }, [activePhotoId]) + // Bidirectional sync of filter store with URL query params. useFilterUrlSync() @@ -101,7 +117,7 @@ function MainApp() { )} - {!isSettings && } + {!isSettings && viewMode !== 'preview' && } {/* Right Sidebar */} diff --git a/frontend/src/components/KeyboardHints.tsx b/frontend/src/components/KeyboardHints.tsx index 0299720..e666f08 100644 --- a/frontend/src/components/KeyboardHints.tsx +++ b/frontend/src/components/KeyboardHints.tsx @@ -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. -
- {/* 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. */} -
- {hints.map((hint, i) => ( -
- - {hint.key} - - - {hint.action} - - {i < hints.length - 1 && ( +
+ {collapsed ? ( + // Collapsed handle: a small pill peeking from the bottom so the + // user can re-open the panel without remembering the shortcut. + + ) : ( +
+ {hints.map((hint, i) => ( +
+ + {hint.key} + + + {hint.action} + - )} -
- ))} - {selectedCount > 0 && ( - <> - - - {selectedCount} selected - - - )} -
+
+ ))} + +
+ )}
) } diff --git a/frontend/src/components/ScanProgress.tsx b/frontend/src/components/ScanProgress.tsx index 043322f..00ee8ee 100644 --- a/frontend/src/components/ScanProgress.tsx +++ b/frontend/src/components/ScanProgress.tsx @@ -1,8 +1,6 @@ -import { useEffect, useRef, useState } from 'react' -import { FolderOpen, Loader2, Check, AlertCircle, X, Brain, Sparkles } from 'lucide-react' +import { useEffect, useRef } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { library, WorkerStatus } from '../services/api' -import clsx from 'clsx' interface ScanStatus { is_scanning: boolean @@ -14,9 +12,14 @@ interface ScanStatus { type Phase = 'idle' | 'scanning' | 'processing' | 'done' +/** + * Headless background-activity orchestrator. Polls scan + worker status + * and invalidates affected query caches when a scan/processing pass + * completes. The visible status indicator now lives inline in the + * LeftSidebar (small spinner next to the FOLDERS header / specific + * folder rows) — see useScanActivity. + */ export function ScanProgress() { - const [isVisible, setIsVisible] = useState(false) - const [isMinimized, setIsMinimized] = useState(false) const queryClient = useQueryClient() const wasScanningRef = useRef(false) const wasProcessingRef = useRef(false) @@ -43,7 +46,6 @@ export function ScanProgress() { enabled: true, }) - const visionActive = visionQueued(workerStatus) const totalActive = totalQueued(workerStatus) const phase: Phase = isScanning @@ -54,18 +56,11 @@ export function ScanProgress() { useEffect(() => { if (phase === 'scanning') { - setIsVisible(true) - setIsMinimized(false) wasScanningRef.current = true wasProcessingRef.current = false } else if (phase === 'processing') { - // Show widget when processing starts (even without a prior scan, - // e.g. backfill triggered from Settings). - if (!isVisible) setIsVisible(true) wasProcessingRef.current = true - if (wasScanningRef.current) { - // Scan just finished — invalidate data caches. wasScanningRef.current = false queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: ['folders'] }) @@ -76,7 +71,6 @@ export function ScanProgress() { } } else if (phase === 'idle') { if (wasScanningRef.current) { - // Scan finished with no queued processing (small import). wasScanningRef.current = false queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: ['folders'] }) @@ -86,172 +80,15 @@ export function ScanProgress() { queryClient.invalidateQueries({ queryKey: ['library', 'stats'] }) } if (wasProcessingRef.current) { - // Processing just drained — refresh tags (new clusters/objects). wasProcessingRef.current = false queryClient.invalidateQueries({ queryKey: ['tags'] }) queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: ['library', 'stats'] }) } - if (isVisible) { - setTimeout(() => setIsVisible(false), 3000) - } } - }, [phase, isVisible, queryClient]) + }, [phase, queryClient]) - if (!isVisible) return null - - // Scan progress percentage - const scanProgress = scanStatus && scanStatus.total_files > 0 - ? (scanStatus.processed_files / scanStatus.total_files) * 100 - : 0 - - const isComplete = phase === 'idle' && (scanStatus?.processed_files ?? 0) > 0 - const hasErrors = scanStatus?.errors && scanStatus.errors.length > 0 - - return ( -
- {/* Header */} -
setIsMinimized(!isMinimized)} - > -
- {phase === 'scanning' ? ( - - ) : phase === 'processing' ? ( - - ) : isComplete && !hasErrors ? ( - - ) : hasErrors ? ( - - ) : ( - - )} - {!isMinimized && ( - - {phase === 'scanning' - ? 'Scanning Folders' - : phase === 'processing' - ? 'Processing Photos' - : isComplete - ? 'Complete' - : 'Status'} - - )} -
- {!isMinimized && ( - - )} -
- - {/* Content */} - {!isMinimized && ( -
- {/* Scan phase */} - {phase === 'scanning' && scanStatus && ( - <> - {scanStatus.current_folder && ( -
- {scanStatus.current_folder} -
- )} -
-
-
-
-
-
- - {scanStatus.processed_files} / {scanStatus.total_files || '?'} files - - - {Math.round(scanProgress)}% - -
- - )} - - {/* Processing phase */} - {phase === 'processing' && workerStatus && ( - <> -
- - Analyzing photos… -
-
- {visionActive > 0 && ( -
- Vision pipeline - {visionActive} queued -
- )} - {(totalActive - visionActive) > 0 && ( -
- Other tasks - {totalActive - visionActive} queued -
- )} -
-
- Thumbnails, embeddings, faces, tags — runs in background -
- - )} - - {/* Done phase */} - {phase === 'idle' && isComplete && ( -
- - All processing complete -
- )} - - {/* Errors */} - {hasErrors && ( -
-
- {scanStatus!.errors.slice(0, 3).map((error, i) => ( -
- • {error} -
- ))} - {scanStatus!.errors.length > 3 && ( -
- +{scanStatus!.errors.length - 3} more errors -
- )} -
-
- )} -
- )} -
- ) -} - -function visionQueued(ws: WorkerStatus | undefined): number { - if (!ws) return 0 - const queued = ws.queues?.['vision'] ?? 0 - const active = ws.workers - ?.filter(w => w.queues?.includes('vision')) - .reduce((sum, w) => sum + (w.active ?? 0) + (w.reserved ?? 0), 0) ?? 0 - return queued + active + return null } function totalQueued(ws: WorkerStatus | undefined): number { diff --git a/frontend/src/components/filter/FilterBar.tsx b/frontend/src/components/filter/FilterBar.tsx index 9c713db..e03f123 100644 --- a/frontend/src/components/filter/FilterBar.tsx +++ b/frontend/src/components/filter/FilterBar.tsx @@ -132,12 +132,14 @@ export function FilterBar({ const colorActive = colorLabel !== null const colorValue = colorActive ? colorLabel : null - const flagActive = flag !== 'any' - const flagValue = flagActive - ? flag === 'date_warning' - ? 'date issues' - : flag - : null + const flagActive = flag !== 'any' || needsReview + const flagValue = needsReview + ? 'needs review' + : flag !== 'any' + ? flag === 'date_warning' + ? 'date issues' + : flag + : null const tagActive = tagIds.length > 0 const activeTagNames = allTags @@ -301,20 +303,29 @@ export function FilterBar({ {/* Flag — hidden in the Discarded section, where the flag is - * pinned to "discarded" by the section preset. */} + * pinned to "discarded" by the section preset. The Needs review + * option lives here too: it sets a different store field + * (`needsReview`) but is mutually exclusive with the other flag + * values from the user's perspective. */} {!hideFlagPill && ( setFlag('any')} + onClear={() => { + setFlag('any') + setNeedsReview(false) + }} >
+
)} - {/* Needs review — binary toggle */} - - {/* Tags */} {allTags.length > 0 && ( {visible.length === 0 ? ( -
- Pick photos with P to fill the heap +
+ + Select photos with{' '} + + S + {' '} + to fill the heap +
) : (
diff --git a/frontend/src/components/heaps/HeapsPanel.tsx b/frontend/src/components/heaps/HeapsPanel.tsx index 53dd589..ccb53c2 100644 --- a/frontend/src/components/heaps/HeapsPanel.tsx +++ b/frontend/src/components/heaps/HeapsPanel.tsx @@ -273,7 +273,7 @@ export function HeapsPanel() { 'group relative flex h-[24px] cursor-pointer items-center gap-1 rounded px-2 text-[12px] leading-none', isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2', // Active-heap row gets a soft primary wash so the - // user always knows where Pick / T will land, even + // user always knows where Select / T will land, even // when viewing a different section. isActive && !isFiltered && 'bg-primary/8 text-text', isDropTarget && 'ring-2 ring-primary bg-primary/10' @@ -350,7 +350,7 @@ export function HeapsPanel() { {isActive && ( Active diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index 6c812c7..a4a6061 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -50,6 +50,7 @@ import { UploadModal } from '../upload/UploadModal' import { useSharedFoldersQuery } from '../../hooks/useSharingQueries' import { useAuth } from '../../contexts/AuthContext' import { useFeaturesQuery } from '../../hooks/useFeaturesQuery' +import { useScanActivity } from '../../hooks/useScanActivity' interface TreeItem { id: string @@ -61,10 +62,15 @@ interface TreeItem { /** For folder rows only: the user-set "hide from views" flag. Drives * the muted styling + eye-off badge + menu item label. */ isHidden?: boolean + /** For folder rows only: filesystem path. Used to match against the + * scan-status `current_folder` so we can show an inline spinner on + * the row that's actively being scanned. */ + path?: string } export function LeftSidebar() { const { user, isAdmin, logout } = useAuth() + const scanActivity = useScanActivity() const [expandedItems, setExpandedItems] = useState>(new Set(['library', 'folders', 'heaps'])) // Inline rename state for source-root rows. Stores the id being edited // and the draft name. Double-click a folder row to start. @@ -405,6 +411,7 @@ export function LeftSidebar() { count: node.photo_count, type: 'folder', isHidden: node.is_hidden, + path: node.path, children: node.children.length > 0 ? node.children.map(folderNodeToTreeItem) : undefined, @@ -628,6 +635,26 @@ export function LeftSidebar() { )} + {/* Activity spinner — shown on the FOLDERS section header + * whenever any background scan/processing is happening, and + * on a folder row whose path is the one currently being + * scanned. Replaces the old bottom-right ScanProgress popup. */} + {(() => { + const showOnFolders = + isSectionHeader && item.id === 'folders' && scanActivity.active + const showOnFolderRow = + !!item.path && + !!scanActivity.currentFolder && + scanActivity.currentFolder.startsWith(item.path) + if (!showOnFolders && !showOnFolderRow) return null + return ( + + ) + })()} + {/* Count Badge — fixed-width slot so counts line up in a column * across rows regardless of digit count. Section headers skip * the badge entirely (they're labels, not navigable rows). */} diff --git a/frontend/src/components/layout/RightSidebar.tsx b/frontend/src/components/layout/RightSidebar.tsx index 8930d36..d677771 100644 --- a/frontend/src/components/layout/RightSidebar.tsx +++ b/frontend/src/components/layout/RightSidebar.tsx @@ -155,7 +155,7 @@ export function RightSidebar() { const { data: allTags = [] } = useTagsQuery() const [tagInput, setTagInput] = useState('') - // Active heap membership for the bulk Pick toggle. + // Active heap membership for the bulk Select toggle. const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers() const heapMutation = useMutation({ @@ -385,7 +385,7 @@ export function RightSidebar() { } > - {allMembers ? 'Picked' : 'Pick'} + {allMembers ? 'Selected' : 'Select'} ) })} @@ -77,7 +85,10 @@ function FilmstripThumb({ photo }: { photo: Photo }) { onError={() => setErrored(true)} className={clsx( 'h-full w-full object-cover transition-opacity duration-200', - loaded ? 'opacity-100' : 'opacity-0' + loaded ? 'opacity-100' : 'opacity-0', + // Match the grid's discarded styling so the filmstrip mirrors + // what the user sees behind the preview. + photo.is_discarded && 'opacity-50 grayscale' )} /> diff --git a/frontend/src/components/preview/PreviewImage.tsx b/frontend/src/components/preview/PreviewImage.tsx index 3f4af97..d678fa0 100644 --- a/frontend/src/components/preview/PreviewImage.tsx +++ b/frontend/src/components/preview/PreviewImage.tsx @@ -24,15 +24,19 @@ export function PreviewImage({ photo }: PreviewImageProps) { } function PreviewVideo({ photo }: { photo: Photo }) { + // min-h-0 + overflow-hidden are needed so a portrait/vertical video + // doesn't push past the column's allotted height and shove the + // filmstrip off-screen — flex items default to min-height:auto, which + // makes intrinsically-tall content overflow. return ( -
+
) diff --git a/frontend/src/components/preview/PreviewView.tsx b/frontend/src/components/preview/PreviewView.tsx index fb0b3dd..7a2cf9c 100644 --- a/frontend/src/components/preview/PreviewView.tsx +++ b/frontend/src/components/preview/PreviewView.tsx @@ -10,6 +10,7 @@ import { PreviewImage } from './PreviewImage' import { PreviewFilmstrip } from './PreviewFilmstrip' import { getPreviewImageSrc, isVideo } from './previewSrc' import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel' +import { KeyboardHints } from '../KeyboardHints' export function PreviewView() { const activePhotoId = usePhotoStore((s) => s.activePhotoId) @@ -18,25 +19,17 @@ export function PreviewView() { const containerRef = useRef(null) const previouslyFocusedRef = useRef(null) - const [infoPanelOpen, setInfoPanelOpen] = useState(false) + const [infoPanelOpen, setInfoPanelOpen] = useState(true) - // Snapshot the photo we opened on, captured once at mount via the - // store's getState (which is guaranteed to reflect the value the - // openPreview action just wrote, even if the React subscription - // hasn't been delivered to this component's first render yet). This - // is the id we'll restore on close, no matter how many neighbours - // the user arrows through inside the preview. + // Track the photo we OPENED preview on as a defensive fallback only. + // On close we restore the LAST viewed photo so the grid focus matches + // what the user just saw (filmstrip/arrow nav can land them anywhere). const openOriginRef = useRef( activePhotoId ?? usePhotoStore.getState().activePhotoId ) const closePreview = useCallback(() => { - // Bypass the store action and write the restoration directly so - // the snapshot ref is the single source of truth. Falls back to - // the live activePhotoId if the ref was somehow never populated - // (defensive — openPreview always sets activePhotoId before - // PreviewView mounts). const id = - openOriginRef.current ?? usePhotoStore.getState().activePhotoId + usePhotoStore.getState().activePhotoId ?? openOriginRef.current usePhotoStore.setState({ viewMode: 'grid', activePhotoId: id, @@ -258,6 +251,13 @@ export function PreviewView() { + {/* Shortcut hints — sits above the filmstrip and centers to the + * image column (not the viewport), so the optional info panel + * on the right doesn't push it off-axis. */} +
+ +
+
- {/* Flag — Pick + Discard */} + {/* Flag — Select + Discard */}
@@ -519,7 +519,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro } > - {isInActiveHeap ? 'Picked' : 'Pick'} + {isInActiveHeap ? 'Selected' : 'Select'}