Unify admin editors into a tabbed EditorShell (site, structure, article, banner)

- One mounted shell with four kept-alive tabs; pens open/switch tabs,
  closing only hides the shell so unsaved page-editor state survives
  until a real reload; admin panels never reload the page, regions are
  refreshed in place via the shared swapdoc.js helper
- SiteEditor split: site settings vs. the new StructureEditor tree tab;
  BannerEditor split out of the old site editor
- Article editor format bar (bold/italic/code/link/table/image) with a
  table size picker and Ctrl/Cmd-B/I/S bindings; fenced code block on an
  empty line; icon-only save button disabled (saturate(0)) when clean
- Media uploads use icon buttons; favicon upload by clicking the preview
  tile; placeholders reserved for actual defaults in effect
- Banner/site pens and auth buttons grouped in an .editor-pens container;
  page pen no longer shown to anonymous visitors
- While editing, window scroll is locked, the panel exactly fills the
  available window height, and only #main scrolls; editor scroll drives
  the article
- Banner design inherit label names the design and its true source;
  backend banner_design_source excludes the node's own setting and the
  doc payload carries banner_design_inherited
This commit is contained in:
2026-08-19 00:14:05 +00:00
parent 43b841ebb6
commit 4dac16a9ac
13 changed files with 1673 additions and 1019 deletions
+48 -29
View File
@@ -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
`<head id="pagerite-user">`) + 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
`<head id="pagerite-user">`), 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
+415
View File
@@ -0,0 +1,415 @@
<script setup>
// Banner editor tab: per-page banner HTML and banner design, previewed into
// the real #page-banner region. Close and tab switching live in EditorShell.
import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { EditorView, basicSetup } from 'codemirror'
import { EditorState } from '@codemirror/state'
import { html } from '@codemirror/lang-html'
import { cmHighlight, cmTheme } from './cmtheme'
import { loadPlain, runScripts } from './swapdoc'
const props = defineProps({
pagePath: { type: String, default: '' },
})
// close/path-change are wired by EditorShell; this tab never emits them.
defineEmits(['close', 'pathChange'])
const path = ref('')
const banner = ref('')
const saveError = ref('')
const fileInput = ref(null)
const bannerEl = ref(null)
let ws = null
let pendingSave = null
let reconnectTimer = null
let reconnectDelay = 2000
const MAX_RECONNECT_DELAY = 16000
let everConnected = false
let view = null // CodeMirror for the banner HTML
let syncing = false // set while replacing the document programmatically
// Banner design selector options come from the backend via settings.
const theme = ref('')
const bannerDesign = ref(null)
const bannerDesignFrom = ref(null)
const bannerDesigns = ref([])
// Where an empty banner code field falls back to (null = nowhere: the
// banner is just the design artwork), shown as a caption under the field.
const bannerFrom = ref(null)
// The design that "inherit" resolves to and where it comes from
// (bannerDesignFrom: an ancestor path, "" = the front page, null = the
// active theme's default).
const bannerDesignInherited = ref('')
// One-shot callback run on the next save ack (set by onBannerDesignChange,
// whose re-render must not race the save it triggers).
let refreshOnSave = null
// The inherit option names the design actually in effect and its source.
const inheritLabel = computed(() => {
if (bannerDesignFrom.value === null) {
return `— (from ${theme.value || 'none'})`
}
const where = bannerDesignFrom.value === ''
? 'the front page'
: `/${bannerDesignFrom.value}`
return `— (${bannerDesignInherited.value || 'none'} from ${where})`
})
function normPath(p) {
return p.trim().replace(/^\/+|\/+$/g, '')
}
function send(msg) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg))
} else {
ensureConnected()
}
}
function ensureConnected() {
if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
connect()
}
}
const timers = {}
function debounce(key, fn, ms = 600) {
clearTimeout(timers[key])
timers[key] = setTimeout(fn, ms)
}
function save() {
const msg = { type: 'save', path: normPath(path.value), banner: banner.value }
pendingSave = msg
send(msg)
}
function openPath(p) {
path.value = p
send({ type: 'open', path: p })
}
watch(() => props.pagePath, (p) => { openPath(normPath(p)) })
function updateWindowTitle() {
document.title = `banner: /${path.value} 🖊️`
}
onActivated(() => {
updateWindowTitle()
if (banner.value.trim()) previewBanner()
})
// The shell stays mounted while hidden: when it is re-shown with this tab
// active, restore the window title and banner preview.
function onEditorShown() {
if (document.body.dataset.editorMode !== 'banner') return
updateWindowTitle()
if (banner.value.trim()) previewBanner()
}
async function loadSettings() {
try {
const s = await (await fetch('/_api/settings')).json()
theme.value = s.theme || ''
bannerDesigns.value = s.banner_designs || []
} catch { /* keep default */ }
}
// Re-render the page from the server (after banner design changes), then
// re-overlay the page's own banner code if one is being edited.
async function rerender() {
if (await loadPlain(path.value)) {
if (banner.value.trim()) previewBanner()
}
}
// --- Banner design ---------------------------------------------------------
function onBannerDesignChange() {
const msg = {
type: 'save',
path: normPath(path.value),
banner_design: bannerDesign.value,
}
pendingSave = msg
send(msg)
refreshOnSave = rerender
}
// --- Banner editing ------------------------------------------------------
function setDocument(text) {
syncing = true
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } })
syncing = false
banner.value = text
}
function previewBanner() {
const el = document.getElementById('page-banner')
if (!el) return
if (banner.value.trim()) {
// Own banner code supplements the design: the inlined design artwork
// (marked with [data-design]) is detached while the author code is
// swapped in (so runScripts never re-runs the design's own scripts),
// then put back first — author code stays last so its styles win.
const artwork = [...el.querySelectorAll('[data-design]')]
for (const a of artwork) a.remove()
el.innerHTML = banner.value
runScripts(el)
el.prepend(...artwork)
} else {
// No banner code of its own: the region must show the inherited/design
// banner — re-render from the server (an empty write here would wipe it).
loadPlain(path.value)
}
}
function onBannerInput() {
previewBanner()
debounce('banner-html', save, 400)
}
function stripBannerMedia(html) {
// A banner has one piece of media: uploading replaces earlier img/video
// tags instead of stacking them. (Other HTML, e.g. canvas+script, stays.)
const doc = new DOMParser().parseFromString(html, 'text/html')
for (const el of doc.querySelectorAll('img, video')) el.remove()
return doc.body.innerHTML.trim()
}
async function uploadBannerMedia(file) {
// Banner media goes to the shared content store, like article images.
if (!file || !/^(image|video)\//.test(file.type)) return
const name = file.name.replace(/[^\w.-]/g, '-')
const res = await fetch(`/_api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file })
if (!res.ok) return
const { path: stored } = await res.json()
const tag = file.type.startsWith('video/')
? `<video src="${stored}" autoplay muted loop playsinline></video>`
: `<img src="${stored}" alt="">`
const rest = stripBannerMedia(banner.value)
setDocument(rest ? `${tag}\n${rest}` : tag)
previewBanner()
save()
}
function onBannerPaste(ev) {
const file = [...(ev.clipboardData?.files || [])]
.find((f) => /^(image|video)\//.test(f.type))
if (file) {
ev.preventDefault()
uploadBannerMedia(file)
}
}
function onMessage(ev) {
const msg = JSON.parse(ev.data)
if (msg.type === 'doc' && msg.path === path.value) {
setDocument(msg.banner ?? '')
bannerDesign.value = msg.banner_design ?? null
bannerDesignFrom.value = msg.banner_design_from ?? null
bannerDesignInherited.value = msg.banner_design_inherited ?? ''
bannerFrom.value = msg.banner_from ?? null
if (banner.value.trim()) previewBanner()
} else if (msg.type === 'saved') {
saveError.value = ''
pendingSave = null
refreshOnSave?.()
refreshOnSave = null
} else if (msg.type === 'error') {
saveError.value = '⚠️ changes could not be saved'
}
}
function onKeydown(ev) {
if ((ev.ctrlKey || ev.metaKey) && ev.key === 's') {
ev.preventDefault()
save()
}
}
function connect() {
ws = new WebSocket(
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_api/ws/editor`,
)
ws.onmessage = onMessage
ws.onopen = () => {
reconnectDelay = 2000
if (everConnected) {
if (pendingSave) send(pendingSave)
} else {
openPath(normPath(props.pagePath))
}
everConnected = true
}
ws.onclose = () => {
clearTimeout(reconnectTimer)
reconnectTimer = setTimeout(() => {
connect()
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
}, reconnectDelay)
}
}
onMounted(async () => {
connect()
view = new EditorView({
state: EditorState.create({
doc: '',
extensions: [
basicSetup,
html(),
cmTheme,
cmHighlight,
EditorView.lineWrapping,
EditorView.updateListener.of((u) => {
if (u.docChanged && !syncing) {
banner.value = view.state.doc.toString()
onBannerInput()
}
}),
],
}),
parent: bannerEl.value,
})
addEventListener('keydown', onKeydown)
addEventListener('pagerite:editor-shown', onEditorShown)
await loadSettings()
})
onUnmounted(() => {
clearTimeout(reconnectTimer)
for (const t of Object.values(timers)) clearTimeout(t)
if (ws) {
ws.onclose = null // intentional close, no reconnect
ws.close()
}
view?.destroy()
removeEventListener('keydown', onKeydown)
removeEventListener('pagerite:editor-shown', onEditorShown)
})
</script>
<template>
<div class="banner-editor">
<div v-if="saveError">{{ saveError }}</div>
<section class="block" @paste="onBannerPaste">
<div class="block-head">
<select
v-model="bannerDesign"
class="text-input design-select"
title="Banner design (artwork + its own styles)"
@change="onBannerDesignChange"
>
<option :value="null">{{ inheritLabel }}</option>
<option value="">none</option>
<option v-for="d in bannerDesigns" :key="d" :value="d">{{ d }}</option>
</select>
<button
type="button"
class="icon-btn"
title="upload banner image/video (replaces existing media) — pasting works too"
@click="fileInput.click()"
>🖼</button>
<input
ref="fileInput"
type="file"
accept="image/*,video/*"
hidden
@change="(ev) => { uploadBannerMedia(ev.target.files[0]); ev.target.value = '' }"
/>
</div>
<div v-if="bannerFrom !== null" class="note">
left empty, the banner code is inherited from /{{ bannerFrom }}
</div>
<div ref="bannerEl" class="banner-cm" />
</section>
</div>
</template>
<style scoped>
.banner-editor {
display: flex;
flex-direction: column;
}
.block {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.5rem 1rem;
background: var(--surface);
flex: 1;
min-height: 0;
}
.block-head {
display: flex;
align-items: center;
gap: 0.6rem;
}
.note {
color: var(--muted);
font-size: 0.8rem;
}
.block-head .icon-btn {
margin-left: auto;
padding: 0 0.2rem;
font-size: 1rem;
background: none;
border: none;
cursor: pointer;
opacity: 0.7;
}
.block-head .icon-btn:hover {
opacity: 1;
}
/* The banner design selector stays compact; the upload button is pushed
right by its auto margin. */
.design-select {
flex: 0 1 auto;
width: auto;
font-size: 0.85rem;
}
.text-input {
flex: 1;
min-width: 4rem;
font: inherit;
font-size: 0.9rem;
padding: 0.2rem 0.5rem;
background: var(--bg);
color: var(--text);
border: 1px solid var(--line);
border-radius: 4px;
}
/* CodeMirror window for the banner HTML; fills the tab and scrolls
internally. */
.banner-cm {
flex: 1;
min-height: 0;
border: 1px solid var(--line);
border-radius: 4px;
overflow: hidden;
}
.banner-cm :deep(.cm-editor) {
height: 100%;
font-size: 0.85rem;
}
.banner-cm :deep(.cm-scroller) {
overflow: auto;
}
.banner-cm :deep(.cm-gutters) {
display: none;
}
</style>
+176
View File
@@ -0,0 +1,176 @@
<script setup>
// Tabbed shell for the four admin editors. The individual pens are shorthands
// that open the shell on a given tab; once open, tabs switch instantly without
// closing the panel. Tabs are kept alive so switching preserves state.
import { onMounted, onUnmounted, provide, ref, watch } from 'vue'
import PageEditor from './PageEditor.vue'
import BannerEditor from './BannerEditor.vue'
import SiteEditor from './SiteEditor.vue'
import StructureEditor from './StructureEditor.vue'
const props = defineProps({
pagePath: { type: String, default: '' },
initialMode: { type: String, default: 'page' },
})
const emit = defineEmits(['close'])
const currentPath = ref(props.pagePath)
const activeMode = ref(props.initialMode)
// Tab order: site-wide settings first (site, structure), then — after a
// visual break — the per-page editors (article, banner).
const MODES = [
{ key: 'site', label: 'site', component: SiteEditor },
{ key: 'structure', label: 'structure', component: StructureEditor },
{ key: 'page', label: 'article', component: PageEditor, breakBefore: true },
{ key: 'banner', label: 'banner', component: BannerEditor },
]
function switchMode(mode) {
if (MODES.some((m) => m.key === mode)) activeMode.value = mode
}
function onPathChange(path) {
currentPath.value = path
}
function close() {
emit('close')
}
provide('editorShell', { switchMode })
watch(activeMode, (mode) => {
document.body.dataset.editorMode = mode
}, { immediate: true })
// A pen click while the shell is open (main.js) switches tabs, and also
// retargets the editors when the user fetch-navigated with the shell open.
function onSwitchEvent(ev) {
if (ev.detail?.path != null) currentPath.value = ev.detail.path
if (ev.detail?.mode) switchMode(ev.detail.mode)
}
// Closing the shell hides it but keeps it mounted (main.js); the tabs stay
// cached in KeepAlive the whole time, so no state is ever lost until a real
// page reload. On re-show each active tab re-applies its window title and
// preview via its own pagerite:editor-shown listener.
function onKeydown(ev) {
if (ev.key === 'Escape' && document.body.classList.contains('editing')) close()
}
onMounted(() => {
document.body.dataset.editorMode = activeMode.value
addEventListener('pagerite:switch-editor', onSwitchEvent)
addEventListener('keydown', onKeydown)
})
onUnmounted(() => {
removeEventListener('pagerite:switch-editor', onSwitchEvent)
removeEventListener('keydown', onKeydown)
})
</script>
<template>
<div class="editor-root overlay">
<header class="editor-tabs">
<template v-for="m in MODES" :key="m.key">
<span v-if="m.breakBefore" class="tab-break" />
<button
type="button"
class="tab"
:class="{ active: activeMode === m.key }"
@click="switchMode(m.key)"
>
{{ m.label }}
</button>
</template>
<button type="button" class="close" title="close" @click="close"></button>
</header>
<div class="editor-tab-body">
<KeepAlive>
<component
:is="MODES.find((m) => m.key === activeMode).component"
:key="activeMode"
:page-path="currentPath"
@close="close"
@path-change="onPathChange"
/>
</KeepAlive>
</div>
</div>
</template>
<style scoped>
/* Docked-overlay positioning (sticky, height, slide-in) comes from the
global pagerite.css (.editor-root.overlay); the shell is a flex column so
the tab body fills what the tab bar leaves. */
.editor-root {
display: flex;
flex-direction: column;
}
.editor-tabs {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.4rem 1rem;
border-bottom: 1px solid var(--line);
background: var(--surface);
flex-shrink: 0;
}
.editor-tabs .tab {
padding: 0.25rem 0.8rem;
font: inherit;
font-size: 0.9rem;
color: var(--muted);
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
}
.editor-tabs .tab:hover {
color: var(--text);
}
/* Visual break between the site-wide tabs and the per-page tabs. */
.editor-tabs .tab-break {
align-self: stretch;
margin: 0.2rem 0.5rem;
border-left: 1px solid var(--line);
}
.editor-tabs .tab.active {
color: var(--text);
border-bottom-color: var(--accent);
}
.editor-tabs .close {
margin-left: auto;
padding: 0 0.3rem;
background: none;
border: none;
color: var(--muted);
font-size: 1.05rem;
cursor: pointer;
}
.editor-tabs .close:hover {
color: var(--text);
}
.editor-tab-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* The active tab component's root fills the body. */
.editor-tab-body > * {
flex: 1;
min-height: 0;
}
</style>
+271 -50
View File
@@ -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)
})
</script>
<template>
<div class="editor-root overlay">
<div class="page-editor">
<header class="toolbar">
<input v-model="title" placeholder="Title" class="title" @input="requestRender" />
<label class="title-field">
<span class="field-label">title</span>
<input v-model="title" class="title" @input="requestRender" />
</label>
<label><input v-model="published" type="checkbox" /> published</label>
<input
ref="fileInput"
@@ -303,10 +419,41 @@ onUnmounted(() => {
hidden
@change="(ev) => { uploadImage(ev.target.files[0]); ev.target.value = '' }"
/>
<button type="button" @click="fileInput.click()">image</button>
<button type="button" @click="saveAndClose">save</button>
<button type="button" class="close" title="close" @click="close"></button>
<button
type="button"
class="save"
title="save (Ctrl+S)"
:disabled="!dirty"
@click="saveAndRefresh"
>💾</button>
</header>
<div class="format-bar">
<button type="button" title="bold" @click="wrapInline('**')"><b>B</b></button>
<button type="button" title="italic" @click="wrapInline('*')"><i>I</i></button>
<button type="button" title="code (empty line: code block)" @click="insertCode"><code>&lt;/&gt;</code></button>
<button type="button" title="link" @click="insertLink">🔗</button>
<button
type="button"
title="table"
:class="{ active: tablePicker }"
@click="tablePicker = !tablePicker"
></button>
<button type="button" title="insert image (upload) — pasting works too" @click="fileInput.click()">🖼</button>
<div v-if="tablePicker" class="table-picker" @mouseleave="tableSize = { cols: 0, rows: 0 }">
<div class="tp-grid" :style="{ gridTemplateColumns: `repeat(${TABLE_MAX_COLS}, 1fr)` }">
<button
v-for="n in TABLE_MAX_COLS * TABLE_MAX_ROWS"
:key="n"
type="button"
class="tp-cell"
:class="{ on: tableSize.cols >= (n - 1) % TABLE_MAX_COLS + 1 && tableSize.rows >= Math.floor((n - 1) / TABLE_MAX_COLS) + 1 }"
@mouseenter="tableSize = { cols: (n - 1) % TABLE_MAX_COLS + 1, rows: Math.floor((n - 1) / TABLE_MAX_COLS) + 1 }"
@click="insertTable(tableSize.cols, tableSize.rows)"
/>
</div>
<div class="tp-size">{{ tableSize.cols || '' }} × {{ tableSize.rows || '' }}</div>
</div>
</div>
<div v-if="saveError">{{ saveError }}</div>
<div class="panes">
<div ref="editorEl" class="editor" />
@@ -315,15 +462,11 @@ onUnmounted(() => {
</template>
<style scoped>
.editor-root {
.page-editor {
display: flex;
flex-direction: column;
height: 100vh;
}
/* Docked-overlay positioning lives in the global pagerite.css (.editor-host /
.editor-root.overlay) since the host element is created by main.js. */
.toolbar {
display: flex;
align-items: center;
@@ -333,6 +476,19 @@ onUnmounted(() => {
background: var(--surface);
}
.toolbar .title-field {
flex: 1;
min-width: 4rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.toolbar .field-label {
color: var(--muted);
font-size: 0.85rem;
}
.toolbar .title {
flex: 1;
min-width: 4rem;
@@ -350,28 +506,93 @@ onUnmounted(() => {
white-space: nowrap;
}
.toolbar button {
font: inherit;
padding: 0.25rem 0.8rem;
background: var(--accent2);
color: white;
/* Borderless icon button; greyed out (desaturated) while there is nothing
to save. */
.toolbar button.save {
padding: 0 0.3rem;
font-size: 1rem;
background: none;
border: none;
color: var(--text);
cursor: pointer;
}
/* Disabled = black & white only; an explicit color overrides the browser's
built-in dimmed disabled-button color. */
.toolbar button.save:disabled {
color: var(--text);
filter: saturate(0);
cursor: default;
}
/* Markdown helpers: plain icon buttons under the toolbar. */
.format-bar {
position: relative;
display: flex;
align-items: center;
gap: 0.15rem;
padding: 0.25rem 1rem;
border-bottom: 1px solid var(--line);
background: var(--surface);
}
.format-bar button {
min-width: 1.7rem;
padding: 0.15rem 0.3rem;
font: inherit;
font-size: 0.85rem;
color: var(--muted);
background: none;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
/* Window-style close button, top right corner. */
.toolbar .close {
margin-left: auto;
padding: 0 0.3rem;
background: none;
color: var(--muted);
font-size: 1.05rem;
}
.toolbar .close:hover {
.format-bar button:hover,
.format-bar button.active {
color: var(--text);
border-color: var(--line);
}
/* Table size picker: hover grid popup below the format bar; the hovered
cell and everything up-left of it is the table to insert. */
.table-picker {
position: absolute;
top: 100%;
left: 6.5rem;
z-index: 20;
padding: 0.5rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 6px;
box-shadow: 0 4px 16px #0004;
}
.tp-grid {
display: grid;
gap: 2px;
}
.tp-cell {
width: 1.05rem;
height: 1.05rem;
min-width: 0;
padding: 0;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 2px;
}
.tp-cell.on {
background: var(--accent);
border-color: var(--accent);
}
.tp-size {
margin-top: 0.35rem;
color: var(--muted);
font-size: 0.8rem;
text-align: center;
}
.panes {
@@ -385,7 +606,7 @@ onUnmounted(() => {
/* CodeMirror sits inside a bordered box, like a dialog's input area, with
a slight margin to the panel edges. Wheel scroll stays in the editor and
drives the document (syncScroll) instead of double-scrolling. */
drives the article (syncScroll) instead of double-scrolling. */
.editor {
flex: 1;
min-width: 0;
File diff suppressed because it is too large Load Diff
+322
View File
@@ -0,0 +1,322 @@
<script setup>
// Structure tab: the draggable site structure tree. Focusing a page's row
// navigates to it in place (no transitions). Close and tab switching live
// in EditorShell.
//
// The tree comes from the server nested (GET /_api/pages); every node is
// real — a label with a title and slug, with content (landing page) or
// without (category whose URL renders a placeholder page). The front page
// is a top-level row with an empty slug, not the parent of the others.
import { inject, onActivated, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import StructureTree from './StructureTree.vue'
import { slugify } from './slugify'
import { loadPlain } from './swapdoc'
const props = defineProps({
pagePath: { type: String, default: '' },
})
const emit = defineEmits(['close', 'pathChange'])
const shell = inject('editorShell', null)
const path = ref('')
const saveError = ref('')
const tree = ref([])
function normPath(p) {
return p.trim().replace(/^\/+|\/+$/g, '')
}
// Debounce per key: text edits save while typing, without a request per
// keystroke.
const timers = {}
function debounce(key, fn, ms = 600) {
clearTimeout(timers[key])
timers[key] = setTimeout(fn, ms)
}
function updateWindowTitle() {
document.title = 'site structure 🖊️'
}
watch(() => props.pagePath, (p) => { path.value = normPath(p) })
onActivated(updateWindowTitle)
// The shell stays mounted while hidden: when it is re-shown with this tab
// active, restore the window title.
function onEditorShown() {
if (document.body.dataset.editorMode === 'structure') updateWindowTitle()
}
// Tree row focus: switch the edited page and show it, skipping transitions.
async function navigate(p) {
path.value = p
emit('pathChange', p)
await loadPlain(p)
}
// If the currently edited page moved (rename/move of itself or an
// ancestor), follow it to the new path.
function followMove(oldPath, newPath) {
if (path.value === oldPath) navigate(newPath)
else if (oldPath && path.value.startsWith(`${oldPath}/`)) {
navigate(newPath + path.value.slice(oldPath.length))
}
}
// --- New page flow -------------------------------------------------------
// The row at the end of any list adds a *pending* row there: a
// local-only item that can be dragged into place before anything is
// filled in. It is persisted only on commit (✓/Enter), at wherever it
// currently sits.
const pending = ref(null)
function newPage(list) {
if (pending.value) return // one at a time
pending.value = {
slug: '',
path: '',
title: '',
order: 0,
published: true,
has_content: true,
children: [],
pending: true,
}
list.push(pending.value)
}
// Where does the pending row currently sit? -> {parentPath, list, index}.
function locatePending(nodes, parentPath) {
const i = nodes.indexOf(pending.value)
if (i >= 0) return { parentPath, list: nodes, i }
for (const n of nodes) {
const found = locatePending(n.children, n.path)
if (found) return found
}
return null
}
function discardPending() {
const loc = locatePending(tree.value, '')
if (loc) loc.list.splice(loc.i, 1)
pending.value = null
}
async function commitPending() {
const node = pending.value
if (!node) return
// Empty slug: derive one from the title (transliterated to ASCII).
const slug = slugify(node.slug.trim()) || slugify(node.title)
if (!slug) {
return
}
const loc = locatePending(tree.value, '')
const parentPath = loc?.parentPath ?? ''
const newPath = parentPath ? `${parentPath}/${slug}` : slug
const res = await fetch(`/_api/pages/${newPath}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
title: node.title.trim() || slug,
markdown: '', // empty markdown creates an empty page (never deletes)
published: true,
}),
})
if (!res.ok) {
// Show the server's reason (e.g. a reserved file name); the pending
// row stays so it can be edited and committed again.
saveError.value = `⚠️ ${await errorDetail(res)}`
return
}
// Place it exactly where the row was dropped: a fresh order key halfway
// between its new siblings (the PUT appended it at the end).
if (loc) {
const prev = loc.list[loc.i - 1]
const next = loc.list[loc.i + 1]
const order = prev && next ? (prev.order + next.order) / 2
: prev ? prev.order + 1
: next ? next.order - 1
: 1
await postStructure({ path: newPath, order })
} else {
saveError.value = ''
}
pending.value = null
await refreshPages()
await navigate(newPath)
// Hand over to the page editor tab for the actual writing.
shell?.switchMode('page')
}
// --- Site structure tree (drag-and-drop ordering/moving) ----------------
async function refreshPages() {
try {
tree.value = await (await fetch('/_api/pages')).json()
} catch { /* list stays stale; not fatal */ }
}
// Human-readable reason from a failed API call (FastAPI errors carry a
// JSON {detail}), falling back to a generic message.
async function errorDetail(res) {
const body = await res.json().catch(() => null)
return body?.detail || 'changes could not be saved'
}
async function postStructure(op) {
const res = await fetch('/_api/structure', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(op),
})
if (res.ok) {
saveError.value = ''
loadPlain(path.value) // refresh menus and content from the server
} else {
saveError.value = `⚠️ ${await errorDetail(res)}`
}
await refreshPages()
return res.ok
}
async function onReorder(parentPath, list, evt) {
// vuedraggable already mutated `list`; persist the moved item only: a
// fresh order key halfway between its new siblings (all other items
// keep theirs), plus the new path when the parent changed. The pending
// new-page row is local-only — its position is read at commit time.
const change = evt.moved || evt.added
if (!change) return
const el = change.element
if (el.pending) return
const i = change.newIndex
let prev, next
for (let j = i - 1; j >= 0 && !prev; j--) if (!list[j].pending) prev = list[j]
for (let j = i + 1; j < list.length && !next; j++) if (!list[j].pending) next = list[j]
const order = prev && next ? (prev.order + next.order) / 2
: prev ? prev.order + 1
: next ? next.order - 1
: 1
const newPath = parentPath ? `${parentPath}/${el.slug}` : el.slug
const op = { path: el.path, order }
if (newPath !== el.path) op.move_to = newPath
if (await postStructure(op) && op.move_to) followMove(el.path, op.move_to)
}
// Inline title/slug editing: rows are always editable. Title saves while
// typing (debounced); the slug commits on blur/Enter, since it renames
// the path (moving the whole subtree with it).
function onTitleInput(node, ev) {
const title = ev.target.value.trim()
if (!title || title === node.title) return
debounce(`title:${node.path}`, async () => {
await postStructure({ path: node.path, title })
})
}
// The slug inputs are filtered as you type (StructureTree onSlugInput,
// see slugify.js); the server re-validates and its reason is shown.
async function commitSlug(node, ev) {
const slug = ev.target.value.trim()
if (slug === node.slug) return
const parent = node.path.split('/').slice(0, -1).join('/')
// Empty slug at top level = the front page (path "").
const moveTo = parent ? (slug ? `${parent}/${slug}` : parent) : slug
if (await postStructure({ path: node.path, move_to: moveTo })) {
followMove(node.path, moveTo)
} else {
ev.target.value = node.slug // rename failed: put the old slug back
}
}
// Two-step delete (no dialogs): the first click arms the row's button for
// a few seconds, the second actually deletes.
const arming = ref(null)
let armTimer = null
function armRemove(node) {
if (arming.value === node.path) {
clearTimeout(armTimer)
arming.value = null
removePage(node)
} else {
arming.value = node.path
clearTimeout(armTimer)
armTimer = setTimeout(() => { arming.value = null }, 3000)
}
}
async function removePage(node) {
const res = await fetch(`/_api/pages/${node.path}`, { method: 'DELETE' })
if (res.ok) {
saveError.value = ''
refreshPages()
const p = node.path
if (p === path.value || (p && path.value.startsWith(`${p}/`))) {
// The current page was deleted — or reduced to a category, which now
// renders a placeholder page. Either way, re-render from the server.
if (node.children.length) loadPlain(path.value)
else navigate('')
} else {
loadPlain(path.value) // refresh menus
}
} else {
saveError.value = '⚠️ changes could not be saved'
}
}
provide('structureHandlers', {
current: () => path.value,
open: navigate,
arming: () => arming.value,
armRemove,
reorder: onReorder,
titleInput: onTitleInput,
commitSlug,
commitPending,
discardPending,
newPage,
})
onMounted(() => {
path.value = normPath(props.pagePath)
refreshPages()
addEventListener('pagerite:editor-shown', onEditorShown)
})
onUnmounted(() => {
clearTimeout(armTimer)
for (const t of Object.values(timers)) clearTimeout(t)
removeEventListener('pagerite:editor-shown', onEditorShown)
})
</script>
<template>
<div class="structure-editor">
<div v-if="saveError">{{ saveError }}</div>
<section class="block structure">
<StructureTree :nodes="tree" />
</section>
</div>
</template>
<style scoped>
.structure-editor {
display: flex;
flex-direction: column;
}
.block {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--line);
background: var(--surface);
}
.structure {
flex: 1;
overflow-y: auto;
min-height: 0;
}
</style>
+4 -5
View File
@@ -1,6 +1,6 @@
<script setup>
// Recursive site-structure tree with drag-and-drop ordering (vue-draggable).
// Nodes come from the server (GET /_api/pages via SiteEditor.vue) as
// Nodes come from the server (GET /_api/pages via StructureEditor.vue) as
// {slug, path, title, order, published, has_content, children}.
// Every node is real: a label whose title and slug are always editable
// inline — the title saves while typing (and focusing it opens the page),
@@ -19,8 +19,8 @@
// The front page is the root row with an empty slug: renaming it away
// leaves no front page, and giving another top-level row the empty slug
// makes it the front page. Delete is a two-step inline button (no dialog).
// Actions are injected from SiteEditor.vue to avoid per-level event
// forwarding.
// Actions are injected from the parent editor (StructureEditor.vue) to
// avoid per-level event forwarding.
import { inject } from 'vue'
import draggable from 'vuedraggable'
import { slugify } from './slugify'
@@ -104,7 +104,7 @@ function onEnd() {
v-model="element.title"
v-focus
class="edit title-edit"
placeholder="Title"
title="Page title"
@keyup.enter="handlers.commitPending()"
@keyup.esc="handlers.discardPending()"
/>
@@ -126,7 +126,6 @@ function onEnd() {
<input
class="edit title-edit"
:value="element.title"
placeholder="Title"
title="Label in the navigation — saves while typing; click opens the page"
@input="handlers.titleInput(element, $event)"
@focus="handlers.open(element.path)"
+35 -23
View File
@@ -257,9 +257,28 @@ body.editing #sidebar {
display: none;
}
/* While editing, the window itself does not scroll: the editor panel is
exactly the remaining window height, and only the article area (#main)
scrolls. Without the editor, normal full-page scroll applies. */
body.editing {
height: 100vh;
overflow: hidden;
}
body.editing #content {
min-height: 0;
grid-template-rows: 100%;
}
body.editing #main {
height: 100%;
min-height: 0;
overflow-y: auto;
}
/* The editor host lives inside #content: it starts below the banner and
ends above the footer. The panel itself sticks to the viewport while
scrolling (but never taller than the content area). */
ends above the footer. With the page scroll locked while editing, the
panel exactly fills that space — the full available window height. */
.editor-host {
position: absolute;
top: 0;
@@ -269,10 +288,7 @@ body.editing #sidebar {
}
.editor-root.overlay {
position: sticky;
top: 0;
height: 100vh;
max-height: 100%;
height: 100%;
background: var(--bg);
animation: editor-slide-in 0.25s ease;
}
@@ -288,15 +304,20 @@ body.editing #sidebar {
}
}
/* The banner's own pen: opens the site editor (banner + structure).
Qualified with `button` to beat the later .edit-link rule's left offset
(both classes apply to the same element). */
button.banner-edit-link {
/* Banner-area pens (banner editor, site editor) and auth buttons live in a
single flex container pinned to the banner's top-right corner. */
.editor-pens {
position: absolute;
top: 0.6rem;
right: 1.25rem;
left: auto;
right: 0.5rem;
z-index: 10;
display: flex;
align-items: center;
gap: 0.6rem;
}
.editor-pens button {
position: static;
font: inherit;
border: none;
cursor: pointer;
@@ -452,19 +473,10 @@ article h1 .edit-link {
}
/* Login/profile buttons injected by pagerite.js when Paskia SSO is in use.
Both sit in the banner's top-right corner, to the right of the site pen,
and are styled like the edit pens. */
They live inside the .editor-pens flex container in the banner's top-right
corner and inherit its reset; keep only their opacity/text-shadow tweaks. */
button.login-link,
button.profile-link {
position: absolute;
top: 0.6rem;
right: 0.5rem;
z-index: 10;
font: inherit;
border: none;
cursor: pointer;
background: none;
padding: 0;
opacity: 0.7;
text-shadow: 0 0 0.1em black;
}
+48 -27
View File
@@ -1,9 +1,14 @@
// Pagerite editor entries. Two separate apps, mounted in their own
// dynamically created host divs inside the static document:
// - PageEditor ("page" mode): pen next to an article heading — Markdown
// editing with the preview rendered into the visible article.
// - SiteEditor ("site" mode): pen on the banner — banner HTML editing
// (previewed into the real banner) and the site structure tree.
// Pagerite editor entry. A single tabbed EditorShell is mounted in a
// dynamically created host div inside the static document. The shell hosts
// four tabs: PageEditor (Markdown + preview), BannerEditor (per-page banner
// HTML/design), SiteEditor (site-wide config), and StructureEditor (the
// site structure tree).
//
// Individual pens are shorthands that open the shell on a particular tab;
// once the shell is open, pens switch tabs instead of closing/remounting.
// Closing hides the shell but keeps the Vue app mounted, so editor state —
// including unsaved page text — survives and editing can continue on the
// next pen click; it is lost only on a real page reload.
if (import.meta.env.DEV) {
// Base styles only; the theme CSS is imported by pagerite.js (which
// always runs first — the editor opens from public pages).
@@ -11,26 +16,36 @@ if (import.meta.env.DEV) {
}
import { createApp } from 'vue'
import PageEditor from './PageEditor.vue'
import SiteEditor from './SiteEditor.vue'
import EditorShell from './EditorShell.vue'
let host = null
let app = null
let savedTitle = null
let visible = false
export function openEditor(path, { mode = 'page' } = {}) {
closeEditor()
if (app) {
// Shell already mounted: re-show it if hidden, switch to the requested
// tab and retarget the editors to the current page (the shell survives
// fetch-navigation).
if (!visible) showEditor()
document.body.dataset.editorMode = mode
dispatchEvent(new CustomEvent('pagerite:switch-editor', { detail: { mode, path } }))
return
}
savedTitle = document.title
host = document.createElement('div')
host.className = 'editor-host'
// Docked inside #content: below the banner, next to the article only.
document.getElementById('content').prepend(host)
document.body.classList.add('editing')
// Which kind of editor is open; pagerite.js uses this to decide
// whether a pen click closes the panel or swaps in the other editor.
// Which tab is active; pagerite.js uses this to decide whether a pen click
// closes the panel or switches tabs.
document.body.dataset.editorMode = mode
app = createApp(mode === 'site' ? SiteEditor : PageEditor, {
visible = true
app = createApp(EditorShell, {
pagePath: path,
initialMode: mode,
onClose: closeEditor,
})
app.mount(host)
@@ -45,33 +60,39 @@ export function openEditor(path, { mode = 'page' } = {}) {
})
}
function showEditor() {
savedTitle = document.title
host.style.display = ''
host.firstElementChild?.classList.remove('closing')
document.body.classList.add('editing')
visible = true
dispatchEvent(new CustomEvent('pagerite:editor-shown'))
}
export function closeEditor() {
if (!host) return
if (!visible) return
visible = false
document.body.classList.remove('editing')
delete document.body.dataset.editorMode
// Slide the panel out in sync with the page shifting back.
// dataset.editorMode is kept while hidden: the tabs use it to tell whether
// a pagerite:editor-shown event targets them.
// Slide the panel out in sync with the page shifting back, then hide it.
host.firstElementChild?.classList.add('closing')
const old = host
const oldApp = app
const restoreTitle = savedTitle
host = null
app = null
savedTitle = null
setTimeout(() => {
oldApp?.unmount()
old.remove()
if (host) return // a new editor has opened; its title is authoritative
const h = host
setTimeout(() => { h.style.display = 'none' }, 250)
dispatchEvent(new CustomEvent('pagerite:editor-hidden'))
// Restore the server-rendered title for the current URL. Re-fetching makes
// sure a brand change in the site editor or an in-place navigation leaves
// the correct public title behind.
const restoreTitle = savedTitle
savedTitle = null
fetch(location.pathname)
.then((r) => r.text())
.then((html) => {
if (visible) return // reopened meanwhile; the editor owns the title
const doc = new DOMParser().parseFromString(html, 'text/html')
if (doc.title) document.title = doc.title
})
.catch(() => {
if (restoreTitle != null) document.title = restoreTitle
if (!visible && restoreTitle != null) document.title = restoreTitle
})
}, 250)
}
+41 -35
View File
@@ -60,29 +60,33 @@ import "overlayscrollbars/overlayscrollbars.css";
function makePen(mode) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = mode === "page" ? "edit-link" : "edit-link banner-edit-link";
btn.title = "edit";
btn.textContent = "🖊️";
btn.dataset.editorSrc = editorMeta.src;
btn.dataset.editorCss = editorMeta.css || "";
btn.dataset.editorMode = mode;
if (mode === "page") {
btn.className = "edit-link";
btn.title = "edit page";
btn.textContent = "🖊️";
} else if (mode === "banner") {
btn.className = "edit-link";
btn.title = "edit banner";
btn.textContent = "🖊️";
} else {
btn.className = "edit-link site-edit-link";
btn.title = "site settings";
btn.textContent = "⚙️";
}
return btn;
}
function injectPens() {
const banner = document.getElementById("page-banner");
if (banner && !banner.parentElement.querySelector(".banner-edit-link")) {
banner.after(makePen("site"));
}
function injectPagePen() {
const article = document.querySelector("#main article");
if (article && !article.querySelector("button.edit-link")) {
article.prepend(makePen("page"));
}
}
function addLoginButton(url) {
const banner = document.getElementById("page-banner");
if (!banner || banner.parentElement.querySelector(".login-link")) return;
function makeLoginButton(url) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "login-link";
@@ -97,43 +101,45 @@ import "overlayscrollbars/overlayscrollbars.css";
// Cancelled or error: leave the button in place.
}
});
banner.after(btn);
return btn;
}
function injectProfileButton() {
const banner = document.getElementById("page-banner");
if (!banner || banner.parentElement.querySelector(".profile-link")) return;
function makeProfileButton() {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "profile-link";
btn.title = "profile";
btn.textContent = "👤";
btn.textContent = "🔐";
btn.addEventListener("click", () => {
showAuthIframe("/auth/").catch(() => {});
});
banner.after(btn);
return btn;
}
function renderAuthUi() {
// Editing is open for admins and, as a dev/no-proxy fallback, when no
// Paskia SSO is detected at all.
const canEdit = isAdmin || !ssoAvailable;
const banner = document.getElementById("page-banner");
if (banner) {
for (const el of banner.parentElement.querySelectorAll(
".banner-edit-link, .login-link, .profile-link",
)) {
el.remove();
const old = banner.parentElement.querySelector(".editor-pens");
if (old) old.remove();
const pens = document.createElement("div");
pens.className = "editor-pens";
if (canEdit) {
pens.append(makePen("banner"));
pens.append(makePen("site"));
}
if (isAdmin && ssoAvailable) {
pens.append(makeProfileButton());
} else if (!isAdmin && ssoAvailable && loginIframeUrl) {
pens.append(makeLoginButton(loginIframeUrl));
} else if (!isAdmin && ssoAvailable) {
pens.append(makeProfileButton());
}
if (isAdmin) {
injectPens();
if (ssoAvailable) injectProfileButton();
} else if (ssoAvailable && loginIframeUrl) {
addLoginButton(loginIframeUrl);
} else if (ssoAvailable) {
injectProfileButton();
} else {
// No Paskia SSO: dev/no-proxy fallback, leave editing open.
injectPens();
banner.after(pens);
}
if (canEdit) injectPagePen();
}
async function setupAuth() {
@@ -433,10 +439,10 @@ import "overlayscrollbars/overlayscrollbars.css";
addEventListener("click", (ev) => {
if (ev.defaultPrevented || ev.button !== 0
|| ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
// Edit buttons toggle the editor panel docked on this page: load the
// Vue app on demand (with any extra styles) and mount it in place.
// Clicking the pen of the already-open editor closes it; clicking the
// other pen swaps the panel for the other editor type.
// Edit buttons toggle the tabbed editor panel docked on this page: load
// the Vue app on demand (with any extra styles) and mount it in place.
// Clicking the pen of the already-open tab closes the shell; clicking
// another pen switches the shell to that tab.
const editBtn = ev.target.closest("button.edit-link");
if (editBtn && editBtn.dataset.editorSrc) {
ev.preventDefault();
+117
View File
@@ -0,0 +1,117 @@
// Shared in-place page re-rendering for the editor tabs: fetch a page
// without transitions and swap its dynamic regions into the live document.
// Used by BannerEditor (banner design changes), SiteEditor (theme changes)
// and StructureEditor (tree navigation).
export function runScripts(root) {
// Scripts injected via innerHTML do not execute; re-create them.
if (!root) return
for (const old of root.querySelectorAll('script')) {
const s = document.createElement('script')
for (const a of old.attributes) s.setAttribute(a.name, a.value)
s.textContent = old.textContent
old.replaceWith(s)
}
}
function swapRegions(doc) {
for (const id of ['page-banner', 'nav', 'main']) {
const fresh = doc.getElementById(id)
const el = document.getElementById(id)
if (fresh && el) el.replaceWith(document.importNode(fresh, true))
}
// #sidebar is omitted entirely when the section has no sub-navigation,
// so it may be absent on either side: replace, insert, or remove.
const freshSidebar = doc.getElementById('sidebar')
const curSidebar = document.getElementById('sidebar')
if (freshSidebar && curSidebar) {
curSidebar.replaceWith(document.importNode(freshSidebar, true))
} else if (freshSidebar) {
document.getElementById('main')?.before(document.importNode(freshSidebar, true))
} else if (curSidebar) {
curSidebar.remove()
}
// The brand lives in the header, outside the swappable regions, and is
// absent entirely when neither a brand nor custom brand HTML is set. A
// plain link keeps its element (text swap only, preserving the shrink-
// to-fit observers); anything else (custom HTML wrapper) is replaced.
const freshBrand = doc.getElementById('brand')
const curBrand = document.getElementById('brand')
if (freshBrand && curBrand && freshBrand.tagName === 'A' && curBrand.tagName === 'A') {
curBrand.textContent = freshBrand.textContent
} else if (freshBrand && curBrand) {
curBrand.replaceWith(document.importNode(freshBrand, true))
runScripts(document.getElementById('brand'))
} else if (curBrand) {
curBrand.remove()
} else if (freshBrand) {
document.getElementById('nav')?.before(document.importNode(freshBrand, true))
runScripts(document.getElementById('brand'))
}
// Site-wide custom CSS is in <head> and must be swapped too.
const freshUserStyle = doc.getElementById('pagerite-user')
const curUserStyle = document.getElementById('pagerite-user')
if (freshUserStyle && curUserStyle) {
curUserStyle.textContent = freshUserStyle.textContent
} else if (freshUserStyle) {
document.head.appendChild(document.importNode(freshUserStyle, true))
} else if (curUserStyle) {
curUserStyle.remove()
}
// Theme and other public stylesheets live in <head>, rendered with stable
// ids by the backend; sync them positionally so the custom CSS (rendered
// last) always keeps winning by order. Diff-based: unchanged sheets keep
// their elements, so their @keyframes are never torn down (re-creating
// keyframes would replay the editor's slide-in animation).
const freshLinks = [...doc.head.querySelectorAll('link[rel="stylesheet"]')]
const freshIds = new Set(freshLinks.map((l) => l.id))
for (const link of [...document.head.querySelectorAll('link[rel="stylesheet"]')]) {
if (!link.dataset.pagerite && !freshIds.has(link.id)) link.remove()
}
// Insert missing sheets in the fresh document's order, each right after
// its predecessor's element. The first sheet rendered is always the base
// CSS, so its link doubles as the fallback anchor when nothing matched yet
// (e.g. no theme was selected before and the position is otherwise lost).
let anchor = null
for (const link of freshLinks) {
const cur = link.id && document.getElementById(link.id)
if (cur && cur.href === link.href) {
anchor = cur
continue
}
const el = document.importNode(link, true)
// Same id, new URL (theme switch): replace in place, keeping position.
if (cur) cur.replaceWith(el)
else if (anchor) anchor.after(el)
else document.getElementById('pagerite-base')?.after(el) ?? document.head.append(el)
anchor = el
}
// The editor keeps its own title while open; only inherit the server title
// when navigating outside the editor (e.g. fetch-navigation swaps).
if (!document.body.classList.contains('editing')) {
document.title = doc.title
}
}
// Fetch /p, swap its regions into the live page and replaceState to it.
// Returns the final URL (after redirects), or null when the fetch did not
// yield a page. Category and missing URLs render a placeholder 404 page —
// fine to swap in (new pages are created by editing them).
export async function loadPlain(p) {
let doc
let finalUrl = `/${p}`
try {
const res = await fetch(finalUrl)
const type = res.headers.get('content-type') || ''
if (!type.includes('text/html')) return null
if (res.redirected) finalUrl = res.url
doc = new DOMParser().parseFromString(await res.text(), 'text/html')
} catch { return null }
if (!doc.getElementById('main')) return null
swapRegions(doc)
history.replaceState(null, '', finalUrl)
runScripts(document.getElementById('page-banner'))
runScripts(document.getElementById('main'))
dispatchEvent(new CustomEvent('pagerite:preview')) // re-inject + re-tuck the edit pens
return finalUrl
}
+11 -3
View File
@@ -557,10 +557,18 @@ async def editor_ws(ws: WebSocket) -> None:
# null = default artwork); the site editor shows it
# as the banner field's placeholder.
"banner_from": views.banner_source(data.menu, path),
# Which node's banner-design setting applies here
# (null = the active theme's default design).
"banner_design_from": views.banner_design_source(
# Which node's banner-design setting would apply on
# inherit ("" = front page, null = the active
# theme's default) and what design that resolves to.
"banner_design_from": (
src := views.banner_design_source(
data.menu, path, data.theme
)
),
"banner_design_inherited": (
views.banner_design(data.menu, src, data.theme)
if src is not None
else views.theme_banner_design(data.theme)
),
})
case "render":
+23 -16
View File
@@ -314,14 +314,25 @@ def _design_banner(design: str) -> HTML:
return HTML(f'<div data-design="{design}">{body}</div>')
def theme_banner_design(theme: str) -> str:
"""The theme's own banner design (a theme folder doubles as a banner
design when it ships banner.css, banner.svg or banner.html), "" if it
has none."""
if _valid_name(theme) and any(
(THEMES / theme / f).exists()
for f in ("banner.css", "banner.svg", "banner.html")
):
return theme
return ""
def banner_design(menu: dict[str, Node], path: str, theme: str = "") -> str:
"""The effective banner design name at ``path`` ("" = no design).
Nodes set ``banner_design`` to a design name, "" (explicitly none) or
None (inherit). Resolution walks the ancestor chain from the node
upwards, then the front page, then falls back to the active theme's own
design (a theme folder doubles as a banner design when it ships
banner.css or banner.svg).
design.
"""
chain = resolve(menu, path) or []
for node in reversed(chain):
@@ -330,30 +341,26 @@ def banner_design(menu: dict[str, Node], path: str, theme: str = "") -> str:
front = menu.get("")
if front and front.banner_design is not None:
return front.banner_design
if (
_valid_name(theme)
and any(
(THEMES / theme / f).exists()
for f in ("banner.css", "banner.svg", "banner.html")
)
):
return theme
return ""
return theme_banner_design(theme)
def banner_design_source(
menu: dict[str, Node], path: str, theme: str = ""
) -> str | None:
"""Which node's banner-design setting applies at ``path`` (like
banner_source), or None when the active theme's default applies.
Used by the site editor for the design selector's inherit label."""
"""Which node's banner-design setting *would* apply at ``path`` if the
node itself set nothing: the nearest ancestor's path ("" = the front
page), or None when the active theme's default applies. Used by the
banner editor for the design selector's inherit label."""
chain = resolve(menu, path) or []
segs = path.split("/")
for i in range(len(chain) - 1, -1, -1):
# Skip the node itself (chain[-1]): its own setting is not inheritance.
for i in range(len(chain) - 2, -1, -1):
if chain[i].banner_design is not None:
return "/".join(segs[: i + 1])
# The front page is a top-level sibling of the chain, not an ancestor;
# it does not inherit from itself.
front = menu.get("")
if front and front.banner_design is not None:
if path and front and front.banner_design is not None:
return ""
return None