Files
pagerite/frontend/src/SiteEditor.vue
T
LeoVasanko ce69f69b0e Add font picker to site editor, neutral base banner styling
- Base stylesheet: per-family font variables (--font-source-sans etc.)
  with functional slots referencing them; --font-brand defaults to
  var(--font-heading). Restore Fraunces/Literata faces dropped in the
  font-set swap
- Site editor: stylized A button (always Fira Code) opens a font panel
  with body/heading/brand tabs; options render their own name in the
  candidate font at the target's size/weight; clicking the current pick
  clears it. Picks are stored as plain :root rows in the custom CSS,
  parsed and rewritten on change
- Base banner: drop black text-shadow on light background; legibility
  via a bottom scrim fading artwork into --bg. Purple keeps its own nav
  shadow
- Headings no longer accent-colored in base (moved to purple theme)
- Keep #pagerite-user custom CSS last in <head> at every mutation point
  so its :root rules win equal specificity (dev injects base/theme
  styles after the server-rendered tag)
2026-08-17 02:22:09 +00:00

1150 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 whose URL renders a placeholder page). The front page
// is a top-level row with an empty slug, not the parent of the others.
import { 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 { css } from '@codemirror/lang-css'
import { html } from '@codemirror/lang-html'
import { cmHighlight, cmTheme } from './cmtheme'
import { slugify } from './slugify'
const props = defineProps({
pagePath: { type: String, default: '' },
})
const emit = defineEmits(['close'])
const path = ref('')
const banner = ref('')
const saveError = ref('')
const tree = ref([])
const fileInput = ref(null)
const bannerEl = ref(null)
let ws = null
let pendingSave = null
let reconnectTimer = null
let reconnectDelay = 2000
const MAX_RECONNECT_DELAY = 16000
let everConnected = false
let view = null // CodeMirror for the banner HTML
let syncing = false // set while replacing the document programmatically
const bannerPh = new Compartment() // placeholder shows the inherited source
let cssView = null // CodeMirror for the site-wide custom CSS
let cssSyncing = false // set while replacing the CSS document programmatically
const customCss = ref('')
const cssEl = ref(null)
// 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))
} else {
ensureConnected()
}
}
function ensureConnected() {
if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
connect()
}
}
// 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
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))
}
// Site-wide custom CSS is in <head> and must be swapped too.
const freshUserStyle = doc.getElementById('pagerite-user')
const curUserStyle = document.getElementById('pagerite-user')
if (freshUserStyle && curUserStyle) {
curUserStyle.textContent = freshUserStyle.textContent
} else if (freshUserStyle) {
document.head.appendChild(document.importNode(freshUserStyle, true))
} else if (curUserStyle) {
curUserStyle.remove()
}
// Theme and other public stylesheets live in <head> and must be kept in
// sync; editor-only stylesheets are preserved. Diff-based: unchanged
// sheets keep their elements, so their @keyframes are never torn down
// (re-creating keyframes would replay the editor's slide-in animation).
const curLinks = [...document.head.querySelectorAll('link[rel="stylesheet"]')]
.filter((l) => !l.dataset.pagerite)
const freshHrefs = [...doc.head.querySelectorAll('link[rel="stylesheet"]')]
.map((l) => l.href)
for (const link of curLinks) {
if (!freshHrefs.includes(link.href)) link.remove()
}
const have = new Set(
[...document.head.querySelectorAll('link[rel="stylesheet"]')].map((l) => l.href),
)
for (const link of doc.head.querySelectorAll('link[rel="stylesheet"]')) {
if (!have.has(link.href)) document.head.appendChild(document.importNode(link, 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 and missing URLs render a placeholder 404 page — fine to
// swap in (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 row at the end of any list adds a *pending* row there: a
// local-only item that can be dragged into place before anything is
// filled in. It is persisted only on commit (✓/Enter), at wherever it
// currently sits.
const pending = ref(null)
function newPage(list) {
if (pending.value) return // one at a time
pending.value = {
slug: '',
path: '',
title: '',
order: 0,
published: true,
has_content: true,
children: [],
pending: true,
}
list.push(pending.value)
}
// Where does the pending row currently sit? -> {parentPath, list, index}.
function locatePending(nodes, parentPath) {
const i = nodes.indexOf(pending.value)
if (i >= 0) return { parentPath, list: nodes, i }
for (const n of nodes) {
const found = locatePending(n.children, n.path)
if (found) return found
}
return null
}
function discardPending() {
const loc = locatePending(tree.value, '')
if (loc) loc.list.splice(loc.i, 1)
pending.value = null
}
async function commitPending() {
const node = pending.value
if (!node) return
// Empty slug: derive one from the title (transliterated to ASCII).
const slug = slugify(node.slug.trim()) || slugify(node.title)
if (!slug) {
return
}
const loc = locatePending(tree.value, '')
const parentPath = loc?.parentPath ?? ''
const newPath = parentPath ? `${parentPath}/${slug}` : slug
const res = await fetch(`/_api/pages/${newPath}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
title: node.title.trim() || slug,
markdown: '',
published: true,
}),
})
if (!res.ok) {
// Show the server's reason (e.g. a reserved file name); the pending
// row stays so it can be edited and committed again.
saveError.value = `⚠️ ${await errorDetail(res)}`
return
}
// Place it exactly where the row was dropped: a fresh order key halfway
// between its new siblings (the PUT appended it at the end).
if (loc) {
const prev = loc.list[loc.i - 1]
const next = loc.list[loc.i + 1]
const order = prev && next ? (prev.order + next.order) / 2
: prev ? prev.order + 1
: next ? next.order - 1
: 1
await postStructure({ path: newPath, order })
} else {
saveError.value = ''
}
pending.value = null
await refreshPages()
navigate(newPath)
}
// --- 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('')
const theme = ref('purple')
const THEME_OPTIONS = [
{ value: '', label: 'none' },
{ value: 'purple', label: 'purple' },
]
async function loadSettings() {
try {
const s = await (await fetch('/_api/settings')).json()
brand.value = s.brand
theme.value = s.theme || ''
customCss.value = s.custom_css || ''
} 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 saveSettings(opts = {}) {
const res = await fetch('/_api/settings', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
brand: brand.value,
theme: theme.value,
custom_css: customCss.value,
...opts,
}),
})
if (res.ok) {
saveError.value = ''
} else {
saveError.value = '⚠️ changes could not be saved'
}
}
function saveBrand() {
saveSettings()
}
async function onThemeChange() {
await saveSettings()
if (import.meta.env.DEV) {
// Dev: styles are Vite-injected <style> tags, not <link>s, so the
// stylesheet sync in swapRegions can't switch themes. Drop the old
// theme's injected styles and import the new theme module instead.
for (const el of document.head.querySelectorAll('style[data-vite-dev-id]')) {
if (el.dataset.viteDevId.includes('/themes/')) el.remove()
}
if (theme.value) {
await import(/* @vite-ignore */ `/src/assets/themes/${theme.value}/theme.css`)
}
// The freshly injected theme style now sits after the custom CSS;
// move the custom CSS back to the end so it keeps winning.
applyCustomCss(customCss.value)
}
loadPlain(path.value)
}
// --- Site-wide custom CSS --------------------------------------------------
// Edits apply to the live page immediately and save while typing.
function applyCustomCss(css) {
let el = document.getElementById('pagerite-user')
if (css.trim()) {
if (!el) {
el = document.createElement('style')
el.id = 'pagerite-user'
}
el.textContent = css
// Keep it last in <head>: in dev Vite injects the base stylesheet
// after the server-rendered tag, and equal-specificity :root rules
// (font variables) are decided by order.
document.head.append(el)
} else if (el) {
el.remove()
}
}
function onCustomCssInput() {
applyCustomCss(customCss.value)
debounce('custom-css', saveCustomCss, 400)
}
function saveCustomCss() {
saveSettings()
}
function setCssDocument(text) {
cssSyncing = true
cssView.dispatch({ changes: { from: 0, to: cssView.state.doc.length, insert: text } })
cssSyncing = false
customCss.value = text
}
// --- Font overrides --------------------------------------------------------
// Font picks live in the custom CSS as plain :root rows in one exact format
// (` --font-body: var(--font-source-sans);`), so no marker comments are
// needed: the rows are parsed out on load, and on change they are stripped
// and rewritten — adding a :root block if none exists, dropping it when the
// last font row goes away. Values reference the per-family variables from
// pagerite.css, so no font stacks are spelled out here.
const FONT_ROW = /^\s*--font-(body|heading|brand):\s*var\(--font-[a-z-]+\);\s*$/
const FONT_OPTIONS = [
{ value: 'var(--font-source-serif)', label: 'Source Serif 4', serif: true },
{ value: 'var(--font-fraunces)', label: 'Fraunces', serif: true },
{ value: 'var(--font-literata)', label: 'Literata', serif: true },
{ value: 'var(--font-source-sans)', label: 'Source Sans 3', serif: false },
{ value: 'var(--font-inter)', label: 'Inter', serif: false },
{ value: 'var(--font-montserrat)', label: 'Montserrat', serif: false },
{ value: 'var(--font-fira-code)', label: 'Fira Code', serif: false },
]
const fontHeading = ref('')
const fontBody = ref('')
const fontBrand = ref('')
// Font picker popup: a stylized "A" opens a panel with a tab per target
// (heading/body/brand); each option's name is its own preview, rendered in
// the candidate font at the size and weight of the element being styled.
const fontPicker = ref(null) // open tab: 'heading' | 'body' | 'brand' | null
let fontTabLast = 'body'
const serifFonts = computed(() => FONT_OPTIONS.filter((o) => o.serif))
const sansFonts = computed(() => FONT_OPTIONS.filter((o) => !o.serif))
function toggleFontPanel() {
if (fontPicker.value) {
fontTabLast = fontPicker.value
fontPicker.value = null
} else {
fontPicker.value = fontTabLast
}
}
function fontRefFor(name) {
return { heading: fontHeading, body: fontBody, brand: fontBrand }[name]
}
function fontStyleFor(name) {
if (name === 'brand') return { fontWeight: 700, fontSize: '1.5rem' }
if (name === 'heading') return { fontWeight: 600, fontSize: '1.3rem' }
return {}
}
function pickFont(value) {
const r = fontRefFor(fontPicker.value)
// Clicking the current pick clears it back to the base-style default.
r.value = r.value === value ? '' : value
onFontChange()
}
function fontRows() {
const rows = []
if (fontBody.value) rows.push(` --font-body: ${fontBody.value};`)
if (fontHeading.value) rows.push(` --font-heading: ${fontHeading.value};`)
if (fontBrand.value) rows.push(` --font-brand: ${fontBrand.value};`)
return rows
}
function onFontChange() {
const rows = fontRows()
// Strip our rows wherever they are, then drop :root blocks left empty.
let css = customCss.value
.split('\n')
.filter((l) => !FONT_ROW.test(l))
.join('\n')
.replace(/:root\s*\{\s*\}\n?/g, '')
if (rows.length) {
if (/:root\s*\{/.test(css)) {
// Merge into the existing :root block.
css = css.replace(/:root\s*\{/, (m) => `${m}\n${rows.join('\n')}`)
} else {
const rest = css.trimStart()
css = `:root {\n${rows.join('\n')}\n}\n${rest ? `\n${rest}` : ''}`
}
}
setCssDocument(css)
onCustomCssInput()
}
function parseFonts(css) {
const get = (name) =>
css.match(new RegExp(`^\\s*--font-${name}:\\s*(var\\(--font-[a-z-]+\\));`, 'm'))?.[1] || ''
fontBody.value = get('body')
fontHeading.value = get('heading')
fontBrand.value = get('brand')
}
// Two-step delete (no dialogs): the first click arms the row's button for
// a few seconds, the second actually deletes.
const arming = ref(null)
let armTimer = null
function armRemove(node) {
if (arming.value === node.path) {
clearTimeout(armTimer)
arming.value = null
removePage(node)
} else {
arming.value = node.path
clearTimeout(armTimer)
armTimer = setTimeout(() => { arming.value = null }, 3000)
}
}
async function removePage(node) {
const res = await fetch(`/_api/pages/${node.path}`, { method: 'DELETE' })
if (res.ok) {
saveError.value = ''
refreshPages()
const p = node.path
if (p === path.value || (p && path.value.startsWith(`${p}/`))) {
// The current page was deleted — or reduced to a category, which now
// renders a placeholder page. Either way, re-render from the server.
if (node.children.length) loadPlain(path.value)
else navigate('')
} else {
loadPlain(path.value) // refresh menus
}
} else {
saveError.value = '⚠️ changes could not be saved'
}
}
// --- Site structure tree (drag-and-drop ordering/moving) ----------------
async function refreshPages() {
try {
tree.value = await (await fetch('/_api/pages')).json()
} catch { /* list stays stale; not fatal */ }
}
// Human-readable reason from a failed API call (FastAPI errors carry a
// JSON {detail}), falling back to a generic message.
async function errorDetail(res) {
const body = await res.json().catch(() => null)
return body?.detail || 'changes could not be saved'
}
async function postStructure(op) {
const res = await fetch('/_api/structure', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(op),
})
if (res.ok) {
saveError.value = ''
loadPlain(path.value) // refresh menus and content from the server
} else {
saveError.value = `⚠️ ${await errorDetail(res)}`
}
await refreshPages()
return res.ok
}
async function onReorder(parentPath, list, evt) {
// vuedraggable already mutated `list`; persist the moved item only: a
// fresh order key halfway between its new siblings (all other items
// keep theirs), plus the new path when the parent changed. The pending
// new-page row is local-only — its position is read at commit time.
const change = evt.moved || evt.added
if (!change) return
const el = change.element
if (el.pending) return
const i = change.newIndex
let prev, next
for (let j = i - 1; j >= 0 && !prev; j--) if (!list[j].pending) prev = list[j]
for (let j = i + 1; j < list.length && !next; j++) if (!list[j].pending) next = list[j]
const order = prev && next ? (prev.order + next.order) / 2
: prev ? prev.order + 1
: next ? next.order - 1
: 1
const newPath = parentPath ? `${parentPath}/${el.slug}` : el.slug
const op = { path: el.path, order }
if (newPath !== el.path) op.move_to = newPath
if (await postStructure(op) && op.move_to) followMove(el.path, op.move_to)
}
// Inline title/slug editing: rows are always editable. Title saves while
// typing (debounced); the slug commits on blur/Enter, since it renames
// the path (moving the whole subtree with it).
function onTitleInput(node, ev) {
const title = ev.target.value.trim()
if (!title || title === node.title) return
debounce(`title:${node.path}`, async () => {
await postStructure({ path: node.path, title })
})
}
// The slug inputs are filtered as you type (StructureTree onSlugInput,
// see slugify.js); the server re-validates and its reason is shown.
async function commitSlug(node, ev) {
const slug = ev.target.value.trim()
if (slug === node.slug) return
const parent = node.path.split('/').slice(0, -1).join('/')
// Empty slug at top level = the front page (path "").
const moveTo = parent ? (slug ? `${parent}/${slug}` : parent) : slug
if (await postStructure({ path: node.path, move_to: moveTo })) {
followMove(node.path, moveTo)
} else {
ev.target.value = node.slug // rename failed: put the old slug back
}
}
provide('structureHandlers', {
current: () => path.value,
open: navigate,
arming: () => arming.value,
armRemove,
reorder: onReorder,
titleInput: onTitleInput,
commitSlug,
commitPending,
discardPending,
newPage,
})
// --- 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) {
return
}
const { path: stored } = await res.json()
const tag = file.type.startsWith('video/')
? `<video src="${stored}" autoplay muted loop playsinline></video>`
: `<img src="${stored}" alt="">`
const rest = stripBannerMedia(banner.value)
setDocument(rest ? `${tag}\n${rest}` : tag)
previewBanner()
save()
}
function onBannerPaste(ev) {
const file = [...(ev.clipboardData?.files || [])]
.find((f) => /^(image|video)\//.test(f.type))
if (file) {
ev.preventDefault()
uploadBannerMedia(file)
}
}
function onMessage(ev) {
const msg = JSON.parse(ev.data)
if (msg.type === 'doc' && msg.path === path.value) {
setDocument(msg.banner ?? '')
// 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()
} else if (msg.type === 'saved') {
saveError.value = ''
pendingSave = null
refreshPages()
} else if (msg.type === 'error') {
saveError.value = '⚠️ changes could not be saved'
}
}
function onKeydown(ev) {
if ((ev.ctrlKey || ev.metaKey) && ev.key === 's') {
ev.preventDefault()
save()
}
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 = () => {
reconnectDelay = 2000
if (everConnected) {
// Reconnected: resend any save attempted while offline.
if (pendingSave) send(pendingSave)
} else {
openPath(normPath(props.pagePath))
}
everConnected = true
}
ws.onclose = () => {
clearTimeout(reconnectTimer)
reconnectTimer = setTimeout(() => {
connect()
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
}, reconnectDelay)
}
}
onMounted(async () => {
refreshPages()
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,
})
cssView = new EditorView({
state: EditorState.create({
doc: '',
extensions: [
basicSetup,
css(),
cmTheme,
cmHighlight,
EditorView.lineWrapping,
placeholder('Site styling CSS'),
EditorView.updateListener.of((u) => {
if (u.docChanged && !cssSyncing) {
customCss.value = cssView.state.doc.toString()
onCustomCssInput()
}
}),
],
}),
parent: cssEl.value,
})
addEventListener('keydown', onKeydown)
await loadSettings()
parseFonts(customCss.value)
setCssDocument(customCss.value)
applyCustomCss(customCss.value)
})
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()
cssView?.destroy()
removeEventListener('keydown', onKeydown)
})
</script>
<template>
<div class="editor-root overlay">
<header class="toolbar">
<span class="mode-label">site editor</span>
<button type="button" class="close" title="close" @click="close"></button>
</header>
<div v-if="saveError">{{ saveError }}</div>
<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"
/>
<select
v-model="theme"
class="text-input theme-select"
title="Theme"
@change="onThemeChange"
>
<option v-for="opt in THEME_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<button
type="button"
class="font-btn"
:class="{ active: !!fontPicker }"
title="Fonts"
@click="toggleFontPanel"
>
A
</button>
</label>
<div v-if="fontPicker" class="font-picker">
<div class="font-tabs">
<button
v-for="name in ['body', 'heading', 'brand']"
:key="name"
type="button"
:class="{ active: fontPicker === name }"
@click="fontPicker = name"
>
{{ name }}
</button>
</div>
<div class="font-cols">
<div class="font-col">
<button
v-for="opt in serifFonts"
:key="opt.label"
type="button"
class="font-opt"
:class="{ current: fontRefFor(fontPicker).value === opt.value }"
:style="{ fontFamily: opt.value, ...fontStyleFor(fontPicker) }"
@click="pickFont(opt.value)"
>
{{ opt.label }}
</button>
</div>
<div class="font-col">
<button
v-for="opt in sansFonts"
:key="opt.label"
type="button"
class="font-opt"
:class="{ current: fontRefFor(fontPicker).value === opt.value }"
:style="{ fontFamily: opt.value, ...fontStyleFor(fontPicker) }"
@click="pickFont(opt.value)"
>
{{ opt.label }}
</button>
</div>
</div>
</div>
<div ref="cssEl" class="css-cm" />
</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" />
</section>
</div>
</template>
<style scoped>
.editor-root {
display: flex;
flex-direction: column;
height: 100vh;
}
/* Docked-overlay positioning lives in the global pagerite.css (.editor-host /
.editor-root.overlay) since the host element is created by main.js. */
.toolbar {
display: flex;
align-items: center;
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;
}
/* 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;
}
.theme-select {
flex: 0 0 auto;
width: auto;
}
/* Stylized "A" icon button opening the font panel — always Fira Code, so
it stays distinctive no matter which fonts are configured. */
.font-btn {
font-family: var(--font-fira-code);
font-weight: 700;
font-size: 1.5rem;
padding: 0 0.4rem;
color: var(--muted);
background: none;
border: none;
cursor: pointer;
}
.font-btn:hover {
color: var(--text);
}
.font-btn.active {
color: var(--accent);
}
/* Font picker panel. Colors come from the theme variables, so contrast
against the page background is automatic in both light and dark themes. */
.font-picker {
padding: 0.5rem;
background: var(--bg);
color: var(--text);
border: 1px solid var(--line);
border-radius: 6px;
box-shadow: 0 4px 16px #0004;
}
.font-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 0.4rem;
}
.font-tabs button {
flex: 1;
padding: 0.15rem 0.5rem;
font: inherit;
color: var(--muted);
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
}
.font-tabs button.active {
color: var(--text);
border-bottom-color: var(--accent);
}
.font-cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.25rem 0.75rem;
}
.font-col {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
/* Each option's name is its own preview (font family + size/weight of the
element being styled are set inline). */
.font-opt {
padding: 0.3rem 0.45rem;
line-height: 1.3;
color: inherit;
background: none;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
text-align: left;
}
.font-opt:hover {
background: var(--surface);
}
.font-opt.current {
border-color: var(--accent);
}
/* 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;
}
/* CodeMirror window for site-wide custom CSS. */
.css-cm {
border: 1px solid var(--line);
border-radius: 4px;
overflow: hidden;
}
.css-cm :deep(.cm-editor) {
max-height: 12rem;
font-size: 0.85rem;
}
.css-cm :deep(.cm-scroller) {
overflow: auto;
}
.css-cm :deep(.cm-gutters) {
display: none;
}
.structure {
flex: 1;
overflow-y: auto;
min-height: 0;
}
</style>