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:
@@ -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 1–5 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.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user