mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
feat: implement entry deletion
- database: cascade_cached_bytes_after_subtree_delete — one-pass set-aware SQL that excludes the entire subtree simultaneously, avoiding sibling-blob cross-counting bug - database: delete_entry — collects subtree IDs, runs set-aware cascade, NULLs archive_run_items.produced_entry_id (FK blocker), deletes children then root; ON DELETE CASCADE handles artifacts/tags/collections - server: DELETE /api/archives/:archive_id/entries/:entry_uid route, wrapped in a transaction for atomicity - frontend: deleteEntry API call, handleEntryDeleted callback in App.jsx (optimistic list removal + selection clear), Delete entry button in ContextRail with confirm guard - tests: 4 database tests (unknown uid, subtree removal, run_item nulling, cached_bytes recalculation) + 3 route tests (204+gone, 404, 401)
This commit is contained in:
parent
cad3ae9885
commit
ed1f883ff1
15 changed files with 416 additions and 128 deletions
|
|
@ -212,6 +212,12 @@ export default function App() {
|
|||
)
|
||||
}, [])
|
||||
|
||||
const handleEntryDeleted = useCallback((entryUid) => {
|
||||
setEntries(prev => prev.filter(e => e.entry_uid !== entryUid))
|
||||
setSelectedEntry(prev => prev?.entry_uid === entryUid ? null : prev)
|
||||
setSelectedEntryUid(prev => prev === entryUid ? null : prev)
|
||||
}, [])
|
||||
|
||||
const handleCaptureClick = useCallback(() => {
|
||||
setCaptureDialogOpen(true)
|
||||
}, [])
|
||||
|
|
@ -317,6 +323,7 @@ export default function App() {
|
|||
tagNodes={tagNodes}
|
||||
onTagsRefresh={handleTagsRefresh}
|
||||
onEntryTitleChange={handleEntryTitleChange}
|
||||
onEntryDeleted={handleEntryDeleted}
|
||||
humanizeTags={humanizeTags}
|
||||
/>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -58,6 +58,14 @@ export async function removeTag(archiveId, entryUid, tagUid) {
|
|||
if (!resp.ok) throw new Error(`Remove failed (${resp.status})`);
|
||||
}
|
||||
|
||||
export async function deleteEntry(archiveId, entryUid) {
|
||||
const resp = await fetch(
|
||||
`/api/archives/${archiveId}/entries/${entryUid}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
if (!resp.ok) throw new Error(`Delete failed (${resp.status})`);
|
||||
}
|
||||
|
||||
export async function renameTag(archiveId, tagUid, name) {
|
||||
const res = await fetch(
|
||||
`/api/archives/${archiveId}/tags/${tagUid}`,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections, updateEntryTitle } from '../api'
|
||||
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections, updateEntryTitle, deleteEntry } 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, onTagFilterSet, tagNodes, onTagsRefresh, onEntryTitleChange, humanizeTags }) {
|
||||
export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh, onEntryTitleChange, onEntryDeleted, humanizeTags }) {
|
||||
const [detail, setDetail] = useState(null)
|
||||
const [tags, setTags] = useState([])
|
||||
const [assignInput, setAssignInput] = useState('')
|
||||
|
|
@ -86,6 +86,17 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
|
|||
}
|
||||
}
|
||||
|
||||
async function handleDeleteEntry() {
|
||||
if (!selectedEntry || !archiveId) return
|
||||
if (!window.confirm('Delete this entry? This cannot be undone.')) return
|
||||
try {
|
||||
await deleteEntry(archiveId, selectedEntry.entry_uid)
|
||||
onEntryDeleted?.(selectedEntry.entry_uid)
|
||||
} catch {
|
||||
// silently ignore — entry stays selected if delete failed
|
||||
}
|
||||
}
|
||||
|
||||
const metaRows = detail ? [
|
||||
['Added', formatTimestamp(detail.summary.archived_at)],
|
||||
['Source', detail.summary.source_kind],
|
||||
|
|
@ -232,6 +243,12 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rail-delete-zone">
|
||||
<button className="rail-delete-btn" onClick={handleDeleteEntry}>
|
||||
Delete entry
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -527,6 +527,31 @@ select {
|
|||
background: var(--accent-2);
|
||||
}
|
||||
|
||||
/* ── Rail delete zone ───────────────────────────────────────────────────── */
|
||||
.rail-delete-zone {
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
}
|
||||
.rail-delete-btn {
|
||||
width: 100%;
|
||||
padding: 8px 14px;
|
||||
background: none;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
color: var(--accent);
|
||||
border-radius: var(--r);
|
||||
cursor: pointer;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.rail-delete-btn:hover {
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Capture dialog ──────────────────────────────────────────────────────── */
|
||||
.capture-dialog {
|
||||
border: 1px solid var(--line);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue