1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

feat(frontend): add async capture UX with skeleton entries for in-progress captures (#31)

* feat(frontend): add SkeletonEntryRow with shimmer animation

Adds a new SkeletonEntryRow component that renders animated shimmer
placeholder cells matching the exact column layout of EntryRow
(col-added, col-title with icon circle, col-type pill, col-size,
col-url). CSS appended to styles.css using existing design tokens
(--paper-2, --line-soft, --paper-3) for the warm-toned shimmer.

* feat(frontend): async capture UX — reset dialog on submit, show skeleton rows

When the user presses Archive, CaptureDialog now immediately resets its
form to a fresh empty state and closes. Background captures continue
polling via intervals that survive the dialog close.

Skeleton rows appear at the top of EntriesView (filtered to the active
archive) for each in-flight job, giving visual feedback that something
is being processed. On completion the skeleton is removed and the entry
list refreshes; on failure the skeleton is removed and an error toast
fires — matching the existing toast behaviour.

Architecture:
- App owns pending-capture state (pendingCaptures) as the single source
  of truth, persisted to sessionStorage['pendingCaptures']. On page
  refresh, App seeds the list and passes it to CaptureDialog as
  activeJobs so polling reconnects without a second sessionStorage read.
- CaptureDialog emits onJobStarted({id,jobUid,locator,archiveId}) only
  after submitCapture() returns a job_uid — never before, never on
  failure — ensuring no orphan skeletons can persist after a refresh.
- onJobSettled(id) is called by startPolling on any terminal state
  (completed, failed, or network error), removing the skeleton.
- EntriesView filters pendingCaptures by archiveId so a capture in
  archive A never shows a skeleton in archive B.

Removed from CaptureDialog: submitItem, resetRow, hasActiveJobs,
anyActive, CapStatusDot. CaptureRow is simplified to idle-only display
with no disabled state, no retry button, no status dot.

* fix(frontend): skeleton replacement ordering and col-check alignment

- Await the entry list refresh before removing the skeleton so the
  real row arrives before the placeholder disappears. handleCaptured
  now returns its Promise.all; startPolling awaits it before calling
  onJobSettled on the success path.
- Add col-check as the first child of SkeletonEntryRow to match
  EntryRow's DOM structure; without it, columns misalign on touch
  devices where col-check becomes display:flex.

* fix(frontend): use Promise.allSettled in handleCaptured to prevent runs-fetch failure from poisoning successful captures

Promise.all rejects on the first failure. If the /runs refresh threw,
the rejection would propagate through startPolling's await and land in
the outer catch block, firing an error toast for a capture that had
already succeeded. Promise.allSettled settles unconditionally so a
transient runs fetch error is silently absorbed while the entry list
still refreshes.
This commit is contained in:
TheGeneralist 2026-07-20 14:27:15 +02:00 committed by GitHub
parent 14fd091d1f
commit d202e177e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 197 additions and 109 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

@ -107,6 +107,18 @@ export default function App() {
const [toasts, setToasts] = useState([]) const [toasts, setToasts] = useState([])
const toastIdRef = useRef(0) const toastIdRef = useRef(0)
const [pendingCaptures, setPendingCaptures] = useState(() => {
try {
const saved = JSON.parse(sessionStorage.getItem('pendingCaptures') || '[]')
if (Array.isArray(saved)) return saved
} catch {}
return []
})
useEffect(() => {
sessionStorage.setItem('pendingCaptures', JSON.stringify(pendingCaptures))
}, [pendingCaptures])
const [ublockWarningIgnored, setUblockWarningIgnored] = useState( const [ublockWarningIgnored, setUblockWarningIgnored] = useState(
() => sessionStorage.getItem('ublockWarningIgnored') === 'true' () => sessionStorage.getItem('ublockWarningIgnored') === 'true'
) )
@ -424,7 +436,7 @@ export default function App() {
const handleCaptured = useCallback(() => { const handleCaptured = useCallback(() => {
if (!archiveId) return if (!archiveId) return
Promise.all([ return Promise.allSettled([
loadEntries(archiveId, searchQuery, tagFilter), loadEntries(archiveId, searchQuery, tagFilter),
fetchRuns(archiveId).then(setRuns), fetchRuns(archiveId).then(setRuns),
]) ])
@ -447,6 +459,15 @@ export default function App() {
setUblockWarningIgnored(true) setUblockWarningIgnored(true)
setToasts(prev => prev.filter(t => !(t.type === 'warning' && t.locator))) setToasts(prev => prev.filter(t => !(t.type === 'warning' && t.locator)))
}, []) }, [])
const handleJobStarted = useCallback((record) => {
setPendingCaptures(prev => [...prev, record])
}, [])
const handleJobSettled = useCallback((id) => {
setPendingCaptures(prev => prev.filter(c => c.id !== id))
}, [])
const [previewEntryUid, setPreviewEntryUid] = useState(null) const [previewEntryUid, setPreviewEntryUid] = useState(null)
const [currentAudio, setCurrentAudio] = useState(null) const [currentAudio, setCurrentAudio] = useState(null)
@ -541,6 +562,7 @@ export default function App() {
selectedUids={selectedUids} selectedUids={selectedUids}
onRowClick={handleRowClick} onRowClick={handleRowClick}
archiveId={archiveId} archiveId={archiveId}
pendingCaptures={pendingCaptures}
/> />
)} )}
{view === 'runs' && <RunsView runs={runs} />} {view === 'runs' && <RunsView runs={runs} />}
@ -605,6 +627,9 @@ export default function App() {
onClose={handleCaptureClose} onClose={handleCaptureClose}
onCaptured={handleCaptured} onCaptured={handleCaptured}
onToast={handleToast} onToast={handleToast}
activeJobs={pendingCaptures}
onJobStarted={handleJobStarted}
onJobSettled={handleJobSettled}
/> />
<ToastStack toasts={toasts} onDismiss={handleDismissToast} onIgnoreUblock={handleIgnoreUblock} /> <ToastStack toasts={toasts} onDismiss={handleDismissToast} onIgnoreUblock={handleIgnoreUblock} />
</> </>

View file

@ -74,11 +74,7 @@ function makeItem(locator = '') {
} }
} }
function hasActiveJobs(items) { export default function CaptureDialog({ open, archiveId, onClose, onCaptured, onToast, onJobStarted, onJobSettled, activeJobs = [] }) {
return items.some(it => it.status === 'submitting' || it.status === 'running')
}
export default function CaptureDialog({ open, archiveId, onClose, onCaptured, onToast }) {
const dialogRef = useRef(null) const dialogRef = useRef(null)
const isFirstRenderRef = useRef(true) const isFirstRenderRef = useRef(true)
// jobUid intervalId; survives dialog close since component stays mounted // jobUid intervalId; survives dialog close since component stays mounted
@ -97,13 +93,20 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
useEffect(() => { onCapturedRef.current = onCaptured }, [onCaptured]) useEffect(() => { onCapturedRef.current = onCaptured }, [onCaptured])
useEffect(() => { onToastRef.current = onToast }, [onToast]) useEffect(() => { onToastRef.current = onToast }, [onToast])
const onJobStartedRef = useRef(onJobStarted)
const onJobSettledRef = useRef(onJobSettled)
useEffect(() => { onJobStartedRef.current = onJobStarted }, [onJobStarted])
useEffect(() => { onJobSettledRef.current = onJobSettled }, [onJobSettled])
const [items, setItems] = useState(() => { const [items, setItems] = useState(() => {
try { try {
const saved = JSON.parse(sessionStorage.getItem('captureItems') || 'null') const saved = JSON.parse(sessionStorage.getItem('captureItems') || 'null')
if (Array.isArray(saved) && saved.length > 0) { if (Array.isArray(saved) && saved.length > 0) {
// Ensure nextItemId stays ahead of restored ids const idle = saved.filter(it => !it.status || it.status === 'idle')
saved.forEach(it => { if (it.id >= nextItemId) nextItemId = it.id + 1 }) if (idle.length > 0) {
return saved idle.forEach(it => { if (it.id >= nextItemId) nextItemId = it.id + 1 })
return idle
}
} }
} catch {} } catch {}
return [makeItem()] return [makeItem()]
@ -142,24 +145,20 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
// Reader mode: off by default, per-session only // Reader mode: off by default, per-session only
const [readerMode, setReaderMode] = useState(false) const [readerMode, setReaderMode] = useState(false)
// On mount: clean up old single-locator sessionStorage keys; reconnect running jobs // On mount: clean up old single-locator sessionStorage keys; reconnect running bg jobs
useEffect(() => { useEffect(() => {
;['captureDialogLocator','captureDialogError','captureDialogBusy', ;['captureDialogLocator','captureDialogError','captureDialogBusy',
'captureDialogJobStatus','captureDialogJobUid'].forEach(k => sessionStorage.removeItem(k)) 'captureDialogJobStatus','captureDialogJobUid'].forEach(k => sessionStorage.removeItem(k))
setItems(prev => prev.map(it => // Reconnect polling for any bg jobs still running from a previous session.
// 'submitting' means page was refreshed mid-fetch reset to idle so user can retry // activeJobs is read from the initial prop value (App's sessionStorage-seeded state).
it.status === 'submitting' ? { ...it, status: 'idle', error: null } : it activeJobs.forEach(job => {
)) if (job.jobUid && !pollIntervals.current.has(job.jobUid)) {
startPolling(job.id, job.jobUid, job.locator, job.archiveId, null)
// 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 // 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()) // Handle native dialog 'close' event (Escape key or programmatic .close())
useEffect(() => { useEffect(() => {
@ -174,12 +173,12 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
return () => dialog.removeEventListener('close', handler) return () => dialog.removeEventListener('close', handler)
}, [onClose]) }, [onClose])
// Open/close driven by parent; don't reset if active jobs are in flight // Open/close driven by parent; always reset form on reopen
useEffect(() => { useEffect(() => {
const dialog = dialogRef.current const dialog = dialogRef.current
if (!dialog) return if (!dialog) return
if (open) { if (open) {
if (!isFirstRenderRef.current && !hasActiveJobs(items)) { if (!isFirstRenderRef.current) {
setItems([makeItem()]) setItems([makeItem()])
} }
isFirstRenderRef.current = false isFirstRenderRef.current = false
@ -199,22 +198,16 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
} }
}, []) }, [])
function startPolling(itemId, jobUid, locator, aid, batchId = null) { function startPolling(bgJobId, jobUid, locator, aid, batchId = null) {
if (pollIntervals.current.has(jobUid)) return // already polling if (pollIntervals.current.has(jobUid)) return
const intervalId = setInterval(async () => { const intervalId = setInterval(async () => {
try { try {
const updated = await pollCaptureJob(aid, jobUid) const updated = await pollCaptureJob(aid, jobUid)
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)
setItems(prev => prev.map(it => it.id === itemId ? { ...it, status: 'completed' } : it)) await Promise.resolve(onCapturedRef.current?.())
setTimeout(() => { onJobSettledRef.current?.(bgJobId)
setItems(prev => {
const next = prev.filter(it => it.id !== itemId)
return next.length === 0 ? [makeItem()] : next
})
}, 1400)
onCapturedRef.current()
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) {
@ -237,10 +230,8 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
} 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)
onJobSettledRef.current?.(bgJobId)
const errText = updated.error_text || 'Capture failed.' 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) onToastRef.current(errText, locator)
settleBatch(batchId, 'failed', locator) settleBatch(batchId, 'failed', locator)
} }
@ -248,10 +239,8 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
} catch (e) { } catch (e) {
clearInterval(pollIntervals.current.get(jobUid)) clearInterval(pollIntervals.current.get(jobUid))
pollIntervals.current.delete(jobUid) pollIntervals.current.delete(jobUid)
onJobSettledRef.current?.(bgJobId)
const msg = e.message || 'Network error' const msg = e.message || 'Network error'
setItems(prev => prev.map(it =>
it.id === itemId ? { ...it, status: 'failed', error: msg } : it
))
onToastRef.current(msg, locator) onToastRef.current(msg, locator)
settleBatch(batchId, 'failed', locator) settleBatch(batchId, 'failed', locator)
} }
@ -294,31 +283,31 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
onToastRef.current(text, null, type, headline) onToastRef.current(text, null, type, headline)
} }
async function submitBgJob(locator, quality, batchId) {
async function submitItem(item, batchId = null) { const aid = archiveIdRef.current
if (!item.locator.trim()) return const id = crypto.randomUUID?.() ?? `job-${Date.now()}-${Math.random()}`
const aid = archiveId // Capture session options at call time (synchronous before first await)
const loc = item.locator.trim() const extensions = {
const qual = item.quality || 'best' ublock_enabled: ublockEnabled,
setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'submitting', error: null } : it)) reader_mode: readerMode,
cookie_ext_enabled: cookieExtEnabled,
modal_closer_enabled: modalCloserEnabled,
via_freedium: freediumEnabled,
}
try { try {
const extensions = { ublock_enabled: ublockEnabled, reader_mode: readerMode, cookie_ext_enabled: cookieExtEnabled, modal_closer_enabled: modalCloserEnabled, via_freedium: freediumEnabled } const job = await submitCapture(aid, locator, quality, extensions)
const job = await submitCapture(aid, loc, qual, extensions) // Notify App to add skeleton + persist
setItems(prev => prev.map(it => onJobStartedRef.current?.({ id, jobUid: job.job_uid, locator, archiveId: aid })
it.id === item.id ? { ...it, status: 'running', jobUid: job.job_uid, archiveId: aid } : it startPolling(id, job.job_uid, locator, aid, batchId)
))
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)) onToastRef.current(msg, locator)
onToastRef.current(msg, loc) settleBatch(batchId, 'failed', locator)
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.locator.trim())
if (toSubmit.length === 0) return if (toSubmit.length === 0) return
const batchId = toSubmit.length > 1 const batchId = toSubmit.length > 1
? (crypto.randomUUID?.() ?? `batch-${Date.now()}`) ? (crypto.randomUUID?.() ?? `batch-${Date.now()}`)
@ -326,8 +315,13 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
if (batchId) { if (batchId) {
batchRef.current.set(batchId, { total: toSubmit.length, archived: 0, warnings: 0, failed: 0, failedLocators: [], warningLocators: [] }) batchRef.current.set(batchId, { total: toSubmit.length, archived: 0, warnings: 0, failed: 0, failedLocators: [], warningLocators: [] })
} }
toSubmit.forEach(it => submitItem(it, batchId)) // Capture options at call time before any state changes
const capturedQuality = toSubmit.map(it => it.quality || 'best')
// Reset form and close dialog immediately
setItems([makeItem()])
dialogRef.current?.close() dialogRef.current?.close()
// Submit each in background
toSubmit.forEach((it, i) => submitBgJob(it.locator.trim(), capturedQuality[i], batchId))
} }
function addRow() { function addRow() {
@ -343,12 +337,6 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
}) })
} }
function resetRow(id) {
setItems(prev => prev.map(it =>
it.id === id ? { ...it, status: 'idle', error: null } : it
))
}
function updateLocator(id, val) { function updateLocator(id, val) {
// Cancel any in-flight debounce and immediately clear stale probe results. // Cancel any in-flight debounce and immediately clear stale probe results.
// This prevents old qualities from being visible (and submittable) while // This prevents old qualities from being visible (and submittable) while
@ -390,8 +378,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
setItems(prev => prev.map(it => it.id === id ? { ...it, quality: val } : it)) setItems(prev => prev.map(it => it.id === id ? { ...it, quality: val } : it))
} }
const pendingCount = items.filter(it => it.status === 'idle' && it.locator.trim()).length const pendingCount = items.filter(it => it.locator.trim()).length
const anyActive = hasActiveJobs(items)
return ( return (
<dialog ref={dialogRef} className="capture-dialog"> <dialog ref={dialogRef} className="capture-dialog">
@ -415,11 +402,10 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
<CaptureRow <CaptureRow
key={item.id} key={item.id}
item={item} item={item}
autoFocus={idx === items.length - 1 && item.status === 'idle'} autoFocus={idx === items.length - 1}
onLocatorChange={val => updateLocator(item.id, val)} onLocatorChange={val => updateLocator(item.id, val)}
onQualityChange={val => updateQuality(item.id, val)} onQualityChange={val => updateQuality(item.id, val)}
onRemove={() => removeRow(item.id)} onRemove={() => removeRow(item.id)}
onReset={() => resetRow(item.id)}
onSubmit={handleArchive} onSubmit={handleArchive}
/> />
))} ))}
@ -549,7 +535,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
{pendingCount > 1 ? `Archive ${pendingCount}` : 'Archive'} {pendingCount > 1 ? `Archive ${pendingCount}` : 'Archive'}
</button> </button>
<button type="button" className="capture-cancel" onClick={() => dialogRef.current?.close()}> <button type="button" className="capture-cancel" onClick={() => dialogRef.current?.close()}>
{anyActive ? 'Close' : 'Cancel'} Cancel
</button> </button>
</div> </div>
</div> </div>
@ -557,19 +543,17 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
) )
} }
function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemove, onReset, onSubmit }) { function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemove, onSubmit }) {
const inputRef = useRef(null) const inputRef = useRef(null)
const isActive = item.status === 'submitting' || item.status === 'running'
useEffect(() => { useEffect(() => {
if (autoFocus && item.status === 'idle') { if (autoFocus) {
inputRef.current?.focus() inputRef.current?.focus()
} }
}, [autoFocus]) // eslint-disable-line react-hooks/exhaustive-deps }, [autoFocus]) // eslint-disable-line react-hooks/exhaustive-deps
// Quality control shown right of the input (hidden when active or completed) // Quality control shown right of the input
const qualityEl = (() => { const qualityEl = (() => {
if (item.status === 'completed' || isActive) return null
if (!isVideoSource(item.locator)) return null if (!isVideoSource(item.locator)) return null
if (item.probeState === 'probing') { if (item.probeState === 'probing') {
return <span className="capture-quality-probing" aria-label="Checking available qualities"></span> return <span className="capture-quality-probing" aria-label="Checking available qualities"></span>
@ -611,9 +595,8 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemov
})() })()
return ( return (
<div className={`capture-row capture-row--${item.status}`}> <div className="capture-row">
<div className="capture-row-main"> <div className="capture-row-main">
<CapStatusDot status={item.status} />
<input <input
ref={inputRef} ref={inputRef}
className="capture-input" className="capture-input"
@ -622,26 +605,15 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemov
value={item.locator} value={item.locator}
onChange={e => onLocatorChange(e.target.value)} onChange={e => onLocatorChange(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') onSubmit() }} onKeyDown={e => { if (e.key === 'Enter') onSubmit() }}
disabled={isActive || item.status === 'completed'}
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
/> />
{qualityEl} {qualityEl}
{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"> <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"> <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"/> <line x1="3" y1="3" x2="13" y2="13"/><line x1="13" y1="3" x2="3" y2="13"/>
</svg> </svg>
</button> </button>
)}
</div> </div>
{item.error && ( {item.error && (
<p className="capture-row-error">{item.error}</p> <p className="capture-row-error">{item.error}</p>
@ -649,20 +621,3 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemov
</div> </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" />
}

View file

@ -1,6 +1,8 @@
import SkeletonEntryRow from './SkeletonEntryRow';
import EntryRow from './EntryRow'; import EntryRow from './EntryRow';
export default function EntriesView({ entries, selectedUids, onRowClick, archiveId }) { export default function EntriesView({ entries, selectedUids, onRowClick, archiveId, pendingCaptures = [] }) {
return ( return (
<section id="archive-view" className="view is-active"> <section id="archive-view" className="view is-active">
<div className="entry-table"> <div className="entry-table">
@ -13,6 +15,9 @@ export default function EntriesView({ entries, selectedUids, onRowClick, archive
<div className="col-url">Original URL</div> <div className="col-url">Original URL</div>
</div> </div>
<div id="entries-body"> <div id="entries-body">
{pendingCaptures.filter(c => c.archiveId === archiveId).reverse().map(cap => (
<SkeletonEntryRow key={cap.id} />
))}
{entries.map(entry => ( {entries.map(entry => (
<EntryRow <EntryRow
key={entry.entry_uid} key={entry.entry_uid}

View file

@ -0,0 +1,23 @@
export default function SkeletonEntryRow() {
return (
<div className="skeleton-row">
<div className="col-check" aria-hidden="true" />
<div className="col-added">
<span className="skeleton-cell" style={{ width: 108, height: 13 }} />
</div>
<div className="col-title" style={{ gap: '0.42em', display: 'flex', alignItems: 'center' }}>
<span className="skeleton-cell" style={{ width: 14, height: 14, borderRadius: '50%', flexShrink: 0 }} />
<span className="skeleton-cell" style={{ width: '58%', height: 13 }} />
</div>
<div className="col-type">
<span className="skeleton-cell" style={{ width: 58, height: 20, borderRadius: 99 }} />
</div>
<div className="col-size">
<span className="skeleton-cell" style={{ width: 44, height: 12 }} />
</div>
<div className="col-url">
<span className="skeleton-cell" style={{ width: '65%', height: 12 }} />
</div>
</div>
)
}

View file

@ -2694,3 +2694,35 @@ select {
.preview-modal-newtab:hover { background: var(--field); color: var(--ink); } .preview-modal-newtab:hover { background: var(--field); color: var(--ink); }
/* Push content above the fixed AudioBar when it is visible */ /* Push content above the fixed AudioBar when it is visible */
body.has-audio-bar { padding-bottom: 56px; } body.has-audio-bar { padding-bottom: 56px; }
/* ── Skeleton entry row ─────────────────────────────────────────────────── */
@keyframes skeleton-shimmer {
0% { background-position: 200% center; }
100% { background-position: -200% center; }
}
.skeleton-cell {
display: inline-block;
border-radius: 3px;
background: linear-gradient(90deg, var(--paper-2) 25%, var(--line-soft) 50%, var(--paper-2) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.8s ease-in-out infinite;
}
/* Row container — flex row matching #entries-body > div layout */
.skeleton-row {
display: flex;
align-items: center;
background: var(--paper-3);
}
.skeleton-row > div {
padding: 7px 10px;
flex-shrink: 0;
overflow: hidden;
}
.skeleton-row .col-added { padding-left: 22px; }
.skeleton-row > div:last-child { padding-right: 22px; }
@media (pointer: coarse) {
.skeleton-row .col-added { padding-left: 10px; }
}