10d05bd2e7
- 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>
530 lines
23 KiB
TypeScript
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>
|
|
)
|
|
}
|