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 { Timeline } from './components/timeline/Timeline'
|
||||||
import { DuplicatesView } from './components/duplicates/DuplicatesView'
|
import { DuplicatesView } from './components/duplicates/DuplicatesView'
|
||||||
import { MapView } from './components/map/MapView'
|
import { MapView } from './components/map/MapView'
|
||||||
@@ -29,8 +29,24 @@ function MainApp() {
|
|||||||
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
|
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
|
||||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(true)
|
const [rightSidebarOpen, setRightSidebarOpen] = useState(true)
|
||||||
const viewMode = usePhotoStore((state) => state.viewMode)
|
const viewMode = usePhotoStore((state) => state.viewMode)
|
||||||
|
const activePhotoId = usePhotoStore((state) => state.activePhotoId)
|
||||||
const currentSection = useFilterStore((s) => s.currentSection)
|
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.
|
// Bidirectional sync of filter store with URL query params.
|
||||||
useFilterUrlSync()
|
useFilterUrlSync()
|
||||||
|
|
||||||
@@ -101,7 +117,7 @@ function MainApp() {
|
|||||||
<Timeline />
|
<Timeline />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!isSettings && <KeyboardHints />}
|
{!isSettings && viewMode !== 'preview' && <KeyboardHints />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Sidebar */}
|
{/* 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 { 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() {
|
export function KeyboardHints() {
|
||||||
const selectedCount = usePhotoStore((state) => state.selectedPhotos.length)
|
const selectedCount = usePhotoStore((s) => s.selectedPhotos.length)
|
||||||
const viewMode = usePhotoStore((state) => state.viewMode)
|
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
|
const [collapsed, setCollapsed] = useState(
|
||||||
// would just be confusing. Hide them.
|
() => typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1'
|
||||||
if (viewMode === 'preview') return null
|
)
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, collapsed ? '1' : '0')
|
||||||
|
}, [collapsed])
|
||||||
|
|
||||||
const hints = selectedCount > 0
|
// `H` toggles the panel. `?` (shift+/) collides with the global `/`
|
||||||
? [
|
// search shortcut, so we use a plain letter instead.
|
||||||
{ key: '1-5', action: 'Rate' },
|
useHotkeys('h', () => setCollapsed((c) => !c), { preventDefault: true })
|
||||||
{ key: 'P', action: 'Pick → heap' },
|
|
||||||
{ key: 'X', action: 'Discard' },
|
const hints = getHints({ selectedCount, currentSection, viewMode })
|
||||||
{ key: 'Space', action: 'Preview' },
|
|
||||||
{ key: 'I', action: 'Info panel' },
|
// Nothing relevant to show — hide entirely.
|
||||||
{ key: 'Esc', action: 'Deselect' },
|
if (hints.length === 0) return null
|
||||||
]
|
|
||||||
: [
|
|
||||||
{ key: '↑↓←→', action: 'Navigate' },
|
|
||||||
{ key: 'Space', action: 'Preview' },
|
|
||||||
{ key: 'Tab', action: 'Library panel' },
|
|
||||||
{ key: 'I', action: 'Info panel' },
|
|
||||||
{ key: '/', action: 'Search' },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Absolute (not fixed) so the parent's flex/position context can
|
<div className="pointer-events-none absolute bottom-0 left-1/2 z-30 -translate-x-1/2 pb-4">
|
||||||
// center it relative to the timeline area, not the viewport. Mount
|
{collapsed ? (
|
||||||
// inside the main column in App.tsx so it isn't offset by the
|
// Collapsed handle: a small pill peeking from the bottom so the
|
||||||
// sidebar widths.
|
// user can re-open the panel without remembering the shortcut.
|
||||||
<div className="pointer-events-none absolute bottom-4 left-1/2 z-30 -translate-x-1/2">
|
<button
|
||||||
{/* Near-opaque dark pill so the hints stay legible against busy
|
type="button"
|
||||||
* thumbnails. The previous bg-surface/40 + 5% white ring left
|
onClick={() => setCollapsed(false)}
|
||||||
* text washed out when a bright photo sat directly behind it. */}
|
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"
|
||||||
<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">
|
title="Show shortcuts (H)"
|
||||||
{hints.map((hint, i) => (
|
>
|
||||||
<div key={i} className="flex items-center gap-1.5">
|
<ChevronUp className="h-3 w-3" />
|
||||||
<kbd className="rounded bg-white/15 px-1.5 py-0.5 text-[11px] font-medium text-white shadow-sm">
|
Shortcuts
|
||||||
{hint.key}
|
<kbd className="rounded bg-white/15 px-1 py-0.5 text-[10px] font-medium text-white">
|
||||||
</kbd>
|
H
|
||||||
<span className="whitespace-nowrap text-xs text-white/85">
|
</kbd>
|
||||||
{hint.action}
|
</button>
|
||||||
</span>
|
) : (
|
||||||
{i < hints.length - 1 && (
|
<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>
|
<span className="ml-1 text-white/30">•</span>
|
||||||
)}
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
<button
|
||||||
{selectedCount > 0 && (
|
type="button"
|
||||||
<>
|
onClick={() => setCollapsed(true)}
|
||||||
<span className="text-white/30">•</span>
|
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"
|
||||||
<span className="whitespace-nowrap text-xs font-semibold text-primary">
|
title="Hide shortcuts (H)"
|
||||||
{selectedCount} selected
|
>
|
||||||
</span>
|
<kbd className="rounded bg-white/15 px-1 py-0.5 text-[10px] font-medium text-white">
|
||||||
</>
|
H
|
||||||
)}
|
</kbd>
|
||||||
</div>
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { FolderOpen, Loader2, Check, AlertCircle, X, Brain, Sparkles } from 'lucide-react'
|
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { library, WorkerStatus } from '../services/api'
|
import { library, WorkerStatus } from '../services/api'
|
||||||
import clsx from 'clsx'
|
|
||||||
|
|
||||||
interface ScanStatus {
|
interface ScanStatus {
|
||||||
is_scanning: boolean
|
is_scanning: boolean
|
||||||
@@ -14,9 +12,14 @@ interface ScanStatus {
|
|||||||
|
|
||||||
type Phase = 'idle' | 'scanning' | 'processing' | 'done'
|
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() {
|
export function ScanProgress() {
|
||||||
const [isVisible, setIsVisible] = useState(false)
|
|
||||||
const [isMinimized, setIsMinimized] = useState(false)
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const wasScanningRef = useRef(false)
|
const wasScanningRef = useRef(false)
|
||||||
const wasProcessingRef = useRef(false)
|
const wasProcessingRef = useRef(false)
|
||||||
@@ -43,7 +46,6 @@ export function ScanProgress() {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const visionActive = visionQueued(workerStatus)
|
|
||||||
const totalActive = totalQueued(workerStatus)
|
const totalActive = totalQueued(workerStatus)
|
||||||
|
|
||||||
const phase: Phase = isScanning
|
const phase: Phase = isScanning
|
||||||
@@ -54,18 +56,11 @@ export function ScanProgress() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phase === 'scanning') {
|
if (phase === 'scanning') {
|
||||||
setIsVisible(true)
|
|
||||||
setIsMinimized(false)
|
|
||||||
wasScanningRef.current = true
|
wasScanningRef.current = true
|
||||||
wasProcessingRef.current = false
|
wasProcessingRef.current = false
|
||||||
} else if (phase === 'processing') {
|
} 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
|
wasProcessingRef.current = true
|
||||||
|
|
||||||
if (wasScanningRef.current) {
|
if (wasScanningRef.current) {
|
||||||
// Scan just finished — invalidate data caches.
|
|
||||||
wasScanningRef.current = false
|
wasScanningRef.current = false
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||||
@@ -76,7 +71,6 @@ export function ScanProgress() {
|
|||||||
}
|
}
|
||||||
} else if (phase === 'idle') {
|
} else if (phase === 'idle') {
|
||||||
if (wasScanningRef.current) {
|
if (wasScanningRef.current) {
|
||||||
// Scan finished with no queued processing (small import).
|
|
||||||
wasScanningRef.current = false
|
wasScanningRef.current = false
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||||
@@ -86,172 +80,15 @@ export function ScanProgress() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
|
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
|
||||||
}
|
}
|
||||||
if (wasProcessingRef.current) {
|
if (wasProcessingRef.current) {
|
||||||
// Processing just drained — refresh tags (new clusters/objects).
|
|
||||||
wasProcessingRef.current = false
|
wasProcessingRef.current = false
|
||||||
queryClient.invalidateQueries({ queryKey: ['tags'] })
|
queryClient.invalidateQueries({ queryKey: ['tags'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
|
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
|
||||||
}
|
}
|
||||||
if (isVisible) {
|
|
||||||
setTimeout(() => setIsVisible(false), 3000)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [phase, isVisible, queryClient])
|
}, [phase, queryClient])
|
||||||
|
|
||||||
if (!isVisible) return null
|
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function totalQueued(ws: WorkerStatus | undefined): number {
|
function totalQueued(ws: WorkerStatus | undefined): number {
|
||||||
|
|||||||
@@ -132,12 +132,14 @@ export function FilterBar({
|
|||||||
const colorActive = colorLabel !== null
|
const colorActive = colorLabel !== null
|
||||||
const colorValue = colorActive ? colorLabel : null
|
const colorValue = colorActive ? colorLabel : null
|
||||||
|
|
||||||
const flagActive = flag !== 'any'
|
const flagActive = flag !== 'any' || needsReview
|
||||||
const flagValue = flagActive
|
const flagValue = needsReview
|
||||||
? flag === 'date_warning'
|
? 'needs review'
|
||||||
? 'date issues'
|
: flag !== 'any'
|
||||||
: flag
|
? flag === 'date_warning'
|
||||||
: null
|
? 'date issues'
|
||||||
|
: flag
|
||||||
|
: null
|
||||||
|
|
||||||
const tagActive = tagIds.length > 0
|
const tagActive = tagIds.length > 0
|
||||||
const activeTagNames = allTags
|
const activeTagNames = allTags
|
||||||
@@ -301,20 +303,29 @@ export function FilterBar({
|
|||||||
</FilterPill>
|
</FilterPill>
|
||||||
|
|
||||||
{/* Flag — hidden in the Discarded section, where the flag is
|
{/* 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 && (
|
{!hideFlagPill && (
|
||||||
<FilterPill
|
<FilterPill
|
||||||
label="Flag"
|
label="Flag"
|
||||||
value={flagValue}
|
value={flagValue}
|
||||||
isActive={flagActive}
|
isActive={flagActive}
|
||||||
onClear={() => setFlag('any')}
|
onClear={() => {
|
||||||
|
setFlag('any')
|
||||||
|
setNeedsReview(false)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setFlag('any')}
|
onClick={() => {
|
||||||
|
setFlag('any')
|
||||||
|
setNeedsReview(false)
|
||||||
|
}}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'rounded px-2 py-1 text-left text-xs transition-colors',
|
'rounded px-2 py-1 text-left text-xs transition-colors',
|
||||||
flag === 'any'
|
flag === 'any' && !needsReview
|
||||||
? 'bg-primary text-white'
|
? 'bg-primary text-white'
|
||||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
|
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
|
||||||
)}
|
)}
|
||||||
@@ -322,7 +333,10 @@ export function FilterBar({
|
|||||||
Any
|
Any
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setFlag('discarded')}
|
onClick={() => {
|
||||||
|
setFlag('discarded')
|
||||||
|
setNeedsReview(false)
|
||||||
|
}}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'rounded px-2 py-1 text-left text-xs transition-colors',
|
'rounded px-2 py-1 text-left text-xs transition-colors',
|
||||||
flag === 'discarded'
|
flag === 'discarded'
|
||||||
@@ -333,7 +347,10 @@ export function FilterBar({
|
|||||||
Discarded
|
Discarded
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setFlag('date_warning')}
|
onClick={() => {
|
||||||
|
setFlag('date_warning')
|
||||||
|
setNeedsReview(false)
|
||||||
|
}}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex items-center gap-1.5 rounded px-2 py-1 text-left text-xs transition-colors',
|
'flex items-center gap-1.5 rounded px-2 py-1 text-left text-xs transition-colors',
|
||||||
flag === 'date_warning'
|
flag === 'date_warning'
|
||||||
@@ -345,25 +362,26 @@ export function FilterBar({
|
|||||||
<AlertTriangle className="h-3 w-3" />
|
<AlertTriangle className="h-3 w-3" />
|
||||||
Date issues
|
Date issues
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</FilterPill>
|
</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 */}
|
{/* Tags */}
|
||||||
{allTags.length > 0 && (
|
{allTags.length > 0 && (
|
||||||
<FilterPill
|
<FilterPill
|
||||||
|
|||||||
@@ -92,8 +92,14 @@ export function ActiveHeapCard() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{visible.length === 0 ? (
|
{visible.length === 0 ? (
|
||||||
<div className="flex h-full items-center justify-center px-2 text-center text-[11px] text-text-faint">
|
<div className="flex h-full items-center justify-center px-2 text-center">
|
||||||
Pick photos with P to fill the heap
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative h-full">
|
<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',
|
'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',
|
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
||||||
// Active-heap row gets a soft primary wash so the
|
// 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.
|
// when viewing a different section.
|
||||||
isActive && !isFiltered && 'bg-primary/8 text-text',
|
isActive && !isFiltered && 'bg-primary/8 text-text',
|
||||||
isDropTarget && 'ring-2 ring-primary bg-primary/10'
|
isDropTarget && 'ring-2 ring-primary bg-primary/10'
|
||||||
@@ -350,7 +350,7 @@ export function HeapsPanel() {
|
|||||||
{isActive && (
|
{isActive && (
|
||||||
<span
|
<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"
|
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
|
Active
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { UploadModal } from '../upload/UploadModal'
|
|||||||
import { useSharedFoldersQuery } from '../../hooks/useSharingQueries'
|
import { useSharedFoldersQuery } from '../../hooks/useSharingQueries'
|
||||||
import { useAuth } from '../../contexts/AuthContext'
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
import { useFeaturesQuery } from '../../hooks/useFeaturesQuery'
|
import { useFeaturesQuery } from '../../hooks/useFeaturesQuery'
|
||||||
|
import { useScanActivity } from '../../hooks/useScanActivity'
|
||||||
|
|
||||||
interface TreeItem {
|
interface TreeItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -61,10 +62,15 @@ interface TreeItem {
|
|||||||
/** For folder rows only: the user-set "hide from views" flag. Drives
|
/** For folder rows only: the user-set "hide from views" flag. Drives
|
||||||
* the muted styling + eye-off badge + menu item label. */
|
* the muted styling + eye-off badge + menu item label. */
|
||||||
isHidden?: boolean
|
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() {
|
export function LeftSidebar() {
|
||||||
const { user, isAdmin, logout } = useAuth()
|
const { user, isAdmin, logout } = useAuth()
|
||||||
|
const scanActivity = useScanActivity()
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
|
||||||
// Inline rename state for source-root rows. Stores the id being edited
|
// Inline rename state for source-root rows. Stores the id being edited
|
||||||
// and the draft name. Double-click a folder row to start.
|
// and the draft name. Double-click a folder row to start.
|
||||||
@@ -405,6 +411,7 @@ export function LeftSidebar() {
|
|||||||
count: node.photo_count,
|
count: node.photo_count,
|
||||||
type: 'folder',
|
type: 'folder',
|
||||||
isHidden: node.is_hidden,
|
isHidden: node.is_hidden,
|
||||||
|
path: node.path,
|
||||||
children: node.children.length > 0
|
children: node.children.length > 0
|
||||||
? node.children.map(folderNodeToTreeItem)
|
? node.children.map(folderNodeToTreeItem)
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -628,6 +635,26 @@ export function LeftSidebar() {
|
|||||||
</span>
|
</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
|
{/* Count Badge — fixed-width slot so counts line up in a column
|
||||||
* across rows regardless of digit count. Section headers skip
|
* across rows regardless of digit count. Section headers skip
|
||||||
* the badge entirely (they're labels, not navigable rows). */}
|
* the badge entirely (they're labels, not navigable rows). */}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export function RightSidebar() {
|
|||||||
const { data: allTags = [] } = useTagsQuery()
|
const { data: allTags = [] } = useTagsQuery()
|
||||||
const [tagInput, setTagInput] = useState('')
|
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 { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
|
||||||
|
|
||||||
const heapMutation = useMutation({
|
const heapMutation = useMutation({
|
||||||
@@ -385,7 +385,7 @@ export function RightSidebar() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ShoppingBasket className="h-3 w-3" />
|
<ShoppingBasket className="h-3 w-3" />
|
||||||
{allMembers ? 'Picked' : 'Pick'}
|
{allMembers ? 'Selected' : 'Select'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => bulkDiscardMutation.mutate(selectedPhotos)}
|
onClick={() => bulkDiscardMutation.mutate(selectedPhotos)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
import { photos as photosApi } from '../../services/api'
|
import { photos as photosApi } from '../../services/api'
|
||||||
|
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||||
|
|
||||||
interface PreviewFilmstripProps {
|
interface PreviewFilmstripProps {
|
||||||
photos: Photo[]
|
photos: Photo[]
|
||||||
@@ -13,6 +14,7 @@ const CELL_SIZE = 72
|
|||||||
|
|
||||||
export function PreviewFilmstrip({ photos, currentIndex, onSelect }: PreviewFilmstripProps) {
|
export function PreviewFilmstrip({ photos, currentIndex, onSelect }: PreviewFilmstripProps) {
|
||||||
const activeRef = useRef<HTMLButtonElement>(null)
|
const activeRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeRef.current?.scrollIntoView({
|
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">
|
<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) => {
|
{photos.map((photo, index) => {
|
||||||
const isActive = index === currentIndex
|
const isActive = index === currentIndex
|
||||||
|
const isInActiveHeap = activeHeapMembers.has(photo.id)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
ref={isActive ? activeRef : null}
|
ref={isActive ? activeRef : null}
|
||||||
onClick={() => onSelect(photo.id)}
|
onClick={() => onSelect(photo.id)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'relative shrink-0 overflow-hidden rounded-sm transition-all',
|
'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)]',
|
||||||
'hover:opacity-100',
|
isActive && 'scale-90 opacity-100 ring-2 ring-blue-500 ring-offset-2 ring-offset-surface',
|
||||||
isActive
|
!isActive && isInActiveHeap && 'opacity-100',
|
||||||
? 'ring-2 ring-primary opacity-100'
|
!isActive && !isInActiveHeap && 'opacity-60 hover:opacity-100 hover:ring-1 hover:ring-text-muted/70'
|
||||||
: 'opacity-60'
|
|
||||||
)}
|
)}
|
||||||
style={{ width: CELL_SIZE, height: CELL_SIZE }}
|
style={{ width: CELL_SIZE, height: CELL_SIZE }}
|
||||||
title={photo.filename}
|
title={photo.filename}
|
||||||
>
|
>
|
||||||
<FilmstripThumb photo={photo} />
|
<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>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -77,7 +85,10 @@ function FilmstripThumb({ photo }: { photo: Photo }) {
|
|||||||
onError={() => setErrored(true)}
|
onError={() => setErrored(true)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'h-full w-full object-cover transition-opacity duration-200',
|
'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 }) {
|
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 (
|
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
|
<video
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
src={getVideoSrc(photo)}
|
src={getVideoSrc(photo)}
|
||||||
controls
|
controls
|
||||||
autoPlay
|
autoPlay
|
||||||
muted
|
muted
|
||||||
className="max-h-full max-w-full"
|
className="max-h-full max-w-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { PreviewImage } from './PreviewImage'
|
|||||||
import { PreviewFilmstrip } from './PreviewFilmstrip'
|
import { PreviewFilmstrip } from './PreviewFilmstrip'
|
||||||
import { getPreviewImageSrc, isVideo } from './previewSrc'
|
import { getPreviewImageSrc, isVideo } from './previewSrc'
|
||||||
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
|
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
|
||||||
|
import { KeyboardHints } from '../KeyboardHints'
|
||||||
|
|
||||||
export function PreviewView() {
|
export function PreviewView() {
|
||||||
const activePhotoId = usePhotoStore((s) => s.activePhotoId)
|
const activePhotoId = usePhotoStore((s) => s.activePhotoId)
|
||||||
@@ -18,25 +19,17 @@ export function PreviewView() {
|
|||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const previouslyFocusedRef = useRef<HTMLElement | null>(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
|
// Track the photo we OPENED preview on as a defensive fallback only.
|
||||||
// store's getState (which is guaranteed to reflect the value the
|
// On close we restore the LAST viewed photo so the grid focus matches
|
||||||
// openPreview action just wrote, even if the React subscription
|
// what the user just saw (filmstrip/arrow nav can land them anywhere).
|
||||||
// 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.
|
|
||||||
const openOriginRef = useRef<string | null>(
|
const openOriginRef = useRef<string | null>(
|
||||||
activePhotoId ?? usePhotoStore.getState().activePhotoId
|
activePhotoId ?? usePhotoStore.getState().activePhotoId
|
||||||
)
|
)
|
||||||
const closePreview = useCallback(() => {
|
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 =
|
const id =
|
||||||
openOriginRef.current ?? usePhotoStore.getState().activePhotoId
|
usePhotoStore.getState().activePhotoId ?? openOriginRef.current
|
||||||
usePhotoStore.setState({
|
usePhotoStore.setState({
|
||||||
viewMode: 'grid',
|
viewMode: 'grid',
|
||||||
activePhotoId: id,
|
activePhotoId: id,
|
||||||
@@ -258,6 +251,13 @@ export function PreviewView() {
|
|||||||
|
|
||||||
<PreviewImage photo={currentPhoto} />
|
<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
|
<PreviewFilmstrip
|
||||||
photos={photos}
|
photos={photos}
|
||||||
currentIndex={safeIndex}
|
currentIndex={safeIndex}
|
||||||
|
|||||||
@@ -494,7 +494,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Flag — Pick + Discard */}
|
{/* Flag — Select + Discard */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-text-muted">Flag</label>
|
<label className="mb-1 block text-xs text-text-muted">Flag</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -519,7 +519,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ShoppingBasket className="h-3 w-3" />
|
<ShoppingBasket className="h-3 w-3" />
|
||||||
{isInActiveHeap ? 'Picked' : 'Pick'}
|
{isInActiveHeap ? 'Selected' : 'Select'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => updateMutation.mutate({ is_discarded: !isDiscarded })}
|
onClick={() => updateMutation.mutate({ is_discarded: !isDiscarded })}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
Star,
|
Star,
|
||||||
ShoppingBasket,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Check,
|
|
||||||
Copy,
|
Copy,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Users,
|
Users,
|
||||||
@@ -73,11 +71,9 @@ interface PhotoThumbnailProps {
|
|||||||
* thumbnails stay at the explicit `size`. */
|
* thumbnails stay at the explicit `size`. */
|
||||||
fill?: boolean
|
fill?: boolean
|
||||||
isSelected: 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
|
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
|
onClick: (e: React.MouseEvent) => void
|
||||||
onDoubleClick?: (e: React.MouseEvent) => void
|
onDoubleClick?: (e: React.MouseEvent) => void
|
||||||
}
|
}
|
||||||
@@ -88,7 +84,6 @@ export function PhotoThumbnail({
|
|||||||
fill = false,
|
fill = false,
|
||||||
isSelected,
|
isSelected,
|
||||||
isInActiveHeap = false,
|
isInActiveHeap = false,
|
||||||
activeHeapName = null,
|
|
||||||
onClick,
|
onClick,
|
||||||
onDoubleClick,
|
onDoubleClick,
|
||||||
}: PhotoThumbnailProps) {
|
}: PhotoThumbnailProps) {
|
||||||
@@ -203,12 +198,27 @@ export function PhotoThumbnail({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'group relative cursor-pointer overflow-hidden rounded-sm transition-all duration-200',
|
'group relative cursor-pointer overflow-hidden rounded-sm will-change-transform',
|
||||||
// Two-tone hover ring: bright primary inner + dark offset so it
|
// Animate INTO selection (springy ease-in over 300ms); snap
|
||||||
// pops on light AND dark photos.
|
// back instantly when deselected by dropping the transition
|
||||||
'hover:ring-2 hover:ring-primary/60 hover:ring-offset-1 hover:ring-offset-bg',
|
// entirely. Heap membership doesn't animate — it just paints
|
||||||
|
// the green tint overlay.
|
||||||
isSelected &&
|
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'
|
!imageLoaded && 'bg-surface animate-pulse'
|
||||||
)}
|
)}
|
||||||
style={
|
style={
|
||||||
@@ -271,6 +281,18 @@ export function PhotoThumbnail({
|
|||||||
</div>
|
</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 ────────────────────────────────────────────────────
|
{/* ── Ornaments ────────────────────────────────────────────────────
|
||||||
* All overlays compose the THUMB_BADGE_* classes so they share one
|
* All overlays compose the THUMB_BADGE_* classes so they share one
|
||||||
* shape/size/ring family. Colour signals semantics:
|
* shape/size/ring family. Colour signals semantics:
|
||||||
@@ -315,27 +337,12 @@ export function PhotoThumbnail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* TL — selection */}
|
{/* TL — owner badge for shared photos. Selection itself is
|
||||||
{isSelected && (
|
* conveyed by the ring/outline on the wrapper, no badge needed. */}
|
||||||
<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. */}
|
|
||||||
{photo.owner_username && (
|
{photo.owner_username && (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'absolute left-1',
|
'absolute left-1 top-1',
|
||||||
isSelected ? 'top-7' : 'top-1',
|
|
||||||
THUMB_BADGE_BASE,
|
THUMB_BADGE_BASE,
|
||||||
THUMB_BADGE_NEUTRAL,
|
THUMB_BADGE_NEUTRAL,
|
||||||
'max-w-[90px]'
|
'max-w-[90px]'
|
||||||
@@ -374,22 +381,9 @@ export function PhotoThumbnail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* BR — flags stack: heap (primary) · duplicate / discard (neutral).
|
{/* BR — duplicate / discard (neutral). Heap membership is shown
|
||||||
* Heap is the only user-state flag here so it gets primary; the
|
* via the green tint overlay above, no badge here. */}
|
||||||
* rest are metadata about the file, so they're neutral-dark. */}
|
|
||||||
<div className="absolute bottom-1 right-1 flex items-center gap-1">
|
<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 && (
|
{photo.is_duplicate && (
|
||||||
<div
|
<div
|
||||||
className={clsx(THUMB_BADGE_BASE, THUMB_BADGE_SQUARE, THUMB_BADGE_NEUTRAL)}
|
className={clsx(THUMB_BADGE_BASE, THUMB_BADGE_SQUARE, THUMB_BADGE_NEUTRAL)}
|
||||||
|
|||||||
@@ -182,21 +182,26 @@ export function Timeline() {
|
|||||||
const prevViewModeRef = useRef(viewMode)
|
const prevViewModeRef = useRef(viewMode)
|
||||||
|
|
||||||
// Auto-focus the first photo on initial grid load so arrow-key nav
|
// 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
|
// works immediately without a pre-click. One-shot — after the user
|
||||||
// current active photo — we never clobber the user's selection or
|
// explicitly clears the selection (Escape), we don't re-focus, so
|
||||||
// the one they restored by navigating back from preview.
|
// the metadata sidebar can collapse and stay collapsed.
|
||||||
|
const didAutoFocusRef = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (didAutoFocusRef.current) return
|
||||||
if (viewMode !== 'grid') return
|
if (viewMode !== 'grid') return
|
||||||
if (activePhotoId) return
|
if (activePhotoId) {
|
||||||
|
didAutoFocusRef.current = true
|
||||||
|
return
|
||||||
|
}
|
||||||
if (photos.length === 0) return
|
if (photos.length === 0) return
|
||||||
|
didAutoFocusRef.current = true
|
||||||
selectPhoto(photos[0].id)
|
selectPhoto(photos[0].id)
|
||||||
}, [viewMode, activePhotoId, photos, selectPhoto])
|
}, [viewMode, activePhotoId, photos, selectPhoto])
|
||||||
|
|
||||||
// Membership in the active heap (for the basket affordance). Subscribed
|
// Membership in the active heap (drives the green tint on each
|
||||||
// once at this level so we don't have hundreds of thumbnails each
|
// thumbnail). Subscribed once at this level so we don't have hundreds
|
||||||
// subscribing to the same query.
|
// of thumbnails each subscribing to the same query.
|
||||||
const { memberIds: activeHeapMembers, activeHeap } = useActiveHeapMembers()
|
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
|
||||||
const activeHeapName = activeHeap?.name ?? null
|
|
||||||
|
|
||||||
// Build the flat virtualizer items: a mix of group headers and rows of
|
// Build the flat virtualizer items: a mix of group headers and rows of
|
||||||
// photos. Date headers appear only in the main timeline (groupBy='date').
|
// photos. Date headers appear only in the main timeline (groupBy='date').
|
||||||
@@ -783,7 +788,6 @@ export function Timeline() {
|
|||||||
fill
|
fill
|
||||||
isSelected={selectedPhotos.includes(photo.id)}
|
isSelected={selectedPhotos.includes(photo.id)}
|
||||||
isInActiveHeap={activeHeapMembers.has(photo.id)}
|
isInActiveHeap={activeHeapMembers.has(photo.id)}
|
||||||
activeHeapName={activeHeapName}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
selectRange(photo.id)
|
selectRange(photo.id)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { HEAPS_QUERY_KEY } from './useHeapsQuery'
|
|||||||
import { toast } from '../components/ToastContainer'
|
import { toast } from '../components/ToastContainer'
|
||||||
import { registerUndoable, useUndoStore } from '../store/undoStore'
|
import { registerUndoable, useUndoStore } from '../store/undoStore'
|
||||||
import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
|
import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
|
||||||
import { stripPhotosFromCache } from './usePhotosQuery'
|
import type { Photo } from '../types/photo'
|
||||||
|
|
||||||
interface KeyboardShortcutsProps {
|
interface KeyboardShortcutsProps {
|
||||||
onToggleLeftSidebar: () => void
|
onToggleLeftSidebar: () => void
|
||||||
@@ -77,16 +77,61 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
onError: (e: any) => toast.error('Bulk color failed', e.message || 'Unknown error'),
|
onError: (e: any) => toast.error('Bulk color failed', e.message || 'Unknown error'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const bulkDiscardMutation = useMutation({
|
/** Flip the cached photos to is_discarded=value in every list query
|
||||||
mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids),
|
* without removing them. Lets the grid grey them in place instead of
|
||||||
onSuccess: invalidatePhotoQueries,
|
* reflowing — they stay until a hard reload, which gives the user
|
||||||
onError: (e: any) => toast.error('Bulk discard failed', e.message || 'Unknown error'),
|
* 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({
|
/** Look up a photo's CURRENT cached state (is_discarded etc) without
|
||||||
mutationFn: (ids: string[]) => photosApi.bulkRestore(ids),
|
* triggering a refetch. Walks every ['photos', _] entry first
|
||||||
onSuccess: invalidatePhotoQueries,
|
* (timeline lists), then falls back to the per-photo cache. */
|
||||||
onError: (e: any) => toast.error('Bulk restore failed', e.message || 'Unknown error'),
|
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.
|
/** The set of photo ids the next culling action should apply to.
|
||||||
@@ -109,44 +154,43 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
const ids = cullTargets()
|
const ids = cullTargets()
|
||||||
if (ids.length === 0) return
|
if (ids.length === 0) return
|
||||||
|
|
||||||
// Discard is a removal from the timeline view: yank it from the
|
// Discard / restore: leave the photos in place and just flip the
|
||||||
// selection / cache before the network round-trip so the grid
|
// tint via the dedicated mutation (no cache strip, no timeline
|
||||||
// reflows immediately and the next photo takes over the active
|
// removal). They disappear on hard reload because the section
|
||||||
// cursor. Restore goes through the same removal path because it
|
// filter excludes them in the wrong direction.
|
||||||
// only fires from views (discard pile) where restored photos no
|
|
||||||
// longer match the filter.
|
|
||||||
if (data.is_discarded === true || data.is_discarded === false) {
|
if (data.is_discarded === true || data.is_discarded === false) {
|
||||||
usePhotoStore.getState().removePhotosFromTimeline(ids)
|
const discarded = data.is_discarded
|
||||||
stripPhotosFromCache(queryClient, ids)
|
discardMutation.mutate(
|
||||||
}
|
{ ids, discarded },
|
||||||
|
|
||||||
if (ids.length === 1) {
|
|
||||||
const id = ids[0]
|
|
||||||
updateMutation.mutate(
|
|
||||||
{ id, data },
|
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Only the discard/restore subset of single-photo updates is
|
const verb = discarded ? 'Discarded' : 'Restored'
|
||||||
// undoable today — rating and color round-trip cleanly enough
|
registerUndoable(
|
||||||
// that the manual fix is faster than maintaining per-photo
|
`${verb} ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
|
||||||
// previous-value snapshots.
|
async () => {
|
||||||
if (data.is_discarded === true) {
|
markCachedDiscarded(ids, !discarded)
|
||||||
registerUndoable('Discarded 1 photo', async () => {
|
await (discarded
|
||||||
await photosApi.bulkRestore([id])
|
? photosApi.bulkRestore(ids)
|
||||||
invalidatePhotoQueries()
|
: photosApi.bulkDiscard(ids))
|
||||||
})
|
queryClient.invalidateQueries({
|
||||||
} else if (data.is_discarded === false) {
|
queryKey: LIBRARY_STATS_QUERY_KEY,
|
||||||
registerUndoable('Restored 1 photo', async () => {
|
})
|
||||||
await photosApi.bulkDiscard([id])
|
ids.forEach((id) =>
|
||||||
invalidatePhotoQueries()
|
queryClient.invalidateQueries({ queryKey: ['photo', id] })
|
||||||
})
|
)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ids.length === 1) {
|
||||||
|
updateMutation.mutate({ id: ids[0], data })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Multi-selection — fan out to the right bulk endpoint per field.
|
// Multi-selection — fan out to the right bulk endpoint per field.
|
||||||
if (data.rating !== undefined) {
|
if (data.rating !== undefined) {
|
||||||
bulkRatingMutation.mutate({ ids, rating: data.rating })
|
bulkRatingMutation.mutate({ ids, rating: data.rating })
|
||||||
@@ -154,34 +198,20 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
if (data.color_label !== undefined) {
|
if (data.color_label !== undefined) {
|
||||||
bulkColorMutation.mutate({ ids, color: data.color_label })
|
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
|
// heap. If every selected photo is already a member, remove them; otherwise
|
||||||
// add the missing ones. No active heap → toast hint.
|
// add the missing ones. No active heap → toast hint.
|
||||||
const heapMutation = useMutation({
|
const heapMutation = useMutation({
|
||||||
@@ -251,7 +281,7 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
? [state.activePhotoId]
|
? [state.activePhotoId]
|
||||||
: []
|
: []
|
||||||
if (ids.length === 0) {
|
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
|
return
|
||||||
}
|
}
|
||||||
const heapsList = queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []
|
const heapsList = queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []
|
||||||
@@ -342,12 +372,12 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
|
|
||||||
useHotkeys('0', () => updateActive({ rating: 0 }), HK_OPTS)
|
useHotkeys('0', () => updateActive({ rating: 0 }), HK_OPTS)
|
||||||
|
|
||||||
// P (Pick) is unified with "add to active heap" — Pick a photo and you're
|
// S (Select) is unified with "add to active heap" — Select a photo and
|
||||||
// adding it to the heap you set as active. Toggling on already-picked
|
// you're adding it to the heap you set as active. Toggling on already-
|
||||||
// photos removes them from the heap.
|
// selected photos removes them from the heap.
|
||||||
useHotkeys('p', togglePickOnSelection, HK_OPTS)
|
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)
|
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({
|
clearSelection: () => set({
|
||||||
selectedPhotos: [],
|
selectedPhotos: [],
|
||||||
rangeStartId: null,
|
rangeStartId: null,
|
||||||
|
activePhotoId: null,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setActivePhoto: (id) => set({ activePhotoId: id }),
|
setActivePhoto: (id) => set({ activePhotoId: id }),
|
||||||
|
|||||||
Reference in New Issue
Block a user