mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
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 (✕).
This commit is contained in:
parent
2e8820a0da
commit
03390362c5
10 changed files with 195 additions and 89 deletions
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
} 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)
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,25 +67,29 @@ 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">
|
||||
<button
|
||||
type="button"
|
||||
className="toast-view-btn"
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? 'Hide' : 'Details'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="toast-view-btn toast-ignore-btn"
|
||||
onClick={() => { onIgnoreUblock?.(); onDismiss(toast.id) }}
|
||||
>
|
||||
Ignore
|
||||
</button>
|
||||
{toast.text && (
|
||||
<button
|
||||
type="button"
|
||||
className="toast-view-btn"
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? 'Hide' : 'Details'}
|
||||
</button>
|
||||
)}
|
||||
{toast.locator && (
|
||||
<button
|
||||
type="button"
|
||||
className="toast-view-btn toast-ignore-btn"
|
||||
onClick={() => { onIgnoreUblock?.(); onDismiss(toast.id) }}
|
||||
>
|
||||
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 && (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue