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:
2026-04-14 23:45:36 +02:00
parent a6eb406052
commit 8529771122
17 changed files with 501 additions and 408 deletions

View File

@@ -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() {
<Timeline />
)}
</div>
{!isSettings && <KeyboardHints />}
{!isSettings && viewMode !== 'preview' && <KeyboardHints />}
</div>
{/* Right Sidebar */}

View File

@@ -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>
)
}

View File

@@ -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 (
<div
className={clsx(
'fixed bottom-4 right-4 z-40 overflow-hidden rounded-lg border border-border bg-surface shadow-xl transition-all duration-300',
isMinimized ? 'w-12' : 'w-80'
)}
>
{/* Header */}
<div
className="flex cursor-pointer items-center justify-between bg-surface-2 px-3 py-2"
onClick={() => setIsMinimized(!isMinimized)}
>
<div className="flex items-center gap-2">
{phase === 'scanning' ? (
<Loader2 className="h-4 w-4 animate-spin text-primary" />
) : phase === 'processing' ? (
<Sparkles className="h-4 w-4 animate-pulse text-amber-400" />
) : isComplete && !hasErrors ? (
<Check className="h-4 w-4 text-pick" />
) : hasErrors ? (
<AlertCircle className="h-4 w-4 text-reject" />
) : (
<FolderOpen className="h-4 w-4 text-text-muted" />
)}
{!isMinimized && (
<span className="text-sm font-medium text-text">
{phase === 'scanning'
? 'Scanning Folders'
: phase === 'processing'
? 'Processing Photos'
: isComplete
? 'Complete'
: 'Status'}
</span>
)}
</div>
{!isMinimized && (
<button
onClick={(e) => {
e.stopPropagation()
setIsVisible(false)
}}
className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
>
<X className="h-3 w-3" />
</button>
)}
</div>
{/* Content */}
{!isMinimized && (
<div className="p-3">
{/* Scan phase */}
{phase === 'scanning' && scanStatus && (
<>
{scanStatus.current_folder && (
<div className="mb-2 text-xs text-text-muted">
<span className="font-mono">{scanStatus.current_folder}</span>
</div>
)}
<div className="mb-2">
<div className="h-1.5 overflow-hidden rounded-full bg-surface-offset">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${scanProgress}%` }}
/>
</div>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-text-muted">
{scanStatus.processed_files} / {scanStatus.total_files || '?'} files
</span>
<span className="font-medium text-primary">
{Math.round(scanProgress)}%
</span>
</div>
</>
)}
{/* Processing phase */}
{phase === 'processing' && workerStatus && (
<>
<div className="mb-2 flex items-center gap-2 text-xs text-text-muted">
<Brain className="h-3.5 w-3.5 text-amber-400" />
<span>Analyzing photos&hellip;</span>
</div>
<div className="space-y-1 text-xs">
{visionActive > 0 && (
<div className="flex items-center justify-between">
<span className="text-text-muted">Vision pipeline</span>
<span className="font-mono text-amber-400">{visionActive} queued</span>
</div>
)}
{(totalActive - visionActive) > 0 && (
<div className="flex items-center justify-between">
<span className="text-text-muted">Other tasks</span>
<span className="font-mono text-text-muted">{totalActive - visionActive} queued</span>
</div>
)}
</div>
<div className="mt-2 text-[11px] text-text-muted/60">
Thumbnails, embeddings, faces, tags &mdash; runs in background
</div>
</>
)}
{/* Done phase */}
{phase === 'idle' && isComplete && (
<div className="flex items-center gap-2 text-xs text-pick">
<Check className="h-3.5 w-3.5" />
<span>All processing complete</span>
</div>
)}
{/* Errors */}
{hasErrors && (
<div className="mt-2 max-h-20 overflow-y-auto rounded bg-reject/10 p-2">
<div className="text-xs text-reject">
{scanStatus!.errors.slice(0, 3).map((error, i) => (
<div key={i} className="truncate">
{error}
</div>
))}
{scanStatus!.errors.length > 3 && (
<div className="mt-1 text-text-muted">
+{scanStatus!.errors.length - 3} more errors
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
)
}
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 {

View File

@@ -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({
</FilterPill>
{/* 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 && (
<FilterPill
label="Flag"
value={flagValue}
isActive={flagActive}
onClear={() => setFlag('any')}
onClear={() => {
setFlag('any')
setNeedsReview(false)
}}
>
<div className="flex flex-col gap-1">
<button
onClick={() => 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
</button>
<button
onClick={() => 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
</button>
<button
onClick={() => 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({
<AlertTriangle className="h-3 w-3" />
Date issues
</button>
<button
onClick={() => {
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)"
>
<AlertTriangle className="h-3 w-3" />
Needs review
</button>
</div>
</FilterPill>
)}
{/* Needs review — binary toggle */}
<button
onClick={() => 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)"
>
<AlertTriangle className="h-3 w-3" />
Needs review
</button>
{/* Tags */}
{allTags.length > 0 && (
<FilterPill

View File

@@ -92,8 +92,14 @@ export function ActiveHeapCard() {
}}
>
{visible.length === 0 ? (
<div className="flex h-full items-center justify-center px-2 text-center text-[11px] text-text-faint">
Pick photos with P to fill the heap
<div className="flex h-full items-center justify-center px-2 text-center">
<span className="rounded bg-black/70 px-2 py-1 text-[11px] font-medium text-white shadow-sm backdrop-blur-sm">
Select photos with{' '}
<kbd className="rounded bg-white/20 px-1 font-mono text-[10px] text-white">
S
</kbd>{' '}
to fill the heap
</span>
</div>
) : (
<div className="relative h-full">

View File

@@ -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 && (
<span
className="ml-1 flex-shrink-0 rounded-full bg-primary/25 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-primary"
title="Active heap — Pick (P) and the basket badge on photos point here"
title="Active heap — Select (S) and the basket badge on photos point here"
>
Active
</span>

View File

@@ -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<Set<string>>(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() {
</span>
)}
{/* 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 (
<span
className="ml-1 inline-block h-2.5 w-2.5 flex-shrink-0 animate-spin rounded-full border border-primary/30 border-t-primary"
aria-label="Background activity in progress"
/>
)
})()}
{/* 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). */}

View File

@@ -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() {
}
>
<ShoppingBasket className="h-3 w-3" />
{allMembers ? 'Picked' : 'Pick'}
{allMembers ? 'Selected' : 'Select'}
</button>
<button
onClick={() => bulkDiscardMutation.mutate(selectedPhotos)}

View File

@@ -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<HTMLButtonElement>(null)
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
useEffect(() => {
activeRef.current?.scrollIntoView({
@@ -26,22 +28,28 @@ export function PreviewFilmstrip({ photos, currentIndex, onSelect }: PreviewFilm
<div className="flex h-24 shrink-0 items-center gap-1 overflow-x-auto border-t border-border bg-surface px-2 py-2">
{photos.map((photo, index) => {
const isActive = index === currentIndex
const isInActiveHeap = activeHeapMembers.has(photo.id)
return (
<button
key={photo.id}
ref={isActive ? activeRef : null}
onClick={() => 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}
>
<FilmstripThumb photo={photo} />
{isInActiveHeap && (
<div className="pointer-events-none absolute inset-0 bg-emerald-500/40" />
)}
{isActive && (
<div className="pointer-events-none absolute inset-0 bg-blue-500/50" />
)}
</button>
)
})}
@@ -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'
)}
/>
</>

View File

@@ -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 (
<div className="flex flex-1 items-center justify-center bg-black">
<div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black">
<video
key={photo.id}
src={getVideoSrc(photo)}
controls
autoPlay
muted
className="max-h-full max-w-full"
className="max-h-full max-w-full object-contain"
/>
</div>
)

View File

@@ -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<HTMLDivElement>(null)
const previouslyFocusedRef = useRef<HTMLElement | null>(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<string | null>(
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() {
<PreviewImage photo={currentPhoto} />
{/* 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. */}
<div className="pointer-events-none absolute inset-x-0 bottom-24 z-10">
<KeyboardHints />
</div>
<PreviewFilmstrip
photos={photos}
currentIndex={safeIndex}

View File

@@ -494,7 +494,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
</div>
</div>
{/* Flag — Pick + Discard */}
{/* Flag — Select + Discard */}
<div>
<label className="mb-1 block text-xs text-text-muted">Flag</label>
<div className="flex gap-2">
@@ -519,7 +519,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
}
>
<ShoppingBasket className="h-3 w-3" />
{isInActiveHeap ? 'Picked' : 'Pick'}
{isInActiveHeap ? 'Selected' : 'Select'}
</button>
<button
onClick={() => updateMutation.mutate({ is_discarded: !isDiscarded })}

View File

@@ -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 (
<div
className={clsx(
'group relative cursor-pointer overflow-hidden rounded-sm transition-all duration-200',
// Two-tone hover ring: bright primary inner + dark offset so it
// pops on light AND dark photos.
'hover:ring-2 hover:ring-primary/60 hover:ring-offset-1 hover:ring-offset-bg',
'group relative cursor-pointer overflow-hidden rounded-sm will-change-transform',
// Animate INTO selection (springy ease-in over 300ms); snap
// back instantly when deselected by dropping the transition
// entirely. Heap membership doesn't animate — it just paints
// the green tint overlay.
isSelected &&
'ring-2 ring-primary ring-offset-2 ring-offset-bg shadow-lg',
'transition-[transform,box-shadow] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)]',
// Hover is a subtle preview; selection/heap-membership are the
// prominent ring + tint states. They must read differently
// because keyboard nav drives selection while the mouse drives
// hover — the two can land on different photos at the same
// time, and the user needs to tell which one is actually
// selected.
!isSelected && !isInActiveHeap &&
'hover:ring-1 hover:ring-text-muted/70 hover:ring-offset-1 hover:ring-offset-bg',
isSelected &&
'scale-90 ring-2 ring-blue-500 ring-offset-2 ring-offset-bg',
// Active-heap membership signals via the green tint overlay
// only — no ring, no scale, no ornament. The grid layout stays
// intact and the photo just gets a green wash. When BOTH apply,
// selection's blue ring takes over.
!imageLoaded && 'bg-surface animate-pulse'
)}
style={
@@ -271,6 +281,18 @@ export function PhotoThumbnail({
</div>
)}
{/* 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 && (
<div className="pointer-events-none absolute inset-0 bg-emerald-500/40" />
)}
{isSelected && (
<div className="pointer-events-none absolute inset-0 bg-blue-500/50" />
)}
{/* ── 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({
</div>
)}
{/* TL — selection */}
{isSelected && (
<div
className={clsx(
'absolute left-1 top-1',
THUMB_BADGE_BASE,
THUMB_BADGE_SQUARE,
THUMB_BADGE_PRIMARY
)}
>
<Check className={THUMB_BADGE_ICON} strokeWidth={3} />
</div>
)}
{/* 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 && (
<div
className={clsx(
'absolute left-1',
isSelected ? 'top-7' : 'top-1',
'absolute left-1 top-1',
THUMB_BADGE_BASE,
THUMB_BADGE_NEUTRAL,
'max-w-[90px]'
@@ -374,22 +381,9 @@ export function PhotoThumbnail({
</div>
)}
{/* 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. */}
<div className="absolute bottom-1 right-1 flex items-center gap-1">
{isInActiveHeap && (
<div
className={clsx(THUMB_BADGE_BASE, THUMB_BADGE_PRIMARY, 'max-w-[120px]')}
title={activeHeapName ? `In heap: ${activeHeapName}` : 'In active heap'}
>
<ShoppingBasket
className={clsx(THUMB_BADGE_ICON, 'flex-shrink-0')}
strokeWidth={2.5}
/>
{activeHeapName && <span className="truncate">{activeHeapName}</span>}
</div>
)}
{photo.is_duplicate && (
<div
className={clsx(THUMB_BADGE_BASE, THUMB_BADGE_SQUARE, THUMB_BADGE_NEUTRAL)}

View File

@@ -182,21 +182,26 @@ export function Timeline() {
const prevViewModeRef = useRef(viewMode)
// Auto-focus the first photo on initial grid load so arrow-key nav
// works immediately without a pre-click. Only fires when there's no
// current active photo — we never clobber the user's selection or
// the one they restored by navigating back from preview.
// works immediately without a pre-click. One-shot — after the user
// explicitly clears the selection (Escape), we don't re-focus, so
// the metadata sidebar can collapse and stay collapsed.
const didAutoFocusRef = useRef(false)
useEffect(() => {
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)

View File

@@ -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<Photo[]>({ 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<Photo[]>({ 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>(['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<Heap[]>(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)

View File

@@ -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<ScanStatus>({
queryKey: ['scan-status'],
queryFn: () => library.scanStatus(),
refetchInterval: (query) =>
query.state.data?.is_scanning ? 2000 : 10000,
})
const { data: workerStatus } = useQuery<WorkerStatus>({
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,
}
}

View File

@@ -136,6 +136,7 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
clearSelection: () => set({
selectedPhotos: [],
rangeStartId: null,
activePhotoId: null,
}),
setActivePhoto: (id) => set({ activePhotoId: id }),