mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-22 03:05:32 +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:
parent
4d14ed3a32
commit
c296634956
6 changed files with 213 additions and 55 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,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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue