From f2715088d2192aca3e55b0cef18cc60abe195bee Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Tue, 18 Aug 2026 22:20:38 +0000 Subject: [PATCH] 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. --- AGENTS.md | 10 ++- frontend/src/SiteEditor.vue | 135 +++++++++++++++++++++++++++++-- pagerite/app.py | 9 ++- pagerite/data.py | 5 ++ pagerite/themes/summer/theme.css | 1 - pagerite/views.py | 17 ++-- 6 files changed, 160 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d918d95..a639a0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 + `` 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; diff --git a/frontend/src/SiteEditor.vue b/frontend/src/SiteEditor.vue index 6a9483d..8ca0f60 100644 --- a/frontend/src/SiteEditor.vue +++ b/frontend/src/SiteEditor.vue @@ -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> diff --git a/pagerite/app.py b/pagerite/app.py index 3a4d1e6..2a2446b 100644 --- a/pagerite/app.py +++ b/pagerite/app.py @@ -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) diff --git a/pagerite/data.py b/pagerite/data.py index 643d4ed..d069e14 100644 --- a/pagerite/data.py +++ b/pagerite/data.py @@ -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). diff --git a/pagerite/themes/summer/theme.css b/pagerite/themes/summer/theme.css index 7f207ec..87442e1 100644 --- a/pagerite/themes/summer/theme.css +++ b/pagerite/themes/summer/theme.css @@ -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; } diff --git a/pagerite/views.py b/pagerite/views.py index 0f3cef4..bb17eb4 100644 --- a/pagerite/views.py +++ b/pagerite/views.py @@ -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),