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>
154 lines
4.8 KiB
TypeScript
154 lines
4.8 KiB
TypeScript
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>
|
|
)
|
|
}
|