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