1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00
archivr/frontend/src/components/ToastStack.jsx
TheGeneralist 03390362c5
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 (✕).
2026-07-11 15:29:10 +02:00

145 lines
5.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react'
const TOAST_TTL = 7000 // ms before auto-dismiss; paused when detail is expanded
export default function ToastStack({ toasts, onDismiss, onIgnoreUblock }) {
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} onIgnoreUblock={onIgnoreUblock} />
))}
</div>
)
}
// 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(() => {
if (expanded) return
const timer = setTimeout(() => onDismiss(toast.id), TOAST_TTL)
return () => clearTimeout(timer)
}, [expanded, toast.id, onDismiss])
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 (
<div className="toast toast--warning" 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 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"
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"
onClick={() => onDismiss(toast.id)}
aria-label="Dismiss"
>
×
</button>
</div>
</div>
{expanded && (
<p className="toast-warning-detail">
{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>
)
}
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">{toast.headline || 'Capture failed'}</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"
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.text && (
<pre className="toast-error-detail">{toast.text}</pre>
)}
</div>
)
}