Files
HartOMat/frontend/src/pages/Admin.tsx
T
Hartmut ec667dd56a refactor: remove dead export_gltf.py, cleanup rendering tasks, improve tessellation UI
- Remove export_gltf.py (Blender-based GLB export replaced by OCC direct)
- Remove unused export_gltf_for_order_line_task
- Add Ultra tessellation preset to Admin settings
- Improve tessellation preset descriptions and styling
- Minor cleanup across media, rendering, and workflow modules

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:37:35 +01:00

2071 lines
99 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState, useRef } from 'react'
import { toast } from 'sonner'
import { UserPlus, Trash2, Pencil, ChevronDown, ChevronUp, ChevronRight, Settings, RefreshCw, CheckCircle2, XCircle, Clock, DollarSign, Layers, AlertTriangle, Upload, FileBox, Plus, X, LayoutDashboard, Cpu, Zap, AlertCircle } from 'lucide-react'
import { Link } from 'react-router-dom'
import api from '../api/client'
import ConfirmModal from '../components/ConfirmModal'
import HelpTooltip from '../components/HelpTooltip'
import TemplateEditor from '../components/admin/TemplateEditor'
import PricingTierTable from '../components/admin/PricingTierTable'
import OutputTypeTable from '../components/admin/OutputTypeTable'
import RenderTemplateTable from '../components/admin/RenderTemplateTable'
import GlobalRenderPositionsPanel from '../components/admin/GlobalRenderPositionsPanel'
import { useAuthStore, isAdmin as checkIsAdmin } from '../store/auth'
import { listPricingTiers } from '../api/pricing'
import { listOutputTypes } from '../api/outputTypes'
import {
listAssetLibraries, createAssetLibrary, deleteAssetLibrary, refreshAssetLibraryCatalog,
type AssetLibrary,
} from '../api/assetLibraries'
import { getTenantDefaultDashboard } from '../api/dashboard'
import type { WidgetConfig } from '../api/dashboard'
import DashboardCustomizeModal from '../components/dashboard/DashboardCustomizeModal'
import { getGpuProbeResult, triggerGpuProbe } from '../api/worker'
import type { GPUProbeResult } from '../api/worker'
export default function AdminPage() {
const qc = useQueryClient()
const user = useAuthStore((s) => s.user)
const isAdmin = checkIsAdmin(user)
const [showNewUser, setShowNewUser] = useState(false)
const [newUser, setNewUser] = useState({ email: '', password: '', full_name: '', role: 'client' })
const [editingUserId, setEditingUserId] = useState<string | null>(null)
const [editUserDraft, setEditUserDraft] = useState<{ full_name: string; role: string; is_active: boolean }>({ full_name: '', role: 'client', is_active: true })
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null)
const [priorityNewEntry, setPriorityNewEntry] = useState<string>('')
const { data: users } = useQuery({
queryKey: ['admin-users'],
queryFn: async () => {
const res = await api.get('/admin/users')
return res.data as any[]
},
})
const { data: templates } = useQuery({
queryKey: ['admin-templates'],
queryFn: async () => {
const res = await api.get('/templates?include_inactive=true')
return res.data as any[]
},
})
const createUserMut = useMutation({
mutationFn: (data: typeof newUser) => api.post('/admin/users', data),
onSuccess: () => {
toast.success('User created')
qc.invalidateQueries({ queryKey: ['admin-users'] })
setShowNewUser(false)
setNewUser({ email: '', password: '', full_name: '', role: 'client' })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const deleteUserMut = useMutation({
mutationFn: (id: string) => api.delete(`/admin/users/${id}`),
onSuccess: () => {
toast.success('User deleted')
qc.invalidateQueries({ queryKey: ['admin-users'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const updateUserMut = useMutation({
mutationFn: ({ id, data }: { id: string; data: { full_name: string; role: string; is_active: boolean } }) =>
api.patch(`/admin/users/${id}`, data),
onSuccess: () => {
toast.success('User updated')
qc.invalidateQueries({ queryKey: ['admin-users'] })
setEditingUserId(null)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
type Settings = {
thumbnail_renderer: string
blender_engine: string
blender_cycles_samples: number
blender_eevee_samples: number
threejs_render_size: number
thumbnail_format: string
blender_smooth_angle: number
cycles_device: string
blender_max_concurrent_renders: number
render_stall_timeout_minutes: number
product_thumbnail_priority: string // JSON array
render_backend: string
smtp_enabled: boolean
smtp_host: string
smtp_port: number
smtp_user: string
smtp_password: string
smtp_from_address: string
gltf_scale_factor: number
gltf_smooth_normals: boolean
viewer_max_distance: number
viewer_min_distance: number
gltf_material_quality: string
gltf_pbr_roughness: number
gltf_pbr_metallic: number
scene_linear_deflection: number
scene_angular_deflection: number
render_linear_deflection: number
render_angular_deflection: number
tessellation_engine: string
}
const { data: settings } = useQuery({
queryKey: ['admin-settings'],
queryFn: async () => {
const res = await api.get('/admin/settings')
return res.data as Settings
},
})
const { data: outputTypes } = useQuery({
queryKey: ['output-types-all'],
queryFn: () => listOutputTypes(false),
enabled: isAdmin,
})
// 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
const [viewerDraft, setViewerDraft] = useState<Partial<Settings>>({})
const viewer3d = { ...settings, ...viewerDraft } as Settings
const [tessellationDraft, setTessellationDraft] = useState<Partial<Settings>>({})
const tess = { ...settings, ...tessellationDraft } as Settings
const [showAdvancedTess, setShowAdvancedTess] = useState(false)
const { data: rendererStatus, refetch: refetchStatus } = useQuery({
queryKey: ['renderer-status'],
queryFn: async () => {
const res = await api.get('/admin/settings/renderer-status')
return res.data as Record<string, { available: boolean; note: string; url: string | null }>
},
refetchInterval: 30000,
})
const updateSettingsMut = useMutation({
mutationFn: (data: Partial<Settings>) => api.put('/admin/settings', data),
onSuccess: () => {
toast.success('Settings saved')
qc.invalidateQueries({ queryKey: ['admin-settings'] })
setBlenderDraft({})
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const processUnprocessedMut = useMutation({
mutationFn: () => api.post('/admin/settings/process-unprocessed'),
onSuccess: (res) => {
toast.success(res.data.message || 'Processing queued')
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const regenerateMut = useMutation({
mutationFn: () => api.post('/admin/settings/regenerate-thumbnails'),
onSuccess: (res) => {
toast.success(res.data.message || 'Thumbnails re-queued')
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const importMediaAssetsMut = useMutation({
mutationFn: () => api.post('/admin/import-media-assets'),
onSuccess: (res) => {
toast.success(`Imported: ${res.data.created} created, ${res.data.skipped} skipped`)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Import failed'),
})
const cleanupOrphanedMut = useMutation({
mutationFn: () => api.post('/media/cleanup-orphaned'),
onSuccess: (res) => {
toast.success(`Cleanup done: ${res.data.deleted} orphaned records deleted (${res.data.checked} checked)`)
qc.invalidateQueries({ queryKey: ['media-browser'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Cleanup failed'),
})
const reextractMetadataMut = useMutation({
mutationFn: () => api.post('/admin/settings/reextract-metadata'),
onSuccess: (res) => {
toast.success(res.data.message || 'Metadata re-extraction queued')
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const cleanupOrphanedCadMut = useMutation({
mutationFn: () => api.post('/admin/settings/cleanup-orphaned-cad-files'),
onSuccess: (res) => {
toast.success(`Deleted ${res.data.deleted_records} orphaned CAD records, freed ${res.data.freed_mb} MB`)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Cleanup failed'),
})
const recoverStuckMut = useMutation({
mutationFn: () => api.post('/admin/settings/recover-stuck-processing'),
onSuccess: (res) => {
toast.success(res.data.message || 'Stuck files recovered')
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const seedWorkflowsMut = useMutation({
mutationFn: () => api.post('/admin/settings/seed-workflows'),
onSuccess: (res) => {
toast.success(res.data.message || 'Workflows seeded')
qc.invalidateQueries({ queryKey: ['workflows'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const generateMissingUsdMastersMut = useMutation({
mutationFn: () => api.post('/admin/settings/generate-missing-usd-masters'),
onSuccess: (res) => toast.success(res.data.message || 'USD master export queued'),
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const generateMissingCanonicalScenesMut = useMutation({
mutationFn: () => api.post('/admin/settings/generate-missing-canonical-scenes'),
onSuccess: (res) => toast.success(res.data.message || 'Canonical scene export queued'),
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const [smtpDraft, setSmtpDraft] = useState<Partial<Settings>>({})
const smtp = { ...settings, ...smtpDraft } as Settings
const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} })
const [showTenantDashboardModal, setShowTenantDashboardModal] = useState(false)
const { data: tenantDefaultWidgets } = useQuery<WidgetConfig[]>({
queryKey: ['tenant-default-dashboard'],
queryFn: getTenantDefaultDashboard,
enabled: isAdmin,
staleTime: 300_000,
})
// GPU Probe
const [gpuProbeExpanded, setGpuProbeExpanded] = useState(false)
const [gpuProbing, setGpuProbing] = useState(false)
const gpuPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const { data: gpuProbeResult, refetch: refetchGpuProbe } = useQuery<GPUProbeResult>({
queryKey: ['gpu-probe-result'],
queryFn: getGpuProbeResult,
enabled: isAdmin,
refetchInterval: gpuProbing ? 2000 : false,
staleTime: 0,
})
const handleRunGpuCheck = async () => {
if (!isAdmin) return
setGpuProbing(true)
try {
await triggerGpuProbe()
// Poll for up to 45 seconds
let elapsed = 0
const interval = setInterval(async () => {
elapsed += 2
await refetchGpuProbe()
if (elapsed >= 45) {
clearInterval(interval)
setGpuProbing(false)
}
}, 2000)
gpuPollRef.current = interval
} catch {
toast.error('Failed to trigger GPU check')
setGpuProbing(false)
}
}
const gpuStatusBadge = () => {
if (!gpuProbeResult) return null
const s = gpuProbeResult.status
if (s === 'ok') {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-status-success-bg text-status-success-text">
<CheckCircle2 size={11} />
GPU OK{gpuProbeResult.device_type ? ` (${gpuProbeResult.device_type})` : ''}
</span>
)
}
if (s === 'failed') {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
<Cpu size={11} />
CPU Fallback
</span>
)
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-status-error-bg text-status-error-text">
<XCircle size={11} />
{s === 'error' ? 'Error' : 'Unknown'}
</span>
)
}
type AdminTab = 'overview' | 'users' | 'render' | 'pricing' | 'libraries' | 'config'
const [activeTab, setActiveTab] = useState<AdminTab>('overview')
const hasUnsavedChanges =
Object.keys(blenderDraft).length > 0 ||
Object.keys(viewerDraft).length > 0 ||
Object.keys(tessellationDraft).length > 0 ||
Object.keys(smtpDraft).length > 0
const TABS: { id: AdminTab; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'users', label: 'Users' },
{ id: 'render', label: 'Render' },
{ id: 'pricing', label: 'Pricing' },
{ id: 'libraries', label: 'Libraries' },
{ id: 'config', label: 'Config' },
]
return (
<div>
{/* Tab header */}
<div className="px-8 pt-6 pb-0 bg-surface border-b border-border-default sticky top-0 z-10">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold text-content">Admin</h1>
{hasUnsavedChanges && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-amber-50 border border-amber-200 rounded-lg text-amber-700 text-sm dark:bg-amber-950 dark:border-amber-800 dark:text-amber-300">
<AlertCircle size={14} />
Unsaved changes
</div>
)}
</div>
<div className="flex gap-1 -mb-px">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-accent text-accent'
: 'border-transparent text-content-secondary hover:text-content hover:border-border-default'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
<div className="p-8 space-y-8">
{/* ------------------------------------------------------------------ */}
{/* Pricing Summary */}
{/* ------------------------------------------------------------------ */}
{activeTab === 'overview' && <PricingSummaryCard />}
{/* ------------------------------------------------------------------ */}
{/* Users (admin only) */}
{/* ------------------------------------------------------------------ */}
{activeTab === 'users' && isAdmin && <div className="card">
<div className="p-4 border-b border-border-default flex items-center justify-between">
<h2 className="font-semibold text-content">Users</h2>
<button onClick={() => setShowNewUser(!showNewUser)} className="btn-primary">
<UserPlus size={16} />New User
</button>
</div>
{showNewUser && (
<div className="p-4 border-b border-border-light bg-surface-alt">
<div className="grid grid-cols-2 gap-3 mb-3">
<input
placeholder="Full name"
value={newUser.full_name}
onChange={(e) => setNewUser({ ...newUser, full_name: e.target.value })}
className="px-3 py-2 border border-border-default rounded-md text-sm"
/>
<input
placeholder="Email"
type="email"
value={newUser.email}
onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
className="px-3 py-2 border border-border-default rounded-md text-sm"
/>
<input
placeholder="Password"
type="password"
value={newUser.password}
onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
className="px-3 py-2 border border-border-default rounded-md text-sm"
/>
<select
value={newUser.role}
onChange={(e) => setNewUser({ ...newUser, role: e.target.value })}
className="px-3 py-2 border border-border-default rounded-md text-sm"
>
<option value="client">Client</option>
<option value="project_manager">Project Manager</option>
<option value="admin">Admin</option>
</select>
</div>
<button
onClick={() => createUserMut.mutate(newUser)}
disabled={createUserMut.isPending}
className="btn-primary"
>
{createUserMut.isPending ? 'Creating...' : 'Create User'}
</button>
</div>
)}
<div className="divide-y divide-border-light">
{users?.map((u) => (
<div key={u.id}>
{editingUserId === u.id ? (
<div className="px-6 py-3 bg-surface-alt space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-content-muted block mb-1">Full name</label>
<input
value={editUserDraft.full_name}
onChange={(e) => setEditUserDraft((d) => ({ ...d, full_name: e.target.value }))}
className="px-3 py-1.5 border border-border-default rounded-md text-sm w-full"
/>
</div>
<div>
<label className="text-xs text-content-muted block mb-1">Role</label>
<select
value={editUserDraft.role}
onChange={(e) => setEditUserDraft((d) => ({ ...d, role: e.target.value }))}
className="px-3 py-1.5 border border-border-default rounded-md text-sm w-full"
>
<option value="client">Client</option>
<option value="project_manager">Project Manager</option>
<option value="admin">Admin</option>
<option value="global_admin">Global Admin</option>
<option value="tenant_admin">Tenant Admin</option>
</select>
</div>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-content cursor-pointer">
<input
type="checkbox"
checked={editUserDraft.is_active}
onChange={(e) => setEditUserDraft((d) => ({ ...d, is_active: e.target.checked }))}
className="rounded"
/>
Active
</label>
<div className="flex gap-2 ml-auto">
<button
onClick={() => setEditingUserId(null)}
className="btn-secondary text-sm"
>
Cancel
</button>
<button
onClick={() => updateUserMut.mutate({ id: u.id, data: editUserDraft })}
disabled={updateUserMut.isPending}
className="btn-primary text-sm"
>
{updateUserMut.isPending ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
) : (
<div className="flex items-center px-6 py-3">
<div className="flex-1">
<p className="text-sm font-medium text-content">{u.full_name}</p>
<p className="text-xs text-content-muted">{u.email}</p>
</div>
<span className={`badge mr-4 ${checkIsAdmin(u) ? 'badge-green' : 'badge-gray'}`}>
{u.role}
</span>
<span className={`badge mr-4 ${u.is_active ? 'badge-green' : 'badge-red'}`}>
{u.is_active ? 'active' : 'inactive'}
</span>
<button
onClick={() => {
setEditingUserId(u.id)
setEditUserDraft({ full_name: u.full_name, role: u.role, is_active: u.is_active })
}}
className="text-content-muted hover:text-accent transition-colors mr-3"
title="Edit user"
>
<Pencil size={16} />
</button>
<button
onClick={() => {
setConfirmState({
open: true,
title: 'Delete User',
message: `Delete user "${u.email}"? This cannot be undone.`,
onConfirm: () => {
deleteUserMut.mutate(u.id)
setConfirmState((s) => ({ ...s, open: false }))
},
})
}}
className="text-content-muted hover:text-red-500 transition-colors"
title="Delete user"
>
<Trash2 size={16} />
</button>
</div>
)}
</div>
))}
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Blender Render Settings (admin only) */}
{/* ------------------------------------------------------------------ */}
{activeTab === 'render' && isAdmin && <div className="card">
<div className="p-4 border-b border-border-default flex items-center justify-between">
<div className="flex items-center gap-2">
<Settings size={16} className="text-content-muted" />
<div>
<h2 className="font-semibold text-content">Blender Render Settings</h2>
<p className="text-xs text-content-muted mt-0.5">
Render quality, performance and thumbnail output options for Blender 5.
</p>
</div>
</div>
<button
onClick={() => refetchStatus()}
className="text-content-muted hover:text-content-secondary transition-colors"
title="Refresh service status"
>
<RefreshCw size={15} />
</button>
</div>
<div className="p-6 space-y-6">
{/* ── Render Quality ───────────────────────────────────────────── */}
<div className="space-y-4">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Render Quality</p>
<div className="rounded-lg border border-border-default bg-surface-alt p-4 space-y-4">
{/* Engine */}
<div className="flex items-center gap-6 flex-wrap">
<span className="text-sm font-medium text-content-secondary w-28 shrink-0">Render engine</span>
{(['cycles', 'eevee'] as const).map((eng) => (
<button
key={eng}
onClick={() => setBlenderDraft((d) => ({ ...d, blender_engine: eng }))}
title={
eng === 'cycles'
? 'Cycles — path-tracing engine; photorealistic results but slower (affected by sample count)'
: 'EEVEE Next — real-time rasteriser; very fast but less physically accurate'
}
className={`px-4 py-1.5 rounded-full border text-sm font-medium transition-colors ${
blender.blender_engine === eng
? 'bg-blue-600 text-white border-blue-600'
: 'bg-surface text-content-secondary border-border-default hover:border-blue-400 hover:text-blue-600'
}`}
>
{eng === 'cycles' ? 'Cycles (ray tracing)' : 'EEVEE Next (real-time)'}
</button>
))}
</div>
{/* Cycles device — only relevant for Cycles */}
{blender.blender_engine === 'cycles' && (
<div className="flex items-center gap-6 flex-wrap">
<span className="text-sm font-medium text-content-secondary w-28 shrink-0">Cycles device</span>
{(['auto', 'gpu', 'cpu'] as const).map((dev) => (
<button
key={dev}
onClick={() => setBlenderDraft((d) => ({ ...d, cycles_device: dev }))}
title={
dev === 'auto'
? 'Auto — tries OptiX / CUDA / HIP first; falls back to CPU if no compatible GPU is found'
: dev === 'gpu'
? 'GPU only — always render on GPU; logs a warning if no compatible GPU is available'
: 'CPU only — always render on CPU; useful for debugging or when the GPU is busy'
}
className={`px-4 py-1.5 rounded-full border text-sm font-medium transition-colors ${
blender.cycles_device === dev
? 'bg-blue-600 text-white border-blue-600'
: 'bg-surface text-content-secondary border-border-default hover:border-blue-400 hover:text-blue-600'
}`}
>
{dev === 'auto' ? 'Auto (GPU → CPU)' : dev === 'gpu' ? 'GPU only' : 'CPU only'}
</button>
))}
<p className="text-xs text-content-muted">
{blender.cycles_device === 'auto'
? 'Tries OptiX / CUDA / HIP, falls back to CPU if no GPU is available.'
: blender.cycles_device === 'gpu'
? 'Always use GPU. Logs a warning if no compatible GPU is found.'
: 'Always use CPU — useful for debugging or when GPU is busy.'}
</p>
</div>
)}
{/* Sample counts */}
<div className="grid grid-cols-2 gap-4 max-w-sm">
<div>
<label className="flex items-center gap-1 text-xs font-medium text-content-secondary mb-1">
Cycles samples
<HelpTooltip helpKey="setting.blender_cycles_samples" position="top" size={12} />
</label>
<input
type="number"
min={1} max={4096} step={32}
value={blender.blender_cycles_samples ?? 256}
onChange={(e) => setBlenderDraft((d) => ({ ...d, blender_cycles_samples: Number(e.target.value) }))}
title="Number of Cycles path-tracing samples (14096). Higher values = better quality + longer render time. Default: 256"
className="w-full px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
/>
<p className="text-xs text-content-muted mt-0.5">Higher = better quality, slower</p>
</div>
<div>
<label className="flex items-center gap-1 text-xs font-medium text-content-secondary mb-1">
EEVEE samples
<HelpTooltip helpKey="setting.blender_eevee_samples" position="top" size={12} />
</label>
<input
type="number"
min={1} max={1024} step={16}
value={blender.blender_eevee_samples ?? 64}
onChange={(e) => setBlenderDraft((d) => ({ ...d, blender_eevee_samples: Number(e.target.value) }))}
title="EEVEE anti-aliasing sample count (11024). Higher values = smoother edges + longer render time. Default: 64"
className="w-full px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
/>
<p className="text-xs text-content-muted mt-0.5">Higher = better AA, slower</p>
</div>
</div>
{/* Smooth by angle */}
<div className="flex items-center gap-4 flex-wrap">
<span className="flex items-center gap-1 text-sm font-medium text-content-secondary w-28 shrink-0">
Smooth angle
<HelpTooltip helpKey="setting.blender_smooth_angle" size={12} />
</span>
<input
type="number"
min={0} max={180} step={5}
value={blender.blender_smooth_angle ?? 30}
onChange={(e) => setBlenderDraft((d) => ({ ...d, blender_smooth_angle: Number(e.target.value) }))}
title="Auto-smooth angle in degrees (0180°). Faces with dihedral angles below this threshold are shaded smooth; sharper edges stay hard. 30° works well for most mechanical parts."
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
/>
<span className="text-sm text-content-muted">°</span>
<p className="text-xs text-content-muted">
{(blender.blender_smooth_angle ?? 30) === 0
? '0° = flat shading on all faces.'
: `Faces with edges sharper than ${blender.blender_smooth_angle ?? 30}° stay hard; others smooth. 30° works well for most mechanical parts.`}
</p>
</div>
</div>
</div>
{/* ── Performance ──────────────────────────────────────────────── */}
<div className="space-y-4">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Performance</p>
<div className="rounded-lg border border-border-default bg-surface-alt p-4 space-y-4">
<div className="flex items-center gap-4 flex-wrap">
<span className="text-sm font-medium text-content-secondary w-28 shrink-0">Max concurrent</span>
<input
type="number"
min={1} max={16} step={1}
value={blender.blender_max_concurrent_renders ?? 3}
onChange={(e) => setBlenderDraft((d) => ({ ...d, blender_max_concurrent_renders: Number(e.target.value) }))}
title="Maximum parallel Blender render jobs (116). Each job uses ~400 MB RAM. Applied live without restart. Default: 3"
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
/>
<p className="text-xs text-content-muted">
Max parallel Blender render jobs (116). Higher values use more RAM (~400 MB each). Applied live without restart.
</p>
</div>
<div className="flex items-center gap-4 flex-wrap">
<span className="text-sm font-medium text-content-secondary w-28 shrink-0">Stall timeout</span>
<input
type="number"
min={10} max={10080} step={10}
value={blender.render_stall_timeout_minutes ?? 120}
onChange={(e) => setBlenderDraft((d) => ({ ...d, render_stall_timeout_minutes: Number(e.target.value) }))}
title="Minutes before a stuck render job is automatically restarted (1010080). The watchdog checks every 5 minutes. Default: 120"
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
/>
<p className="text-xs text-content-muted">
Minutes before a stuck render job is auto-restarted (1010080). Checked every 5 min by the watchdog.
</p>
</div>
</div>
</div>
{/* Save button — appears when draft has unsaved changes */}
{Object.keys(blenderDraft).length > 0 && (
<button
onClick={() => updateSettingsMut.mutate(blenderDraft)}
disabled={updateSettingsMut.isPending}
className="btn-primary text-sm"
>
{updateSettingsMut.isPending ? 'Saving…' : 'Save Settings'}
</button>
)}
{/* ── Output ───────────────────────────────────────────────────── */}
<div className="space-y-4">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Output</p>
<div className="flex items-center gap-4 flex-wrap">
<label className="text-sm font-medium text-content-secondary shrink-0 w-28">Thumbnail format</label>
{(['jpg', 'png'] as const).map((fmt) => (
<button
key={fmt}
onClick={() => updateSettingsMut.mutate({ thumbnail_format: fmt })}
disabled={updateSettingsMut.isPending}
className={`px-4 py-1.5 rounded-full border text-sm font-medium transition-colors ${
settings?.thumbnail_format === fmt
? 'text-white'
: 'bg-surface text-content-secondary border-border-default hover:border-accent hover:text-accent'
}`}
style={settings?.thumbnail_format === fmt ? { backgroundColor: 'var(--color-accent)', borderColor: 'var(--color-accent)' } : undefined}
>
{fmt === 'jpg' ? 'JPEG (smaller)' : 'PNG (lossless)'}
</button>
))}
<p className="text-xs text-content-muted">
{settings?.thumbnail_format === 'jpg'
? 'JPEG — ~35× smaller files, minimal quality loss at 92% quality.'
: 'PNG — lossless, larger files.'}
</p>
</div>
{/* Product thumbnail priority chain */}
{(() => {
let priorityList: string[] = ['latest_render', 'cad_thumbnail']
try {
const parsed = JSON.parse(settings?.product_thumbnail_priority ?? '["latest_render","cad_thumbnail"]')
if (Array.isArray(parsed)) priorityList = parsed
} catch {}
const savePriority = (list: string[]) => {
updateSettingsMut.mutate({ product_thumbnail_priority: JSON.stringify(list) } as any)
}
const moveUp = (i: number) => {
if (i === 0) return
const next = [...priorityList]
;[next[i - 1], next[i]] = [next[i], next[i - 1]]
savePriority(next)
}
const moveDown = (i: number) => {
if (i === priorityList.length - 1) return
const next = [...priorityList]
;[next[i], next[i + 1]] = [next[i + 1], next[i]]
savePriority(next)
}
const remove = (i: number) => savePriority(priorityList.filter((_, j) => j !== i))
const addEntry = () => {
if (!priorityNewEntry || priorityList.includes(priorityNewEntry)) return
savePriority([...priorityList, priorityNewEntry])
setPriorityNewEntry('')
}
const entryLabel = (e: string) =>
e === 'cad_thumbnail' ? 'CAD Thumbnail'
: e === 'latest_render' ? 'Latest Render (any type)'
: outputTypes?.find((ot) => ot.id === e)?.name ?? `Output type …${e.slice(-8)}`
const entryColor = (e: string) =>
e === 'cad_thumbnail' ? 'bg-surface-alt border-border-default text-content-muted'
: e === 'latest_render' ? 'bg-status-info-bg border-border-default text-status-info-text'
: 'bg-status-success-bg border-border-default text-status-success-text'
// Options not yet in the list
const addableOptions = [
...(['latest_render', 'cad_thumbnail'] as string[]).filter((v) => !priorityList.includes(v)),
...(outputTypes ?? []).filter((ot) => !priorityList.includes(ot.id)).map((ot) => ot.id),
]
return (
<div className="flex items-start gap-4 pt-1">
<label className="text-sm font-medium text-content-secondary shrink-0 w-28 pt-1">Product thumbnail:</label>
<div className="flex flex-col gap-2 min-w-[280px]">
{priorityList.map((entry, i) => (
<div
key={entry + i}
className={`flex items-center gap-2 border rounded-lg px-3 py-2 ${entryColor(entry)}`}
>
<span className="text-xs font-mono text-content-muted w-4 shrink-0">{i + 1}</span>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{entryLabel(entry)}</span>
{entry !== 'cad_thumbnail' && entry !== 'latest_render' && (
<span className="text-xs text-content-muted">newest completed render</span>
)}
</div>
<button
disabled={i === 0 || updateSettingsMut.isPending}
onClick={() => moveUp(i)}
className="p-0.5 rounded hover:bg-surface-hover disabled:opacity-30 text-content-muted"
title="Move up"
>
<ChevronUp size={14} />
</button>
<button
disabled={i === priorityList.length - 1 || updateSettingsMut.isPending}
onClick={() => moveDown(i)}
className="p-0.5 rounded hover:bg-surface-hover disabled:opacity-30 text-content-muted"
title="Move down"
>
<ChevronDown size={14} />
</button>
<button
disabled={updateSettingsMut.isPending}
onClick={() => remove(i)}
className="p-0.5 rounded hover:bg-status-error-bg text-content-muted hover:text-red-600"
title="Remove"
>
<X size={14} />
</button>
</div>
))}
{addableOptions.length > 0 && (
<div className="flex items-center gap-2">
<select
value={priorityNewEntry}
onChange={(e) => setPriorityNewEntry(e.target.value)}
className="flex-1 px-2 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-accent"
>
<option value="">Add entry</option>
{addableOptions.map((v) => (
<option key={v} value={v}>{entryLabel(v)}</option>
))}
</select>
<button
disabled={!priorityNewEntry || updateSettingsMut.isPending}
onClick={addEntry}
className="btn-secondary py-1.5 px-3 text-sm flex items-center gap-1 disabled:opacity-40"
>
<Plus size={13} /> Add
</button>
</div>
)}
<p className="text-xs text-content-muted">
Sources are tried top to bottom. For specific output types, the <span className="font-medium">newest completed render</span> of that type is used. "CAD Thumbnail" always matches and stops the search.
</p>
</div>
</div>
)
})()}
</div>{/* end Output */}
{/* ── Service Status ───────────────────────────────────────────── */}
<div className="space-y-3">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Service Status</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{rendererStatus && Object.entries(rendererStatus).map(([name, info]) => (
<div
key={name}
className={`rounded-lg border p-3 flex items-start gap-2.5 ${
info.available ? 'border-border-default bg-status-success-bg' : 'border-border-default bg-surface-alt'
}`}
>
{info.available
? <CheckCircle2 size={16} className="text-green-500 shrink-0 mt-0.5" />
: <XCircle size={16} className="text-content-muted shrink-0 mt-0.5" />
}
<div className="min-w-0">
<p className="text-sm font-semibold text-content capitalize">{name}</p>
<p className="text-xs text-content-muted truncate">{info.note || (info.available ? 'Online' : 'Offline')}</p>
</div>
</div>
))}
{!rendererStatus && (
<div className="flex items-center gap-2 text-xs text-content-muted p-2">
<Clock size={13} /> Checking service status
</div>
)}
</div>
</div>
{/* ── Maintenance ──────────────────────────────────────────────── */}
<div className="space-y-3">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Maintenance</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex flex-col gap-1">
<button
onClick={() => recoverStuckMut.mutate()}
disabled={recoverStuckMut.isPending}
className="btn-secondary text-sm w-full justify-start border-amber-400/40 text-amber-600 hover:bg-amber-50"
title="Reset CAD files stuck in 'processing' for more than 10 minutes to 'failed'. Runs automatically every 5 min."
>
<RefreshCw size={14} className={recoverStuckMut.isPending ? 'animate-spin' : ''} />
{recoverStuckMut.isPending ? 'Recovering…' : 'Recover Stuck Processing'}
</button>
<p className="text-xs text-content-muted">Resets files stuck in 'processing' to 'failed'. Runs automatically every 5 min.</p>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5">
<button
onClick={() => processUnprocessedMut.mutate()}
disabled={processUnprocessedMut.isPending}
className="btn-secondary text-sm flex-1 justify-start"
title="Queue all pending and failed STEP files that have never been successfully processed"
>
<RefreshCw size={14} className={processUnprocessedMut.isPending ? 'animate-spin' : ''} />
{processUnprocessedMut.isPending ? 'Queueing…' : 'Process Unprocessed'}
</button>
<HelpTooltip helpKey="action.process_unprocessed" position="left" />
</div>
<p className="text-xs text-content-muted">Queues all pending/failed STEP files for initial processing.</p>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5">
<button
onClick={() => regenerateMut.mutate()}
disabled={regenerateMut.isPending}
className="btn-secondary text-sm flex-1 justify-start"
title="Re-render thumbnails for all completed CAD files using the current Blender settings"
>
<RefreshCw size={14} className={regenerateMut.isPending ? 'animate-spin' : ''} />
{regenerateMut.isPending ? 'Re-queuing…' : 'Regenerate All Thumbnails'}
</button>
<HelpTooltip helpKey="action.regenerate_thumbnails" position="left" />
</div>
<p className="text-xs text-content-muted">Re-renders thumbnails for all completed CAD files.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => generateMissingUsdMastersMut.mutate()}
disabled={generateMissingUsdMastersMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Queue USD master export for all completed CAD files without a USD master asset"
>
<RefreshCw size={14} className={generateMissingUsdMastersMut.isPending ? 'animate-spin' : ''} />
{generateMissingUsdMastersMut.isPending ? 'Queueing…' : 'Generate Missing USD Masters'}
</button>
<p className="text-xs text-content-muted">Exports USD canonical scene for all completed CAD files missing one.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => generateMissingCanonicalScenesMut.mutate()}
disabled={generateMissingCanonicalScenesMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Queue geometry GLB + USD master export for all completed CAD files without a geometry GLB"
>
<RefreshCw size={14} className={generateMissingCanonicalScenesMut.isPending ? 'animate-spin' : ''} />
{generateMissingCanonicalScenesMut.isPending ? 'Queueing…' : 'Generate Missing Canonical Scenes'}
</button>
<p className="text-xs text-content-muted">Queues geometry GLB + USD master for all completed CAD files missing a canonical scene.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => importMediaAssetsMut.mutate()}
disabled={importMediaAssetsMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Create MediaAsset records for all existing CAD thumbnails and order line renders"
>
<RefreshCw size={14} className={importMediaAssetsMut.isPending ? 'animate-spin' : ''} />
{importMediaAssetsMut.isPending ? 'Importing…' : 'Import Existing Media'}
</button>
<p className="text-xs text-content-muted">Registers existing renders &amp; CAD thumbnails in the Media Browser.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => cleanupOrphanedMut.mutate()}
disabled={cleanupOrphanedMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Find and delete all MediaAsset DB records whose backing file is missing on disk"
>
<Trash2 size={14} className={cleanupOrphanedMut.isPending ? 'animate-spin' : ''} />
{cleanupOrphanedMut.isPending ? 'Checking files…' : 'Clean Up Orphaned Media'}
</button>
<p className="text-xs text-content-muted">Removes DB records for renders whose files no longer exist on disk.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => setConfirmState({
open: true,
title: 'Delete Orphaned STEP Files',
message: 'Delete all orphaned STEP files (not linked to any product)? This cannot be undone.',
onConfirm: () => { cleanupOrphanedCadMut.mutate(); setConfirmState(s => ({ ...s, open: false })) },
})}
disabled={cleanupOrphanedCadMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Delete STEP files and thumbnails that are no longer linked to any product"
>
<Trash2 size={14} className={cleanupOrphanedCadMut.isPending ? 'animate-spin' : ''} />
{cleanupOrphanedCadMut.isPending ? 'Deleting…' : 'Clean Up Orphaned STEP Files'}
</button>
<p className="text-xs text-content-muted">Removes STEP files, thumbnails, and DB records not linked to any product.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => reextractMetadataMut.mutate()}
disabled={reextractMetadataMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Re-extract OCC bounding box and sharp-edge data for all completed CAD files"
>
<RefreshCw size={14} className={reextractMetadataMut.isPending ? 'animate-spin' : ''} />
{reextractMetadataMut.isPending ? 'Queueing…' : 'Re-extract CAD Metadata'}
</button>
<p className="text-xs text-content-muted">Updates dimensions and edge data for existing files (no re-render).</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => seedWorkflowsMut.mutate()}
disabled={seedWorkflowsMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Create standard workflow definitions (Still Cycles/EEVEE, Turntable, Multi-Angle) if not yet present"
>
<RefreshCw size={14} className={seedWorkflowsMut.isPending ? 'animate-spin' : ''} />
{seedWorkflowsMut.isPending ? 'Seeding…' : 'Seed Standard Workflows'}
</button>
<p className="text-xs text-content-muted">Creates the 4 standard workflow definitions if they don't exist yet.</p>
</div>
</div>
</div>
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Global Render Positions (admin only) */}
{/* ------------------------------------------------------------------ */}
{activeTab === 'render' && isAdmin && <div className="card">
<div className="p-4 border-b border-border-light flex items-center gap-2">
<Settings size={16} className="text-content-muted" />
<div>
<h2 className="font-semibold text-content">Global Render Positions</h2>
<p className="text-xs text-content-muted mt-0.5">
Camera rotation presets available to all products. Per-product positions override these.
</p>
</div>
</div>
<div className="p-4">
<GlobalRenderPositionsPanel />
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Render Templates (admin/PM) */}
{/* ------------------------------------------------------------------ */}
{activeTab === 'render' && <div className="card">
<div className="p-4 border-b border-border-light flex items-center gap-2">
<FileBox size={16} className="text-content-muted" />
<div>
<h2 className="font-semibold text-content">Render Templates</h2>
<p className="text-xs text-content-muted mt-0.5">
Upload .blend studio setups matched by Category + Output Type. Geometry is imported into the template at render time.
</p>
</div>
</div>
<div className="p-4">
<RenderTemplateTable />
</div>
<div className="border-t border-border-light p-4">
<MaterialLibraryPanel />
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Asset Libraries */}
{/* ------------------------------------------------------------------ */}
{activeTab === 'libraries' && <AssetLibraryPanel />}
{/* ------------------------------------------------------------------ */}
{/* Output Types */}
{/* ------------------------------------------------------------------ */}
{activeTab === 'pricing' && <div className="card">
<div className="p-4 border-b border-border-light flex items-center gap-2">
<Layers size={16} className="text-content-muted" />
<div>
<h2 className="font-semibold text-content">Output Types</h2>
<p className="text-xs text-content-muted mt-0.5">
Define what kinds of outputs orders can request (thumbnails, views, formats).
</p>
</div>
</div>
<OutputTypeTable />
</div>}
{/* ------------------------------------------------------------------ */}
{/* Pricing Tiers */}
{/* ------------------------------------------------------------------ */}
{activeTab === 'pricing' && <div className="card">
<div className="p-4 border-b border-border-default flex items-center gap-2">
<DollarSign size={16} className="text-content-muted" />
<div>
<h2 className="font-semibold text-content">Pricing Tiers</h2>
<p className="text-xs text-content-muted mt-0.5">
Configure price per rendering item by category and quality level.
</p>
</div>
</div>
<PricingTierTable />
</div>}
{/* ------------------------------------------------------------------ */}
{/* E-Mail / SMTP Settings */}
{/* ------------------------------------------------------------------ */}
{activeTab === 'config' && isAdmin && (
<div className="card">
<div className="p-4 border-b border-border-default">
<h2 className="font-semibold text-content">E-Mail Notifications (SMTP)</h2>
<p className="text-xs text-content-muted mt-0.5">
Configure outbound SMTP for email notifications. Enable only when credentials are set.
</p>
</div>
<div className="p-6 space-y-4">
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={smtp.smtp_enabled ?? false}
onChange={(e) => setSmtpDraft(d => ({ ...d, smtp_enabled: e.target.checked }))}
className="w-4 h-4 rounded"
/>
<span className="text-sm font-medium text-content">Enable email sending</span>
</label>
{smtp.smtp_enabled && (
<span className="badge badge-green text-xs">Active</span>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-content-secondary mb-1">SMTP Host</label>
<input
type="text"
value={smtp.smtp_host ?? ''}
onChange={(e) => setSmtpDraft(d => ({ ...d, smtp_host: e.target.value }))}
placeholder="smtp.example.com"
className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label className="block text-xs font-medium text-content-secondary mb-1">Port</label>
<input
type="number"
value={smtp.smtp_port ?? 587}
onChange={(e) => setSmtpDraft(d => ({ ...d, smtp_port: parseInt(e.target.value) || 587 }))}
className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label className="block text-xs font-medium text-content-secondary mb-1">Username</label>
<input
type="text"
value={smtp.smtp_user ?? ''}
onChange={(e) => setSmtpDraft(d => ({ ...d, smtp_user: e.target.value }))}
placeholder="user@example.com"
className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label className="block text-xs font-medium text-content-secondary mb-1">Password</label>
<input
type="password"
value={smtp.smtp_password ?? ''}
onChange={(e) => setSmtpDraft(d => ({ ...d, smtp_password: e.target.value }))}
placeholder="••••••••"
className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div className="col-span-2">
<label className="block text-xs font-medium text-content-secondary mb-1">From Address</label>
<input
type="email"
value={smtp.smtp_from_address ?? ''}
onChange={(e) => setSmtpDraft(d => ({ ...d, smtp_from_address: e.target.value }))}
placeholder="noreply@schaeffler.com"
className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
</div>
<button
onClick={() => { updateSettingsMut.mutate(smtpDraft as any); setSmtpDraft({}) }}
disabled={updateSettingsMut.isPending || Object.keys(smtpDraft).length === 0}
className="btn-primary text-sm"
>
Save SMTP Settings
</button>
</div>
</div>
)}
{/* ------------------------------------------------------------------ */}
{/* Templates */}
{/* ------------------------------------------------------------------ */}
{activeTab === 'libraries' && <div className="card">
<div className="p-4 border-b border-border-default">
<h2 className="font-semibold text-content">Templates</h2>
<p className="text-xs text-content-muted mt-0.5">
Click Edit to configure standard fields and component schema for each template.
</p>
</div>
<div className="divide-y divide-border-light">
{templates?.map((t) => {
const isEditing = editingTemplateId === t.id
return (
<div key={t.id}>
{/* Row */}
<div className="flex items-center px-6 py-3 gap-3">
<button
onClick={() => setEditingTemplateId(isEditing ? null : t.id)}
className="text-content-muted hover:text-content-secondary transition-colors shrink-0"
aria-label={isEditing ? 'Collapse editor' : 'Expand editor'}
title={isEditing ? 'Collapse template editor' : 'Expand template editor'}
>
{isEditing ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-content">{t.name}</p>
<p className="text-xs text-content-muted font-mono">{t.category_key}</p>
</div>
<span className={`badge ${t.is_active ? 'badge-green' : 'badge-gray'}`}>
{t.is_active ? 'active' : 'inactive'}
</span>
<button
onClick={() => setEditingTemplateId(isEditing ? null : t.id)}
className="flex items-center gap-1.5 px-3 py-1 rounded-md border border-border-default text-xs text-content-secondary hover:bg-surface-hover hover:border-accent hover:text-accent transition-colors"
>
<Pencil size={12} />
{isEditing ? 'Close' : 'Edit'}
</button>
</div>
{/* Inline editor panel */}
{isEditing && (
<div className="px-6 pb-6">
<TemplateEditor
template={t}
onClose={() => setEditingTemplateId(null)}
/>
</div>
)}
</div>
)
})}
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Dashboard Widget Configuration (admin only) */}
{/* ------------------------------------------------------------------ */}
{activeTab === 'config' && isAdmin && (
<div className="card">
<div className="p-4 border-b border-border-default flex items-center gap-2">
<LayoutDashboard size={16} className="text-content-muted" />
<div>
<h2 className="font-semibold text-content">Dashboard Widget-Konfiguration</h2>
<p className="text-xs text-content-muted mt-0.5">
Sets the default widget layout for all users of this tenant. Users can customize their own layout individually.
</p>
</div>
</div>
<div className="p-4 flex items-center gap-4">
<div className="flex-1">
<p className="text-sm text-content-secondary">
Tenant default:{' '}
<span className="font-medium text-content">
{tenantDefaultWidgets && tenantDefaultWidgets.length > 0
? `${tenantDefaultWidgets.length} Widget${tenantDefaultWidgets.length !== 1 ? 's' : ''} configured`
: 'No default set yet (system default active)'}
</span>
</p>
</div>
<button
onClick={() => setShowTenantDashboardModal(true)}
className="btn-secondary text-sm flex items-center gap-2"
>
<LayoutDashboard size={14} />
Edit Tenant Default Dashboard
</button>
</div>
</div>
)}
{showTenantDashboardModal && (
<DashboardCustomizeModal
currentWidgets={tenantDefaultWidgets ?? []}
onClose={() => setShowTenantDashboardModal(false)}
tenantMode={true}
/>
)}
{/* ------------------------------------------------------------------ */}
{/* 3D Viewer & GLB Export Settings */}
{/* ------------------------------------------------------------------ */}
{activeTab === 'render' && <div className="card">
<div className="p-4 border-b border-border-default">
<h2 className="font-semibold text-content">3D Viewer & GLB Export</h2>
<p className="text-sm text-content-muted mt-0.5">
Settings for the 3D viewer and GLB geometry export
</p>
</div>
<div className="p-4 space-y-4">
{/* Scale Factor */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-content-muted block mb-1">
GLB Scale Factor (mm→m)
</label>
<input
type="number"
step="0.0001"
min="0.0001"
max="1"
value={viewer3d.gltf_scale_factor ?? 0.001}
onChange={e => setViewerDraft(d => ({ ...d, gltf_scale_factor: parseFloat(e.target.value) }))}
className="input w-full"
/>
<p className="text-xs text-content-muted mt-0.5">Default 0.001 converts mm to meters</p>
</div>
<div>
<label className="text-sm font-medium text-content-muted block mb-1">
Smooth Normals
</label>
<label className="flex items-center gap-2 mt-2 cursor-pointer">
<input
type="checkbox"
checked={viewer3d.gltf_smooth_normals ?? true}
onChange={e => setViewerDraft(d => ({ ...d, gltf_smooth_normals: e.target.checked }))}
className="w-4 h-4"
/>
<span className="text-sm text-content">Apply Laplacian smoothing on export</span>
</label>
<p className="text-xs text-content-muted mt-1">Smooths surface normals during GLB export for a less faceted look in the 3D viewer.</p>
</div>
</div>
{/* Camera / Zoom Limits */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-content-muted block mb-1">
Max Zoom-Out Distance
</label>
<input
type="number"
step="1"
min="1"
max="10000"
value={viewer3d.viewer_max_distance ?? 50}
onChange={e => setViewerDraft(d => ({ ...d, viewer_max_distance: parseFloat(e.target.value) }))}
title="Maximum camera distance from the model in the 3D viewer (in metres after mm→m conversion). Default: 50"
className="input w-full"
/>
<p className="text-xs text-content-muted mt-1">Maximum camera pull-back distance in the 3D viewer (metres).</p>
</div>
<div>
<label className="text-sm font-medium text-content-muted block mb-1">
Min Zoom-In Distance
</label>
<input
type="number"
step="0.001"
min="0.0001"
max="1"
value={viewer3d.viewer_min_distance ?? 0.001}
onChange={e => setViewerDraft(d => ({ ...d, viewer_min_distance: parseFloat(e.target.value) }))}
title="Minimum camera distance from the model in the 3D viewer (in metres). Default: 0.001. Prevents clipping into the geometry."
className="input w-full"
/>
<p className="text-xs text-content-muted mt-1">Closest the camera can zoom in (metres). Prevents clipping through geometry.</p>
</div>
</div>
{/* PBR Material Quality */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-sm font-medium text-content-muted block mb-1">
GLB Material Mode
</label>
<select
value={viewer3d.gltf_material_quality ?? 'pbr_colors'}
onChange={e => setViewerDraft(d => ({ ...d, gltf_material_quality: e.target.value }))}
title="Controls what material data is embedded in exported GLB files. 'None' exports bare geometry; 'PBR Colors' bakes part colours into PBR materials."
className="input w-full"
>
<option value="none">None (geometry only)</option>
<option value="pbr_colors">PBR Colors (from part colors)</option>
</select>
<p className="text-xs text-content-muted mt-1">Material data embedded in exported GLB files.</p>
</div>
<div>
<label className="text-sm font-medium text-content-muted block mb-1">
PBR Roughness (01)
</label>
<input
type="number"
step="0.05"
min="0"
max="1"
value={viewer3d.gltf_pbr_roughness ?? 0.4}
onChange={e => setViewerDraft(d => ({ ...d, gltf_pbr_roughness: parseFloat(e.target.value) }))}
title="Surface roughness for GLB PBR materials (0 = mirror-smooth, 1 = fully matte). Default: 0.4 — appropriate for brushed metal."
className="input w-full"
/>
<p className="text-xs text-content-muted mt-1">0 = mirror-smooth, 1 = fully matte. Default 0.4 suits brushed metal.</p>
</div>
<div>
<label className="text-sm font-medium text-content-muted block mb-1">
PBR Metallic (01)
</label>
<input
type="number"
step="0.05"
min="0"
max="1"
value={viewer3d.gltf_pbr_metallic ?? 0.6}
onChange={e => setViewerDraft(d => ({ ...d, gltf_pbr_metallic: parseFloat(e.target.value) }))}
title="Metallic factor for GLB PBR materials (0 = dielectric/plastic, 1 = fully metallic). Default: 0.6 — suitable for steel parts."
className="input w-full"
/>
<p className="text-xs text-content-muted mt-1">0 = plastic/dielectric, 1 = fully metallic. Default 0.6 suits steel parts.</p>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => {
updateSettingsMut.mutate(viewerDraft)
setViewerDraft({})
}}
disabled={Object.keys(viewerDraft).length === 0 || updateSettingsMut.isPending}
className="btn-primary disabled:opacity-40"
>
Save 3D Settings
</button>
{Object.keys(viewerDraft).length > 0 && (
<button
onClick={() => setViewerDraft({})}
className="btn-secondary"
>
Reset
</button>
)}
</div>
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Tessellation Quality */}
{/* ------------------------------------------------------------------ */}
{activeTab === 'render' && <div className="card">
<div className="p-4 border-b border-border-default">
<h2 className="font-semibold text-content">Tessellation Quality</h2>
<p className="text-sm text-content-muted mt-0.5">
Controls how STEP geometry is converted to triangle meshes. These settings affect both the 3D viewer and Blender renders.
</p>
</div>
<div className="p-4 space-y-6">
{/* Presets */}
{(() => {
const PRESETS = [
{
label: 'Draft',
icon: '',
description: 'Fast preview visible faceting on curved surfaces',
useCase: 'Quick checks, large assemblies',
color: 'border-amber-400',
activeColor: 'border-amber-500 ring-2 ring-amber-200',
values: { scene_linear_deflection: 0.2, scene_angular_deflection: 0.3, render_linear_deflection: 0.05, render_angular_deflection: 0.1 },
},
{
label: 'Standard',
icon: '',
description: 'Smooth curves, good quality-to-size ratio',
useCase: 'Recommended for most parts',
color: 'border-blue-400',
activeColor: 'border-blue-500 ring-2 ring-blue-200',
values: { scene_linear_deflection: 0.1, scene_angular_deflection: 0.1, render_linear_deflection: 0.03, render_angular_deflection: 0.05 },
},
{
label: 'Fine',
icon: '',
description: 'Near-perfect surfaces, 3-5x larger files',
useCase: 'Close-up renders, small precision parts',
color: 'border-emerald-400',
activeColor: 'border-emerald-500 ring-2 ring-emerald-200',
values: { scene_linear_deflection: 0.05, scene_angular_deflection: 0.05, render_linear_deflection: 0.01, render_angular_deflection: 0.02 },
},
{
label: 'Ultra',
icon: '',
description: 'Maximum fidelity, very slow export',
useCase: 'Marketing renders, extreme close-ups',
color: 'border-purple-400',
activeColor: 'border-purple-500 ring-2 ring-purple-200',
values: { scene_linear_deflection: 0.02, scene_angular_deflection: 0.02, render_linear_deflection: 0.005, render_angular_deflection: 0.01 },
},
]
const isActive = (preset: typeof PRESETS[0]) =>
tess.scene_linear_deflection === preset.values.scene_linear_deflection &&
tess.scene_angular_deflection === preset.values.scene_angular_deflection &&
tess.render_linear_deflection === preset.values.render_linear_deflection &&
tess.render_angular_deflection === preset.values.render_angular_deflection
const isCustom = !PRESETS.some(isActive)
return (
<div>
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide mb-2">Quality Presets</p>
<div className="grid grid-cols-4 gap-3">
{PRESETS.map(preset => (
<button
key={preset.label}
onClick={() => setTessellationDraft(preset.values)}
className={`p-3 rounded-lg border-2 text-left transition-all ${isActive(preset) ? preset.activeColor : preset.color + ' opacity-60 hover:opacity-100'}`}
style={isActive(preset) ? { backgroundColor: 'var(--color-bg-surface-alt)' } : undefined}
>
<div className="flex items-center gap-1.5">
<span className="text-base">{preset.icon}</span>
<span className="font-semibold text-sm">{preset.label}</span>
</div>
<div className="text-xs text-content-muted mt-1">{preset.description}</div>
<div className="text-xs text-content-secondary mt-1.5 italic">{preset.useCase}</div>
</button>
))}
</div>
{isCustom && (
<p className="text-xs text-amber-600 mt-2">Current values don't match any preset (custom configuration)</p>
)}
</div>
)
})()}
{/* Tessellation engine selector */}
<div className="space-y-2">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Tessellation Engine</p>
<div className="flex flex-col gap-2">
{[
{ value: 'occ', label: 'OCC BRepMesh', description: 'Default engine. Fast, but produces fan-shaped triangles at cylinder seam lines.' },
{ value: 'gmsh', label: 'GMSH Frontal-Delaunay', description: 'Uniform mesh — eliminates fan artifacts on cylindrical parts. 10-30% slower. Recommended for bearings.' },
].map(opt => (
<label key={opt.value} className="flex items-start gap-3 cursor-pointer p-3 rounded-lg border border-border-default hover:border-blue-400 transition-colors">
<input
type="radio"
name="tessellation_engine"
value={opt.value}
checked={(tess.tessellation_engine ?? 'occ') === opt.value}
onChange={() => setTessellationDraft(d => ({ ...d, tessellation_engine: opt.value }))}
className="mt-0.5 shrink-0"
/>
<div>
<div className="text-sm font-medium">{opt.label}</div>
<div className="text-xs text-content-muted mt-0.5">{opt.description}</div>
</div>
</label>
))}
</div>
</div>
{/* Explanation of deflection parameters */}
<div className="rounded-lg border border-border-default p-4 space-y-3" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">How deflection values work</p>
<div className="grid grid-cols-2 gap-4 text-xs text-content-muted">
<div>
<p className="font-medium text-content-secondary mb-1">Linear deflection (mm)</p>
<p>Maximum allowed distance between the original curved surface and the generated triangles. A value of 0.1 mm means no triangle edge can deviate more than 0.1 mm from the true surface. Lower values produce smoother curves but more triangles.</p>
</div>
<div>
<p className="font-medium text-content-secondary mb-1">Angular deflection (rad)</p>
<p>Maximum angle between adjacent triangle normals. Controls how finely curved regions are subdivided. A value of 0.1 rad (~6°) means neighboring triangles can differ by at most ~6°. Primarily affects small fillets and tight curvatures.</p>
</div>
</div>
</div>
<button
onClick={() => setShowAdvancedTess(v => !v)}
className="text-xs text-accent hover:underline flex items-center gap-1 mt-1"
>
{showAdvancedTess ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
{showAdvancedTess ? 'Hide manual values' : 'Advanced: edit values manually'}
</button>
{/* Manual inputs */}
{showAdvancedTess && (<>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">3D Viewer + USD Master</p>
<p className="text-xs text-content-muted mt-0.5">Used for the interactive 3D viewer GLB and the canonical USD scene file. Optimized for real-time display.</p>
</div>
<div className="flex items-center gap-3">
<label className="text-sm text-content-secondary w-36 shrink-0">Linear deflection</label>
<input
type="number"
step="0.01"
min="0.001"
max="10"
value={tess.scene_linear_deflection ?? 0.1}
onChange={e => setTessellationDraft(d => ({ ...d, scene_linear_deflection: parseFloat(e.target.value) }))}
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
/>
<span className="text-sm text-content-muted">mm</span>
</div>
<div className="flex items-center gap-3">
<label className="text-sm text-content-secondary w-36 shrink-0">Angular deflection</label>
<input
type="number"
step="0.01"
min="0.01"
max="1.5"
value={tess.scene_angular_deflection ?? 0.1}
onChange={e => setTessellationDraft(d => ({ ...d, scene_angular_deflection: parseFloat(e.target.value) }))}
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
/>
<span className="text-sm text-content-muted">rad</span>
</div>
</div>
<div className="space-y-4">
<div>
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Blender Render Output</p>
<p className="text-xs text-content-muted mt-0.5">Used for final Blender renders (stills, turntables). Higher quality since render time matters more than file size.</p>
</div>
<div className="flex items-center gap-3">
<label className="text-sm text-content-secondary w-36 shrink-0">Linear deflection</label>
<input
type="number"
step="0.005"
min="0.001"
max="10"
value={tess.render_linear_deflection ?? 0.03}
onChange={e => setTessellationDraft(d => ({ ...d, render_linear_deflection: parseFloat(e.target.value) }))}
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
/>
<span className="text-sm text-content-muted">mm</span>
</div>
<div className="flex items-center gap-3">
<label className="text-sm text-content-secondary w-36 shrink-0">Angular deflection</label>
<input
type="number"
step="0.005"
min="0.005"
max="1.5"
value={tess.render_angular_deflection ?? 0.05}
onChange={e => setTessellationDraft(d => ({ ...d, render_angular_deflection: parseFloat(e.target.value) }))}
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
/>
<span className="text-sm text-content-muted">rad</span>
</div>
</div>
</div>
</>)}
<div className="flex gap-2">
<button
onClick={() => { updateSettingsMut.mutate(tessellationDraft); setTessellationDraft({}) }}
disabled={Object.keys(tessellationDraft).length === 0 || updateSettingsMut.isPending}
className="btn-primary disabled:opacity-40"
>
Save Tessellation Settings
</button>
{Object.keys(tessellationDraft).length > 0 && (
<button onClick={() => setTessellationDraft({})} className="btn-secondary">Reset</button>
)}
</div>
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Material Library link */}
{/* ------------------------------------------------------------------ */}
{activeTab === 'render' && <div className="card p-5 flex items-center justify-between">
<div>
<h2 className="font-semibold text-content">Material Library</h2>
<p className="text-xs text-content-muted mt-0.5">
Manage shared materials for CAD part assignments.
</p>
</div>
<Link to="/materials" className="btn-secondary text-sm">
Open Material Library
</Link>
</div>}
{/* ------------------------------------------------------------------ */}
{/* GPU Status */}
{/* ------------------------------------------------------------------ */}
{activeTab === 'render' && isAdmin && (
<div className="card">
<button
className="w-full p-5 flex items-center justify-between text-left"
onClick={() => setGpuProbeExpanded((v) => !v)}
>
<div className="flex items-center gap-3">
<Zap size={18} className="text-content-secondary" />
<div>
<h2 className="font-semibold text-content">GPU Status</h2>
<p className="text-xs text-content-muted mt-0.5">
Verify that the render-worker is using the GPU (not CPU fallback).
</p>
</div>
</div>
<div className="flex items-center gap-3">
{gpuStatusBadge()}
{gpuProbeExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</div>
</button>
{gpuProbeExpanded && (
<div className="px-5 pb-5 space-y-4 border-t border-border-default pt-4">
<div className="flex items-center gap-3">
<button
onClick={handleRunGpuCheck}
disabled={gpuProbing}
className="btn-primary flex items-center gap-2"
>
{gpuProbing ? (
<><RefreshCw size={14} className="animate-spin" /> Running probe</>
) : (
<><Zap size={14} /> Run GPU Check</>
)}
</button>
{gpuProbeResult && (
<span className="text-xs text-content-muted">
Last checked: {gpuProbeResult.timestamp ? new Date(gpuProbeResult.timestamp).toLocaleString() : '—'}
</span>
)}
</div>
{gpuProbeResult && (
<div className="bg-surface-alt rounded-md p-4 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Status</span>
{gpuStatusBadge()}
</div>
{gpuProbeResult.device_type && (
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Device type</span>
<span className="text-xs text-content">{gpuProbeResult.device_type}</span>
</div>
)}
{gpuProbeResult.devices && gpuProbeResult.devices.length > 0 && (
<div className="flex items-start gap-2">
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Devices</span>
<div className="space-y-0.5">
{gpuProbeResult.devices.map((d: string, i: number) => (
<span key={i} className="block text-xs text-content">{d}</span>
))}
</div>
</div>
)}
{gpuProbeResult.render_time_s != null && (
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Render time</span>
<span className="text-xs text-content">{gpuProbeResult.render_time_s.toFixed(2)}s</span>
</div>
)}
{gpuProbeResult.error && (
<div className="flex items-start gap-2">
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Error</span>
<span className="text-xs text-status-error-text font-mono">{gpuProbeResult.error}</span>
</div>
)}
</div>
)}
{!gpuProbeResult && !gpuProbing && (
<p className="text-xs text-content-muted">No probe result yet. Click "Run GPU Check" to trigger a test render.</p>
)}
</div>
)}
</div>
)}
<ConfirmModal
open={confirmState.open}
title={confirmState.title}
message={confirmState.message}
onConfirm={confirmState.onConfirm}
onCancel={() => setConfirmState((s) => ({ ...s, open: false }))}
/>
</div>
</div>
)
}
function MaterialLibraryPanel() {
return (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-content-secondary">Material Library</h4>
<p className="text-xs text-content-muted">
Materials for "Material Replace" are now managed via Asset Libraries. The active asset library's materials are used at render time.
</p>
<Link
to="/asset-libraries"
className="inline-flex items-center gap-1 text-sm text-accent hover:text-accent-hover"
>
<Layers size={14} />
Manage Asset Libraries
</Link>
</div>
)
}
function PricingSummaryCard() {
const { data: tiers } = useQuery({
queryKey: ['pricing-tiers'],
queryFn: listPricingTiers,
})
const { data: outputTypes } = useQuery({
queryKey: ['output-types-admin'],
queryFn: () => listOutputTypes(true),
})
const defaultTier = tiers?.find((t) => t.category_key === 'default' && t.is_active)
const activeTiers = tiers?.filter((t) => t.is_active).length ?? 0
const totalOTs = outputTypes?.length ?? 0
const otsWithTier = outputTypes?.filter((ot) => ot.pricing_tier_id != null).length ?? 0
return (
<div className="card">
<div className="p-4 border-b border-border-default flex items-center gap-2">
<DollarSign size={16} className="text-content-muted" />
<h2 className="font-semibold text-content">Pricing Overview</h2>
</div>
<div className="p-4 grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center">
<p className="text-2xl font-bold text-content">
{defaultTier ? `${Number(defaultTier.price_per_item).toFixed(2)}` : ''}
</p>
<p className="text-xs text-content-muted">Global default price</p>
{!defaultTier && (
<p className="text-xs text-amber-600 flex items-center justify-center gap-1 mt-1">
<AlertTriangle size={10} /> Not configured
</p>
)}
</div>
<div className="text-center">
<p className="text-2xl font-bold text-content">{activeTiers}</p>
<p className="text-xs text-content-muted">Active pricing tiers</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-content">{otsWithTier} / {totalOTs}</p>
<p className="text-xs text-content-muted">Output types with explicit tier</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-content">{totalOTs - otsWithTier}</p>
<p className="text-xs text-content-muted">Using category default</p>
</div>
</div>
</div>
)
}
// ── Asset Library Panel ───────────────────────────────────────────────────────
function AssetLibraryPanel() {
const qc = useQueryClient()
const [showCreate, setShowCreate] = useState(false)
const [newName, setNewName] = useState('')
const [newDesc, setNewDesc] = useState('')
const [newFile, setNewFile] = useState<File | null>(null)
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} })
const { data: libraries = [] } = useQuery({
queryKey: ['asset-libraries'],
queryFn: listAssetLibraries,
})
const createMut = useMutation({
mutationFn: () => createAssetLibrary({ name: newName, description: newDesc || undefined, blend_file: newFile! }),
onSuccess: () => {
toast.success('Asset library created')
qc.invalidateQueries({ queryKey: ['asset-libraries'] })
setShowCreate(false)
setNewName('')
setNewDesc('')
setNewFile(null)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create'),
})
const deleteMut = useMutation({
mutationFn: (id: string) => deleteAssetLibrary(id),
onSuccess: () => {
toast.success('Asset library deleted')
qc.invalidateQueries({ queryKey: ['asset-libraries'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to delete'),
})
const refreshMut = useMutation({
mutationFn: (id: string) => refreshAssetLibraryCatalog(id),
onSuccess: () => {
toast.success('Catalog refresh queued')
setTimeout(() => qc.invalidateQueries({ queryKey: ['asset-libraries'] }), 3000)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to refresh'),
})
const toggle = (id: string) =>
setExpanded((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n })
return (
<div className="card">
<div className="p-4 border-b border-border-light flex items-center justify-between">
<div className="flex items-center gap-2">
<Layers size={16} className="text-content-muted" />
<div>
<h2 className="font-semibold text-content">Asset Libraries</h2>
<p className="text-xs text-content-muted mt-0.5">
Upload Blender .blend files containing production materials and node groups.
</p>
</div>
</div>
<button className="btn-primary" onClick={() => setShowCreate(!showCreate)}>
<Plus size={14} />New Library
</button>
</div>
{showCreate && (
<div className="p-4 border-b border-border-light bg-surface-alt space-y-3">
<div className="flex gap-3">
<input
className="input flex-1"
placeholder="Library name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<input
className="input flex-1"
placeholder="Description (optional)"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
/>
</div>
<div className="flex items-center gap-3">
<label className="btn-secondary cursor-pointer">
<Upload size={14} />
{newFile ? newFile.name : 'Choose .blend file'}
<input
type="file"
accept=".blend"
className="hidden"
onChange={(e) => setNewFile(e.target.files?.[0] ?? null)}
/>
</label>
<button
className="btn-primary"
disabled={!newName || !newFile || createMut.isPending}
onClick={() => createMut.mutate()}
>
{createMut.isPending ? 'Creating' : 'Create'}
</button>
<button className="btn-secondary" onClick={() => setShowCreate(false)}>
<X size={14} />
</button>
</div>
</div>
)}
{libraries.length === 0 ? (
<div className="p-8 text-center text-content-muted text-sm">
No asset libraries yet. Upload a .blend file to get started.
</div>
) : (
<div className="divide-y divide-border-light">
{(libraries as AssetLibrary[]).map((lib) => {
const isExpanded = expanded.has(lib.id)
const matCount = lib.catalog.materials.length
const ngCount = lib.catalog.node_groups.length
return (
<div key={lib.id}>
<div className="p-4 flex items-center gap-3">
<button
onClick={() => toggle(lib.id)}
className="text-content-muted hover:text-content"
>
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
<div className="flex-1 min-w-0">
<p className="font-medium text-content text-sm">{lib.name}</p>
{lib.description && (
<p className="text-xs text-content-muted">{lib.description}</p>
)}
</div>
<span className="text-xs text-content-muted">
{lib.original_filename ?? ''}
</span>
<span className="badge-neutral text-xs">{matCount} materials</span>
<span className="badge-neutral text-xs">{ngCount} node groups</span>
<button
className="btn-secondary text-xs"
onClick={() => refreshMut.mutate(lib.id)}
disabled={refreshMut.isPending}
title="Re-scan catalog from .blend"
>
<RefreshCw size={12} />Refresh
</button>
<button
className="btn-danger text-xs"
onClick={() => {
setConfirmState({
open: true,
title: 'Delete Asset Library',
message: `Delete "${lib.name}"?`,
onConfirm: () => {
deleteMut.mutate(lib.id)
setConfirmState((s) => ({ ...s, open: false }))
},
})
}}
>
<Trash2 size={12} />
</button>
</div>
{isExpanded && (
<div className="px-10 pb-4 space-y-3">
{matCount > 0 && (
<div>
<p className="text-xs font-medium text-content-muted mb-1">Materials</p>
<div className="flex flex-wrap gap-1">
{lib.catalog.materials.map((m) => {
const name = typeof m === 'string' ? m : m.name
return (
<span key={name} className="text-xs px-2 py-0.5 rounded bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
{name}
</span>
)
})}
</div>
</div>
)}
{ngCount > 0 && (
<div>
<p className="text-xs font-medium text-content-muted mb-1">Node Groups</p>
<div className="flex flex-wrap gap-1">
{lib.catalog.node_groups.map((ng) => (
<span key={ng} className="text-xs px-2 py-0.5 rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{ng}
</span>
))}
</div>
</div>
)}
{matCount === 0 && ngCount === 0 && (
<p className="text-xs text-content-muted italic">
No assets found. Click "Refresh" to scan the .blend for marked assets.
</p>
)}
</div>
)}
</div>
)
})}
</div>
)}
<ConfirmModal
open={confirmState.open}
title={confirmState.title}
message={confirmState.message}
onConfirm={confirmState.onConfirm}
onCancel={() => setConfirmState((s) => ({ ...s, open: false }))}
/>
</div>
)
}