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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user