mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-22 11:15:41 +02:00
feat(server,frontend): expose children endpoint + expand UI for container entries
server:
- add GET /api/archives/:id/entries/:uid/children → list_entry_children,
calling list_child_entries with caller_bits so visibility model is enforced
- fix capture_handler: use result.status ("completed"/"failed") to set job
status rather than always "completed", mirroring rearchive_handler; this
surfaces partial playlist failures to the polling client
frontend:
- api.js: add fetchEntryChildren(archiveId, entryUid)
- EntryRow: one outer div.entry-row-outer (display:block) keeps nth-child
striping correct; inner div.entry-row-main is the flex row with all column
cells and event handling; .child-entries sits below inside the outer wrapper
- expand chevron appears when entry.child_count > 0; clicking fetches children
lazily and renders ChildRow components reusing .col-* flex widths
- child-count badge shown next to title on container entries
- styles.css: scoped CSS with #entries-body > .entry-row-outer selectors
(higher specificity than > div) to override flex on outer wrapper; inner row
and column rules replicated at correct depth; nth-child, is-selected,
is-multi-selected, url-cell hover all handled
This commit is contained in:
parent
ccdacfd582
commit
08087a4d6b
4 changed files with 265 additions and 36 deletions
|
|
@ -1,8 +1,35 @@
|
|||
import { useState } from 'react';
|
||||
import { formatTimestamp, formatBytes, valueText, sourceIconSvg } from '../utils';
|
||||
import { fetchEntryChildren } from '../api';
|
||||
|
||||
function ChildRow({ entry }) {
|
||||
return (
|
||||
<div className="child-entry-row">
|
||||
<div className="col-check" aria-hidden="true" />
|
||||
<div className="col-added">{formatTimestamp(entry.archived_at)}</div>
|
||||
<div className="col-title">
|
||||
<span className="source-icon">
|
||||
<span dangerouslySetInnerHTML={{ __html: sourceIconSvg(entry.source_kind) }} />
|
||||
</span>
|
||||
<span className="entry-title">{valueText(entry.title) || valueText(entry.entry_uid)}</span>
|
||||
</div>
|
||||
<div className="col-type">
|
||||
<span className="type-pill">{valueText(entry.entity_kind)}</span>
|
||||
</div>
|
||||
<div className="col-size">
|
||||
<span className="size-total">{formatBytes(entry.total_artifact_bytes)}</span>
|
||||
</div>
|
||||
<div className="url-cell col-url">{valueText(entry.original_url)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EntryRow({ entry, archiveId, isSelected, isMultiSelected, onRowClick }) {
|
||||
const [favFailed, setFavFailed] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [children, setChildren] = useState(null);
|
||||
const [childrenLoading, setChildrenLoading] = useState(false);
|
||||
|
||||
const showFavicon =
|
||||
entry.source_kind === 'web' &&
|
||||
entry.entity_kind === 'page' &&
|
||||
|
|
@ -24,49 +51,101 @@ export default function EntryRow({ entry, archiveId, isSelected, isMultiSelected
|
|||
);
|
||||
|
||||
const checked = isSelected || isMultiSelected;
|
||||
const hasChildren = entry.child_count > 0;
|
||||
|
||||
function handleCheckboxClick(e) {
|
||||
e.stopPropagation();
|
||||
// treat checkbox tap as ctrl+click: toggle this entry without clearing others
|
||||
onRowClick(entry, { ctrlKey: true, metaKey: false, shiftKey: false, preventDefault() {} });
|
||||
}
|
||||
|
||||
async function handleExpandClick(e) {
|
||||
e.stopPropagation();
|
||||
if (expanded) {
|
||||
setExpanded(false);
|
||||
return;
|
||||
}
|
||||
setExpanded(true);
|
||||
if (children === null && !childrenLoading) {
|
||||
setChildrenLoading(true);
|
||||
try {
|
||||
const result = await fetchEntryChildren(archiveId, entry.entry_uid);
|
||||
setChildren(result);
|
||||
} catch (_) {
|
||||
setChildren([]);
|
||||
} finally {
|
||||
setChildrenLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Outer wrapper: display:block so child-entries lives below the flex row.
|
||||
// One wrapper per entry → nth-child striping stays correct.
|
||||
// is-selected/is-multi-selected go here so #entries-body > div.is-selected still matches.
|
||||
const outerClass = [
|
||||
'entry-row-outer',
|
||||
isSelected && 'is-selected',
|
||||
isMultiSelected && 'is-multi-selected',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[isSelected && 'is-selected', isMultiSelected && 'is-multi-selected'].filter(Boolean).join(' ') || undefined}
|
||||
tabIndex={0}
|
||||
data-entry-uid={entry.entry_uid}
|
||||
onMouseDown={e => { if (e.shiftKey) e.preventDefault() }}
|
||||
onClick={e => onRowClick(entry, e)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') onRowClick(entry, e) }}
|
||||
>
|
||||
<div className="col-check">
|
||||
<button
|
||||
type="button"
|
||||
className={`row-checkbox${checked ? ' is-checked' : ''}`}
|
||||
aria-pressed={checked}
|
||||
aria-label={checked ? 'Deselect entry' : 'Select entry'}
|
||||
onClick={handleCheckboxClick}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
<div className={outerClass} data-entry-uid={entry.entry_uid}>
|
||||
{/* entry-row-main is the interactive flex row */}
|
||||
<div
|
||||
className="entry-row-main"
|
||||
tabIndex={0}
|
||||
onMouseDown={e => { if (e.shiftKey) e.preventDefault(); }}
|
||||
onClick={e => onRowClick(entry, e)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') onRowClick(entry, e); }}
|
||||
>
|
||||
<div className="col-check">
|
||||
<button
|
||||
type="button"
|
||||
className={`row-checkbox${checked ? ' is-checked' : ''}`}
|
||||
aria-pressed={checked}
|
||||
aria-label={checked ? 'Deselect entry' : 'Select entry'}
|
||||
onClick={handleCheckboxClick}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-added">{formatTimestamp(entry.archived_at)}</div>
|
||||
<div className="col-title">
|
||||
{hasChildren && (
|
||||
<button
|
||||
type="button"
|
||||
className={`entry-expand-btn${expanded ? ' is-expanded' : ''}`}
|
||||
aria-label={expanded ? 'Collapse children' : `Expand ${entry.child_count} items`}
|
||||
aria-expanded={expanded}
|
||||
onClick={handleExpandClick}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<span className="source-icon">{icon}</span>
|
||||
<span className="entry-title">{valueText(entry.title) || valueText(entry.entry_uid)}</span>
|
||||
{hasChildren && (
|
||||
<span className="child-count-badge" aria-hidden="true">{entry.child_count}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-type">
|
||||
<span className="type-pill">{valueText(entry.entity_kind)}</span>
|
||||
</div>
|
||||
<div className="col-size">
|
||||
<span className="size-total">{formatBytes(entry.total_artifact_bytes)}</span>
|
||||
{entry.cached_bytes > 0 && entry.total_artifact_bytes > 0 && (
|
||||
<span className="size-cached-pct" title={`${formatBytes(entry.cached_bytes)} already on disk from an earlier entry`}>
|
||||
{Math.round(entry.cached_bytes / entry.total_artifact_bytes * 100)}% cached
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="url-cell col-url">{valueText(entry.original_url)}</div>
|
||||
</div>
|
||||
<div className="col-added">{formatTimestamp(entry.archived_at)}</div>
|
||||
<div className="col-title">
|
||||
<span className="source-icon">{icon}</span>
|
||||
<span className="entry-title">{valueText(entry.title) || valueText(entry.entry_uid)}</span>
|
||||
</div>
|
||||
<div className="col-type">
|
||||
<span className="type-pill">{valueText(entry.entity_kind)}</span>
|
||||
</div>
|
||||
<div className="col-size">
|
||||
<span className="size-total">{formatBytes(entry.total_artifact_bytes)}</span>
|
||||
{entry.cached_bytes > 0 && entry.total_artifact_bytes > 0 && (
|
||||
<span className="size-cached-pct" title={`${formatBytes(entry.cached_bytes)} already on disk from an earlier entry`}>
|
||||
{Math.round(entry.cached_bytes / entry.total_artifact_bytes * 100)}% cached
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="url-cell col-url">{valueText(entry.original_url)}</div>
|
||||
{expanded && (
|
||||
<div className="child-entries" aria-label={`${entry.child_count} child entries`}>
|
||||
{childrenLoading && <div className="child-entries-loading">Loading…</div>}
|
||||
{children && children.map(child => (
|
||||
<ChildRow key={child.entry_uid} entry={child} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue