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:
153
frontend/src/components/sidebar/BulkTakenAtEditor.tsx
Normal file
153
frontend/src/components/sidebar/BulkTakenAtEditor.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useState } from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
guessDateFromPath,
|
||||
type DateGuess,
|
||||
} from '../../lib/guessDateFromPath'
|
||||
import type { Photo } from '../../types/photo'
|
||||
|
||||
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. */
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user