perf+ux: cut grid re-renders, coalesce discard, dedup bulk mutations

Frontend cleanup pass driven by the post-shadcn review.

Performance
- Memoize PhotoThumbnail and route cell click/double-click through
  stable handlers so heap-membership invalidation no longer re-renders
  every visible thumbnail.
- Cap usePhotosQuery's eager background page-walk at 20 pages with a
  50ms inter-page yield — was unbounded (up to 100k photos cold).
- Drop the per-thumbnail loading spinner in favour of the existing
  pulse skeleton; only retry state still surfaces a spinner.

UX
- Coalesce rapid X/U presses into a single undo entry + one toast
  (1.2s window) so accidental bursts are easy to back out.
- Optimistic rating/color updates with per-id snapshot rollback on
  error, matching the existing discard pattern.
- Section-aware empty timeline state with a Clear-all-filters CTA.
- Carry the search-match chip from the grid into the preview header.
- Add a basket-icon badge for active heap membership so the green
  tint isn't the only signal (colorblind-safe).
- Standardise error toasts via formatApiError(): FastAPI detail,
  validation arrays, axios message, with a 'Network Error' filter.

Architecture
- Extract useBulkPhotoMutations and stop duplicating
  bulkRating/bulkColor across RightSidebar and useKeyboardShortcuts.
- Split RightSidebar (714 -> 448 LOC) and PhotoInfoPanel (952 -> 716)
  into co-located sub-components: BulkTakenAtEditor, BulkTagsEditor,
  TagsEditor, TakenAtEditor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 10:13:39 +02:00
parent 7efac4354e
commit e65e798021
18 changed files with 992 additions and 646 deletions

View File

@@ -3,10 +3,12 @@ import { useVirtualizer } from '@tanstack/react-virtual'
import { format, parseISO } from 'date-fns'
import clsx from 'clsx'
import { usePhotoStore } from '../../store/photoStore'
import { useFilterStore } from '../../store/filterStore'
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
import { PhotoThumbnail } from './PhotoThumbnail'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { Button } from '@/components/ui/button'
import { ImageOff } from 'lucide-react'
import type { Photo } from '../../types/photo'
// Layout constants for the grid + grouped headers.
@@ -203,6 +205,27 @@ export function Timeline() {
// of thumbnails each subscribing to the same query.
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
// Stable cell handlers. PhotoThumbnail is wrapped in React.memo so
// identity-stable callbacks let it skip re-render on unrelated store
// churn (e.g. heap membership invalidation). visibleSequence is read
// through a ref at click time so scrolling doesn't rebind the
// double-click handler.
const visibleSequenceRef = useRef<string[]>([])
const handleCellClick = useCallback(
(photo: Photo, e: React.MouseEvent) => {
if (e.shiftKey) selectRange(photo.id)
else if (e.ctrlKey || e.metaKey) togglePhotoSelection(photo.id)
else selectPhoto(photo.id)
},
[selectRange, togglePhotoSelection, selectPhoto],
)
const handleCellDoubleClick = useCallback(
(photo: Photo) => {
openPreview(photo.id, visibleSequenceRef.current)
},
[openPreview],
)
// Build the flat virtualizer items: a mix of group headers and rows of
// photos. Date headers appear only in the main timeline (groupBy='date').
const items = useMemo(
@@ -498,6 +521,7 @@ export function Timeline() {
// (e.g. the global Space hotkey).
useEffect(() => {
setVisiblePhotoIds(visibleSequence)
visibleSequenceRef.current = visibleSequence
}, [visibleSequence, setVisiblePhotoIds])
// Locate the active photo in the visual grid. Returns the FIRST
@@ -660,11 +684,7 @@ export function Timeline() {
}
if (photos.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-text-muted">(°° </div>
</div>
)
return <EmptyTimelineState />
}
return (
@@ -788,16 +808,8 @@ export function Timeline() {
fill
isSelected={selectedPhotos.includes(photo.id)}
isInActiveHeap={activeHeapMembers.has(photo.id)}
onClick={(e) => {
if (e.shiftKey) {
selectRange(photo.id)
} else if (e.ctrlKey || e.metaKey) {
togglePhotoSelection(photo.id)
} else {
selectPhoto(photo.id)
}
}}
onDoubleClick={() => openPreview(photo.id, visibleSequence)}
onClick={handleCellClick}
onDoubleClick={handleCellDoubleClick}
/>
))}
</div>
@@ -809,3 +821,64 @@ export function Timeline() {
</div>
)
}
/**
* Empty-state rendered when the current filter/section returns zero photos.
* Distinguishes "library-empty" from "filters-too-strict": the former hints
* at upload, the latter offers a one-click Clear all.
*/
function EmptyTimelineState() {
const filterState = useFilterStore()
const clearAll = useFilterStore((s) => s.clearAll)
const currentSection = useFilterStore((s) => s.currentSection)
const filtersActive = hasActiveFilters(filterState)
const { title, hint } = sectionEmptyCopy(currentSection, filtersActive)
return (
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
<ImageOff className="h-10 w-10 text-text-muted/40" />
<div className="text-sm font-medium text-text">{title}</div>
<p className="max-w-sm text-xs text-text-muted">{hint}</p>
{filtersActive && (
<Button variant="outline" size="sm" onClick={clearAll} className="mt-1">
Clear all filters
</Button>
)}
</div>
)
}
function sectionEmptyCopy(
section: string,
filtersActive: boolean,
): { title: string; hint: string } {
if (filtersActive) {
return {
title: 'No photos match',
hint: 'Your filters are excluding everything in this section. Clear them to see the full library.',
}
}
switch (section) {
case 'discarded':
return {
title: 'Discard pile is empty',
hint: 'Photos you discard (X) land here until you empty them permanently.',
}
case 'rated':
return {
title: 'No rated photos yet',
hint: 'Rate photos 15 with the number keys and they will appear here.',
}
case 'tags':
return {
title: 'No tagged photos',
hint: 'Add tags from a photo\u2019s metadata panel or via the bulk tag editor.',
}
default:
return {
title: 'Library is empty',
hint: 'Add photos via the upload button, or point the PHOTO_DIRS volume at a folder with existing images.',
}
}
}