diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1797667..3ee4ad8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef, createContext } from 'react' +import { useState, useEffect, useCallback, useRef, useMemo, createContext } from 'react' import { fetchArchives, fetchEntries, searchEntries, fetchRuns, fetchTags, checkSetup, fetchMe, fetchEntryDetail } from './api' import LoginPage from './components/LoginPage.jsx' import SetupPage from './components/SetupPage.jsx' @@ -77,6 +77,7 @@ export default function App() { setTagFilter(tag) setSelectedEntryUid(entry) setSelectedEntry(null) + setSelectedUids(entry ? new Set([entry]) : new Set()) } window.addEventListener('popstate', handler) return () => window.removeEventListener('popstate', handler) @@ -87,6 +88,10 @@ export default function App() { const [entries, setEntries] = useState([]) const [selectedEntryUid, setSelectedEntryUid] = useState(() => parseLocation().entry) const [selectedEntry, setSelectedEntry] = useState(null) + const [selectedUids, setSelectedUids] = useState(() => { + const e = parseLocation().entry + return e ? new Set([e]) : new Set() + }) const [tagFilter, setTagFilter] = useState(() => parseLocation().tag) const [view, setView] = useState(() => parseLocation().view) const [settingsTab, setSettingsTab] = useState(() => parseLocation().settingsTab) @@ -113,6 +118,7 @@ export default function App() { const searchInputRef = useRef(null) const pendingSearchFocus = useRef(false) const firstArchiveLoad = useRef(true) + const lastAnchorIndexRef = useRef(null) const humanizeTags = currentUser?.humanize_slugs ?? false; @@ -143,6 +149,14 @@ export default function App() { results = await fetchEntries(aid) } setEntries(results) + // Prune multi-selection to only entries still visible after load. + // Single-select (size 1) survives naturally; bulk-select is clipped to visible rows. + setSelectedUids(prev => { + if (prev.size < 2) return prev // single-select persists across search/filter + const visible = new Set(results.map(e => e.entry_uid)) + const pruned = new Set([...prev].filter(uid => visible.has(uid))) + return pruned.size === prev.size ? prev : pruned + }) setResultCount(results.length === 0 ? 'No results' : `${results.length} result${results.length === 1 ? '' : 's'}`) } catch { setEntries([]) @@ -181,6 +195,7 @@ export default function App() { setTagFilter(null) setSelectedEntry(null) setSelectedEntryUid(null) + setSelectedUids(new Set()) Promise.all([ loadEntries(archiveId, '', null), fetchRuns(archiveId).then(setRuns), @@ -232,6 +247,39 @@ export default function App() { setSelectedEntry(entry) }, []) + const handleRowClick = useCallback((entry, e) => { + if (e.shiftKey && lastAnchorIndexRef.current !== null) { + e.preventDefault() + const anchorIdx = entries.findIndex(x => x.entry_uid === lastAnchorIndexRef.current) + const clickIdx = entries.findIndex(x => x.entry_uid === entry.entry_uid) + if (anchorIdx === -1 || clickIdx === -1) { + // anchor evicted by search/filter/delete — fall back to single select + lastAnchorIndexRef.current = entry.entry_uid + setSelectedUids(new Set([entry.entry_uid])) + selectEntry(entry) + return + } + const lo = Math.min(anchorIdx, clickIdx) + const hi = Math.max(anchorIdx, clickIdx) + const range = entries.slice(lo, hi + 1) + const uids = new Set(range.map(x => x.entry_uid)) + setSelectedUids(uids) + if (uids.size === 1) selectEntry(range[0]) + } else if (e.ctrlKey || e.metaKey) { + lastAnchorIndexRef.current = entry.entry_uid + setSelectedUids(prev => { + const next = new Set(prev) + if (next.has(entry.entry_uid)) next.delete(entry.entry_uid) + else next.add(entry.entry_uid) + return next + }) + } else { + lastAnchorIndexRef.current = entry.entry_uid + setSelectedUids(new Set([entry.entry_uid])) + selectEntry(entry) + } + }, [entries, selectEntry]) + const handleTagFilterSet = useCallback((fullPath) => { setTagFilter(fullPath) }, []) @@ -285,8 +333,37 @@ export default function App() { 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 }) }, []) + const handleBulkDeleted = useCallback((uids) => { + setEntries(prev => prev.filter(e => !uids.has(e.entry_uid))) + setSelectedUids(new Set()) + setSelectedEntry(null) + setSelectedEntryUid(null) + }, []) + + // Auto-snap: drive selectedEntryUid from selectedUids so URL sync and detail + // panel stay correct. size >= 2 clears single-entry state (bulk panel takes over). + useEffect(() => { + if (selectedUids.size >= 2) { + setSelectedEntryUid(null) + setSelectedEntry(null) + } else if (selectedUids.size === 1) { + const [uid] = selectedUids + setSelectedEntryUid(uid) + // selectedEntry object restored by the existing restore effect below + } else { + setSelectedEntryUid(null) + setSelectedEntry(null) + } + }, [selectedUids]) + + const selectedEntries = useMemo( + () => entries.filter(e => selectedUids.has(e.entry_uid)), + [entries, selectedUids] + ) + // Restore selectedEntry object from selectedEntryUid when entries load. // Handles page refresh and back/forward navigation where only the UID is known. useEffect(() => { @@ -444,15 +521,9 @@ export default function App() { {view === 'archive' && ( )} {view === 'runs' && } @@ -480,12 +551,15 @@ export default function App() {
+ diff --git a/frontend/src/components/EntryRow.jsx b/frontend/src/components/EntryRow.jsx index 3d5eff3..57b6abf 100644 --- a/frontend/src/components/EntryRow.jsx +++ b/frontend/src/components/EntryRow.jsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { formatTimestamp, formatBytes, valueText, sourceIconSvg } from '../utils'; -export default function EntryRow({ entry, archiveId, isSelected, onSelect }) { +export default function EntryRow({ entry, archiveId, isSelected, isMultiSelected, onRowClick }) { const [favFailed, setFavFailed] = useState(false); const showFavicon = entry.source_kind === 'web' && @@ -23,14 +23,33 @@ export default function EntryRow({ entry, archiveId, isSelected, onSelect }) { ); + const checked = isSelected || isMultiSelected; + + 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() {} }); + } + return (
{ if (e.key === 'Enter') onSelect(); }} + onMouseDown={e => { if (e.shiftKey) e.preventDefault() }} + onClick={e => onRowClick(entry, e)} + onKeyDown={e => { if (e.key === 'Enter') onRowClick(entry, e) }} > +
+
{formatTimestamp(entry.archived_at)}
{icon} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 715c6a8..e442556 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -306,6 +306,11 @@ select { outline: 2px solid var(--accent); outline-offset: -2px; } +#entries-body > div.is-multi-selected { + background: #eee2d2; + outline: 2px solid var(--accent); + outline-offset: -2px; +} .col-added { width: 162px; color: var(--muted); } .col-title { flex: 1 1 0; min-width: 0; overflow: hidden; display: flex; align-items: center; gap: 0.42em; } @@ -322,8 +327,9 @@ select { } .col-url { flex: 0 0 30%; min-width: 0; overflow: hidden; } -.entry-header-row > div:first-child, -#entries-body > div > div:first-child { padding-left: 22px; } +/* col-check is first-child but hidden on desktop — target col-added directly */ +.entry-header-row .col-added, +#entries-body > div .col-added { padding-left: 22px; } .entry-header-row > div:last-child, #entries-body > div > div:last-child { padding-right: 22px; } @@ -337,6 +343,56 @@ select { #entries-body .is-selected .url-cell { overflow: visible; white-space: normal; } .type-pill { display: inline-block; padding: 2px 6px; background: #d8e3df; color: #275a5f; border: 1px solid #bfd0ca; border-radius: var(--r); } +/* ── Multi-select checkbox column ───────────────────────────────────────── */ +/* Hidden on desktop (pointer:fine); always visible on touch/mobile */ +.col-check { + display: none; + width: 40px; + flex-shrink: 0; + align-items: center; + justify-content: center; + padding: 0 !important; + cursor: pointer; +} +.row-checkbox { + /* reset