1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

feat: non-blocking batch capture dialog, toasts on failure, fix /runs errors

CaptureDialog:
- Replace single textarea with multi-row inputs; + button adds rows
- Submit fires all pending rows in parallel, dialog stays open/usable
- Polling intervals live on a persistent ref (not cleared on close) so
  toasts fire even after the dialog is dismissed
- archiveId stored per item at submit time; page-refresh reconnect uses
  it.archiveId instead of the possibly-null prop
- Completed rows flash green then self-remove; failed rows show inline
  error + retry button
- Cancel becomes Close while jobs are in flight

ToastStack (new component):
- Fixed bottom-right overlay with spring-in animation
- Error toast: truncated locator, View error / Hide toggle expanding
  full error_text in a monospace pre block
- Auto-dismisses after 7 s; timer pauses while detail is expanded

RunsView:
- Failed rows are clickable and expand a full-width detail row showing
  error_summary in a scrollable monospace block

capture.rs (archivr-core):
- Staging dir is now "{millis}-{uuid}" — parallel captures in the same
  millisecond can no longer collide on temp paths
- create_archive_run moved before URL Content-Type probe so every
  attempt appears in /runs regardless of outcome
- Probe failures now call create_archive_run_item with source_metadata
  fallback then fail_run, recording error_text on the item and
  error_summary on the run with correct failed_count

styles.css:
- Capture dialog: header row, multi-row layout, status dots, spinner,
  add-row dashed button, per-row error text
- Toast stack: fixed overlay, error card with coloured left border,
  monospace detail expansion
- Run error rows: clickable hover tint, expand hint chevron, detail pre
This commit is contained in:
TheGeneralist 2026-07-05 13:56:00 +02:00
parent d83faab8e6
commit fb1115a409
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
11 changed files with 698 additions and 223 deletions

View file

