Files
Void-Homelab/public/views/reference.js
root 8ae9bced24 chore: version 2.0.0-alpha.2 + changelog
Search view: read ?q from hash, call /api/search, group hits by kind
with rank + space_id; sidebar filters for kinds and space_id; updates
on Enter or filter change.

Bumps package.json + server.js VERSION to 2.0.0-alpha.2 and pins the
/health version assertion to match.

CHANGELOG: full Plan 2 entry covering API surface, capability tiering,
audit chain extension (approve/reject events), and the SPA shell.

Security: adds safeHref() to dom.js and applies it everywhere an
API-supplied URL becomes href / src (reference media block + reference
source_url anchor + resource url anchor). javascript: and other
non-http(s)/mailto schemes from agent-suggested content can no longer
execute in the owner's browser.

Plan 2 surface is feature-complete: 22/22 tasks landed, 185 tests
across 43 files, SPA renders end-to-end including the suggest -> approve
agent flow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 02:26:56 +10:00

145 lines
5.2 KiB
JavaScript

// Reference detail: media block + summary + metadata + tag attach/detach + linked-from.
import { api } from '../api.js';
import { el, mount, clear, safeHref } from '../dom.js';
function mediaBlock(ref) {
if (ref.kind === 'image' && (ref.source_url || ref.blob_path)) {
const src = safeHref(ref.source_url || ref.blob_path);
if (src === '#') return el('span', { class: 'muted' }, '(image url rejected by scheme check)');
return el('img', { src, style: { maxWidth: '100%', borderRadius: '4px', border: '1px solid var(--border)' } });
}
if (ref.kind === 'video' && ref.source_url) {
const yt = ref.source_url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]{6,})/);
if (yt) {
return el('iframe', {
src: 'https://www.youtube-nocookie.com/embed/' + yt[1],
style: { width: '100%', aspectRatio: '16/9', border: 'none', borderRadius: '4px' },
allowfullscreen: true,
referrerpolicy: 'no-referrer'
});
}
return el('a', { href: safeHref(ref.source_url), target: '_blank', rel: 'noopener noreferrer' }, ref.source_url);
}
if (ref.source_url) {
return el('a', { href: safeHref(ref.source_url), target: '_blank', rel: 'noopener noreferrer' }, ref.source_url);
}
return el('span', { class: 'muted' }, '(no source)');
}
function metadataTable(ref) {
const rows = [
['kind', ref.kind],
['source_url', ref.source_url],
['captured_at', ref.captured_at],
['status', ref.status],
['source_kind', ref.source_kind],
['external_id', ref.external_id]
].filter(([, v]) => v);
return el('table', { style: { width: '100%', borderCollapse: 'collapse', fontSize: '12px' } },
el('tbody', {}, rows.map(([k, v]) =>
el('tr', {},
el('td', { class: 'muted', style: { padding: '4px 8px', verticalAlign: 'top', width: '120px' } }, k),
el('td', { style: { padding: '4px 8px', wordBreak: 'break-all' } }, String(v))
)
))
);
}
async function renderTagsCard(card, ref) {
const list = card.querySelector('.tags-list');
clear(list);
let tags = [];
try { tags = await api.get(`/api/ref/${ref.id}/tags`); }
catch { /* leave empty */ }
for (const t of tags) {
list.appendChild(el('span', {
class: 'status idle',
style: { marginRight: '6px', cursor: 'pointer' },
title: 'click to detach',
onclick: async () => {
try { await api.del(`/api/ref/${ref.id}/tags/${t.id}`); renderTagsCard(card, ref); }
catch (e) { alert('detach failed: ' + e.message); }
}
}, t.name));
}
}
async function tagAttachUI(ref, onChange) {
const input = el('input', { type: 'text', placeholder: 'Tag name', style: { flex: 1 } });
async function attach() {
const name = input.value.trim();
if (!name) return;
try {
const tag = await api.post('/api/tags', { name });
await api.post(`/api/ref/${ref.id}/tags`, { tag_id: tag.id });
input.value = '';
onChange();
} catch (e) { alert('attach failed: ' + e.message); }
}
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') attach(); });
return el('div', { style: { display: 'flex', gap: '8px', marginTop: '8px' } },
input, el('button', { class: 'primary', onclick: attach }, 'Attach')
);
}
export async function render(main, ctx) {
const id = ctx.params.id;
mount(main, el('p', { class: 'view-sub muted' }, 'Loading …'));
let ref;
try { ref = await api.get('/api/refs/' + id); }
catch (e) {
mount(main,
el('h1', { class: 'view-h1' }, 'Reference not found'),
el('p', { class: 'view-sub muted' }, e.message)
);
return;
}
const tagsCard = el('div', { class: 'card' },
el('h3', {}, 'Tags'),
el('div', { class: 'tags-list' })
);
tagsCard.appendChild(await tagAttachUI(ref, () => renderTagsCard(tagsCard, ref)));
const linkedFromCard = el('div', { class: 'card' },
el('h3', {}, 'Linked from'),
el('div', { id: 'ref-linked-from' }, el('span', { class: 'muted' }, 'Loading …'))
);
mount(main,
el('h1', { class: 'view-h1' }, ref.title || '(untitled reference)'),
el('p', { class: 'view-sub' },
el('span', { class: 'status idle' }, ref.kind),
ref.summary ? ' · ' + ref.summary.slice(0, 200) : ''
),
el('div', { class: 'card' }, el('h3', {}, 'Media'), mediaBlock(ref)),
ref.summary
? el('div', { class: 'card' }, el('h3', {}, 'Summary'), el('p', {}, ref.summary))
: null,
el('div', { class: 'card' }, el('h3', {}, 'Metadata'), metadataTable(ref)),
tagsCard,
linkedFromCard
);
renderTagsCard(tagsCard, ref);
try {
const links = await api.get('/api/links/to/ref/' + id);
const wrap = document.getElementById('ref-linked-from');
if (!links.length) mount(wrap, el('span', { class: 'muted' }, 'Not linked from anywhere yet.'));
else mount(wrap,
el('ul', { class: 'plain' }, links.map(l =>
el('li', {},
el('span', { class: 'status idle' }, l.from_type),
' ', l.from_id.slice(0, 8) + '…',
' ', el('span', { class: 'muted' }, 'rel: ' + l.relation)
)
))
);
} catch (e) {
const wrap = document.getElementById('ref-linked-from');
if (wrap) mount(wrap, el('span', { class: 'muted' }, 'Could not load: ' + e.message));
}
}