Add site-wide custom brand HTML with media upload

Data.brand_html replaces the brand link entirely (rendered in a #brand
div on top of the banner, next to the nav); editable in the site editor
via a small HTML CodeMirror with image/video upload and paste, live
preview, and debounced save through /_api/settings.
This commit is contained in:
2026-08-18 22:20:38 +00:00
parent f8cdfd53c0
commit f2715088d2
6 changed files with 160 additions and 17 deletions
+8 -2
View File
@@ -68,7 +68,11 @@ not for the public pages. See `docs/design-principles.md` for the design.
and embedded in page ETags so nav-affecting changes invalidate caches.
`Data.brand` is the site name (header link + `<title>` suffix), editable
in the site editor via `/_api/settings`; empty = no header link and
no `<title>` suffix. `Data.theme` is the active theme name (empty =
no `<title>` suffix. `Data.brand_html` is raw trusted HTML replacing the
brand link entirely (rendered in a `#brand` div on top of the banner,
next to the nav) — site-wide, not per-page like banners; edited in the
site editor with image/video upload into `Data.files`. `Data.theme` is
the active theme name (empty =
none/base only); themes are folders in `pagerite/themes/{name}`
containing `theme.css` and/or `banner.css` (+ `banner.svg` artwork and
any extra assets the CSS references, like summer's `grass.svg`),
@@ -175,7 +179,9 @@ not for the public pages. See `docs/design-principles.md` for the design.
(CodeMirror + server-rendered preview over WebSocket `/_api/ws/editor`,
previewing into the visible article; editor scroll drives document
scroll) opened by the article pen — it edits content and title only,
never the path — and `SiteEditor.vue` (site brand + theme selector +
never the path — and `SiteEditor.vue` (site brand + optional custom
brand HTML (with image/video upload, replaces the brand link) + theme
selector +
favicon upload/remove + site-wide custom CSS + per-page banner design
selector (inherit/none/named design, inherited by children) + banner
HTML edited in small CodeMirror windows;
+129 -6
View File
@@ -47,6 +47,12 @@ let cssSyncing = false // set while replacing the CSS document programmatically
const customCss = ref('')
const cssEl = ref(null)
let brandView = null // CodeMirror for the custom brand HTML
let brandSyncing = false // set while replacing the document programmatically
const brandHtml = ref('')
const brandEl = ref(null)
const brandInput = ref(null)
function normPath(p) {
return p.trim().replace(/^\/+|\/+$/g, '')
}
@@ -108,16 +114,22 @@ function swapRegions(doc) {
} else if (curSidebar) {
curSidebar.remove()
}
// The brand link lives in the header, outside the swappable regions,
// and is absent entirely when no brand is configured.
// The brand lives in the header, outside the swappable regions, and is
// absent entirely when neither a brand nor custom brand HTML is set. A
// plain link keeps its element (text swap only, preserving the shrink-
// to-fit observers); anything else (custom HTML wrapper) is replaced.
const freshBrand = doc.getElementById('brand')
const curBrand = document.getElementById('brand')
if (freshBrand && curBrand) {
if (freshBrand && curBrand && freshBrand.tagName === 'A' && curBrand.tagName === 'A') {
curBrand.textContent = freshBrand.textContent
} else if (freshBrand && curBrand) {
curBrand.replaceWith(document.importNode(freshBrand, true))
runScripts(document.getElementById('brand'))
} else if (curBrand) {
curBrand.remove()
} else if (freshBrand) {
document.getElementById('nav')?.before(document.importNode(freshBrand, true))
runScripts(document.getElementById('brand'))
}
// Site-wide custom CSS is in <head> and must be swapped too.
const freshUserStyle = doc.getElementById('pagerite-user')
@@ -302,6 +314,9 @@ async function loadSettings() {
try {
const s = await (await fetch('/_api/settings')).json()
brand.value = s.brand
brandHtml.value = s.brand_html || ''
setBrandDocument(brandHtml.value)
previewBrand()
theme.value = s.theme || ''
customCss.value = s.custom_css || ''
favicon.value = s.favicon || ''
@@ -372,6 +387,11 @@ function updateEditorTitle() {
function applyBrand(b) {
let el = document.getElementById('brand')
if (b) {
if (el && el.tagName !== 'A') {
// Currently the custom-HTML wrapper: swap back to a plain link.
el.remove()
el = null
}
if (!el) {
el = document.createElement('a')
el.id = 'brand'
@@ -385,8 +405,63 @@ function applyBrand(b) {
updateEditorTitle()
}
// Custom brand HTML (site-wide, replaces the brand link entirely) is
// previewed into the header on every keystroke and saves debounced.
function previewBrand() {
if (brandHtml.value.trim()) {
const el = document.createElement('div')
el.id = 'brand'
el.innerHTML = brandHtml.value
const old = document.getElementById('brand')
if (old) old.replaceWith(el)
else document.getElementById('nav')?.before(el)
runScripts(el)
} else {
applyBrand(brand.value)
}
}
function setBrandDocument(text) {
brandSyncing = true
brandView.dispatch({ changes: { from: 0, to: brandView.state.doc.length, insert: text } })
brandSyncing = false
brandHtml.value = text
}
function onBrandHtmlInput() {
previewBrand()
debounce('brand-html', () => saveSettings(), 400)
}
// Brand media goes to the shared content store, like banner media.
async function uploadBrandMedia(file) {
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(brandHtml.value)
setBrandDocument(rest ? `${tag}\n${rest}` : tag)
previewBrand()
saveSettings()
}
function onBrandPaste(ev) {
const file = [...(ev.clipboardData?.files || [])]
.find((f) => /^(image|video)\//.test(f.type))
if (file) {
ev.preventDefault()
uploadBrandMedia(file)
}
}
function onBrandInput() {
applyBrand(brand.value)
// Custom brand HTML wins over the plain text brand in the header.
if (!brandHtml.value.trim()) applyBrand(brand.value)
else updateEditorTitle()
debounce('brand', saveBrand)
}
@@ -400,6 +475,7 @@ async function saveSettings(opts = {}) {
brand: brand.value,
theme: theme.value,
custom_css: customCss.value,
brand_html: brandHtml.value,
...opts,
}),
})
@@ -722,6 +798,9 @@ provide('structureHandlers', {
// (null = the theme default), shown in the selector's inherit option.
const bannerDesign = ref(null)
const bannerDesignFrom = ref(null)
// One-shot callback run on the next save ack (set by onBannerDesignChange,
// whose re-render must not race the save it triggers).
let refreshOnSave = null
const inheritLabel = computed(() => {
if (bannerDesignFrom.value === null) {
@@ -732,7 +811,8 @@ const inheritLabel = computed(() => {
function onBannerDesignChange() {
// Saves immediately; the preview needs a server re-render (the design's
// inline SVG and its stylesheet link both change).
// inline SVG and its stylesheet link both change), and the re-fetch must
// wait for the save ack or it races ahead and renders the old design.
const msg = {
type: 'save',
path: normPath(path.value),
@@ -740,7 +820,7 @@ function onBannerDesignChange() {
}
pendingSave = msg
send(msg)
loadPlain(path.value)
refreshOnSave = () => loadPlain(path.value)
}
// --- Banner editing ------------------------------------------------------
@@ -845,6 +925,10 @@ function onMessage(ev) {
saveError.value = ''
pendingSave = null
refreshPages()
// A one-shot re-render requested by a save that must be visible first
// (banner design change). Only the most recent one matters.
refreshOnSave?.()
refreshOnSave = null
} else if (msg.type === 'error') {
saveError.value = '⚠️ changes could not be saved'
}
@@ -925,6 +1009,26 @@ onMounted(async () => {
}),
parent: cssEl.value,
})
brandView = new EditorView({
state: EditorState.create({
doc: '',
extensions: [
basicSetup,
html(),
cmTheme,
cmHighlight,
EditorView.lineWrapping,
placeholder('custom brand HTML (replaces the brand link; empty = plain link)'),
EditorView.updateListener.of((u) => {
if (u.docChanged && !brandSyncing) {
brandHtml.value = brandView.state.doc.toString()
onBrandHtmlInput()
}
}),
],
}),
parent: brandEl.value,
})
addEventListener('keydown', onKeydown)
await loadSettings()
parseFonts(customCss.value)
@@ -942,6 +1046,7 @@ onUnmounted(() => {
}
view?.destroy()
cssView?.destroy()
brandView?.destroy()
removeEventListener('keydown', onKeydown)
})
</script>
@@ -983,6 +1088,24 @@ onUnmounted(() => {
A
</button>
</label>
<div class="brand-code" @paste="onBrandPaste">
<div class="block-head">
<span class="field-label">brand code (replaces the brand link)</span>
<button
type="button"
title="upload brand image/video (replaces existing media) — pasting works too"
@click="brandInput.click()"
>add image/video</button>
<input
ref="brandInput"
type="file"
accept="image/*,video/*"
hidden
@change="(ev) => { uploadBrandMedia(ev.target.files[0]); ev.target.value = '' }"
/>
</div>
<div ref="brandEl" class="banner-cm" />
</div>
<div class="favicon-row">
<img v-if="favicon" :src="favicon" class="favicon-preview" alt="" />
<span v-else class="favicon-preview favicon-empty">?</span>
+6 -3
View File
@@ -288,6 +288,7 @@ async def get_settings() -> dict:
the themes and banner designs available on disk for the selectors."""
return {
"brand": data.brand,
"brand_html": data.brand_html,
"theme": data.theme,
"custom_css": data.custom_css,
"favicon": f"/_f/{data.favicon}" if data.favicon else "",
@@ -302,6 +303,7 @@ class SettingsIn(BaseModel):
brand: str
theme: str
custom_css: str
brand_html: str = ""
@app.put("/_api/settings", status_code=204)
@@ -309,6 +311,7 @@ async def put_settings(settings: SettingsIn) -> None:
"""Update site-wide settings; bumps the version so ETags invalidate."""
with kanta.transaction("update settings"):
data.brand = settings.brand
data.brand_html = settings.brand_html
data.theme = settings.theme
data.custom_css = settings.custom_css
data.version += 1
@@ -682,7 +685,7 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
if request.headers.get("if-none-match") == etag:
return Response(status_code=304)
return HTMLResponse(
views.render_page(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon),
views.render_page(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html),
headers={
"etag": etag,
"last-modified": _http_date(node.modified),
@@ -693,7 +696,7 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
# Category label without a landing page: placeholder with the pen
# to create it (404 — no page here, but the node is real).
return HTMLResponse(
views.render_category(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon),
views.render_category(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html),
404,
headers={
"last-modified": _http_date(node.modified),
@@ -706,4 +709,4 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
for slug, item in sorted_nodes(data.menu):
if item.published:
return RedirectResponse(f"/{slug}")
return HTMLResponse(views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon), 404)
return HTMLResponse(views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html), 404)
+5
View File
@@ -85,6 +85,11 @@ class Data(msgspec.Struct):
#: Site name shown in the header and <title> suffix; editable in the
#: site editor. Empty = no brand link in the header, no title suffix.
brand: str = "Pagerite"
#: Raw trusted HTML replacing the brand link entirely (a logo image,
#: styled markup, canvas+script...), site-wide — not per-page
#: overridable like banners. Rendered in the header on top of the
#: banner artwork, next to the nav. Empty = the plain brand link.
brand_html: str = ""
#: Active theme name (empty = none/base only). Themes live in
#: frontend/src/assets/themes/{theme}/theme.css, with their banner
#: artwork at pagerite/themes/{theme}/banner.svg (inlined server-side).
-1
View File
@@ -149,7 +149,6 @@ main {
article h1 {
color: var(--accent);
width: fit-content;
padding-bottom: 0.12em;
text-shadow: 0 1px #ffffffaa;
background: url("grass.svg") bottom left / auto 0.35em repeat-x;
}
+12 -5
View File
@@ -171,8 +171,12 @@ def _layout(
)
def _brand_link(brand: str) -> HTML:
"""Header brand link; omitted entirely when no brand is configured."""
def _brand_link(brand: str, brand_html: str = "") -> HTML:
"""Header brand: custom HTML (in a #brand wrapper, rendered instead of
the link) when configured, else the plain brand link; omitted entirely
when neither is set."""
if brand_html.strip():
return HTML(str(E.div(HTML(brand_html), id="brand")))
return HTML(str(E.a(brand, href="/", id="brand"))) if brand else HTML("")
@@ -391,6 +395,7 @@ def render_page(
custom_css: str = "",
theme: str = "",
favicon: str = "",
brand_html: str = "",
) -> str:
"""Render a full HTML page for the slug path."""
node = resolve(menu, path)[-1]
@@ -398,7 +403,7 @@ def render_page(
return str(
_layout(_page_assets(), custom_css, theme, banner_design(menu, path, theme), favicon)(
Title=f"{title} {brand}" if brand else title,
Brand=_brand_link(brand),
Brand=_brand_link(brand, brand_html),
Nav=nav_html(menu, path),
Sidebar=sidebar_html(menu, path),
Banner=banner_html(menu, path, theme),
@@ -414,6 +419,7 @@ def render_category(
custom_css: str = "",
theme: str = "",
favicon: str = "",
brand_html: str = "",
) -> str:
"""Render the placeholder for a content-less category label (404).
@@ -434,7 +440,7 @@ def render_category(
return str(
_layout(_page_assets(), custom_css, theme, banner_design(menu, path, theme), favicon)(
Title=f"{title} {brand}" if brand else title,
Brand=_brand_link(brand),
Brand=_brand_link(brand, brand_html),
Nav=nav_html(menu, path),
Sidebar=sidebar,
Banner=banner_html(menu, path, theme),
@@ -450,6 +456,7 @@ def render_not_found(
custom_css: str = "",
theme: str = "",
favicon: str = "",
brand_html: str = "",
) -> str:
"""Render a 404 page within the normal layout."""
doc = E.article
@@ -459,7 +466,7 @@ def render_not_found(
return str(
_layout(_page_assets(), custom_css, theme, banner_design(menu, path, theme), favicon)(
Title=f"Not Found {brand}" if brand else "Not Found",
Brand=_brand_link(brand),
Brand=_brand_link(brand, brand_html),
Nav=nav_html(menu, path),
Sidebar=sidebar_html(menu, path),
Banner=banner_html(menu, path, theme),