1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

feat: multi-select entry rows (shift/ctrl+click, mobile checkbox)

- App: replace single selectedEntryUid with selectedUids Set; anchor stored
  as UID so shift+click range is stable across search/filter reloads
- Shift+click: range-select from UID anchor resolved against current entries;
  falls back to single-select if anchor was evicted
- Ctrl/Cmd+click: toggle individual entry in selection
- Plain click: single-select (existing ContextRail behaviour unchanged)
- onMouseDown preventDefault when shift held to suppress browser text selection
- selectedUids pruned to visible entries on loadEntries (size >= 2 only;
  single-select persists across search/filter)
- Auto-snap effect: drives selectedEntryUid from selectedUids so URL sync,
  detail panel, and back/forward navigation stay correct
- EntriesView: passes selectedUids + onRowClick; computes isSelected /
  isMultiSelected per row
- EntryRow: mobile-only checkbox column (pointer:coarse media query);
  uses <button aria-pressed> with aria-label for assistive tech; onKeyDown
  stopPropagation so Space/Enter on the button don't bubble to the row handler
- CSS: is-multi-selected style; col-check hidden by default, shown on coarse
  pointer; custom checkbox button with checked state and tick mark
This commit is contained in:
TheGeneralist 2026-07-14 15:37:10 +02:00
parent 278dc928df
commit 4d14ed3a32
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
4 changed files with 205 additions and 18 deletions

View file

@ -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' && (
<EntriesView
entries={entries}
selectedEntryUid={selectedEntryUid}
onSelectEntry={selectEntry}
selectedUids={selectedUids}
onRowClick={handleRowClick}
archiveId={archiveId}
tagFilter={tagFilter}
onClearTagFilter={handleClearTagFilter}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
resultCount={resultCount}
searchBusy={searchBusy}
/>
)}
{view === 'runs' && <RunsView runs={runs} />}
@ -480,12 +551,15 @@ export default function App() {
<ContextRail
archiveId={archiveId}
selectedEntry={selectedEntry}
selectedUids={selectedUids}
selectedEntries={selectedEntries}
detail={entryDetail}
onTagFilterSet={handleTagFilterSet}
tagNodes={tagNodes}
onTagsRefresh={handleTagsRefresh}
onEntryTitleChange={handleEntryTitleChange}
onEntryDeleted={handleEntryDeleted}
onBulkDeleted={handleBulkDeleted}
humanizeTags={humanizeTags}
onDetailRefresh={handleDetailRefresh}
onOpenPreview={handleOpenPreview}

View file

@ -1,10 +1,11 @@
import EntryRow from './EntryRow';
export default function EntriesView({ entries, selectedEntryUid, onSelectEntry, archiveId }) {
export default function EntriesView({ entries, selectedUids, onRowClick, archiveId }) {
return (
<section id="archive-view" className="view is-active">
<div className="entry-table">
<div className="entry-header-row">
<div className="col-check" aria-hidden="true" />
<div className="col-added">Added</div>
<div className="col-title">Title</div>
<div className="col-type">Type</div>
@ -17,8 +18,9 @@ export default function EntriesView({ entries, selectedEntryUid, onSelectEntry,
key={entry.entry_uid}
entry={entry}
archiveId={archiveId}
isSelected={entry.entry_uid === selectedEntryUid}
onSelect={() => onSelectEntry(entry)}
isSelected={selectedUids.size === 1 && selectedUids.has(entry.entry_uid)}
isMultiSelected={selectedUids.size >= 2 && selectedUids.has(entry.entry_uid)}
onRowClick={onRowClick}
/>
))}
</div>

View file

@ -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 }) {
<span dangerouslySetInnerHTML={{ __html: sourceIconSvg(entry.source_kind) }} />
);
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 (
<div
className={isSelected ? 'is-selected' : undefined}
className={[isSelected && 'is-selected', isMultiSelected && 'is-multi-selected'].filter(Boolean).join(' ') || undefined}
tabIndex={0}
data-entry-uid={entry.entry_uid}
onClick={onSelect}
onKeyDown={e => { 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) }}
>
<div className="col-check">
<button
type="button"
className={`row-checkbox${checked ? ' is-checked' : ''}`}
aria-pressed={checked}
aria-label={checked ? 'Deselect entry' : 'Select entry'}
onClick={handleCheckboxClick}
onKeyDown={e => e.stopPropagation()}
/>
</div>
<div className="col-added">{formatTimestamp(entry.archived_at)}</div>
<div className="col-title">
<span className="source-icon">{icon}</span>

View file

@ -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 <button> UA styles */
-webkit-appearance: none;
appearance: none;
padding: 0;
border: 1.5px solid var(--line);
cursor: pointer;
/* visual checkbox */
display: block;
width: 17px;
height: 17px;
border-radius: 4px;
background: var(--paper-3);
transition: border-color .15s ease, background .15s ease;
position: relative;
flex-shrink: 0;
}
.row-checkbox.is-checked {
background: var(--accent);
border-color: var(--accent);
}
.row-checkbox.is-checked::after {
content: '';
position: absolute;
left: 4px;
top: 2px;
width: 5px;
height: 8px;
border: 2px solid #fff;
border-top: none;
border-left: none;
transform: rotate(45deg);
}
@media (pointer: coarse) {
.col-check { display: flex; }
.entry-header-row .col-added,
#entries-body > div .col-added { padding-left: 10px; }
}
/* ── Runs view (table) ───────────────────────────────────────────────────── */
#runs-view .entry-table {
border-collapse: collapse;
@ -527,6 +583,42 @@ select {
background: var(--accent-2);
}
/* ── Bulk-action panel ───────────────────────────────────────────────────── */
.bulk-panel { padding-top: 2px; }
.bulk-count {
font-size: 13px;
color: var(--muted);
margin-bottom: 22px;
}
.bulk-count-num {
font-size: 22px;
font-weight: 700;
color: var(--ink);
line-height: 1;
margin-right: 4px;
}
.bulk-coll-row {
display: flex;
align-items: center;
gap: 8px;
}
.bulk-coll-select {
flex: 1;
min-width: 0;
height: 32px;
padding: 0 8px;
border: 1px solid var(--line);
border-radius: var(--r);
background: var(--field);
color: var(--ink);
font-size: 13px;
cursor: pointer;
outline: none;
transition: border-color .15s ease;
}
.bulk-coll-select:focus { border-color: var(--accent); }
.tag-add-btn:disabled { opacity: 0.45; cursor: default; }
/* ── Rail delete zone ───────────────────────────────────────────────────── */
.rail-delete-zone {
margin-top: 24px;