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

@@ -1,8 +1,7 @@
import { useState } from 'react'
import { X, Star, ShoppingBasket, Trash2, Plus } from 'lucide-react'
import { X, Star, ShoppingBasket, Trash2 } from 'lucide-react'
import clsx from 'clsx'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { format } from 'date-fns'
import { usePhotoStore } from '../../store/photoStore'
import {
photos as photosApi,
@@ -10,21 +9,19 @@ import {
tags as tagsApi,
} from '../../services/api'
import type { Photo } from '../../types/photo'
import {
guessDateFromPath,
type DateGuess,
} from '../../lib/guessDateFromPath'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
import { stripPhotosFromCache } from '../../hooks/usePhotosQuery'
import { toast } from '../ToastContainer'
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
import { BulkTakenAtEditor } from '../sidebar/BulkTakenAtEditor'
import { BulkTagsEditor } from '../sidebar/BulkTagsEditor'
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useBulkPhotoMutations } from '../../hooks/useBulkPhotoMutations'
import { formatApiError } from '../../lib/apiError'
/**
* Right-hand details panel.
@@ -36,22 +33,12 @@ export function RightSidebar() {
const { selectedPhotos, activePhotoId, clearSelection } = usePhotoStore()
const queryClient = useQueryClient()
const invalidatePhotoQueries = () => {
queryClient.invalidateQueries({ queryKey: ['photo'] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
}
const {
bulkRating: bulkRatingMutation,
bulkColor: bulkColorMutation,
invalidatePhotoQueries,
} = useBulkPhotoMutations()
const bulkRatingMutation = useMutation({
mutationFn: ({ ids, rating }: { ids: string[]; rating: number }) =>
photosApi.bulkSetRating(ids, rating),
onSuccess: invalidatePhotoQueries,
})
const bulkColorMutation = useMutation({
mutationFn: ({ ids, color }: { ids: string[]; color: string | null }) =>
photosApi.bulkSetColor(ids, color),
onSuccess: invalidatePhotoQueries,
})
const bulkDiscardMutation = useMutation({
mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids),
// Yank the photos from the timeline before the network round-trip
@@ -94,7 +81,7 @@ export function RightSidebar() {
photosApi.bulkSetTakenAt(ids, iso),
onSuccess: reportBulkTakenAt,
onError: (e: any) =>
toast.error('Date update failed', e?.message || 'Unknown error'),
toast.error('Date update failed', formatApiError(e)),
})
const bulkTakenAtMapMutation = useMutation({
@@ -102,7 +89,7 @@ export function RightSidebar() {
photosApi.bulkSetTakenAtMap(map),
onSuccess: reportBulkTakenAt,
onError: (e: any) =>
toast.error('Date update failed', e?.message || 'Unknown error'),
toast.error('Date update failed', formatApiError(e)),
})
// Bulk tag mutations. Tag mutations also need to invalidate the tags
@@ -123,7 +110,7 @@ export function RightSidebar() {
invalidateTagsAndPhotos()
},
onError: (e: any) =>
toast.error('Add tags failed', e?.message || 'Unknown error'),
toast.error('Add tags failed', formatApiError(e)),
})
const bulkRemoveTagsMutation = useMutation({
mutationFn: ({ ids, tagIds }: { ids: string[]; tagIds: string[] }) =>
@@ -137,7 +124,7 @@ export function RightSidebar() {
invalidateTagsAndPhotos()
},
onError: (e: any) =>
toast.error('Remove tags failed', e?.message || 'Unknown error'),
toast.error('Remove tags failed', formatApiError(e)),
})
// Idempotent create-and-attach: lets the user type a brand-new tag
@@ -152,7 +139,7 @@ export function RightSidebar() {
invalidateTagsAndPhotos()
},
onError: (e: any) =>
toast.error('Create tag failed', e?.message || 'Unknown error'),
toast.error('Create tag failed', formatApiError(e)),
})
const { data: allTags = [] } = useTagsQuery()
@@ -182,7 +169,7 @@ export function RightSidebar() {
if (activeHeap && ctx?.previous) {
queryClient.setQueryData(['heap-photo-ids', activeHeap.id], ctx.previous)
}
toast.error('Heap update failed', e?.message || 'Unknown error')
toast.error('Heap update failed', formatApiError(e))
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
@@ -459,265 +446,3 @@ export function RightSidebar() {
</div>
)
}
interface BulkTakenAtEditorProps {
disabled: boolean
selectedCount: number
collectPhotos: () => Photo[]
onApplyUniform: (iso: string) => void
onApplyMap: (map: Record<string, string>) => void
}
/** Bulk Date Taken sub-panel used by RightSidebar in multi-select mode.
* Two modes share one UI:
* 1. Apply-one: user types a datetime, clicks Apply, every selected
* photo is rewritten to that date.
* 2. Guess-from-path: we run `guessDateFromPath` against each selected
* photo's filepath, show a preview of the hits + misses, and let
* the user commit the per-photo map in one round-trip. */
function BulkTakenAtEditor({
disabled,
selectedCount,
collectPhotos,
onApplyUniform,
onApplyMap,
}: BulkTakenAtEditorProps) {
const [uniformDraft, setUniformDraft] = useState('')
const [preview, setPreview] = useState<
| {
hits: { photo: Photo; guess: DateGuess }[]
misses: Photo[]
}
| null
>(null)
const handleGuess = () => {
const photos = collectPhotos()
const hits: { photo: Photo; guess: DateGuess }[] = []
const misses: Photo[] = []
for (const p of photos) {
const g = guessDateFromPath(p.filepath)
if (g) hits.push({ photo: p, guess: g })
else misses.push(p)
}
setPreview({ hits, misses })
}
const handleApplyPreview = () => {
if (!preview) return
const map: Record<string, string> = {}
for (const { photo, guess } of preview.hits) {
map[photo.id] = guess.date.toISOString()
}
if (Object.keys(map).length === 0) return
onApplyMap(map)
setPreview(null)
}
const handleApplyUniform = () => {
if (!uniformDraft) return
const parsed = new Date(uniformDraft)
if (Number.isNaN(parsed.getTime())) return
onApplyUniform(parsed.toISOString())
}
return (
<div className="space-y-2">
{/* Apply-one row */}
<div className="flex items-center gap-1.5">
<Input
type="datetime-local"
value={uniformDraft}
onChange={(e) => setUniformDraft(e.target.value)}
disabled={disabled}
className="h-7 flex-1 text-xs"
/>
<Button
size="sm"
onClick={handleApplyUniform}
disabled={disabled || !uniformDraft}
className="bg-primary/20 text-primary hover:bg-primary/30"
title={`Apply this date to all ${selectedCount} selected`}
>
Apply
</Button>
</div>
{/* Guess-from-path preview */}
{preview === null ? (
<Button
variant="outline"
size="sm"
onClick={handleGuess}
disabled={disabled}
className="w-full border-dashed border-primary/50 bg-transparent text-primary hover:bg-primary/10"
title="Scan each photo's folder + filename for a date pattern"
>
Guess from folder paths
</Button>
) : (
<div className="rounded border border-border bg-bg p-2 text-[11px]">
<div className="mb-1.5 text-text-muted">
{preview.hits.length} will update ·{' '}
{preview.misses.length} skipped
</div>
{preview.hits.length > 0 && (
<ul className="mb-1.5 max-h-24 space-y-0.5 overflow-y-auto font-mono text-[10px] text-text">
{preview.hits.slice(0, 5).map(({ photo, guess }) => (
<li key={photo.id} className="truncate" title={photo.filepath}>
<span className="text-text-muted">{photo.filename}</span>
{' → '}
<span className="text-primary">
{format(guess.date, 'yyyy-MM-dd')}
</span>
</li>
))}
{preview.hits.length > 5 && (
<li className="text-text-muted">
and {preview.hits.length - 5} more
</li>
)}
</ul>
)}
<div className="flex gap-1.5">
<Button
size="sm"
onClick={handleApplyPreview}
disabled={disabled || preview.hits.length === 0}
className="flex-1 bg-primary/20 text-primary hover:bg-primary/30"
>
Apply {preview.hits.length}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setPreview(null)}
disabled={disabled}
className="text-text-muted"
>
Cancel
</Button>
</div>
</div>
)}
</div>
)
}
interface BulkTagsEditorProps {
allTags: { id: string; name: string; color: string | null }[]
tagInput: string
onTagInputChange: (value: string) => void
disabled: boolean
onApply: (tagId: string) => void
onRemove: (tagId: string) => void
onCreate: (name: string) => void
}
/**
* Compact bulk tag editor for the multi-select right sidebar. Unlike the
* single-photo TagsEditor we don't show "current tags" — there's no clean
* single-photo notion of that across an arbitrary selection. Instead the
* user picks an existing tag (apply to all) or types a new one (create
* and apply to all).
*/
function BulkTagsEditor({
allTags,
tagInput,
onTagInputChange,
disabled,
onApply,
onRemove,
onCreate,
}: BulkTagsEditorProps) {
const trimmed = tagInput.trim()
const lower = trimmed.toLowerCase()
const filtered = trimmed
? allTags.filter((t) => t.name.toLowerCase().includes(lower))
: allTags
const exactMatch = trimmed
? allTags.find((t) => t.name.toLowerCase() === lower)
: null
const handleSubmit = () => {
if (!trimmed || disabled) return
if (exactMatch) {
onApply(exactMatch.id)
onTagInputChange('')
} else {
onCreate(trimmed)
}
}
return (
<div className="space-y-2">
<Input
type="text"
value={tagInput}
onChange={(e) => onTagInputChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
} else if (e.key === 'Escape') {
onTagInputChange('')
}
}}
placeholder="Filter or create…"
disabled={disabled}
className="h-7 text-xs"
/>
{trimmed && !exactMatch && (
<Button
variant="outline"
size="sm"
onClick={handleSubmit}
disabled={disabled}
className="w-full border-dashed border-primary/50 bg-transparent text-primary hover:bg-primary/10"
>
<Plus className="mr-1 h-3 w-3" />
Create "{trimmed}" and apply
</Button>
)}
{filtered.length > 0 ? (
<div className="flex max-h-40 flex-wrap gap-1 overflow-y-auto">
{filtered.map((tag) => (
<span
key={tag.id}
className="flex items-center gap-1 rounded bg-surface-2 px-2 py-0.5 text-xs text-text"
style={
tag.color
? { backgroundColor: `${tag.color}33`, color: tag.color }
: undefined
}
>
<button
onClick={() => onApply(tag.id)}
disabled={disabled}
className="hover:underline disabled:opacity-50"
title={`Apply "${tag.name}" to selection`}
>
{tag.name}
</button>
<button
onClick={() => onRemove(tag.id)}
disabled={disabled}
className="rounded p-0.5 opacity-60 hover:bg-surface-offset hover:opacity-100 disabled:opacity-30"
title={`Remove "${tag.name}" from selection`}
aria-label={`Remove ${tag.name} from selection`}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
) : (
<div className="text-xs text-text-faint">No tags match</div>
)}
</div>
)
}