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:
@@ -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(``)
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)" },
|
||||
]))
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user