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:
parent
d83faab8e6
commit
fb1115a409
11 changed files with 698 additions and 223 deletions
|
|
@ -1,5 +1,6 @@
|
|||
use anyhow::{Context, Result};
|
||||
use chrono::Local;
|
||||
use uuid::Uuid;
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
|
|
@ -677,7 +678,13 @@ pub fn perform_capture(
|
|||
locator: &str,
|
||||
archive_id: Option<&str>,
|
||||
) -> Result<CaptureResult> {
|
||||
let timestamp = Local::now().format("%Y-%m-%dT%H-%M-%S%.3f").to_string();
|
||||
// Append a UUID so parallel captures starting in the same millisecond
|
||||
// never collide on the staging directory or file names.
|
||||
let timestamp = format!(
|
||||
"{}-{}",
|
||||
Local::now().format("%Y-%m-%dT%H-%M-%S%.3f"),
|
||||
Uuid::new_v4().simple(),
|
||||
);
|
||||
let store_path = &archive_paths.store_path;
|
||||
|
||||
let conn = database::open_or_initialize(&archive_paths.archive_path)?;
|
||||
|
|
@ -685,24 +692,31 @@ pub fn perform_capture(
|
|||
|
||||
let mut source = determine_source(locator);
|
||||
|
||||
// For generic http/https URLs, probe Content-Type to distinguish a plain
|
||||
// file download from an HTML page that needs single-file archiving.
|
||||
// The probe runs before creating DB records so source_kind is set correctly.
|
||||
//
|
||||
// Note: probe failures return early without a DB run record. This is
|
||||
// intentional — a failed network probe means we haven't started archiving
|
||||
// and there is nothing meaningful to record.
|
||||
// Create the run record before probing so every attempt — including
|
||||
// probe failures — is visible in /runs with a proper status and error.
|
||||
let run = database::create_archive_run(&conn, user_id, 1)?;
|
||||
|
||||
// For generic http/https URLs, probe Content-Type to decide whether to
|
||||
// treat the URL as a raw file download or an HTML page for SingleFile.
|
||||
if source == Source::Url {
|
||||
match downloader::http::probe_url_kind(locator) {
|
||||
Ok(downloader::http::UrlKind::Html) => source = Source::WebPage,
|
||||
Ok(downloader::http::UrlKind::File) => {}
|
||||
Err(e) => return Err(anyhow::anyhow!("Failed to probe URL: {e}")),
|
||||
Err(e) => {
|
||||
// Record a failed item using the pre-probe source_kind so
|
||||
// failed_count increments and the item carries error_text.
|
||||
let (probe_sk, probe_ek, _) = source_metadata(Source::Url);
|
||||
let item = database::create_archive_run_item(
|
||||
&conn, run.id, None, 0, locator, None, probe_sk, probe_ek,
|
||||
)?;
|
||||
let msg = format!("Failed to probe URL: {e}");
|
||||
return Err(fail_run(&conn, &run, &item, &msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (source_kind, entity_kind, _) = source_metadata(source);
|
||||
|
||||
let run = database::create_archive_run(&conn, user_id, 1)?;
|
||||
let item = database::create_archive_run_item(
|
||||
&conn,
|
||||
run.id,
|
||||
|
|
|
|||
1
crates/archivr-server/static/assets/index-BZfMx3eM.css
Normal file
1
crates/archivr-server/static/assets/index-BZfMx3eM.css
Normal file
File diff suppressed because one or more lines are too long
40
crates/archivr-server/static/assets/index-CTwpNgei.js
Normal file
40
crates/archivr-server/static/assets/index-CTwpNgei.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -4,8 +4,8 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Archivr</title>
|
||||
<script type="module" crossorigin src="/assets/index-j6_-GyNh.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dhs0pFsV.css">
|
||||
<script type="module" crossorigin src="/assets/index-CTwpNgei.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BZfMx3eM.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import CollectionsView from './components/CollectionsView'
|
|||
import SettingsView from './components/SettingsView'
|
||||
import ContextRail from './components/ContextRail'
|
||||
import { displayPath } from './utils'
|
||||
import ToastStack from './components/ToastStack'
|
||||
|
||||
export const AuthContext = createContext(null);
|
||||
|
||||
|
|
@ -82,6 +83,9 @@ export default function App() {
|
|||
return saved === 'true'
|
||||
})
|
||||
|
||||
const [toasts, setToasts] = useState([])
|
||||
const toastIdRef = useRef(0)
|
||||
|
||||
const humanizeTags = currentUser?.humanize_slugs ?? false;
|
||||
|
||||
// Persist captureDialogOpen to sessionStorage
|
||||
|
|
@ -234,6 +238,15 @@ export default function App() {
|
|||
])
|
||||
}, [archiveId, searchQuery, tagFilter, loadEntries])
|
||||
|
||||
const handleToast = useCallback((errorText, locator) => {
|
||||
const id = ++toastIdRef.current
|
||||
setToasts(prev => [...prev, { id, errorText, locator }])
|
||||
}, [])
|
||||
|
||||
const handleDismissToast = useCallback((id) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}, [])
|
||||
|
||||
if (authState === 'loading') return <div className="auth-loading">Loading\u2026</div>;
|
||||
if (authState === 'setup') return <SetupPage onComplete={() => setAuthState('login')} />;
|
||||
if (authState === 'login') return <LoginPage onLogin={user => { setCurrentUser(user); setAuthState('authenticated'); }} />;
|
||||
|
|
@ -332,7 +345,9 @@ export default function App() {
|
|||
archiveId={archiveId}
|
||||
onClose={handleCaptureClose}
|
||||
onCaptured={handleCaptured}
|
||||
onToast={handleToast}
|
||||
/>
|
||||
<ToastStack toasts={toasts} onDismiss={handleDismissToast} />
|
||||
</>
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// jobUid → intervalId; survives dialog close since component stays mounted
|
||||
const pollIntervals = useRef(new Map())
|
||||
|
||||
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)
|
||||
// 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])
|
||||
|
||||
// Persist state to sessionStorage
|
||||
useEffect(() => {
|
||||
sessionStorage.setItem('captureDialogLocator', locator)
|
||||
}, [locator])
|
||||
|
||||
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" />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { useState } from 'react'
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
|
|
@ -20,6 +22,12 @@ function StatusBadge({ status }) {
|
|||
}
|
||||
|
||||
export default function RunsView({ runs }) {
|
||||
const [expanded, setExpanded] = useState(null) // run_uid of the expanded row
|
||||
|
||||
function toggle(uid) {
|
||||
setExpanded(prev => prev === uid ? null : uid)
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="runs-view" className="view is-active">
|
||||
<table className="entry-table">
|
||||
|
|
@ -34,16 +42,43 @@ export default function RunsView({ runs }) {
|
|||
</thead>
|
||||
<tbody>
|
||||
{runs.length === 0 ? (
|
||||
<tr><td colSpan={5} style={{ color: 'var(--muted)', padding: '24px 16px', textAlign: 'center' }}>No runs yet.</td></tr>
|
||||
) : runs.map((run, i) => (
|
||||
<tr key={i}>
|
||||
<td>{fmtDate(run.started_at)}</td>
|
||||
<td><StatusBadge status={run.status} /></td>
|
||||
<td>{run.requested_count ?? '—'}</td>
|
||||
<td>{run.completed_count ?? '—'}</td>
|
||||
<td>{run.failed_count ?? '—'}</td>
|
||||
<tr>
|
||||
<td colSpan={5} style={{ color: 'var(--muted)', padding: '24px 16px', textAlign: 'center' }}>
|
||||
No runs yet.
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
) : runs.map(run => {
|
||||
const hasError = run.status === 'failed' && run.error_summary
|
||||
const isExpanded = expanded === run.run_uid
|
||||
return [
|
||||
<tr
|
||||
key={run.run_uid}
|
||||
className={hasError ? 'run-row run-row--failed' : 'run-row'}
|
||||
onClick={hasError ? () => toggle(run.run_uid) : undefined}
|
||||
title={hasError ? (isExpanded ? 'Click to hide error' : 'Click to view error') : undefined}
|
||||
>
|
||||
<td>{fmtDate(run.started_at)}</td>
|
||||
<td>
|
||||
<StatusBadge status={run.status} />
|
||||
{hasError && (
|
||||
<span className="run-expand-hint" aria-hidden="true">
|
||||
{isExpanded ? '▴' : '▾'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{run.requested_count ?? '—'}</td>
|
||||
<td>{run.completed_count ?? '—'}</td>
|
||||
<td>{run.failed_count ?? '—'}</td>
|
||||
</tr>,
|
||||
hasError && isExpanded && (
|
||||
<tr key={`${run.run_uid}-detail`} className="run-error-row">
|
||||
<td colSpan={5}>
|
||||
<pre className="run-error-detail">{run.error_summary}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
),
|
||||
]
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
|
|
|||
63
frontend/src/components/ToastStack.jsx
Normal file
63
frontend/src/components/ToastStack.jsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
|
||||
const TOAST_TTL = 7000 // ms before auto-dismiss; paused when error is expanded
|
||||
|
||||
export default function ToastStack({ toasts, onDismiss }) {
|
||||
if (!toasts.length) return null
|
||||
return (
|
||||
<div className="toast-stack" role="log" aria-live="polite" aria-label="Notifications">
|
||||
{toasts.map(t => (
|
||||
<Toast key={t.id} toast={t} onDismiss={onDismiss} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Toast({ toast, onDismiss }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
// Auto-dismiss after TTL; paused while error detail is expanded
|
||||
useEffect(() => {
|
||||
if (expanded) return
|
||||
const timer = setTimeout(() => onDismiss(toast.id), TOAST_TTL)
|
||||
return () => clearTimeout(timer)
|
||||
}, [expanded, toast.id, onDismiss])
|
||||
|
||||
const short = toast.locator
|
||||
? (toast.locator.length > 48 ? toast.locator.slice(0, 45) + '\u2026' : toast.locator)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="toast toast--error" role="alert" aria-atomic="true">
|
||||
<div className="toast-top">
|
||||
<span className="toast-icon" aria-hidden="true">✕</span>
|
||||
<div className="toast-body">
|
||||
<span className="toast-headline">Capture failed</span>
|
||||
{short && <span className="toast-locator">{short}</span>}
|
||||
</div>
|
||||
<div className="toast-btns">
|
||||
{toast.errorText && (
|
||||
<button
|
||||
type="button"
|
||||
className="toast-view-btn"
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
>
|
||||
{expanded ? 'Hide' : 'View error'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="toast-dismiss"
|
||||
onClick={() => onDismiss(toast.id)}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && toast.errorText && (
|
||||
<pre className="toast-error-detail">{toast.errorText}</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -557,8 +557,7 @@ select {
|
|||
border: 1px solid var(--line);
|
||||
background: var(--paper);
|
||||
padding: 0;
|
||||
min-width: 360px;
|
||||
max-width: 520px;
|
||||
width: min(540px, calc(100vw - 32px));
|
||||
border-radius: var(--r3);
|
||||
box-shadow: 0 20px 60px rgba(20, 29, 24, 0.28);
|
||||
}
|
||||
|
|
@ -569,7 +568,7 @@ select {
|
|||
padding: 28px;
|
||||
}
|
||||
.capture-dialog-title {
|
||||
margin: 0 0 16px;
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-family: var(--display);
|
||||
font-weight: 600;
|
||||
|
|
@ -634,6 +633,233 @@ select {
|
|||
.capture-submit:hover { opacity: 0.85; }
|
||||
.capture-submit:disabled { opacity: 0.45; cursor: default; }
|
||||
|
||||
/* ── Capture dialog: header + multi-row ─────────────────────────────────── */
|
||||
.capture-dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.capture-dialog-close {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--r);
|
||||
padding: 0;
|
||||
}
|
||||
.capture-dialog-close svg { width: 14px; height: 14px; }
|
||||
.capture-dialog-close:hover { background: var(--paper-2); color: var(--ink); }
|
||||
|
||||
.capture-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.capture-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.capture-row-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
/* No bottom-margin on inputs inside rows; gap handles spacing */
|
||||
.capture-row-main .capture-input { margin-bottom: 0; }
|
||||
|
||||
/* Status dot */
|
||||
.cap-dot {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.cap-dot--idle { background: var(--paper-2); }
|
||||
.cap-dot--running { background: #fdefd8; color: #8a4f10; }
|
||||
.cap-dot--ok { background: #d8eddf; color: #235c35; }
|
||||
.cap-dot--err { background: #f5ddd8; color: #8d3f30; }
|
||||
|
||||
/* Spinner inside the running dot */
|
||||
.cap-spinner {
|
||||
display: block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 2px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: cap-spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes cap-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Per-row action buttons (remove / retry) */
|
||||
.capture-row-action {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--r);
|
||||
padding: 0;
|
||||
}
|
||||
.capture-row-action svg { width: 12px; height: 12px; }
|
||||
.capture-row-action:hover { color: var(--accent); background: var(--paper-2); }
|
||||
.capture-row-remove:hover { color: var(--accent); }
|
||||
|
||||
/* Inline error under a failed row */
|
||||
.capture-row-error {
|
||||
margin: 0;
|
||||
padding-left: 28px; /* align past the status dot */
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* Add-another button */
|
||||
.capture-add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: var(--r);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 7px 12px;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 18px;
|
||||
transition: border-color .15s, color .15s, background .15s;
|
||||
}
|
||||
.capture-add-row svg { width: 13px; height: 13px; flex-shrink: 0; }
|
||||
.capture-add-row:hover { border-color: var(--accent-2); color: var(--ink); background: var(--paper-2); }
|
||||
|
||||
/* ── Toast stack ─────────────────────────────────────────────────────────── */
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 9000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 340px;
|
||||
max-width: calc(100vw - 32px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r2);
|
||||
box-shadow: 0 6px 24px rgba(20, 29, 24, 0.18), 0 1px 4px rgba(20, 29, 24, 0.1);
|
||||
overflow: hidden;
|
||||
animation: toast-slide-in 0.22s cubic-bezier(.22,.68,0,1.2);
|
||||
}
|
||||
@keyframes toast-slide-in {
|
||||
from { opacity: 0; transform: translateY(10px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
.toast--error { border-left: 3px solid var(--accent); }
|
||||
|
||||
.toast-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 12px 12px 14px;
|
||||
}
|
||||
.toast-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.toast-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.toast-headline {
|
||||
display: block;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.toast-locator {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.toast-btns {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toast-view-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--link);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 3px 9px;
|
||||
border-radius: var(--r);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.toast-view-btn:hover { background: var(--paper-2); border-color: var(--link); }
|
||||
.toast-dismiss {
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--r);
|
||||
padding: 0;
|
||||
}
|
||||
.toast-dismiss:hover { color: var(--ink); background: var(--paper-2); }
|
||||
.toast-error-detail {
|
||||
margin: 0;
|
||||
padding: 10px 14px 12px;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--muted);
|
||||
background: var(--paper-2);
|
||||
border-top: 1px solid var(--line);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Utility ─────────────────────────────────────────────────────────────── */
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
|
|
@ -1311,6 +1537,32 @@ select {
|
|||
.run-status--failed { background: #f5ddd8; color: #8d3f30; border: 1px solid #e0b8b0; }
|
||||
.run-status--in-progress { background: #fdefd8; color: #8a4f10; border: 1px solid #f0cfa0; }
|
||||
|
||||
/* Clickable failed run rows and expandable error detail */
|
||||
.run-row--failed { cursor: pointer; }
|
||||
.run-row--failed:hover td { background: #fdf0ed !important; }
|
||||
.run-expand-hint {
|
||||
margin-left: 6px;
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.run-error-row td {
|
||||
padding: 0 !important;
|
||||
background: var(--paper-2) !important;
|
||||
}
|
||||
.run-error-detail {
|
||||
margin: 0;
|
||||
padding: 10px 16px 12px;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--muted);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Token created banner ────────────────────────────────────────────────── */
|
||||
.token-banner {
|
||||
background: #d8eddf;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue