feat: editable branding — app name and subtitle configurable in Admin Settings

Adds app_name and app_subtitle as system settings with a dedicated
Branding tab in the Admin panel. Both values are served via a public
GET /api/admin/branding endpoint (no auth) so the login page can also
show the configured name before the user signs in.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 11:02:38 +02:00
co-authored by Claude Sonnet 4.6
parent c262b31bc5
commit 4946cc300a
6 changed files with 150 additions and 6 deletions
+11
View File
@@ -0,0 +1,11 @@
import api from './client'
export interface BrandingSettings {
app_name: string
app_subtitle: string
}
export async function fetchBranding(): Promise<BrandingSettings> {
const { data } = await api.get<BrandingSettings>('/admin/branding')
return data
}
+5 -3
View File
@@ -8,6 +8,7 @@ import { getWorkerActivity } from '../../api/worker'
import { listOrders } from '../../api/orders'
import NotificationCenter from './NotificationCenter'
import ChatPanel from '../chat/ChatPanel'
import { useBranding } from '../../hooks/useBranding'
const nav = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', end: true },
@@ -39,6 +40,7 @@ export default function Layout() {
const location = useLocation()
const [sidebarOpen, setSidebarOpen] = useState(false)
const [chatOpen, setChatOpen] = useState(false)
const { appName, appSubtitle } = useBranding()
// Extract page context from URL for the chat agent
const chatContext = (() => {
@@ -81,7 +83,7 @@ export default function Layout() {
>
<Menu size={20} />
</button>
<span className="flex-1 text-sm font-semibold text-content">Hart.O.Mat</span>
<span className="flex-1 text-sm font-semibold text-content">{appName}</span>
<NotificationCenter />
</header>
@@ -108,8 +110,8 @@ export default function Layout() {
<span className="text-accent-text text-sm font-bold">H</span>
</div>
<div className="flex-1">
<p className="font-semibold text-content text-sm">Hart.O.Mat</p>
<p className="text-xs text-content-muted">Hartomatisierung</p>
<p className="font-semibold text-content text-sm">{appName}</p>
<p className="text-xs text-content-muted">{appSubtitle}</p>
</div>
{/* NotificationCenter in sidebar header (desktop); hidden on mobile (shown in top bar) */}
<span className="hidden md:block">
+15
View File
@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query'
import { fetchBranding } from '../api/branding'
export function useBranding() {
const { data } = useQuery({
queryKey: ['branding'],
queryFn: fetchBranding,
staleTime: 5 * 60 * 1000,
retry: false,
})
return {
appName: data?.app_name ?? 'Hart.O.Mat',
appSubtitle: data?.app_subtitle ?? 'Hartomatisierung',
}
}
+83 -2
View File
@@ -82,6 +82,8 @@ export default function AdminPage() {
})
type Settings = {
app_name: string
app_subtitle: string
thumbnail_renderer: string
blender_engine: string
blender_cycles_samples: number
@@ -117,6 +119,9 @@ export default function AdminPage() {
enabled: isAdmin,
})
const [brandingDraft, setBrandingDraft] = useState<Partial<Settings>>({})
const branding = { ...settings, ...brandingDraft } as Settings
// Local draft for Blender options so the user can change multiple fields before saving
const [blenderDraft, setBlenderDraft] = useState<Partial<Settings>>({})
const blender = { ...settings, ...blenderDraft } as Settings
@@ -130,9 +135,12 @@ export default function AdminPage() {
const updateSettingsMut = useMutation({
mutationFn: (data: Partial<Settings>) => api.put('/admin/settings', data),
onSuccess: () => {
onSuccess: (_data, variables) => {
toast.success('Settings saved')
qc.invalidateQueries({ queryKey: ['admin-settings'] })
if ('app_name' in variables || 'app_subtitle' in variables) {
qc.invalidateQueries({ queryKey: ['branding'] })
}
setBlenderDraft({})
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
@@ -309,7 +317,7 @@ export default function AdminPage() {
)
}
type AdminTab = 'overview' | 'users' | 'render-settings' | 'output-types' | 'templates' | 'pricing' | 'libraries' | 'system'
type AdminTab = 'overview' | 'users' | 'branding' | 'render-settings' | 'output-types' | 'templates' | 'pricing' | 'libraries' | 'system'
const [activeTab, setActiveTab] = useState<AdminTab>('overview')
// Blender Status (via Celery on render-worker)
@@ -324,6 +332,7 @@ export default function AdminPage() {
})
const hasUnsavedChanges =
Object.keys(brandingDraft).length > 0 ||
Object.keys(blenderDraft).length > 0 ||
Object.keys(viewerDraft).length > 0 ||
Object.keys(tessellationDraft).length > 0 ||
@@ -332,6 +341,7 @@ export default function AdminPage() {
const TABS: { id: AdminTab; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'users', label: 'Users' },
{ id: 'branding', label: 'Branding' },
{ id: 'render-settings', label: 'Render Settings' },
{ id: 'output-types', label: 'Output Types' },
{ id: 'templates', label: 'Templates & Positions' },
@@ -533,6 +543,77 @@ export default function AdminPage() {
</div>
</div>}
{/* ================================================================== */}
{/* Branding */}
{/* ================================================================== */}
{activeTab === 'branding' && isAdmin && (
<div className="mb-6">
<div className="card p-6 max-w-lg">
<div className="flex items-center gap-2 mb-6">
<Monitor size={18} className="text-accent" />
<h2 className="text-base font-semibold text-content">App Branding</h2>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">
Application Name
</label>
<input
type="text"
value={branding.app_name ?? 'Hart.O.Mat'}
onChange={(e) => setBrandingDraft((d) => ({ ...d, app_name: e.target.value }))}
maxLength={100}
className="input-base w-full"
placeholder="Hart.O.Mat"
/>
<p className="text-xs text-content-muted mt-1">
Shown in the sidebar, mobile header, and login page.
</p>
</div>
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">
Subtitle
</label>
<input
type="text"
value={branding.app_subtitle ?? 'Hartomatisierung'}
onChange={(e) => setBrandingDraft((d) => ({ ...d, app_subtitle: e.target.value }))}
maxLength={100}
className="input-base w-full"
placeholder="Hartomatisierung"
/>
<p className="text-xs text-content-muted mt-1">
Shown below the app name in the sidebar.
</p>
</div>
</div>
<div className="mt-6 flex items-center gap-3">
<button
className="btn-primary"
onClick={() => {
updateSettingsMut.mutate(brandingDraft, {
onSuccess: () => {
setBrandingDraft({})
},
})
}}
disabled={Object.keys(brandingDraft).length === 0 || updateSettingsMut.isPending}
>
{updateSettingsMut.isPending ? 'Saving...' : 'Save Branding'}
</button>
{Object.keys(brandingDraft).length > 0 && (
<button
className="btn-secondary"
onClick={() => setBrandingDraft({})}
>
Discard
</button>
)}
</div>
</div>
</div>
)}
{/* ================================================================== */}
{/* Render Settings */}
{/* ================================================================== */}
+3 -1
View File
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
import { Eye, EyeOff } from 'lucide-react'
import api from '../api/client'
import { useAuthStore } from '../store/auth'
import { useBranding } from '../hooks/useBranding'
export default function LoginPage() {
const navigate = useNavigate()
@@ -13,6 +14,7 @@ export default function LoginPage() {
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const { appName } = useBranding()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
@@ -36,7 +38,7 @@ export default function LoginPage() {
<div className="w-16 h-16 bg-accent rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-white text-2xl font-bold">S</span>
</div>
<h1 className="text-2xl font-bold text-content">HartOMat</h1>
<h1 className="text-2xl font-bold text-content">{appName}</h1>
<p className="text-content-muted text-sm mt-1">Media Creation Pipeline</p>
</div>