mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
feat: multi-select entries with bulk delete, tag, and collection actions (#28)
* feat: multi-select entry rows (shift/ctrl+click, mobile checkbox) * feat: bulk-action panel for multi-selected entries
This commit is contained in:
parent
278dc928df
commit
cd463d2810
10 changed files with 418 additions and 73 deletions
47
crates/archivr-server/static/assets/index-B7dq_VAM.js
Normal file
47
crates/archivr-server/static/assets/index-B7dq_VAM.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
crates/archivr-server/static/assets/index-nbPbdgtG.css
Normal file
1
crates/archivr-server/static/assets/index-nbPbdgtG.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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-BHmYoVqT.js"></script>
|
<script type="module" crossorigin src="/assets/index-B7dq_VAM.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-p2mzAHzf.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-nbPbdgtG.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -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 { fetchArchives, fetchEntries, searchEntries, fetchRuns, fetchTags, checkSetup, fetchMe, fetchEntryDetail } from './api'
|
||||||
import LoginPage from './components/LoginPage.jsx'
|
import LoginPage from './components/LoginPage.jsx'
|
||||||
import SetupPage from './components/SetupPage.jsx'
|
import SetupPage from './components/SetupPage.jsx'
|
||||||
|
|
@ -77,6 +77,7 @@ export default function App() {
|
||||||
setTagFilter(tag)
|
setTagFilter(tag)
|
||||||
setSelectedEntryUid(entry)
|
setSelectedEntryUid(entry)
|
||||||
setSelectedEntry(null)
|
setSelectedEntry(null)
|
||||||
|
setSelectedUids(entry ? new Set([entry]) : new Set())
|
||||||
}
|
}
|
||||||
window.addEventListener('popstate', handler)
|
window.addEventListener('popstate', handler)
|
||||||
return () => window.removeEventListener('popstate', handler)
|
return () => window.removeEventListener('popstate', handler)
|
||||||
|
|
@ -87,6 +88,10 @@ export default function App() {
|
||||||
const [entries, setEntries] = useState([])
|
const [entries, setEntries] = useState([])
|
||||||
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 e = parseLocation().entry
|
||||||
|
return e ? new Set([e]) : new Set()
|
||||||
|
})
|
||||||
const [tagFilter, setTagFilter] = useState(() => parseLocation().tag)
|
const [tagFilter, setTagFilter] = useState(() => parseLocation().tag)
|
||||||
const [view, setView] = useState(() => parseLocation().view)
|
const [view, setView] = useState(() => parseLocation().view)
|
||||||
const [settingsTab, setSettingsTab] = useState(() => parseLocation().settingsTab)
|
const [settingsTab, setSettingsTab] = useState(() => parseLocation().settingsTab)
|
||||||
|
|
@ -113,6 +118,7 @@ export default function App() {
|
||||||
const searchInputRef = useRef(null)
|
const searchInputRef = useRef(null)
|
||||||
const pendingSearchFocus = useRef(false)
|
const pendingSearchFocus = useRef(false)
|
||||||
const firstArchiveLoad = useRef(true)
|
const firstArchiveLoad = useRef(true)
|
||||||
|
const lastAnchorIndexRef = useRef(null)
|
||||||
|
|
||||||
const humanizeTags = currentUser?.humanize_slugs ?? false;
|
const humanizeTags = currentUser?.humanize_slugs ?? false;
|
||||||
|
|
||||||
|
|
@ -143,6 +149,14 @@ export default function App() {
|
||||||
results = await fetchEntries(aid)
|
results = await fetchEntries(aid)
|
||||||
}
|
}
|
||||||
setEntries(results)
|
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'}`)
|
setResultCount(results.length === 0 ? 'No results' : `${results.length} result${results.length === 1 ? '' : 's'}`)
|
||||||
} catch {
|
} catch {
|
||||||
setEntries([])
|
setEntries([])
|
||||||
|
|
@ -181,6 +195,7 @@ export default function App() {
|
||||||
setTagFilter(null)
|
setTagFilter(null)
|
||||||
setSelectedEntry(null)
|
setSelectedEntry(null)
|
||||||
setSelectedEntryUid(null)
|
setSelectedEntryUid(null)
|
||||||
|
setSelectedUids(new Set())
|
||||||
Promise.all([
|
Promise.all([
|
||||||
loadEntries(archiveId, '', null),
|
loadEntries(archiveId, '', null),
|
||||||
fetchRuns(archiveId).then(setRuns),
|
fetchRuns(archiveId).then(setRuns),
|
||||||
|
|
@ -232,6 +247,39 @@ export default function App() {
|
||||||
setSelectedEntry(entry)
|
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) => {
|
const handleTagFilterSet = useCallback((fullPath) => {
|
||||||
setTagFilter(fullPath)
|
setTagFilter(fullPath)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
@ -285,8 +333,37 @@ export default function App() {
|
||||||
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 })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
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.
|
// Restore selectedEntry object from selectedEntryUid when entries load.
|
||||||
// Handles page refresh and back/forward navigation where only the UID is known.
|
// Handles page refresh and back/forward navigation where only the UID is known.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -444,15 +521,9 @@ export default function App() {
|
||||||
{view === 'archive' && (
|
{view === 'archive' && (
|
||||||
<EntriesView
|
<EntriesView
|
||||||
entries={entries}
|
entries={entries}
|
||||||
selectedEntryUid={selectedEntryUid}
|
selectedUids={selectedUids}
|
||||||
onSelectEntry={selectEntry}
|
onRowClick={handleRowClick}
|
||||||
archiveId={archiveId}
|
archiveId={archiveId}
|
||||||
tagFilter={tagFilter}
|
|
||||||
onClearTagFilter={handleClearTagFilter}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
onSearchChange={setSearchQuery}
|
|
||||||
resultCount={resultCount}
|
|
||||||
searchBusy={searchBusy}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{view === 'runs' && <RunsView runs={runs} />}
|
{view === 'runs' && <RunsView runs={runs} />}
|
||||||
|
|
@ -480,12 +551,15 @@ export default function App() {
|
||||||
<ContextRail
|
<ContextRail
|
||||||
archiveId={archiveId}
|
archiveId={archiveId}
|
||||||
selectedEntry={selectedEntry}
|
selectedEntry={selectedEntry}
|
||||||
|
selectedUids={selectedUids}
|
||||||
|
selectedEntries={selectedEntries}
|
||||||
detail={entryDetail}
|
detail={entryDetail}
|
||||||
onTagFilterSet={handleTagFilterSet}
|
onTagFilterSet={handleTagFilterSet}
|
||||||
tagNodes={tagNodes}
|
tagNodes={tagNodes}
|
||||||
onTagsRefresh={handleTagsRefresh}
|
onTagsRefresh={handleTagsRefresh}
|
||||||
onEntryTitleChange={handleEntryTitleChange}
|
onEntryTitleChange={handleEntryTitleChange}
|
||||||
onEntryDeleted={handleEntryDeleted}
|
onEntryDeleted={handleEntryDeleted}
|
||||||
|
onBulkDeleted={handleBulkDeleted}
|
||||||
humanizeTags={humanizeTags}
|
humanizeTags={humanizeTags}
|
||||||
onDetailRefresh={handleDetailRefresh}
|
onDetailRefresh={handleDetailRefresh}
|
||||||
onOpenPreview={handleOpenPreview}
|
onOpenPreview={handleOpenPreview}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { fetchEntryTags, assignTag, removeTag, listEntryCollections, updateEntryTitle, deleteEntry, rearchiveEntry, pollCaptureJob } from '../api'
|
import { fetchEntryTags, assignTag, removeTag, listEntryCollections, listCollections, addEntryToCollection, updateEntryTitle, deleteEntry, rearchiveEntry, pollCaptureJob } from '../api'
|
||||||
import { formatTimestamp, formatBytes, valueText, sourceIconSvg, displayPath } from '../utils'
|
import { formatTimestamp, formatBytes, valueText, sourceIconSvg, displayPath } from '../utils'
|
||||||
|
|
||||||
const VIS_LABEL = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }
|
const VIS_LABEL = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }
|
||||||
|
|
@ -11,7 +11,7 @@ const ExternalIcon = () => (
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
export default function ContextRail({ archiveId, selectedEntry, detail, onTagFilterSet, tagNodes, onTagsRefresh, onEntryTitleChange, onEntryDeleted, humanizeTags, onDetailRefresh, onOpenPreview, onPlay }) {
|
export default function ContextRail({ archiveId, selectedEntry, selectedUids, selectedEntries, detail, onTagFilterSet, tagNodes, onTagsRefresh, onEntryTitleChange, onEntryDeleted, onBulkDeleted, humanizeTags, onDetailRefresh, onOpenPreview, onPlay }) {
|
||||||
const [tags, setTags] = useState([])
|
const [tags, setTags] = useState([])
|
||||||
const [assignInput, setAssignInput] = useState('')
|
const [assignInput, setAssignInput] = useState('')
|
||||||
const [entryCollections, setEntryCollections] = useState([])
|
const [entryCollections, setEntryCollections] = useState([])
|
||||||
|
|
@ -24,6 +24,17 @@ export default function ContextRail({ archiveId, selectedEntry, detail, onTagFil
|
||||||
const [rearchiveError, setRearchiveError] = useState('')
|
const [rearchiveError, setRearchiveError] = useState('')
|
||||||
const rearchivePollRef = useRef(null)
|
const rearchivePollRef = useRef(null)
|
||||||
|
|
||||||
|
// ── Bulk-panel state ────────────────────────────────────────────────────
|
||||||
|
const isBulk = selectedUids?.size >= 2
|
||||||
|
const [bulkTagInput, setBulkTagInput] = useState('')
|
||||||
|
const [bulkTagState, setBulkTagState] = useState('idle') // 'idle'|'running'|'done'|'error'
|
||||||
|
const [bulkTagError, setBulkTagError] = useState('')
|
||||||
|
const [collections, setCollections] = useState([])
|
||||||
|
const [bulkCollUid, setBulkCollUid] = useState('')
|
||||||
|
const [bulkCollState, setBulkCollState] = useState('idle') // 'idle'|'running'|'done'|'error'
|
||||||
|
const [bulkCollError, setBulkCollError] = useState('')
|
||||||
|
const [bulkDeleteState, setBulkDeleteState] = useState('idle') // 'idle'|'running'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const seq = ++selectSeqRef.current
|
const seq = ++selectSeqRef.current
|
||||||
if (rearchivePollRef.current) { clearInterval(rearchivePollRef.current); rearchivePollRef.current = null }
|
if (rearchivePollRef.current) { clearInterval(rearchivePollRef.current); rearchivePollRef.current = null }
|
||||||
|
|
@ -54,6 +65,80 @@ export default function ContextRail({ archiveId, selectedEntry, detail, onTagFil
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Fetch available collections when entering bulk mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isBulk || !archiveId) { setCollections([]); return }
|
||||||
|
listCollections(archiveId).then(setCollections).catch(() => setCollections([]))
|
||||||
|
}, [isBulk, archiveId])
|
||||||
|
|
||||||
|
// Reset transient bulk state when selection changes
|
||||||
|
useEffect(() => {
|
||||||
|
setBulkTagInput('')
|
||||||
|
setBulkTagState('idle')
|
||||||
|
setBulkTagError('')
|
||||||
|
setBulkCollUid('')
|
||||||
|
setBulkCollState('idle')
|
||||||
|
setBulkCollError('')
|
||||||
|
setBulkDeleteState('idle')
|
||||||
|
}, [selectedUids])
|
||||||
|
|
||||||
|
async function handleBulkDelete() {
|
||||||
|
const n = selectedUids.size
|
||||||
|
if (!window.confirm(`Delete ${n} entr${n === 1 ? 'y' : 'ies'}? This cannot be undone.`)) return
|
||||||
|
setBulkDeleteState('running')
|
||||||
|
const deletedUids = new Set()
|
||||||
|
for (const uid of selectedUids) {
|
||||||
|
try {
|
||||||
|
await deleteEntry(archiveId, uid)
|
||||||
|
deletedUids.add(uid)
|
||||||
|
} catch {
|
||||||
|
// partial failure — skip and continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBulkDeleteState('idle')
|
||||||
|
onBulkDeleted?.(deletedUids)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBulkTag() {
|
||||||
|
const path = bulkTagInput.trim()
|
||||||
|
if (!path) return
|
||||||
|
setBulkTagState('running')
|
||||||
|
setBulkTagError('')
|
||||||
|
try {
|
||||||
|
for (const uid of selectedUids) {
|
||||||
|
await assignTag(archiveId, uid, path)
|
||||||
|
}
|
||||||
|
setBulkTagInput('')
|
||||||
|
setBulkTagState('done')
|
||||||
|
onTagsRefresh?.()
|
||||||
|
setTimeout(() => setBulkTagState('idle'), 1800)
|
||||||
|
} catch (err) {
|
||||||
|
setBulkTagError(err.message)
|
||||||
|
setBulkTagState('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBulkAddToCollection() {
|
||||||
|
if (!bulkCollUid) return
|
||||||
|
setBulkCollState('running')
|
||||||
|
setBulkCollError('')
|
||||||
|
const failed = []
|
||||||
|
for (const uid of selectedUids) {
|
||||||
|
try {
|
||||||
|
await addEntryToCollection(archiveId, bulkCollUid, uid)
|
||||||
|
} catch (err) {
|
||||||
|
failed.push(uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed.length > 0) {
|
||||||
|
setBulkCollError(`Failed for ${failed.length} entr${failed.length === 1 ? 'y' : 'ies'}.`)
|
||||||
|
setBulkCollState('error')
|
||||||
|
} else {
|
||||||
|
setBulkCollState('done')
|
||||||
|
setTimeout(() => setBulkCollState('idle'), 1800)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleTitleSave() {
|
async function handleTitleSave() {
|
||||||
const newTitle = titleDraft.trim() || null
|
const newTitle = titleDraft.trim() || null
|
||||||
try {
|
try {
|
||||||
|
|
@ -174,10 +259,83 @@ export default function ContextRail({ archiveId, selectedEntry, detail, onTagFil
|
||||||
<aside className="context-rail">
|
<aside className="context-rail">
|
||||||
<div className="rail-eyebrow">Context</div>
|
<div className="rail-eyebrow">Context</div>
|
||||||
|
|
||||||
{!selectedEntry ? (
|
{isBulk ? (
|
||||||
|
<div className="bulk-panel">
|
||||||
|
<p className="bulk-count">
|
||||||
|
<span className="bulk-count-num">{selectedUids.size}</span>
|
||||||
|
{' entries selected'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="rail-section">
|
||||||
|
<div className="rail-section-heading">Assign tag</div>
|
||||||
|
{bulkTagError && (
|
||||||
|
<p className="form-msg form-msg--err" style={{ margin: '0 0 8px' }}>{bulkTagError}</p>
|
||||||
|
)}
|
||||||
|
<div className="tag-input-wrap">
|
||||||
|
<span className="hash">/</span>
|
||||||
|
<input
|
||||||
|
className="tag-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="science/cs"
|
||||||
|
autoComplete="off"
|
||||||
|
value={bulkTagInput}
|
||||||
|
onChange={e => setBulkTagInput(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleBulkTag() }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="tag-add-btn"
|
||||||
|
onClick={handleBulkTag}
|
||||||
|
disabled={bulkTagState === 'running' || !bulkTagInput.trim()}
|
||||||
|
>
|
||||||
|
{bulkTagState === 'running' ? '…' : bulkTagState === 'done' ? '✓' : 'Add'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{collections.length > 0 && (
|
||||||
|
<div className="rail-section">
|
||||||
|
<div className="rail-section-heading">Add to collection</div>
|
||||||
|
<div className="bulk-coll-row">
|
||||||
|
<select
|
||||||
|
className="bulk-coll-select"
|
||||||
|
value={bulkCollUid}
|
||||||
|
onChange={e => setBulkCollUid(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Pick a collection…</option>
|
||||||
|
{collections.map(c => (
|
||||||
|
<option key={c.collection_uid} value={c.collection_uid}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="tag-add-btn"
|
||||||
|
onClick={handleBulkAddToCollection}
|
||||||
|
disabled={!bulkCollUid || bulkCollState === 'running'}
|
||||||
|
>
|
||||||
|
{bulkCollState === 'running' ? '…' : bulkCollState === 'done' ? '✓' : bulkCollState === 'error' ? '!' : 'Add'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{bulkCollError && (
|
||||||
|
<p className="form-msg form-msg--err" style={{ margin: '6px 0 0' }}>{bulkCollError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rail-delete-zone">
|
||||||
|
<button
|
||||||
|
className="rail-delete-btn"
|
||||||
|
onClick={handleBulkDelete}
|
||||||
|
disabled={bulkDeleteState === 'running'}
|
||||||
|
>
|
||||||
|
{bulkDeleteState === 'running'
|
||||||
|
? 'Deleting\u2026'
|
||||||
|
: `Delete ${selectedUids.size} entr${selectedUids.size === 1 ? 'y' : 'ies'}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : !selectedEntry ? (
|
||||||
<p className="tags-empty">Select an entry.</p>
|
<p className="tags-empty">Select an entry.</p>
|
||||||
) : !detail ? (
|
) : !detail ? (
|
||||||
<p className="tags-empty">Loading…</p>
|
<p className="tags-empty">Loading\u2026</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{editingTitle ? (
|
{editingTitle ? (
|
||||||
|
|
@ -268,7 +426,7 @@ export default function ContextRail({ archiveId, selectedEntry, detail, onTagFil
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedEntry && (
|
{selectedEntry && !isBulk && (
|
||||||
<>
|
<>
|
||||||
<div className="rail-section">
|
<div className="rail-section">
|
||||||
<div className="rail-section-heading">Tags</div>
|
<div className="rail-section-heading">Tags</div>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import EntryRow from './EntryRow';
|
import EntryRow from './EntryRow';
|
||||||
|
|
||||||
export default function EntriesView({ entries, selectedEntryUid, onSelectEntry, archiveId }) {
|
export default function EntriesView({ entries, selectedUids, onRowClick, archiveId }) {
|
||||||
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">
|
||||||
<div className="entry-header-row">
|
<div className="entry-header-row">
|
||||||
|
<div className="col-check" aria-hidden="true" />
|
||||||
<div className="col-added">Added</div>
|
<div className="col-added">Added</div>
|
||||||
<div className="col-title">Title</div>
|
<div className="col-title">Title</div>
|
||||||
<div className="col-type">Type</div>
|
<div className="col-type">Type</div>
|
||||||
|
|
@ -17,8 +18,9 @@ export default function EntriesView({ entries, selectedEntryUid, onSelectEntry,
|
||||||
key={entry.entry_uid}
|
key={entry.entry_uid}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
archiveId={archiveId}
|
archiveId={archiveId}
|
||||||
isSelected={entry.entry_uid === selectedEntryUid}
|
isSelected={selectedUids.size === 1 && selectedUids.has(entry.entry_uid)}
|
||||||
onSelect={() => onSelectEntry(entry)}
|
isMultiSelected={selectedUids.size >= 2 && selectedUids.has(entry.entry_uid)}
|
||||||
|
onRowClick={onRowClick}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { formatTimestamp, formatBytes, valueText, sourceIconSvg } from '../utils';
|
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 [favFailed, setFavFailed] = useState(false);
|
||||||
const showFavicon =
|
const showFavicon =
|
||||||
entry.source_kind === 'web' &&
|
entry.source_kind === 'web' &&
|
||||||
|
|
@ -23,14 +23,33 @@ export default function EntryRow({ entry, archiveId, isSelected, onSelect }) {
|
||||||
<span dangerouslySetInnerHTML={{ __html: sourceIconSvg(entry.source_kind) }} />
|
<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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={isSelected ? 'is-selected' : undefined}
|
className={[isSelected && 'is-selected', isMultiSelected && 'is-multi-selected'].filter(Boolean).join(' ') || undefined}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
data-entry-uid={entry.entry_uid}
|
data-entry-uid={entry.entry_uid}
|
||||||
onClick={onSelect}
|
onMouseDown={e => { if (e.shiftKey) e.preventDefault() }}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') onSelect(); }}
|
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-added">{formatTimestamp(entry.archived_at)}</div>
|
||||||
<div className="col-title">
|
<div className="col-title">
|
||||||
<span className="source-icon">{icon}</span>
|
<span className="source-icon">{icon}</span>
|
||||||
|
|
|
||||||
|
|
@ -306,6 +306,11 @@ select {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: -2px;
|
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-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; }
|
.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; }
|
.col-url { flex: 0 0 30%; min-width: 0; overflow: hidden; }
|
||||||
|
|
||||||
.entry-header-row > div:first-child,
|
/* col-check is first-child but hidden on desktop — target col-added directly */
|
||||||
#entries-body > div > div:first-child { padding-left: 22px; }
|
.entry-header-row .col-added,
|
||||||
|
#entries-body > div .col-added { padding-left: 22px; }
|
||||||
.entry-header-row > div:last-child,
|
.entry-header-row > div:last-child,
|
||||||
#entries-body > div > div:last-child { padding-right: 22px; }
|
#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; }
|
#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); }
|
.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 (table) ───────────────────────────────────────────────────── */
|
||||||
#runs-view .entry-table {
|
#runs-view .entry-table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
|
@ -527,6 +583,42 @@ select {
|
||||||
background: var(--accent-2);
|
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 ───────────────────────────────────────────────────── */
|
||||||
.rail-delete-zone {
|
.rail-delete-zone {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue