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:
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 */}
|
||||||
{/* ================================================================== */}
|
{/* ================================================================== */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user