branding image uploads for favicon, logo, og image plus server-side og injection

This commit is contained in:
2026-03-21 23:25:16 +02:00
parent 624cfe8192
commit cdb9e5d8ee
6 changed files with 489 additions and 25 deletions

View File

@@ -182,7 +182,7 @@ export default function Sidebar() {
}}
>
{logoUrl ? (
<img src={logoUrl} alt={appName} style={{ height: 24, objectFit: 'contain' }} />
<img src="/api/v1/branding/logo" alt={appName} style={{ height: 24, objectFit: 'contain' }} />
) : (
appName
)}
@@ -201,7 +201,7 @@ export default function Sidebar() {
}}
>
{logoUrl ? (
<img src={logoUrl} alt={appName} style={{ height: 20, objectFit: 'contain' }} />
<img src="/api/v1/branding/logo" alt={appName} style={{ height: 20, objectFit: 'contain' }} />
) : (
appName.charAt(0)
)}

View File

@@ -9,6 +9,8 @@ interface SiteSettings {
bodyFont: string | null
poweredByVisible: boolean
customCss: string | null
ogImageUrl: string | null
ogDescription: string | null
}
const defaults: SiteSettings = {
@@ -20,6 +22,8 @@ const defaults: SiteSettings = {
bodyFont: null,
poweredByVisible: true,
customCss: null,
ogImageUrl: null,
ogDescription: null,
}
const BrandingContext = createContext<SiteSettings>(defaults)
@@ -59,7 +63,7 @@ export function BrandingProvider({ children }: { children: React.ReactNode }) {
link.rel = 'icon'
document.head.appendChild(link)
}
link.href = settings.faviconUrl
link.href = '/api/v1/branding/favicon'
}
}, [settings.faviconUrl])

View File

