mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
- 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)
256 lines
9.4 KiB
JavaScript
256 lines
9.4 KiB
JavaScript
import { useState, useEffect, useRef } from 'react'
|
||
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' }
|
||
|
||
|
||
const ExternalIcon = () => (
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M7 17 17 7M9 7h8v8"/>
|
||
</svg>
|
||
)
|
||
|
||
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('')
|
||
const [entryCollections, setEntryCollections] = useState([])
|
||
const [assignError, setAssignError] = useState('')
|
||
const selectSeqRef = useRef(0)
|
||
const titleCancelRef = useRef(false)
|
||
const [editingTitle, setEditingTitle] = useState(false)
|
||
const [titleDraft, setTitleDraft] = useState('')
|
||
|
||
useEffect(() => {
|
||
if (!selectedEntry || !archiveId) {
|
||
setDetail(null)
|
||
setTags([])
|
||
setEntryCollections([])
|
||
return
|
||
}
|
||
setEditingTitle(false)
|
||
setTitleDraft('')
|
||
titleCancelRef.current = false
|
||
const seq = ++selectSeqRef.current
|
||
setDetail(null)
|
||
setTags([])
|
||
Promise.all([
|
||
fetchEntryDetail(archiveId, selectedEntry.entry_uid),
|
||
fetchEntryTags(archiveId, selectedEntry.entry_uid),
|
||
listEntryCollections(archiveId, selectedEntry.entry_uid),
|
||
]).then(([det, tgs, ecs]) => {
|
||
if (seq !== selectSeqRef.current) return
|
||
setDetail(det)
|
||
setTags(tgs)
|
||
setEntryCollections(ecs)
|
||
}).catch(() => {})
|
||
}, [selectedEntry, archiveId])
|
||
|
||
async function handleTitleSave() {
|
||
const newTitle = titleDraft.trim() || null
|
||
try {
|
||
await updateEntryTitle(archiveId, selectedEntry.entry_uid, newTitle)
|
||
setDetail(prev => prev ? { ...prev, summary: { ...prev.summary, title: newTitle } } : prev)
|
||
onEntryTitleChange?.(selectedEntry.entry_uid, newTitle)
|
||
} catch {
|
||
// silently revert
|
||
} finally {
|
||
setEditingTitle(false)
|
||
}
|
||
}
|
||
|
||
async function handleAssignTag() {
|
||
const path = assignInput.trim()
|
||
if (!path || !selectedEntry) return
|
||
try {
|
||
await assignTag(archiveId, selectedEntry.entry_uid, path)
|
||
setAssignInput('')
|
||
setAssignError('')
|
||
const updated = await fetchEntryTags(archiveId, selectedEntry.entry_uid)
|
||
setTags(updated)
|
||
onTagsRefresh()
|
||
} catch (e) {
|
||
setAssignError(e.message)
|
||
}
|
||
}
|
||
|
||
async function handleRemoveTag(tagUid) {
|
||
try {
|
||
await removeTag(archiveId, selectedEntry.entry_uid, tagUid)
|
||
const updated = await fetchEntryTags(archiveId, selectedEntry.entry_uid)
|
||
setTags(updated)
|
||
onTagsRefresh()
|
||
} catch {
|
||
// silently ignore
|
||
}
|
||
}
|
||
|
||
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],
|
||
['Type', detail.summary.entity_kind],
|
||
['Visibility', VIS_LABEL[detail.summary.visibility] ?? detail.summary.visibility],
|
||
['Root', detail.structured_root_relpath],
|
||
] : []
|
||
|
||
return (
|
||
<aside className="context-rail">
|
||
<div className="rail-eyebrow">Context</div>
|
||
|
||
{!selectedEntry ? (
|
||
<p className="tags-empty">Select an entry.</p>
|
||
) : !detail ? (
|
||
<p className="tags-empty">Loading…</p>
|
||
) : (
|
||
<>
|
||
{editingTitle ? (
|
||
<input
|
||
className="rail-title-input"
|
||
autoFocus
|
||
value={titleDraft}
|
||
onChange={e => setTitleDraft(e.target.value)}
|
||
onKeyDown={e => {
|
||
if (e.key === 'Enter') e.currentTarget.blur()
|
||
if (e.key === 'Escape') { titleCancelRef.current = true; e.currentTarget.blur() }
|
||
}}
|
||
onBlur={() => { if (titleCancelRef.current) { setEditingTitle(false) } else { handleTitleSave() } titleCancelRef.current = false }}
|
||
/>
|
||
) : (
|
||
<h2
|
||
className="rail-title rail-title--editable"
|
||
title="Click to rename"
|
||
onClick={() => {
|
||
setTitleDraft(detail.summary.title ?? '')
|
||
setEditingTitle(true)
|
||
}}
|
||
>
|
||
{valueText(detail.summary.title) || valueText(detail.summary.entry_uid)}
|
||
<svg className="edit-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||
<path d="M11.5 2.5a1.5 1.5 0 0 1 2 2L5 13l-3 1 1-3 8.5-8.5z"/>
|
||
</svg>
|
||
</h2>
|
||
)}
|
||
|
||
{detail.summary.original_url && (
|
||
<a
|
||
className="url-tile"
|
||
href={detail.summary.original_url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
<span className="ico" dangerouslySetInnerHTML={{ __html: sourceIconSvg(detail.summary.source_kind) }} />
|
||
<span className="u-text">{detail.summary.original_url}</span>
|
||
<span className="ext"><ExternalIcon /></span>
|
||
</a>
|
||
)}
|
||
|
||
<div className="meta-list">
|
||
{metaRows.filter(([, v]) => v != null && v !== '').map(([label, value]) => (
|
||
<div key={label} className="meta-item">
|
||
<span className="meta-k">{label}</span>
|
||
<span className={`meta-v${label === 'Root' ? ' mono' : ''}`}>{valueText(value)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{detail.artifacts.length > 0 && (
|
||
<div className="rail-section">
|
||
<div className="rail-section-heading">
|
||
Artifacts <span className="num">{detail.artifacts.length}</span>
|
||
</div>
|
||
<ul className="artifact-list">
|
||
{detail.artifacts.map((artifact, index) => (
|
||
<li key={index}>
|
||
<a
|
||
href={`/api/archives/${archiveId}/entries/${detail.summary.entry_uid}/artifacts/${index}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="artifact-link"
|
||
>
|
||
<span className="artifact-name">{artifact.artifact_role.replace(/_/g, ' ')}</span>
|
||
<span className="artifact-size">
|
||
{artifact.byte_size != null ? formatBytes(artifact.byte_size) : '—'}
|
||
</span>
|
||
</a>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{selectedEntry && (
|
||
<>
|
||
<div className="rail-section">
|
||
<div className="rail-section-heading">Tags</div>
|
||
{tags.length === 0 ? (
|
||
<p className="tags-empty">No tags yet.</p>
|
||
) : (
|
||
<div className="tags-wrap">
|
||
{tags.map(tag => (
|
||
<span key={tag.tag_uid} className="tag-pill" title={tag.full_path}>
|
||
{humanizeTags ? displayPath(tag.full_path) : tag.full_path}
|
||
<button
|
||
className="remove"
|
||
title={`Remove tag ${tag.full_path}`}
|
||
onClick={() => handleRemoveTag(tag.tag_uid)}
|
||
>×</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
{assignError && (
|
||
<p className="form-msg form-msg--err" style={{ margin: '0 0 8px' }}>{assignError}</p>
|
||
)}
|
||
<div className="tag-input-wrap">
|
||
<span className="hash">/</span>
|
||
<input
|
||
className="tag-input"
|
||
type="text"
|
||
placeholder="science/cs"
|
||
autoComplete="off"
|
||
value={assignInput}
|
||
onChange={e => setAssignInput(e.target.value)}
|
||
onKeyDown={e => { if (e.key === 'Enter') handleAssignTag() }}
|
||
/>
|
||
<button className="tag-add-btn" onClick={handleAssignTag}>Add</button>
|
||
</div>
|
||
</div>
|
||
|
||
{entryCollections.length > 0 && (
|
||
<div className="rail-section">
|
||
<div className="rail-section-heading">Collections</div>
|
||
{entryCollections.map(c => (
|
||
<div key={c.collection_uid} className="coll-row">
|
||
<span className="coll-name">{c.collection_uid}</span>
|
||
<span className="vis-badge">
|
||
{VIS_LABEL[c.visibility_bits] ?? `bits:${c.visibility_bits}`}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="rail-delete-zone">
|
||
<button className="rail-delete-btn" onClick={handleDeleteEntry}>
|
||
Delete entry
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</aside>
|
||
)
|
||
}
|