Files
mule-image/frontend/src/components/dialogs/SettingsDialog.tsx
dtoro 733c16bf82 feat: perceptual-hash duplicate detection + grouped picker view
The Duplicates section was useless: SHA-256-only detection only caught
byte-identical files, not the actual duplicates a real library
accumulates (re-encoded JPEGs, screenshots, resized exports), and the
view was a flat date-sorted list with no grouping or actions. This
replaces the whole flow.

Detection
- New phash + duplicate_group_id columns on Photo, added via an
  idempotent ALTER TABLE pass in init_db (the project has no Alembic).
- Thumbs worker computes a 64-bit pHash from the original-resolution
  decoded frame just before the destructive thumbnail loop. Falls back
  silently — phash is nice-to-have, not a blocker for thumbnails.
- backfill_phashes Celery task fills in phashes for photos that
  predated the column, reading the existing thumb_large rather than
  re-decoding the original.
- regroup_duplicates service runs union-find over Hamming distance
  (threshold 6), persists duplicate_group_id, and maintains is_duplicate
  as derived state so existing badges/counts keep working. Chained
  after scan_all_source_roots with a 60s countdown.

API
- GET /library/duplicates/groups returns all groups with members,
  bucketed in Python from one query. Each group has a reason ("exact"
  iff every member shares a SHA-256, "similar" otherwise).
- POST /library/maintenance/{regroup-duplicates,backfill-phashes}.

Frontend
- New DuplicatesView (sectioned grid, one section per cluster) replaces
  the timeline when the user is in the duplicates section. Each section
  shows a "Keep best, discard N" button that picks the highest-pixel
  copy and reuses the existing undoable bulk-discard so Cmd+Z works.
- Manual best override: hover any non-best thumbnail and click "Keep
  this" (Crown icon, top-right) to override the auto-pick. The header
  annotates "(manual)" so it's obvious which copy will be kept.
- Keyboard nav within the duplicates view walks the flat member list,
  with ↑/↓ jumping by the measured column count and scrollIntoView on
  every move. Timeline's keyboard handler now early-returns in the
  duplicates section so the two don't fight.
- BEST pill / Keep-this button live at top-right with a ring outline so
  they don't collide visually with the cyan selection ring around a
  selected cell. Dimensions chip moved to bottom-left to free both
  right corners for the keep affordances.
- New "Duplicates" section in SettingsDialog: shows group/member counts
  and exposes both backfill + re-detect actions, sharing a query cache
  with DuplicatesView via DUPLICATE_GROUPS_QUERY_KEY.
- PhotoInfoPanel "Basic Info" section now shows the photo's full file
  path in monospace below the size/dimensions/date grid.
- New imagehash==4.3.1 dep in requirements.txt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:19:08 +02:00

