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

feat: bulk-action panel for multi-selected entries

When >= 2 entries are selected, ContextRail switches to a bulk panel:

- Count badge showing number of selected entries
- Assign tag: path input + Add button; applies assignTag() to all selected
  UIDs sequentially; shows done/error feedback; refreshes tag tree on success
- Add to collection: select populated from listCollections(); applies
  addEntryToCollection() to all UIDs (skips already-present errors)
- Delete: window.confirm with count; deletes sequentially; partial failures
  are skipped; calls onBulkDeleted() to remove all deleted entries from the
  list in one update
- Single-entry Tags/Delete/Collections sections gated with !isBulk to prevent
  flash of stale single-entry UI during the async auto-snap effect
- Transient bulk state (tag input, coll selection, delete spinner) resets
  whenever selectedUids changes
- CSS: bulk-count, bulk-coll-row/select, tag-add-btn:disabled
This commit is contained in:
TheGeneralist 2026-07-14 15:37:23 +02:00
parent 4d14ed3a32
commit c296634956
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
6 changed files with 213 additions and 55 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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 name="viewport" content="width=device-width, initial-scale=1" />
<title>Archivr</title>
<script type="module" crossorigin src="/assets/index-BHmYoVqT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-p2mzAHzf.css">
<script type="module" crossorigin src="/assets/index-B7dq_VAM.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-nbPbdgtG.css">
</head>
<body>
<div id="root"></div>

View file

@ -1,5 +1,5 @@
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'
const VIS_LABEL = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }
@ -11,7 +11,7 @@ const ExternalIcon = () => (
</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 [assignInput, setAssignInput] = useState('')
const [entryCollections, setEntryCollections] = useState([])
@ -24,6 +24,17 @@ export default function ContextRail({ archiveId, selectedEntry, detail, onTagFil
const [rearchiveError, setRearchiveError] = useState('')
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(() => {
const seq = ++selectSeqRef.current
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() {
const newTitle = titleDraft.trim() || null
try {
@ -174,10 +259,83 @@ export default function ContextRail({ archiveId, selectedEntry, detail, onTagFil
<aside className="context-rail">
<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>
) : !detail ? (
<p className="tags-empty">Loading</p>
<p className="tags-empty">Loading\u2026</p>
) : (
<>
{editingTitle ? (
@ -268,7 +426,7 @@ export default function ContextRail({ archiveId, selectedEntry, detail, onTagFil
</>
)}
{selectedEntry && (
{selectedEntry && !isBulk && (
<>
<div className="rail-section">
<div className="rail-section-heading">Tags</div>