From 4946cc300a65db341d31ff010f525a25a5e9c06d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 22 Jul 2026 11:02:38 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20editable=20branding=20=E2=80=94=20app?= =?UTF-8?q?=20name=20and=20subtitle=20configurable=20in=20Admin=20Settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/routers/admin.py | 33 +++++++++ frontend/src/api/branding.ts | 11 +++ frontend/src/components/layout/Layout.tsx | 8 ++- frontend/src/hooks/useBranding.ts | 15 ++++ frontend/src/pages/Admin.tsx | 85 ++++++++++++++++++++++- frontend/src/pages/Login.tsx | 4 +- 6 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 frontend/src/api/branding.ts create mode 100644 frontend/src/hooks/useBranding.ts diff --git a/backend/app/api/routers/admin.py b/backend/app/api/routers/admin.py index 000af14..6dfd7c0 100644 --- a/backend/app/api/routers/admin.py +++ b/backend/app/api/routers/admin.py @@ -23,6 +23,8 @@ VALID_ENGINES = {"cycles", "eevee"} VALID_FORMATS = {"jpg", "png"} VALID_CYCLES_DEVICES = {"auto", "gpu", "cpu"} SETTINGS_DEFAULTS: dict[str, str] = { + "app_name": "Hart.O.Mat", + "app_subtitle": "Hartomatisierung", "thumbnail_renderer": "blender", "blender_engine": "cycles", "blender_cycles_samples": "256", @@ -59,6 +61,8 @@ SETTINGS_DEFAULTS: dict[str, str] = { class SettingsOut(BaseModel): + app_name: str = "Hart.O.Mat" + app_subtitle: str = "Hartomatisierung" thumbnail_renderer: str = "blender" blender_engine: str = "cycles" blender_cycles_samples: int = 256 @@ -91,6 +95,8 @@ class SettingsOut(BaseModel): class SettingsUpdate(BaseModel): + app_name: str | None = None + app_subtitle: str | None = None thumbnail_renderer: str | None = None blender_engine: str | 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: return SettingsOut( + app_name=raw.get("app_name", "Hart.O.Mat"), + app_subtitle=raw.get("app_subtitle", "Hartomatisierung"), thumbnail_renderer=raw["thumbnail_renderer"], blender_engine=raw["blender_engine"], 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) async def get_settings( admin: User = Depends(require_global_admin), @@ -255,6 +276,14 @@ async def update_settings( admin: User = Depends(require_global_admin), 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: 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: @@ -292,6 +321,10 @@ async def update_settings( raise HTTPException(400, detail=f"Output type '{entry}' not found") 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: updates["thumbnail_renderer"] = body.thumbnail_renderer if body.blender_engine is not None: diff --git a/frontend/src/api/branding.ts b/frontend/src/api/branding.ts new file mode 100644 index 0000000..f72492c --- /dev/null +++ b/frontend/src/api/branding.ts @@ -0,0 +1,11 @@ +import api from './client' + +export interface BrandingSettings { + app_name: string + app_subtitle: string +} + +export async function fetchBranding(): Promise { + const { data } = await api.get('/admin/branding') + return data +} diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index 223c0b8..da3c3b4 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -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() { > - Hart.O.Mat + {appName} @@ -108,8 +110,8 @@ export default function Layout() { H
-

Hart.O.Mat

-

Hartomatisierung

+

{appName}

+

{appSubtitle}

{/* NotificationCenter in sidebar header (desktop); hidden on mobile (shown in top bar) */} diff --git a/frontend/src/hooks/useBranding.ts b/frontend/src/hooks/useBranding.ts new file mode 100644 index 0000000..27c8289 --- /dev/null +++ b/frontend/src/hooks/useBranding.ts @@ -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', + } +} diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index ee21479..f87d06a 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -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>({}) + const branding = { ...settings, ...brandingDraft } as Settings + // Local draft for Blender options so the user can change multiple fields before saving const [blenderDraft, setBlenderDraft] = useState>({}) const blender = { ...settings, ...blenderDraft } as Settings @@ -130,9 +135,12 @@ export default function AdminPage() { const updateSettingsMut = useMutation({ mutationFn: (data: Partial) => 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('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() { } + {/* ================================================================== */} + {/* Branding */} + {/* ================================================================== */} + {activeTab === 'branding' && isAdmin && ( +
+
+
+ +

App Branding

+
+
+
+ + setBrandingDraft((d) => ({ ...d, app_name: e.target.value }))} + maxLength={100} + className="input-base w-full" + placeholder="Hart.O.Mat" + /> +

+ Shown in the sidebar, mobile header, and login page. +

+
+
+ + setBrandingDraft((d) => ({ ...d, app_subtitle: e.target.value }))} + maxLength={100} + className="input-base w-full" + placeholder="Hartomatisierung" + /> +

+ Shown below the app name in the sidebar. +

+
+
+
+ + {Object.keys(brandingDraft).length > 0 && ( + + )} +
+
+
+ )} + {/* ================================================================== */} {/* Render Settings */} {/* ================================================================== */} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 412d77a..fcc3269 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -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() {
S
-

HartOMat

+

{appName}

Media Creation Pipeline