diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs index 9df6c52..a06af9a 100644 --- a/crates/archivr-server/src/routes.rs +++ b/crates/archivr-server/src/routes.rs @@ -241,6 +241,10 @@ pub fn app_with_state(state: AppState) -> Router { .patch(patch_entry_handler) .delete(delete_entry_handler), ) + .route( + "/api/archives/:archive_id/entries/:entry_uid/children", + get(list_entry_children), + ) .route( "/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index", get(serve_artifact), @@ -404,6 +408,18 @@ async fn list_entries( Ok(Json(archive::list_root_entries(&conn, caller_bits)?)) } +async fn list_entry_children( + State(state): State, + auth: AuthUser, + Path((archive_id, entry_uid)): Path<(String, String)>, +) -> Result>, 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( State(state): State, auth: AuthUser, @@ -902,10 +918,11 @@ async fn capture_handler( notes_str = serde_json::Value::Object(notes_map).to_string(); Some(¬es_str) }; + let job_status = if result.status == "completed" { "completed" } else { "failed" }; database::update_capture_job_status( &conn, &job_uid_bg, - "completed", + job_status, Some(&result.run_uid), None, notes, diff --git a/frontend/src/api.js b/frontend/src/api.js index d9fd243..60d184b 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -25,6 +25,10 @@ export async function fetchEntryDetail(archiveId, 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. // Returns a Promise preserving index order. export function fetchEntryArtifacts(archiveId, entryUid, indices) { diff --git a/frontend/src/components/EntryRow.jsx b/frontend/src/components/EntryRow.jsx index 57b6abf..3d8abee 100644 --- a/frontend/src/components/EntryRow.jsx +++ b/frontend/src/components/EntryRow.jsx @@ -1,8 +1,35 @@ import { useState } from 'react'; import { formatTimestamp, formatBytes, valueText, sourceIconSvg } from '../utils'; +import { fetchEntryChildren } from '../api'; + +function ChildRow({ entry }) { + return ( +
+ + ); +} 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 ( -
{ if (e.shiftKey) e.preventDefault() }} - onClick={e => onRowClick(entry, e)} - onKeyDown={e => { if (e.key === 'Enter') onRowClick(entry, e) }} - > -
-
+
{formatTimestamp(entry.archived_at)}
+
+ {hasChildren && ( +
+
+ {valueText(entry.entity_kind)} +
+
+ {formatBytes(entry.total_artifact_bytes)} + {entry.cached_bytes > 0 && entry.total_artifact_bytes > 0 && ( + + {Math.round(entry.cached_bytes / entry.total_artifact_bytes * 100)}% cached + + )} +
+
{valueText(entry.original_url)}
-
{formatTimestamp(entry.archived_at)}
-
- {icon} - {valueText(entry.title) || valueText(entry.entry_uid)} -
-
- {valueText(entry.entity_kind)} -
-
- {formatBytes(entry.total_artifact_bytes)} - {entry.cached_bytes > 0 && entry.total_artifact_bytes > 0 && ( - - {Math.round(entry.cached_bytes / entry.total_artifact_bytes * 100)}% cached - - )} -
-
{valueText(entry.original_url)}
+ {expanded && ( +
+ {childrenLoading &&
Loading…
} + {children && children.map(child => ( + + ))} +
+ )}
); } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 9b8fe4f..9fdd964 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2726,3 +2726,132 @@ body.has-audio-bar { padding-bottom: 56px; } @media (pointer: coarse) { .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; +}