1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-22 03:05:32 +02:00

fix(frontend): child row stripes, deleted-child visibility, selection completeness

EntryRow.jsx / ChildRow:
- Index-based light/dark classes (child-entry-row--light/dark) replace
  nth-child rules; parity comes from children.map idx so no sibling —
  including the loading div — can shift the stripe order
- Loading div moved outside .child-entries so it never affects child
  row ordering at all
- Accept deletedUids prop; filter expanded children array before render
  so deleted children disappear immediately without waiting for reload

EntriesView.jsx: thread deletedUids through to EntryRow

App.jsx:
- Add deletedUids state; handleEntryDeleted/handleBulkDeleted populate it
- isRoot/hasChildDelete computed from entries before setEntries (safe in
  StrictMode — no side-effects inside updater functions)
- Child delete triggers loadEntries to refresh stale parent child_count
  and total_artifact_bytes
- handleRowClick ctrl/meta cache-miss branch uses det.summary (not det)
  from fetchEntryDetail; archiveId added to dep array
- handleRowClick dep array includes archiveId
This commit is contained in:
TheGeneralist 2026-07-21 13:52:24 +02:00
parent 74d3b0b3be
commit 5f575ac8d7
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
8 changed files with 95 additions and 70 deletions

View file

@ -86,6 +86,7 @@ export default function App() {
const [archives, setArchives] = useState([])
const [archiveId, setArchiveId] = useState(null)
const [entries, setEntries] = useState([])
const [deletedUids, setDeletedUids] = useState(() => new Set())
const [selectedEntryUid, setSelectedEntryUid] = useState(() => parseLocation().entry)
const [selectedEntry, setSelectedEntry] = useState(null)
const [selectedUids, setSelectedUids] = useState(() => {
@ -294,12 +295,20 @@ export default function App() {
if (next.size === 0) {
selectEntry(null)
} else if (next.size === 1) {
// Resolve the remaining UID may be a child not in root entries array.
// Resolve the remaining UID may be a child not in the root entries array
// and not yet cached (e.g. picked up via shift-range without a direct click).
const [remainingUid] = next
const cached = entryCacheRef.current.get(remainingUid)
?? entries.find(x => x.entry_uid === remainingUid)
?? null
selectEntry(cached)
if (cached) {
selectEntry(cached)
} else {
// Cache miss fetch from server rather than clearing the detail panel.
fetchEntryDetail(archiveId, remainingUid)
.then(det => { if (det?.summary) selectEntry(det.summary) })
.catch(() => {})
}
} else {
selectEntry(null)
}
@ -308,7 +317,7 @@ export default function App() {
setSelectedUids(new Set([entry.entry_uid]))
selectEntry(entry)
}
}, [entries, selectedUids, selectEntry])
}, [entries, selectedUids, selectEntry, archiveId])
const handleTagFilterSet = useCallback((fullPath) => {
setTagFilter(fullPath)
@ -360,18 +369,26 @@ export default function App() {
}, [archiveId, selectedEntry])
const handleEntryDeleted = useCallback((entryUid) => {
const isRoot = entries.some(e => e.entry_uid === entryUid)
setDeletedUids(prev => { const n = new Set(prev); n.add(entryUid); return n })
setEntries(prev => prev.filter(e => e.entry_uid !== entryUid))
setSelectedEntry(prev => prev?.entry_uid === entryUid ? null : prev)
setSelectedEntryUid(prev => prev === entryUid ? null : prev)
setSelectedUids(prev => { const n = new Set(prev); n.delete(entryUid); return n })
}, [])
// Child delete: parent row's child_count/size are stale reload after state updates.
if (!isRoot) loadEntries(archiveId, searchQuery, tagFilter)
}, [entries, archiveId, searchQuery, tagFilter, loadEntries])
const handleBulkDeleted = useCallback((uids) => {
const rootUids = new Set(entries.map(e => e.entry_uid))
const hasChildDelete = [...uids].some(u => !rootUids.has(u))
setDeletedUids(prev => { const n = new Set(prev); uids.forEach(u => n.add(u)); return n })
setEntries(prev => prev.filter(e => !uids.has(e.entry_uid)))
setSelectedUids(new Set())
setSelectedEntry(null)
setSelectedEntryUid(null)
}, [])
if (hasChildDelete) loadEntries(archiveId, searchQuery, tagFilter)
}, [entries, archiveId, searchQuery, tagFilter, loadEntries])
// Auto-snap: drive selectedEntryUid from selectedUids so URL sync and detail
// panel stay correct. size >= 2 clears single-entry state (bulk panel takes over).
@ -581,6 +598,7 @@ export default function App() {
onRowClick={handleRowClick}
archiveId={archiveId}
pendingCaptures={pendingCaptures}
deletedUids={deletedUids}
/>
)}
{view === 'runs' && <RunsView runs={runs} />}

