ec667dd56a
- 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>
2071 lines
99 KiB
TypeScript
2071 lines
99 KiB
TypeScript
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 (1–4096). 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 (1–1024). 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 (0–180°). 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 (1–16). 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 (1–16). 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 (10–10080). 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 (10–10080). 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 — ~3–5× 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 & 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 (0–1)
|
||
</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 (0–1)
|
||
</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>
|
||
)
|
||
}
|