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:
@@ -43,6 +43,7 @@ import {
|
||||
LIBRARY_STATS_QUERY_KEY,
|
||||
} from '../../hooks/useLibraryStatsQuery'
|
||||
import { registerUndoable } from '../../store/undoStore'
|
||||
import { formatApiError } from '../../lib/apiError'
|
||||
import type { Photo } from '../../types/photo'
|
||||
import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog'
|
||||
import { ShareDialog } from '../sharing/ShareDialog'
|
||||
@@ -150,7 +151,7 @@ export function LeftSidebar() {
|
||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Discard failed', e?.message || 'Unknown error'),
|
||||
toast.error('Discard failed', formatApiError(e)),
|
||||
})
|
||||
|
||||
// Bulk move mutation for the drag-onto-folder interaction. The mutation
|
||||
@@ -223,7 +224,7 @@ export function LeftSidebar() {
|
||||
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Move failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
||||
toast.error('Move failed', formatApiError(e)),
|
||||
})
|
||||
|
||||
// Bulk copy mutation — Alt-drag uses this instead of move.
|
||||
@@ -245,7 +246,7 @@ export function LeftSidebar() {
|
||||
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Copy failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
||||
toast.error('Copy failed', formatApiError(e)),
|
||||
})
|
||||
|
||||
// Reads the dragged ids out of a drop event payload.
|
||||
@@ -314,7 +315,7 @@ export function LeftSidebar() {
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Rename failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
||||
toast.error('Rename failed', formatApiError(e)),
|
||||
})
|
||||
|
||||
const createFolderMutation = useMutation({
|
||||
@@ -328,7 +329,7 @@ export function LeftSidebar() {
|
||||
setCreateDraft('')
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Create failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
||||
toast.error('Create failed', formatApiError(e)),
|
||||
})
|
||||
|
||||
// Toggle folder hide-from-views. Invalidates every query that could
|
||||
@@ -354,7 +355,7 @@ export function LeftSidebar() {
|
||||
queryClient.invalidateQueries({ queryKey: ['tags'] })
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Toggle failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
||||
toast.error('Toggle failed', formatApiError(e)),
|
||||
})
|
||||
|
||||
const deleteFolderMutation = useMutation({
|
||||
@@ -383,7 +384,7 @@ export function LeftSidebar() {
|
||||
setDeletingFolder(null)
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Delete failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
||||
toast.error('Delete failed', formatApiError(e)),
|
||||
})
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user