@ -1,202 +1,298 @@
import { useRef, useEffect, useState } from 'react'
import { submitCapture, pollCaptureJob } from '../api'
export default function CaptureDialog({ open, archiveId, onClose, onCaptured }) {
let nextItemId = 1
function makeItem(locator = '') {
return { id: nextItemId++, locator, 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)
const hasResumedPollingRef = useRef(false)
const [locator, setLocator] = useState(() => {
const saved = sessionStorage.getItem('captureDialogLocator')
return saved || ''
})
const [error, setError] = useState(() => {
const saved = sessionStorage.getItem('captureDialogError')
return saved || null
})
const [busy, setBusy] = useState(() => {
const saved = sessionStorage.getItem('captureDialogBusy')
return saved === 'true'
})
const [jobStatus, setJobStatus] = useState(() => {
const saved = sessionStorage.getItem('captureDialogJobStatus')
return saved || null
})
const [jobUid, setJobUid] = useState(() => {
const saved = sessionStorage.getItem('captureDialogJobUid')
return saved || null
})
const pollRef = useRef(null)
// jobUid intervalId; survives dialog close since component stays mounted
const pollIntervals = useRef(new Map())
// Persist state to sessionStorage
useEffect(() => {
sessionStorage.setItem('captureDialogLocator', locator)
}, [locator])
// 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])
useEffect(() => {
sessionStorage.setItem('captureDialogError', error || '')
}, [error])
useEffect(() => {
sessionStorage.setItem('captureDialogBusy', busy)
}, [busy])
useEffect(() => {
sessionStorage.setItem('captureDialogJobStatus', jobStatus || '')
}, [jobStatus])
useEffect(() => {
sessionStorage.setItem('captureDialogJobUid', jobUid || '')
}, [jobUid])
// On mount, resume polling if a job was in progress before page refresh
useEffect(() => {
if (hasResumedPollingRef.current) return
if (!jobUid || jobStatus !== 'running' || !archiveId) return
hasResumedPollingRef.current = true
// Resume polling for the saved job
pollRef.current = setInterval(async () => {
try {
const updated = await pollCaptureJob(archiveId, jobUid)
if (updated.status === 'completed') {
clearInterval(pollRef.current)
pollRef.current = null
setBusy(false)
setJobStatus('completed')
clearCaptureState()
dialogRef.current?.close()
onCaptured()
} else if (updated.status === 'failed') {
clearInterval(pollRef.current)
pollRef.current = null
setBusy(false)
setJobStatus('failed')
setError(updated.error_text || 'Capture failed.')
}
// pending / running: keep polling
} catch (pollErr) {
clearInterval(pollRef.current)
pollRef.current = null
setBusy(false)
setError(pollErr.message)
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
}
}, 500)
}, [jobUid, jobStatus, archiveId, onCaptured])
} catch {}
return [makeItem()]
})
// Handle dialog close event
// Persist items to sessionStorage on every change
useEffect(() => {
sessionStorage.setItem('captureItems', JSON.stringify(items))
}, [items])
// 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 handleClose = () => {
clearInterval(pollRef.current)
onClose()
}
dialog.addEventListener('close', handleClose)
return () => dialog.removeEventListener('close', handleClose)
const handler = () => onClose()
dialog.addEventListener('close', handler)
return () => dialog.removeEventListener('close', handler)
}, [onClose])
// Handle open/close from parent
// Open/close driven by parent; don't reset if active jobs are in flight
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
if (open) {
// Only clear state if this is a fresh open from user click (not a restore on first render)
if (!isFirstRenderRef.current) {
setLocator('')
setError(null)
setJobStatus(null)
setBusy(false)
setJobUid(null)
clearInterval(pollRef.current)
if (!isFirstRenderRef.current && !hasActiveJobs(items)) {
setItems([makeItem()])
}
isFirstRenderRef.current = false
if (!dialog.open) dialog.showModal()
} else {
if (dialog.open) dialog.close()
}
}, [open])
}, [open]) // eslint-disable-line react-hooks/exhaustive-deps
function clearCaptureState() {
sessionStorage.removeItem('captureDialogLocator')
sessionStorage.removeItem('captureDialogError')
sessionStorage.removeItem('captureDialogBusy')
sessionStorage.removeItem('captureDialogJobStatus')
sessionStorage.removeItem('captureDialogJobUid')
setLocator('')
setError(null)
setBusy(false)
setJobStatus(null)
setJobUid(null)
// Clear all intervals on unmount (component teardown)
useEffect(() => {
return () => pollIntervals.current.forEach(id => clearInterval(id))
}, [])
function startPolling(itemId, jobUid, locator, aid) {
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)
// Show briefly then remove the row; if last row add a fresh one
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()
} 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)
}
// '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)
}
}, 500)
pollIntervals.current.set(jobUid, intervalId)
}
async function handleSubmit() {
if (!locator.trim()) { setError('Enter a locator.'); return }
setBusy(true)
setError(null)
setJobStatus(null)
async function submitItem(item) {
if (!item.locator.trim()) return
const aid = archiveId // capture at submit time
const loc = item.locator.trim()
setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'submitting', error: null } : it))
try {
const job = await submitCapture(archiveId, locator.trim())
setJobUid(job.job_uid)
setJobStatus('running')
pollRef.current = setInterval(async () => {
try {
const updated = await pollCaptureJob(archiveId, job.job_uid)
if (updated.status === 'completed') {
clearInterval(pollRef.current)
pollRef.current = null
setBusy(false)
setJobStatus('completed')
clearCaptureState()
dialogRef.current?.close()
onCaptured()
} else if (updated.status === 'failed') {
clearInterval(pollRef.current)
pollRef.current = null
setBusy(false)
setJobStatus('failed')
setError(updated.error_text || 'Capture failed.')
}
// pending / running: keep polling
} catch (pollErr) {
clearInterval(pollRef.current)
pollRef.current = null
setBusy(false)
setError(pollErr.message)
}
}, 500)
const job = await submitCapture(aid, loc)
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)
} catch (e) {
setError(e.message)
setBusy(false)
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)
}
}
function buttonLabel() {
if (!busy) return 'Capture'
if (jobStatus === 'running') return 'Running\u2026'
return 'Capturing\u2026'
function handleArchive() {
const toSubmit = items.filter(it => it.status === 'idle' && it.locator.trim())
toSubmit.forEach(it => submitItem(it))
}
function addRow() {
setItems(prev => [...prev, makeItem()])
}
function removeRow(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) {
setItems(prev => prev.map(it => it.id === id ? { ...it, locator: 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">
<h2 className="capture-dialog-title">Capture</h2>
<label htmlFor="capture-locator" className="capture-label">Locator</label>
<input id="capture-locator" className="capture-input" type="text"
placeholder="tweet:1234567890 or https://..."
value={locator} onChange={e => setLocator(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSubmit() }}
autoComplete="off" />
{error && <div className="capture-error">{error}</div>}
<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)}
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>
<div className="capture-actions">
<button type="button" className="capture-cancel" onClick={() => dialogRef.current?.close()}>Cancel</button>
<button type="button" className="capture-submit" onClick={handleSubmit} disabled={busy}>
{buttonLabel()}
<button type="button" className="capture-cancel" onClick={() => dialogRef.current?.close()}>
{anyActive ? 'Close' : 'Cancel'}
</button>
<button
type="button"
className="capture-submit"
onClick={handleArchive}
disabled={pendingCount === 0}
>
{pendingCount > 1 ? `Archive ${pendingCount}` : 'Archive'}
</button>
</div>
</div>
</dialog>
)
}
function CaptureRow({ item, autoFocus, onLocatorChange, 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
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 · 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}
/>
{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" />
}