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:
30
package-lock.json
generated
30
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
56
public/components/markdown_editor.js
Normal file
56
public/components/markdown_editor.js
Normal 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
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
1747
public/vendor/purify.es.mjs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user