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:
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
function onBrandInput() {
|
||||
// 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() {
|
||||
// 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
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user