feat(ui): page header actions — Edit (accent) + Revisions menu (view/restore) + Export, top-right

Editor refactored to expose controls; Edit moved into the doc header as the orange
primary action; new Revisions dropdown lists page_revisions with a modal preview + Restore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-04 23:26:36 +10:00
parent 0a490e4e68
commit 6d01cb34a7
4 changed files with 92 additions and 43 deletions

View File

@@ -1,9 +1,6 @@
// Single-pane Markdown view with an Edit/Preview toggle.
// - Default: rendered HTML (marked → DOMPurify).
// - Click "Edit" to replace the pane in place with a raw-markdown textarea + Save.
// - Click "Preview" to re-render and go back to read mode.
// save(value) is caller-supplied and returns a promise resolving to the updated row.
// Single-pane Markdown view with an Edit/Done toggle. Returns the pane plus its
// controls (toggle + save) so the page can place them in the header. Edit is the
// accent (orange) action; Save appears only in edit mode.
import { marked } from '../vendor/marked.esm.js';
import DOMPurify from '../vendor/purify.es.mjs';
import { el } from '../dom.js';
@@ -15,63 +12,44 @@ export function markdownEditor({ initial = '', save }) {
const ta = el('textarea', {
style: {
width: '100%', minHeight: '420px', resize: 'vertical',
width: '100%', minHeight: '460px', resize: 'vertical',
fontFamily: 'var(--font-mono)', fontSize: '13px', lineHeight: '1.5'
}
});
ta.value = initial;
const preview = el('div', { class: 'md-preview' });
function rerender() {
preview.innerHTML = DOMPurify.sanitize(marked.parse(ta.value)); // sanitized
}
const rerender = () => { preview.innerHTML = DOMPurify.sanitize(marked.parse(ta.value)); };
rerender();
// The pane shows EITHER the preview (read) or the textarea (edit).
const pane = el('div', {}, preview);
const stamp = el('span', { class: 'muted', style: { fontSize: '11px' } }, '');
const saveBtn = el('button', {
class: 'primary',
style: { display: 'none' },
class: 'ghost', style: { display: 'none' },
onclick: async () => {
saveBtn.disabled = true;
try {
const updated = await save(ta.value);
stamp.textContent = updated?.updated_at
? 'Saved ' + new Date(updated.updated_at).toLocaleString()
: 'Saved';
} catch (e) {
stamp.textContent = 'Save failed: ' + e.message;
} finally {
saveBtn.disabled = false;
}
stamp.textContent = updated?.updated_at ? 'Saved ' + new Date(updated.updated_at).toLocaleTimeString() : 'Saved';
} catch (e) { stamp.textContent = 'Save failed: ' + e.message; }
finally { saveBtn.disabled = false; }
}
}, 'Save');
const toggle = el('button', {
class: 'ghost',
class: 'primary',
onclick: () => {
editing = !editing;
if (editing) {
pane.replaceChild(ta, preview);
ta.focus();
toggle.textContent = 'Preview';
saveBtn.style.display = '';
} else {
rerender();
pane.replaceChild(preview, ta);
toggle.textContent = 'Edit';
saveBtn.style.display = 'none';
}
if (editing) { pane.replaceChild(ta, preview); ta.focus(); toggle.textContent = 'Done'; saveBtn.style.display = ''; }
else { rerender(); pane.replaceChild(preview, ta); toggle.textContent = 'Edit'; saveBtn.style.display = 'none'; }
}
}, 'Edit');
return el('div', {},
el('div', { style: { display: 'flex', gap: '12px', alignItems: 'center', marginBottom: '8px' } },
toggle, saveBtn, stamp
),
el('div', { class: 'card' }, pane)
);
return {
pane,
controls: el('span', { class: 'ed-controls' }, toggle, saveBtn, stamp),
setValue: (md) => { ta.value = md; if (!editing) rerender(); },
value: () => ta.value
};
}

View File

@@ -0,0 +1,57 @@
// Revisions dropdown for a page: lists page_revisions; clicking one opens a modal
// with the rendered old content + a Restore action (PATCH body_md).
import { el } from '../dom.js';
import { api } from '../api.js';
import { marked } from '../vendor/marked.esm.js';
import DOMPurify from '../vendor/purify.es.mjs';
export function revisionsMenu(pageId, onRestore) {
const wrap = el('div', { class: 'exp-menu' });
const list = el('div', { class: 'exp-list rev-list' });
const btn = el('button', {
class: 'ghost',
onclick: async (e) => { e.stopPropagation(); if (wrap.classList.toggle('open')) await load(); }
}, 'Revisions ▾');
async function load() {
list.replaceChildren(el('div', { class: 'muted', style: { padding: '7px 10px' } }, 'Loading…'));
let revs = [];
try { revs = await api.get('/api/pages/' + pageId + '/revisions'); } catch { /* */ }
list.replaceChildren();
if (!revs.length) { list.appendChild(el('div', { class: 'muted', style: { padding: '7px 10px' } }, 'No earlier revisions.')); return; }
revs.forEach(r => list.appendChild(el('button', { onclick: () => view(r) },
new Date(r.created_at).toLocaleString() + (r.edited_by ? ` · ${r.edited_by}` : ''))));
}
function view(rev) {
wrap.classList.remove('open');
const body = el('div', { class: 'md-preview' });
body.innerHTML = DOMPurify.sanitize(marked.parse(rev.body_md || ''));
const modal = el('div', {
class: 'rev-overlay',
onclick: (e) => { if (e.target === modal) modal.remove(); }
},
el('div', { class: 'rev-modal' },
el('div', { class: 'rev-modal-head' },
el('span', {}, 'Revision · ' + new Date(rev.created_at).toLocaleString()),
el('button', { class: 'ghost', onclick: () => modal.remove() }, 'Close')),
body,
el('div', { class: 'rev-modal-foot' },
el('button', {
class: 'primary',
onclick: async () => {
try {
await api.patch('/api/pages/' + pageId, { body_md: rev.body_md });
if (onRestore) onRestore(rev.body_md);
modal.remove();
} catch (e) { alert('Restore failed: ' + e.message); }
}
}, 'Restore this version'))));
document.body.appendChild(modal);
}
wrap.append(btn, list);
document.addEventListener('click', () => wrap.classList.remove('open'));
return wrap;
}