import { useState, useRef, useEffect } from 'react'; import { formatTimestamp, formatBytes, valueText, sourceIconSvg } from '../utils'; import { fetchEntryChildren, reorderEntryChildren } from '../api'; function ChildRow({ entry, index, onRowClick, selectedUids, isFirst, isLast, reorderDisabled, onMove, canReorder, isDragging, dropEdge, onHandleDragStart, onHandleDragEnd, onRowDragOver, onRowDrop, }) { const isSelected = (selectedUids?.size === 1) && selectedUids.has(entry.entry_uid); const isMultiSelected = (selectedUids?.size >= 2) && selectedUids.has(entry.entry_uid); const label = valueText(entry.title) || valueText(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', isDragging && 'is-dragging', dropEdge === 'before' && 'is-drop-before', dropEdge === 'after' && 'is-drop-after', ].filter(Boolean).join(' '); return (
{ if (e.shiftKey) e.preventDefault(); }} onClick={e => onRowClick(entry, e)} onDragOver={canReorder ? onRowDragOver : undefined} onDrop={canReorder ? onRowDrop : undefined} aria-keyshortcuts={canReorder ? 'Alt+ArrowUp Alt+ArrowDown' : undefined} onKeyDown={e => { if (canReorder && e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { e.preventDefault(); if (!reorderDisabled) onMove(e.key === 'ArrowUp' ? -1 : 1); return; } if (e.key === 'Enter') onRowClick(entry, e); }} > ); } export default function EntryRow({ entry, archiveId, rowIndex, isSelected, isMultiSelected, onRowClick, selectedUids, deletedUids, renamedTitles, isPublicSession, canReorder = false, onReorderError }) { const [favFailed, setFavFailed] = useState(false); const [expanded, setExpanded] = useState(false); const [children, setChildren] = useState(null); const [childrenLoading, setChildrenLoading] = useState(false); const [reorderSaving, setReorderSaving] = useState(false); const [dragUid, setDragUid] = useState(null); const [dropTarget, setDropTarget] = useState(null); // { uid, after: bool } const childListRef = useRef(null); const focusUidRef = useRef(null); const showFavicon = entry.source_kind === 'web' && entry.entity_kind === 'page' && entry.has_favicon && archiveId && !favFailed; const icon = showFavicon ? ( setFavFailed(true)} style={{ objectFit: 'contain' }} /> ) : ( ); const checked = isSelected || isMultiSelected; const hasChildren = entry.child_count > 0 && !isPublicSession; function handleCheckboxClick(e) { e.stopPropagation(); 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); } } } // Deleted children are hidden locally and already gone server-side, so the // filtered list is exactly the set the server expects. Renames are applied on // top of the fetched list, since that list is only fetched once per expansion. const visibleChildren = (children ?? []) .filter(c => !deletedUids?.has(c.entry_uid)) .map(c => (renamedTitles?.has(c.entry_uid) ? { ...c, title: renamedTitles.get(c.entry_uid) } : c)); async function commitChildOrder(next) { const prev = children; setChildren(next); // optimistic setReorderSaving(true); try { await reorderEntryChildren(archiveId, entry.entry_uid, next.map(c => c.entry_uid)); } catch (err) { setChildren(prev); // revert onReorderError?.(err.message); if (err.status === 400) { // stale set: resync with the server try { setChildren(await fetchEntryChildren(archiveId, entry.entry_uid)); } catch (_) { /* keep reverted list */ } } } finally { setReorderSaving(false); } } function moveChild(fromIdx, toIdx) { if (!canReorder || reorderSaving || fromIdx === toIdx || toIdx < 0 || toIdx >= visibleChildren.length) return; const next = visibleChildren.slice(); const [moved] = next.splice(fromIdx, 1); next.splice(toIdx, 0, moved); commitChildOrder(next); } function handleChildMove(idx, delta) { focusUidRef.current = visibleChildren[idx]?.entry_uid ?? null; moveChild(idx, idx + delta); } // Keep keyboard focus on the moved row (React re-inserts keyed nodes, // which can drop focus in some browsers). useEffect(() => { const uid = focusUidRef.current; if (!uid || !childListRef.current) return; focusUidRef.current = null; const row = childListRef.current.querySelector(`[data-entry-uid="${CSS.escape(uid)}"]`); if (row && !row.contains(document.activeElement)) row.focus(); }, [children]); function handleDragStart(uid, e) { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', uid); // Firefox requires data to start a drag const rowEl = e.currentTarget.closest('.child-entry-row'); if (rowEl) e.dataTransfer.setDragImage(rowEl, 16, rowEl.offsetHeight / 2); setDragUid(uid); } function handleDragEnd() { setDragUid(null); setDropTarget(null); } function handleRowDragOver(uid, e) { if (!dragUid) return; // foreign drags (files, other parents) are not droppable e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const rect = e.currentTarget.getBoundingClientRect(); const after = e.clientY > rect.top + rect.height / 2; if (dropTarget?.uid !== uid || dropTarget?.after !== after) setDropTarget({ uid, after }); } function handleRowDrop(uid, e) { if (!dragUid) return; e.preventDefault(); const fromIdx = visibleChildren.findIndex(c => c.entry_uid === dragUid); const targetIdx = visibleChildren.findIndex(c => c.entry_uid === uid); const after = dropTarget?.uid === uid ? dropTarget.after : false; handleDragEnd(); if (fromIdx === -1 || targetIdx === -1) return; let toIdx = targetIdx + (after ? 1 : 0); if (fromIdx < toIdx) toIdx -= 1; moveChild(fromIdx, toIdx); } const outerClass = [ 'entry-row-outer', rowIndex % 2 === 0 ? 'entry-row-outer--light' : 'entry-row-outer--dark', 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.cacheable_bytes > 0 && ( {Math.round(entry.cached_bytes / entry.cacheable_bytes * 100)}% cached )}
{valueText(entry.original_url)}
{expanded && ( <> {childrenLoading &&
Loading…
}
{visibleChildren.map((child, idx) => ( handleChildMove(idx, delta)} isDragging={dragUid === child.entry_uid} dropEdge={dropTarget?.uid === child.entry_uid && dragUid !== child.entry_uid ? (dropTarget.after ? 'after' : 'before') : null} onHandleDragStart={e => handleDragStart(child.entry_uid, e)} onHandleDragEnd={handleDragEnd} onRowDragOver={e => handleRowDragOver(child.entry_uid, e)} onRowDrop={e => handleRowDrop(child.entry_uid, e)} /> ))}
)}
); }