From c0817330e7ce332b9f830d96bf39e4eb09b98be8 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Tue, 18 Aug 2026 05:22:25 +0000 Subject: [PATCH] Add favicon upload to the site editor Data.favicon names a blob in the content-addressed files store (no migration: msgspec default). PUT/DELETE /_api/settings/favicon upload and clear it; when set, every page links it as , otherwise browsers fall back to the build's /favicon.ico. SiteEditor shows a preview with upload/replace/remove and applies the change to the live page head. --- AGENTS.md | 8 ++- frontend/src/SiteEditor.vue | 109 ++++++++++++++++++++++++++++++++++++ pagerite/app.py | 45 +++++++++++++-- pagerite/data.py | 3 + pagerite/views.py | 14 ++++- 5 files changed, 170 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9c6b64c..860a4e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,11 @@ not for the public pages. See `docs/design-principles.md` for the design. referencing the per-family variables (`--font-source-sans` etc.) from pagerite.css; the base stylesheet's `--font-brand` defaults to `var(--font-heading)`. + `Data.favicon` names a file in the content-addressed `files` store, + uploaded/cleared in the site editor via `PUT`/`DELETE + /_api/settings/favicon`; when set it is linked as `` + on every page, otherwise browsers fall back to the build's + `/favicon.ico` by convention. - `markdown.py` — markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists plugins). Custom image rule: relative srcs resolve against the page path, titled images become figures. @@ -141,7 +146,8 @@ not for the public pages. See `docs/design-principles.md` for the design. 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 + - site-wide custom CSS + banner HTML edited in small CodeMirror windows; + favicon upload/remove + site-wide custom CSS + banner HTML edited in + small CodeMirror windows; banner previewed into `#page-banner`, CSS injected into ``) + vue-draggable structure tree with always-editable title/slug inputs per row, opened by the banner pen — diff --git a/frontend/src/SiteEditor.vue b/frontend/src/SiteEditor.vue index 324066e..d72135c 100644 --- a/frontend/src/SiteEditor.vue +++ b/frontend/src/SiteEditor.vue @@ -315,9 +315,60 @@ async function loadSettings() { brand.value = s.brand theme.value = s.theme || '' customCss.value = s.custom_css || '' + favicon.value = s.favicon || '' } catch { /* keep default */ } } +// --- Favicon --------------------------------------------------------------- +// Uploaded into the content-addressed file store (PUT /_api/settings/favicon) +// and linked on every page as ; empty falls back to the +// build's /favicon.ico. Applies to the live page immediately. +const favicon = ref('') +const faviconInput = ref(null) + +function applyFavicon(url) { + let link = document.querySelector('link[rel="icon"]') + if (url) { + if (!link) { + link = document.createElement('link') + link.rel = 'icon' + link.id = 'pagerite-favicon' + document.head.append(link) + } + link.href = url + } else if (link?.id === 'pagerite-favicon') { + link.remove() + } +} + +async function uploadFavicon(file) { + if (!file || !file.type.startsWith('image/')) return + const res = await fetch('/_api/settings/favicon', { + method: 'PUT', + headers: { 'x-filename': file.name.replace(/[^\w.-]/g, '-') }, + body: file, + }) + if (res.ok) { + saveError.value = '' + const { path: url } = await res.json() + favicon.value = url + applyFavicon(url) + } else { + saveError.value = `⚠️ ${await errorDetail(res)}` + } +} + +async function removeFavicon() { + const res = await fetch('/_api/settings/favicon', { method: 'DELETE' }) + if (res.ok) { + saveError.value = '' + favicon.value = '' + applyFavicon('') + } else { + saveError.value = `⚠️ ${await errorDetail(res)}` + } +} + function currentTitle() { return flatMap.value[path.value]?.title || document.title.replace(/ – [^–]*$/, '') @@ -887,6 +938,28 @@ onUnmounted(() => { A +
+ + ? + + + +