mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-22 11:15:41 +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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue