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

@@ -0,0 +1,121 @@
import { Plus, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
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).
*/
export 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>
)
}

View 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>
)
}

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>
)
}

View File

@@ -0,0 +1,133 @@
import { X } from 'lucide-react'
import { Input } from '@/components/ui/input'
import type { Tag } from '../../services/api'
import type { PhotoTagSummary } from './PhotoInfoPanel'
interface TagsEditorProps {
photoTags: PhotoTagSummary[]
allTags: Tag[]
tagInput: string
onTagInputChange: (value: string) => void
onAttachExisting: (id: string) => void
onCreateAndAttach: (name: string) => void
onRemove: (id: string) => void
}
/**
* Single-photo tag editor. Shows the photo's current tags as chips,
* offers an inline search that surfaces up to 6 matching unused tags,
* and an explicit "Create" affordance when the typed name doesn't
* exist. Used by PhotoInfoPanel in the single-selection right panel.
*/
export 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>
)
}

View File

@@ -0,0 +1,119 @@
import { useMemo } from 'react'
import { format } from 'date-fns'
import clsx from 'clsx'
import {
guessDateFromPath,
toDatetimeLocalValue,
} from '../../lib/guessDateFromPath'
import type { PhotoDetails } from './PhotoInfoPanel'
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. */
export 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>
)
}