- 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>
147 lines
5.2 KiB
JavaScript
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));
|
|
}
|
|
}
|