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:
2026-08-18 05:22:25 +00:00
parent d03ee7fa8f
commit c0817330e7
5 changed files with 170 additions and 9 deletions
+109
View File
@@ -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;