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
+33
View File
@@ -23,6 +23,8 @@ VALID_ENGINES = {"cycles", "eevee"}
VALID_FORMATS = {"jpg", "png"} VALID_FORMATS = {"jpg", "png"}
VALID_CYCLES_DEVICES = {"auto", "gpu", "cpu"} VALID_CYCLES_DEVICES = {"auto", "gpu", "cpu"}
SETTINGS_DEFAULTS: dict[str, str] = { SETTINGS_DEFAULTS: dict[str, str] = {
"app_name": "Hart.O.Mat",
"app_subtitle": "Hartomatisierung",
"thumbnail_renderer": "blender", "thumbnail_renderer": "blender",
"blender_engine": "cycles", "blender_engine": "cycles",
"blender_cycles_samples": "256", "blender_cycles_samples": "256",
@@ -59,6 +61,8 @@ SETTINGS_DEFAULTS: dict[str, str] = {
class SettingsOut(BaseModel): class SettingsOut(BaseModel):
app_name: str = "Hart.O.Mat"
app_subtitle: str = "Hartomatisierung"
thumbnail_renderer: str = "blender" thumbnail_renderer: str = "blender"
blender_engine: str = "cycles" blender_engine: str = "cycles"
blender_cycles_samples: int = 256 blender_cycles_samples: int = 256
@@ -91,6 +95,8 @@ class SettingsOut(BaseModel):
class SettingsUpdate(BaseModel): class SettingsUpdate(BaseModel):
app_name: str | None = None
app_subtitle: str | None = None
thumbnail_renderer: str | None = None thumbnail_renderer: str | None = None
blender_engine: str | None = None blender_engine: str | None = None
blender_cycles_samples: int | None = None blender_cycles_samples: int | None = None
@@ -209,6 +215,8 @@ async def _save_setting(db: AsyncSession, key: str, value: str) -> None:
def _settings_to_out(raw: dict[str, str]) -> SettingsOut: def _settings_to_out(raw: dict[str, str]) -> SettingsOut:
return SettingsOut( return SettingsOut(
app_name=raw.get("app_name", "Hart.O.Mat"),
app_subtitle=raw.get("app_subtitle", "Hartomatisierung"),
thumbnail_renderer=raw["thumbnail_renderer"], thumbnail_renderer=raw["thumbnail_renderer"],
blender_engine=raw["blender_engine"], blender_engine=raw["blender_engine"],
blender_cycles_samples=int(raw["blender_cycles_samples"]), blender_cycles_samples=int(raw["blender_cycles_samples"]),
@@ -241,6 +249,19 @@ def _settings_to_out(raw: dict[str, str]) -> SettingsOut:
) )
@router.get("/branding")
async def get_branding(db: AsyncSession = Depends(get_db)):
"""Public endpoint — returns configured app name and subtitle without authentication."""
result = await db.execute(
select(SystemSetting).where(SystemSetting.key.in_(["app_name", "app_subtitle"]))
)
rows = {row.key: row.value for row in result.scalars().all()}
return {
"app_name": rows.get("app_name", "Hart.O.Mat"),
"app_subtitle": rows.get("app_subtitle", "Hartomatisierung"),
}
@router.get("/settings", response_model=SettingsOut) @router.get("/settings", response_model=SettingsOut)
async def get_settings( async def get_settings(
admin: User = Depends(require_global_admin), admin: User = Depends(require_global_admin),
@@ -255,6 +276,14 @@ async def update_settings(
admin: User = Depends(require_global_admin), admin: User = Depends(require_global_admin),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
if body.app_name is not None:
stripped = body.app_name.strip()
if not stripped:
raise HTTPException(400, detail="app_name cannot be empty")
if len(stripped) > 100:
raise HTTPException(400, detail="app_name must be 100 characters or fewer")
if body.app_subtitle is not None and len(body.app_subtitle) > 100:
raise HTTPException(400, detail="app_subtitle must be 100 characters or fewer")
if body.thumbnail_renderer is not None and body.thumbnail_renderer not in VALID_RENDERERS: if body.thumbnail_renderer is not None and body.thumbnail_renderer not in VALID_RENDERERS:
raise HTTPException(400, detail=f"Invalid renderer. Choose: {', '.join(sorted(VALID_RENDERERS))}") raise HTTPException(400, detail=f"Invalid renderer. Choose: {', '.join(sorted(VALID_RENDERERS))}")
if body.blender_engine is not None and body.blender_engine not in VALID_ENGINES: if body.blender_engine is not None and body.blender_engine not in VALID_ENGINES:
@@ -292,6 +321,10 @@ async def update_settings(
raise HTTPException(400, detail=f"Output type '{entry}' not found") raise HTTPException(400, detail=f"Output type '{entry}' not found")
updates: dict[str, str] = {} updates: dict[str, str] = {}
if body.app_name is not None:
updates["app_name"] = body.app_name.strip()
if body.app_subtitle is not None:
updates["app_subtitle"] = body.app_subtitle.strip()
if body.thumbnail_renderer is not None: if body.thumbnail_renderer is not None:
updates["thumbnail_renderer"] = body.thumbnail_renderer updates["thumbnail_renderer"] = body.thumbnail_renderer
if body.blender_engine is not None: if body.blender_engine is not None:
+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 { listOrders } from '../../api/orders'
import NotificationCenter from './NotificationCenter' import NotificationCenter from './NotificationCenter'
import ChatPanel from '../chat/ChatPanel' import ChatPanel from '../chat/ChatPanel'
import { useBranding } from '../../hooks/useBranding'
const nav = [ const nav = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', end: true }, { to: '/', icon: LayoutDashboard, label: 'Dashboard', end: true },
@@ -39,6 +40,7 @@ export default function Layout() {
const location = useLocation() const location = useLocation()
const [sidebarOpen, setSidebarOpen] = useState(false) const [sidebarOpen, setSidebarOpen] = useState(false)
const [chatOpen, setChatOpen] = useState(false) const [chatOpen, setChatOpen] = useState(false)
const { appName, appSubtitle } = useBranding()
// Extract page context from URL for the chat agent // Extract page context from URL for the chat agent
const chatContext = (() => { const chatContext = (() => {
@@ -81,7 +83,7 @@ export default function Layout() {
> >
<Menu size={20} /> <Menu size={20} />
</button> </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 /> <NotificationCenter />
</header> </header>
@@ -108,8 +110,8 @@ export default function Layout() {
<span className="text-accent-text text-sm font-bold">H</span> <span className="text-accent-text text-sm font-bold">H</span>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<p className="font-semibold text-content text-sm">Hart.O.Mat</p> <p className="font-semibold text-content text-sm">{appName}</p>
<p className="text-xs text-content-muted">Hartomatisierung</p> <p className="text-xs text-content-muted">{appSubtitle}</p>
</div> </div>
{/* NotificationCenter in sidebar header (desktop); hidden on mobile (shown in top bar) */} {/* NotificationCenter in sidebar header (desktop); hidden on mobile (shown in top bar) */}
<span className="hidden md:block"> <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 = { type Settings = {
app_name: string
app_subtitle: string
thumbnail_renderer: string thumbnail_renderer: string
blender_engine: string blender_engine: string
blender_cycles_samples: number blender_cycles_samples: number
@@ -117,6 +119,9 @@ export default function AdminPage() {
enabled: isAdmin, 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 // Local draft for Blender options so the user can change multiple fields before saving
const [blenderDraft, setBlenderDraft] = useState<Partial<Settings>>({}) const [blenderDraft, setBlenderDraft] = useState<Partial<Settings>>({})
const blender = { ...settings, ...blenderDraft } as Settings const blender = { ...settings, ...blenderDraft } as Settings
@@ -130,9 +135,12 @@ export default function AdminPage() {
const updateSettingsMut = useMutation({ const updateSettingsMut = useMutation({
mutationFn: (data: Partial<Settings>) => api.put('/admin/settings', data), mutationFn: (data: Partial<Settings>) => api.put('/admin/settings', data),
onSuccess: () => { onSuccess: (_data, variables) => {
toast.success('Settings saved') toast.success('Settings saved')
qc.invalidateQueries({ queryKey: ['admin-settings'] }) qc.invalidateQueries({ queryKey: ['admin-settings'] })
if ('app_name' in variables || 'app_subtitle' in variables) {
qc.invalidateQueries({ queryKey: ['branding'] })
}
setBlenderDraft({}) setBlenderDraft({})
}, },
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), 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') const [activeTab, setActiveTab] = useState<AdminTab>('overview')
// Blender Status (via Celery on render-worker) // Blender Status (via Celery on render-worker)
@@ -324,6 +332,7 @@ export default function AdminPage() {
}) })
const hasUnsavedChanges = const hasUnsavedChanges =
Object.keys(brandingDraft).length > 0 ||
Object.keys(blenderDraft).length > 0 || Object.keys(blenderDraft).length > 0 ||
Object.keys(viewerDraft).length > 0 || Object.keys(viewerDraft).length > 0 ||
Object.keys(tessellationDraft).length > 0 || Object.keys(tessellationDraft).length > 0 ||
@@ -332,6 +341,7 @@ export default function AdminPage() {
const TABS: { id: AdminTab; label: string }[] = [ const TABS: { id: AdminTab; label: string }[] = [
{ id: 'overview', label: 'Overview' }, { id: 'overview', label: 'Overview' },
{ id: 'users', label: 'Users' }, { id: 'users', label: 'Users' },
{ id: 'branding', label: 'Branding' },
{ id: 'render-settings', label: 'Render Settings' }, { id: 'render-settings', label: 'Render Settings' },
{ id: 'output-types', label: 'Output Types' }, { id: 'output-types', label: 'Output Types' },
{ id: 'templates', label: 'Templates & Positions' }, { id: 'templates', label: 'Templates & Positions' },
@@ -533,6 +543,77 @@ export default function AdminPage() {
</div> </div>
</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 */} {/* Render Settings */}
{/* ================================================================== */} {/* ================================================================== */}
+3 -1
View File
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
import { Eye, EyeOff } from 'lucide-react' import { Eye, EyeOff } from 'lucide-react'
import api from '../api/client' import api from '../api/client'
import { useAuthStore } from '../store/auth' import { useAuthStore } from '../store/auth'
import { useBranding } from '../hooks/useBranding'
export default function LoginPage() { export default function LoginPage() {
const navigate = useNavigate() const navigate = useNavigate()
@@ -13,6 +14,7 @@ export default function LoginPage() {
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const { appName } = useBranding()
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault() 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"> <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> <span className="text-white text-2xl font-bold">S</span>
</div> </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> <p className="text-content-muted text-sm mt-1">Media Creation Pipeline</p>
</div> </div>