feat(ui): page editor + reference detail

Page view: header + split-pane markdown editor (textarea on left,
marked + DOMPurify rendered preview on right) + backlinks card pulling
/api/pages/:id/backlinks. Save calls PATCH /api/pages/:id with body_md
and surfaces the resulting updated_at as a timestamp.

Reference detail: media block (image preview / YouTube embed via
youtube-nocookie / link fallback), summary card, metadata table, tags
card with attach/detach (creates the tag idempotently then attaches),
linked-from card from /api/links/to/ref/:id.

marked + DOMPurify vendored to public/vendor as ESM. The markdown
editor uses the explicit html: opt-in on dom.js's preview element
only — all other text comes from textContent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-01 02:19:23 +10:00
parent ea5a99acff
commit ee582640ea
7 changed files with 2104 additions and 12 deletions

30
package-lock.json generated
View File

@@ -9,8 +9,10 @@
"version": "2.0.0-alpha.1",
"dependencies": {
"bcrypt": "^6.0.0",
"dompurify": "^3.4.7",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"marked": "^18.0.4",
"pg": "^8.21.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
@@ -509,6 +511,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz",
@@ -946,6 +955,15 @@
"wrappy": "1"
}
},
"node_modules/dompurify": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
"integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
@@ -1801,6 +1819,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/marked": {
"version": "18.0.4",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz",
"integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View File

@@ -11,8 +11,10 @@
},
"dependencies": {
"bcrypt": "^6.0.0",
"dompurify": "^3.4.7",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"marked": "^18.0.4",
"pg": "^8.21.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",

View File

@@ -0,0 +1,56 @@
// Split-pane Markdown editor.
// - Left: textarea (raw markdown)
// - Right: rendered HTML preview via marked → DOMPurify
// Save button calls a caller-supplied save(value) that returns a promise
// resolving to the updated row. We surface the last edited_at timestamp.
import { marked } from '../vendor/marked.esm.js';
import DOMPurify from '../vendor/purify.es.mjs';
import { el } from '../dom.js';
marked.setOptions({ gfm: true, breaks: true });
export function markdownEditor({ initial = '', save }) {
const ta = el('textarea', {
style: {
width: '100%', minHeight: '420px', resize: 'vertical',
fontFamily: 'var(--font-mono)', fontSize: '13px', lineHeight: '1.5'
}
});
ta.value = initial;
const preview = el('div', { class: 'md-preview' });
function rerender() {
const html = DOMPurify.sanitize(marked.parse(ta.value));
preview.innerHTML = html; // sanitized — see dom.js note about html: opt-in
}
ta.addEventListener('input', rerender);
rerender();
const stamp = el('span', { class: 'muted', style: { fontSize: '11px' } }, '');
const btn = el('button', {
class: 'primary',
onclick: async () => {
btn.disabled = true;
try {
const updated = await save(ta.value);
if (updated?.updated_at) stamp.textContent = 'Saved ' + new Date(updated.updated_at).toLocaleString();
else stamp.textContent = 'Saved';
} catch (e) {
stamp.textContent = 'Save failed: ' + e.message;
} finally {
btn.disabled = false;
}
}
}, 'Save');
return el('div', {},
el('div', { class: 'row' },
el('div', { class: 'card' }, el('h3', {}, 'Edit'), ta),
el('div', { class: 'card' }, el('h3', {}, 'Preview'), preview)
),
el('div', { style: { display: 'flex', gap: '12px', alignItems: 'center', marginTop: '8px' } },
btn, stamp
)
);
}

77
public/vendor/marked.esm.js vendored Normal file

File diff suppressed because one or more lines are too long

1747
public/vendor/purify.es.mjs vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,56 @@
// T17 stub — full implementation lands in T20.
// Page view: header + markdown editor + backlinks panel.
import { api } from '../api.js';
import { el, mount } from '../dom.js';
import { markdownEditor } from '../components/markdown_editor.js';
export async function render(main, ctx) {
mount(main,
el('h1', { class: 'view-h1' }, 'Page'),
el('p', { class: 'view-sub muted' }, 'id: ' + (ctx.params.id || '—')),
el('div', { class: 'card muted' }, 'Page editor ships in T20.')
const id = ctx.params.id;
mount(main, el('p', { class: 'view-sub muted' }, 'Loading …'));
let page;
try { page = await api.get('/api/pages/' + id); }
catch (e) {
mount(main,
el('h1', { class: 'view-h1' }, 'Page not found'),
el('p', { class: 'view-sub muted' }, e.message)
);
return;
}
const editor = markdownEditor({
initial: page.body_md || '',
save: (value) => api.patch('/api/pages/' + id, { body_md: value })
});
const backlinksCard = el('div', { class: 'card' },
el('h3', {}, 'Backlinks'),
el('div', { id: 'backlinks-list' }, el('span', { class: 'muted' }, 'Loading …'))
);
mount(main,
el('h1', { class: 'view-h1' }, page.title),
el('p', { class: 'view-sub muted' }, '/' + page.slug),
editor,
backlinksCard
);
try {
const links = await api.get('/api/pages/' + id + '/backlinks');
const list = document.getElementById('backlinks-list');
if (!links.length) mount(list, el('span', { class: 'muted' }, 'No backlinks yet.'));
else mount(list,
el('ul', { class: 'plain' }, links.map(l =>
el('li', {},
el('span', { class: 'status idle' }, l.from_type),
' ',
l.source_title || el('span', { class: 'muted' }, '(untitled)'),
' ',
el('span', { class: 'muted', style: { fontSize: '11px' } }, 'rel: ' + l.relation)
)
))
);
} catch (e) {
const list = document.getElementById('backlinks-list');
if (list) mount(list, el('span', { class: 'muted' }, 'Could not load: ' + e.message));
}
}

View File

@@ -1,9 +1,142 @@
// T17 stub — full implementation lands in T20.
import { el, mount } from '../dom.js';
export async function render(main, ctx) {
mount(main,
el('h1', { class: 'view-h1' }, 'Reference'),
el('p', { class: 'view-sub muted' }, 'id: ' + (ctx.params.id || '—')),
el('div', { class: 'card muted' }, 'Reference detail ships in T20.')
// Reference detail: media block + summary + metadata + tag attach/detach + linked-from.
import { api } from '../api.js';
import { el, mount, clear } from '../dom.js';
function mediaBlock(ref) {
if (ref.kind === 'image' && (ref.source_url || ref.blob_path)) {
return el('img', { src: ref.source_url || ref.blob_path, 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: ref.source_url, target: '_blank', rel: 'noopener noreferrer' }, ref.source_url);
}
if (ref.source_url) {
return el('a', { href: 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));
}
}