@@ -1,8 +1,9 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../../lib/api'
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
import { useToast } from '../../hooks/useToast'
import { IconUpload, IconTrash } from '@tabler/icons-react'
interface SiteSettings {
appName: string
@@ -13,6 +14,8 @@ interface SiteSettings {
bodyFont: string | null
poweredByVisible: boolean
customCss: string | null
ogImageUrl: string | null
ogDescription: string | null
}
const defaults: SiteSettings = {
@@ -24,6 +27,137 @@ const defaults: SiteSettings = {
bodyFont: null,
poweredByVisible: true,
customCss: null,
ogImageUrl: null,
ogDescription: null,
}
interface BrandingUploadProps {
label: string
field: string
current: string | null
accept: string
hint: string
onUploaded: (url: string) => void
onRemoved: () => void
}
function BrandingUpload({ label, field, current, accept, hint, onUploaded, onRemoved }: BrandingUploadProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [removing, setRemoving] = useState(false)
const toast = useToast()
const serveField = field === 'ogImage' ? 'og-image' : field
const previewUrl = current ? `/api/v1/branding/${serveField}?t=${Date.now()}` : null
const upload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file || file.size > 5 * 1024 * 1024) {
if (file) toast.error('File too large (max 5MB)')
return
}
setUploading(true)
try {
const form = new FormData()
form.append('file', file)
const res = await fetch(`/api/v1/admin/branding/upload?field=${field}`, {
method: 'POST',
body: form,
credentials: 'include',
})
if (res.ok) {
const data = await res.json()
onUploaded(data.url)
toast.success(`${label} uploaded`)
} else {
const err = await res.json().catch(() => ({ error: 'Upload failed' }))
toast.error(err.error || 'Upload failed')
}
} catch {
toast.error('Upload failed')
} finally {
setUploading(false)
if (inputRef.current) inputRef.current.value = ''
}
}
const remove = async () => {
setRemoving(true)
try {
await fetch(`/api/v1/admin/branding/${serveField}`, {
method: 'DELETE',
credentials: 'include',
})
onRemoved()
toast.success(`${label} removed`)
} catch {
toast.error('Failed to remove')
} finally {
setRemoving(false)
}
}
return (
<div>
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>{label}</label>
<div className="flex items-center gap-3">
{previewUrl && (
<img
src={previewUrl}
alt={label}
style={{
width: 48,
height: 48,
objectFit: 'contain',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border)',
background: 'var(--bg)',
}}
/>
)}
<div className="flex flex-col gap-1.5">
<input
ref={inputRef}
type="file"
accept={accept}
onChange={upload}
className="sr-only"
id={`branding-upload-${field}`}
/>
<label
htmlFor={`branding-upload-${field}`}
className="btn btn-secondary inline-flex items-center gap-2"
style={{
cursor: uploading ? 'wait' : 'pointer',
opacity: uploading ? 0.6 : 1,
fontSize: 'var(--text-xs)',
padding: '6px 12px',
}}
>
<IconUpload size={14} stroke={2} />
{uploading ? 'Uploading...' : current ? 'Replace' : 'Upload'}
</label>
{current && (
<button
onClick={remove}
disabled={removing}
className="action-btn inline-flex items-center gap-1"
style={{
color: 'var(--error)',
fontSize: 'var(--text-xs)',
padding: '4px 8px',
borderRadius: 'var(--radius-sm)',
opacity: removing ? 0.6 : 1,
}}
>
<IconTrash size={12} stroke={2} /> Remove
</button>
)}
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>{hint}</span>
</div>
</div>
</div>
)
}
export default function AdminSettings() {
@@ -45,7 +179,15 @@ export default function AdminSettings() {
setSaving(true)
setSaved(false)
try {
await api.put('/admin/site-settings', form)
await api.put('/admin/site-settings', {
appName: form.appName,
accentColor: form.accentColor,
headerFont: form.headerFont,
bodyFont: form.bodyFont,
poweredByVisible: form.poweredByVisible,
customCss: form.customCss,
ogDescription: form.ogDescription,
})
setSaved(true)
setTimeout(() => setSaved(false), 2000)
toast.success('Branding saved')
@@ -92,26 +234,51 @@ export default function AdminSettings() {
/>
</div>
<div>
<label htmlFor="settings-logo-url" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Logo URL</label>
<input
id="settings-logo-url"
className="input"
value={form.logoUrl || ''}
onChange={e => setForm(f => ({ ...f, logoUrl: e.target.value || null }))}
placeholder="https://example.com/logo.svg"
/>
</div>
<BrandingUpload
label="Logo"
field="logo"
current={form.logoUrl}
accept="image/jpeg,image/png,image/webp,image/svg+xml"
hint="JPG, PNG, WebP or SVG. Max 5MB."
onUploaded={(url) => setForm(f => ({ ...f, logoUrl: url }))}
onRemoved={() => setForm(f => ({ ...f, logoUrl: null }))}
/>
<BrandingUpload
label="Favicon"
field="favicon"
current={form.faviconUrl}
accept="image/jpeg,image/png,image/webp,image/svg+xml,image/x-icon,image/vnd.microsoft.icon"
hint="ICO, PNG, SVG or WebP. Max 5MB."
onUploaded={(url) => setForm(f => ({ ...f, faviconUrl: url }))}
onRemoved={() => setForm(f => ({ ...f, faviconUrl: null }))}
/>
<BrandingUpload
label="OG image"
field="ogImage"
current={form.ogImageUrl}
accept="image/jpeg,image/png,image/webp"
hint="Recommended 1200x630px for best preview on social media. Max 5MB."
onUploaded={(url) => setForm(f => ({ ...f, ogImageUrl: url }))}
onRemoved={() => setForm(f => ({ ...f, ogImageUrl: null }))}
/>
<div>
<label htmlFor="settings-favicon-url" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Favicon URL</label>
<input
id="settings-favicon-url"
<label htmlFor="settings-og-description" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>OG description</label>
<textarea
id="settings-og-description"
className="input"
value={form.faviconUrl || ''}
onChange={e => setForm(f => ({ ...f, faviconUrl: e.target.value || null }))}
placeholder="https://example.com/favicon.ico"
rows={3}
value={form.ogDescription || ''}
onChange={e => setForm(f => ({ ...f, ogDescription: e.target.value || null }))}
placeholder="Shown when your site is shared on social media, messaging apps, etc."
style={{ resize: 'vertical', fontSize: 'var(--text-sm)' }}
maxLength={500}
/>
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2, display: 'block' }}>
Shown when your site is shared on social media, messaging apps, etc.
</span>
</div>
<div>