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.
+
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)"
+ >
+
+ Shortcuts
+
+ H
+
+
+ ) : (
+
+ {hints.map((hint, i) => (
+
+
+ {hint.key}
+
+
+ {hint.action}
+
•
- )}
-
- ))}
- {selectedCount > 0 && (
- <>
-
•
-
- {selectedCount} selected
-
- >
- )}
-
+
+ ))}
+
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)"
+ >
+
+ H
+
+
+
+
+ )}
)
}
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 && (
-
{
- e.stopPropagation()
- setIsVisible(false)
- }}
- className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
- >
-
-
- )}
-
-
- {/* 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)
+ }}
>
setFlag('any')}
+ onClick={() => {
+ setFlag('any')
+ setNeedsReview(false)
+ }}
className={clsx(
'rounded px-2 py-1 text-left text-xs transition-colors',
- flag === 'any'
+ flag === 'any' && !needsReview
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
@@ -322,7 +333,10 @@ export function FilterBar({
Any
setFlag('discarded')}
+ onClick={() => {
+ setFlag('discarded')
+ setNeedsReview(false)
+ }}
className={clsx(
'rounded px-2 py-1 text-left text-xs transition-colors',
flag === 'discarded'
@@ -333,7 +347,10 @@ export function FilterBar({
Discarded
setFlag('date_warning')}
+ onClick={() => {
+ setFlag('date_warning')
+ setNeedsReview(false)
+ }}
className={clsx(
'flex items-center gap-1.5 rounded px-2 py-1 text-left text-xs transition-colors',
flag === 'date_warning'
@@ -345,25 +362,26 @@ export function FilterBar({
Date issues
+
{
+ setFlag('any')
+ setNeedsReview(true)
+ }}
+ className={clsx(
+ 'flex items-center gap-1.5 rounded px-2 py-1 text-left text-xs transition-colors',
+ needsReview
+ ? 'bg-primary text-white'
+ : 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
+ )}
+ title="Show only photos classified as non-photographs (screenshots, documents, memes)"
+ >
+
+ Needs review
+
)}
- {/* Needs review — binary toggle */}
-
setNeedsReview(!needsReview)}
- className={clsx(
- 'flex h-6 items-center gap-1 whitespace-nowrap rounded-full border px-2.5 text-xs transition-colors',
- needsReview
- ? 'border-primary bg-primary text-white'
- : 'border-border bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
- )}
- title="Show only photos classified as non-photographs (screenshots, documents, memes)"
- >
-
- Needs review
-
-
{/* 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'}
bulkDiscardMutation.mutate(selectedPhotos)}
diff --git a/frontend/src/components/preview/PreviewFilmstrip.tsx b/frontend/src/components/preview/PreviewFilmstrip.tsx
index 2b0f758..38b4129 100644
--- a/frontend/src/components/preview/PreviewFilmstrip.tsx
+++ b/frontend/src/components/preview/PreviewFilmstrip.tsx
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import type { Photo } from '../../types/photo'
import { photos as photosApi } from '../../services/api'
+import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
interface PreviewFilmstripProps {
photos: Photo[]
@@ -13,6 +14,7 @@ const CELL_SIZE = 72
export function PreviewFilmstrip({ photos, currentIndex, onSelect }: PreviewFilmstripProps) {
const activeRef = useRef(null)
+ const { memberIds: activeHeapMembers } = useActiveHeapMembers()
useEffect(() => {
activeRef.current?.scrollIntoView({
@@ -26,22 +28,28 @@ export function PreviewFilmstrip({ photos, currentIndex, onSelect }: PreviewFilm
{photos.map((photo, index) => {
const isActive = index === currentIndex
+ const isInActiveHeap = activeHeapMembers.has(photo.id)
return (
onSelect(photo.id)}
className={clsx(
- 'relative shrink-0 overflow-hidden rounded-sm transition-all',
- 'hover:opacity-100',
- isActive
- ? 'ring-2 ring-primary opacity-100'
- : 'opacity-60'
+ 'relative shrink-0 overflow-hidden rounded-sm will-change-transform transition-[transform,box-shadow,opacity] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)]',
+ isActive && 'scale-90 opacity-100 ring-2 ring-blue-500 ring-offset-2 ring-offset-surface',
+ !isActive && isInActiveHeap && 'opacity-100',
+ !isActive && !isInActiveHeap && 'opacity-60 hover:opacity-100 hover:ring-1 hover:ring-text-muted/70'
)}
style={{ width: CELL_SIZE, height: CELL_SIZE }}
title={photo.filename}
>
+ {isInActiveHeap && (
+
+ )}
+ {isActive && (
+
+ )}
)
})}
@@ -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 */}
Flag
@@ -519,7 +519,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
}
>
- {isInActiveHeap ? 'Picked' : 'Pick'}
+ {isInActiveHeap ? 'Selected' : 'Select'}
updateMutation.mutate({ is_discarded: !isDiscarded })}
diff --git a/frontend/src/components/timeline/PhotoThumbnail.tsx b/frontend/src/components/timeline/PhotoThumbnail.tsx
index a562429..791114b 100644
--- a/frontend/src/components/timeline/PhotoThumbnail.tsx
+++ b/frontend/src/components/timeline/PhotoThumbnail.tsx
@@ -1,10 +1,8 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import {
Star,
- ShoppingBasket,
Trash2,
RefreshCw,
- Check,
Copy,
AlertTriangle,
Users,
@@ -73,11 +71,9 @@ interface PhotoThumbnailProps {
* thumbnails stay at the explicit `size`. */
fill?: boolean
isSelected: boolean
- /** True when the photo belongs to the currently active heap. */
+ /** True when the photo belongs to the currently active heap — drives
+ * the green tint overlay. */
isInActiveHeap?: boolean
- /** Name of the active heap. When set + isInActiveHeap, the basket
- * badge expands into a name chip so the user knows which heap. */
- activeHeapName?: string | null
onClick: (e: React.MouseEvent) => void
onDoubleClick?: (e: React.MouseEvent) => void
}
@@ -88,7 +84,6 @@ export function PhotoThumbnail({
fill = false,
isSelected,
isInActiveHeap = false,
- activeHeapName = null,
onClick,
onDoubleClick,
}: PhotoThumbnailProps) {
@@ -203,12 +198,27 @@ export function PhotoThumbnail({
return (
)}
+ {/* Tint overlays — heap membership (green) and selection (blue)
+ * composite on top of the image. They can stack: a photo that's
+ * both selected AND in the active heap shows both tints. Never
+ * tinted on hover so the keyboard-driven selection stays
+ * distinguishable from the mouse-driven hover. */}
+ {isInActiveHeap && (
+
+ )}
+ {isSelected && (
+
+ )}
+
{/* ── Ornaments ────────────────────────────────────────────────────
* All overlays compose the THUMB_BADGE_* classes so they share one
* shape/size/ring family. Colour signals semantics:
@@ -315,27 +337,12 @@ export function PhotoThumbnail({
)}
- {/* TL — selection */}
- {isSelected && (
-
-
-
- )}
-
- {/* TL offset — owner badge for shared photos. Sits below the
- * selection check so both can show simultaneously. */}
+ {/* TL — owner badge for shared photos. Selection itself is
+ * conveyed by the ring/outline on the wrapper, no badge needed. */}
{photo.owner_username && (
)}
- {/* BR — flags stack: heap (primary) · duplicate / discard (neutral).
- * Heap is the only user-state flag here so it gets primary; the
- * rest are metadata about the file, so they're neutral-dark. */}
+ {/* BR — duplicate / discard (neutral). Heap membership is shown
+ * via the green tint overlay above, no badge here. */}
- {isInActiveHeap && (
-
-
- {activeHeapName && {activeHeapName} }
-
- )}
{photo.is_duplicate && (
{
+ if (didAutoFocusRef.current) return
if (viewMode !== 'grid') return
- if (activePhotoId) return
+ if (activePhotoId) {
+ didAutoFocusRef.current = true
+ return
+ }
if (photos.length === 0) return
+ didAutoFocusRef.current = true
selectPhoto(photos[0].id)
}, [viewMode, activePhotoId, photos, selectPhoto])
- // Membership in the active heap (for the basket affordance). Subscribed
- // once at this level so we don't have hundreds of thumbnails each
- // subscribing to the same query.
- const { memberIds: activeHeapMembers, activeHeap } = useActiveHeapMembers()
- const activeHeapName = activeHeap?.name ?? null
+ // Membership in the active heap (drives the green tint on each
+ // thumbnail). Subscribed once at this level so we don't have hundreds
+ // of thumbnails each subscribing to the same query.
+ const { memberIds: activeHeapMembers } = useActiveHeapMembers()
// Build the flat virtualizer items: a mix of group headers and rows of
// photos. Date headers appear only in the main timeline (groupBy='date').
@@ -783,7 +788,6 @@ export function Timeline() {
fill
isSelected={selectedPhotos.includes(photo.id)}
isInActiveHeap={activeHeapMembers.has(photo.id)}
- activeHeapName={activeHeapName}
onClick={(e) => {
if (e.shiftKey) {
selectRange(photo.id)
diff --git a/frontend/src/hooks/useKeyboardShortcuts.ts b/frontend/src/hooks/useKeyboardShortcuts.ts
index 44bacfc..fd749eb 100644
--- a/frontend/src/hooks/useKeyboardShortcuts.ts
+++ b/frontend/src/hooks/useKeyboardShortcuts.ts
@@ -6,7 +6,7 @@ import { HEAPS_QUERY_KEY } from './useHeapsQuery'
import { toast } from '../components/ToastContainer'
import { registerUndoable, useUndoStore } from '../store/undoStore'
import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
-import { stripPhotosFromCache } from './usePhotosQuery'
+import type { Photo } from '../types/photo'
interface KeyboardShortcutsProps {
onToggleLeftSidebar: () => void
@@ -77,16 +77,61 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
onError: (e: any) => toast.error('Bulk color failed', e.message || 'Unknown error'),
})
- const bulkDiscardMutation = useMutation({
- mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids),
- onSuccess: invalidatePhotoQueries,
- onError: (e: any) => toast.error('Bulk discard failed', e.message || 'Unknown error'),
- })
+ /** Flip the cached photos to is_discarded=value in every list query
+ * without removing them. Lets the grid grey them in place instead of
+ * reflowing — they stay until a hard reload, which gives the user
+ * visual context for the undo. */
+ const markCachedDiscarded = (ids: string[], discarded: boolean) => {
+ const set = new Set(ids)
+ queryClient.setQueriesData
({ queryKey: ['photos'] }, (prev) =>
+ prev
+ ? prev.map((p) => (set.has(p.id) ? { ...p, is_discarded: discarded } : p))
+ : prev
+ )
+ }
- const bulkRestoreMutation = useMutation({
- mutationFn: (ids: string[]) => photosApi.bulkRestore(ids),
- onSuccess: invalidatePhotoQueries,
- onError: (e: any) => toast.error('Bulk restore failed', e.message || 'Unknown error'),
+ /** Look up a photo's CURRENT cached state (is_discarded etc) without
+ * triggering a refetch. Walks every ['photos', _] entry first
+ * (timeline lists), then falls back to the per-photo cache. */
+ const findCachedPhoto = (id: string): Photo | undefined => {
+ const entries = queryClient.getQueriesData({ queryKey: ['photos'] })
+ for (const [, list] of entries) {
+ if (!list) continue
+ const p = list.find((x) => x.id === id)
+ if (p) return p
+ }
+ return queryClient.getQueryData(['photo', id])
+ }
+
+ // Discard / restore mutation — the only path that doesn't auto-
+ // invalidate ['photos']. Invalidating would refetch with the active
+ // filter (which excludes discarded photos in every section except
+ // Discarded, and vice-versa) and yank the just-changed photos off
+ // the screen. We want the opposite: the photos stay where they are,
+ // re-tinted via PhotoThumbnail's `is_discarded` styling, until the
+ // user reloads. Same goes for the preview filmstrip.
+ const discardMutation = useMutation({
+ mutationFn: ({ ids, discarded }: { ids: string[]; discarded: boolean }) =>
+ ids.length === 1
+ ? photosApi.update(ids[0], { is_discarded: discarded })
+ : discarded
+ ? photosApi.bulkDiscard(ids)
+ : photosApi.bulkRestore(ids),
+ onMutate: ({ ids, discarded }) => markCachedDiscarded(ids, discarded),
+ onError: (e: any, { ids, discarded }) => {
+ // Roll back the optimistic flip.
+ markCachedDiscarded(ids, !discarded)
+ toast.error(
+ discarded ? 'Discard failed' : 'Restore failed',
+ e?.message || 'Unknown error'
+ )
+ },
+ onSuccess: (_data, { ids }) => {
+ queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
+ ids.forEach((id) =>
+ queryClient.invalidateQueries({ queryKey: ['photo', id] })
+ )
+ },
})
/** The set of photo ids the next culling action should apply to.
@@ -109,44 +154,43 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
const ids = cullTargets()
if (ids.length === 0) return
- // Discard is a removal from the timeline view: yank it from the
- // selection / cache before the network round-trip so the grid
- // reflows immediately and the next photo takes over the active
- // cursor. Restore goes through the same removal path because it
- // only fires from views (discard pile) where restored photos no
- // longer match the filter.
+ // Discard / restore: leave the photos in place and just flip the
+ // tint via the dedicated mutation (no cache strip, no timeline
+ // removal). They disappear on hard reload because the section
+ // filter excludes them in the wrong direction.
if (data.is_discarded === true || data.is_discarded === false) {
- usePhotoStore.getState().removePhotosFromTimeline(ids)
- stripPhotosFromCache(queryClient, ids)
- }
-
- if (ids.length === 1) {
- const id = ids[0]
- updateMutation.mutate(
- { id, data },
+ const discarded = data.is_discarded
+ discardMutation.mutate(
+ { ids, discarded },
{
onSuccess: () => {
- // Only the discard/restore subset of single-photo updates is
- // undoable today — rating and color round-trip cleanly enough
- // that the manual fix is faster than maintaining per-photo
- // previous-value snapshots.
- if (data.is_discarded === true) {
- registerUndoable('Discarded 1 photo', async () => {
- await photosApi.bulkRestore([id])
- invalidatePhotoQueries()
- })
- } else if (data.is_discarded === false) {
- registerUndoable('Restored 1 photo', async () => {
- await photosApi.bulkDiscard([id])
- invalidatePhotoQueries()
- })
- }
+ const verb = discarded ? 'Discarded' : 'Restored'
+ registerUndoable(
+ `${verb} ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
+ async () => {
+ markCachedDiscarded(ids, !discarded)
+ await (discarded
+ ? photosApi.bulkRestore(ids)
+ : photosApi.bulkDiscard(ids))
+ queryClient.invalidateQueries({
+ queryKey: LIBRARY_STATS_QUERY_KEY,
+ })
+ ids.forEach((id) =>
+ queryClient.invalidateQueries({ queryKey: ['photo', id] })
+ )
+ }
+ )
},
}
)
return
}
+ if (ids.length === 1) {
+ updateMutation.mutate({ id: ids[0], data })
+ return
+ }
+
// Multi-selection — fan out to the right bulk endpoint per field.
if (data.rating !== undefined) {
bulkRatingMutation.mutate({ ids, rating: data.rating })
@@ -154,34 +198,20 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
if (data.color_label !== undefined) {
bulkColorMutation.mutate({ ids, color: data.color_label })
}
- if (data.is_discarded === true) {
- bulkDiscardMutation.mutate(ids, {
- onSuccess: () => {
- registerUndoable(
- `Discarded ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
- async () => {
- await photosApi.bulkRestore(ids)
- invalidatePhotoQueries()
- }
- )
- },
- })
- } else if (data.is_discarded === false) {
- bulkRestoreMutation.mutate(ids, {
- onSuccess: () => {
- registerUndoable(
- `Restored ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
- async () => {
- await photosApi.bulkDiscard(ids)
- invalidatePhotoQueries()
- }
- )
- },
- })
- }
}
- // P key (Pick): toggle the current selection's membership in the active
+ /** X toggles the discard flag based on the FIRST target's current
+ * state — so pressing X on a photo that's already discarded restores
+ * it. Mirrors the way Lightroom's flag-toggle works for selections. */
+ const toggleDiscardOnTargets = () => {
+ const ids = cullTargets()
+ if (ids.length === 0) return
+ const first = findCachedPhoto(ids[0])
+ const willDiscard = !(first?.is_discarded ?? false)
+ updateActive({ is_discarded: willDiscard })
+ }
+
+ // S key (Select): toggle the current selection's membership in the active
// heap. If every selected photo is already a member, remove them; otherwise
// add the missing ones. No active heap → toast hint.
const heapMutation = useMutation({
@@ -251,7 +281,7 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
? [state.activePhotoId]
: []
if (ids.length === 0) {
- toast.info('Nothing selected', 'Select photos first, then press P')
+ toast.info('Nothing selected', 'Select photos first, then press S')
return
}
const heapsList = queryClient.getQueryData(HEAPS_QUERY_KEY) ?? []
@@ -342,12 +372,12 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
useHotkeys('0', () => updateActive({ rating: 0 }), HK_OPTS)
- // P (Pick) is unified with "add to active heap" — Pick a photo and you're
- // adding it to the heap you set as active. Toggling on already-picked
- // photos removes them from the heap.
- useHotkeys('p', togglePickOnSelection, HK_OPTS)
+ // S (Select) is unified with "add to active heap" — Select a photo and
+ // you're adding it to the heap you set as active. Toggling on already-
+ // selected photos removes them from the heap.
+ useHotkeys('s', togglePickOnSelection, HK_OPTS)
- useHotkeys('x', () => updateActive({ is_discarded: true }), HK_OPTS)
+ useHotkeys('x', toggleDiscardOnTargets, HK_OPTS)
useHotkeys('u', () => updateActive({ is_discarded: false }), HK_OPTS)
diff --git a/frontend/src/hooks/useScanActivity.ts b/frontend/src/hooks/useScanActivity.ts
new file mode 100644
index 0000000..159d00e
--- /dev/null
+++ b/frontend/src/hooks/useScanActivity.ts
@@ -0,0 +1,64 @@
+import { useQuery } from '@tanstack/react-query'
+import { library, type WorkerStatus } from '../services/api'
+
+interface ScanStatus {
+ is_scanning: boolean
+ current_folder?: string
+ processed_files: number
+ total_files: number
+ errors: string[]
+}
+
+/** Pending tasks waiting in a queue. We deliberately ignore worker
+ * `active`/`reserved` counts here because they can stay non-zero
+ * briefly after a queue drains (workers hold prefetched tasks) and
+ * would otherwise leave the spinner running with nothing to do. */
+function pendingTasks(ws: WorkerStatus | undefined): number {
+ if (!ws) return 0
+ return Object.values(ws.queues ?? {}).reduce((a, b) => a + b, 0)
+}
+
+/** Tasks actively being executed by workers right now. Distinct from
+ * pendingTasks so we can drop the spinner the moment no real work is
+ * in flight, even if reserved tasks linger. */
+function activeTasks(ws: WorkerStatus | undefined): number {
+ if (!ws) return 0
+ return (
+ ws.workers?.reduce((sum, w) => sum + (w.active ?? 0), 0) ?? 0
+ )
+}
+
+/**
+ * Read the current background-activity status (filesystem scan +
+ * processing queues) so consumers can render a small inline spinner
+ * instead of the old bottom-right popover. Shares query keys with
+ * ScanProgress so polling stays deduplicated.
+ */
+export function useScanActivity() {
+ const { data: scanStatus } = useQuery({
+ queryKey: ['scan-status'],
+ queryFn: () => library.scanStatus(),
+ refetchInterval: (query) =>
+ query.state.data?.is_scanning ? 2000 : 10000,
+ })
+ const { data: workerStatus } = useQuery({
+ queryKey: ['worker-status-progress'],
+ queryFn: () => library.maintenance.workerStatus(),
+ refetchInterval: (query) => {
+ const data = query.state.data
+ return pendingTasks(data) + activeTasks(data) > 0 ? 3000 : 15000
+ },
+ })
+
+ const isScanning = scanStatus?.is_scanning ?? false
+ const isProcessing =
+ pendingTasks(workerStatus) + activeTasks(workerStatus) > 0
+
+ return {
+ active: isScanning || isProcessing,
+ isScanning,
+ isProcessing,
+ /** Path of the folder currently being scanned, when known. */
+ currentFolder: scanStatus?.current_folder ?? null,
+ }
+}
diff --git a/frontend/src/store/photoStore.ts b/frontend/src/store/photoStore.ts
index d1d8e5a..b68adb5 100644
--- a/frontend/src/store/photoStore.ts
+++ b/frontend/src/store/photoStore.ts
@@ -136,6 +136,7 @@ export const usePhotoStore = create((set) => ({
clearSelection: () => set({
selectedPhotos: [],
rangeStartId: null,
+ activePhotoId: null,
}),
setActivePhoto: (id) => set({ activePhotoId: id }),