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>
57 lines
1.8 KiB
JavaScript
57 lines
1.8 KiB
JavaScript
// 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
|
|
)
|
|
);
|
|
}
|