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

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
)
);
}