1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-22 03:05:32 +02:00

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 (✕).
This commit is contained in:
TheGeneralist 2026-07-11 15:28:39 +02:00
parent 51d03f12e2
commit 4abfa7adc4
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
8 changed files with 187 additions and 79 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,8 +4,8 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Archivr</title> <title>Archivr</title>
<script type="module" crossorigin src="/assets/index-D6iX-c2T.js"></script> <script type="module" crossorigin src="/assets/index-BbGz9qlG.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C0BIEdow.css"> <link rel="stylesheet" crossorigin href="/assets/index-D0Nzcia0.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -241,10 +241,12 @@ export default function App() {
]) ])
}, [archiveId, searchQuery, tagFilter, loadEntries]) }, [archiveId, searchQuery, tagFilter, loadEntries])
const handleToast = useCallback((text, locator, type = 'error') => { const handleToast = useCallback((text, locator, type = 'error', headline = null) => {
if (type === 'warning' && ublockWarningIgnored) return // Only suppress per-item ublock/cookie warnings (those carry a locator).
// Batch summary warnings (locator = null) must always show.
if (type === 'warning' && ublockWarningIgnored && locator) return
const id = ++toastIdRef.current const id = ++toastIdRef.current
setToasts(prev => [...prev, { id, text, locator, type }]) setToasts(prev => [...prev, { id, text, locator, type, headline }])
}, [ublockWarningIgnored]) }, [ublockWarningIgnored])
const handleDismissToast = useCallback((id) => { const handleDismissToast = useCallback((id) => {
@ -254,7 +256,7 @@ export default function App() {
const handleIgnoreUblock = useCallback(() => { const handleIgnoreUblock = useCallback(() => {
sessionStorage.setItem('ublockWarningIgnored', 'true') sessionStorage.setItem('ublockWarningIgnored', 'true')
setUblockWarningIgnored(true) setUblockWarningIgnored(true)
setToasts(prev => prev.filter(t => t.type !== 'warning')) setToasts(prev => prev.filter(t => !(t.type === 'warning' && t.locator)))
}, []) }, [])
if (authState === 'loading') return <div className="auth-loading">Loading\u2026</div>; if (authState === 'loading') return <div className="auth-loading">Loading\u2026</div>;

View file

@ -85,6 +85,8 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
const pollIntervals = useRef(new Map()) const pollIntervals = useRef(new Map())
// itemId debounce timeoutId for probe calls // itemId debounce timeoutId for probe calls
const probeTimers = useRef(new Map()) 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 // stable ref so probe callbacks always see the current archiveId
const archiveIdRef = useRef(archiveId) const archiveIdRef = useRef(archiveId)
useEffect(() => { archiveIdRef.current = archiveId }, [archiveId]) useEffect(() => { archiveIdRef.current = archiveId }, [archiveId])
@ -194,7 +196,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
} }
}, []) }, [])
function startPolling(itemId, jobUid, locator, aid) { function startPolling(itemId, jobUid, locator, aid, batchId = null) {
if (pollIntervals.current.has(jobUid)) return // already polling if (pollIntervals.current.has(jobUid)) return // already polling
const intervalId = setInterval(async () => { const intervalId = setInterval(async () => {
try { try {
@ -202,7 +204,6 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
if (updated.status === 'completed') { if (updated.status === 'completed') {
clearInterval(pollIntervals.current.get(jobUid)) clearInterval(pollIntervals.current.get(jobUid))
pollIntervals.current.delete(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)) setItems(prev => prev.map(it => it.id === itemId ? { ...it, status: 'completed' } : it))
setTimeout(() => { setTimeout(() => {
setItems(prev => { setItems(prev => {
@ -211,13 +212,25 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
}) })
}, 1400) }, 1400)
onCapturedRef.current() onCapturedRef.current()
// Warn if uBlock was requested but the extension wasn't available
try { try {
const notes = updated.notes_json ? JSON.parse(updated.notes_json) : null const notes = updated.notes_json ? JSON.parse(updated.notes_json) : null
if (notes?.ublock_skipped || notes?.cookie_ext_skipped) { if (notes?.ublock_skipped || notes?.cookie_ext_skipped) {
onToastRef.current(null, locator, 'warning') 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 {} } catch {
if (!batchId) onToastRef.current(null, locator, 'success')
settleBatch(batchId, 'archived')
}
} else if (updated.status === 'failed') { } else if (updated.status === 'failed') {
clearInterval(pollIntervals.current.get(jobUid)) clearInterval(pollIntervals.current.get(jobUid))
pollIntervals.current.delete(jobUid) pollIntervals.current.delete(jobUid)
@ -226,6 +239,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
it.id === itemId ? { ...it, status: 'failed', error: errText } : it it.id === itemId ? { ...it, status: 'failed', error: errText } : it
)) ))
onToastRef.current(errText, locator) onToastRef.current(errText, locator)
settleBatch(batchId, 'failed', locator)
} }
// 'pending' / 'running': keep polling // 'pending' / 'running': keep polling
} catch (e) { } catch (e) {
@ -236,14 +250,51 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
it.id === itemId ? { ...it, status: 'failed', error: msg } : it it.id === itemId ? { ...it, status: 'failed', error: msg } : it
)) ))
onToastRef.current(msg, locator) onToastRef.current(msg, locator)
settleBatch(batchId, 'failed', locator)
} }
}, 500) }, 500)
pollIntervals.current.set(jobUid, intervalId) pollIntervals.current.set(jobUid, intervalId)
} }
async function submitItem(item) { // 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 if (!item.locator.trim()) return
const aid = archiveId // capture at submit time const aid = archiveId
const loc = item.locator.trim() const loc = item.locator.trim()
const qual = item.quality || 'best' const qual = item.quality || 'best'
setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'submitting', error: null } : it)) setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'submitting', error: null } : it))
@ -253,17 +304,27 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
setItems(prev => prev.map(it => setItems(prev => prev.map(it =>
it.id === item.id ? { ...it, status: 'running', jobUid: job.job_uid, archiveId: aid } : it it.id === item.id ? { ...it, status: 'running', jobUid: job.job_uid, archiveId: aid } : it
)) ))
startPolling(item.id, job.job_uid, loc, aid) startPolling(item.id, job.job_uid, loc, aid, batchId)
} catch (e) { } catch (e) {
const msg = e.message || 'Submission failed.' const msg = e.message || 'Submission failed.'
setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'failed', error: msg } : it)) setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'failed', error: msg } : it))
onToastRef.current(msg, loc) onToastRef.current(msg, loc)
settleBatch(batchId, 'failed', loc)
} }
} }
function handleArchive() { function handleArchive() {
const toSubmit = items.filter(it => it.status === 'idle' && it.locator.trim()) const toSubmit = items.filter(it => it.status === 'idle' && it.locator.trim())
toSubmit.forEach(it => submitItem(it)) 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() { function addRow() {

View file

@ -13,9 +13,27 @@ export default function ToastStack({ toasts, onDismiss, onIgnoreUblock }) {
) )
} }
// For URLs: show hostname//tail (drops protocol + middle path, keeps domain + identifier).
// For shorthands (yt:, x:, etc.): keep the tail since it's the whole identifier.
// CSS text-overflow:ellipsis on .toast-locator handles any remaining overflow.
function shortLocator(locator) {
if (!locator) return null
if (locator.length <= 52) return locator
try {
const { hostname, pathname, search } = new URL(locator)
const segments = pathname.split('/').filter(Boolean)
const tail = (segments[segments.length - 1] ?? '') + search
const candidate = tail ? `${hostname}/\u2026/${tail}` : hostname
return candidate.length <= 56 ? candidate : candidate.slice(0, 53) + '\u2026'
} catch {
return '\u2026' + locator.slice(-51)
}
}
function Toast({ toast, onDismiss, onIgnoreUblock }) { function Toast({ toast, onDismiss, onIgnoreUblock }) {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const isWarning = toast.type === 'warning' const isWarning = toast.type === 'warning'
const isSuccess = toast.type === 'success'
// Auto-dismiss after TTL; paused while detail is expanded // Auto-dismiss after TTL; paused while detail is expanded
useEffect(() => { useEffect(() => {
@ -24,9 +42,24 @@ function Toast({ toast, onDismiss, onIgnoreUblock }) {
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [expanded, toast.id, onDismiss]) }, [expanded, toast.id, onDismiss])
const short = toast.locator const short = shortLocator(toast.locator)
? (toast.locator.length > 48 ? toast.locator.slice(0, 45) + '\u2026' : toast.locator)
: null if (isSuccess) {
return (
<div className="toast toast--success" 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">{toast.headline || 'Archived'}</span>
{short && <span className="toast-locator" title={toast.locator}>{short}</span>}
</div>
<div className="toast-btns">
<button type="button" className="toast-dismiss" onClick={() => onDismiss(toast.id)} aria-label="Dismiss">×</button>
</div>
</div>
</div>
)
}
if (isWarning) { if (isWarning) {
return ( return (
@ -34,25 +67,29 @@ function Toast({ toast, onDismiss, onIgnoreUblock }) {
<div className="toast-top"> <div className="toast-top">
<span className="toast-icon" aria-hidden="true"></span> <span className="toast-icon" aria-hidden="true"></span>
<div className="toast-body"> <div className="toast-body">
<span className="toast-headline">Ad-blocking unavailable</span> <span className="toast-headline">{toast.headline || 'Archived with warnings'}</span>
{short && <span className="toast-locator">{short}</span>} {short && <span className="toast-locator" title={toast.locator}>{short}</span>}
</div> </div>
<div className="toast-btns"> <div className="toast-btns">
<button {toast.text && (
type="button" <button
className="toast-view-btn" type="button"
onClick={() => setExpanded(v => !v)} className="toast-view-btn"
aria-expanded={expanded} onClick={() => setExpanded(v => !v)}
> aria-expanded={expanded}
{expanded ? 'Hide' : 'Details'} >
</button> {expanded ? 'Hide' : 'Details'}
<button </button>
type="button" )}
className="toast-view-btn toast-ignore-btn" {toast.locator && (
onClick={() => { onIgnoreUblock?.(); onDismiss(toast.id) }} <button
> type="button"
Ignore className="toast-view-btn toast-ignore-btn"
</button> onClick={() => { onIgnoreUblock?.(); onDismiss(toast.id) }}
>
Ignore
</button>
)}
<button <button
type="button" type="button"
className="toast-dismiss" className="toast-dismiss"
@ -65,7 +102,7 @@ function Toast({ toast, onDismiss, onIgnoreUblock }) {
</div> </div>
{expanded && ( {expanded && (
<p className="toast-warning-detail"> <p className="toast-warning-detail">
{toast.text || 'ARCHIVR_UBLOCK=true but ARCHIVR_UBLOCK_EXT is not set or the path is invalid. The page was captured without ad-blocking. Set ARCHIVR_UBLOCK_EXT to the unpacked uBlock Origin Lite extension directory to enable ad-blocking, or set ARCHIVR_UBLOCK=false to silence this warning.'} {toast.text || 'The page was captured but one or more browser extensions were unavailable (ad-blocking or cookie-consent). Check ARCHIVR_UBLOCK_EXT and ARCHIVR_COOKIE_EXT in your server config.'}
</p> </p>
)} )}
</div> </div>
@ -77,8 +114,8 @@ function Toast({ toast, onDismiss, onIgnoreUblock }) {
<div className="toast-top"> <div className="toast-top">
<span className="toast-icon" aria-hidden="true"></span> <span className="toast-icon" aria-hidden="true"></span>
<div className="toast-body"> <div className="toast-body">
<span className="toast-headline">Capture failed</span> <span className="toast-headline">{toast.headline || 'Capture failed'}</span>
{short && <span className="toast-locator">{short}</span>} {short && <span className="toast-locator" title={toast.locator}>{short}</span>}
</div> </div>
<div className="toast-btns"> <div className="toast-btns">
{toast.text && ( {toast.text && (

View file

@ -869,6 +869,7 @@ select {
} }
.toast--error { border-left: 3px solid var(--accent); } .toast--error { border-left: 3px solid var(--accent); }
.toast--warning { border-left: 3px solid #e8a000; } .toast--warning { border-left: 3px solid #e8a000; }
.toast--success { border-left: 3px solid #22a855; }
.toast-top { .toast-top {
display: flex; display: flex;
@ -883,6 +884,8 @@ select {
color: var(--accent); color: var(--accent);
margin-top: 2px; margin-top: 2px;
} }
.toast--success .toast-icon { color: #22a855; }
.toast--warning .toast-icon { color: #e8a000; }
.toast-body { .toast-body {
flex: 1; flex: 1;
min-width: 0; min-width: 0;