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 name="viewport" content="width=device-width, initial-scale=1" />
<title>Archivr</title>
<script type="module" crossorigin src="/assets/index-D6iX-c2T.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C0BIEdow.css">
<script type="module" crossorigin src="/assets/index-BbGz9qlG.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D0Nzcia0.css">
</head>
<body>
<div id="root"></div>

View file

@ -241,10 +241,12 @@ export default function App() {
])
}, [archiveId, searchQuery, tagFilter, loadEntries])
const handleToast = useCallback((text, locator, type = 'error') => {
if (type === 'warning' && ublockWarningIgnored) return
const handleToast = useCallback((text, locator, type = 'error', headline = null) => {
// 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
setToasts(prev => [...prev, { id, text, locator, type }])
setToasts(prev => [...prev, { id, text, locator, type, headline }])
}, [ublockWarningIgnored])
const handleDismissToast = useCallback((id) => {
@ -254,7 +256,7 @@ export default function App() {
const handleIgnoreUblock = useCallback(() => {
sessionStorage.setItem('ublockWarningIgnored', '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>;

View file

@ -85,6 +85,8 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
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])
@ -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
const intervalId = setInterval(async () => {
try {
@ -202,7 +204,6 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
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 => {
@ -211,13 +212,25 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
})
}, 1400)
onCapturedRef.current()
// Warn if uBlock was requested but the extension wasn't available
try {
const notes = updated.notes_json ? JSON.parse(updated.notes_json) : null
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 {
if (!batchId) onToastRef.current(null, locator, 'success')
settleBatch(batchId, 'archived')
}
} catch {}
} else if (updated.status === 'failed') {
clearInterval(pollIntervals.current.get(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
))
onToastRef.current(errText, locator)
settleBatch(batchId, 'failed', locator)
}
// 'pending' / 'running': keep polling
} catch (e) {
@ -236,14 +250,51 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
it.id === itemId ? { ...it, status: 'failed', error: msg } : it
))
onToastRef.current(msg, locator)
settleBatch(batchId, 'failed', locator)
}
}, 500)
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
const aid = archiveId // capture at submit time
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))
@ -253,17 +304,27 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
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)
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())
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() {

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 }) {
const [expanded, setExpanded] = useState(false)
const isWarning = toast.type === 'warning'
const isSuccess = toast.type === 'success'
// Auto-dismiss after TTL; paused while detail is expanded
useEffect(() => {
@ -24,9 +42,24 @@ function Toast({ toast, onDismiss, onIgnoreUblock }) {
return () => clearTimeout(timer)
}, [expanded, toast.id, onDismiss])
const short = toast.locator
? (toast.locator.length > 48 ? toast.locator.slice(0, 45) + '\u2026' : toast.locator)
: null
const short = shortLocator(toast.locator)
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) {
return (
@ -34,10 +67,11 @@ function Toast({ toast, onDismiss, onIgnoreUblock }) {
<div className="toast-top">
<span className="toast-icon" aria-hidden="true"></span>
<div className="toast-body">
<span className="toast-headline">Ad-blocking unavailable</span>
{short && <span className="toast-locator">{short}</span>}
<span className="toast-headline">{toast.headline || 'Archived with warnings'}</span>
{short && <span className="toast-locator" title={toast.locator}>{short}</span>}
</div>
<div className="toast-btns">
{toast.text && (
<button
type="button"
className="toast-view-btn"
@ -46,6 +80,8 @@ function Toast({ toast, onDismiss, onIgnoreUblock }) {
>
{expanded ? 'Hide' : 'Details'}
</button>
)}
{toast.locator && (
<button
type="button"
className="toast-view-btn toast-ignore-btn"
@ -53,6 +89,7 @@ function Toast({ toast, onDismiss, onIgnoreUblock }) {
>
Ignore
</button>
)}
<button
type="button"
className="toast-dismiss"
@ -65,7 +102,7 @@ function Toast({ toast, onDismiss, onIgnoreUblock }) {
</div>
{expanded && (
<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>
)}
</div>
@ -77,8 +114,8 @@ function Toast({ toast, onDismiss, onIgnoreUblock }) {
<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>}
<span className="toast-headline">{toast.headline || 'Capture failed'}</span>
{short && <span className="toast-locator" title={toast.locator}>{short}</span>}
</div>
<div className="toast-btns">
{toast.text && (

View file

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