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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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></></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;
|
||||
|
||||
+125
-794
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user