diff --git a/public/components/markdown_editor.js b/public/components/markdown_editor.js index b6c9377..c164757 100644 --- a/public/components/markdown_editor.js +++ b/public/components/markdown_editor.js @@ -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 + }; } diff --git a/public/components/revisions_menu.js b/public/components/revisions_menu.js new file mode 100644 index 0000000..64558e6 --- /dev/null +++ b/public/components/revisions_menu.js @@ -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; +} diff --git a/public/style.css b/public/style.css index 809fa41..136d14a 100644 --- a/public/style.css +++ b/public/style.css @@ -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-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 .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 */ .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-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); } +.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-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; } diff --git a/public/views/page.js b/public/views/page.js index 2f0eb77..f367cc8 100644 --- a/public/views/page.js +++ b/public/views/page.js @@ -5,6 +5,7 @@ import { markdownEditor } from '../components/markdown_editor.js'; import { backButton } from '../components/backbtn.js'; import { breadcrumb } from '../components/breadcrumb.js'; import { exportMenu } from '../components/export_menu.js'; +import { revisionsMenu } from '../components/revisions_menu.js'; export async function render(main, ctx) { const id = ctx.params.id; @@ -33,11 +34,15 @@ export async function render(main, ctx) { mount(main, el('div', { class: 'doc-head' }, 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('p', { class: 'view-sub muted' }, '/' + page.slug), - editor, + editor.pane, backlinksCard );