2 Commits

Author SHA1 Message Date
e51b93d59e perf: speed up settings dialog + relocate settings entry point
- Parallelize the six celery inspect.*() calls in /library/maintenance/
  worker-status via asyncio.gather + to_thread, and drop per-call
  timeout from 1.0s to 0.5s. Endpoint goes from ~6.1s to ~0.54s — it
  was the sole bottleneck on opening the Settings dialog.
- SettingsDialog now fetches through React Query with enabled:isOpen,
  so reopening shows cached data instantly while a background refetch
  updates. Worker polling moved to refetchInterval. Loading spinners
  only show when there's no cached data yet, so background refetches
  don't keep them spinning.
- Move the Settings entry point from the TopBar to a pinned row at the
  bottom of the LeftSidebar so it sits alongside the other library
  controls. TopBar no longer takes onOpenSettings.
- Remove the "Scan all folders" bottom action from LeftSidebar — the
  same control already lives in Settings → Library → Re-scan.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:31:52 +02:00
d6c667ae78 feat: persistent metadata panel, symmetric sidebar toggles, keyboard nav scroll
- Right sidebar stays open by default and shows an empty state when
  nothing is selected, instead of auto-hiding on deselect.
- Both sidebars now have a collapse button in their header and an
  expand button in the TopBar that only appears when collapsed, so
  each panel has a discoverable affordance in either state.
- Arrow-key navigation auto-scrolls the destination row into view
  with a ~35% peek margin, cueing the user that there's more content
  in the scroll direction.
