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

View File

@@ -164,7 +164,8 @@ button.ghost:hover { color: var(--text); border-color: var(--accent-dim); }
.doc-head { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; } .doc-head { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.doc-head-left { display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0; flex-wrap: wrap; } .doc-head-left { display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0; flex-wrap: wrap; }
.doc-head .back-btn { margin-bottom: 0; } .doc-head .back-btn { margin-bottom: 0; }
.doc-head .exp-menu { margin-left: auto; } .doc-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; flex: none; flex-wrap: wrap; justify-content: flex-end; }
.ed-controls { display: inline-flex; align-items: center; gap: 8px; }
/* Breadcrumb: Space parent current — sized + themed to sit inline with the back button */ /* Breadcrumb: Space parent current — sized + themed to sit inline with the back button */
.crumbs { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; font-size: 13px; font-family: var(--font-ui); line-height: 1; } .crumbs { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; font-size: 13px; font-family: var(--font-ui); line-height: 1; }
@@ -179,6 +180,14 @@ button.ghost:hover { color: var(--text); border-color: var(--accent-dim); }
.exp-menu.open .exp-list { display: block; } .exp-menu.open .exp-list { display: block; }
.exp-list button { display: block; width: 100%; text-align: left; background: transparent; border: none; color: var(--text); padding: 7px 10px; border-radius: 3px; cursor: pointer; font-size: 12px; font-family: var(--font-ui); } .exp-list button { display: block; width: 100%; text-align: left; background: transparent; border: none; color: var(--text); padding: 7px 10px; border-radius: 3px; cursor: pointer; font-size: 12px; font-family: var(--font-ui); }
.exp-list button:hover { background: var(--accent-soft); color: var(--accent); } .exp-list button:hover { background: var(--accent-soft); color: var(--accent); }
.rev-list { max-height: 340px; overflow-y: auto; min-width: 220px; }
/* Revision viewer modal */
.rev-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.62); z-index: 100; display: flex; align-items: center; justify-content: center; padding: 24px; }
.rev-modal { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; width: min(820px, 94vw); max-height: 86vh; display: flex; flex-direction: column; box-shadow: 0 14px 50px rgba(0, 0, 0, 0.6); }
.rev-modal-head { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border); font-family: var(--font-display); color: var(--accent); letter-spacing: 0.04em; font-size: 13px; }
.rev-modal .md-preview { padding: 16px 20px; overflow-y: auto; flex: 1; }
.rev-modal-foot { padding: 12px 16px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; }
/* modal */ /* modal */
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; } .modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; }

View File

@@ -5,6 +5,7 @@ import { markdownEditor } from '../components/markdown_editor.js';
import { backButton } from '../components/backbtn.js'; import { backButton } from '../components/backbtn.js';
import { breadcrumb } from '../components/breadcrumb.js'; import { breadcrumb } from '../components/breadcrumb.js';
import { exportMenu } from '../components/export_menu.js'; import { exportMenu } from '../components/export_menu.js';
import { revisionsMenu } from '../components/revisions_menu.js';
export async function render(main, ctx) { export async function render(main, ctx) {
const id = ctx.params.id; const id = ctx.params.id;
@@ -33,11 +34,15 @@ export async function render(main, ctx) {
mount(main, mount(main,
el('div', { class: 'doc-head' }, el('div', { class: 'doc-head' },
el('div', { class: 'doc-head-left' }, backButton(), breadcrumb(page)), el('div', { class: 'doc-head-left' }, backButton(), breadcrumb(page)),
exportMenu({ filenameBase: page.slug, getContent: async () => ({ title: page.title, md: page.body_md || '' }) }) el('div', { class: 'doc-actions' },
revisionsMenu(id, (md) => editor.setValue(md)),
editor.controls,
exportMenu({ filenameBase: page.slug, getContent: async () => ({ title: page.title, md: editor.value() }) })
)
), ),
el('h1', { class: 'view-h1' }, page.title), el('h1', { class: 'view-h1' }, page.title),
el('p', { class: 'view-sub muted' }, '/' + page.slug), el('p', { class: 'view-sub muted' }, '/' + page.slug),
editor, editor.pane,
backlinksCard backlinksCard
); );