ui: rework selection/heap visuals, contextual shortcut hints, inline scan activity
- Selection now reads as a blue ring + tint with a springy scale-down, hover stays a subtle gray ring so keyboard-driven and mouse-driven states are tellable apart. - Heap membership is signalled with a green tint only (no badge, no ring, no scale). - Discard/restore is optimistic and non-yanking: photos stay in the grid greyed out until the next reload, X toggles based on the current state, and the same treatment applies in preview. - Filmstrip mirrors the grid styling (selection blue, heap green, discarded grey). - Preview close restores the LAST viewed photo as the focused/selected one in the grid. - Right sidebar collapses on view change and re-opens when a photo is in focus; Esc clears active selection so the panel collapses too. - Keyboard hints panel is context-aware (grid / preview / discarded section), collapsible with H, persisted, and rendered inside the preview column above the filmstrip. - "Pick (P)" renamed to "Select (S)" everywhere. - Needs review moved into the Flag pill dropdown. - Fixed vertical videos overflowing the preview column (min-h-0). - Replaced the bottom-right ScanProgress popover with an inline spinner next to the FOLDERS sidebar header (and on the specific folder row being scanned). ScanProgress is now a headless invalidator; useScanActivity exposes the live status. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,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 */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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…</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 — 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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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). */}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
64
frontend/src/hooks/useScanActivity.ts
Normal file
64
frontend/src/hooks/useScanActivity.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,7 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
|
||||
clearSelection: () => set({
|
||||
selectedPhotos: [],
|
||||
rangeStartId: null,
|
||||
activePhotoId: null,
|
||||
}),
|
||||
|
||||
setActivePhoto: (id) => set({ activePhotoId: id }),
|
||||
|
||||
Reference in New Issue
Block a user