Pagerite: single-user CMS/blog

FastAPI backend rendering HTML with html5tagger, content persisted in a
kanta database and rendered per request. Vue only for the editing tools
(page editor over a WebSocket, site/structure editor); public pages are
plain HTML with fetch navigation. No auth: single trusted author.
This commit is contained in:
2026-08-16 05:49:38 +00:00
commit 36b250a15c
38 changed files with 9085 additions and 0 deletions
+423
View File
@@ -0,0 +1,423 @@
<script setup>
// Page editor: CodeMirror for Markdown, live server-rendered preview
// applied straight into the visible article, saving over one WebSocket
// (/_/api/ws/editor). Docked left of the article on the page itself
// (main.js openEditor) or standalone at /admin with its own preview pane.
// The socket reconnects automatically; unsaved text and pending saves
// survive a disconnect. Editor scroll drives the document scroll, keeping
// the rendered article at the cursor's position.
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import { EditorView, basicSetup } from 'codemirror'
import { EditorState } from '@codemirror/state'
import { markdown } from '@codemirror/lang-markdown'
import { cmHighlight, cmTheme } from './cmtheme'
const props = defineProps({
pagePath: { type: String, default: '' },
standalone: { type: Boolean, default: false },
})
const emit = defineEmits(['close'])
const path = ref('')
const title = ref('')
const published = ref(true)
const status = ref('connecting…')
const previewHtml = ref('')
const previewHasH1 = ref(false)
const editorEl = ref(null)
const previewEl = ref(null)
const fileInput = ref(null)
let ws = null
let view = null
let savedResolve = null
let pendingSave = null
let reconnectTimer = null
let everConnected = false
let dirty = false
let syncingScroll = false
function currentPath() {
return location.hash.replace(/^#\/?/, '').replace(/\/$/, '')
}
function send(msg) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg))
}
function normPath(p) {
return p.trim().replace(/^\/+|\/+$/g, '')
}
function requestRender() {
// No debounce: server-side rendering is fast enough per keystroke.
if (!view) return
dirty = 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
// never moves the page.
const msg = {
type: 'save',
path: path.value,
title: title.value,
markdown: view.state.doc.toString(),
published: published.value,
}
pendingSave = msg
status.value = ws && ws.readyState === WebSocket.OPEN
? 'saving…'
: 'offline — will save on reconnect'
send(msg)
return new Promise((resolve) => { savedResolve = resolve })
}
async function saveAndClose() {
await save()
// Reload so nav/sidebar changes apply, then the editor is gone.
if (props.standalone) location.href = `/${path.value}`
else { dirty = false; emit('close'); location.reload() }
}
function close() {
// Reload if the visible page is showing unsaved preview edits.
const stale = dirty
dirty = false
emit('close')
if (stale && !props.standalone) location.reload()
}
function insertAtCursor(text) {
view.dispatch(view.state.replaceSelection(text))
view.focus()
}
async function uploadImage(file) {
if (!file) return
const name = file.name.replace(/[^\w.-]/g, '-')
const res = await fetch(`/_/api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file })
if (res.ok) {
const { path: stored } = await res.json()
const alt = name.replace(/\.[^.]+$/, '')
insertAtCursor(`![${alt}](${stored})`)
status.value = `uploaded ${name}`
} else {
status.value = `upload failed (${res.status})`
}
}
function openPath(p) {
path.value = p
send({ type: 'open', path: p })
}
function setDocument(text) {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } })
}
function onHashChange() {
const p = currentPath()
if (p !== path.value) openPath(p)
}
function runScripts(root) {
// Scripts injected via innerHTML do not execute; re-create them.
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 previewIntoArticle(html, hasH1) {
// Docked mode previews into the article on the page itself;
// standalone mode has its own preview pane. When the markdown owns its
// h1, the title-derived h1 is hidden (matching server-side rendering).
previewHasH1.value = hasH1
if (props.standalone) {
previewHtml.value = html
nextTick(() => { if (previewEl.value) runScripts(previewEl.value) })
return
}
const article = document.querySelector('#main article')
if (!article) return
const h1 = article.querySelector('h1')
const body = article.querySelector('.body')
// The edit pen may be tucked inside an h1 (title or markdown-owned);
// detach it before textContent/innerHTML wipes destroy the element.
// pagerite.js re-places it into the first visible h1 on pagerite:preview.
const pen = article.querySelector('button.edit-link')
if (pen && (h1?.contains(pen) || body?.contains(pen))) article.prepend(pen)
if (h1) {
h1.style.display = hasH1 ? 'none' : ''
h1.textContent = title.value
}
if (body) {
body.innerHTML = html
runScripts(body)
dispatchEvent(new CustomEvent('pagerite:preview'))
}
}
function onMessage(ev) {
const msg = JSON.parse(ev.data)
if (msg.type === 'doc' && msg.path === path.value) {
title.value = msg.title
published.value = msg.published
setDocument(msg.markdown)
requestRender()
dirty = false // just loaded from the server, nothing unsaved
status.value = msg.exists ? '' : 'new page'
} else if (msg.type === 'html' && msg.path === path.value) {
previewIntoArticle(msg.html, msg.has_h1)
} else if (msg.type === 'saved') {
status.value = `saved ${new Date().toLocaleTimeString()}`
pendingSave = null
savedResolve?.()
savedResolve = null
} else if (msg.type === 'error') {
status.value = `error: ${msg.detail}`
}
}
function onKeydown(ev) {
if ((ev.ctrlKey || ev.metaKey) && ev.key === 's') {
ev.preventDefault()
save()
}
if (ev.key === 'Escape') close()
}
function syncScroll() {
// Editor scroll drives the document: keep the rendered article at the
// same proportional position as the cursor area in the editor.
if (syncingScroll || !view) return
syncingScroll = true
requestAnimationFrame(() => {
const scroller = view.scrollDOM
const max = scroller.scrollHeight - scroller.clientHeight
const pct = max > 0 ? scroller.scrollTop / max : 0
if (props.standalone) {
const pv = previewEl.value
if (pv) pv.scrollTop = pct * (pv.scrollHeight - pv.clientHeight)
} else {
const doc = document.documentElement
window.scrollTo(0, pct * (doc.scrollHeight - innerHeight))
}
syncingScroll = false
})
}
function connect() {
ws = new WebSocket(
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_/api/ws/editor`,
)
ws.onmessage = onMessage
ws.onopen = () => {
status.value = ''
if (everConnected) {
// Reconnected: local text is authoritative — don't re-open (that
// would clobber the editor), just resync preview and pending saves.
requestRender()
if (pendingSave) send(pendingSave)
} else {
openPath(props.standalone ? currentPath() : normPath(props.pagePath))
}
everConnected = true
}
ws.onclose = () => {
status.value = 'offline — reconnecting…'
clearTimeout(reconnectTimer)
reconnectTimer = setTimeout(connect, 1500)
}
}
onMounted(() => {
connect()
view = new EditorView({
state: EditorState.create({
doc: '',
extensions: [
basicSetup,
markdown(),
cmTheme,
cmHighlight,
EditorView.lineWrapping, // Markdown lines are long: soft-wrap them
EditorView.updateListener.of((u) => { if (u.docChanged) requestRender() }),
EditorView.domEventHandlers({
paste(ev) {
// Paste an image straight into the article: upload + insert
const file = [...(ev.clipboardData?.files || [])]
.find((f) => f.type.startsWith('image/'))
if (file) {
ev.preventDefault()
uploadImage(file)
}
},
}),
],
}),
parent: editorEl.value,
})
view.scrollDOM.addEventListener('scroll', syncScroll)
if (props.standalone) addEventListener('hashchange', onHashChange)
addEventListener('keydown', onKeydown)
})
onUnmounted(() => {
clearTimeout(reconnectTimer)
if (ws) {
ws.onclose = null // intentional close, no reconnect
ws.close()
}
view?.destroy()
if (props.standalone) removeEventListener('hashchange', onHashChange)
removeEventListener('keydown', onKeydown)
})
</script>
<template>
<div class="editor-root" :class="{ overlay: !standalone }">
<header class="toolbar">
<input v-model="title" placeholder="Title" class="title" @input="requestRender" />
<label><input v-model="published" type="checkbox" /> published</label>
<input
ref="fileInput"
type="file"
accept="image/*"
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>
<span class="status">{{ status }}</span>
<button v-if="!standalone" type="button" class="close" title="close" @click="close"></button>
</header>
<div class="panes">
<div ref="editorEl" class="editor" />
<div v-if="standalone" ref="previewEl" class="preview">
<article>
<h1 v-if="!previewHasH1">{{ title }}</h1>
<!-- server-rendered markdown preview -->
<div class="body" v-html="previewHtml" />
</article>
</div>
</div>
</div>
</template>
<style scoped>
.editor-root {
display: flex;
flex-direction: column;
height: 100vh;
}
/* Docked-overlay positioning lives in the global style.css (.editor-host /
.editor-root.overlay) since the host element is created by main.js. */
.toolbar {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--line);
background: var(--surface);
}
.toolbar .title {
flex: 1;
min-width: 4rem;
font: inherit;
padding: 0.2rem 0.5rem;
background: var(--bg);
color: var(--text);
border: 1px solid var(--line);
border-radius: 4px;
}
.toolbar label {
color: var(--muted);
font-size: 0.85rem;
white-space: nowrap;
}
.toolbar button {
font: inherit;
padding: 0.25rem 0.8rem;
background: var(--accent2);
color: white;
border: none;
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 {
color: var(--text);
}
.status {
color: var(--muted);
font-size: 0.85rem;
min-width: 5rem;
}
.panes {
flex: 1;
display: flex;
min-height: 0;
padding: 0.6rem 1rem;
gap: 1rem;
background: var(--surface); /* dialog body, same as the toolbar */
}
/* 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. */
.editor {
flex: 1;
min-width: 0;
overflow: hidden;
overscroll-behavior: contain;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--bg);
}
.editor :deep(.cm-editor) {
height: 100%;
}
/* No line numbers / gutter chrome. */
.editor :deep(.cm-gutters) {
display: none;
}
.preview {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 1rem 1.5rem;
border: 1px solid var(--line);
border-radius: 6px;
}
.preview article {
max-width: 44rem;
margin: 0 auto;
}
</style>
+767
View File
@@ -0,0 +1,767 @@
<script setup>
// Site editor: site-wide brand, banner HTML for the current page
// (previewed into the real #page-banner region, so you see exactly which
// banner you're editing) and the draggable site structure tree. Opened
// from the pen on the banner. Everything saves immediately as you edit —
// no save button, no edit mode. Focusing a page's row navigates to it in
// place (no transitions).
//
// 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 redirecting to its first child). The front page is a
// top-level row with an empty slug, not the parent of the others.
import { computed, onMounted, onUnmounted, provide, ref } from 'vue'
import StructureTree from './StructureTree.vue'
import { EditorView, basicSetup } from 'codemirror'
import { EditorState, Compartment } from '@codemirror/state'
import { placeholder } from '@codemirror/view'
import { html } from '@codemirror/lang-html'
import { cmHighlight, cmTheme } from './cmtheme'
const props = defineProps({
pagePath: { type: String, default: '' },
})
const emit = defineEmits(['close'])
const path = ref('')
const banner = ref('')
const status = ref('connecting…')
const tree = ref([])
const fileInput = ref(null)
const bannerEl = ref(null)
let ws = null
let pendingSave = null
let reconnectTimer = null
let everConnected = false
let view = null // CodeMirror for the banner HTML
let syncing = false // set while replacing the document programmatically
const bannerPh = new Compartment() // placeholder shows the inherited source
// path -> node, for quick lookups (current title, delete checks).
const flatMap = computed(() => {
const map = {}
const walk = (nodes) => {
for (const n of nodes) {
map[n.path] = n
walk(n.children)
}
}
walk(tree.value)
return map
})
function normPath(p) {
return p.trim().replace(/^\/+|\/+$/g, '')
}
function send(msg) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg))
}
// 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)
}
// Banner saves are fire-and-forget, with the pending save resent if the
// socket reconnects mid-edit.
function save() {
const msg = { type: 'save', path: normPath(path.value), banner: banner.value }
pendingSave = msg
if (ws && ws.readyState !== WebSocket.OPEN) {
status.value = 'offline — will save on reconnect'
}
send(msg)
}
function close() {
emit('close')
}
function openPath(p) {
path.value = p
send({ type: 'open', path: p })
}
// --- In-place navigation (no transitions, replaceState) ------------------
function swapRegions(doc) {
for (const id of ['page-banner', 'nav', 'sidebar', 'main']) {
const fresh = doc.getElementById(id)
const el = document.getElementById(id)
if (fresh && el) el.replaceWith(document.importNode(fresh, true))
}
// The brand link lives in the header, outside the swappable regions,
// and is absent entirely when no brand is configured.
const freshBrand = doc.getElementById('brand')
const curBrand = document.getElementById('brand')
if (freshBrand && curBrand) {
curBrand.textContent = freshBrand.textContent
} else if (curBrand) {
curBrand.remove()
} else if (freshBrand) {
document.getElementById('nav')?.before(document.importNode(freshBrand, true))
}
document.title = doc.title
}
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
// Category URLs redirect to their first child; reflect that. A 404
// layout is fine too (new pages are created by editing them).
if (res.redirected) finalUrl = res.url
doc = new DOMParser().parseFromString(await res.text(), 'text/html')
} catch { return }
if (!doc.getElementById('main')) return
swapRegions(doc)
history.replaceState(null, '', finalUrl)
runScripts(document.getElementById('page-banner'))
runScripts(document.getElementById('main'))
dispatchEvent(new CustomEvent('pagerite:preview')) // re-tuck the edit pen
// The swap brought in the server-rendered (inherited) banner; overlay
// the page's own banner if one is being edited.
if (banner.value.trim()) previewBanner()
}
// Tree row focus: switch the edited page and show it, skipping transitions.
function navigate(p) {
openPath(p)
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 in the pages header adds a *pending* row to the tree: 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() {
if (pending.value) return // one at a time
pending.value = {
slug: '',
path: '',
title: '',
order: 0,
published: true,
has_content: true,
children: [],
pending: true,
}
tree.value.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
const slug = node.slug.trim().replace(/\/+/g, '')
if (!slug) {
status.value = 'a slug is needed'
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: '',
published: true,
}),
})
if (!res.ok) {
status.value = `create failed (${res.status})`
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 })
}
pending.value = null
await refreshPages()
navigate(newPath)
}
// Give a content-less category a landing page (empty page at its path).
async function addContent(node) {
const res = await fetch(`/_/api/pages/${node.path}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: node.title, markdown: '', published: node.published }),
})
if (res.ok) {
await refreshPages()
navigate(node.path)
} else {
status.value = `failed (${res.status})`
}
}
// --- Site-wide brand (header link + <title> suffix) ----------------------
// Edits apply to the live page immediately and save while typing. An
// empty brand removes the header link and the title suffix entirely.
const brand = ref('')
async function loadSettings() {
try {
brand.value = (await (await fetch('/_/api/settings')).json()).brand
} catch { /* keep default */ }
}
function currentTitle() {
return flatMap.value[path.value]?.title
|| document.title.replace(/ [^]*$/, '')
}
function applyBrand(b) {
let el = document.getElementById('brand')
if (b) {
if (!el) {
el = document.createElement('a')
el.id = 'brand'
el.href = '/'
document.getElementById('nav')?.before(el)
}
el.textContent = b
document.title = `${currentTitle()} ${b}`
} else {
if (el) el.remove()
document.title = currentTitle()
}
}
function onBrandInput() {
applyBrand(brand.value)
debounce('brand', saveBrand)
}
async function saveBrand() {
const res = await fetch('/_/api/settings', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ brand: brand.value }),
})
if (!res.ok) status.value = `brand save failed (${res.status})`
}
// 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) {
refreshPages()
const p = node.path
if (p === path.value || (p && path.value.startsWith(`${p}/`))) {
// The current page was deleted — or reduced to a category that now
// redirects to its first child. Either way, re-render from the server.
if (node.children.length) loadPlain(path.value)
else { status.value = 'deleted'; navigate('') }
} else {
loadPlain(path.value) // refresh menus
}
} else {
status.value = `delete failed (${res.status})`
}
}
// --- 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 */ }
}
async function postStructure(op) {
const res = await fetch('/_/api/structure', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(op),
})
if (!res.ok) status.value = `structure change failed (${res.status})`
else loadPlain(path.value) // refresh menus and content from the server
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 })
})
}
async function commitSlug(node, ev) {
const slug = ev.target.value.trim().replace(/\/+/g, '')
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
}
}
provide('structureHandlers', {
current: () => path.value,
open: navigate,
arming: () => arming.value,
armRemove,
reorder: onReorder,
titleInput: onTitleInput,
commitSlug,
addContent,
commitPending,
discardPending,
})
// --- Banner editing ------------------------------------------------------
// The banner HTML is edited in a small CodeMirror window (HTML syntax),
// previewed into the real #page-banner region on every keystroke.
function setDocument(text) {
syncing = true
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } })
syncing = false
banner.value = text
}
function runScripts(root) {
// Scripts injected via innerHTML do not execute; re-create them.
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 previewBanner() {
const el = document.getElementById('page-banner')
if (!el) return
if (banner.value.trim()) {
// Own banner: preview it live over the region.
el.innerHTML = banner.value
runScripts(el)
} else {
// No banner of its own: the region must show the inherited/default
// 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) {
status.value = `upload failed (${res.status})`
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()
status.value = `uploaded ${name}`
}
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 ?? '')
// Placeholder tells where an empty banner falls back to.
view.dispatch({
effects: bannerPh.reconfigure(placeholder(
msg.banner_from == null
? 'using default artwork'
: `inherited from /${msg.banner_from}`,
)),
})
// Overlay this page's own banner on the swapped region. Empty means
// inherited: the server-rendered region already shows the right one.
if (banner.value.trim()) previewBanner()
status.value = msg.exists ? '' : 'new page'
} else if (msg.type === 'saved') {
status.value = `saved ${new Date().toLocaleTimeString()}`
pendingSave = null
refreshPages()
} else if (msg.type === 'error') {
status.value = `error: ${msg.detail}`
}
}
function onKeydown(ev) {
if ((ev.ctrlKey || ev.metaKey) && ev.key === 's') {
ev.preventDefault()
save()
}
if (ev.key === 'Escape') close()
}
function connect() {
ws = new WebSocket(
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_/api/ws/editor`,
)
ws.onmessage = onMessage
ws.onopen = () => {
status.value = ''
if (everConnected) {
// Reconnected: resend any save attempted while offline.
if (pendingSave) send(pendingSave)
} else {
openPath(normPath(props.pagePath))
}
everConnected = true
}
ws.onclose = () => {
status.value = 'offline — reconnecting…'
clearTimeout(reconnectTimer)
reconnectTimer = setTimeout(connect, 1500)
}
}
onMounted(() => {
refreshPages()
loadSettings()
connect()
view = new EditorView({
state: EditorState.create({
doc: '',
extensions: [
basicSetup,
html(),
cmTheme,
cmHighlight,
EditorView.lineWrapping,
bannerPh.of(placeholder('')),
EditorView.updateListener.of((u) => {
if (u.docChanged && !syncing) {
banner.value = view.state.doc.toString()
onBannerInput()
}
}),
],
}),
parent: bannerEl.value,
})
addEventListener('keydown', onKeydown)
})
onUnmounted(() => {
clearTimeout(reconnectTimer)
clearTimeout(armTimer)
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)
})
</script>
<template>
<div class="editor-root overlay">
<header class="toolbar">
<span class="mode-label">site editor</span>
<span class="status">{{ status }}</span>
<button type="button" class="close" title="close" @click="close"></button>
</header>
<section class="block">
<label class="field">
<span class="field-label">site</span>
<input
v-model="brand"
class="text-input"
placeholder="Site name (header link and window title)"
@input="onBrandInput"
/>
</label>
</section>
<section class="block" @paste="onBannerPaste">
<div class="block-head">
<span class="field-label">Banner on /{{ path }}</span>
<button
type="button"
title="upload banner image/video (replaces existing media) — pasting works too"
@click="fileInput.click()"
>add image/video</button>
<input
ref="fileInput"
type="file"
accept="image/*,video/*"
hidden
@change="(ev) => { uploadBannerMedia(ev.target.files[0]); ev.target.value = '' }"
/>
</div>
<div ref="bannerEl" class="banner-cm" />
</section>
<section class="block structure">
<StructureTree :nodes="tree" />
<button
type="button"
class="add"
title="new page — drag the new row into place, then fill in title and slug"
@click="newPage"
></button>
</section>
</div>
</template>
<style scoped>
.editor-root {
display: flex;
flex-direction: column;
height: 100vh;
}
/* Docked-overlay positioning lives in the global style.css (.editor-host /
.editor-root.overlay) since the host element is created by main.js. */
.toolbar {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--line);
background: var(--surface);
}
.mode-label {
color: var(--muted);
font-size: 0.85rem;
white-space: nowrap;
}
.status {
color: var(--muted);
font-size: 0.85rem;
}
/* Window-style close button, top right corner. */
.toolbar .close {
margin-left: auto;
padding: 0 0.3rem;
background: none;
border: none;
color: var(--muted);
font-size: 1.05rem;
cursor: pointer;
}
.toolbar .close:hover {
color: var(--text);
}
.block {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--line);
background: var(--surface);
}
.block-head {
display: flex;
align-items: center;
gap: 0.6rem;
}
.field {
display: flex;
align-items: center;
gap: 0.5rem;
}
.field-label {
color: var(--muted);
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.block-head button {
margin-left: auto;
font: inherit;
font-size: 0.85rem;
padding: 0.15rem 0.6rem;
background: var(--accent2);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
.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;
}
/* Small CodeMirror window for the banner HTML; scrolls internally. */
.banner-cm {
border: 1px solid var(--line);
border-radius: 4px;
overflow: hidden;
}
.banner-cm :deep(.cm-editor) {
max-height: 7rem;
font-size: 0.85rem;
}
.banner-cm :deep(.cm-scroller) {
overflow: auto;
}
/* No line numbers / gutter chrome. */
.banner-cm :deep(.cm-gutters) {
display: none;
}
.structure {
flex: 1;
overflow-y: auto;
min-height: 0;
}
/* Icon buttons () keep the emoji's own color, no button chrome. */
.structure .add {
align-self: flex-start; /* don't stretch to the block's full width */
margin-top: 0.3rem;
margin-left: 1.2em; /* align with the row titles, past the drag handle */
padding: 0.1rem 0.3rem;
background: none;
border: none;
font-size: 1rem;
cursor: pointer;
opacity: 0.7;
}
.structure .add:hover {
opacity: 1;
}
</style>
+251
View File
@@ -0,0 +1,251 @@
<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
// {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),
// the slug commits on blur/Enter since it renames the path, moving the
// whole subtree. Nodes without content are category labels that redirect
// to their first child; the on their row gives them a landing page.
// The in the panel header adds a *pending* row: a local-only item that
// can be dragged into place before its title/slug are filled in, and is
// persisted to the server only on commit (✓/Enter, Esc discards).
// 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.
import { inject } from 'vue'
import draggable from 'vuedraggable'
defineOptions({ name: 'StructureTree' })
const props = defineProps({
nodes: { type: Array, required: true },
parentPath: { type: String, default: '' },
depth: { type: Number, default: 0 },
})
const handlers = inject('structureHandlers')
// Focus the title input of a fresh pending row.
const vFocus = { mounted: (el) => el.focus() }
function onChange(evt) {
handlers.reorder(props.parentPath, props.nodes, evt)
}
// Drag guard: the front page (slug "") is a top-level item — it cannot be
// dropped into a section (its empty slug is only valid at the root). And
// nothing may be dropped under itself or one of its own descendants.
function onMove(evt) {
const el = evt.draggedContext.element
if (el.pending) return true // unsaved row: position it anywhere
const targetParent = evt.to.dataset.parent || ''
if (el.slug === '') return targetParent === ''
if (targetParent === el.path || targetParent.startsWith(`${el.path}/`)) return false
return true
}
// While dragging, reveal empty child lists as drop zones (style.css) so a
// page can be moved under a childless page.
function onStart() {
document.body.classList.add('tree-dragging')
}
function onEnd() {
document.body.classList.remove('tree-dragging')
}
</script>
<template>
<draggable
class="treelist"
:data-parent="parentPath"
:list="nodes"
item-key="path"
group="sitetree"
ghost-class="ghost"
:move="onMove"
@change="onChange"
@start="onStart"
@end="onEnd"
>
<template #item="{ element }">
<div class="node">
<!-- Indentation is row padding, not a container margin, so the
slug and action columns stay aligned across nesting levels. -->
<div
class="row"
:class="{ current: element.path === handlers.current() }"
:style="depth ? { paddingLeft: `${depth * 1.1}rem` } : null"
>
<span class="drag" title="drag to reorder/move"></span>
<template v-if="element.pending">
<input
v-model="element.title"
v-focus
class="edit title-edit"
placeholder="Title"
@keyup.enter="handlers.commitPending()"
@keyup.esc="handlers.discardPending()"
/>
<input
v-model="element.slug"
class="edit slug-edit"
placeholder="slug"
@keyup.enter="handlers.commitPending()"
@keyup.esc="handlers.discardPending()"
/>
<span class="acts">
<button type="button" class="act" title="create page" @click="handlers.commitPending()"></button>
<button type="button" class="act del" title="discard" @click="handlers.discardPending()"></button>
</span>
</template>
<template v-else>
<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)"
/>
<input
class="edit slug-edit"
:value="element.slug"
placeholder="front page"
title="Slug (last path segment) — renames move the whole subtree. Empty at top level = front page"
@change="handlers.commitSlug(element, $event)"
/>
<span class="acts">
<span v-if="!element.published" class="draft">draft</span>
<button
v-if="!element.has_content"
type="button"
class="act"
title="add a landing page (currently redirects to the first child)"
@click="handlers.addContent(element)"
></button>
<button
type="button"
class="act del"
:class="{ armed: handlers.arming() === element.path }"
:title="element.children.length
? 'delete the landing page (the category keeps its subpages)'
: 'delete page'"
@click="handlers.armRemove(element)"
>{{ handlers.arming() === element.path ? 'delete?' : '✕' }}</button>
</span>
</template>
</div>
<StructureTree
v-if="element.slug !== '' && !element.pending"
:nodes="element.children"
:parent-path="element.path"
:depth="depth + 1"
/>
</div>
</template>
</draggable>
</template>
<style scoped>
.treelist {
min-height: 0;
}
.node {
margin-left: 0.2rem;
}
/* Grid rows: handle / title / slug / actions line up as columns. Rows are
full width at every level (indentation is row padding) and the slug and
action columns are fixed-width, so they align across nesting levels. */
.row {
display: grid;
grid-template-columns: 1.2em minmax(3rem, 1fr) 7rem 5rem;
align-items: baseline;
gap: 0.35rem;
}
.row.current .title-edit {
color: var(--accent);
font-weight: 500;
}
.drag {
color: var(--muted);
cursor: grab;
}
/* Rows are always editable: inputs stay borderless until interacted with. */
.edit {
font: inherit;
font-size: 0.85rem;
padding: 0.1rem 0.4rem;
background: transparent;
color: var(--text);
border: 1px solid transparent;
border-radius: 4px;
min-width: 0;
}
.edit:hover {
border-color: var(--line);
}
.edit:focus {
background: var(--bg);
border-color: var(--accent);
outline: none;
}
.title-edit {
cursor: pointer;
}
.title-edit:focus {
cursor: text;
}
.slug-edit {
font-family: "Fira Code", monospace;
color: var(--muted);
}
.acts {
display: flex;
align-items: baseline;
gap: 0.25rem;
justify-content: end;
}
.draft {
color: var(--muted);
font-size: 0.75rem;
}
.act {
padding: 0 0.25rem;
background: none;
border: none;
color: var(--muted);
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
}
/* Two-step delete: the first click arms the button, the second deletes. */
.act.armed {
color: #e06c75;
font-weight: 600;
}
.del:hover {
color: #e06c75;
}
.ghost {
opacity: 0.4;
}
</style>
+41
View File
@@ -0,0 +1,41 @@
// Shared CodeMirror styling for both editors: a light theme matching the
// site's inputs, and a highlight style in the site palette — the default
// highlight style (from basicSetup) underlines headings/links and uses
// colors that clash with the page.
import { EditorView } from 'codemirror'
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { tags } from '@lezer/highlight'
// The base theme sets monospace on .cm-scroller, so the font must be set
// there, not on "&".
export const cmTheme = EditorView.theme({
"&": {
backgroundColor: "var(--bg)",
color: "var(--text)",
},
".cm-scroller": { fontFamily: '"Fira Code", monospace' },
".cm-content": { caretColor: "var(--text)" },
".cm-cursor": { borderLeftColor: "var(--text)" },
// basicSetup's active-line highlight assumes a dark theme.
".cm-activeLine": { backgroundColor: "transparent" },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground":
{ backgroundColor: "var(--line)" },
"&.cm-focused": { outline: "none" },
})
export const cmHighlight = syntaxHighlighting(HighlightStyle.define([
{ tag: tags.heading, fontWeight: "600", color: "var(--accent)" },
{ tag: tags.strong, fontWeight: "700" },
{ tag: tags.emphasis, fontStyle: "italic" },
{ tag: tags.strikethrough, textDecoration: "line-through" },
{ tag: tags.link, color: "var(--accent2)" },
{ tag: tags.url, color: "var(--muted)" },
{ tag: tags.monospace, color: "var(--accent2)" },
{ tag: tags.quote, color: "var(--muted)", fontStyle: "italic" },
// HTML (banner editor) and Markdown raw blocks
{ tag: tags.tagName, color: "var(--accent)" },
{ tag: tags.attributeName, color: "var(--accent2)" },
{ tag: tags.attributeValue, color: "var(--text)" },
{ tag: tags.comment, color: "var(--muted)" },
{ tag: tags.processingInstruction, color: "var(--muted)" },
]))
+55
View File
@@ -0,0 +1,55 @@
// 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.
// The standalone /admin shell (#app in the DOM) mounts PageEditor with the
// page selected by location hash, as a no-dynamic-import fallback.
import { createApp } from 'vue'
import PageEditor from './PageEditor.vue'
import SiteEditor from './SiteEditor.vue'
let host = null
export function openEditor(path, { standalone = false, mode = 'page' } = {}) {
closeEditor()
host = document.createElement('div')
host.className = 'editor-host'
// Docked inside #content: below the banner, next to the article only.
const container = (!standalone && document.getElementById('content')) || document.body
container.prepend(host)
if (!standalone) {
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.
document.body.dataset.editorMode = mode
}
createApp(mode === 'site' ? SiteEditor : PageEditor, {
pagePath: path,
standalone,
onClose: closeEditor,
}).mount(host)
}
export function closeEditor() {
if (!host) return
document.body.classList.remove('editing')
delete document.body.dataset.editorMode
// Slide the panel out in sync with the page shifting back.
host.firstElementChild?.classList.add('closing')
const old = host
host = null
setTimeout(() => old.remove(), 250)
}
const shell = document.getElementById('app')
if (shell) {
// Standalone /admin shell: mount into it and follow the location hash.
host = shell
createApp(PageEditor, {
pagePath: location.hash.replace(/^#\/?/, '').replace(/\/$/, ''),
standalone: true,
onClose: () => {},
}).mount(shell)
}