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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,8 +4,8 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Archivr</title> <title>Archivr</title>
<script type="module" crossorigin src="/assets/index-mnEjEwmD.js"></script> <script type="module" crossorigin src="/assets/index-Bc8JhW_4.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-sQJOY7Hx.css"> <link rel="stylesheet" crossorigin href="/assets/index-D8xkhocK.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -86,6 +86,7 @@ export default function App() {
const [archives, setArchives] = useState([]) const [archives, setArchives] = useState([])
const [archiveId, setArchiveId] = useState(null) const [archiveId, setArchiveId] = useState(null)
const [entries, setEntries] = useState([]) const [entries, setEntries] = useState([])
const [deletedUids, setDeletedUids] = useState(() => new Set())
const [selectedEntryUid, setSelectedEntryUid] = useState(() => parseLocation().entry) const [selectedEntryUid, setSelectedEntryUid] = useState(() => parseLocation().entry)
const [selectedEntry, setSelectedEntry] = useState(null) const [selectedEntry, setSelectedEntry] = useState(null)
const [selectedUids, setSelectedUids] = useState(() => { const [selectedUids, setSelectedUids] = useState(() => {
@ -294,12 +295,20 @@ export default function App() {
if (next.size === 0) { if (next.size === 0) {
selectEntry(null) selectEntry(null)
} else if (next.size === 1) { } 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 [remainingUid] = next
const cached = entryCacheRef.current.get(remainingUid) const cached = entryCacheRef.current.get(remainingUid)
?? entries.find(x => x.entry_uid === remainingUid) ?? entries.find(x => x.entry_uid === remainingUid)
?? null ?? 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 { } else {
selectEntry(null) selectEntry(null)
} }
@ -308,7 +317,7 @@ export default function App() {
setSelectedUids(new Set([entry.entry_uid])) setSelectedUids(new Set([entry.entry_uid]))
selectEntry(entry) selectEntry(entry)
} }
}, [entries, selectedUids, selectEntry]) }, [entries, selectedUids, selectEntry, archiveId])
const handleTagFilterSet = useCallback((fullPath) => { const handleTagFilterSet = useCallback((fullPath) => {
setTagFilter(fullPath) setTagFilter(fullPath)
@ -360,18 +369,26 @@ export default function App() {
}, [archiveId, selectedEntry]) }, [archiveId, selectedEntry])
const handleEntryDeleted = useCallback((entryUid) => { 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)) setEntries(prev => prev.filter(e => e.entry_uid !== entryUid))
setSelectedEntry(prev => prev?.entry_uid === entryUid ? null : prev) setSelectedEntry(prev => prev?.entry_uid === entryUid ? null : prev)
setSelectedEntryUid(prev => prev === entryUid ? null : prev) setSelectedEntryUid(prev => prev === entryUid ? null : prev)
setSelectedUids(prev => { const n = new Set(prev); n.delete(entryUid); return n }) 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 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))) setEntries(prev => prev.filter(e => !uids.has(e.entry_uid)))
setSelectedUids(new Set()) setSelectedUids(new Set())
setSelectedEntry(null) setSelectedEntry(null)
setSelectedEntryUid(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 // Auto-snap: drive selectedEntryUid from selectedUids so URL sync and detail
// panel stay correct. size >= 2 clears single-entry state (bulk panel takes over). // panel stay correct. size >= 2 clears single-entry state (bulk panel takes over).
@ -581,6 +598,7 @@ export default function App() {
onRowClick={handleRowClick} onRowClick={handleRowClick}
archiveId={archiveId} archiveId={archiveId}
pendingCaptures={pendingCaptures} pendingCaptures={pendingCaptures}
deletedUids={deletedUids}
/> />
)} )}
{view === 'runs' && <RunsView runs={runs} />} {view === 'runs' && <RunsView runs={runs} />}

View file

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

View file

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

View file

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