View file

@ -2,7 +2,7 @@ import SkeletonEntryRow from './SkeletonEntryRow';
import EntryRow from './EntryRow';
export default function EntriesView({ entries, selectedUids, onRowClick, archiveId, pendingCaptures = [] }) {
export default function EntriesView({ entries, selectedUids, onRowClick, archiveId, pendingCaptures = [], deletedUids }) {
return (
<section id="archive-view" className="view is-active">
<div className="entry-table">
@ -27,6 +27,7 @@ export default function EntriesView({ entries, selectedUids, onRowClick, archive
isMultiSelected={selectedUids.size >= 2 && selectedUids.has(entry.entry_uid)}
onRowClick={onRowClick}
selectedUids={selectedUids}
deletedUids={deletedUids}
/>
))}
</div>

View file

@ -2,11 +2,12 @@ import { useState } from 'react';
import { formatTimestamp, formatBytes, valueText, sourceIconSvg } from '../utils';
import { fetchEntryChildren } from '../api';
function ChildRow({ entry, onRowClick, selectedUids }) {
function ChildRow({ entry, index, onRowClick, selectedUids }) {
const isSelected = (selectedUids?.size === 1) && selectedUids.has(entry.entry_uid);
const isMultiSelected = (selectedUids?.size >= 2) && selectedUids.has(entry.entry_uid);
const cls = ['child-entry-row',
index % 2 === 0 ? 'child-entry-row--light' : 'child-entry-row--dark',
isSelected && 'is-selected',
isMultiSelected && 'is-multi-selected',
].filter(Boolean).join(' ');
@ -39,7 +40,7 @@ function ChildRow({ entry, onRowClick, selectedUids }) {
);
}
export default function EntryRow({ entry, archiveId, isSelected, isMultiSelected, onRowClick, selectedUids }) {
export default function EntryRow({ entry, archiveId, isSelected, isMultiSelected, onRowClick, selectedUids, deletedUids }) {
const [favFailed, setFavFailed] = useState(false);
const [expanded, setExpanded] = useState(false);
const [children, setChildren] = useState(null);
@ -150,17 +151,22 @@ export default function EntryRow({ entry, archiveId, isSelected, isMultiSelected
<div className="url-cell col-url">{valueText(entry.original_url)}</div>
</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}
onRowClick={onRowClick}
selectedUids={selectedUids}
/>
))}
</div>
<div className="child-entries" aria-label={`${entry.child_count} child entries`}>
{children && children
.filter(c => !deletedUids?.has(c.entry_uid))
.map((child, idx) => (
<ChildRow
key={child.entry_uid}
entry={child}
index={idx}
onRowClick={onRowClick}
selectedUids={selectedUids}
/>
))}
</div>
</>
)}
</div>
);

View file

@ -2796,8 +2796,8 @@ body.has-audio-bar { padding-bottom: 56px; }
}
.child-entry-row:hover { opacity: 1; }
.child-entry-row:last-child { border-bottom: none; }
.child-entry-row:nth-child(odd) { background: var(--paper-3); }
.child-entry-row:nth-child(even) { background: #f2ede5; }
.child-entry-row--light { background: var(--paper-3); }
.child-entry-row--dark { background: #f2ede5; }
.child-entry-row.is-selected {
background: #eee2d2;
box-shadow: inset 0 0 0 2px var(--accent);