Files
Void-Homelab/public/views/reference.js
root 16f2083253 feat(ui): blackflame theming pass — edit toggle, md tables, back button, Little Blue action cards
- markdown_editor Edit toggle uses themed ghost button
- .md-preview gets full blackflame styling incl. tables (migrated BookStack tables now render as tables)
- reusable back button on page/reference/project/resource reading views
- Little Blue actions regrouped into themed cards, pairing Start/Stop per guest

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 23:02:32 +10:00

147 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';
import { backButton } from '../components/backbtn.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,
backButton(),
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));
}
}