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:
@@ -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
|
||||||
);
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
57
public/components/revisions_menu.js
Normal file
57
public/components/revisions_menu.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user