Files
HartOMat/frontend/src/components/admin/RenderTemplateTable.tsx
T
Hartmut 10d05bd2e7 feat(phase7.1): add HelpTooltip system with contextual help icons
- New HelpTooltip component: hover-triggered floating panel, themed via
  CSS variables, supports top/right/bottom/left positioning, no deps
- New helpTexts.ts registry: 14 entries covering render settings,
  admin actions, template fields, and wizard fields
- Admin.tsx: tooltips on Cycles/EEVEE samples, smooth angle, regenerate
  thumbnails, process unprocessed
- RenderTemplateTable.tsx: tooltips on material replace, lighting only,
  shadow catcher column headers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 20:16:42 +01:00

530 lines
23 KiB
TypeScript

import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Pencil, Trash2, Plus, Check, X, Upload, Download } from 'lucide-react'
import HelpTooltip from '../HelpTooltip'
import { toast } from 'sonner'
import {
listRenderTemplates,
createRenderTemplate,
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 [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) throw new Error('Please select a .blend file')
const fd = new FormData()
fd.append('name', form.name.trim())
fd.append('file', addFile)
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)
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'),
})
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> })
}
const inputCls = 'px-2 py-1 text-sm border border-border-default rounded bg-surface focus:outline-none focus:ring-1 focus:ring-blue-400'
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 Type</th>
<th className="px-3 py-2 font-medium">Collection</th>
<th className="px-3 py-2 font-medium">
<span className="inline-flex items-center gap-1">
Mat. Replace
<HelpTooltip helpKey="template.material_replace_enabled" position="bottom" size={12} />
</span>
</th>
<th className="px-3 py-2 font-medium">
<span className="inline-flex items-center gap-1">
Lighting Only
<HelpTooltip helpKey="template.lighting_only" position="bottom" size={12} />
</span>
</th>
<th className="px-3 py-2 font-medium">
<span className="inline-flex items-center gap-1">
Shadow Catcher
<HelpTooltip helpKey="template.shadow_catcher" position="bottom" size={12} />
</span>
</th>
<th className="px-3 py-2 font-medium" title="Rotate camera around product instead of product rotation (faster GPU rendering)">Cam Orbit</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>
{/* Add row */}
{showAdd && (
<tr className="border-b bg-surface-hover/40">
<td className="px-3 py-2">
<input
className={inputCls + ' w-40'}
placeholder="Template name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</td>
<td className="px-3 py-2">
<select
className={inputCls}
value={form.category_key}
onChange={(e) => setForm({ ...form, category_key: e.target.value })}
>
<option value="">Any (default)</option>
{ALL_CATEGORIES.map((c) => (
<option key={c.key} value={c.key}>{c.label}</option>
))}
</select>
</td>
<td className="px-3 py-2">
<select
className={inputCls}
value={form.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>
</td>
<td className="px-3 py-2">
<input
className={inputCls + ' w-28'}
value={form.target_collection}
onChange={(e) => setForm({ ...form, target_collection: e.target.value })}
/>
</td>
<td className="px-3 py-2 text-center">
<input
type="checkbox"
checked={form.material_replace_enabled}
onChange={(e) => setForm({ ...form, material_replace_enabled: e.target.checked })}
/>
</td>
<td className="px-3 py-2 text-center">
<input
type="checkbox"
checked={form.lighting_only}
onChange={(e) => setForm({ ...form, lighting_only: e.target.checked })}
/>
</td>
<td className="px-3 py-2 text-center">
<input
type="checkbox"
checked={form.shadow_catcher_enabled}
title="Enable Shadowcatcher collection (Cycles only)"
onChange={(e) => setForm({ ...form, shadow_catcher_enabled: e.target.checked })}
/>
</td>
<td className="px-3 py-2 text-center">
<input
type="checkbox"
checked={form.camera_orbit}
title="Rotate camera around product (better GPU performance)"
onChange={(e) => setForm({ ...form, camera_orbit: e.target.checked })}
/>
</td>
<td className="px-3 py-2">
<label className="flex items-center gap-1 text-xs cursor-pointer text-accent hover:text-accent-hover">
<Upload size={14} />
{addFile ? addFile.name : 'Choose .blend'}
<input
ref={fileInputRef}
type="file"
accept=".blend"
className="hidden"
onChange={(e) => setAddFile(e.target.files?.[0] || null)}
/>
</label>
</td>
<td />
<td className="px-3 py-2">
<div className="flex gap-1">
<button
onClick={() => createMut.mutate()}
disabled={!form.name.trim() || !addFile || createMut.isPending}
className="p-1 text-status-success-text hover:bg-surface-hover rounded disabled:opacity-40"
title="Create"
>
<Check size={16} />
</button>
<button
onClick={() => { setShowAdd(false); setForm(EMPTY_FORM); setAddFile(null) }}
className="p-1 text-content-muted hover:bg-surface-hover rounded"
title="Cancel"
>
<X size={16} />
</button>
</div>
</td>
</tr>
)}
{/* Template rows */}
{isLoading && (
<tr><td colSpan={11} className="px-3 py-4 text-center text-content-muted">Loading...</td></tr>
)}
{templates?.map((t) => {
const isEditing = editingId === t.id
return (
<tr key={t.id} className="border-b hover:bg-surface-hover/50">
<td className="px-3 py-2">
{isEditing ? (
<input
className={inputCls + ' w-40'}
value={editDraft.name ?? t.name}
onChange={(e) => setEditDraft({ ...editDraft, name: e.target.value })}
/>
) : (
<span className="font-medium">{t.name}</span>
)}
</td>
<td className="px-3 py-2">
{isEditing ? (
<select
className={inputCls}
value={editDraft.category_key ?? t.category_key ?? ''}
onChange={(e) => setEditDraft({ ...editDraft, category_key: e.target.value || null })}
>
<option value="">Any</option>
{ALL_CATEGORIES.map((c) => (
<option key={c.key} value={c.key}>{c.label}</option>
))}
</select>
) : (
t.category_key || <span className="text-content-muted">Any</span>
)}
</td>
<td className="px-3 py-2">
{isEditing ? (
<div className="flex flex-col gap-0.5 max-h-32 overflow-y-auto">
{outputTypes?.map((ot: OutputType) => {
const checked = (editDraft.output_type_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 current = editDraft.output_type_ids ?? []
const next = checked
? current.filter((id: string) => id !== ot.id)
: [...current, ot.id]
setEditDraft({ ...editDraft, output_type_ids: next })
}}
/>
{ot.name}
</label>
)
})}
</div>
) : (
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">
{isEditing ? (
<input
className={inputCls + ' w-28'}
value={editDraft.target_collection ?? t.target_collection}
onChange={(e) => setEditDraft({ ...editDraft, target_collection: e.target.value })}
/>
) : (
<code className="text-xs bg-surface-muted px-1 rounded">{t.target_collection}</code>
)}
</td>
<td className="px-3 py-2 text-center">
{isEditing ? (
<input
type="checkbox"
checked={editDraft.material_replace_enabled ?? t.material_replace_enabled}
onChange={(e) => setEditDraft({ ...editDraft, material_replace_enabled: e.target.checked })}
/>
) : (
t.material_replace_enabled ? (
<span className="text-status-success-text text-xs font-medium">Yes</span>
) : (
<span className="text-content-muted text-xs">No</span>
)
)}
</td>
<td className="px-3 py-2 text-center">
{isEditing ? (
<input
type="checkbox"
checked={editDraft.lighting_only ?? t.lighting_only}
onChange={(e) => setEditDraft({ ...editDraft, lighting_only: e.target.checked })}
/>
) : (
t.lighting_only ? (
<span className="text-status-warning-text text-xs font-medium">HDR</span>
) : (
<span className="text-content-muted text-xs"></span>
)
)}
</td>
<td className="px-3 py-2 text-center">
{isEditing ? (
<input
type="checkbox"
checked={editDraft.shadow_catcher_enabled ?? t.shadow_catcher_enabled}
title="Enable Shadowcatcher collection (Cycles only)"
onChange={(e) => setEditDraft({ ...editDraft, shadow_catcher_enabled: e.target.checked })}
/>
) : (
t.shadow_catcher_enabled ? (
<span className="text-violet-600 text-xs font-medium">On</span>
) : (
<span className="text-content-muted text-xs"></span>
)
)}
</td>
<td className="px-3 py-2 text-center">
{isEditing ? (
<input
type="checkbox"
checked={editDraft.camera_orbit ?? t.camera_orbit}
title="Rotate camera around product (better GPU performance)"
onChange={(e) => setEditDraft({ ...editDraft, camera_orbit: e.target.checked })}
/>
) : (
t.camera_orbit ? (
<span className="text-teal-600 text-xs font-medium">Cam</span>
) : (
<span className="text-content-muted text-xs">Obj</span>
)
)}
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1">
<span className="text-xs text-content-secondary truncate max-w-[120px]" title={t.original_filename}>
{t.original_filename}
</span>
<button
onClick={() => { setReuploadId(t.id); reuploadRef.current?.click() }}
className="p-0.5 text-accent hover:bg-surface-hover rounded"
title="Re-upload .blend"
>
<Upload size={12} />
</button>
<a
href={`/api/render-templates/${t.id}/download`}
className="p-0.5 text-accent hover:bg-surface-hover rounded"
title="Download .blend"
>
<Download size={12} />
</a>
</div>
</td>
<td className="px-3 py-2 text-center">
{isEditing ? (
<input
type="checkbox"
checked={editDraft.is_active ?? t.is_active}
onChange={(e) => setEditDraft({ ...editDraft, is_active: e.target.checked })}
/>
) : (
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">
{isEditing ? (
<div className="flex gap-1">
<button onClick={saveEdit} className="p-1 text-status-success-text hover:bg-surface-hover rounded" title="Save">
<Check size={16} />
</button>
<button onClick={() => setEditingId(null)} className="p-1 text-content-muted hover:bg-surface-muted rounded" title="Cancel">
<X size={16} />
</button>
</div>
) : (
<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={() => {
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>
)
})}
{!isLoading && (!templates || templates.length === 0) && !showAdd && (
<tr>
<td colSpan={11} 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>
)
}