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

@@ -17,17 +17,16 @@ import {
useQueryClient,
keepPreviousData,
} from '@tanstack/react-query'
import { format } from 'date-fns'
import {
photos as photosApi,
heaps as heapsApi,
tags as tagsApi,
type Tag,
} from '../../services/api'
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 { formatApiError } from '../../lib/apiError'
import { toast } from '../ToastContainer'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
@@ -40,18 +39,17 @@ import {
COLOR_LABEL_OPTIONS,
type ColorLabel,
} from '../../constants/colorLabels'
import {
guessDateFromPath,
toDatetimeLocalValue,
} from '../../lib/guessDateFromPath'
import { toDatetimeLocalValue } from '../../lib/guessDateFromPath'
import { TagsEditor } from './TagsEditor'
import { TakenAtEditor } from './TakenAtEditor'
interface PhotoTagSummary {
export interface PhotoTagSummary {
id: string
name: string
color: string | null
}
interface PhotoDetails {
export interface PhotoDetails {
id: string
filename: string
filepath: string
@@ -240,21 +238,21 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
},
onSuccess: () => invalidateTagsAndPhoto(),
onError: (e: any) =>
toast.error('Add tag failed', e?.message || 'Unknown error'),
toast.error('Add tag failed', formatApiError(e)),
})
const attachExistingTagMutation = useMutation({
mutationFn: (tagId: string) => tagsApi.addToPhoto(photoId, [tagId]),
onSuccess: () => invalidateTagsAndPhoto(),
onError: (e: any) =>
toast.error('Add tag failed', e?.message || 'Unknown error'),
toast.error('Add tag failed', formatApiError(e)),
})
const removeTagMutation = useMutation({
mutationFn: (tagId: string) => tagsApi.removeFromPhoto(photoId, tagId),
onSuccess: () => invalidateTagsAndPhoto(),
onError: (e: any) =>
toast.error('Remove tag failed', e?.message || 'Unknown error'),
toast.error('Remove tag failed', formatApiError(e)),
})
// Local drafts for the text fields. Mirror the server value but stay
@@ -297,7 +295,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
onError: (e: any) => {
toast.error(
'Rename failed',
e?.response?.data?.detail || e.message || 'Unknown error'
formatApiError(e)
)
setFilenameDraft(current)
},
@@ -345,7 +343,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
onError: (e: any) => {
toast.error(
'Date update failed',
e?.response?.data?.detail || e.message || 'Unknown error'
formatApiError(e)
)
setTakenAtDraft(
photo?.taken_at
@@ -707,129 +705,6 @@ function Section({
)
}
interface TagsEditorProps {
photoTags: PhotoTagSummary[]
allTags: Tag[]
tagInput: string
onTagInputChange: (value: string) => void
onAttachExisting: (id: string) => void
onCreateAndAttach: (name: string) => void
onRemove: (id: string) => void
}
function TagsEditor({
photoTags,
allTags,
tagInput,
onTagInputChange,
onAttachExisting,
onCreateAndAttach,
onRemove,
}: TagsEditorProps) {
const trimmed = tagInput.trim()
const lowerTrimmed = trimmed.toLowerCase()
const photoTagIds = new Set(photoTags.map((t) => t.id))
const suggestions = trimmed
? allTags
.filter(
(t) =>
!photoTagIds.has(t.id) &&
t.name.toLowerCase().includes(lowerTrimmed)
)
.slice(0, 6)
: []
const exactMatch = trimmed
? allTags.find((t) => t.name.toLowerCase() === lowerTrimmed)
: null
const handleSubmit = () => {
if (!trimmed) return
if (exactMatch) {
if (!photoTagIds.has(exactMatch.id)) {
onAttachExisting(exactMatch.id)
}
onTagInputChange('')
} else {
onCreateAndAttach(trimmed)
}
}
return (
<div className="space-y-2">
{photoTags.length > 0 ? (
<div className="flex flex-wrap gap-1">
{photoTags.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
}
>
{tag.name}
<button
onClick={() => onRemove(tag.id)}
className="rounded p-0.5 opacity-60 hover:bg-surface-offset hover:opacity-100"
title="Remove tag"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
) : (
<div className="text-xs text-text-faint">No tags</div>
)}
<div className="relative">
<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="Add tag…"
className="h-7 bg-bg text-xs"
/>
{suggestions.length > 0 && (
<div className="mt-1 rounded border border-border bg-bg shadow-md">
{suggestions.map((s) => (
<button
key={s.id}
onClick={() => {
onAttachExisting(s.id)
onTagInputChange('')
}}
className="block w-full px-2 py-1 text-left text-xs text-text hover:bg-surface-2"
>
{s.name}
</button>
))}
</div>
)}
{trimmed && !exactMatch && (
<button
onClick={handleSubmit}
className="mt-1 w-full rounded border border-dashed border-primary/50 px-2 py-1 text-left text-xs text-primary hover:bg-primary/10"
>
+ Create "{trimmed}"
</button>
)}
</div>
</div>
)
}
function Field({ label, value }: { label: string; value: string }) {
return (
<div>
@@ -839,113 +714,3 @@ function Field({ label, value }: { label: string; value: string }) {
)
}
interface TakenAtEditorProps {
photo: PhotoDetails
draft: string
onDraftChange: (v: string) => void
onCommit: (raw?: string) => void
darkTheme: boolean
}
/** Editable Date Taken field with a source badge (EXIF / filesystem / manual)
* and a folder-guess suggestion row that only shows up when the filepath
* implies a different date than what's currently stored. The suggestion
* hint is the whole point of this feature — epoch-reset phones and
* corrupted EXIF dumps end up clustered in the wrong corner of the
* timeline until someone rewrites them from the folder name. */
function TakenAtEditor({
photo,
draft,
onDraftChange,
onCommit,
darkTheme,
}: TakenAtEditorProps) {
const source = photo.taken_at_source ?? null
const sourceLabel =
source === 'exif'
? 'EXIF'
: source === 'filesystem'
? 'FILE'
: source === 'manual'
? 'MANUAL'
: null
const guess = useMemo(
() => guessDateFromPath(photo.filepath),
[photo.filepath]
)
// Show the suggestion when:
// - there's no stored date at all, OR
// - the guess disagrees with the stored date by more than a day.
// A same-day match is treated as "already correct enough" so we don't
// nag the user on photos that happen to sit in a dated folder.
const showSuggestion = useMemo(() => {
if (!guess) return false
if (!photo.taken_at) return true
const current = new Date(photo.taken_at).getTime()
const suggested = guess.date.getTime()
return Math.abs(current - suggested) > 24 * 60 * 60 * 1000
}, [guess, photo.taken_at])
const inputClass = clsx(
'flex-1 rounded border px-2 py-1 text-xs focus:outline-none',
darkTheme
? 'border-white/15 bg-black/40 text-white focus:border-primary'
: 'border-border bg-bg text-text focus:border-primary'
)
return (
<div className="mt-2 text-xs">
<label className="mb-1 block text-text-muted">Date Taken</label>
<div className="flex items-center gap-1.5">
<input
type="datetime-local"
value={draft}
onChange={(e) => onDraftChange(e.target.value)}
onBlur={() => onCommit()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
} else if (e.key === 'Escape') {
onDraftChange(
photo.taken_at
? toDatetimeLocalValue(new Date(photo.taken_at))
: ''
)
e.currentTarget.blur()
}
}}
className={inputClass}
/>
{sourceLabel && (
<span
className="rounded-sm bg-black/60 px-1.5 py-0.5 text-[9px] font-semibold tracking-wider text-white"
title={`Source: ${sourceLabel.toLowerCase()}`}
>
{sourceLabel}
</span>
)}
</div>
{showSuggestion && guess && (
<button
onClick={() => {
const next = toDatetimeLocalValue(guess.date)
onDraftChange(next)
onCommit(next)
}}
className={clsx(
'mt-1.5 flex w-full items-center justify-between gap-2 rounded border border-dashed px-2 py-1 text-[11px] transition-colors',
'border-primary/50 text-primary hover:bg-primary/10'
)}
title={`Match "${guess.matched}" in path (${guess.source}, ${guess.confidence} confidence)`}
>
<span className="truncate">
Folder suggests {format(guess.date, 'MMM d, yyyy')}
</span>
<span className="shrink-0 font-semibold">Apply</span>
</button>
)}
</div>
)
}