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:
parent
14fd091d1f
commit
d202e177e1
7 changed files with 197 additions and 109 deletions
47
crates/archivr-server/static/assets/index-D1LSSXsT.js
Normal file
47
crates/archivr-server/static/assets/index-D1LSSXsT.js
Normal file
File diff suppressed because one or more lines are too long
1
crates/archivr-server/static/assets/index-DKombv9L.css
Normal file
1
crates/archivr-server/static/assets/index-DKombv9L.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -107,6 +107,18 @@ export default function App() {
|
|||
|
||||
const [toasts, setToasts] = useState([])
|
||||
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(
|
||||
() => sessionStorage.getItem('ublockWarningIgnored') === 'true'
|
||||
)
|
||||
|
|
@ -424,7 +436,7 @@ export default function App() {
|
|||
|
||||
const handleCaptured = useCallback(() => {
|
||||
if (!archiveId) return
|
||||
Promise.all([
|
||||
return Promise.allSettled([
|
||||
loadEntries(archiveId, searchQuery, tagFilter),
|
||||
fetchRuns(archiveId).then(setRuns),
|
||||
])
|
||||
|
|
@ -447,6 +459,15 @@ export default function App() {
|
|||
setUblockWarningIgnored(true)
|
||||
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 [currentAudio, setCurrentAudio] = useState(null)
|
||||
|
||||
|
|
@ -541,6 +562,7 @@ export default function App() {
|
|||
selectedUids={selectedUids}
|
||||
onRowClick={handleRowClick}
|
||||
archiveId={archiveId}
|
||||
pendingCaptures={pendingCaptures}
|
||||
/>
|
||||
)}
|
||||
{view === 'runs' && <RunsView runs={runs} />}
|
||||
|
|
@ -605,6 +627,9 @@ export default function App() {
|
|||
onClose={handleCaptureClose}
|
||||
onCaptured={handleCaptured}
|
||||
onToast={handleToast}
|
||||
activeJobs={pendingCaptures}
|
||||
onJobStarted={handleJobStarted}
|
||||
onJobSettled={handleJobSettled}
|
||||
/>
|
||||
<ToastStack toasts={toasts} onDismiss={handleDismissToast} onIgnoreUblock={handleIgnoreUblock} />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -74,11 +74,7 @@ function makeItem(locator = '') {
|
|||
}
|
||||
}
|
||||
|
||||
function hasActiveJobs(items) {
|
||||
return items.some(it => it.status === 'submitting' || it.status === 'running')
|
||||
}
|
||||
|
||||
export default function CaptureDialog({ open, archiveId, onClose, onCaptured, onToast }) {
|
||||
export default function CaptureDialog({ open, archiveId, onClose, onCaptured, onToast, onJobStarted, onJobSettled, activeJobs = [] }) {
|
||||
const dialogRef = useRef(null)
|
||||
const isFirstRenderRef = useRef(true)
|
||||
// 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(() => { 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(() => {
|
||||
try {
|
||||
const saved = JSON.parse(sessionStorage.getItem('captureItems') || 'null')
|
||||
if (Array.isArray(saved) && saved.length > 0) {
|
||||
// Ensure nextItemId stays ahead of restored ids
|
||||
saved.forEach(it => { if (it.id >= nextItemId) nextItemId = it.id + 1 })
|
||||
return saved
|
||||
const idle = saved.filter(it => !it.status || it.status === 'idle')
|
||||
if (idle.length > 0) {
|
||||
idle.forEach(it => { if (it.id >= nextItemId) nextItemId = it.id + 1 })
|
||||
return idle
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return [makeItem()]
|
||||
|
|
@ -142,24 +145,20 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
// Reader mode: off by default, per-session only
|
||||
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(() => {
|
||||
;['captureDialogLocator','captureDialogError','captureDialogBusy',
|
||||
'captureDialogJobStatus','captureDialogJobUid'].forEach(k => sessionStorage.removeItem(k))
|
||||
|
||||
setItems(prev => prev.map(it =>
|
||||
// 'submitting' means page was refreshed mid-fetch — reset to idle so user can retry
|
||||
it.status === 'submitting' ? { ...it, status: 'idle', error: null } : it
|
||||
))
|
||||
|
||||
// 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)
|
||||
// Reconnect polling for any bg jobs still running from a previous session.
|
||||
// activeJobs is read from the initial prop value (App's sessionStorage-seeded state).
|
||||
activeJobs.forEach(job => {
|
||||
if (job.jobUid && !pollIntervals.current.has(job.jobUid)) {
|
||||
startPolling(job.id, job.jobUid, job.locator, job.archiveId, null)
|
||||
}
|
||||
})
|
||||
// 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())
|
||||
useEffect(() => {
|
||||
|
|
@ -174,12 +173,12 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
return () => dialog.removeEventListener('close', handler)
|
||||
}, [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(() => {
|
||||
const dialog = dialogRef.current
|
||||
if (!dialog) return
|
||||
if (open) {
|
||||
if (!isFirstRenderRef.current && !hasActiveJobs(items)) {
|
||||
if (!isFirstRenderRef.current) {
|
||||
setItems([makeItem()])
|
||||
}
|
||||
isFirstRenderRef.current = false
|
||||
|
|
@ -199,22 +198,16 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
}
|
||||
}, [])
|
||||
|
||||
function startPolling(itemId, jobUid, locator, aid, batchId = null) {
|
||||
if (pollIntervals.current.has(jobUid)) return // already polling
|
||||
function startPolling(bgJobId, jobUid, locator, aid, batchId = null) {
|
||||
if (pollIntervals.current.has(jobUid)) return
|
||||
const intervalId = setInterval(async () => {
|
||||
try {
|
||||
const updated = await pollCaptureJob(aid, jobUid)
|
||||
if (updated.status === 'completed') {
|
||||
clearInterval(pollIntervals.current.get(jobUid))
|
||||
pollIntervals.current.delete(jobUid)
|
||||
setItems(prev => prev.map(it => it.id === itemId ? { ...it, status: 'completed' } : it))
|
||||
setTimeout(() => {
|
||||
setItems(prev => {
|
||||
const next = prev.filter(it => it.id !== itemId)
|
||||
return next.length === 0 ? [makeItem()] : next
|
||||
})
|
||||
}, 1400)
|
||||
onCapturedRef.current()
|
||||
await Promise.resolve(onCapturedRef.current?.())
|
||||
onJobSettledRef.current?.(bgJobId)
|
||||
try {
|
||||
const notes = updated.notes_json ? JSON.parse(updated.notes_json) : null
|
||||
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') {
|
||||
clearInterval(pollIntervals.current.get(jobUid))
|
||||
pollIntervals.current.delete(jobUid)
|
||||
onJobSettledRef.current?.(bgJobId)
|
||||
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)
|
||||
settleBatch(batchId, 'failed', locator)
|
||||
}
|
||||
|
|
@ -248,10 +239,8 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
} catch (e) {
|
||||
clearInterval(pollIntervals.current.get(jobUid))
|
||||
pollIntervals.current.delete(jobUid)
|
||||
onJobSettledRef.current?.(bgJobId)
|
||||
const msg = e.message || 'Network error'
|
||||
setItems(prev => prev.map(it =>
|
||||
it.id === itemId ? { ...it, status: 'failed', error: msg } : it
|
||||
))
|
||||
onToastRef.current(msg, locator)
|
||||
settleBatch(batchId, 'failed', locator)
|
||||
}
|
||||
|
|
@ -294,31 +283,31 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
onToastRef.current(text, null, type, headline)
|
||||
}
|
||||
|
||||
|
||||
async function submitItem(item, batchId = null) {
|
||||
if (!item.locator.trim()) return
|
||||
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))
|
||||
async function submitBgJob(locator, quality, batchId) {
|
||||
const aid = archiveIdRef.current
|
||||
const id = crypto.randomUUID?.() ?? `job-${Date.now()}-${Math.random()}`
|
||||
// Capture session options at call time (synchronous — before first await)
|
||||
const extensions = {
|
||||
ublock_enabled: ublockEnabled,
|
||||
reader_mode: readerMode,
|
||||
cookie_ext_enabled: cookieExtEnabled,
|
||||
modal_closer_enabled: modalCloserEnabled,
|
||||
via_freedium: freediumEnabled,
|
||||
}
|
||||
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, loc, qual, extensions)
|
||||
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, batchId)
|
||||
const job = await submitCapture(aid, locator, quality, extensions)
|
||||
// Notify App to add skeleton + persist
|
||||
onJobStartedRef.current?.({ id, jobUid: job.job_uid, locator, archiveId: aid })
|
||||
startPolling(id, job.job_uid, locator, 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)
|
||||
onToastRef.current(msg, locator)
|
||||
settleBatch(batchId, 'failed', locator)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
const batchId = toSubmit.length > 1
|
||||
? (crypto.randomUUID?.() ?? `batch-${Date.now()}`)
|
||||
|
|
@ -326,8 +315,13 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
if (batchId) {
|
||||
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()
|
||||
// Submit each in background
|
||||
toSubmit.forEach((it, i) => submitBgJob(it.locator.trim(), capturedQuality[i], batchId))
|
||||
}
|
||||
|
||||
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) {
|
||||
// Cancel any in-flight debounce and immediately clear stale probe results.
|
||||
// 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))
|
||||
}
|
||||
|
||||
const pendingCount = items.filter(it => it.status === 'idle' && it.locator.trim()).length
|
||||
const anyActive = hasActiveJobs(items)
|
||||
const pendingCount = items.filter(it => it.locator.trim()).length
|
||||
|
||||
return (
|
||||
<dialog ref={dialogRef} className="capture-dialog">
|
||||
|
|
@ -415,11 +402,10 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
<CaptureRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
autoFocus={idx === items.length - 1 && item.status === 'idle'}
|
||||
autoFocus={idx === items.length - 1}
|
||||
onLocatorChange={val => updateLocator(item.id, val)}
|
||||
onQualityChange={val => updateQuality(item.id, val)}
|
||||
onRemove={() => removeRow(item.id)}
|
||||
onReset={() => resetRow(item.id)}
|
||||
onSubmit={handleArchive}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -549,7 +535,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
{pendingCount > 1 ? `Archive ${pendingCount}` : 'Archive'}
|
||||
</button>
|
||||
<button type="button" className="capture-cancel" onClick={() => dialogRef.current?.close()}>
|
||||
{anyActive ? 'Close' : 'Cancel'}
|
||||
Cancel
|
||||
</button>
|
||||
</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 isActive = item.status === 'submitting' || item.status === 'running'
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && item.status === 'idle') {
|
||||
if (autoFocus) {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [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 = (() => {
|
||||
if (item.status === 'completed' || isActive) return null
|
||||
if (!isVideoSource(item.locator)) return null
|
||||
if (item.probeState === 'probing') {
|
||||
return <span className="capture-quality-probing" aria-label="Checking available qualities">…</span>
|
||||
|
|
@ -611,9 +595,8 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemov
|
|||
})()
|
||||
|
||||
return (
|
||||
<div className={`capture-row capture-row--${item.status}`}>
|
||||
<div className="capture-row">
|
||||
<div className="capture-row-main">
|
||||
<CapStatusDot status={item.status} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="capture-input"
|
||||
|
|
@ -622,26 +605,15 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemov
|
|||
value={item.locator}
|
||||
onChange={e => onLocatorChange(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') onSubmit() }}
|
||||
disabled={isActive || item.status === 'completed'}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{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">
|
||||
<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"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<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">
|
||||
<line x1="3" y1="3" x2="13" y2="13"/><line x1="13" y1="3" x2="3" y2="13"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{item.error && (
|
||||
<p className="capture-row-error">{item.error}</p>
|
||||
|
|
@ -649,20 +621,3 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemov
|
|||
</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" />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import SkeletonEntryRow from './SkeletonEntryRow';
|
||||
|
||||
import EntryRow from './EntryRow';
|
||||
|
||||
export default function EntriesView({ entries, selectedUids, onRowClick, archiveId }) {
|
||||
export default function EntriesView({ entries, selectedUids, onRowClick, archiveId, pendingCaptures = [] }) {
|
||||
return (
|
||||
<section id="archive-view" className="view is-active">
|
||||
<div className="entry-table">
|
||||
|
|
@ -13,6 +15,9 @@ export default function EntriesView({ entries, selectedUids, onRowClick, archive
|
|||
<div className="col-url">Original URL</div>
|
||||
</div>
|
||||
<div id="entries-body">
|
||||
{pendingCaptures.filter(c => c.archiveId === archiveId).reverse().map(cap => (
|
||||
<SkeletonEntryRow key={cap.id} />
|
||||
))}
|
||||
{entries.map(entry => (
|
||||
<EntryRow
|
||||
key={entry.entry_uid}
|
||||
|
|
|
|||
23
frontend/src/components/SkeletonEntryRow.jsx
Normal file
23
frontend/src/components/SkeletonEntryRow.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -2694,3 +2694,35 @@ select {
|
|||
.preview-modal-newtab:hover { background: var(--field); color: var(--ink); }
|
||||
/* Push content above the fixed AudioBar when it is visible */
|
||||
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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue