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:
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Pencil, Trash2, Check, X } from 'lucide-react'
|
||||
import { Plus, Pencil, Trash2, Check, X, Copy } from 'lucide-react'
|
||||
import {
|
||||
listGlobalRenderPositions,
|
||||
createGlobalRenderPosition,
|
||||
@@ -18,6 +18,7 @@ interface EditState {
|
||||
rotation_z: number
|
||||
is_default: boolean
|
||||
sort_order: number
|
||||
focal_length_mm: number | null
|
||||
}
|
||||
|
||||
const EMPTY_EDIT: EditState = {
|
||||
@@ -28,6 +29,7 @@ const EMPTY_EDIT: EditState = {
|
||||
rotation_z: 0,
|
||||
is_default: false,
|
||||
sort_order: 0,
|
||||
focal_length_mm: null,
|
||||
}
|
||||
|
||||
export default function GlobalRenderPositionsPanel() {
|
||||
@@ -66,6 +68,7 @@ export default function GlobalRenderPositionsPanel() {
|
||||
rotation_z: pos.rotation_z,
|
||||
is_default: pos.is_default,
|
||||
sort_order: pos.sort_order,
|
||||
focal_length_mm: pos.focal_length_mm,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -129,6 +132,7 @@ export default function GlobalRenderPositionsPanel() {
|
||||
<th className="pb-1 pr-3 text-center">Rot X°</th>
|
||||
<th className="pb-1 pr-3 text-center">Rot Y°</th>
|
||||
<th className="pb-1 pr-3 text-center">Rot Z°</th>
|
||||
<th className="pb-1 pr-3 text-center">Focal mm</th>
|
||||
<th className="pb-1 pr-3 text-center">Default</th>
|
||||
<th className="pb-1 pr-3 text-center">Order</th>
|
||||
<th className="pb-1" />
|
||||
@@ -151,6 +155,16 @@ export default function GlobalRenderPositionsPanel() {
|
||||
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_x')}</td>
|
||||
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_y')}</td>
|
||||
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_z')}</td>
|
||||
<td className="py-1 pr-2 text-center">
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
placeholder="50"
|
||||
className="input w-16 text-sm"
|
||||
value={editing!.focal_length_mm ?? ''}
|
||||
onChange={(e) => setEditing({ ...editing!, focal_length_mm: e.target.value ? parseFloat(e.target.value) : null })}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1 pr-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -179,12 +193,32 @@ export default function GlobalRenderPositionsPanel() {
|
||||
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_x}</td>
|
||||
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_y}</td>
|
||||
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_z}</td>
|
||||
<td className="py-1.5 pr-3 text-center text-content-muted">
|
||||
{pos.focal_length_mm != null ? pos.focal_length_mm : <span className="opacity-40">50</span>}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-center">
|
||||
{pos.is_default && <span className="text-accent text-xs font-medium">✓</span>}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.sort_order}</td>
|
||||
<td className="py-1.5 flex items-center gap-1">
|
||||
<button className="btn btn-xs" onClick={() => startEdit(pos)}><Pencil size={12} /></button>
|
||||
<button
|
||||
className="btn btn-xs text-blue-500"
|
||||
onClick={() => createMut.mutate({
|
||||
name: `${pos.name} (copy)`,
|
||||
rotation_x: pos.rotation_x,
|
||||
rotation_y: pos.rotation_y,
|
||||
rotation_z: pos.rotation_z,
|
||||
is_default: false,
|
||||
sort_order: pos.sort_order,
|
||||
focal_length_mm: pos.focal_length_mm,
|
||||
sensor_width_mm: pos.sensor_width_mm,
|
||||
})}
|
||||
disabled={createMut.isPending}
|
||||
title="Duplicate"
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-xs text-red-500"
|
||||
onClick={() => { if (confirm(`Delete "${pos.name}"?`)) deleteMut.mutate(pos.id) }}
|
||||
@@ -213,6 +247,16 @@ export default function GlobalRenderPositionsPanel() {
|
||||
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_x')}</td>
|
||||
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_y')}</td>
|
||||
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_z')}</td>
|
||||
<td className="py-1 pr-2 text-center">
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
placeholder="50"
|
||||
className="input w-16 text-sm"
|
||||
value={editing.focal_length_mm ?? ''}
|
||||
onChange={(e) => setEditing({ ...editing, focal_length_mm: e.target.value ? parseFloat(e.target.value) : null })}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1 pr-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Pencil, Trash2, Plus, Check, X, ChevronDown } from 'lucide-react'
|
||||
import { Pencil, Trash2, Plus, Check, X, ChevronDown, Copy } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
listOutputTypes, createOutputType, updateOutputType, deleteOutputType,
|
||||
@@ -174,6 +174,31 @@ export default function OutputTypeTable() {
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to delete'),
|
||||
})
|
||||
|
||||
const duplicateMut = useMutation({
|
||||
mutationFn: (ot: OutputType) => createOutputType({
|
||||
name: `${ot.name} (copy)`,
|
||||
description: ot.description,
|
||||
renderer: ot.renderer,
|
||||
render_settings: ot.render_settings,
|
||||
output_format: ot.output_format,
|
||||
sort_order: ot.sort_order,
|
||||
compatible_categories: ot.compatible_categories,
|
||||
render_backend: ot.render_backend,
|
||||
is_animation: ot.is_animation,
|
||||
transparent_bg: ot.transparent_bg,
|
||||
cycles_device: ot.cycles_device,
|
||||
pricing_tier_id: ot.pricing_tier_id,
|
||||
workflow_definition_id: ot.workflow_definition_id,
|
||||
is_active: ot.is_active,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Output type duplicated')
|
||||
qc.invalidateQueries({ queryKey: ['output-types-admin'] })
|
||||
qc.invalidateQueries({ queryKey: ['output-types'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to duplicate'),
|
||||
})
|
||||
|
||||
// Check if transparent_bg / bg_color controls should be visible
|
||||
function showTransparentBg(renderer: string, _format: string) {
|
||||
return renderer === 'blender'
|
||||
@@ -710,6 +735,14 @@ export default function OutputTypeTable() {
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon text-content-muted hover:text-blue-500"
|
||||
onClick={() => duplicateMut.mutate(ot)}
|
||||
disabled={duplicateMut.isPending}
|
||||
title="Duplicate output type"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon text-content-muted hover:text-red-500"
|
||||
onClick={() => {
|
||||
|
||||
@@ -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">∗</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)
|
||||
|
||||
Reference in New Issue
Block a user