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
|
referencing the per-family variables (`--font-source-sans` etc.) from
|
||||||
pagerite.css;
|
pagerite.css;
|
||||||
the base stylesheet's `--font-brand` defaults to `var(--font-heading)`.
|
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,
|
- `markdown.py` — markdown-it-py renderer (html passthrough + attrs,
|
||||||
footnote, deflist, tasklists plugins). Custom image rule: relative srcs
|
footnote, deflist, tasklists plugins). Custom image rule: relative srcs
|
||||||
resolve against the page path, titled images become figures.
|
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
|
previewing into the visible article; editor scroll drives document
|
||||||
scroll) opened by the article pen — it edits content and title only,
|
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 + 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
|
banner previewed into `#page-banner`, CSS injected into
|
||||||
`<head id="pagerite-user">`) + vue-draggable structure tree with
|
`<head id="pagerite-user">`) + vue-draggable structure tree with
|
||||||
always-editable title/slug inputs per row, opened by the banner pen —
|
always-editable title/slug inputs per row, opened by the banner pen —
|
||||||
|
|||||||
@@ -315,9 +315,60 @@ async function loadSettings() {
|
|||||||
brand.value = s.brand
|
brand.value = s.brand
|
||||||
theme.value = s.theme || ''
|
theme.value = s.theme || ''
|
||||||
customCss.value = s.custom_css || ''
|
customCss.value = s.custom_css || ''
|
||||||
|
favicon.value = s.favicon || ''
|
||||||
} catch { /* keep default */ }
|
} 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() {
|
function currentTitle() {
|
||||||
return flatMap.value[path.value]?.title
|
return flatMap.value[path.value]?.title
|
||||||
|| document.title.replace(/ – [^–]*$/, '')
|
|| document.title.replace(/ – [^–]*$/, '')
|
||||||
@@ -887,6 +938,28 @@ onUnmounted(() => {
|
|||||||
A
|
A
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</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 v-if="fontPicker" class="font-picker">
|
||||||
<div class="font-tabs">
|
<div class="font-tabs">
|
||||||
<button
|
<button
|
||||||
@@ -1025,6 +1098,42 @@ onUnmounted(() => {
|
|||||||
text-overflow: ellipsis;
|
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 {
|
.block-head button {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
|||||||
+40
-5
@@ -274,8 +274,13 @@ async def update_structure(op: StructureOp) -> None:
|
|||||||
|
|
||||||
@app.get("/_api/settings")
|
@app.get("/_api/settings")
|
||||||
async def get_settings() -> dict[str, str]:
|
async def get_settings() -> dict[str, str]:
|
||||||
"""Site-wide settings (brand, theme and custom CSS)."""
|
"""Site-wide settings (brand, theme, custom CSS and favicon URL)."""
|
||||||
return {"brand": data.brand, "theme": data.theme, "custom_css": data.custom_css}
|
return {
|
||||||
|
"brand": data.brand,
|
||||||
|
"theme": data.theme,
|
||||||
|
"custom_css": data.custom_css,
|
||||||
|
"favicon": f"/_f/{data.favicon}" if data.favicon else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class SettingsIn(BaseModel):
|
class SettingsIn(BaseModel):
|
||||||
@@ -296,6 +301,36 @@ async def put_settings(settings: SettingsIn) -> None:
|
|||||||
data.version += 1
|
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):
|
class ToggleTaskIn(BaseModel):
|
||||||
"""Payload for toggling one task-list checkbox."""
|
"""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:
|
if request.headers.get("if-none-match") == etag:
|
||||||
return Response(status_code=304)
|
return Response(status_code=304)
|
||||||
return HTMLResponse(
|
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},
|
headers={"etag": etag},
|
||||||
)
|
)
|
||||||
if node is not None and node.published and node.content is None:
|
if node is not None and node.published and node.content is None:
|
||||||
# Category label without a landing page: placeholder with the pen
|
# Category label without a landing page: placeholder with the pen
|
||||||
# to create it (404 — no page here, but the node is real).
|
# 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:
|
if node is None and not path:
|
||||||
# No front page (no top-level node with slug ""): "/" opens the
|
# No front page (no top-level node with slug ""): "/" opens the
|
||||||
# first item of the navigation instead.
|
# first item of the navigation instead.
|
||||||
for slug, item in sorted_nodes(data.menu):
|
for slug, item in sorted_nodes(data.menu):
|
||||||
if item.published:
|
if item.published:
|
||||||
return RedirectResponse(f"/{slug}")
|
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>.
|
#: Raw site-wide custom CSS, injected inline in every page <head>.
|
||||||
#: Trusted author content; not sanitized.
|
#: Trusted author content; not sanitized.
|
||||||
custom_css: str = ""
|
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`
|
#: Legacy flat page store (pre-tree databases); migrated into `menu`
|
||||||
#: on startup, then cleared. Never written otherwise.
|
#: on startup, then cleared. Never written otherwise.
|
||||||
pages: dict[str, Page] = {}
|
pages: dict[str, Page] = {}
|
||||||
|
|||||||
+11
-3
@@ -86,6 +86,7 @@ def _layout(
|
|||||||
modules: list[str] = (),
|
modules: list[str] = (),
|
||||||
custom_css: str = "",
|
custom_css: str = "",
|
||||||
theme: str = "",
|
theme: str = "",
|
||||||
|
favicon: str = "",
|
||||||
) -> Template:
|
) -> Template:
|
||||||
"""Page layout template with standard asset URLs and ES-module scripts.
|
"""Page layout template with standard asset URLs and ES-module scripts.
|
||||||
|
|
||||||
@@ -99,6 +100,10 @@ def _layout(
|
|||||||
doc = Document(E.Title, lang="en")
|
doc = Document(E.Title, lang="en")
|
||||||
if theme:
|
if theme:
|
||||||
doc.meta(name="pagerite:theme", content=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
|
# Editor asset URLs for pagerite.js, which injects the 🖊️ edit pens
|
||||||
# itself once it has validated the session (pages render identically
|
# itself once it has validated the session (pages render identically
|
||||||
# for everyone; editing is gated by the auth proxy in front of /_api).
|
# for everyone; editing is gated by the auth proxy in front of /_api).
|
||||||
@@ -294,13 +299,14 @@ def render_page(
|
|||||||
brand: str = SITE_NAME,
|
brand: str = SITE_NAME,
|
||||||
custom_css: str = "",
|
custom_css: str = "",
|
||||||
theme: str = "",
|
theme: str = "",
|
||||||
|
favicon: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Render a full HTML page for the slug path."""
|
"""Render a full HTML page for the slug path."""
|
||||||
node = resolve(menu, path)[-1]
|
node = resolve(menu, path)[-1]
|
||||||
title = _title(path.rpartition("/")[2], node)
|
title = _title(path.rpartition("/")[2], node)
|
||||||
scripts, styles = _page_assets(theme)
|
scripts, styles = _page_assets(theme)
|
||||||
return str(
|
return str(
|
||||||
_layout(styles, scripts, custom_css, theme)(
|
_layout(styles, scripts, custom_css, theme, favicon)(
|
||||||
Title=f"{title} – {brand}" if brand else title,
|
Title=f"{title} – {brand}" if brand else title,
|
||||||
Brand=_brand_link(brand),
|
Brand=_brand_link(brand),
|
||||||
Nav=nav_html(menu, path),
|
Nav=nav_html(menu, path),
|
||||||
@@ -317,6 +323,7 @@ def render_category(
|
|||||||
brand: str = SITE_NAME,
|
brand: str = SITE_NAME,
|
||||||
custom_css: str = "",
|
custom_css: str = "",
|
||||||
theme: str = "",
|
theme: str = "",
|
||||||
|
favicon: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Render the placeholder for a content-less category label (404).
|
"""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.")
|
doc.p("This section has no page of its own yet.")
|
||||||
scripts, styles = _page_assets(theme)
|
scripts, styles = _page_assets(theme)
|
||||||
return str(
|
return str(
|
||||||
_layout(styles, scripts, custom_css, theme)(
|
_layout(styles, scripts, custom_css, theme, favicon)(
|
||||||
Title=f"{title} – {brand}" if brand else title,
|
Title=f"{title} – {brand}" if brand else title,
|
||||||
Brand=_brand_link(brand),
|
Brand=_brand_link(brand),
|
||||||
Nav=nav_html(menu, path),
|
Nav=nav_html(menu, path),
|
||||||
@@ -353,6 +360,7 @@ def render_not_found(
|
|||||||
brand: str = SITE_NAME,
|
brand: str = SITE_NAME,
|
||||||
custom_css: str = "",
|
custom_css: str = "",
|
||||||
theme: str = "",
|
theme: str = "",
|
||||||
|
favicon: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Render a 404 page within the normal layout."""
|
"""Render a 404 page within the normal layout."""
|
||||||
doc = E.article
|
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.")
|
doc.p(f"No article at /{path}. If there was before, it may have been deleted.")
|
||||||
scripts, styles = _page_assets(theme)
|
scripts, styles = _page_assets(theme)
|
||||||
return str(
|
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",
|
Title=f"Not Found – {brand}" if brand else "Not Found",
|
||||||
Brand=_brand_link(brand),
|
Brand=_brand_link(brand),
|
||||||
Nav=nav_html(menu, path),
|
Nav=nav_html(menu, path),
|
||||||
|
|||||||
Reference in New Issue
Block a user