branding image uploads for favicon, logo, og image plus server-side og injection
This commit is contained in:
@@ -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)
|
||||
)}
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user