836 lines
31 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react'
import {
X,
RefreshCw,
Wrench,
Film,
Image as ImageIcon,
AlertTriangle,
Database,
Loader2,
Cpu,
AlertCircle,
CheckCircle2,
Copy,
Sparkles,
} from 'lucide-react'
import clsx from 'clsx'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
library,
type MediaType,
} from '../../services/api'
import { toast } from '../ToastContainer'
// React Query keys for the settings panels. Kept here (not in a shared
// hook module) since they're internal to this dialog and used by the
// runAction refresh step to invalidate after mutations.
const SETTINGS_THUMB_STATS_KEY = ['settings', 'thumbnail-stats'] as const
const SETTINGS_LIB_STATS_KEY = ['settings', 'library-stats'] as const
const SETTINGS_WORKER_STATUS_KEY = ['settings', 'worker-status'] as const
const SETTINGS_MISSING_STATS_KEY = ['settings', 'missing-stats'] as const
// Shared with the DuplicatesView so a regroup invalidates the same cache
// the grid renders from. Imported via the canonical hook key.
import { DUPLICATE_GROUPS_QUERY_KEY } from '../../hooks/useDuplicateGroupsQuery'
interface SettingsDialogProps {
isOpen: boolean
onClose: () => void
}
/**
* Catch-all "settings + admin" panel. Currently exposes the maintenance
* endpoints exposed by /api/v1/library/maintenance/* — regenerate
* thumbnails (with filters), run the data-integrity cleanup, and trigger
* a full library re-scan. The thumbnail stats block is the entry point
* users will look at to understand what's going on after a scan.
*
* Each action is gated by an in-flight flag so double-clicks don't
* stack background jobs, and the stats block re-fetches whenever the
* dialog opens or after any action completes.
*/
export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
const queryClient = useQueryClient()
const [showAllErrors, setShowAllErrors] = useState(false)
// One key per action so each button has its own spinner without
// blocking the others.
const [busy, setBusy] = useState<Record<string, boolean>>({})
// All four panels fetch through React Query so cached data shows
// instantly on reopen while a background refetch updates the numbers.
// `enabled: isOpen` avoids firing requests while the dialog is closed,
// but the cache entries survive between opens (default gcTime = 5m).
const thumbStatsQuery = useQuery({
queryKey: SETTINGS_THUMB_STATS_KEY,
queryFn: library.maintenance.thumbnailStats,
enabled: isOpen,
// Treat as stale immediately so reopening the dialog triggers a
// background refetch on top of the cached view.
staleTime: 0,
})
const libStatsQuery = useQuery({
queryKey: SETTINGS_LIB_STATS_KEY,
queryFn: library.stats,
enabled: isOpen,
staleTime: 0,
})
// Worker status polls every 5s while the dialog is open — `refetchInterval`
// replaces the old setInterval loop. Missing-stats is relatively cheap
// but shares the same 5s rhythm to keep the orphan banner live.
const workerStatusQuery = useQuery({
queryKey: SETTINGS_WORKER_STATUS_KEY,
queryFn: library.maintenance.workerStatus,
enabled: isOpen,
refetchInterval: isOpen ? 5000 : false,
staleTime: 0,
})
const missingStatsQuery = useQuery({
queryKey: SETTINGS_MISSING_STATS_KEY,
queryFn: library.maintenance.missingStats,
enabled: isOpen,
refetchInterval: isOpen ? 5000 : false,
staleTime: 0,
})
// Duplicates: shares the cache with DuplicatesView so a regroup
// triggered from Settings updates the grid view immediately.
const duplicatesQuery = useQuery({
queryKey: DUPLICATE_GROUPS_QUERY_KEY,
queryFn: library.duplicates.groups,
enabled: isOpen,
staleTime: 0,
})
const thumbStats = thumbStatsQuery.data
const libStats = libStatsQuery.data
const workerStatus = workerStatusQuery.data
const missingStats = missingStatsQuery.data
// "loading" in the UI sense = fetching AND no cached data yet. Background
// refetches on top of cached data shouldn't flip the refresh spinners.
const loadingStats =
(thumbStatsQuery.isFetching && !thumbStatsQuery.data) ||
(libStatsQuery.isFetching && !libStatsQuery.data)
const loadingWorkers =
(workerStatusQuery.isFetching && !workerStatusQuery.data) ||
(missingStatsQuery.isFetching && !missingStatsQuery.data)
const refreshStats = useCallback(() => {
queryClient.invalidateQueries({ queryKey: SETTINGS_THUMB_STATS_KEY })
queryClient.invalidateQueries({ queryKey: SETTINGS_LIB_STATS_KEY })
queryClient.invalidateQueries({ queryKey: DUPLICATE_GROUPS_QUERY_KEY })
}, [queryClient])
const refreshWorkers = useCallback(() => {
queryClient.invalidateQueries({ queryKey: SETTINGS_WORKER_STATUS_KEY })
queryClient.invalidateQueries({ queryKey: SETTINGS_MISSING_STATS_KEY })
}, [queryClient])
// Surface fetch errors once (React Query de-dupes retries but we still
// want a single toast so the user knows something went wrong).
useEffect(() => {
if (!isOpen) return
if (thumbStatsQuery.error || libStatsQuery.error) {
console.error('Failed to load settings stats', thumbStatsQuery.error ?? libStatsQuery.error)
toast.error('Could not load library stats')
}
}, [isOpen, thumbStatsQuery.error, libStatsQuery.error])
useEffect(() => {
if (!isOpen) return
if (workerStatusQuery.error || missingStatsQuery.error) {
console.error('Failed to load worker status', workerStatusQuery.error ?? missingStatsQuery.error)
toast.error('Could not load worker status')
}
}, [isOpen, workerStatusQuery.error, missingStatsQuery.error])
// Esc closes.
useEffect(() => {
if (!isOpen) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [isOpen, onClose])
const runAction = useCallback(
async <T,>(
key: string,
fn: () => Promise<T>,
successTitle: string,
describe?: (result: T) => string | undefined
) => {
if (busy[key]) return
setBusy((b) => ({ ...b, [key]: true }))
try {
const result = await fn()
toast.success(successTitle, describe?.(result))
refreshStats()
refreshWorkers()
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e)
toast.error(`${successTitle} failed`, message)
} finally {
setBusy((b) => ({ ...b, [key]: false }))
}
},
[busy, refreshStats, refreshWorkers]
)
const regenerate = useCallback(
(
key: string,
body: {
media_types?: MediaType[]
only_failed?: boolean
only_pending?: boolean
}
) =>
runAction(
key,
() => library.maintenance.regenerateThumbnails(body),
'Regeneration queued',
(r) => `${r.queued} photos queued, ${r.cleared_dirs} thumb dirs cleared`
),
[runAction]
)
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="relative z-10 flex max-h-[85vh] w-[640px] flex-col rounded-lg border border-border bg-surface shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-5 py-3">
<h2 className="text-base font-semibold text-text">Settings</h2>
<button
onClick={onClose}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
title="Close (Esc)"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-5">
{/* ----------------------------------------------------- */}
{/* Library overview */}
{/* ----------------------------------------------------- */}
<Section
icon={<Database className="h-4 w-4" />}
title="Library"
right={
<button
onClick={refreshStats}
disabled={loadingStats}
className="flex items-center gap-1 rounded border border-border px-2 py-1 text-xs text-text-muted hover:bg-surface-2 disabled:opacity-50"
>
{loadingStats ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
Refresh
</button>
}
>
<div className="grid grid-cols-3 gap-2 text-xs">
<Stat label="Photos" value={libStats?.total_photos} />
<Stat label="Videos" value={libStats?.total_videos} />
<Stat
label="On disk"
value={
libStats ? `${libStats.total_size_gb.toFixed(1)} GB` : undefined
}
/>
</div>
<div className="mt-3">
<ActionButton
loading={busy.scan}
onClick={() =>
runAction(
'scan',
() => library.scan(),
'Library scan started'
)
}
>
<RefreshCw className="h-4 w-4" />
Re-scan source folders
</ActionButton>
</div>
</Section>
{/* ----------------------------------------------------- */}
{/* Duplicate detection */}
{/* ----------------------------------------------------- */}
<Section
icon={<Copy className="h-4 w-4" />}
title="Duplicates"
>
<div className="grid grid-cols-2 gap-2 text-xs">
<Stat
label="Groups"
value={duplicatesQuery.data?.total_groups}
/>
<Stat
label="Members"
value={duplicatesQuery.data?.total_members}
/>
</div>
<p className="mt-3 text-xs text-text-muted">
Duplicates are detected by perceptual hash (pHash), which catches
visually-identical photos even when their bytes differ re-encoded
JPEGs, screenshots, resized exports. Backfill computes hashes for
photos that existed before pHash was added; Re-detect re-runs
clustering across the whole library.
</p>
<div className="mt-2 flex flex-wrap gap-2">
<ActionButton
loading={busy['backfill-phashes']}
onClick={() =>
runAction(
'backfill-phashes',
() => library.maintenance.backfillPhashes(),
'pHash backfill queued'
)
}
>
<Sparkles className="h-4 w-4" />
Backfill perceptual hashes
</ActionButton>
<ActionButton
loading={busy['regroup-duplicates']}
onClick={() =>
runAction(
'regroup-duplicates',
() => library.maintenance.regroupDuplicates(),
'Duplicate detection queued'
)
}
>
<Copy className="h-4 w-4" />
Re-detect duplicates
</ActionButton>
</div>
</Section>
{/* ----------------------------------------------------- */}
{/* Thumbnail maintenance */}
{/* ----------------------------------------------------- */}
<Section
icon={<ImageIcon className="h-4 w-4" />}
title="Thumbnails"
>
<div className="grid grid-cols-4 gap-2 text-xs">
<Stat
label="Completed"
value={thumbStats?.completed}
tone="ok"
/>
<Stat
label="Pending"
value={thumbStats?.pending}
tone="muted"
/>
<Stat
label="Processing"
value={thumbStats?.processing}
tone="muted"
/>
<Stat
label="Failed"
value={thumbStats?.failed}
tone={thumbStats && thumbStats.failed > 0 ? 'warn' : 'muted'}
/>
</div>
<p className="mt-3 text-xs text-text-muted">
Reset on-disk thumbnails and re-queue generation. Use after
upgrading the worker or to fix the gray placeholders left
behind by an earlier failure.
</p>
<div className="mt-2 flex flex-wrap gap-2">
<ActionButton
loading={busy['regen-videos']}
onClick={() =>
regenerate('regen-videos', { media_types: ['video'] })
}
>
<Film className="h-4 w-4" />
Regenerate video thumbnails
</ActionButton>
<ActionButton
loading={busy['regen-failed']}
onClick={() =>
regenerate('regen-failed', { only_failed: true })
}
disabled={!!thumbStats && thumbStats.failed === 0}
>
<AlertTriangle className="h-4 w-4" />
Retry failed
{thumbStats ? ` (${thumbStats.failed})` : ''}
</ActionButton>
<ActionButton
loading={busy['regen-pending']}
onClick={() =>
regenerate('regen-pending', { only_pending: true })
}
disabled={!!thumbStats && thumbStats.pending === 0}
>
<RefreshCw className="h-4 w-4" />
Kick pending
{thumbStats ? ` (${thumbStats.pending})` : ''}
</ActionButton>
<ActionButton
loading={busy['regen-all']}
destructive
onClick={() => {
if (
!confirm(
'Regenerate thumbnails for the entire library? ' +
'This will queue every photo and may take a while.'
)
)
return
regenerate('regen-all', {})
}}
>
<RefreshCw className="h-4 w-4" />
Regenerate all
</ActionButton>
</div>
</Section>
{/* ----------------------------------------------------- */}
{/* Worker fleet diagnostics */}
{/* ----------------------------------------------------- */}
<Section
icon={<Cpu className="h-4 w-4" />}
title="Workers"
right={
<button
onClick={refreshWorkers}
disabled={loadingWorkers}
className="flex items-center gap-1 rounded border border-border px-2 py-1 text-xs text-text-muted hover:bg-surface-2 disabled:opacity-50"
>
{loadingWorkers ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
Refresh
</button>
}
>
{/* Top-line health */}
<div className="grid grid-cols-3 gap-2 text-xs">
<Stat
label="Workers"
value={workerStatus?.worker_count}
tone={
workerStatus
? workerStatus.worker_count > 0
? 'ok'
: 'warn'
: 'muted'
}
/>
<Stat
label="Broker"
value={
workerStatus
? workerStatus.broker_ok
? 'OK'
: 'DOWN'
: undefined
}
tone={
workerStatus
? workerStatus.broker_ok
? 'ok'
: 'warn'
: 'muted'
}
/>
<Stat
label="Failed tasks"
value={workerStatus?.failures.total}
tone={
workerStatus && workerStatus.failures.total > 0
? 'warn'
: 'muted'
}
/>
</div>
{/* Inline error banners for the obvious failure modes */}
{workerStatus?.broker_error && (
<ErrorBanner
title="Cannot reach Redis broker"
detail={workerStatus.broker_error}
/>
)}
{workerStatus?.inspect_error && (
<ErrorBanner
title="Celery inspect failed"
detail={workerStatus.inspect_error}
/>
)}
{workerStatus &&
workerStatus.broker_ok &&
workerStatus.worker_count === 0 && (
<ErrorBanner
title="No workers responding"
detail="Broker is reachable but no celery worker pinged back. Check the mulita-worker container logs."
/>
)}
{/* Queue depth */}
{workerStatus && (
<div className="mt-3">
<div className="mb-1 text-[10px] uppercase tracking-wide text-text-muted">
Queue depth
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
{Object.entries(workerStatus.queues).map(([name, depth]) => (
<div
key={name}
className="flex items-center justify-between rounded bg-surface px-2 py-1"
>
<span className="text-text-muted">{name}</span>
<span
className={clsx(
'font-mono font-semibold',
depth > 0 ? 'text-text' : 'text-text-muted'
)}
>
{depth}
</span>
</div>
))}
</div>
</div>
)}
{/* Orphaned rows (files gone from disk) */}
{missingStats &&
((missingStats.would_delete ?? 0) > 0 ||
(missingStats.would_delete_folders ?? 0) > 0) && (
<div className="mt-3 rounded border border-star/40 bg-star/10 p-2">
<div className="flex items-center justify-between gap-2">
<div className="text-xs">
<div className="flex items-center gap-1.5 font-medium text-text">
<AlertTriangle className="h-3.5 w-3.5 text-star" />
Orphaned rows
</div>
<div className="mt-0.5 text-[10px] text-text-muted">
{missingStats.would_delete ?? 0} photos and{' '}
{missingStats.would_delete_folders ?? 0} folders
point at paths that no longer exist on disk under a
mounted source root. Usually means PHOTO_DIRS was
repointed at a different library.
{missingStats.skipped_unmounted > 0 && (
<>
{' '}
{missingStats.skipped_unmounted} more rows are
under unmounted roots and will not be touched.
</>
)}
</div>
</div>
<ActionButton
loading={busy['prune-missing']}
destructive
onClick={() => {
const photos = missingStats.would_delete ?? 0
const folders = missingStats.would_delete_folders ?? 0
if (
!confirm(
`Delete ${photos} photo rows and ${folders} folder rows whose paths are missing? ` +
'This cannot be undone.'
)
)
return
runAction(
'prune-missing',
() => library.maintenance.pruneMissing(),
'Orphans pruned',
(r) =>
`${r.deleted ?? 0} photos + ${r.deleted_folders ?? 0} folders deleted`
)
}}
>
<AlertTriangle className="h-4 w-4" />
Prune
</ActionButton>
</div>
</div>
)}
{/* Per-worker breakdown */}
{workerStatus && workerStatus.workers.length > 0 && (
<div className="mt-3 space-y-2">
<div className="text-[10px] uppercase tracking-wide text-text-muted">
Worker fleet
</div>
{workerStatus.workers.map((w) => (
<div
key={w.name}
className="rounded border border-border bg-surface p-2 text-xs"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{w.status === 'online' ? (
<CheckCircle2 className="h-3.5 w-3.5 text-pick" />
) : (
<AlertCircle className="h-3.5 w-3.5 text-reject" />
)}
<span className="font-mono text-text">{w.name}</span>
</div>
<span className="text-text-muted">
{w.active}/{w.concurrency ?? '?'} active
</span>
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-text-muted">
<span>reserved: {w.reserved}</span>
<span>scheduled: {w.scheduled}</span>
{w.queues.length > 0 && (
<span>queues: {w.queues.join(', ')}</span>
)}
</div>
{w.active_tasks.length > 0 && (
<div className="mt-1.5 space-y-0.5 border-t border-border pt-1.5">
{w.active_tasks.map((t) => (
<div
key={t.id}
className="truncate font-mono text-[10px] text-text-muted"
title={`${t.name} ${JSON.stringify(t.args)}`}
>
<span className="text-text">{t.name}</span>{' '}
{Array.isArray(t.args)
? t.args.map((a) => String(a)).join(', ')
: ''}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
{/* Recent task failures */}
{workerStatus && workerStatus.failures.recent.length > 0 && (
<div className="mt-3">
<div className="mb-1 flex items-center justify-between">
<div className="text-[10px] uppercase tracking-wide text-text-muted">
Recent failures ({workerStatus.failures.total})
</div>
{workerStatus.failures.recent.length > 5 && (
<button
onClick={() => setShowAllErrors((v) => !v)}
className="text-[10px] text-text-muted hover:text-text"
>
{showAllErrors ? 'Show less' : 'Show all'}
</button>
)}
</div>
<div className="max-h-48 space-y-1 overflow-y-auto rounded border border-border bg-surface p-2">
{(showAllErrors
? workerStatus.failures.recent
: workerStatus.failures.recent.slice(0, 5)
).map((f) => (
<div
key={f.photo_id}
className="border-b border-border/40 pb-1 last:border-b-0 last:pb-0"
>
<div className="flex items-center justify-between gap-2 text-[11px]">
<span className="truncate font-mono text-text">
{f.filename}
</span>
<span className="shrink-0 text-[10px] text-text-muted">
{f.media_type}
</span>
</div>
<div className="break-all font-mono text-[10px] text-reject">
{f.error || '(no error message)'}
</div>
</div>
))}
</div>
</div>
)}
{/* Recent scan errors (Redis list) */}
{workerStatus && workerStatus.scan_errors.length > 0 && (
<div className="mt-3">
<div className="mb-1 text-[10px] uppercase tracking-wide text-text-muted">
Scan errors
</div>
<div className="max-h-32 space-y-0.5 overflow-y-auto rounded border border-border bg-surface p-2 font-mono text-[10px] text-reject">
{workerStatus.scan_errors.map((e, i) => (
<div key={i} className="break-all">
{e}
</div>
))}
</div>
</div>
)}
{!workerStatus && (
<div className="mt-3 flex items-center gap-2 text-xs text-text-muted">
<Loader2 className="h-3 w-3 animate-spin" />
Loading worker status
</div>
)}
</Section>
{/* ----------------------------------------------------- */}
{/* Data integrity */}
{/* ----------------------------------------------------- */}
<Section
icon={<Wrench className="h-4 w-4" />}
title="Maintenance"
>
<p className="text-xs text-text-muted">
Re-runs the source-roots / folders / photos integrity
cleanup that normally only runs on backend startup. Safe
to run any time.
</p>
<div className="mt-2">
<ActionButton
loading={busy.cleanup}
onClick={() =>
runAction(
'cleanup',
() => library.maintenance.cleanup(),
'Data integrity cleanup complete'
)
}
>
<Wrench className="h-4 w-4" />
Run data integrity cleanup
</ActionButton>
</div>
</Section>
</div>
</div>
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Local presentational helpers — kept private to this file.
// ---------------------------------------------------------------------------
function Section({
icon,
title,
right,
children,
}: {
icon: React.ReactNode
title: string
right?: React.ReactNode
children: React.ReactNode
}) {
return (
<section className="mb-5 last:mb-0">
<div className="mb-2 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-sm font-medium text-text">
{icon}
{title}
</h3>
{right}
</div>
<div className="rounded border border-border bg-bg p-3">{children}</div>
</section>
)
}
function Stat({
label,
value,
tone = 'muted',
}: {
label: string
value: number | string | undefined
tone?: 'ok' | 'warn' | 'muted'
}) {
const toneClass =
tone === 'ok'
? 'text-pick'
: tone === 'warn'
? 'text-reject'
: 'text-text'
return (
<div className="rounded bg-surface p-2">
<div className="text-[10px] uppercase tracking-wide text-text-muted">
{label}
</div>
<div className={clsx('text-base font-semibold', toneClass)}>
{value ?? '—'}
</div>
</div>
)
}
function ErrorBanner({ title, detail }: { title: string; detail: string }) {
return (
<div className="mt-3 rounded border border-reject/40 bg-reject/10 p-2 text-xs">
<div className="flex items-center gap-1.5 font-medium text-reject">
<AlertTriangle className="h-3.5 w-3.5" />
{title}
</div>
<div className="mt-0.5 break-all font-mono text-[10px] text-reject/80">
{detail}
</div>
</div>
)
}
function ActionButton({
loading,
disabled,
destructive,
onClick,
children,
}: {
loading?: boolean
disabled?: boolean
destructive?: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<button
onClick={onClick}
disabled={disabled || loading}
className={clsx(
'flex items-center gap-2 rounded border px-3 py-1.5 text-xs font-medium transition-colors',
destructive
? 'border-reject/40 text-reject hover:bg-reject/10'
: 'border-border text-text hover:bg-surface-2',
(disabled || loading) && 'cursor-not-allowed opacity-50'
)}
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{!loading && children}
{loading && (
// Re-render only the textual children when loading by stripping
// the icon (children[0]) — we keep just the label so the spinner
// takes the icon slot.
Array.isArray(children) ? children.slice(1) : children
)}
</button>
)
}