Serve built assets under /_/assets, admin at /_/admin
Reorganize the URL space so all machinery lives under /_/: the Vite build (now with assetsDir: '', two ES-module entries and hashed shared assets: style.css, pygments.css, banner.svg, fonts moved from pagerite/static to frontend/src/assets) is served at /_/assets via frontend.route(app, "/_/assets") with cached="/", and the admin shell moves to /_/admin, leaving only "_" as a reserved top-level slug. A /favicon.ico route serves the file Vite copies from frontend/public. In dev, Vite proxies content pages and /_/admin to the backend. Editor changes: drop the noisy status line for a save-error indicator, reconnect the WebSocket with exponential backoff, and re-establish the connection from send() when it has dropped. The socket connects when an editor is opened (they mount on pen click), not before.
This commit is contained in:
+27
-25
@@ -2,10 +2,11 @@
|
||||
// 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.
|
||||
// (main.js openEditor) or standalone at /_/admin with its own preview pane.
|
||||
// The socket connects when the editor is opened and reconnects with
|
||||
// exponential backoff after a failure; unsaved text and pending saves
|
||||
// survive a disconnect. Editor scroll drives the document scroll, keeping the
|
||||
// rendered article at the cursor's position.
|
||||
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { EditorView, basicSetup } from 'codemirror'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
@@ -21,7 +22,7 @@ const emit = defineEmits(['close'])
|
||||
const path = ref('')
|
||||
const title = ref('')
|
||||
const published = ref(true)
|
||||
const status = ref('connecting…')
|
||||
const saveError = ref('')
|
||||
const previewHtml = ref('')
|
||||
const previewHasH1 = ref(false)
|
||||
const editorEl = ref(null)
|
||||
@@ -33,6 +34,8 @@ let view = null
|
||||
let savedResolve = null
|
||||
let pendingSave = null
|
||||
let reconnectTimer = null
|
||||
let reconnectDelay = 2000
|
||||
const MAX_RECONNECT_DELAY = 16000
|
||||
let everConnected = false
|
||||
let dirty = false
|
||||
let syncingScroll = false
|
||||
@@ -42,7 +45,17 @@ function currentPath() {
|
||||
}
|
||||
|
||||
function send(msg) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(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()
|
||||
}
|
||||
}
|
||||
|
||||
function normPath(p) {
|
||||
@@ -67,9 +80,6 @@ function save() {
|
||||
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 })
|
||||
}
|
||||
@@ -102,9 +112,6 @@ async function uploadImage(file) {
|
||||
const { path: stored } = await res.json()
|
||||
const alt = name.replace(/\.[^.]+$/, '')
|
||||
insertAtCursor(``)
|
||||
status.value = `uploaded ${name}`
|
||||
} else {
|
||||
status.value = `upload failed (${res.status})`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,16 +177,15 @@ function onMessage(ev) {
|
||||
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()}`
|
||||
saveError.value = ''
|
||||
pendingSave = null
|
||||
savedResolve?.()
|
||||
savedResolve = null
|
||||
} else if (msg.type === 'error') {
|
||||
status.value = `error: ${msg.detail}`
|
||||
saveError.value = '⚠️ changes could not be saved'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +223,7 @@ function connect() {
|
||||
)
|
||||
ws.onmessage = onMessage
|
||||
ws.onopen = () => {
|
||||
status.value = ''
|
||||
reconnectDelay = 2000
|
||||
if (everConnected) {
|
||||
// Reconnected: local text is authoritative — don't re-open (that
|
||||
// would clobber the editor), just resync preview and pending saves.
|
||||
@@ -229,9 +235,11 @@ function connect() {
|
||||
everConnected = true
|
||||
}
|
||||
ws.onclose = () => {
|
||||
status.value = 'offline — reconnecting…'
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = setTimeout(connect, 1500)
|
||||
reconnectTimer = setTimeout(() => {
|
||||
connect()
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
||||
}, reconnectDelay)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,9 +302,9 @@ onUnmounted(() => {
|
||||
/>
|
||||
<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 v-if="saveError">{{ saveError }}</div>
|
||||
<div class="panes">
|
||||
<div ref="editorEl" class="editor" />
|
||||
<div v-if="standalone" ref="previewEl" class="preview">
|
||||
@@ -370,12 +378,6 @@ onUnmounted(() => {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
min-width: 5rem;
|
||||
}
|
||||
|
||||
.panes {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
+41
-27
@@ -25,7 +25,7 @@ const emit = defineEmits(['close'])
|
||||
|
||||
const path = ref('')
|
||||
const banner = ref('')
|
||||
const status = ref('connecting…')
|
||||
const saveError = ref('')
|
||||
const tree = ref([])
|
||||
const fileInput = ref(null)
|
||||
const bannerEl = ref(null)
|
||||
@@ -33,6 +33,8 @@ 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
|
||||
@@ -56,7 +58,17 @@ function normPath(p) {
|
||||
}
|
||||
|
||||
function send(msg) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(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
|
||||
@@ -72,9 +84,6 @@ function debounce(key, fn, ms = 600) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -189,7 +198,6 @@ async function commitPending() {
|
||||
if (!node) return
|
||||
const slug = node.slug.trim().replace(/\/+/g, '')
|
||||
if (!slug) {
|
||||
status.value = 'a slug is needed'
|
||||
return
|
||||
}
|
||||
const loc = locatePending(tree.value, '')
|
||||
@@ -205,7 +213,7 @@ async function commitPending() {
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
status.value = `create failed (${res.status})`
|
||||
saveError.value = '⚠️ changes could not be saved'
|
||||
return
|
||||
}
|
||||
// Place it exactly where the row was dropped: a fresh order key halfway
|
||||
@@ -218,6 +226,8 @@ async function commitPending() {
|
||||
: next ? next.order - 1
|
||||
: 1
|
||||
await postStructure({ path: newPath, order })
|
||||
} else {
|
||||
saveError.value = ''
|
||||
}
|
||||
pending.value = null
|
||||
await refreshPages()
|
||||
@@ -232,10 +242,11 @@ async function addContent(node) {
|
||||
body: JSON.stringify({ title: node.title, markdown: '', published: node.published }),
|
||||
})
|
||||
if (res.ok) {
|
||||
saveError.value = ''
|
||||
await refreshPages()
|
||||
navigate(node.path)
|
||||
} else {
|
||||
status.value = `failed (${res.status})`
|
||||
saveError.value = '⚠️ changes could not be saved'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,7 +294,11 @@ async function saveBrand() {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ brand: brand.value }),
|
||||
})
|
||||
if (!res.ok) status.value = `brand save failed (${res.status})`
|
||||
if (res.ok) {
|
||||
saveError.value = ''
|
||||
} else {
|
||||
saveError.value = '⚠️ changes could not be saved'
|
||||
}
|
||||
}
|
||||
|
||||
// Two-step delete (no dialogs): the first click arms the row's button for
|
||||
@@ -306,18 +321,19 @@ function armRemove(node) {
|
||||
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 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 navigate('')
|
||||
} else {
|
||||
loadPlain(path.value) // refresh menus
|
||||
}
|
||||
} else {
|
||||
status.value = `delete failed (${res.status})`
|
||||
saveError.value = '⚠️ changes could not be saved'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,8 +350,12 @@ async function postStructure(op) {
|
||||
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
|
||||
if (res.ok) {
|
||||
saveError.value = ''
|
||||
loadPlain(path.value) // refresh menus and content from the server
|
||||
} else {
|
||||
saveError.value = '⚠️ changes could not be saved'
|
||||
}
|
||||
await refreshPages()
|
||||
return res.ok
|
||||
}
|
||||
@@ -453,7 +473,6 @@ async function uploadBannerMedia(file) {
|
||||
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()
|
||||
@@ -464,7 +483,6 @@ async function uploadBannerMedia(file) {
|
||||
setDocument(rest ? `${tag}\n${rest}` : tag)
|
||||
previewBanner()
|
||||
save()
|
||||
status.value = `uploaded ${name}`
|
||||
}
|
||||
|
||||
function onBannerPaste(ev) {
|
||||
@@ -491,13 +509,12 @@ function onMessage(ev) {
|
||||
// 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()}`
|
||||
saveError.value = ''
|
||||
pendingSave = null
|
||||
refreshPages()
|
||||
} else if (msg.type === 'error') {
|
||||
status.value = `error: ${msg.detail}`
|
||||
saveError.value = '⚠️ changes could not be saved'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,7 +532,7 @@ function connect() {
|
||||
)
|
||||
ws.onmessage = onMessage
|
||||
ws.onopen = () => {
|
||||
status.value = ''
|
||||
reconnectDelay = 2000
|
||||
if (everConnected) {
|
||||
// Reconnected: resend any save attempted while offline.
|
||||
if (pendingSave) send(pendingSave)
|
||||
@@ -525,9 +542,11 @@ function connect() {
|
||||
everConnected = true
|
||||
}
|
||||
ws.onclose = () => {
|
||||
status.value = 'offline — reconnecting…'
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = setTimeout(connect, 1500)
|
||||
reconnectTimer = setTimeout(() => {
|
||||
connect()
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
||||
}, reconnectDelay)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,9 +594,9 @@ onUnmounted(() => {
|
||||
<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>
|
||||
<div v-if="saveError">{{ saveError }}</div>
|
||||
|
||||
<section class="block">
|
||||
<label class="field">
|
||||
@@ -647,11 +666,6 @@ onUnmounted(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Window-style close button, top right corner. */
|
||||
.toolbar .close {
|
||||
margin-left: auto;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 360">
|
||||
<defs>
|
||||
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#0b0918"/>
|
||||
<stop offset="0.6" stop-color="#241842"/>
|
||||
<stop offset="1" stop-color="#3d2b6b"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="aur1" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#00d4c8" stop-opacity="0"/>
|
||||
<stop offset="0.5" stop-color="#00d4c8" stop-opacity="0.7"/>
|
||||
<stop offset="1" stop-color="#7c5cff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="aur2" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#7c5cff" stop-opacity="0"/>
|
||||
<stop offset="0.5" stop-color="#ff5c8a" stop-opacity="0.55"/>
|
||||
<stop offset="1" stop-color="#ffd75c" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1600" height="360" fill="url(#sky)"/>
|
||||
<g fill="#ffffff">
|
||||
<circle cx="120" cy="60" r="1.6" opacity="0.9"/>
|
||||
<circle cx="300" cy="30" r="1.1" opacity="0.7"/>
|
||||
<circle cx="470" cy="90" r="1.4" opacity="0.8"/>
|
||||
<circle cx="640" cy="45" r="1" opacity="0.6"/>
|
||||
<circle cx="820" cy="70" r="1.5" opacity="0.85"/>
|
||||
<circle cx="990" cy="35" r="1.1" opacity="0.7"/>
|
||||
<circle cx="1150" cy="85" r="1.6" opacity="0.9"/>
|
||||
<circle cx="1320" cy="50" r="1" opacity="0.6"/>
|
||||
<circle cx="1480" cy="95" r="1.3" opacity="0.8"/>
|
||||
</g>
|
||||
<path d="M0 190 Q 400 90 800 170 T 1600 150 V240 Q 1200 210 800 240 T 0 250 Z" fill="url(#aur1)"/>
|
||||
<path d="M0 230 Q 400 140 800 210 T 1600 190 V280 Q 1200 250 800 280 T 0 290 Z" fill="url(#aur2)"/>
|
||||
<path d="M0 300 Q 400 260 800 300 T 1600 290 V360 H0 Z" fill="#12101c"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
@font-face { font-family: 'Fraunces'; font-weight: 100 1000; font-style: normal; font-display: swap; src: url('fraunces.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Literata'; font-weight: 100 900; font-style: normal; font-display: swap; src: url('literata.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Fira Code'; font-weight: 300 700; font-style: normal; font-display: swap; src: url('firacode.woff2') format('woff2'); }
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,86 @@
|
||||
pre { line-height: 125%; }
|
||||
td.linenos .normal { color: #6e7681; background-color: #0d1117; padding-left: 5px; padding-right: 5px; }
|
||||
span.linenos { color: #6e7681; background-color: #0d1117; padding-left: 5px; padding-right: 5px; }
|
||||
td.linenos .special { color: #e6edf3; background-color: #6e7681; padding-left: 5px; padding-right: 5px; }
|
||||
span.linenos.special { color: #e6edf3; background-color: #6e7681; padding-left: 5px; padding-right: 5px; }
|
||||
pre code .hll { background-color: #6e7681 }
|
||||
pre code { color: #E6EDF3 }
|
||||
pre code .c { color: #8B949E; font-style: italic } /* Comment */
|
||||
pre code .err { color: #F85149 } /* Error */
|
||||
pre code .esc { color: #E6EDF3 } /* Escape */
|
||||
pre code .g { color: #E6EDF3 } /* Generic */
|
||||
pre code .k { color: #FF7B72 } /* Keyword */
|
||||
pre code .l { color: #A5D6FF } /* Literal */
|
||||
pre code .n { color: #E6EDF3 } /* Name */
|
||||
pre code .o { color: #FF7B72; font-weight: bold } /* Operator */
|
||||
pre code .x { color: #E6EDF3 } /* Other */
|
||||
pre code .p { color: #E6EDF3 } /* Punctuation */
|
||||
pre code .ch { color: #8B949E; font-style: italic } /* Comment.Hashbang */
|
||||
pre code .cm { color: #8B949E; font-style: italic } /* Comment.Multiline */
|
||||
pre code .cp { color: #8B949E; font-weight: bold; font-style: italic } /* Comment.Preproc */
|
||||
pre code .cpf { color: #8B949E; font-style: italic } /* Comment.PreprocFile */
|
||||
pre code .c1 { color: #8B949E; font-style: italic } /* Comment.Single */
|
||||
pre code .cs { color: #8B949E; font-weight: bold; font-style: italic } /* Comment.Special */
|
||||
pre code .gd { color: #FFA198; background-color: #490202 } /* Generic.Deleted */
|
||||
pre code .ge { color: #E6EDF3; font-style: italic } /* Generic.Emph */
|
||||
pre code .ges { color: #E6EDF3; font-weight: bold; font-style: italic } /* Generic.EmphStrong */
|
||||
pre code .gr { color: #FFA198 } /* Generic.Error */
|
||||
pre code .gh { color: #79C0FF; font-weight: bold } /* Generic.Heading */
|
||||
pre code .gi { color: #56D364; background-color: #0F5323 } /* Generic.Inserted */
|
||||
pre code .go { color: #8B949E } /* Generic.Output */
|
||||
pre code .gp { color: #8B949E } /* Generic.Prompt */
|
||||
pre code .gs { color: #E6EDF3; font-weight: bold } /* Generic.Strong */
|
||||
pre code .gu { color: #79C0FF } /* Generic.Subheading */
|
||||
pre code .gt { color: #FF7B72 } /* Generic.Traceback */
|
||||
pre code .g-Underline { color: #E6EDF3; text-decoration: underline } /* Generic.Underline */
|
||||
pre code .kc { color: #79C0FF } /* Keyword.Constant */
|
||||
pre code .kd { color: #FF7B72 } /* Keyword.Declaration */
|
||||
pre code .kn { color: #FF7B72 } /* Keyword.Namespace */
|
||||
pre code .kp { color: #79C0FF } /* Keyword.Pseudo */
|
||||
pre code .kr { color: #FF7B72 } /* Keyword.Reserved */
|
||||
pre code .kt { color: #FF7B72 } /* Keyword.Type */
|
||||
pre code .ld { color: #79C0FF } /* Literal.Date */
|
||||
pre code .m { color: #A5D6FF } /* Literal.Number */
|
||||
pre code .s { color: #A5D6FF } /* Literal.String */
|
||||
pre code .na { color: #E6EDF3 } /* Name.Attribute */
|
||||
pre code .nb { color: #E6EDF3 } /* Name.Builtin */
|
||||
pre code .nc { color: #F0883E; font-weight: bold } /* Name.Class */
|
||||
pre code .no { color: #79C0FF; font-weight: bold } /* Name.Constant */
|
||||
pre code .nd { color: #D2A8FF; font-weight: bold } /* Name.Decorator */
|
||||
pre code .ni { color: #FFA657 } /* Name.Entity */
|
||||
pre code .ne { color: #F0883E; font-weight: bold } /* Name.Exception */
|
||||
pre code .nf { color: #D2A8FF; font-weight: bold } /* Name.Function */
|
||||
pre code .nl { color: #79C0FF; font-weight: bold } /* Name.Label */
|
||||
pre code .nn { color: #FF7B72 } /* Name.Namespace */
|
||||
pre code .nx { color: #E6EDF3 } /* Name.Other */
|
||||
pre code .py { color: #79C0FF } /* Name.Property */
|
||||
pre code .nt { color: #7EE787 } /* Name.Tag */
|
||||
pre code .nv { color: #79C0FF } /* Name.Variable */
|
||||
pre code .ow { color: #FF7B72; font-weight: bold } /* Operator.Word */
|
||||
pre code .pm { color: #E6EDF3 } /* Punctuation.Marker */
|
||||
pre code .w { color: #6E7681 } /* Text.Whitespace */
|
||||
pre code .mb { color: #A5D6FF } /* Literal.Number.Bin */
|
||||
pre code .mf { color: #A5D6FF } /* Literal.Number.Float */
|
||||
pre code .mh { color: #A5D6FF } /* Literal.Number.Hex */
|
||||
pre code .mi { color: #A5D6FF } /* Literal.Number.Integer */
|
||||
pre code .mo { color: #A5D6FF } /* Literal.Number.Oct */
|
||||
pre code .sa { color: #79C0FF } /* Literal.String.Affix */
|
||||
pre code .sb { color: #A5D6FF } /* Literal.String.Backtick */
|
||||
pre code .sc { color: #A5D6FF } /* Literal.String.Char */
|
||||
pre code .dl { color: #79C0FF } /* Literal.String.Delimiter */
|
||||
pre code .sd { color: #A5D6FF } /* Literal.String.Doc */
|
||||
pre code .s2 { color: #A5D6FF } /* Literal.String.Double */
|
||||
pre code .se { color: #79C0FF } /* Literal.String.Escape */
|
||||
pre code .sh { color: #79C0FF } /* Literal.String.Heredoc */
|
||||
pre code .si { color: #A5D6FF } /* Literal.String.Interpol */
|
||||
pre code .sx { color: #A5D6FF } /* Literal.String.Other */
|
||||
pre code .sr { color: #79C0FF } /* Literal.String.Regex */
|
||||
pre code .s1 { color: #A5D6FF } /* Literal.String.Single */
|
||||
pre code .ss { color: #A5D6FF } /* Literal.String.Symbol */
|
||||
pre code .bp { color: #E6EDF3 } /* Name.Builtin.Pseudo */
|
||||
pre code .fm { color: #D2A8FF; font-weight: bold } /* Name.Function.Magic */
|
||||
pre code .vc { color: #79C0FF } /* Name.Variable.Class */
|
||||
pre code .vg { color: #79C0FF } /* Name.Variable.Global */
|
||||
pre code .vi { color: #79C0FF } /* Name.Variable.Instance */
|
||||
pre code .vm { color: #79C0FF } /* Name.Variable.Magic */
|
||||
pre code .il { color: #A5D6FF } /* Literal.Number.Integer.Long */
|
||||
@@ -0,0 +1,695 @@
|
||||
/* Shared styles for server-rendered pages and Vue components. */
|
||||
@import url("./fonts/fonts.css");
|
||||
@import url("./pygments.css");
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #12101c;
|
||||
--surface: #1b1830;
|
||||
--text: #e8e6f2;
|
||||
--muted: #9a94b8;
|
||||
--accent: #00d4c8;
|
||||
--accent2: #7c5cff;
|
||||
--line: #ffffff1a;
|
||||
/* Width of the docked editor panel (used both here for shifting the page
|
||||
and in the Vue editor's own styles). */
|
||||
--editor-w: min(46rem, 50vw);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Literata", Georgia, serif;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.65;
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
/* Full-bleed elements (.wide) size to 100vw, which counts the vertical
|
||||
scrollbar; clip the few stray pixels instead of scrolling. */
|
||||
overflow-x: clip;
|
||||
/* Full height even on short pages: the footer sits at the bottom and the
|
||||
docked editor (sized by #content) never collapses. */
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent2);
|
||||
}
|
||||
|
||||
/* Full-width banner: image header with the brand and nav overlaid. */
|
||||
#banner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
min-height: 11rem;
|
||||
background: url("./banner.svg") center 40% / cover;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* Per-page banner content (img, styled div, canvas...) overlays the
|
||||
default artwork; swapped along with #nav/#main on fetch-navigation. */
|
||||
#page-banner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#page-banner>* {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#brand,
|
||||
#nav {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#brand {
|
||||
font-family: "Fraunces", serif;
|
||||
font-weight: 700;
|
||||
font-size: 2.4rem;
|
||||
text-decoration: none;
|
||||
margin: auto 1.25rem 0;
|
||||
padding-top: 1.5rem;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent2));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
text-shadow: none;
|
||||
filter: drop-shadow(0 0 0.1rem #000);
|
||||
}
|
||||
|
||||
/* Nav overlaid at the bottom of the banner */
|
||||
#nav {
|
||||
font-size: 1.3em;
|
||||
text-shadow: 0 0 0.1em black;
|
||||
padding: 0.35rem 1.25rem;
|
||||
}
|
||||
|
||||
#nav ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 0.25rem 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
#nav a {
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
#nav ul ul a {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#nav a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#nav span {
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
#nav .current {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Sidebar + main row. A symmetric grid: the article column is sized by the
|
||||
viewport alone (never by content), with equally sized flexible gutters
|
||||
on both sides. The sidebar sits in the left gutter, so it appearing or
|
||||
disappearing never shifts the article; the right gutter balances it. */
|
||||
#content {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(0, 78rem) 1fr;
|
||||
/* The docked editor pushes the content (not the header) right. */
|
||||
transition: margin-left 0.25s ease;
|
||||
}
|
||||
|
||||
body.editing #content {
|
||||
margin-left: var(--editor-w);
|
||||
padding-left: 1rem;
|
||||
/* gap between the docked editor and the content */
|
||||
/* No overflow clipping here: .editor-host lives outside this box
|
||||
(negative left), and .wide shrink-wraps to the remaining space. */
|
||||
}
|
||||
|
||||
/* The sidebar's gutter space is needed by the editor instead. */
|
||||
body.editing #sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The editor host lives inside #content: it starts below the banner and
|
||||
ends above the footer. The panel itself sticks to the viewport while
|
||||
scrolling (but never taller than the content area). */
|
||||
.editor-host {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: calc(0px - var(--editor-w));
|
||||
width: var(--editor-w);
|
||||
}
|
||||
|
||||
.editor-root.overlay {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
max-height: 100%;
|
||||
background: var(--bg);
|
||||
animation: editor-slide-in 0.25s ease;
|
||||
}
|
||||
|
||||
.editor-root.overlay.closing {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
@keyframes editor-slide-in {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Site structure tree: while dragging, empty child lists appear as drop
|
||||
zones so a page can be moved under a childless page. */
|
||||
body.tree-dragging .treelist:empty {
|
||||
min-height: 1.2rem;
|
||||
outline: 1px dashed var(--line);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* The banner's own pen: opens the site editor (banner + structure).
|
||||
Qualified with `button` to beat the later .edit-link rule's left offset
|
||||
(both classes apply to the same element). */
|
||||
button.banner-edit-link {
|
||||
position: absolute;
|
||||
top: 0.6rem;
|
||||
right: 1.25rem;
|
||||
left: auto;
|
||||
z-index: 10;
|
||||
font: inherit;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
opacity: 0.55;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.banner-edit-link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
grid-column: 1;
|
||||
/* Pinned to the page's left edge (not the article's) and kept in view
|
||||
while scrolling. Translucent + blurred rather than an opaque box, so
|
||||
full-bleed .wide images can pass underneath without a hard edge. */
|
||||
justify-self: start;
|
||||
align-self: start;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
width: 12rem;
|
||||
max-height: 100vh;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1rem 1rem 1.25rem;
|
||||
border-radius: 0 0 0.5rem 0;
|
||||
background: color-mix(in srgb, var(--bg) 75%, transparent);
|
||||
backdrop-filter: blur(0.5rem);
|
||||
}
|
||||
|
||||
#sidebar:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#sidebar ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8em;
|
||||
line-height: 1.0;
|
||||
}
|
||||
|
||||
#sidebar a {
|
||||
text-decoration: none;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#sidebar a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#sidebar .current {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
main {
|
||||
grid-column: 2;
|
||||
/* No top padding: a leading wide image sits flush under the banner, and
|
||||
text-first pages get their spacing from the h1's top margin instead. */
|
||||
padding: 0 1.25rem 3rem;
|
||||
}
|
||||
|
||||
article h1,
|
||||
article h2,
|
||||
article h3 {
|
||||
font-family: "Fraunces", serif;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
article h1 {
|
||||
font-size: 2.2rem;
|
||||
margin: 2rem 0 1.2rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Margin strategy: bottom-only inside articles. Top margins misalign
|
||||
column tops and collapse unpredictably; spacing comes from below. */
|
||||
article p,
|
||||
article ul,
|
||||
article ol,
|
||||
article dl,
|
||||
article blockquote,
|
||||
article pre,
|
||||
article figure {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
article h3 {
|
||||
margin: 1.4rem 0 0.4rem;
|
||||
color: color-mix(in oklab, var(--accent2) 60%, var(--muted));
|
||||
}
|
||||
|
||||
/* Lists: small diamond emoji markers — blue 🔹 on odd nesting levels,
|
||||
orange 🔸 on even. The marker occupies a 1em outdented box so wrapped
|
||||
lines align. */
|
||||
article ul {
|
||||
list-style: none;
|
||||
padding-inline-start: 1em;
|
||||
}
|
||||
|
||||
article ul li::before {
|
||||
content: "🔹";
|
||||
display: inline-block;
|
||||
margin-left: -1.3em;
|
||||
width: 1.3em;
|
||||
}
|
||||
|
||||
article ul ul li::before {
|
||||
content: "🔸";
|
||||
}
|
||||
|
||||
article ul ul ul li::before {
|
||||
content: "🔹";
|
||||
}
|
||||
|
||||
/* Task lists render emoji checkmarks (see markdown.py), no diamond. */
|
||||
article .task-list-item::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
article {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.edit-link {
|
||||
position: absolute;
|
||||
top: 0.2rem;
|
||||
/* In the left gutter, on the same side as the docked editor panel. */
|
||||
left: -2.2rem;
|
||||
z-index: 2;
|
||||
/* stay above full-bleed .wide images */
|
||||
font: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
text-shadow: 0 0 0.1em black;
|
||||
}
|
||||
|
||||
/* pagerite.js tucks the pen at the end of the article's first h1. */
|
||||
article h1 .edit-link {
|
||||
position: static;
|
||||
font-size: 1.1rem;
|
||||
vertical-align: 0.3em;
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
.edit-link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
article p,
|
||||
article li,
|
||||
article dd {
|
||||
text-align: justify;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
/* Multi-column reading on wide displays, but only for long articles
|
||||
(pagerite.js adds .multicol based on content length and splits the body
|
||||
into .colseg segments separated by full-width h2s and wide figures;
|
||||
only segments with enough text get .cols and thus columns). Columns only
|
||||
reflow text inside the article; the article's width never changes. */
|
||||
@media (min-width: 100rem) {
|
||||
.multicol .colseg.cols {
|
||||
columns: 2;
|
||||
column-gap: 3.5rem;
|
||||
column-rule: 1px solid var(--line);
|
||||
}
|
||||
}
|
||||
|
||||
.multicol .colseg {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
p,
|
||||
li {
|
||||
break-inside: avoid-column;
|
||||
}
|
||||
|
||||
figure,
|
||||
pre,
|
||||
blockquote,
|
||||
table,
|
||||
dl {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
article h2 {
|
||||
font-size: 1.5rem;
|
||||
margin: 2.2rem 0 0.6rem;
|
||||
color: var(--accent2);
|
||||
}
|
||||
|
||||
article a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
article a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Blockquotes: inner paragraphs carry no margins (spacing comes from the
|
||||
blockquote itself, bottom-only like everything else in articles). The
|
||||
negative left margin pushes the bar out past the text edge, so quoted
|
||||
text aligns with the surrounding paragraphs — same trick as code blocks. */
|
||||
blockquote {
|
||||
margin: 0 0 1rem -0.5rem;
|
||||
padding: 0 0 0 0.25rem;
|
||||
border-left: 0.25rem solid var(--accent2);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
blockquote p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow-x: auto;
|
||||
padding: 0.5rem 0.8rem;
|
||||
/* Code text aligns with the surrounding paragraphs: the box extends
|
||||
past them by its own padding. */
|
||||
margin-left: -0.8rem;
|
||||
margin-right: -0.8rem;
|
||||
background: #ffffff09;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Inline code integrates with the text, no box of its own */
|
||||
p code,
|
||||
li code {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Click-to-copy button (added by pagerite.js) */
|
||||
.copy {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
right: 0.35rem;
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.15rem 0.6rem;
|
||||
color: var(--muted);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
pre:hover .copy,
|
||||
.copy:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.copy.copied {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "Fira Code", ui-monospace, monospace;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid var(--line);
|
||||
padding: 0.35rem 0.8rem;
|
||||
}
|
||||
|
||||
/* Images and figures */
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
/* Captions of full-bleed images: centered and kept to a readable width. */
|
||||
figure:has(.wide) figcaption {
|
||||
max-width: 65ch;
|
||||
margin-inline: auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Positioning via brace-attribute classes: {.right}, {.left}, {.wide} */
|
||||
figure:has(.right),
|
||||
img.right {
|
||||
float: right;
|
||||
margin: 0.3rem 0 1rem 1.5rem;
|
||||
max-width: 45%;
|
||||
}
|
||||
|
||||
figure:has(.left),
|
||||
img.left {
|
||||
float: left;
|
||||
margin: 0.3rem 1.5rem 1rem 0;
|
||||
max-width: 45%;
|
||||
}
|
||||
|
||||
/* .wide is full bleed: edge to edge of the viewport (or of the space left
|
||||
of the docked editor). Centered on the article column — which is itself
|
||||
centered in the available space — via margin-left: 50% + translateX(-50%).
|
||||
The sidebar stacks above it (z-index + opaque background). */
|
||||
figure:has(.wide),
|
||||
img.wide {
|
||||
display: block;
|
||||
/* no inline strut/descender gaps around the image */
|
||||
width: 100vw;
|
||||
max-width: none;
|
||||
margin-left: 50%;
|
||||
margin-right: 0;
|
||||
/* kill the UA figure margin, it overflowed the body */
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* A paragraph wrapping only a wide image must not add its line height. */
|
||||
p:has(> img.wide:only-child) {
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
body.editing figure:has(.wide),
|
||||
body.editing img.wide {
|
||||
width: calc(100vw - var(--editor-w) - 1rem);
|
||||
}
|
||||
|
||||
/* Scroll reveal (pagerite.js adds .reveal/.in; JS off = fully visible) */
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
translate: 0 14px;
|
||||
transition:
|
||||
opacity 0.6s ease,
|
||||
translate 0.6s ease;
|
||||
}
|
||||
|
||||
.reveal.in {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
.reveal {
|
||||
opacity: 1;
|
||||
translate: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.task-list-item {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.footnote {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* The footer element is kept (the editor host ends above it) but currently
|
||||
empty and zero-height. */
|
||||
footer {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Rotating-cube page transition, adapted from termotohtori.fi.
|
||||
FRAGILE: do not tweak; the view-transition pseudo-tree is picky. */
|
||||
::view-transition {
|
||||
perspective: 1000px;
|
||||
background: #000;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
::view-transition-group(root),
|
||||
::view-transition-image-pair(root) {
|
||||
transform-style: preserve-3d;
|
||||
isolation: auto;
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
mix-blend-mode: normal;
|
||||
backface-visibility: hidden;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes group-rotate {
|
||||
to {
|
||||
transform: rotateY(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-out-a-bit {
|
||||
to {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in-a-bit {
|
||||
from {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
::view-transition-group(root) {
|
||||
transform-origin: 50% 50% -50vw;
|
||||
animation: 300ms ease-in-out forwards group-rotate;
|
||||
}
|
||||
|
||||
::view-transition-old(root) {
|
||||
animation: 300ms ease-in-out forwards fade-out-a-bit;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
transform-origin: 0 0;
|
||||
transform: rotateY(90deg);
|
||||
inset: 0 auto 0 100%;
|
||||
animation: 300ms ease-in-out forwards fade-in-a-bit;
|
||||
}
|
||||
|
||||
/* Reverse direction for browser back navigation (same geometry, mirrored). */
|
||||
@keyframes group-rotate-back {
|
||||
to {
|
||||
transform: rotateY(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
html.nav-back::view-transition-group(root) {
|
||||
animation-name: group-rotate-back;
|
||||
}
|
||||
|
||||
html.nav-back::view-transition-new(root) {
|
||||
transform-origin: 100% 0;
|
||||
transform: rotateY(-90deg);
|
||||
inset: 0 100% 0 auto;
|
||||
}
|
||||
|
||||
/* Same-section navigation: a plain crossfade instead of the cube. These
|
||||
rules only override animation/geometry, leaving the FRAGILE block's
|
||||
perspective and layering untouched. The old snapshot stays fully opaque
|
||||
underneath while the new one fades in on top — never a dip to black. */
|
||||
@keyframes nav-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
html.nav-fade::view-transition-group(root) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
html.nav-fade::view-transition-old(root) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
html.nav-fade::view-transition-new(root) {
|
||||
transform: none;
|
||||
inset: 0;
|
||||
animation: 200ms ease-in-out nav-fade-in;
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import "./assets/style.css";
|
||||
|
||||
// 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
|
||||
// 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'
|
||||
@@ -45,7 +47,7 @@ export function closeEditor() {
|
||||
|
||||
const shell = document.getElementById('app')
|
||||
if (shell) {
|
||||
// Standalone /admin shell: mount into it and follow the location hash.
|
||||
// Standalone /_/admin shell: mount into it and follow the location hash.
|
||||
host = shell
|
||||
createApp(PageEditor, {
|
||||
pagePath: location.hash.replace(/^#\/?/, '').replace(/\/$/, ''),
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import "./assets/style.css";
|
||||
|
||||
// Fetch-navigation: swap dynamic regions (#nav, #main) instead of full
|
||||
// page loads. Real <a href> links are used throughout, so this is pure
|
||||
// progressive enhancement - without JS every link does a normal load.
|
||||
//
|
||||
// Also: scroll-reveal effects and code copy buttons. These need no
|
||||
// support from the article itself and are re-applied after each swap.
|
||||
(() => {
|
||||
const REGIONS = ["page-banner", "nav", "sidebar", "main"];
|
||||
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)");
|
||||
let editorModule = null;
|
||||
|
||||
function runScripts(root) {
|
||||
// Scripts inserted via DOM swapping 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);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scroll reveal + code block copy buttons -------------------------
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
e.target.classList.add("in");
|
||||
observer.unobserve(e.target);
|
||||
}
|
||||
}
|
||||
}, { rootMargin: "0px 0px -8% 0px" });
|
||||
|
||||
function addCopyButtons(main) {
|
||||
for (const pre of main.querySelectorAll("pre")) {
|
||||
if (pre.querySelector(".copy")) continue;
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "copy";
|
||||
btn.type = "button";
|
||||
btn.textContent = "copy";
|
||||
btn.addEventListener("click", async () => {
|
||||
const code = pre.querySelector("code");
|
||||
await navigator.clipboard.writeText(
|
||||
(code || pre).textContent.replace(/\n$/, ""),
|
||||
);
|
||||
btn.textContent = "copied";
|
||||
btn.classList.add("copied");
|
||||
setTimeout(() => {
|
||||
btn.textContent = "copy";
|
||||
btn.classList.remove("copied");
|
||||
}, 1500);
|
||||
});
|
||||
pre.append(btn);
|
||||
}
|
||||
}
|
||||
|
||||
// Tuck the article edit pen at the end of the first h1 (which may come
|
||||
// from the markdown itself). Re-runs when the editor replaces the
|
||||
// previewed body, since that wipes elements inside it.
|
||||
function placeEditPen() {
|
||||
const article = document.querySelector("#main article");
|
||||
const btn = article?.querySelector("button.edit-link");
|
||||
// First visible h1: the title h1 may be display:none when the
|
||||
// markdown owns its heading (editor preview state).
|
||||
const h1 = [...(article?.querySelectorAll("h1") || [])]
|
||||
.find((h) => h.offsetParent !== null);
|
||||
if (btn && h1 && btn.parentElement !== h1) h1.append(btn);
|
||||
}
|
||||
|
||||
addEventListener("pagerite:preview", placeEditPen);
|
||||
|
||||
function applyEffects() {
|
||||
(window.requestIdleCallback || setTimeout)(preload);
|
||||
const main = document.getElementById("main");
|
||||
addCopyButtons(main);
|
||||
placeEditPen();
|
||||
// Multi-column layout only when there is enough text to justify it.
|
||||
// Split the body into columned segments: h2s and wide figures are
|
||||
// full-width separators and never go inside columns.
|
||||
const article = main.querySelector("article");
|
||||
if (article) {
|
||||
const body = article.querySelector(".body");
|
||||
article.classList.toggle(
|
||||
"multicol",
|
||||
!!body && body.textContent.trim().length > 1800,
|
||||
);
|
||||
if (body && article.classList.contains("multicol")
|
||||
&& !body.querySelector(".colseg")) {
|
||||
// h2s and anything holding a wide image are full-width separators
|
||||
const isSeparator = (el) =>
|
||||
el.tagName === "H2" || el.querySelector("img.wide") !== null;
|
||||
let seg = null;
|
||||
for (const el of [...body.children]) {
|
||||
if (isSeparator(el)) {
|
||||
seg = null;
|
||||
body.append(el);
|
||||
} else {
|
||||
if (!seg) {
|
||||
seg = document.createElement("div");
|
||||
seg.className = "colseg";
|
||||
body.append(seg);
|
||||
}
|
||||
seg.append(el);
|
||||
}
|
||||
}
|
||||
// Columns are per section: only segments with enough text get them,
|
||||
// so a short ingress or a brief section stays single-column.
|
||||
for (const s of body.querySelectorAll(".colseg")) {
|
||||
s.classList.toggle("cols", s.textContent.trim().length > 600);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (reduceMotion.matches) return;
|
||||
for (const el of main.querySelectorAll(
|
||||
"h2, h3, figure, img, pre, blockquote, table, dl, .task-list-item",
|
||||
)) {
|
||||
if (!el.classList.contains("reveal")) {
|
||||
el.classList.add("reveal");
|
||||
observer.observe(el);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Preloading ------------------------------------------------------
|
||||
// Warm the HTTP cache with all linked pages and their resources, so
|
||||
// navigation (and the cube transition) is instant. Pages carry ETags,
|
||||
// so re-running this after each navigation revalidates cheaply (304)
|
||||
// and picks up changed content and images.
|
||||
function preload() {
|
||||
const urls = new Set();
|
||||
for (const a of document.querySelectorAll('#nav a[href^="/"], #main a[href^="/"]')) {
|
||||
if (!a.pathname.startsWith("/_/admin")) urls.add(a.pathname);
|
||||
}
|
||||
for (const url of urls) {
|
||||
if (url === location.pathname) continue;
|
||||
fetch(url)
|
||||
.then((r) => (r.ok ? r.text() : ""))
|
||||
.then((html) => {
|
||||
if (!html) return;
|
||||
// Off-screen parse: load the page's images and other resources
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
for (const img of doc.querySelectorAll("img")) {
|
||||
const i = new Image();
|
||||
i.src = img.src;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// The path we are currently showing. location.pathname is unusable for
|
||||
// this on popstate (it has already changed to the target); the editors
|
||||
// signal their replaceState navigation with pagerite:preview.
|
||||
let currentPath = location.pathname;
|
||||
addEventListener("pagerite:preview", () => {
|
||||
currentPath = location.pathname;
|
||||
});
|
||||
|
||||
// --- Fetch navigation ------------------------------------------------
|
||||
async function load(url, push = true, back = false) {
|
||||
// Navigating with the editor open closes it; unsaved edits are lost
|
||||
// (the region swap discards the previewed changes anyway).
|
||||
if (document.body.classList.contains("editing")) {
|
||||
editorModule?.then((m) => m.closeEditor());
|
||||
}
|
||||
let doc;
|
||||
let finalUrl = url;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const type = res.headers.get("content-type") || "";
|
||||
if (!res.ok || !type.includes("text/html")) throw new Error("not a page");
|
||||
// Section URLs redirect to their first child; reflect that.
|
||||
if (res.redirected) finalUrl = res.url;
|
||||
doc = new DOMParser().parseFromString(await res.text(), "text/html");
|
||||
} catch {
|
||||
location.href = url; // fall back to a normal navigation
|
||||
return;
|
||||
}
|
||||
if (REGIONS.some((id) => !doc.getElementById(id))) {
|
||||
location.href = url;
|
||||
return;
|
||||
}
|
||||
const doit = () => {
|
||||
for (const id of REGIONS) {
|
||||
const el = document.getElementById(id);
|
||||
el.replaceWith(document.importNode(doc.getElementById(id), true));
|
||||
}
|
||||
document.title = doc.title;
|
||||
// Banners may contain scripts (canvas etc.), content pages may too.
|
||||
runScripts(document.getElementById("page-banner"));
|
||||
runScripts(document.getElementById("main"));
|
||||
applyEffects();
|
||||
};
|
||||
// Rotating cube page transition (see the FRAGILE block in style.css);
|
||||
// mirrored when navigating back through history. Navigation within the
|
||||
// same top-level section crossfades instead, in either direction.
|
||||
if (document.startViewTransition && !reduceMotion.matches) {
|
||||
const seg = (u) => new URL(u, location.href).pathname.split("/")[1];
|
||||
const fade = seg(finalUrl) === seg(currentPath);
|
||||
const root = document.documentElement.classList;
|
||||
root.toggle("nav-fade", fade);
|
||||
root.toggle("nav-back", back && !fade);
|
||||
document.startViewTransition(doit).finished.finally(() => {
|
||||
root.remove("nav-fade", "nav-back");
|
||||
});
|
||||
} else {
|
||||
doit();
|
||||
}
|
||||
currentPath = new URL(finalUrl, location.href).pathname;
|
||||
if (push) history.pushState(null, "", finalUrl);
|
||||
scrollTo(0, 0);
|
||||
}
|
||||
|
||||
addEventListener("click", (ev) => {
|
||||
if (ev.defaultPrevented || ev.button !== 0
|
||||
|| ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
|
||||
// Edit buttons toggle the editor panel docked on this page: load the
|
||||
// Vue app on demand (with any extra styles) and mount it in place.
|
||||
// Clicking the pen of the already-open editor closes it; clicking the
|
||||
// other pen swaps the panel for the other editor type.
|
||||
const editBtn = ev.target.closest("button.edit-link");
|
||||
if (editBtn && editBtn.dataset.editorSrc) {
|
||||
ev.preventDefault();
|
||||
const mode = editBtn.dataset.editorMode || "page";
|
||||
if (document.body.classList.contains("editing")
|
||||
&& document.body.dataset.editorMode === mode) {
|
||||
editorModule?.then((m) => m.closeEditor());
|
||||
return;
|
||||
}
|
||||
for (const css of (editBtn.dataset.editorCss || "").split(",")) {
|
||||
if (css && !document.querySelector(`link[href="${css}"]`)) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = css;
|
||||
document.head.append(link);
|
||||
}
|
||||
}
|
||||
const path = location.pathname.replace(/^\/+|\/+$/g, "");
|
||||
editorModule = import(/* @vite-ignore */ editBtn.dataset.editorSrc);
|
||||
editorModule
|
||||
.then((m) => m.openEditor(path, { mode }))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
const a = ev.target.closest("a[href]");
|
||||
if (!a || a.target || a.hasAttribute("download")) return;
|
||||
const url = new URL(a.href, location.href);
|
||||
if (url.origin !== location.origin) return;
|
||||
// Same-page anchor links (footnotes etc.): let the browser handle them
|
||||
if (url.pathname === location.pathname && url.hash) return;
|
||||
if (url.pathname.startsWith("/_/")) return;
|
||||
ev.preventDefault();
|
||||
load(url);
|
||||
});
|
||||
|
||||
addEventListener("popstate", () => load(location.href, false, true));
|
||||
|
||||
applyEffects();
|
||||
})();
|
||||
+22
-4
@@ -5,19 +5,37 @@ import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
const backendUrl = process.env.PAGERITE_BACKEND_URL || 'http://localhost:3200'
|
||||
|
||||
// Proxy content pages (/slug, /path/to/slug) to the FastAPI backend in dev.
|
||||
// Excludes Vite internals (/@..., /src, /node_modules, /__...) and the
|
||||
// backend's /_ prefix. /_/api and /_/f are handled by the fastapi-vue plugin;
|
||||
// /_/admin is proxied explicitly below.
|
||||
const CONTENT_PROXY = '^\\/(?!_|@|src|node_modules|__)(?:[^./?]+(?:\\/[^./?]+)*)?(?:\\?.*)?$'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
fastapiVue(),
|
||||
fastapiVue({ paths: ["/_/api", "/_/f"] }),
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
"/_/admin": { target: backendUrl, changeOrigin: false },
|
||||
[CONTENT_PROXY]: { target: backendUrl, changeOrigin: false },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
// JS entry only: no index.html in the build (it would shadow our /),
|
||||
// and a manifest so the backend can resolve hashed asset names.
|
||||
// Emit hashed assets at the root of frontend-build so the backend can
|
||||
// serve them under /_/assets/{file} without a nested /assets directory.
|
||||
manifest: true,
|
||||
assetsDir: '',
|
||||
rollupOptions: {
|
||||
input: fileURLToPath(new URL('./src/main.js', import.meta.url)),
|
||||
input: {
|
||||
main: fileURLToPath(new URL('./src/main.js', import.meta.url)),
|
||||
pagerite: fileURLToPath(new URL('./src/pagerite.js', import.meta.url)),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user