- Fix: the width sentinel's measurement effect never installed its
  ResizeObserver when Timeline first rendered the loading state (ref
  was null, empty-dep effect didn't re-run), so containerWidth stuck
  at 0 and the grid fell back to 4 columns × 200px forever. Switched
  to a callback ref that attaches the observer the moment the
  sentinel actually mounts.
- KeyboardHints surface the Tab (library) and I (info) shortcuts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:20:22 +02:00
8 changed files with 315 additions and 172 deletions

View File

@@ -285,16 +285,31 @@ async def get_worker_status(db: AsyncSession = Depends(get_db)):
import redis as _redis import redis as _redis
# ----- Celery inspect (workers + active tasks) ------------------------- # ----- Celery inspect (workers + active tasks) -------------------------
# Each inspect.* call is a separate broadcast-and-wait with its own
# timeout, so running them serially multiplies the wait. Fan them out
# to threads and gather, collapsing 6 × timeout into ~1 × timeout.
# Timeout dropped to 0.5s — a responsive worker answers within a few
# ms; anything past that is effectively "not responding" for the
# purposes of a settings dashboard.
import asyncio
workers: list[dict] = [] workers: list[dict] = []
inspect_error: Optional[str] = None inspect_error: Optional[str] = None
try: try:
inspect = celery_app.control.inspect(timeout=1.0) inspect = celery_app.control.inspect(timeout=0.5)
ping = inspect.ping() or {} ping, active, reserved, scheduled, stats, active_queues = await asyncio.gather(
active = inspect.active() or {} asyncio.to_thread(inspect.ping),
reserved = inspect.reserved() or {} asyncio.to_thread(inspect.active),
scheduled = inspect.scheduled() or {} asyncio.to_thread(inspect.reserved),
stats = inspect.stats() or {} asyncio.to_thread(inspect.scheduled),
active_queues = inspect.active_queues() or {} asyncio.to_thread(inspect.stats),
asyncio.to_thread(inspect.active_queues),
)
ping = ping or {}
active = active or {}
reserved = reserved or {}
scheduled = scheduled or {}
stats = stats or {}
active_queues = active_queues or {}
worker_names = set(ping) | set(active) | set(stats) worker_names = set(ping) | set(active) | set(stats)
for name in sorted(worker_names): for name in sorted(worker_names):

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useState } from 'react'
import { Timeline } from './components/timeline/Timeline' import { Timeline } from './components/timeline/Timeline'
import { LeftSidebar } from './components/layout/LeftSidebar' import { LeftSidebar } from './components/layout/LeftSidebar'
import { RightSidebar } from './components/layout/RightSidebar' import { RightSidebar } from './components/layout/RightSidebar'
@@ -18,9 +18,8 @@ import { usePhotosQuery } from './hooks/usePhotosQuery'
function App() { function App() {
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true) const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
const [rightSidebarOpen, setRightSidebarOpen] = useState(false) const [rightSidebarOpen, setRightSidebarOpen] = useState(true)
const [settingsOpen, setSettingsOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false)
const selectedPhotos = usePhotoStore((state) => state.selectedPhotos)
const viewMode = usePhotoStore((state) => state.viewMode) const viewMode = usePhotoStore((state) => state.viewMode)
// Bidirectional sync of filter store with URL query params. // Bidirectional sync of filter store with URL query params.
@@ -38,24 +37,18 @@ function App() {
getFirstPhotoId: () => allPhotos?.[0]?.id ?? null, getFirstPhotoId: () => allPhotos?.[0]?.id ?? null,
}) })
// Auto-show right sidebar when photos are selected — but only in grid mode, // Right sidebar stays open by default and shows whatever's selected
// so leaving the preview doesn't fight the user's prior sidebar state. // (or an empty state if nothing is). User can still toggle it manually.
// Lives in an effect (not the render body) to avoid setState-during-render
// and the cascading re-renders the audit caught.
useEffect(() => {
if (viewMode !== 'grid') return
if (selectedPhotos.length > 0 && !rightSidebarOpen) {
setRightSidebarOpen(true)
} else if (selectedPhotos.length === 0 && rightSidebarOpen) {
setRightSidebarOpen(false)
}
}, [viewMode, selectedPhotos.length, rightSidebarOpen])
const showRightSidebar = rightSidebarOpen && viewMode === 'grid' const showRightSidebar = rightSidebarOpen && viewMode === 'grid'
return ( return (
<div className="flex flex-col h-screen bg-bg text-text"> <div className="flex flex-col h-screen bg-bg text-text">
<TopBar onOpenSettings={() => setSettingsOpen(true)} /> <TopBar
leftSidebarOpen={leftSidebarOpen}
rightSidebarOpen={showRightSidebar}
onExpandLeft={() => setLeftSidebarOpen(true)}
onExpandRight={() => setRightSidebarOpen(true)}
/>
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Left Sidebar */} {/* Left Sidebar */}
@@ -64,7 +57,10 @@ function App() {
leftSidebarOpen ? 'w-64' : 'w-0' leftSidebarOpen ? 'w-64' : 'w-0'
} overflow-hidden border-r border-border bg-surface`} } overflow-hidden border-r border-border bg-surface`}
> >
<LeftSidebar /> <LeftSidebar
onCollapse={() => setLeftSidebarOpen(false)}
onOpenSettings={() => setSettingsOpen(true)}
/>
</div> </div>
{/* Main column — filter bar, discard bar, timeline. Lives to the {/* Main column — filter bar, discard bar, timeline. Lives to the
@@ -90,7 +86,7 @@ function App() {
showRightSidebar ? 'w-80' : 'w-0' showRightSidebar ? 'w-80' : 'w-0'
} overflow-hidden border-l border-border bg-surface`} } overflow-hidden border-l border-border bg-surface`}
> >
<RightSidebar /> <RightSidebar onCollapse={() => setRightSidebarOpen(false)} />
</div> </div>
</div> </div>

View File

@@ -14,13 +14,14 @@ export function KeyboardHints() {
{ key: 'P', action: 'Pick → heap' }, { key: 'P', action: 'Pick → heap' },
{ key: 'X', action: 'Discard' }, { key: 'X', action: 'Discard' },
{ key: 'Space', action: 'Preview' }, { key: 'Space', action: 'Preview' },
{ key: 'I', action: 'Info panel' },
{ key: 'Esc', action: 'Deselect' }, { key: 'Esc', action: 'Deselect' },
] ]
: [ : [
{ key: '↑↓←→', action: 'Navigate' }, { key: '↑↓←→', action: 'Navigate' },
{ key: 'Click', action: 'Select' },
{ key: 'Shift+Click', action: 'Range' },
{ key: 'Space', action: 'Preview' }, { key: 'Space', action: 'Preview' },
{ key: 'Tab', action: 'Library panel' },
{ key: 'I', action: 'Info panel' },
{ key: '/', action: 'Search' }, { key: '/', action: 'Search' },
] ]

View File

@@ -13,16 +13,21 @@ import {
CheckCircle2, CheckCircle2,
} from 'lucide-react' } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { import {
library, library,
type ThumbnailStats,
type LibraryStats,
type MediaType, type MediaType,
type WorkerStatus,
type MissingStats,
} from '../../services/api' } from '../../services/api'
import { toast } from '../ToastContainer' import { toast } from '../ToastContainer'
// React Query keys for the settings panels. Kept here (not in a shared
// hook module) since they're internal to this dialog and used by the
// runAction refresh step to invalidate after mutations.
const SETTINGS_THUMB_STATS_KEY = ['settings', 'thumbnail-stats'] as const
const SETTINGS_LIB_STATS_KEY = ['settings', 'library-stats'] as const
const SETTINGS_WORKER_STATUS_KEY = ['settings', 'worker-status'] as const
const SETTINGS_MISSING_STATS_KEY = ['settings', 'missing-stats'] as const
interface SettingsDialogProps { interface SettingsDialogProps {
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
@@ -40,70 +45,96 @@ interface SettingsDialogProps {
* dialog opens or after any action completes. * dialog opens or after any action completes.
*/ */
export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
const [thumbStats, setThumbStats] = useState<ThumbnailStats | null>(null) const queryClient = useQueryClient()
const [libStats, setLibStats] = useState<LibraryStats | null>(null)
const [workerStatus, setWorkerStatus] = useState<WorkerStatus | null>(null)
const [missingStats, setMissingStats] = useState<MissingStats | null>(null)
const [loadingStats, setLoadingStats] = useState(false)
const [loadingWorkers, setLoadingWorkers] = useState(false)
const [showAllErrors, setShowAllErrors] = useState(false) const [showAllErrors, setShowAllErrors] = useState(false)
// One key per action so each button has its own spinner without // One key per action so each button has its own spinner without
// blocking the others. // blocking the others.
const [busy, setBusy] = useState<Record<string, boolean>>({}) const [busy, setBusy] = useState<Record<string, boolean>>({})
const refreshStats = useCallback(async () => { // All four panels fetch through React Query so cached data shows
setLoadingStats(true) // instantly on reopen while a background refetch updates the numbers.
try { // `enabled: isOpen` avoids firing requests while the dialog is closed,
const [thumbs, lib] = await Promise.all([ // but the cache entries survive between opens (default gcTime = 5m).
library.maintenance.thumbnailStats(), const thumbStatsQuery = useQuery({
library.stats(), queryKey: SETTINGS_THUMB_STATS_KEY,
]) queryFn: library.maintenance.thumbnailStats,
setThumbStats(thumbs) enabled: isOpen,
setLibStats(lib) // Treat as stale immediately so reopening the dialog triggers a
} catch (e) { // background refetch on top of the cached view.
console.error('Failed to load settings stats', e) staleTime: 0,
toast.error('Could not load library stats') })
} finally { const libStatsQuery = useQuery({
setLoadingStats(false) queryKey: SETTINGS_LIB_STATS_KEY,
} queryFn: library.stats,
}, []) enabled: isOpen,
staleTime: 0,
})
// Worker status polls every 5s while the dialog is open — `refetchInterval`
// replaces the old setInterval loop. Missing-stats is relatively cheap
// but shares the same 5s rhythm to keep the orphan banner live.
const workerStatusQuery = useQuery({
queryKey: SETTINGS_WORKER_STATUS_KEY,
queryFn: library.maintenance.workerStatus,
enabled: isOpen,
refetchInterval: isOpen ? 5000 : false,
staleTime: 0,
})
const missingStatsQuery = useQuery({
queryKey: SETTINGS_MISSING_STATS_KEY,
queryFn: library.maintenance.missingStats,
enabled: isOpen,
refetchInterval: isOpen ? 5000 : false,
staleTime: 0,
})
const refreshWorkers = useCallback(async () => { const thumbStats = thumbStatsQuery.data
setLoadingWorkers(true) const libStats = libStatsQuery.data
try { const workerStatus = workerStatusQuery.data
const [ws, ms] = await Promise.all([ const missingStats = missingStatsQuery.data
library.maintenance.workerStatus(), // "loading" in the UI sense = fetching AND no cached data yet. Background
library.maintenance.missingStats(), // refetches on top of cached data shouldn't flip the refresh spinners.
]) const loadingStats =
setWorkerStatus(ws) (thumbStatsQuery.isFetching && !thumbStatsQuery.data) ||
setMissingStats(ms) (libStatsQuery.isFetching && !libStatsQuery.data)
} catch (e) { const loadingWorkers =
console.error('Failed to load worker status', e) (workerStatusQuery.isFetching && !workerStatusQuery.data) ||
toast.error('Could not load worker status') (missingStatsQuery.isFetching && !missingStatsQuery.data)
} finally {
setLoadingWorkers(false)
}
}, [])
// Esc closes; load stats when opened. Workers section auto-polls const refreshStats = useCallback(() => {
// every 5s while the dialog is open so the user sees live worker queryClient.invalidateQueries({ queryKey: SETTINGS_THUMB_STATS_KEY })
// activity without manually hammering the refresh button. queryClient.invalidateQueries({ queryKey: SETTINGS_LIB_STATS_KEY })
}, [queryClient])
const refreshWorkers = useCallback(() => {
queryClient.invalidateQueries({ queryKey: SETTINGS_WORKER_STATUS_KEY })
queryClient.invalidateQueries({ queryKey: SETTINGS_MISSING_STATS_KEY })
}, [queryClient])
// Surface fetch errors once (React Query de-dupes retries but we still
// want a single toast so the user knows something went wrong).
useEffect(() => {
if (!isOpen) return
if (thumbStatsQuery.error || libStatsQuery.error) {
console.error('Failed to load settings stats', thumbStatsQuery.error ?? libStatsQuery.error)
toast.error('Could not load library stats')
}
}, [isOpen, thumbStatsQuery.error, libStatsQuery.error])
useEffect(() => {
if (!isOpen) return
if (workerStatusQuery.error || missingStatsQuery.error) {
console.error('Failed to load worker status', workerStatusQuery.error ?? missingStatsQuery.error)
toast.error('Could not load worker status')
}
}, [isOpen, workerStatusQuery.error, missingStatsQuery.error])
// Esc closes.
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return
refreshStats()
refreshWorkers()
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose() if (e.key === 'Escape') onClose()
} }
window.addEventListener('keydown', handler) window.addEventListener('keydown', handler)
const poll = window.setInterval(() => { return () => window.removeEventListener('keydown', handler)
refreshWorkers() }, [isOpen, onClose])
}, 5000)
return () => {
window.removeEventListener('keydown', handler)
window.clearInterval(poll)
}
}, [isOpen, onClose, refreshStats, refreshWorkers])
const runAction = useCallback( const runAction = useCallback(
async <T,>( async <T,>(
@@ -117,7 +148,8 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
try { try {
const result = await fn() const result = await fn()
toast.success(successTitle, describe?.(result)) toast.success(successTitle, describe?.(result))
await Promise.all([refreshStats(), refreshWorkers()]) refreshStats()
refreshWorkers()
} catch (e: unknown) { } catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e) const message = e instanceof Error ? e.message : String(e)
toast.error(`${successTitle} failed`, message) toast.error(`${successTitle} failed`, message)

View File

@@ -8,15 +8,16 @@ import {
Star, Star,
Trash2, Trash2,
HardDrive, HardDrive,
RefreshCw,
Copy, Copy,
Tag as TagIcon, Tag as TagIcon,
Layers2, Layers2,
MoreHorizontal, MoreHorizontal,
Pencil, Pencil,
PanelLeftClose,
Settings,
} from 'lucide-react' } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import { sourceFolders, library, photos as photosApi, type FolderTreeNode } from '../../services/api' import { sourceFolders, photos as photosApi, type FolderTreeNode } from '../../services/api'
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from '../ToastContainer' import { toast } from '../ToastContainer'
import { useFilterStore } from '../../store/filterStore' import { useFilterStore } from '../../store/filterStore'
@@ -41,9 +42,13 @@ interface TreeItem {
type?: 'folder' | 'heap' | 'special' type?: 'folder' | 'heap' | 'special'
} }
export function LeftSidebar() { interface LeftSidebarProps {
onCollapse: () => void
onOpenSettings: () => void
}
export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps'])) const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
const [isScanning, setIsScanning] = useState(false)
// 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.
const [renamingId, setRenamingId] = useState<string | null>(null) const [renamingId, setRenamingId] = useState<string | null>(null)
@@ -304,30 +309,6 @@ export function LeftSidebar() {
toast.error('Delete failed', e?.response?.data?.detail || e.message || 'Unknown error'), toast.error('Delete failed', e?.response?.data?.detail || e.message || 'Unknown error'),
}) })
// Mutation for scanning all folders
const scanLibraryMutation = useMutation({
mutationFn: library.scan,
onMutate: () => {
setIsScanning(true)
toast.info('Scan Started', 'Scanning all folders for new photos...')
},
onSuccess: () => {
toast.success('Scan Complete', 'All folders have been scanned')
},
onError: (error: any) => {
toast.error('Scan Failed', error.message || 'Failed to scan folders')
},
onSettled: () => {
setIsScanning(false)
// Refetch photos after scan
queryClient.invalidateQueries({ queryKey: ['photos'] })
},
})
const handleScanAll = () => {
scanLibraryMutation.mutate()
}
const toggleExpanded = (id: string) => { const toggleExpanded = (id: string) => {
const newExpanded = new Set(expandedItems) const newExpanded = new Set(expandedItems)
if (newExpanded.has(id)) { if (newExpanded.has(id)) {
@@ -674,25 +655,37 @@ export function LeftSidebar() {
return ( return (
<div className="flex h-full flex-col bg-surface"> <div className="flex h-full flex-col bg-surface">
{/* Header with collapse button. Matches the right sidebar header
* so both panels have symmetric affordances. */}
<div className="flex h-11 flex-shrink-0 items-center justify-between border-b border-border px-4">
<h2 className="text-sm font-semibold text-text">Library</h2>
<button
onClick={onCollapse}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
title="Collapse panel (Tab)"
aria-label="Collapse panel"
>
<PanelLeftClose className="h-4 w-4" />
</button>
</div>
{/* Tree View */} {/* Tree View */}
<div className="flex-1 overflow-y-auto py-2"> <div className="flex-1 overflow-y-auto py-2">
{libraryTree.map((item) => renderTreeItem(item))} {libraryTree.map((item) => renderTreeItem(item))}
<HeapsPanel /> <HeapsPanel />
</div> </div>
{/* Bottom Actions */} {/* Settings entry point — pinned to the bottom of the panel so it
{folderTree.length > 0 && ( * sits out of the way of the library tree but is always reachable. */}
<div className="border-t border-border p-3"> <div className="border-t border-border p-2">
<button <button
onClick={handleScanAll} onClick={onOpenSettings}
disabled={isScanning} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm text-text-muted hover:bg-surface-2 hover:text-text"
className="flex w-full items-center gap-2 rounded bg-surface-2 px-3 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50" title="Settings"
> >
<RefreshCw className={clsx('h-4 w-4', isScanning && 'animate-spin')} /> <Settings className="h-4 w-4" />
{isScanning ? 'Scanning...' : 'Scan all folders'} Settings
</button> </button>
</div> </div>
)}
<DeleteFolderDialog <DeleteFolderDialog
isOpen={!!deletingFolder} isOpen={!!deletingFolder}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { X, Star, Info, ShoppingBasket, Trash2, Plus } from 'lucide-react' import { X, Star, Info, ShoppingBasket, Trash2, Plus, PanelRightClose } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { usePhotoStore } from '../../store/photoStore' import { usePhotoStore } from '../../store/photoStore'
@@ -21,7 +21,11 @@ import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
* - 2+ photos selected → renders a slim bulk-action panel that fans out * - 2+ photos selected → renders a slim bulk-action panel that fans out
* rating / color / discard / pick across the entire selection. * rating / color / discard / pick across the entire selection.
*/ */
export function RightSidebar() { interface RightSidebarProps {
onCollapse: () => void
}
export function RightSidebar({ onCollapse }: RightSidebarProps) {
const { selectedPhotos, activePhotoId, clearSelection } = usePhotoStore() const { selectedPhotos, activePhotoId, clearSelection } = usePhotoStore()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -134,14 +138,53 @@ export function RightSidebar() {
}, },
}) })
// Unified header rendered in every branch so the collapse button is
// always reachable regardless of selection state. Title and the
// clear-selection X adapt to what's selected.
const headerTitle =
selectedPhotos.length === 0
? 'Metadata'
: selectedPhotos.length === 1
? 'Metadata'
: `${selectedPhotos.length} Photos Selected`
const Header = () => (
<div className="flex h-11 flex-shrink-0 items-center justify-between border-b border-border px-4">
<h2 className="text-sm font-semibold text-text">{headerTitle}</h2>
<div className="flex items-center gap-1">
{selectedPhotos.length > 0 && (
<button
onClick={clearSelection}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear selection (Esc)"
aria-label="Clear selection"
>
<X className="h-4 w-4" />
</button>
)}
<button
onClick={onCollapse}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
title="Collapse panel (I)"
aria-label="Collapse panel"
>
<PanelRightClose className="h-4 w-4" />
</button>
</div>
</div>
)
if (selectedPhotos.length === 0) { if (selectedPhotos.length === 0) {
return ( return (
<div className="flex h-full items-center justify-center p-4 text-center"> <div className="flex h-full flex-col bg-surface">
<Header />
<div className="flex flex-1 items-center justify-center p-4 text-center">
<div className="text-text-muted"> <div className="text-text-muted">
<Info className="mx-auto mb-2 h-8 w-8" /> <Info className="mx-auto mb-2 h-8 w-8" />
<p className="text-sm">Select photos to view details</p> <p className="text-sm">Select photos to view details</p>
</div> </div>
</div> </div>
</div>
) )
} }
@@ -150,17 +193,7 @@ export function RightSidebar() {
const id = activePhotoId ?? selectedPhotos[0] const id = activePhotoId ?? selectedPhotos[0]
return ( return (
<div className="flex h-full flex-col bg-surface"> <div className="flex h-full flex-col bg-surface">
<div className="flex h-11 flex-shrink-0 items-center justify-between border-b border-border px-4"> <Header />
<h2 className="text-sm font-semibold text-text">Metadata</h2>
<button
onClick={clearSelection}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear selection"
aria-label="Clear selection"
>
<X className="h-4 w-4" />
</button>
</div>
<PhotoInfoPanel photoId={id} /> <PhotoInfoPanel photoId={id} />
</div> </div>
) )
@@ -171,18 +204,7 @@ export function RightSidebar() {
return ( return (
<div className="flex h-full flex-col bg-surface"> <div className="flex h-full flex-col bg-surface">
<div className="flex items-center justify-between border-b border-border px-4 py-3"> <Header />
<h2 className="text-sm font-semibold text-text">
{selectedPhotos.length} Photos Selected
</h2>
<button
onClick={clearSelection}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear selection"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-3 border-b border-border p-4"> <div className="space-y-3 border-b border-border p-4">
<p className="text-xs text-text-muted"> <p className="text-xs text-text-muted">

View File

@@ -1,30 +1,57 @@
import { Settings } from 'lucide-react' import { PanelLeftOpen, PanelRightOpen } from 'lucide-react'
import muliLogo from '../../assets/muli-logo.png' import muliLogo from '../../assets/muli-logo.png'
interface TopBarProps { interface TopBarProps {
onOpenSettings: () => void leftSidebarOpen: boolean
rightSidebarOpen: boolean
onExpandLeft: () => void
onExpandRight: () => void
} }
/** /**
* Slim top bar — logo on the left, settings gear on the right. The * Slim top bar — logo on the left, settings gear on the right. The
* active heap badge moved into the Heaps panel in the left sidebar * active heap badge moved into the Heaps panel in the left sidebar
* (where it actually relates to the heap rows the user navigates to). * (where it actually relates to the heap rows the user navigates to).
*
* Also hosts the "expand sidebar" affordances: when a side panel is
* collapsed, a small panel-open icon appears on the corresponding edge
* so the user has a way to bring it back without hunting for the
* keyboard shortcut. When the panel is open, the button hides — its
* collapse twin lives in the panel's own header.
*/ */
export function TopBar({ onOpenSettings }: TopBarProps) { export function TopBar({
leftSidebarOpen,
rightSidebarOpen,
onExpandLeft,
onExpandRight,
}: TopBarProps) {
return ( return (
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4"> <header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!leftSidebarOpen && (
<button
onClick={onExpandLeft}
className="rounded p-1.5 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
title="Expand panel (Tab)"
aria-label="Expand left panel"
>
<PanelLeftOpen className="h-4 w-4" />
</button>
)}
<img src={muliLogo} alt="Mulimago" className="h-7 w-7 object-contain" /> <img src={muliLogo} alt="Mulimago" className="h-7 w-7 object-contain" />
<h1 className="text-lg font-semibold text-text">Mulimago</h1> <h1 className="text-lg font-semibold text-text">Mulimago</h1>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!rightSidebarOpen && (
<button <button
onClick={onOpenSettings} onClick={onExpandRight}
className="rounded p-1.5 text-text-muted transition-colors hover:bg-surface-2 hover:text-text" className="rounded p-1.5 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
title="Settings" title="Expand panel (I)"
aria-label="Expand right panel"
> >
<Settings className="h-4 w-4" /> <PanelRightOpen className="h-4 w-4" />
</button> </button>
)}
</div> </div>
</header> </header>
) )

View File

@@ -1,4 +1,4 @@
import { useRef, useEffect, useMemo, useState } from 'react' import { useRef, useEffect, useMemo, useState, useCallback } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual' import { useVirtualizer } from '@tanstack/react-virtual'
import { format, parseISO } from 'date-fns' import { format, parseISO } from 'date-fns'
import { usePhotoStore } from '../../store/photoStore' import { usePhotoStore } from '../../store/photoStore'
@@ -167,7 +167,6 @@ export function Timeline() {
// because parentRef has padding and we'd otherwise have to subtract // because parentRef has padding and we'd otherwise have to subtract
// it (and account for any scrollbar) — easy to get wrong by a pixel // it (and account for any scrollbar) — easy to get wrong by a pixel
// and end up with a column count off by one. // and end up with a column count off by one.
const widthSentinelRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0) const [containerWidth, setContainerWidth] = useState(0)
const { const {
@@ -295,17 +294,35 @@ export function Timeline() {
// resize, and any layout change driven by the sidebar collapse / // resize, and any layout change driven by the sidebar collapse /
// right panel toggle. ResizeObserver picks up everything window // right panel toggle. ResizeObserver picks up everything window
// resize misses (sidebar collapse doesn't fire window resize). // resize misses (sidebar collapse doesn't fire window resize).
useEffect(() => { //
const el = widthSentinelRef.current // Uses a callback ref (not useRef + useEffect) because Timeline
// early-returns a loading/empty state before the sentinel exists,
// so a mount-only effect would see a null ref and never install
// the observer. The callback ref fires whenever the sentinel
// actually attaches, which is the moment we can measure it.
const roRef = useRef<ResizeObserver | null>(null)
const measureElRef = useRef<HTMLDivElement | null>(null)
const widthSentinelRef = useCallback((el: HTMLDivElement | null) => {
roRef.current?.disconnect()
roRef.current = null
measureElRef.current = el
if (!el) return if (!el) return
const measure = () => setContainerWidth(el.clientWidth) const measure = () => setContainerWidth(el.clientWidth)
measure() measure()
const ro = new ResizeObserver(measure) const ro = new ResizeObserver(measure)
ro.observe(el) ro.observe(el)
window.addEventListener('resize', measure) roRef.current = ro
}, [])
useEffect(() => {
const onResize = () => {
const el = measureElRef.current
if (el) setContainerWidth(el.clientWidth)
}
window.addEventListener('resize', onResize)
return () => { return () => {
ro.disconnect() window.removeEventListener('resize', onResize)
window.removeEventListener('resize', measure) roRef.current?.disconnect()
roRef.current = null
} }
}, []) }, [])
@@ -318,6 +335,17 @@ export function Timeline() {
[items] [items]
) )
// Parallel array: photoRows[i] corresponds to items[photoRowItemIndex[i]].
// Lets keyboard nav jump the virtualizer to the destination row even when
// it hasn't been rendered yet (beyond the overscan window).
const photoRowItemIndex = useMemo(() => {
const map: number[] = []
items.forEach((it, idx) => {
if (it.type === 'row') map.push(idx)
})
return map
}, [items])
// Flat visible-order id sequence — exactly the order the user reads // Flat visible-order id sequence — exactly the order the user reads
// off the grid (top-to-bottom, left-to-right within each row). // off the grid (top-to-bottom, left-to-right within each row).
// Includes duplicates from tag-grouping; landing on the same photo's // Includes duplicates from tag-grouping; landing on the same photo's
@@ -416,6 +444,35 @@ export function Timeline() {
} else { } else {
selectPhoto(dest.photo.id) selectPhoto(dest.photo.id)
} }
// Bring the destination row into view if it's off-screen, leaving
// a "peek" margin so the next row above/below stays partly visible
// — cues the user that there's more content in the scroll direction.
// In-viewport moves are a no-op, so same-row arrow presses don't
// jitter the scroll position.
const itemIdx = photoRowItemIndex[nextRow]
const scrollEl = parentRef.current
if (itemIdx !== undefined && scrollEl) {
// Sum item heights up to itemIdx to get this row's offset in the
// virtualizer's coordinate space. Cheap enough at O(items) and
// avoids reaching into virtualizer.measurementsCache internals.
let rowTop = 0
for (let i = 0; i < itemIdx; i++) rowTop += items[i].height
const rowHeight = items[itemIdx].height
const peek = Math.round(cellSize * 0.35)
const viewTop = scrollEl.scrollTop
const viewBottom = viewTop + scrollEl.clientHeight
if (rowTop - peek < viewTop) {
// Destination is above (or flush with) the viewport top. Leave
// `peek` pixels of the previous row visible above it.
scrollEl.scrollTo({ top: Math.max(0, rowTop - peek) })
} else if (rowTop + rowHeight + peek > viewBottom) {
// Destination is below the viewport bottom. Leave `peek` pixels
// of the next row visible below it.
scrollEl.scrollTo({
top: rowTop + rowHeight + peek - scrollEl.clientHeight,
})
}
}
} }
switch (e.key) { switch (e.key) {
@@ -455,7 +512,7 @@ export function Timeline() {
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewMode, photoRows, photos, selectedPhotos, activePhotoId]) }, [viewMode, photoRows, photos, selectedPhotos, activePhotoId, photoRowItemIndex, items, cellSize])
if (isLoading) { if (isLoading) {
return ( return (