1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00
archivr/frontend/src/components/CaptureDialog.jsx
TheGeneralist 03390362c5
capture: close dialog on Archive; rich per-URL and batch toast notifications (#22)
* http: realistic UA; fall back to WebPage on probe failure

Replace bare 'archivr/0.1' user agent with a full Chrome 131 UA string
(archivr/0.1 token retained at the end) in both probe_url_kind and download.

More importantly, stop hard-failing when probe_url_kind returns an error.
Sites behind Cloudflare's managed JS challenge (e.g. Medium) return 403
before any header tuning can help — a plain HTTP client cannot pass the
challenge. Instead, log a warning and fall back to Source::WebPage so that
the SingleFile/Chromium path gets a chance; a real browser can solve the
challenge transparently.

* capture: close dialog on Archive; rich per-URL and batch toast notifications

UX changes:
- Pressing Archive closes the capture dialog immediately; jobs continue
  polling in the background (component stays mounted).
- Probe failures (e.g. Cloudflare JS challenge, HTTP 403) now fall back to
  Source::WebPage so SingleFile/Chromium can attempt the capture instead of
  hard-failing at the probe stage.

Toast notifications:
- Single URL: green 'Archived' on success, amber 'Archived with warnings'
  (with expandable detail) when uBlock/cookie-ext was skipped, red 'Capture
  failed' with expandable error text on failure. Per-item warning/error toasts
  include the locator so the user knows which URL was affected.
- Multi-URL batch: per-item failure and warning toasts still fire with
  locators; per-item success toasts are suppressed. Once all jobs settle a
  single summary toast fires: 'N archived', 'N archived (M with warnings)',
  'N archived (M with warnings), F failed', or 'N failed'. The summary Detail
  section lists the exact URLs that failed or warned, so the user retains that
  information after per-item toasts auto-dismiss.
- Batch summary color: green = all clean; amber = any warnings or failures
  present; red = all failed.
- handleIgnoreUblock now only removes per-item warning toasts (those with a
  locator) and persists the ignore flag; batch summary warnings (locator=null)
  are not swept.

ToastStack improvements:
- All three branches (success/warning/error) support a toast.headline field
  so batch summaries can set their own copy.
- Warning branch: Details button conditional on toast.text; Ignore button
  conditional on toast.locator (uBlock-specific, not shown on batch summaries).
- Locator display uses hostname/…/last-segment for URLs (preserves domain for
  context, tail for identity) and tail-truncation for shorthands; full locator
  in title attribute for hover.
- Icon colors: green for success (✓), amber for warning (⚠), red for error (✕).
2026-07-11 15:29:10 +02:00

633 lines
26 KiB
JavaScript

import { useRef, useEffect, useState, useCallback } from 'react'
import { submitCapture, pollCaptureJob, probeCapture, getInstanceSettings } from '../api'
let nextItemId = 1
// Returns true only for locators that determine_source() routes to yt-dlp download.
// Mirrors the exact conditions in capture.rs — playlist/channel shorthands are excluded
// (they return an "not yet implemented" error), as are tweet/thread shorthands.
function isVideoSource(locator) {
const l = locator.trim()
const ll = l.toLowerCase()
// yt: / youtube: shorthands — video/short/shorts only; playlist and channel are unsupported
for (const scheme of ['yt:', 'youtube:']) {
if (ll.startsWith(scheme)) {
const after = ll.slice(scheme.length)
return after.startsWith('video/') || after.startsWith('short/') || after.startsWith('shorts/')
}
}
// ytm: shorthand — track only; playlist is not yet implemented
if (ll.startsWith('ytm:')) {
return !ll.slice(4).startsWith('playlist/')
}
// x: / twitter: / tweet: shorthands — only x:media:ID routes to yt-dlp (Source::X)
for (const scheme of ['x:', 'twitter:', 'tweet:']) {
if (ll.startsWith(scheme)) {
return ll.slice(scheme.length).startsWith('media:')
}
}
// spotify: shorthands — all will fail with a clear error; no probe needed
if (ll.startsWith('spotify:')) return false
// Other platform shorthands — all go to yt-dlp
if (ll.startsWith('instagram:') || ll.startsWith('facebook:') ||
ll.startsWith('tiktok:') || ll.startsWith('reddit:') ||
ll.startsWith('snapchat:')) return true
// HTTP/HTTPS URLs — match the same regexes and prefix checks as determine_source
if (ll.startsWith('http://') || ll.startsWith('https://')) {
// YouTube Music track (watch) — before generic YouTube check
if (/^https?:\/\/music\.youtube\.com\/watch/.test(ll)) return true
// YouTube video (watch, youtu.be, shorts) — not playlist or channel
if (/^https?:\/\/(?:www\.)?(?:youtu\.be\/[0-9A-Za-z_-]+|youtube\.com\/watch\?v=[0-9A-Za-z_-]+|youtube\.com\/shorts\/[0-9A-Za-z_-]+)/.test(l)) return true
// x.com → Source::X → yt-dlp (note: twitter.com URLs fall through to Source::Url, not yt-dlp)
if (ll.startsWith('https://x.com/') || ll.startsWith('http://x.com/')) return true
// Instagram
if (/^https?:\/\/(?:www\.)?instagram\.com\//.test(ll)) return true
// Facebook + fb.watch
if (/^https?:\/\/(?:www\.)?facebook\.com\//.test(ll) || ll.startsWith('https://fb.watch/') || ll.startsWith('http://fb.watch/')) return true
// TikTok
if (/^https?:\/\/(?:www\.)?tiktok\.com\//.test(ll)) return true
// Reddit + redd.it
if (/^https?:\/\/(?:www\.)?reddit\.com\//.test(ll) || ll.startsWith('https://redd.it/') || ll.startsWith('http://redd.it/')) return true
// Snapchat
if (/^https?:\/\/(?:www\.)?snapchat\.com\//.test(ll)) return true
// Spotify — all will fail with a clear error; no probe needed
if (ll.startsWith('https://open.spotify.com/') || ll.startsWith('http://open.spotify.com/')) return false
}
return false
}
function makeItem(locator = '') {
return {
id: nextItemId++, locator, quality: 'best',
// probe: tracks yt-dlp metadata fetch for the locator
probeState: 'idle', // 'idle' | 'probing' | 'done'
probeQualities: null, // null | string[] when done, e.g. ["1080p","720p","480p"]
probeHasAudio: false, // true when probe confirms at least one audio track
status: 'idle', error: null, jobUid: null, archiveId: null,
}
}
function hasActiveJobs(items) {
return items.some(it => it.status === 'submitting' || it.status === 'running')
}
export default function CaptureDialog({ open, archiveId, onClose, onCaptured, onToast }) {
const dialogRef = useRef(null)
const isFirstRenderRef = useRef(true)
// jobUid → intervalId; survives dialog close since component stays mounted
const pollIntervals = useRef(new Map())
// itemId → debounce timeoutId for probe calls
const probeTimers = useRef(new Map())
// batchId → { total, archived, warnings, failed }; only populated for multi-URL submits
const batchRef = useRef(new Map())
// stable ref so probe callbacks always see the current archiveId
const archiveIdRef = useRef(archiveId)
useEffect(() => { archiveIdRef.current = archiveId }, [archiveId])
// Stable refs so polling callbacks always use the latest prop values
const onCapturedRef = useRef(onCaptured)
const onToastRef = useRef(onToast)
useEffect(() => { onCapturedRef.current = onCaptured }, [onCaptured])
useEffect(() => { onToastRef.current = onToast }, [onToast])
const [items, setItems] = useState(() => {
try {
const saved = JSON.parse(sessionStorage.getItem('captureItems') || 'null')
if (Array.isArray(saved) && saved.length > 0) {
// Ensure nextItemId stays ahead of restored ids
saved.forEach(it => { if (it.id >= nextItemId) nextItemId = it.id + 1 })
return saved
}
} catch {}
return [makeItem()]
})
// Persist items to sessionStorage on every change
useEffect(() => {
sessionStorage.setItem('captureItems', JSON.stringify(items))
}, [items])
// Advanced options panel state
const [advancedOpen, setAdvancedOpen] = useState(false)
// null = use server default; true/false = per-session override
const [ublockOverride, setUblockOverride] = useState(null)
// Server-side global settings (loaded on mount, null until loaded)
const [globalSettings, setGlobalSettings] = useState(null)
// Cookie consent: session-level only, initialized from server default
const [cookieExtEnabled, setCookieExtEnabled] = useState(true)
// Load global settings from server once on mount
useEffect(() => {
getInstanceSettings()
.then(s => {
setGlobalSettings(s)
setCookieExtEnabled(s.cookie_ext_enabled ?? true)
})
.catch(() => setGlobalSettings({}))
}, [])
// Effective uBlock for this session
const ublockEnabled = ublockOverride !== null ? ublockOverride : (globalSettings?.ublock_enabled ?? true)
// Reader mode: off by default, per-session only
const [readerMode, setReaderMode] = useState(false)
// On mount: clean up old single-locator sessionStorage keys; reconnect running jobs
useEffect(() => {
;['captureDialogLocator','captureDialogError','captureDialogBusy',
'captureDialogJobStatus','captureDialogJobUid'].forEach(k => sessionStorage.removeItem(k))
setItems(prev => prev.map(it =>
// 'submitting' means page was refreshed mid-fetch — reset to idle so user can retry
it.status === 'submitting' ? { ...it, status: 'idle', error: null } : it
))
// Reconnect polling for any jobs still running from a previous session
items.forEach(it => {
if (it.status === 'running' && it.jobUid && it.archiveId && !pollIntervals.current.has(it.jobUid)) {
startPolling(it.id, it.jobUid, it.locator, it.archiveId)
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) // intentionally runs once on mount with initial values
// Handle native dialog 'close' event (Escape key or programmatic .close())
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
const handler = () => {
probeTimers.current.forEach(id => clearTimeout(id))
probeTimers.current.clear()
onClose()
}
dialog.addEventListener('close', handler)
return () => dialog.removeEventListener('close', handler)
}, [onClose])
// Open/close driven by parent; don't reset if active jobs are in flight
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
if (open) {
if (!isFirstRenderRef.current && !hasActiveJobs(items)) {
setItems([makeItem()])
}
isFirstRenderRef.current = false
if (!dialog.open) dialog.showModal()
} else {
probeTimers.current.forEach(id => clearTimeout(id))
probeTimers.current.clear()
if (dialog.open) dialog.close()
}
}, [open]) // eslint-disable-line react-hooks/exhaustive-deps
// Clear all intervals and probe timers on unmount (component teardown)
useEffect(() => {
return () => {
pollIntervals.current.forEach(id => clearInterval(id))
probeTimers.current.forEach(id => clearTimeout(id))
}
}, [])
function startPolling(itemId, jobUid, locator, aid, batchId = null) {
if (pollIntervals.current.has(jobUid)) return // already polling
const intervalId = setInterval(async () => {
try {
const updated = await pollCaptureJob(aid, jobUid)
if (updated.status === 'completed') {
clearInterval(pollIntervals.current.get(jobUid))
pollIntervals.current.delete(jobUid)
setItems(prev => prev.map(it => it.id === itemId ? { ...it, status: 'completed' } : it))
setTimeout(() => {
setItems(prev => {
const next = prev.filter(it => it.id !== itemId)
return next.length === 0 ? [makeItem()] : next
})
}, 1400)
onCapturedRef.current()
try {
const notes = updated.notes_json ? JSON.parse(updated.notes_json) : null
if (notes?.ublock_skipped || notes?.cookie_ext_skipped) {
const both = notes.ublock_skipped && notes.cookie_ext_skipped
const msg = both
? 'Captured without ad-blocking or cookie-consent extension. Check ARCHIVR_UBLOCK_EXT and ARCHIVR_COOKIE_EXT in your server config.'
: notes.ublock_skipped
? 'Captured without ad-blocking. ARCHIVR_UBLOCK=true but ARCHIVR_UBLOCK_EXT is not set or the path is invalid.'
: 'Captured without cookie-consent extension. ARCHIVR_COOKIE_EXT is not set or the path is invalid.'
onToastRef.current(msg, locator, 'warning')
settleBatch(batchId, 'warning', locator)
} else {
if (!batchId) onToastRef.current(null, locator, 'success')
settleBatch(batchId, 'archived')
}
} catch {
if (!batchId) onToastRef.current(null, locator, 'success')
settleBatch(batchId, 'archived')
}
} else if (updated.status === 'failed') {
clearInterval(pollIntervals.current.get(jobUid))
pollIntervals.current.delete(jobUid)
const errText = updated.error_text || 'Capture failed.'
setItems(prev => prev.map(it =>
it.id === itemId ? { ...it, status: 'failed', error: errText } : it
))
onToastRef.current(errText, locator)
settleBatch(batchId, 'failed', locator)
}
// 'pending' / 'running': keep polling
} catch (e) {
clearInterval(pollIntervals.current.get(jobUid))
pollIntervals.current.delete(jobUid)
const msg = e.message || 'Network error'
setItems(prev => prev.map(it =>
it.id === itemId ? { ...it, status: 'failed', error: msg } : it
))
onToastRef.current(msg, locator)
settleBatch(batchId, 'failed', locator)
}
}, 500)
pollIntervals.current.set(jobUid, intervalId)
}
// Increments the batch counter for the given outcome and emits a summary
// toast once all jobs in the batch have settled.
// 'warning' counts as archived (succeeded with caveats); locator recorded for detail text.
function settleBatch(batchId, outcome, locator = null) {
if (!batchId) return
const batch = batchRef.current.get(batchId)
if (!batch) return
if (outcome === 'archived' || outcome === 'warning') {
batch.archived++
if (outcome === 'warning') { batch.warnings++; if (locator) batch.warningLocators.push(locator) }
} else {
batch.failed++
if (locator) batch.failedLocators.push(locator)
}
if (batch.archived + batch.failed < batch.total) return
// All settled — build headline + detail text, then emit summary and clean up.
batchRef.current.delete(batchId)
const { archived, warnings, failed, failedLocators, warningLocators } = batch
let headline
if (archived === 0) {
headline = `${failed} failed`
} else {
const archivedStr = warnings > 0
? `${archived} archived (${warnings} with warnings)`
: `${archived} archived`
headline = failed > 0 ? `${archivedStr}, ${failed} failed` : archivedStr
}
const type = archived === 0 ? 'error' : (failed > 0 || warnings > 0) ? 'warning' : 'success'
const parts = []
if (failedLocators.length > 0) parts.push(`Failed:\n${failedLocators.map(l => ` ${l}`).join('\n')}`)
if (warningLocators.length > 0) parts.push(`With warnings:\n${warningLocators.map(l => ` ${l}`).join('\n')}`)
const text = parts.length > 0 ? parts.join('\n') : null
onToastRef.current(text, null, type, headline)
}
async function submitItem(item, batchId = null) {
if (!item.locator.trim()) return
const aid = archiveId
const loc = item.locator.trim()
const qual = item.quality || 'best'
setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'submitting', error: null } : it))
try {
const extensions = { ublock_enabled: ublockEnabled, reader_mode: readerMode, cookie_ext_enabled: cookieExtEnabled }
const job = await submitCapture(aid, loc, qual, extensions)
setItems(prev => prev.map(it =>
it.id === item.id ? { ...it, status: 'running', jobUid: job.job_uid, archiveId: aid } : it
))
startPolling(item.id, job.job_uid, loc, aid, batchId)
} catch (e) {
const msg = e.message || 'Submission failed.'
setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'failed', error: msg } : it))
onToastRef.current(msg, loc)
settleBatch(batchId, 'failed', loc)
}
}
function handleArchive() {
const toSubmit = items.filter(it => it.status === 'idle' && it.locator.trim())
if (toSubmit.length === 0) return
const batchId = toSubmit.length > 1
? (crypto.randomUUID?.() ?? `batch-${Date.now()}`)
: null
if (batchId) {
batchRef.current.set(batchId, { total: toSubmit.length, archived: 0, warnings: 0, failed: 0, failedLocators: [], warningLocators: [] })
}
toSubmit.forEach(it => submitItem(it, batchId))
dialogRef.current?.close()
}
function addRow() {
setItems(prev => [...prev, makeItem()])
}
function removeRow(id) {
clearTimeout(probeTimers.current.get(id))
probeTimers.current.delete(id)
setItems(prev => {
const next = prev.filter(it => it.id !== id)
return next.length === 0 ? [makeItem()] : next
})
}
function resetRow(id) {
setItems(prev => prev.map(it =>
it.id === id ? { ...it, status: 'idle', error: null } : it
))
}
function updateLocator(id, val) {
// Cancel any in-flight debounce and immediately clear stale probe results.
// This prevents old qualities from being visible (and submittable) while
// the 600ms debounce is pending for the new URL.
clearTimeout(probeTimers.current.get(id))
setItems(prev => prev.map(it =>
it.id === id
? { ...it, locator: val, probeState: 'idle', probeQualities: null, probeHasAudio: false, quality: 'best' }
: it
))
if (!isVideoSource(val)) return
// Schedule a fresh probe after the user stops typing
const timer = setTimeout(async () => {
probeTimers.current.delete(id)
setItems(prev => prev.map(it => it.id === id ? { ...it, probeState: 'probing' } : it))
try {
const result = await probeCapture(archiveIdRef.current, val.trim())
setItems(prev => prev.map(it => {
if (it.id !== id || it.locator !== val) return it // stale — locator changed again
const qualities = result.qualities ?? []
const hasAudio = result.has_audio ?? false
// Audio-only source: no video heights but audio confirmed — force audio mode
const quality = (qualities.length === 0 && hasAudio) ? 'audio' : 'best'
return { ...it, probeState: 'done', probeQualities: qualities, probeHasAudio: hasAudio, quality }
}))
} catch {
// Probe failed (network error, etc.) — clear silently; user can still submit
setItems(prev => prev.map(it =>
it.id === id ? { ...it, probeState: 'idle', probeQualities: null } : it
))
}
}, 600)
probeTimers.current.set(id, timer)
}
function updateQuality(id, val) {
setItems(prev => prev.map(it => it.id === id ? { ...it, quality: val } : it))
}
const pendingCount = items.filter(it => it.status === 'idle' && it.locator.trim()).length
const anyActive = hasActiveJobs(items)
return (
<dialog ref={dialogRef} className="capture-dialog">
<div className="capture-dialog-inner">
<div className="capture-dialog-header">
<h2 className="capture-dialog-title">Capture</h2>
<button
type="button"
className="capture-dialog-close"
onClick={() => dialogRef.current?.close()}
aria-label="Close"
>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="3" y1="3" x2="13" y2="13"/><line x1="13" y1="3" x2="3" y2="13"/>
</svg>
</button>
</div>
<div className="capture-rows">
{items.map((item, idx) => (
<CaptureRow
key={item.id}
item={item}
autoFocus={idx === items.length - 1 && item.status === 'idle'}
onLocatorChange={val => updateLocator(item.id, val)}
onQualityChange={val => updateQuality(item.id, val)}
onRemove={() => removeRow(item.id)}
onReset={() => resetRow(item.id)}
onSubmit={handleArchive}
/>
))}
</div>
<button type="button" className="capture-add-row" onClick={addRow}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="8" y1="2" x2="8" y2="14"/><line x1="2" y1="8" x2="14" y2="8"/>
</svg>
Add another
</button>
{/* ── Advanced options ────────────────────────────── */}
<div className="capture-advanced">
<button
type="button"
className="capture-advanced-toggle"
onClick={() => setAdvancedOpen(v => !v)}
aria-expanded={advancedOpen}
>
<svg
className={`capture-chevron${advancedOpen ? ' capture-chevron--open' : ''}`}
viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
>
<polyline points="4 6 8 10 12 6"/>
</svg>
Advanced options
</button>
{advancedOpen && (
<div className="capture-advanced-panel">
<label className="capture-ext-row">
<span className="capture-ext-label">
<span className="capture-ext-name">uBlock Origin Lite</span>
<span className="capture-ext-desc">Block ads during this capture</span>
</span>
<button
type="button"
role="switch"
aria-checked={ublockEnabled}
className={`ext-toggle ext-toggle--sm${ublockEnabled ? ' ext-toggle--on' : ''}`}
onClick={() => setUblockOverride(v => v === null ? !ublockEnabled : !v)}
aria-label="Toggle uBlock for this capture"
>
<span className="ext-toggle-knob" />
</button>
</label>
<label className="capture-ext-row" style={{ marginTop: 8 }}>
<span className="capture-ext-label">
<span className="capture-ext-name">Block cookie banners</span>
<span className="capture-ext-desc">Dismiss cookie consent banners during this capture</span>
{!globalSettings?.cookie_ext_available && (
<span className="capture-ext-hint">Not configured &mdash; set <code>ARCHIVR_COOKIE_EXT</code></span>
)}
</span>
<button
type="button"
role="switch"
aria-checked={cookieExtEnabled}
className={`ext-toggle ext-toggle--sm${cookieExtEnabled ? ' ext-toggle--on' : ''}`}
onClick={() => setCookieExtEnabled(v => !v)}
aria-label="Toggle cookie banner blocking for this capture"
>
<span className="ext-toggle-knob" />
</button>
</label>
<label className="capture-ext-row" style={{ marginTop: 8 }}>
<span className="capture-ext-label">
<span className="capture-ext-name">Reader mode</span>
<span className="capture-ext-desc">Distil to article text via Readability (off by default)</span>
</span>
<button
type="button"
role="switch"
aria-checked={readerMode}
className={`ext-toggle ext-toggle--sm${readerMode ? ' ext-toggle--on' : ''}`}
onClick={() => setReaderMode(v => !v)}
aria-label="Toggle reader mode for this capture"
>
<span className="ext-toggle-knob" />
</button>
</label>
</div>
)}
</div>
{/* ── Primary action ──────────────────────────────── */}
<div className="capture-actions">
<button
type="button"
className="capture-submit"
onClick={handleArchive}
disabled={pendingCount === 0}
>
{pendingCount > 1 ? `Archive ${pendingCount}` : 'Archive'}
</button>
<button type="button" className="capture-cancel" onClick={() => dialogRef.current?.close()}>
{anyActive ? 'Close' : 'Cancel'}
</button>
</div>
</div>
</dialog>
)
}
function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemove, onReset, onSubmit }) {
const inputRef = useRef(null)
const isActive = item.status === 'submitting' || item.status === 'running'
useEffect(() => {
if (autoFocus && item.status === 'idle') {
inputRef.current?.focus()
}
}, [autoFocus]) // eslint-disable-line react-hooks/exhaustive-deps
// Quality control shown right of the input (hidden when active or completed)
const qualityEl = (() => {
if (item.status === 'completed' || isActive) return null
if (!isVideoSource(item.locator)) return null
if (item.probeState === 'probing') {
return <span className="capture-quality-probing" aria-label="Checking available qualities"></span>
}
if (item.probeState === 'done') {
const qualities = item.probeQualities ?? []
const hasAudio = item.probeHasAudio ?? false
if (qualities.length === 0 && !hasAudio) {
return <span className="capture-quality-hint">No media detected</span>
}
if (qualities.length === 0 && hasAudio) {
// Audio-only source: no video tracks, only audio available.
// Don't offer "Best quality" — it would request a video format and fail.
return (
<select
className="capture-quality"
value="audio"
onChange={e => onQualityChange(e.target.value)}
aria-label="Video quality"
>
<option value="audio">Audio only</option>
</select>
)
}
return (
<select
className="capture-quality"
value={item.quality || 'best'}
onChange={e => onQualityChange(e.target.value)}
aria-label="Video quality"
>
<option value="best">Best quality</option>
{qualities.map(q => <option key={q} value={q}>{q}</option>)}
{hasAudio && <option value="audio">Audio only</option>}
</select>
)
}
return null // probeState === 'idle', debounce not yet fired
})()
return (
<div className={`capture-row capture-row--${item.status}`}>
<div className="capture-row-main">
<CapStatusDot status={item.status} />
<input
ref={inputRef}
className="capture-input"
type="text"
placeholder="https://… · yt:ID · ytm:ID · tweet:ID · x:ID"
value={item.locator}
onChange={e => onLocatorChange(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') onSubmit() }}
disabled={isActive || item.status === 'completed'}
autoComplete="off"
spellCheck={false}
/>
{qualityEl}
{item.status === 'failed' && (
<button type="button" className="capture-row-action" onClick={onReset} title="Retry">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M13 2.5A7 7 0 1 1 6.5 1"/>
<polyline points="6.5 1 4 3.5 6.5 6"/>
</svg>
</button>
)}
{!isActive && item.status !== 'completed' && item.status !== 'failed' && (
<button type="button" className="capture-row-action capture-row-remove" onClick={onRemove} aria-label="Remove">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="3" y1="3" x2="13" y2="13"/><line x1="13" y1="3" x2="3" y2="13"/>
</svg>
</button>
)}
</div>
{item.error && (
<p className="capture-row-error">{item.error}</p>
)}
</div>
)
}
function CapStatusDot({ status }) {
if (status === 'submitting' || status === 'running') {
return (
<span className="cap-dot cap-dot--running" aria-label="Running">
<span className="cap-spinner" />
</span>
)
}
if (status === 'completed') {
return <span className="cap-dot cap-dot--ok" aria-label="Done"></span>
}
if (status === 'failed') {
return <span className="cap-dot cap-dot--err" aria-label="Failed"></span>
}
return <span className="cap-dot cap-dot--idle" aria-hidden="true" />
}