diff --git a/AGENTS.md b/AGENTS.md index a639a0d..10c6003 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,7 +123,7 @@ not for the public pages. See `docs/design-principles.md` for the design. - `seed.py` — demo content written on startup for paths missing from the database (never overwrites existing pages). - `frontend/src/` — the Vue editor and public-page entries. - - `main.js` — Vue editor app entry, mounts PageEditor/SiteEditor. + - `main.js` — Vue editor app entry, mounts the tabbed EditorShell. - `pagerite.js` — public page entry; runs fetch-navigation, scroll-reveal, OverlayScrollbars on `document.body` (floating, auto-hiding scrollbars that never reserve layout space or shift the page when appearing; @@ -136,10 +136,11 @@ not for the public pages. See `docs/design-principles.md` for the design. learn the current session's admin status. The same reverse proxy that gates `/_api` returns 401 for anonymous users, 403 for users without the admin permission, and 200 for admins. When Paskia is detected, a - 🔑 login button (anonymous) or 👤 profile button (logged in) is shown in + 🔑 login button (anonymous) or 🔐 profile button (logged in) is shown in the banner corner; both open Paskia's iframe dialog via `showAuthIframe` - instead of navigating away. Admins also get the 🖊️ edit pens (asset URLs - from the `pagerite:editor-src`/`-css` meta tags). If no Paskia SSO is + instead of navigating away. Admins also get the 🖊️ page/banner edit pens + and a ⚙️ site-settings pen (asset URLs from the + `pagerite:editor-src`/`-css` meta tags). If no Paskia SSO is detected (dev/no proxy), editing is left open. Pages themselves render identically for everyone; the real gate is the auth proxy in front of all of `/_api`. The backend links the stylesheets in a fixed order — @@ -174,21 +175,42 @@ not for the public pages. See `docs/design-principles.md` for the design. overrides); gitignored. Do not delete it without asking. - `scripts/fastapi-vue/` — helper scripts from the fastapi-vue template (build hook etc.), do not edit. -- `frontend/` — the Vue editor as **two separate apps** mounted in their - own host divs created inside the static document: `PageEditor.vue` +- `frontend/` — the Vue editor as a single tabbed `EditorShell.vue` mounted + in a host div created inside the static document. The shell hosts four + kept-alive tabs (ordered site-wide first — site, structure — then, after a + visual break, the per-page tabs — article, banner): `PageEditor.vue` (CodeMirror + server-rendered preview over WebSocket `/_api/ws/editor`, - previewing into the visible article; editor scroll drives document - scroll) opened by the article pen — it edits content and title only, - never the path — and `SiteEditor.vue` (site brand + optional custom - brand HTML (with image/video upload, replaces the brand link) + theme - selector + - favicon upload/remove + site-wide custom CSS + per-page banner design - selector (inherit/none/named design, inherited by children) + banner - HTML edited in small CodeMirror windows; - banner previewed into `#page-banner`, CSS injected into - `
`) + vue-draggable structure tree with - always-editable title/slug inputs per row, opened by the banner pen — - everything saves immediately as you edit (brand/title/CSS debounced, + previewing into the visible article; editor scroll drives the article + scroll — while any editor is open the window scroll is locked + (`body.editing`), the panel exactly fills the available window height, and + only `#main` scrolls; a format bar offers Markdown helpers — bold/italic/code/link/ + table/image upload, with Ctrl/Cmd-B/I/S bindings — for the + hard-to-remember syntax) — edits content and + title only, never the path — `BannerEditor.vue` + (per-page banner HTML + banner design selector, previewed into + `#page-banner`), `SiteEditor.vue` (site brand + optional custom brand + HTML with image/video upload + theme selector + font picker + favicon + upload — clicking the preview tile picks a new one — + + site-wide custom CSS, CSS injected into + ``), and `StructureEditor.vue` (the + vue-draggable structure tree with + always-editable title/slug inputs per row). Media uploads everywhere use + 🖼️ icon buttons (pasting into the editor works too). The article, banner and + site-settings pens are shorthands that open the shell on the matching tab; + once open, clicking a pen switches tabs (and retargets the editors to the + current page) instead of closing/remounting. The ✕ in the tab bar closes + the shell (Escape too); tabs have no close buttons of their own. Closing + only HIDES the shell — the Vue app stays mounted, so page-editor state + (unsaved text included) survives until a real page reload; saving there is + explicit (💾/Ctrl+S) and refreshes the page regions in place. Admin panels + never reload the page. In-place + page re-rendering shared by the banner/site/structure tabs lives in + `swapdoc.js` (`runScripts`/`loadPlain`: fetch a page, swap the dynamic + regions, replaceState). Placeholder texts are reserved for showing the + actual default in effect when a field is left empty (e.g. the pending + row's slug derived from its title); labels and help are real elements or + tooltips, never placeholders. + Everything saves immediately as you edit (brand/title/CSS debounced, slug on commit since it renames the path), theme change swaps the stylesheet in place, tree rows navigate in place without transitions when focused, and the front page is a root-only row whose empty slug is @@ -200,19 +222,16 @@ not for the public pages. See `docs/design-principles.md` for the design. target. Committing a pending page PUTs it with empty markdown (creates an empty page that renders with its title — saving never deletes; deletion is the page editor's explicit choice: saving trimmed-empty - text issues a REST DELETE), then hands over to the page editor - (CodeMirror focuses on mount). Dropping ON the lower part of a row - moves the page under that - row (the child list's container invisibly overlaps its own row's bottom - via negative margin — Sortable inserts it as the first child natively), - while a row's exposed top edge inserts a sibling before it. Row + text issues a REST DELETE), then switches to the page editor tab for + the actual writing. Dropping ON the lower part of a row moves the page + under that row (the child list's container invisibly overlaps its own + row's bottom via negative margin — Sortable inserts it as the first child + natively), while a row's exposed top edge inserts a sibling before it. Row indentation is structural (each nested list margin-indents itself), so a dragged row previews its whole subtree at the target list's depth. The - two pens swap the docked - panel for the other editor; clicking the open editor's own pen closes it. Normally dynamic-imported onto the content page by - pagerite.js when a 🖊️ edit pen is clicked (the pens are injected by - pagerite.js after the session validates; they carry - `data-editor-src`/`data-editor-css`/`data-editor-mode`). + shell is dynamic-imported onto the content page by pagerite.js when an edit + pen is clicked (the pens are injected by pagerite.js after the session + validates; they carry `data-editor-src`/`data-editor-css`/`data-editor-mode`). In dev, modules load from the Vite dev server (`PAGERITE_VITE_URL`), in prod from the hashed build assets resolved via `frontend-build/.vite/manifest.json`. `vite.config.js` sets diff --git a/frontend/src/BannerEditor.vue b/frontend/src/BannerEditor.vue new file mode 100644 index 0000000..c4029ae --- /dev/null +++ b/frontend/src/BannerEditor.vue @@ -0,0 +1,415 @@ + + + + + + + diff --git a/frontend/src/EditorShell.vue b/frontend/src/EditorShell.vue new file mode 100644 index 0000000..7f24b76 --- /dev/null +++ b/frontend/src/EditorShell.vue @@ -0,0 +1,176 @@ + + + + + + + diff --git a/frontend/src/PageEditor.vue b/frontend/src/PageEditor.vue index ed4d960..991bac3 100644 --- a/frontend/src/PageEditor.vue +++ b/frontend/src/PageEditor.vue @@ -4,18 +4,23 @@ // (/_api/ws/editor). Docked left of the article on the page itself. // The socket connects when the editor is opened and reconnects with // exponential backoff after a failure; unsaved text and pending saves -// survive a disconnect. Editor scroll drives the document scroll, keeping the -// rendered article at the cursor's position. -import { onMounted, onUnmounted, ref, watch } from 'vue' +// survive a disconnect. Editor scroll drives the article scroll (while +// editing the window scroll is locked and only #main scrolls), keeping the +// rendered article at the cursor's position. Saving (💾 / Ctrl+S) is explicit +// and refreshes the page regions in place — never a reload — so the editor +// state (unsaved text included) also survives closing the shell; it is lost +// only on a real page reload. +import { onActivated, onMounted, onUnmounted, ref, watch } from 'vue' import { EditorView, basicSetup } from 'codemirror' import { EditorState } from '@codemirror/state' import { markdown } from '@codemirror/lang-markdown' import { cmHighlight, cmTheme } from './cmtheme' +import { loadPlain } from './swapdoc' const props = defineProps({ pagePath: { type: String, default: '' }, }) -const emit = defineEmits(['close']) +const emit = defineEmits(['close', 'pathChange']) const path = ref('') const title = ref('') @@ -32,7 +37,7 @@ let reconnectTimer = null let reconnectDelay = 2000 const MAX_RECONNECT_DELAY = 16000 let everConnected = false -let dirty = false +const dirty = ref(false) // unsaved text exists (drives the 💾 button) let syncingScroll = false function send(msg) { @@ -65,16 +70,24 @@ function updateWindowTitle() { } watch([title, path], updateWindowTitle) +watch(() => props.pagePath, (p) => { openPath(normPath(p)) }) + +onActivated(() => { + updateWindowTitle() + // Re-shown with unsaved text: restore the working preview into the + // article (closing discarded it in favour of the server-rendered page). + if (dirty.value) requestRender() +}) function requestRender() { // No debounce: server-side rendering is fast enough per keystroke. if (!view) return - dirty = true + dirty.value = true send({ type: 'render', path: path.value, markdown: view.state.doc.toString() }) } function save() { - // Path is not editable here (that's the site editor's job); saving + // Path is not editable here (that's the structure tab's job); saving // never moves the page. const markdown = view.state.doc.toString() if (markdown.trim() === '') { @@ -96,20 +109,20 @@ function save() { return new Promise((resolve) => { savedResolve = resolve }) } -async function saveAndClose() { +async function saveAndRefresh() { await save() - // Reload so nav/sidebar changes apply, then the editor is gone. - dirty = false - emit('close') - location.reload() + dirty.value = false + // Refresh the page regions from the server so nav/sidebar changes apply + // (never a reload: the editor keeps its state). + loadPlain(path.value) } function close() { - // Reload if the visible page is showing unsaved preview edits. - const stale = dirty - dirty = false emit('close') - if (stale) location.reload() + // Discard the unsaved preview by re-rendering the page from the server. + // The editor text itself is kept (the shell stays mounted while hidden) + // and can still be saved later. + if (dirty.value) loadPlain(path.value) } function insertAtCursor(text) { @@ -128,6 +141,82 @@ async function uploadImage(file) { } } +// --- Format toolbar -------------------------------------------------------- +// Small Markdown helpers for the hard-to-remember syntax; each leaves the +// relevant part selected so typing replaces it. +function wrapInline(mark) { + // Toggle: wrapped selection (or wrapping marks around it) is unwrapped. + const { from, to } = view.state.selection.main + const doc = view.state.doc + if (doc.sliceString(Math.max(0, from - mark.length), from) === mark + && doc.sliceString(to, to + mark.length) === mark) { + view.dispatch({ changes: [ + { from: to, to: to + mark.length }, + { from: from - mark.length, to: from }, + ] }) + } else { + const text = doc.sliceString(from, to) + view.dispatch({ + changes: { from, to, insert: mark + text + mark }, + selection: { anchor: from + mark.length, head: to + mark.length }, + }) + } + view.focus() +} + +function insertCode() { + // On an empty line with no selection: a fenced code block, cursor inside. + // Otherwise an inline code wrap (toggling). + const { from, to } = view.state.selection.main + const line = view.state.doc.lineAt(from) + if (from === to && !line.text.trim()) { + view.dispatch({ + changes: { from: line.from, to: line.to, insert: '```\n\n```' }, + selection: { anchor: line.from + 4 }, + }) + view.focus() + return + } + wrapInline('`') +} + +function insertLink() { + // Selected text becomes the link label — or the URL if it looks like one. + const { from, to } = view.state.selection.main + const text = view.state.sliceDoc(from, to) + const isUrl = /^https?:\/\/\S+$/.test(text) + const insert = isUrl ? `[](${text})` : `[${text}]()` + const urlStart = from + insert.length - 1 // inside the parens + view.dispatch({ + changes: { from, to, insert }, + selection: isUrl ? { anchor: from + 1 } : { anchor: urlStart }, + }) + view.focus() +} + +// Table size picker: a hover grid popup (cols × rows) under the toolbar. +const tablePicker = ref(false) +const tableSize = ref({ cols: 0, rows: 0 }) +const TABLE_MAX_COLS = 8 +const TABLE_MAX_ROWS = 6 + +function insertTable(cols, rows) { + // A GFM table on its own blank-separated block, first header cell + // selected. + const { from, to } = view.state.selection.main + const before = from > 0 && view.state.doc.sliceString(from - 1, from) !== '\n' ? '\n\n' : '' + const row = (cells) => `| ${cells.join(' | ')} |` + const table = `${before}${row(Array(cols).fill('column'))}\n` + + `${row(Array(cols).fill('---'))}\n` + + `${Array(rows).fill(row(Array(cols).fill(''))).join('\n')}\n` + view.dispatch({ + changes: { from, to, insert: table }, + selection: { anchor: from + before.length + 2, head: from + before.length + 8 }, + }) + tablePicker.value = false + view.focus() +} + function openPath(p) { path.value = p send({ type: 'open', path: p }) @@ -177,12 +266,13 @@ function onMessage(ev) { published.value = msg.published setDocument(msg.markdown) requestRender() - dirty = false // just loaded from the server, nothing unsaved + dirty.value = false // just loaded from the server, nothing unsaved } else if (msg.type === 'html' && msg.path === path.value) { previewIntoArticle(msg.html, msg.has_h1) } else if (msg.type === 'saved') { saveError.value = '' pendingSave = null + dirty.value = false savedResolve?.() savedResolve = null } else if (msg.type === 'error') { @@ -191,24 +281,45 @@ function onMessage(ev) { } function onKeydown(ev) { - if ((ev.ctrlKey || ev.metaKey) && ev.key === 's') { + if (!(ev.ctrlKey || ev.metaKey)) return + if (ev.key === 's') { ev.preventDefault() save() + return } - if (ev.key === 'Escape') close() + // Bold/italic only when typing in the CodeMirror editor itself. + if (!view?.hasFocus) return + if (ev.key === 'b') { + ev.preventDefault() + wrapInline('**') + } else if (ev.key === 'i') { + ev.preventDefault() + wrapInline('*') + } +} + +// The shell stays mounted while hidden: when it is re-shown with this tab +// active, restore the window title (and the working preview if unsaved +// text exists — closing discarded it in favour of the server render). +function onEditorShown() { + if (document.body.dataset.editorMode !== 'page') return + updateWindowTitle() + if (dirty.value) requestRender() } function syncScroll() { - // Editor scroll drives the document: keep the rendered article at the - // same proportional position as the cursor area in the editor. + // Editor scroll drives the article: keep the rendered page at the same + // proportional position as the cursor area in the editor. While editing + // the window scroll is locked and #main is the scrolling element. if (syncingScroll || !view) return + const main = document.getElementById('main') + if (!main) return syncingScroll = true requestAnimationFrame(() => { const scroller = view.scrollDOM const max = scroller.scrollHeight - scroller.clientHeight const pct = max > 0 ? scroller.scrollTop / max : 0 - const doc = document.documentElement - window.scrollTo(0, pct * (doc.scrollHeight - innerHeight)) + main.scrollTop = pct * (main.scrollHeight - main.clientHeight) syncingScroll = false }) } @@ -277,6 +388,7 @@ onMounted(() => { path: () => path.value, } addEventListener('keydown', onKeydown) + addEventListener('pagerite:editor-shown', onEditorShown) }) onUnmounted(() => { @@ -288,13 +400,17 @@ onUnmounted(() => { view?.destroy() delete window.__pageritePageEditor removeEventListener('keydown', onKeydown) + removeEventListener('pagerite:editor-shown', onEditorShown) }) -