feat: per-position camera settings, material alias dialog, product delete, media browser links

- Per-render-position focal_length_mm/sensor_width_mm (DB → pipeline → Blender)
- FOV-based camera distance with min clamp fix for wide-angle lenses
- Unmapped materials blocking dialog on "Dispatch Renders" with batch alias creation
- Material check endpoint (GET /orders/{id}/check-materials)
- Batch alias endpoint (POST /materials/batch-aliases)
- Quick-map "No alias" badges on Materials page
- Full product hard-delete with storage cleanup (MinIO + disk files + orphaned CadFile)
- Delete button on ProductDetail page with confirmation
- Clickable product names in Media Browser (links to product page)
- Single-line render dispatch/retry (POST /orders/{id}/lines/{id}/dispatch-render)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 12:16:37 +01:00
parent 0020376702
commit b583b0d7a2
48 changed files with 1827 additions and 376 deletions
@@ -1,11 +1,12 @@
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 { 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,
@@ -40,6 +41,7 @@ export default function RenderTemplateTable() {
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)
@@ -58,10 +60,14 @@ export default function RenderTemplateTable() {
const createMut = useMutation({
mutationFn: () => {
if (!addFile) throw new Error('Please select a .blend file')
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())
fd.append('file', addFile)
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')
@@ -76,6 +82,7 @@ export default function RenderTemplateTable() {
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'),
@@ -111,6 +118,24 @@ export default function RenderTemplateTable() {
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({
@@ -264,31 +289,45 @@ export default function RenderTemplateTable() {
/>
</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>
<div className="flex flex-col gap-1">
<label className="flex items-center gap-1 text-xs cursor-pointer text-accent hover:text-accent-hover">
<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={inputCls + ' text-xs w-32'}
value={cloneBlendFrom}
onChange={(e) => { setCloneBlendFrom(e.target.value); setAddFile(null) }}
>
<option value="">or re-use existing</option>
{templates?.map((t) => (
<option key={t.id} value={t.id}>{t.original_filename} ({t.name})</option>
))}
</select>
)}
</div>
</td>
<td />
<td className="px-3 py-2">
<div className="flex gap-1">
<button
onClick={() => createMut.mutate()}
disabled={!form.name.trim() || !addFile || createMut.isPending}
disabled={!form.name.trim() || (!addFile && !cloneBlendFrom) || 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) }}
onClick={() => { setShowAdd(false); setForm(EMPTY_FORM); setAddFile(null); setCloneBlendFrom('') }}
className="p-1 text-content-muted hover:bg-surface-hover rounded"
title="Cancel"
>
@@ -446,6 +485,9 @@ export default function RenderTemplateTable() {
</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">&#8727;</span>
)}
<span className="text-xs text-content-secondary truncate max-w-[120px]" title={t.original_filename}>
{t.original_filename}
</span>
@@ -495,6 +537,9 @@ export default function RenderTemplateTable() {
<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)