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 <link rel="icon">, 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.
This commit is contained in:
@@ -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 `<link rel="icon">`
|
||||
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
|
||||
`<head id="pagerite-user">`) + vue-draggable structure tree with
|
||||
always-editable title/slug inputs per row, opened by the banner pen —
|
||||
|
||||
@@ -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 <link rel="icon">; 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
|
||||
</button>
|
||||
</label>
|
||||
<div class="favicon-row">
|
||||
<img v-if="favicon" :src="favicon" class="favicon-preview" alt="" />
|
||||
<span v-else class="favicon-preview favicon-empty">?</span>
|
||||
<button
|
||||
type="button"
|
||||
title="upload favicon (ico, png, svg...)"
|
||||
@click="faviconInput.click()"
|
||||
>{{ favicon ? 'replace favicon' : 'upload favicon' }}</button>
|
||||
<button
|
||||
v-if="favicon"
|
||||
type="button"
|
||||
title="remove favicon (back to the default)"
|
||||
@click="removeFavicon"
|
||||
>remove</button>
|
||||
<input
|
||||
ref="faviconInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
@change="(ev) => { uploadFavicon(ev.target.files[0]); ev.target.value = '' }"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="fontPicker" class="font-picker">
|
||||
<div class="font-tabs">
|
||||
<button
|
||||
@@ -1025,6 +1098,42 @@ onUnmounted(() => {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Favicon row: tiny preview (or placeholder tile) + upload/remove buttons. */
|
||||
.favicon-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.favicon-preview {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
object-fit: contain;
|
||||
border-radius: 3px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.favicon-empty {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.favicon-row button {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.15rem 0.6rem;
|
||||
background: var(--accent2);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.block-head button {
|
||||
margin-left: auto;
|
||||
font: inherit;
|
||||
|
||||
+40
-5
@@ -274,8 +274,13 @@ async def update_structure(op: StructureOp) -> None:
|
||||
|
||||
@app.get("/_api/settings")
|
||||
async def get_settings() -> dict[str, str]:
|
||||
"""Site-wide settings (brand, theme and custom CSS)."""
|
||||
return {"brand": data.brand, "theme": data.theme, "custom_css": data.custom_css}
|
||||
"""Site-wide settings (brand, theme, custom CSS and favicon URL)."""
|
||||
return {
|
||||
"brand": data.brand,
|
||||
"theme": data.theme,
|
||||
"custom_css": data.custom_css,
|
||||
"favicon": f"/_f/{data.favicon}" if data.favicon else "",
|
||||
}
|
||||
|
||||
|
||||
class SettingsIn(BaseModel):
|
||||
@@ -296,6 +301,36 @@ async def put_settings(settings: SettingsIn) -> None:
|
||||
data.version += 1
|
||||
|
||||
|
||||
@app.put("/_api/settings/favicon")
|
||||
async def put_favicon(request: Request) -> dict[str, str]:
|
||||
"""Upload a favicon into the content-addressed store and activate it.
|
||||
|
||||
Raw image body (ico/png/svg...); the stored name is a blake3 hash
|
||||
prefix + extension, and pages link it as <link rel="icon">. Returns
|
||||
{"path": "/_f/..."}.
|
||||
"""
|
||||
body = await request.body()
|
||||
if not body:
|
||||
raise HTTPException(400, "empty file")
|
||||
stored = _hash_name(body, request.headers.get("x-filename", "favicon.ico"))
|
||||
with kanta.transaction("upload favicon"):
|
||||
data.files[stored] = body
|
||||
data.favicon = stored
|
||||
data.version += 1
|
||||
return {"path": f"/_f/{stored}"}
|
||||
|
||||
|
||||
@app.delete("/_api/settings/favicon", status_code=204)
|
||||
async def delete_favicon() -> None:
|
||||
"""Clear the custom favicon (back to the build's /favicon.ico).
|
||||
|
||||
The blob stays in the content-addressed store; only the reference goes.
|
||||
"""
|
||||
with kanta.transaction("clear favicon"):
|
||||
data.favicon = ""
|
||||
data.version += 1
|
||||
|
||||
|
||||
class ToggleTaskIn(BaseModel):
|
||||
"""Payload for toggling one task-list checkbox."""
|
||||
|
||||
@@ -575,17 +610,17 @@ 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),
|
||||
views.render_page(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon),
|
||||
headers={"etag": etag},
|
||||
)
|
||||
if node is not None and node.published and node.content is None:
|
||||
# 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), 404)
|
||||
return HTMLResponse(views.render_category(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon), 404)
|
||||
if node is None and not path:
|
||||
# No front page (no top-level node with slug ""): "/" opens the
|
||||
# first item of the navigation instead.
|
||||
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), 404)
|
||||
return HTMLResponse(views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon), 404)
|
||||
|
||||
@@ -86,6 +86,9 @@ class Data(msgspec.Struct):
|
||||
#: Raw site-wide custom CSS, injected inline in every page <head>.
|
||||
#: Trusted author content; not sanitized.
|
||||
custom_css: str = ""
|
||||
#: Favicon: name of a file in `files` (content-addressed), linked as
|
||||
#: <link rel="icon"> on every page. Empty = the build's /favicon.ico.
|
||||
favicon: str = ""
|
||||
#: Legacy flat page store (pre-tree databases); migrated into `menu`
|
||||
#: on startup, then cleared. Never written otherwise.
|
||||
pages: dict[str, Page] = {}
|
||||
|
||||
+11
-3
@@ -86,6 +86,7 @@ def _layout(
|
||||
modules: list[str] = (),
|
||||
custom_css: str = "",
|
||||
theme: str = "",
|
||||
favicon: str = "",
|
||||
) -> Template:
|
||||
"""Page layout template with standard asset URLs and ES-module scripts.
|
||||
|
||||
@@ -99,6 +100,10 @@ def _layout(
|
||||
doc = Document(E.Title, lang="en")
|
||||
if theme:
|
||||
doc.meta(name="pagerite:theme", content=theme)
|
||||
# A custom favicon (from the site editor) is linked explicitly; without
|
||||
# one, browsers fall back to the build's /favicon.ico by convention.
|
||||
if favicon:
|
||||
doc.link(rel="icon", href=f"/_f/{favicon}", id="pagerite-favicon")
|
||||
# Editor asset URLs for pagerite.js, which injects the 🖊️ edit pens
|
||||
# itself once it has validated the session (pages render identically
|
||||
# for everyone; editing is gated by the auth proxy in front of /_api).
|
||||
@@ -294,13 +299,14 @@ def render_page(
|
||||
brand: str = SITE_NAME,
|
||||
custom_css: str = "",
|
||||
theme: str = "",
|
||||
favicon: str = "",
|
||||
) -> str:
|
||||
"""Render a full HTML page for the slug path."""
|
||||
node = resolve(menu, path)[-1]
|
||||
title = _title(path.rpartition("/")[2], node)
|
||||
scripts, styles = _page_assets(theme)
|
||||
return str(
|
||||
_layout(styles, scripts, custom_css, theme)(
|
||||
_layout(styles, scripts, custom_css, theme, favicon)(
|
||||
Title=f"{title} – {brand}" if brand else title,
|
||||
Brand=_brand_link(brand),
|
||||
Nav=nav_html(menu, path),
|
||||
@@ -317,6 +323,7 @@ def render_category(
|
||||
brand: str = SITE_NAME,
|
||||
custom_css: str = "",
|
||||
theme: str = "",
|
||||
favicon: str = "",
|
||||
) -> str:
|
||||
"""Render the placeholder for a content-less category label (404).
|
||||
|
||||
@@ -336,7 +343,7 @@ def render_category(
|
||||
doc.p("This section has no page of its own yet.")
|
||||
scripts, styles = _page_assets(theme)
|
||||
return str(
|
||||
_layout(styles, scripts, custom_css, theme)(
|
||||
_layout(styles, scripts, custom_css, theme, favicon)(
|
||||
Title=f"{title} – {brand}" if brand else title,
|
||||
Brand=_brand_link(brand),
|
||||
Nav=nav_html(menu, path),
|
||||
@@ -353,6 +360,7 @@ def render_not_found(
|
||||
brand: str = SITE_NAME,
|
||||
custom_css: str = "",
|
||||
theme: str = "",
|
||||
favicon: str = "",
|
||||
) -> str:
|
||||
"""Render a 404 page within the normal layout."""
|
||||
doc = E.article
|
||||
@@ -361,7 +369,7 @@ def render_not_found(
|
||||
doc.p(f"No article at /{path}. If there was before, it may have been deleted.")
|
||||
scripts, styles = _page_assets(theme)
|
||||
return str(
|
||||
_layout(styles, scripts, custom_css, theme)(
|
||||
_layout(styles, scripts, custom_css, theme, favicon)(
|
||||
Title=f"Not Found – {brand}" if brand else "Not Found",
|
||||
Brand=_brand_link(brand),
|
||||
Nav=nav_html(menu, path),
|
||||
|
||||
Reference in New Issue
Block a user