mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +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
|
|
@ -241,6 +241,10 @@ pub fn app_with_state(state: AppState) -> Router {
|
||||||
.patch(patch_entry_handler)
|
.patch(patch_entry_handler)
|
||||||
.delete(delete_entry_handler),
|
.delete(delete_entry_handler),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/archives/:archive_id/entries/:entry_uid/children",
|
||||||
|
get(list_entry_children),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index",
|
"/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index",
|
||||||
get(serve_artifact),
|
get(serve_artifact),
|
||||||
|
|
@ -404,6 +408,18 @@ async fn list_entries(
|
||||||
Ok(Json(archive::list_root_entries(&conn, caller_bits)?))
|
Ok(Json(archive::list_root_entries(&conn, caller_bits)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_entry_children(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path((archive_id, entry_uid)): Path<(String, String)>,
|
||||||
|
) -> Result<Json<Vec<archive::EntrySummary>>, ApiError> {
|
||||||
|
auth.require_auth()?;
|
||||||
|
let mounted = mounted_archive(&state, &archive_id)?;
|
||||||
|
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||||
|
let caller_bits = auth_to_caller_bits(&auth);
|
||||||
|
Ok(Json(archive::list_child_entries(&conn, &entry_uid, caller_bits)?))
|
||||||
|
}
|
||||||
|
|
||||||
async fn search_entries_handler(
|
async fn search_entries_handler(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
|
|
@ -902,10 +918,11 @@ async fn capture_handler(
|
||||||
notes_str = serde_json::Value::Object(notes_map).to_string();
|
notes_str = serde_json::Value::Object(notes_map).to_string();
|
||||||
Some(¬es_str)
|
Some(¬es_str)
|
||||||
};
|
};
|
||||||
|
let job_status = if result.status == "completed" { "completed" } else { "failed" };
|
||||||
database::update_capture_job_status(
|
database::update_capture_job_status(
|
||||||
&conn,
|
&conn,
|
||||||
&job_uid_bg,
|
&job_uid_bg,
|
||||||
"completed",
|
job_status,
|
||||||
Some(&result.run_uid),
|
Some(&result.run_uid),
|
||||||
None,
|
None,
|
||||||
notes,
|
notes,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,10 @@ export async function fetchEntryDetail(archiveId, entryUid) {
|
||||||
return getJson(`/api/archives/${archiveId}/entries/${entryUid}`);
|
return getJson(`/api/archives/${archiveId}/entries/${entryUid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchEntryChildren(archiveId, entryUid) {
|
||||||
|
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/children`);
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch multiple artifact JSON payloads for an entry in parallel.
|
// Fetch multiple artifact JSON payloads for an entry in parallel.
|
||||||
// Returns a Promise<Array> preserving index order.
|
// Returns a Promise<Array> preserving index order.
|
||||||
export function fetchEntryArtifacts(archiveId, entryUid, indices) {
|
export function fetchEntryArtifacts(archiveId, entryUid, indices) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,35 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { formatTimestamp, formatBytes, valueText, sourceIconSvg } from '../utils';
|
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 }) {
|
export default function EntryRow({ entry, archiveId, isSelected, isMultiSelected, onRowClick }) {
|
||||||
const [favFailed, setFavFailed] = useState(false);
|
const [favFailed, setFavFailed] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [children, setChildren] = useState(null);
|
||||||
|
const [childrenLoading, setChildrenLoading] = useState(false);
|
||||||
|
|
||||||
const showFavicon =
|
const showFavicon =
|
||||||
entry.source_kind === 'web' &&
|
entry.source_kind === 'web' &&
|
||||||
entry.entity_kind === 'page' &&
|
entry.entity_kind === 'page' &&
|
||||||
|
|
@ -24,49 +51,101 @@ export default function EntryRow({ entry, archiveId, isSelected, isMultiSelected
|
||||||
);
|
);
|
||||||
|
|
||||||
const checked = isSelected || isMultiSelected;
|
const checked = isSelected || isMultiSelected;
|
||||||
|
const hasChildren = entry.child_count > 0;
|
||||||
|
|
||||||
function handleCheckboxClick(e) {
|
function handleCheckboxClick(e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// treat checkbox tap as ctrl+click: toggle this entry without clearing others
|
|
||||||
onRowClick(entry, { ctrlKey: true, metaKey: false, shiftKey: false, preventDefault() {} });
|
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 (
|
return (
|
||||||
<div
|
<div className={outerClass} data-entry-uid={entry.entry_uid}>
|
||||||
className={[isSelected && 'is-selected', isMultiSelected && 'is-multi-selected'].filter(Boolean).join(' ') || undefined}
|
{/* entry-row-main is the interactive flex row */}
|
||||||
tabIndex={0}
|
<div
|
||||||
data-entry-uid={entry.entry_uid}
|
className="entry-row-main"
|
||||||
onMouseDown={e => { if (e.shiftKey) e.preventDefault() }}
|
tabIndex={0}
|
||||||
onClick={e => onRowClick(entry, e)}
|
onMouseDown={e => { if (e.shiftKey) e.preventDefault(); }}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') onRowClick(entry, e) }}
|
onClick={e => onRowClick(entry, e)}
|
||||||
>
|
onKeyDown={e => { if (e.key === 'Enter') onRowClick(entry, e); }}
|
||||||
<div className="col-check">
|
>
|
||||||
<button
|
<div className="col-check">
|
||||||
type="button"
|
<button
|
||||||
className={`row-checkbox${checked ? ' is-checked' : ''}`}
|
type="button"
|
||||||
aria-pressed={checked}
|
className={`row-checkbox${checked ? ' is-checked' : ''}`}
|
||||||
aria-label={checked ? 'Deselect entry' : 'Select entry'}
|
aria-pressed={checked}
|
||||||
onClick={handleCheckboxClick}
|
aria-label={checked ? 'Deselect entry' : 'Select entry'}
|
||||||
onKeyDown={e => e.stopPropagation()}
|
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>
|
||||||
<div className="col-added">{formatTimestamp(entry.archived_at)}</div>
|
{expanded && (
|
||||||
<div className="col-title">
|
<div className="child-entries" aria-label={`${entry.child_count} child entries`}>
|
||||||
<span className="source-icon">{icon}</span>
|
{childrenLoading && <div className="child-entries-loading">Loading…</div>}
|
||||||
<span className="entry-title">{valueText(entry.title) || valueText(entry.entry_uid)}</span>
|
{children && children.map(child => (
|
||||||
</div>
|
<ChildRow key={child.entry_uid} entry={child} />
|
||||||
<div className="col-type">
|
))}
|
||||||
<span className="type-pill">{valueText(entry.entity_kind)}</span>
|
</div>
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2726,3 +2726,132 @@ body.has-audio-bar { padding-bottom: 56px; }
|
||||||
@media (pointer: coarse) {
|
@media (pointer: coarse) {
|
||||||
.skeleton-row .col-added { padding-left: 10px; }
|
.skeleton-row .col-added { padding-left: 10px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Child entry expansion ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Outer wrapper: one block per entry-group so nth-child stays correct.
|
||||||
|
#entries-body > .entry-row-outer beats #entries-body > div on specificity
|
||||||
|
(id + class > id + element) so display:flex is safely overridden. */
|
||||||
|
#entries-body > .entry-row-outer {
|
||||||
|
display: block;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inner flex row: replicates the #entries-body > div row behaviour. */
|
||||||
|
#entries-body > .entry-row-outer > .entry-row-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: default;
|
||||||
|
border-bottom: 1px solid var(--line-soft);
|
||||||
|
/* Reset padding that #entries-body > div > div would otherwise apply. */
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: unset;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Column cells inside the inner row. */
|
||||||
|
#entries-body > .entry-row-outer > .entry-row-main > div {
|
||||||
|
padding: 7px 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#entries-body > .entry-row-outer > .entry-row-main > div:last-child { padding-right: 22px; }
|
||||||
|
#entries-body > .entry-row-outer > .entry-row-main .col-added { padding-left: 22px; }
|
||||||
|
|
||||||
|
/* Striping: one outer wrapper per entry, so nth-child alternation is preserved. */
|
||||||
|
#entries-body > .entry-row-outer:nth-child(even) { background: #f2ede5; }
|
||||||
|
#entries-body > .entry-row-outer:nth-child(odd) { background: var(--paper-3); }
|
||||||
|
|
||||||
|
/* Selection: class lives on outer wrapper, outline scoped to inner row. */
|
||||||
|
#entries-body > .entry-row-outer.is-selected { background: #eee2d2; outline: none; }
|
||||||
|
#entries-body > .entry-row-outer.is-multi-selected { background: #eee2d2; outline: none; }
|
||||||
|
#entries-body > .entry-row-outer.is-selected .entry-row-main {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
#entries-body > .entry-row-outer.is-multi-selected .entry-row-main {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* URL overflow on hover / selection — same semantics as original. */
|
||||||
|
#entries-body > .entry-row-outer .url-cell:hover,
|
||||||
|
#entries-body > .entry-row-outer.is-selected .url-cell { overflow: visible; white-space: normal; }
|
||||||
|
|
||||||
|
/* child-entries container: reset the padding that #entries-body > div > div applies. */
|
||||||
|
#entries-body > .entry-row-outer > .child-entries {
|
||||||
|
display: block;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: unset;
|
||||||
|
overflow: visible;
|
||||||
|
border-left: 2px solid var(--line-soft);
|
||||||
|
margin-left: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Each child row reuses the same .col-* flex widths as normal rows. */
|
||||||
|
.child-entry-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: default;
|
||||||
|
border-bottom: 1px solid var(--line-soft);
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
.child-entry-row:last-child { border-bottom: none; }
|
||||||
|
.child-entry-row > div {
|
||||||
|
padding: 6px 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.child-entry-row .col-added { padding-left: 22px; }
|
||||||
|
.child-entry-row > div:last-child { padding-right: 22px; }
|
||||||
|
|
||||||
|
/* Expand chevron button */
|
||||||
|
.entry-expand-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin-right: 2px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.45;
|
||||||
|
color: inherit;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.entry-expand-btn::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-top: 4px solid transparent;
|
||||||
|
border-bottom: 4px solid transparent;
|
||||||
|
border-left: 6px solid currentColor;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.entry-expand-btn:hover { opacity: 1; }
|
||||||
|
.entry-expand-btn.is-expanded::before { transform: rotate(90deg); }
|
||||||
|
|
||||||
|
/* Child count badge next to title */
|
||||||
|
.child-count-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 5px;
|
||||||
|
padding: 0 5px;
|
||||||
|
font-size: 0.72em;
|
||||||
|
font-weight: 600;
|
||||||
|
background: color-mix(in srgb, var(--line-soft) 60%, transparent);
|
||||||
|
border-radius: 10px;
|
||||||
|
opacity: 0.75;
|
||||||
|
vertical-align: middle;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading placeholder inside child-entries */
|
||||||
|
.child-entries-loading {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue