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,6 +3,7 @@ import { Copy, Layers, Sparkles, Trash2, Loader2, Info, Crown } from 'lucide-rea
import clsx from 'clsx'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Button } from '@/components/ui/button'
import { formatApiError } from '../../lib/apiError'
import {
useDuplicateGroupsQuery,
DUPLICATE_GROUPS_QUERY_KEY,
@@ -63,7 +64,7 @@ export function DuplicatesView() {
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
},
onError: (e: any) =>
toast.error('Discard failed', e?.message || 'Unknown error'),
toast.error('Discard failed', formatApiError(e)),
})
// Hooks below this point must run on every render — rules of hooks
@@ -310,8 +311,8 @@ function DuplicateGroupSection({
size={180}
fill
isSelected={selectedPhotos.includes(member.id)}
onClick={() => onSelectMember(member.id)}
onDoubleClick={() => onPreviewMember(member.id)}
onClick={(p) => onSelectMember(p.id)}
onDoubleClick={(p) => onPreviewMember(p.id)}
/>
{/* BEST pill — top-right, pick-coloured. Composes the same
* THUMB_BADGE_* family used by PhotoThumbnail so the full