Phase 5 of docs/website/THEMING_AND_NAV.md: uploaded logo/hero/favicon overrides on top of the BRAND_* env defaults, delivered through an HTML shell that is no longer built once at boot. - utils/htmlShell.js owns the shell lifecycle: rendered lazily, cached per process, invalidated on a brand_assets/theme_visual write with a 5-minute TTL so other workers converge. A settings-read failure renders the env-only shell and caches that, so a DB outage is not a failing query per page view, and with no rows the output is byte-identical to what app.js served before. - POST /admin/settings/brand-asset/:slot uploads one asset and writes the row in the same call, so an upload never leaves an unreferenced file. It reuses the shared multer allowlist and only tightens it per slot: favicons are PNG-only and capped at 512 KB, logos at 1 MB, heroes at 8 MB. Refused files are unlinked before the response. - utils/brandAssets.js constrains a stored asset to a same-origin path under /uploads, /brand or /assets — these are the only settings values written straight into the page as a URL. Strict on write, forgiving on read. - The shell also carries the resolved theme as a <style id="theme-boot"> block, removing the first-paint flash phases 3-4 deferred; SiteContext drops that block once a successful settings fetch has been applied. - BrandLogo renders beside the MoonDot on all six shells and renders nothing when no logo is set, which is the shipped default. Co-Authored-By: Claude <noreply@anthropic.com>
214 lines
7.9 KiB
JavaScript
214 lines
7.9 KiB
JavaScript
import { useRef, useState } from 'react'
|
||
import { api } from '../../../api/client.js'
|
||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||
|
||
// Admin · Appearance → Brand assets (docs/website/THEMING_AND_NAV.md §6.3).
|
||
//
|
||
// Three slots, each an override layer over the matching BRAND_* env value. An
|
||
// empty slot is not "no image" — it is "whatever this instance was deployed
|
||
// with", which is why every row shows what it currently resolves to rather than
|
||
// an empty box.
|
||
//
|
||
// Unlike the theme form above, an upload SAVES IMMEDIATELY: the file and the
|
||
// settings row are written by one request, because an upload that stored a file
|
||
// and then waited for a Save press would leave litter in /uploads whenever the
|
||
// admin changed their mind. Clearing a slot is the same deal in reverse.
|
||
const SLOTS = [
|
||
{
|
||
id: 'logo',
|
||
label: 'Logo',
|
||
accept: 'image/png,image/jpeg,image/webp,image/avif,image/gif',
|
||
limit: '1 MB',
|
||
envVar: 'BRAND_LOGO',
|
||
help: 'Shown beside the moon in the site header, the admin sidebar and the player portal, and used as the link preview image when a page is shared.',
|
||
},
|
||
{
|
||
id: 'hero',
|
||
label: 'Hero image',
|
||
accept: 'image/png,image/jpeg,image/webp,image/avif,image/gif',
|
||
limit: '8 MB',
|
||
envVar: 'BRAND_HERO',
|
||
// §4.9: the hero editor's own background beats this, and an admin who does
|
||
// not know that files a bug against a working system.
|
||
help: 'The image behind the portal hero. If the hero editor has its own background image set, that wins over this one.',
|
||
},
|
||
{
|
||
id: 'favicon',
|
||
label: 'Favicon',
|
||
accept: 'image/png',
|
||
limit: '512 KB',
|
||
envVar: 'BRAND_FAVICON',
|
||
// §4.10: .ico would mean adding a type to the upload allowlist, and the
|
||
// stored extension coming from that allowlist is what makes uploads safe.
|
||
help: 'The browser tab icon. PNG only — a 32×32 or 64×64 square works everywhere.',
|
||
},
|
||
]
|
||
|
||
export default function BrandAssetsPanel({ initial }) {
|
||
const { brand, refresh: refreshSite } = useSite()
|
||
const [assets, setAssets] = useState(initial || {})
|
||
const [busySlot, setBusySlot] = useState('')
|
||
const [error, setError] = useState('')
|
||
const inputs = useRef({})
|
||
|
||
async function upload(slot, file) {
|
||
if (!file) return
|
||
setBusySlot(slot)
|
||
setError('')
|
||
try {
|
||
const res = await api.admin.uploadBrandAsset(slot, file)
|
||
setAssets(res.brand_assets || {})
|
||
await refreshSite()
|
||
} catch (err) {
|
||
setError(err.message || 'Could not upload that image.')
|
||
} finally {
|
||
setBusySlot('')
|
||
// Let the same file be picked again after a failure — a file input holds
|
||
// its value, so re-choosing it would fire no change event.
|
||
if (inputs.current[slot]) inputs.current[slot].value = ''
|
||
}
|
||
}
|
||
|
||
async function clear(slot) {
|
||
setBusySlot(slot)
|
||
setError('')
|
||
try {
|
||
const next = { ...assets }
|
||
delete next[slot]
|
||
// Clearing the last override deletes the row rather than storing `{}` —
|
||
// absence of the row is what selects the env defaults (§2), and a stored
|
||
// empty object would be a different state that means the same thing.
|
||
if (Object.keys(next).length) await api.admin.updateSettings({ brand_assets: next })
|
||
else await api.admin.resetSetting('brand_assets')
|
||
setAssets(next)
|
||
await refreshSite()
|
||
} catch (err) {
|
||
setError(err.message || 'Could not clear that asset.')
|
||
} finally {
|
||
setBusySlot('')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<span className="field-label">Brand assets</span>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, marginTop: 8 }}>
|
||
{SLOTS.map((slot) => {
|
||
const overridden = Boolean(assets[slot.id])
|
||
// What the site actually uses right now: the override, or the env
|
||
// value the brand block already resolved for us.
|
||
const effective = assets[slot.id] || brand[slot.id] || ''
|
||
return (
|
||
<div
|
||
key={slot.id}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'flex-start',
|
||
gap: 14,
|
||
padding: 12,
|
||
border: '1px solid var(--line)',
|
||
borderRadius: 'var(--radius-input)',
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: 76,
|
||
height: 48,
|
||
flex: '0 0 auto',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
border: '1px solid var(--line-soft)',
|
||
borderRadius: 6,
|
||
background: 'var(--bg-deep)',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
{effective ? (
|
||
<img src={effective} alt="" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
|
||
) : (
|
||
<span className="sans dim" style={{ fontSize: '0.68rem' }}>
|
||
none
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div className="sans" style={{ fontSize: '0.86rem', color: 'var(--ink)' }}>
|
||
{slot.label}
|
||
</div>
|
||
<div className="sans dim" style={{ fontSize: '0.74rem', lineHeight: 1.6, marginTop: 2 }}>
|
||
{slot.help}
|
||
</div>
|
||
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 6 }}>
|
||
{overridden ? (
|
||
<>
|
||
Uploaded override — <code>{assets[slot.id]}</code>
|
||
</>
|
||
) : effective ? (
|
||
<>
|
||
Using the deployed default from <code>{slot.envVar}</code>
|
||
</>
|
||
) : (
|
||
<>
|
||
Not set — <code>{slot.envVar}</code> is empty, so nothing is rendered
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 8, flexWrap: 'wrap' }}>
|
||
<input
|
||
ref={(el) => {
|
||
inputs.current[slot.id] = el
|
||
}}
|
||
type="file"
|
||
accept={slot.accept}
|
||
disabled={Boolean(busySlot)}
|
||
onChange={(e) => upload(slot.id, e.target.files?.[0])}
|
||
className="sans"
|
||
style={{ fontSize: '0.74rem', maxWidth: 240 }}
|
||
aria-label={`Upload a ${slot.label.toLowerCase()}`}
|
||
/>
|
||
<span className="sans dim" style={{ fontSize: '0.7rem' }}>
|
||
max {slot.limit}
|
||
</span>
|
||
{overridden && (
|
||
<button
|
||
type="button"
|
||
onClick={() => clear(slot.id)}
|
||
disabled={Boolean(busySlot)}
|
||
className="sans"
|
||
title={`Go back to ${slot.envVar}`}
|
||
style={linkBtn}
|
||
>
|
||
{busySlot === slot.id ? 'working…' : 'clear'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
{error && (
|
||
<span className="sans" style={{ display: 'block', marginTop: 8, color: '#d98b84', fontSize: '0.85rem' }}>
|
||
{error}
|
||
</span>
|
||
)}
|
||
<span className="sans dim" style={{ display: 'block', marginTop: 8, fontSize: '0.76rem' }}>
|
||
Uploads apply as soon as they finish — there is nothing to save here. The footer’s “powered by
|
||
Runic Gateway” mark is the project’s badge, not this instance’s, and never changes.
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const linkBtn = {
|
||
border: 'none',
|
||
background: 'transparent',
|
||
color: 'var(--accent)',
|
||
fontSize: '0.72rem',
|
||
cursor: 'pointer',
|
||
padding: 0,
|
||
}
|