9a794ff2da
Admin tables (same pattern as OutputTypeTable): - RenderTemplateTable: 11 cramped columns → expandable form row with grouped fields, boolean flags consolidated into compact badges, .blend upload in proper section - PricingTierTable: inline cell editing → expandable form with labeled fields, shared renderEditFormGrid() for add/edit modes - GlobalRenderPositionsPanel: tiny rotation inputs → expandable form with w-24 inputs, proper labels, sensor_width_mm added to edit form Page polish: - WorkerManagement: larger scale controls (p-2 rounded-lg), wider number displays (w-12), proper labels, more prominent Save button - Billing: status select gets visible dropdown indicator (ChevronDown icon), hover border to signal interactivity, larger action buttons with borders - OrderDetail: batch override in proper card with title/description, per-line override shows compact "+ override" link (expands on click), active overrides show as amber badge with X to clear Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
597 lines
25 KiB
TypeScript
597 lines
25 KiB
TypeScript
import React, { useState, useRef } from 'react'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { Pencil, Trash2, Plus, Check, X, Upload, Download, Copy } from 'lucide-react'
|
|
import HelpTooltip from '../HelpTooltip'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
listRenderTemplates,
|
|
createRenderTemplate,
|
|
duplicateRenderTemplate,
|
|
updateRenderTemplate,
|
|
deleteRenderTemplate,
|
|
reuploadBlendFile,
|
|
} from '../../api/renderTemplates'
|
|
import type { RenderTemplate } from '../../api/renderTemplates'
|
|
import { listOutputTypes } from '../../api/outputTypes'
|
|
import type { OutputType } from '../../api/outputTypes'
|
|
|
|
const ALL_CATEGORIES = [
|
|
{ key: 'TRB', label: 'TRB' },
|
|
{ key: 'Kugellager', label: 'Kugellager' },
|
|
{ key: 'CRB', label: 'CRB' },
|
|
{ key: 'Gleitlager', label: 'Gleitlager' },
|
|
{ key: 'SRB_TORB', label: 'SRB/TORB' },
|
|
{ key: 'Linear_schiene', label: 'Linear' },
|
|
{ key: 'Anschlagplatten', label: 'Anschlag' },
|
|
]
|
|
|
|
const EMPTY_FORM = {
|
|
name: '',
|
|
category_key: '' as string,
|
|
output_type_id: '' as string,
|
|
target_collection: 'Product',
|
|
material_replace_enabled: false,
|
|
lighting_only: false,
|
|
shadow_catcher_enabled: false,
|
|
camera_orbit: true,
|
|
}
|
|
|
|
export default function RenderTemplateTable() {
|
|
const qc = useQueryClient()
|
|
const [showAdd, setShowAdd] = useState(false)
|
|
const [form, setForm] = useState(EMPTY_FORM)
|
|
const [addFile, setAddFile] = useState<File | null>(null)
|
|
const [cloneBlendFrom, setCloneBlendFrom] = useState<string>('')
|
|
const [editingId, setEditingId] = useState<string | null>(null)
|
|
const [editDraft, setEditDraft] = useState<Partial<RenderTemplate>>({})
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const reuploadRef = useRef<HTMLInputElement>(null)
|
|
const [reuploadId, setReuploadId] = useState<string | null>(null)
|
|
|
|
const { data: templates, isLoading } = useQuery({
|
|
queryKey: ['render-templates'],
|
|
queryFn: listRenderTemplates,
|
|
})
|
|
|
|
const { data: outputTypes } = useQuery({
|
|
queryKey: ['output-types-admin'],
|
|
queryFn: () => listOutputTypes(true),
|
|
})
|
|
|
|
const createMut = useMutation({
|
|
mutationFn: () => {
|
|
if (!addFile && !cloneBlendFrom) throw new Error('Please select a .blend file or choose an existing one')
|
|
const fd = new FormData()
|
|
fd.append('name', form.name.trim())
|
|
if (addFile) {
|
|
fd.append('file', addFile)
|
|
} else if (cloneBlendFrom) {
|
|
fd.append('clone_blend_from', cloneBlendFrom)
|
|
}
|
|
fd.append('category_key', form.category_key || '')
|
|
fd.append('output_type_id', form.output_type_id || '')
|
|
fd.append('target_collection', form.target_collection || 'Product')
|
|
fd.append('material_replace_enabled', String(form.material_replace_enabled))
|
|
fd.append('lighting_only', String(form.lighting_only))
|
|
fd.append('shadow_catcher_enabled', String(form.shadow_catcher_enabled))
|
|
fd.append('camera_orbit', String(form.camera_orbit))
|
|
return createRenderTemplate(fd)
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('Render template created')
|
|
qc.invalidateQueries({ queryKey: ['render-templates'] })
|
|
setForm(EMPTY_FORM)
|
|
setAddFile(null)
|
|
setCloneBlendFrom('')
|
|
setShowAdd(false)
|
|
},
|
|
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create template'),
|
|
})
|
|
|
|
const updateMut = useMutation({
|
|
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
|
updateRenderTemplate(id, data as any),
|
|
onSuccess: () => {
|
|
toast.success('Template updated')
|
|
qc.invalidateQueries({ queryKey: ['render-templates'] })
|
|
setEditingId(null)
|
|
},
|
|
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to update'),
|
|
})
|
|
|
|
const deleteMut = useMutation({
|
|
mutationFn: deleteRenderTemplate,
|
|
onSuccess: () => {
|
|
toast.success('Template deleted')
|
|
qc.invalidateQueries({ queryKey: ['render-templates'] })
|
|
},
|
|
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to delete'),
|
|
})
|
|
|
|
const reuploadMut = useMutation({
|
|
mutationFn: ({ id, file }: { id: string; file: File }) => reuploadBlendFile(id, file),
|
|
onSuccess: () => {
|
|
toast.success('.blend file updated')
|
|
qc.invalidateQueries({ queryKey: ['render-templates'] })
|
|
setReuploadId(null)
|
|
},
|
|
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to upload'),
|
|
})
|
|
|
|
const duplicateMut = useMutation({
|
|
mutationFn: (t: RenderTemplate) => duplicateRenderTemplate(t.id, {
|
|
name: `${t.name} (copy)`,
|
|
category_key: t.category_key,
|
|
target_collection: t.target_collection,
|
|
material_replace_enabled: t.material_replace_enabled,
|
|
lighting_only: t.lighting_only,
|
|
shadow_catcher_enabled: t.shadow_catcher_enabled,
|
|
camera_orbit: t.camera_orbit,
|
|
output_type_ids: t.output_type_ids ?? [],
|
|
}),
|
|
onSuccess: () => {
|
|
toast.success('Template duplicated')
|
|
qc.invalidateQueries({ queryKey: ['render-templates'] })
|
|
},
|
|
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to duplicate'),
|
|
})
|
|
|
|
function startEdit(t: RenderTemplate) {
|
|
setEditingId(t.id)
|
|
setEditDraft({
|
|
name: t.name,
|
|
category_key: t.category_key,
|
|
output_type_ids: t.output_type_ids ?? [],
|
|
target_collection: t.target_collection,
|
|
material_replace_enabled: t.material_replace_enabled,
|
|
lighting_only: t.lighting_only,
|
|
shadow_catcher_enabled: t.shadow_catcher_enabled,
|
|
camera_orbit: t.camera_orbit,
|
|
is_active: t.is_active,
|
|
})
|
|
}
|
|
|
|
function saveEdit() {
|
|
if (!editingId) return
|
|
updateMut.mutate({ id: editingId, data: editDraft as Record<string, unknown> })
|
|
}
|
|
|
|
// Render the edit form grid (shared between edit-row and add-row)
|
|
function renderEditFormGrid(
|
|
mode: 'edit' | 'add',
|
|
t: RenderTemplate | null,
|
|
) {
|
|
const isEdit = mode === 'edit' && t !== null
|
|
|
|
// Value getters
|
|
const val = (field: keyof typeof EMPTY_FORM | 'is_active' | 'output_type_ids') => {
|
|
if (isEdit) {
|
|
if (field === 'name') return editDraft.name ?? t!.name
|
|
if (field === 'category_key') return editDraft.category_key ?? t!.category_key ?? ''
|
|
if (field === 'output_type_ids') return editDraft.output_type_ids ?? t!.output_type_ids ?? []
|
|
if (field === 'target_collection') return editDraft.target_collection ?? t!.target_collection
|
|
if (field === 'material_replace_enabled') return editDraft.material_replace_enabled ?? t!.material_replace_enabled
|
|
if (field === 'lighting_only') return editDraft.lighting_only ?? t!.lighting_only
|
|
if (field === 'shadow_catcher_enabled') return editDraft.shadow_catcher_enabled ?? t!.shadow_catcher_enabled
|
|
if (field === 'camera_orbit') return editDraft.camera_orbit ?? t!.camera_orbit
|
|
if (field === 'is_active') return editDraft.is_active ?? t!.is_active
|
|
return (editDraft as any)[field] ?? (t as any)[field]
|
|
}
|
|
return (form as any)[field]
|
|
}
|
|
|
|
const set = (field: string, value: any) => {
|
|
if (isEdit) {
|
|
setEditDraft({ ...editDraft, [field]: value } as any)
|
|
} else {
|
|
setForm({ ...form, [field]: value })
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{/* Row 1: Name | Category | Output Types */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-content-muted mb-1">Name</label>
|
|
<input
|
|
className="input-sm w-full"
|
|
placeholder="Template name"
|
|
value={val('name') as string}
|
|
onChange={(e) => set('name', e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-content-muted mb-1">Category</label>
|
|
<select
|
|
className="input-sm w-full"
|
|
value={val('category_key') as string}
|
|
onChange={(e) => set('category_key', e.target.value || (isEdit ? null : ''))}
|
|
>
|
|
<option value="">Any (default)</option>
|
|
{ALL_CATEGORIES.map((c) => (
|
|
<option key={c.key} value={c.key}>{c.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-content-muted mb-1">Output Types</label>
|
|
{isEdit ? (
|
|
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto p-1.5 border border-border-default rounded bg-surface">
|
|
{outputTypes?.map((ot: OutputType) => {
|
|
const ids = val('output_type_ids') as string[]
|
|
const checked = ids.includes(ot.id)
|
|
return (
|
|
<label key={ot.id} className="flex items-center gap-1 text-xs cursor-pointer whitespace-nowrap">
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
onChange={() => {
|
|
const next = checked
|
|
? ids.filter((id: string) => id !== ot.id)
|
|
: [...ids, ot.id]
|
|
set('output_type_ids', next)
|
|
}}
|
|
/>
|
|
{ot.name}
|
|
</label>
|
|
)
|
|
})}
|
|
</div>
|
|
) : (
|
|
<select
|
|
className="input-sm w-full"
|
|
value={(form as any).output_type_id ?? ''}
|
|
onChange={(e) => setForm({ ...form, output_type_id: e.target.value })}
|
|
>
|
|
<option value="">Any (default)</option>
|
|
{outputTypes?.map((ot: OutputType) => (
|
|
<option key={ot.id} value={ot.id}>{ot.name}</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 2: Collection | Checkboxes */}
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mt-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-content-muted mb-1">Collection Name</label>
|
|
<input
|
|
className="input-sm w-full"
|
|
value={val('target_collection') as string}
|
|
onChange={(e) => set('target_collection', e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-content-muted mb-1">
|
|
<span className="inline-flex items-center gap-1">
|
|
Mat. Replace
|
|
<HelpTooltip helpKey="template.material_replace_enabled" position="bottom" size={12} />
|
|
</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 mt-1">
|
|
<input
|
|
type="checkbox"
|
|
checked={val('material_replace_enabled') as boolean}
|
|
onChange={(e) => set('material_replace_enabled', e.target.checked)}
|
|
/>
|
|
<span className="text-sm text-content-secondary">Enabled</span>
|
|
</label>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-content-muted mb-1">
|
|
<span className="inline-flex items-center gap-1">
|
|
Lighting Only
|
|
<HelpTooltip helpKey="template.lighting_only" position="bottom" size={12} />
|
|
</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 mt-1">
|
|
<input
|
|
type="checkbox"
|
|
checked={val('lighting_only') as boolean}
|
|
onChange={(e) => set('lighting_only', e.target.checked)}
|
|
/>
|
|
<span className="text-sm text-content-secondary">HDR only</span>
|
|
</label>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-content-muted mb-1">
|
|
<span className="inline-flex items-center gap-1">
|
|
Shadow Catcher
|
|
<HelpTooltip helpKey="template.shadow_catcher" position="bottom" size={12} />
|
|
</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 mt-1">
|
|
<input
|
|
type="checkbox"
|
|
checked={val('shadow_catcher_enabled') as boolean}
|
|
title="Enable Shadowcatcher collection (Cycles only)"
|
|
onChange={(e) => set('shadow_catcher_enabled', e.target.checked)}
|
|
/>
|
|
<span className="text-sm text-content-secondary">Enabled</span>
|
|
</label>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-content-muted mb-1">Camera Orbit</label>
|
|
<label className="flex items-center gap-2 mt-1">
|
|
<input
|
|
type="checkbox"
|
|
checked={val('camera_orbit') as boolean}
|
|
title="Rotate camera around product (better GPU performance)"
|
|
onChange={(e) => set('camera_orbit', e.target.checked)}
|
|
/>
|
|
<span className="text-sm text-content-secondary">Cam orbit</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 3: .blend File */}
|
|
<div className="mt-4">
|
|
<label className="block text-xs font-medium text-content-muted mb-1">.blend File</label>
|
|
{isEdit ? (
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-sm text-content-secondary truncate max-w-xs" title={t!.original_filename}>
|
|
{t!.original_filename}
|
|
</span>
|
|
{templates && templates.filter((o) => o.blend_file_path === t!.blend_file_path).length > 1 && (
|
|
<span className="text-xs text-blue-500" title="Shared .blend file">∗ shared</span>
|
|
)}
|
|
<button
|
|
onClick={() => { setReuploadId(t!.id); reuploadRef.current?.click() }}
|
|
className="flex items-center gap-1 text-xs px-2 py-1 text-accent hover:bg-surface-hover rounded border border-border-default"
|
|
title="Re-upload .blend"
|
|
>
|
|
<Upload size={12} /> Re-upload
|
|
</button>
|
|
<a
|
|
href={`/api/render-templates/${t!.id}/download`}
|
|
className="flex items-center gap-1 text-xs px-2 py-1 text-accent hover:bg-surface-hover rounded border border-border-default"
|
|
title="Download .blend"
|
|
>
|
|
<Download size={12} /> Download
|
|
</a>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-3">
|
|
<label className="flex items-center gap-1.5 text-sm cursor-pointer text-accent hover:text-accent-hover px-2 py-1 border border-border-default rounded">
|
|
<Upload size={14} />
|
|
{addFile ? addFile.name : 'Upload .blend'}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".blend"
|
|
className="hidden"
|
|
onChange={(e) => { setAddFile(e.target.files?.[0] || null); setCloneBlendFrom('') }}
|
|
/>
|
|
</label>
|
|
{!addFile && (
|
|
<select
|
|
className="input-sm text-xs"
|
|
value={cloneBlendFrom}
|
|
onChange={(e) => { setCloneBlendFrom(e.target.value); setAddFile(null) }}
|
|
>
|
|
<option value="">or re-use existing...</option>
|
|
{templates?.map((tmpl) => (
|
|
<option key={tmpl.id} value={tmpl.id}>{tmpl.original_filename} ({tmpl.name})</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Row 4: Active + Save/Cancel */}
|
|
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border-light">
|
|
{isEdit ? (
|
|
<label className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={val('is_active') as boolean}
|
|
onChange={(e) => set('is_active', e.target.checked)}
|
|
/>
|
|
<span className="text-sm text-content-secondary">Active (visible in wizard)</span>
|
|
</label>
|
|
) : (
|
|
<span className="text-sm text-content-muted">Will be active by default</span>
|
|
)}
|
|
<div className="flex gap-2">
|
|
<button
|
|
className="btn-secondary text-sm"
|
|
onClick={() => {
|
|
if (isEdit) {
|
|
setEditingId(null)
|
|
} else {
|
|
setShowAdd(false)
|
|
setForm(EMPTY_FORM)
|
|
setAddFile(null)
|
|
setCloneBlendFrom('')
|
|
}
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
className="btn-primary text-sm"
|
|
disabled={isEdit ? updateMut.isPending : (!form.name.trim() || (!addFile && !cloneBlendFrom) || createMut.isPending)}
|
|
onClick={() => {
|
|
if (isEdit) {
|
|
saveEdit()
|
|
} else {
|
|
createMut.mutate()
|
|
}
|
|
}}
|
|
>
|
|
{isEdit ? (updateMut.isPending ? 'Saving...' : 'Save') : (createMut.isPending ? 'Creating...' : 'Create')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-lg font-semibold text-content">Render Templates</h3>
|
|
<button
|
|
onClick={() => setShowAdd(!showAdd)}
|
|
className="flex items-center gap-1 text-sm px-3 py-1.5 bg-blue-600 text-white rounded hover:bg-blue-700"
|
|
>
|
|
<Plus size={14} /> Add Template
|
|
</button>
|
|
</div>
|
|
|
|
{/* Hidden file inputs */}
|
|
<input
|
|
ref={reuploadRef}
|
|
type="file"
|
|
accept=".blend"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0]
|
|
if (file && reuploadId) reuploadMut.mutate({ id: reuploadId, file })
|
|
e.target.value = ''
|
|
}}
|
|
/>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm border-collapse">
|
|
<thead>
|
|
<tr className="bg-surface-alt border-b text-left">
|
|
<th className="px-3 py-2 font-medium">Name</th>
|
|
<th className="px-3 py-2 font-medium">Category</th>
|
|
<th className="px-3 py-2 font-medium">Output Types</th>
|
|
<th className="px-3 py-2 font-medium">Collection</th>
|
|
<th className="px-3 py-2 font-medium">Flags</th>
|
|
<th className="px-3 py-2 font-medium">.blend File</th>
|
|
<th className="px-3 py-2 font-medium">Active</th>
|
|
<th className="px-3 py-2 font-medium w-24">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{isLoading && (
|
|
<tr>
|
|
<td colSpan={8} className="px-3 py-4 text-center text-content-muted">Loading...</td>
|
|
</tr>
|
|
)}
|
|
|
|
{/* Add new — expandable form */}
|
|
{showAdd && (
|
|
<tr className="border-b border-border-light bg-status-success-bg">
|
|
<td colSpan={8} className="px-6 py-5 border-l-4 border-status-success-text">
|
|
<div className="text-sm font-medium text-content-secondary mb-3">New Render Template</div>
|
|
{renderEditFormGrid('add', null)}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
|
|
{/* Template rows */}
|
|
{templates?.map((t) => (
|
|
<React.Fragment key={t.id}>
|
|
{/* Display row — always visible */}
|
|
<tr className={`border-b border-border-light hover:bg-surface-hover/50 ${editingId === t.id ? 'bg-surface-hover' : ''}`}>
|
|
<td className="px-3 py-2">
|
|
<span className="font-medium">{t.name}</span>
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
{t.category_key || <span className="text-content-muted">Any</span>}
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
{t.output_type_names && t.output_type_names.length > 0 ? (
|
|
<div className="flex flex-wrap gap-1">
|
|
{t.output_type_names.map((name, i) => (
|
|
<span key={i} className="inline-block text-xs px-1.5 py-0.5 bg-blue-100 text-blue-800 rounded">
|
|
{name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<span className="text-content-muted">Any</span>
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<code className="text-xs bg-surface-muted px-1 rounded">{t.target_collection}</code>
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<div className="flex flex-wrap gap-1">
|
|
{t.material_replace_enabled && (
|
|
<span className="text-status-success-text text-xs font-medium" title="Material Replace">Mat</span>
|
|
)}
|
|
{t.lighting_only && (
|
|
<span className="text-status-warning-text text-xs font-medium" title="Lighting Only (HDR)">HDR</span>
|
|
)}
|
|
{t.shadow_catcher_enabled && (
|
|
<span className="text-violet-600 text-xs font-medium" title="Shadow Catcher">Shd</span>
|
|
)}
|
|
<span className={`text-xs font-medium ${t.camera_orbit ? 'text-teal-600' : 'text-content-muted'}`} title={t.camera_orbit ? 'Camera Orbit' : 'Object Rotation'}>
|
|
{t.camera_orbit ? 'Cam' : 'Obj'}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<div className="flex items-center gap-1">
|
|
{templates && templates.filter((o) => o.blend_file_path === t.blend_file_path).length > 1 && (
|
|
<span className="text-xs text-blue-500" title="Shared .blend file">∗</span>
|
|
)}
|
|
<span className="text-xs text-content-secondary truncate max-w-[120px]" title={t.original_filename}>
|
|
{t.original_filename}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-3 py-2 text-center">
|
|
{t.is_active ? (
|
|
<span className="inline-block w-2 h-2 rounded-full bg-green-500" title="Active" />
|
|
) : (
|
|
<span className="inline-block w-2 h-2 rounded-full bg-surface-muted" title="Inactive" />
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<div className="flex gap-1">
|
|
<button onClick={() => startEdit(t)} className="p-1 text-accent hover:bg-surface-hover rounded" title="Edit">
|
|
<Pencil size={14} />
|
|
</button>
|
|
<button onClick={() => duplicateMut.mutate(t)} disabled={duplicateMut.isPending} className="p-1 text-blue-500 hover:bg-blue-50 rounded" title="Duplicate">
|
|
<Copy size={14} />
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
if (confirm(`Delete template "${t.name}"?`)) deleteMut.mutate(t.id)
|
|
}}
|
|
className="p-1 text-red-500 hover:bg-red-50 rounded"
|
|
title="Delete"
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
|
|
{/* Expandable edit form row */}
|
|
{editingId === t.id && (
|
|
<tr>
|
|
<td colSpan={8} className="px-6 py-5 bg-surface-alt border-l-4 border-accent border-b-2 border-b-border-light">
|
|
{renderEditFormGrid('edit', t)}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
|
|
{!isLoading && (!templates || templates.length === 0) && !showAdd && (
|
|
<tr>
|
|
<td colSpan={8} className="px-3 py-6 text-center text-content-muted">
|
|
No render templates configured. Click "Add Template" to create one.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p className="mt-2 text-xs text-content-muted">
|
|
Templates define pre-designed .blend studio setups. When rendering, the system matches templates by Category + Output Type with fallback cascade.
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|