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:
@@ -87,3 +87,37 @@ export async function seedAliases(): Promise<{ inserted: number; total: number }
|
||||
const res = await api.post<{ inserted: number; total: number }>('/materials/seed-aliases')
|
||||
return res.data
|
||||
}
|
||||
|
||||
// --- Material check / batch alias ---
|
||||
|
||||
export interface MaterialSuggestion {
|
||||
id: string
|
||||
name: string
|
||||
schaeffler_code: string
|
||||
}
|
||||
|
||||
export interface UnmappedMaterial {
|
||||
raw_name: string
|
||||
suggestions: MaterialSuggestion[]
|
||||
}
|
||||
|
||||
export interface UnmappedMaterialCheck {
|
||||
unmapped: UnmappedMaterial[]
|
||||
total_materials: number
|
||||
mapped_count: number
|
||||
}
|
||||
|
||||
export async function checkOrderMaterials(orderId: string): Promise<UnmappedMaterialCheck> {
|
||||
const res = await api.get<UnmappedMaterialCheck>(`/orders/${orderId}/check-materials`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function batchCreateAliases(
|
||||
mappings: Array<{ alias: string; material_id: string }>
|
||||
): Promise<{ created: number; skipped: number }> {
|
||||
const res = await api.post<{ created: number; skipped: number }>(
|
||||
'/materials/batch-aliases',
|
||||
{ mappings }
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -133,3 +133,6 @@ export const archiveMediaAsset = (id: string): Promise<void> =>
|
||||
|
||||
export const deleteMediaAssetPermanent = (id: string): Promise<void> =>
|
||||
api.delete(`/media/${id}/permanent`).then(() => undefined)
|
||||
|
||||
export const batchDeleteAssets = (ids: string[]): Promise<{ deleted: number; requested: number }> =>
|
||||
api.post('/media/batch-delete', ids).then(r => r.data)
|
||||
|
||||
@@ -235,6 +235,13 @@ export async function cancelLineRender(orderId: string, lineId: string) {
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function dispatchLineRender(orderId: string, lineId: string) {
|
||||
const res = await api.post<{ dispatched: boolean; line_id: string }>(
|
||||
`/orders/${orderId}/lines/${lineId}/dispatch-render`
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function cancelOrderRenders(orderId: string) {
|
||||
const res = await api.post<{ cancelled: number; order_status: string; errors: string[] | null }>(
|
||||
`/orders/${orderId}/cancel-renders`
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface GlobalRenderPosition {
|
||||
rotation_z: number
|
||||
is_default: boolean
|
||||
sort_order: number
|
||||
focal_length_mm: number | null
|
||||
sensor_width_mm: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -19,6 +21,8 @@ export interface GlobalRenderPositionCreate {
|
||||
rotation_z?: number
|
||||
is_default?: boolean
|
||||
sort_order?: number
|
||||
focal_length_mm?: number | null
|
||||
sensor_width_mm?: number | null
|
||||
}
|
||||
|
||||
export interface GlobalRenderPositionPatch {
|
||||
@@ -28,6 +32,8 @@ export interface GlobalRenderPositionPatch {
|
||||
rotation_z?: number
|
||||
is_default?: boolean
|
||||
sort_order?: number
|
||||
focal_length_mm?: number | null
|
||||
sensor_width_mm?: number | null
|
||||
}
|
||||
|
||||
export async function listGlobalRenderPositions(): Promise<GlobalRenderPosition[]> {
|
||||
|
||||
@@ -39,6 +39,26 @@ export async function createRenderTemplate(formData: FormData): Promise<RenderTe
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function duplicateRenderTemplate(
|
||||
sourceId: string,
|
||||
overrides: Partial<Pick<RenderTemplate, 'name' | 'category_key' | 'target_collection' | 'material_replace_enabled' | 'lighting_only' | 'shadow_catcher_enabled' | 'camera_orbit'>> & { output_type_ids?: string[] },
|
||||
): Promise<RenderTemplate> {
|
||||
const fd = new FormData();
|
||||
fd.append('name', overrides.name || 'Untitled (copy)');
|
||||
fd.append('clone_blend_from', sourceId);
|
||||
fd.append('category_key', overrides.category_key || '');
|
||||
fd.append('output_type_ids', (overrides.output_type_ids || []).join(','));
|
||||
fd.append('target_collection', overrides.target_collection || 'Product');
|
||||
fd.append('material_replace_enabled', String(overrides.material_replace_enabled ?? false));
|
||||
fd.append('lighting_only', String(overrides.lighting_only ?? false));
|
||||
fd.append('shadow_catcher_enabled', String(overrides.shadow_catcher_enabled ?? false));
|
||||
fd.append('camera_orbit', String(overrides.camera_orbit ?? true));
|
||||
const { data } = await api.post<RenderTemplate>('/render-templates', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateRenderTemplate(
|
||||
id: string,
|
||||
updates: Partial<Pick<RenderTemplate, 'name' | 'category_key' | 'output_type_ids' | 'target_collection' | 'material_replace_enabled' | 'lighting_only' | 'shadow_catcher_enabled' | 'camera_orbit' | 'is_active'>>,
|
||||
|
||||
@@ -146,7 +146,7 @@ export interface CeleryWorkersResponse {
|
||||
}
|
||||
|
||||
export interface ScaleRequest {
|
||||
service: 'render-worker' | 'worker' | 'worker-thumbnail'
|
||||
service: 'render-worker' | 'worker'
|
||||
count: number
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,6 +13,7 @@ import MaterialPanel, { type IsolateMode } from './MaterialPanel'
|
||||
import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, type MeshRegistryEntry } from './cadUtils'
|
||||
import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries'
|
||||
import { useGeometryMerge } from './useGeometryMerge'
|
||||
import WebGLErrorBoundary from './WebGLErrorBoundary'
|
||||
|
||||
type ViewMode = 'solid' | 'wireframe'
|
||||
type LightPreset = 'studio' | 'warehouse' | 'sunset' | 'park' | 'city'
|
||||
@@ -518,6 +519,7 @@ export default function InlineCadViewer({
|
||||
|
||||
{/* ── Canvas area ── */}
|
||||
<div className="flex-1 relative" onClick={(e) => e.stopPropagation()}>
|
||||
<WebGLErrorBoundary>
|
||||
<Canvas
|
||||
gl={{ powerPreference: 'high-performance', antialias: true }}
|
||||
dpr={[1, 1.5]}
|
||||
@@ -571,6 +573,7 @@ export default function InlineCadViewer({
|
||||
<OrbitControls ref={controlsRef} makeDefault />
|
||||
<CameraAutoFit sceneRef={sceneRef} controlsRef={controlsRef} fitTrigger={fitTrigger} />
|
||||
</Canvas>
|
||||
</WebGLErrorBoundary>
|
||||
|
||||
{/* Material assignment panel */}
|
||||
{pinnedPart && (
|
||||
|
||||
@@ -35,6 +35,7 @@ import MaterialPanel, { type IsolateMode } from './MaterialPanel'
|
||||
import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, type MeshRegistryEntry } from './cadUtils'
|
||||
import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries'
|
||||
import { useGeometryMerge } from './useGeometryMerge'
|
||||
import WebGLErrorBoundary from './WebGLErrorBoundary'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -1033,6 +1034,7 @@ export default function ThreeDViewer({
|
||||
F fit · W wire · G grid · S shadow · click part to assign · Esc close
|
||||
</div>
|
||||
|
||||
<WebGLErrorBoundary>
|
||||
<Canvas
|
||||
gl={{ preserveDrawingBuffer: true, powerPreference: 'high-performance' }}
|
||||
dpr={[1, 1.5]}
|
||||
@@ -1135,6 +1137,7 @@ export default function ThreeDViewer({
|
||||
/>
|
||||
)}
|
||||
</Canvas>
|
||||
</WebGLErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Wraps <Canvas> from @react-three/fiber to catch WebGL context creation
|
||||
* failures (e.g. Chrome GPU sandbox) and show a graceful fallback instead
|
||||
* of crashing the entire React tree.
|
||||
*/
|
||||
export default class WebGLErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ error: string | null }
|
||||
> {
|
||||
constructor(props: { children: ReactNode }) {
|
||||
super(props)
|
||||
this.state = { error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): { error: string } {
|
||||
return { error: error.message || 'WebGL context could not be created' }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, _info: ErrorInfo): void {
|
||||
console.warn('[WebGLErrorBoundary]', error.message)
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-900 text-white gap-3 p-8 text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-yellow-400">
|
||||
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
|
||||
<path d="M12 9v4" /><path d="M12 17h.01" />
|
||||
</svg>
|
||||
<p className="text-lg font-medium">3D Viewer unavailable</p>
|
||||
<p className="text-sm text-gray-400 max-w-md">
|
||||
Your browser could not create a WebGL context. This may be caused by GPU sandbox restrictions or missing graphics drivers.
|
||||
Try a different browser or launch Chrome with <code className="bg-gray-800 px-1 rounded">--disable-gpu-sandbox</code>.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import Modal from '../shared/Modal'
|
||||
import {
|
||||
listMaterials,
|
||||
batchCreateAliases,
|
||||
type UnmappedMaterial,
|
||||
type Material,
|
||||
} from '../../api/materials'
|
||||
|
||||
interface Props {
|
||||
unmapped: UnmappedMaterial[]
|
||||
onResolved: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function UnmappedMaterialsDialog({ unmapped, onResolved, onCancel }: Props) {
|
||||
const [mappings, setMappings] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Load all library materials for the "all materials" fallback in dropdowns
|
||||
const { data: allMaterials } = useQuery({
|
||||
queryKey: ['materials'],
|
||||
queryFn: listMaterials,
|
||||
})
|
||||
|
||||
const libraryMaterials = (allMaterials ?? []).filter(
|
||||
(m: Material) => m.schaeffler_code !== null
|
||||
)
|
||||
|
||||
const allMapped = unmapped.every((u) => mappings[u.raw_name])
|
||||
|
||||
async function handleProceed() {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const batch = unmapped.map((u) => ({
|
||||
alias: u.raw_name,
|
||||
material_id: mappings[u.raw_name],
|
||||
}))
|
||||
await batchCreateAliases(batch)
|
||||
onResolved()
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create aliases')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Unmapped Materials" onClose={onCancel} size="lg">
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Warning banner */}
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-content-secondary">
|
||||
The following materials have no alias to a library material.
|
||||
Map them before rendering to avoid magenta placeholder materials.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mapping table */}
|
||||
<div className="space-y-3">
|
||||
{unmapped.map((u) => (
|
||||
<div
|
||||
key={u.raw_name}
|
||||
className="flex items-center gap-4 p-3 rounded-lg border border-border-default"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-content truncate block">
|
||||
{u.raw_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-72 shrink-0">
|
||||
<select
|
||||
className="w-full text-sm rounded-md border border-border-default px-3 py-1.5 text-content"
|
||||
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
||||
value={mappings[u.raw_name] ?? ''}
|
||||
onChange={(e) =>
|
||||
setMappings((prev) => ({ ...prev, [u.raw_name]: e.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="">Select library material...</option>
|
||||
{/* Suggestions first */}
|
||||
{u.suggestions.length > 0 && (
|
||||
<optgroup label="Suggestions">
|
||||
{u.suggestions.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{/* All library materials */}
|
||||
<optgroup label="All Library Materials">
|
||||
{libraryMaterials
|
||||
.sort((a: Material, b: Material) => a.name.localeCompare(b.name))
|
||||
.map((m: Material) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm rounded-md border border-border-default text-content-secondary hover:bg-surface-muted"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleProceed}
|
||||
disabled={!allMapped || saving}
|
||||
className="px-4 py-2 text-sm rounded-md bg-brand text-white hover:bg-brand-hover disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Map All & Proceed'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import { ChevronLeft, ChevronRight, X, Download } from 'lucide-react'
|
||||
|
||||
export interface LightboxItem {
|
||||
url: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: LightboxItem[]
|
||||
index: number
|
||||
onClose: () => void
|
||||
onIndexChange: (i: number) => void
|
||||
}
|
||||
|
||||
export default function ImageLightbox({ items, index, onClose, onIndexChange }: Props) {
|
||||
const item = items[index]
|
||||
const hasPrev = index > 0
|
||||
const hasNext = index < items.length - 1
|
||||
|
||||
const prev = useCallback(() => { if (hasPrev) onIndexChange(index - 1) }, [hasPrev, index, onIndexChange])
|
||||
const next = useCallback(() => { if (hasNext) onIndexChange(index + 1) }, [hasNext, index, onIndexChange])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
else if (e.key === 'ArrowLeft') prev()
|
||||
else if (e.key === 'ArrowRight') next()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose, prev, next])
|
||||
|
||||
// Prevent body scroll while open
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [])
|
||||
|
||||
if (!item) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Close */}
|
||||
<button
|
||||
className="absolute top-4 right-4 p-2 rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors z-10"
|
||||
onClick={onClose}
|
||||
title="Close (Esc)"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
|
||||
{/* Counter + label */}
|
||||
<div className="absolute top-4 left-4 text-white/70 text-sm z-10 flex items-center gap-3">
|
||||
<span>{index + 1} / {items.length}</span>
|
||||
{item.label && <span className="text-white/50">{item.label}</span>}
|
||||
</div>
|
||||
|
||||
{/* Download */}
|
||||
<a
|
||||
href={item.url}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="absolute top-4 right-16 p-2 rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors z-10"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Download"
|
||||
>
|
||||
<Download size={18} />
|
||||
</a>
|
||||
|
||||
{/* Prev arrow */}
|
||||
{hasPrev && (
|
||||
<button
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 p-2 rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors z-10"
|
||||
onClick={(e) => { e.stopPropagation(); prev() }}
|
||||
title="Previous"
|
||||
>
|
||||
<ChevronLeft size={28} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Next arrow */}
|
||||
{hasNext && (
|
||||
<button
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-2 rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors z-10"
|
||||
onClick={(e) => { e.stopPropagation(); next() }}
|
||||
title="Next"
|
||||
>
|
||||
<ChevronRight size={28} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Image */}
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.label || 'Render'}
|
||||
className="max-h-[90vh] max-w-[90vw] object-contain select-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -243,6 +243,12 @@ export default function AdminPage() {
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
|
||||
})
|
||||
|
||||
const purgeRenderMediaMut = useMutation({
|
||||
mutationFn: () => api.delete('/admin/settings/purge-render-media'),
|
||||
onSuccess: (res) => toast.success(res.data.message || 'Render media purged'),
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
|
||||
})
|
||||
|
||||
const [smtpDraft, setSmtpDraft] = useState<Partial<Settings>>({})
|
||||
const smtp = { ...settings, ...smtpDraft } as Settings
|
||||
|
||||
@@ -1017,6 +1023,23 @@ export default function AdminPage() {
|
||||
</button>
|
||||
<p className="text-xs text-content-muted">Removes STEP files, thumbnails, and DB records not linked to any product.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => setConfirmState({
|
||||
open: true,
|
||||
title: 'Purge All Rendered Media',
|
||||
message: 'Delete ALL still renders and turntable animations? Thumbnails, GLBs, and USD masters are kept. This cannot be undone.',
|
||||
onConfirm: () => { purgeRenderMediaMut.mutate(); setConfirmState(s => ({ ...s, open: false })) },
|
||||
})}
|
||||
disabled={purgeRenderMediaMut.isPending}
|
||||
className="btn-secondary text-sm w-full justify-start text-red-500"
|
||||
title="Delete all still and turntable render media (files + DB records)"
|
||||
>
|
||||
<Trash2 size={14} className={purgeRenderMediaMut.isPending ? 'animate-spin' : ''} />
|
||||
{purgeRenderMediaMut.isPending ? 'Purging…' : 'Purge All Stills & Turntables'}
|
||||
</button>
|
||||
<p className="text-xs text-content-muted">Deletes all rendered images and animations. Thumbnails, GLBs, and USD files are preserved.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => reextractMetadataMut.mutate()}
|
||||
|
||||
@@ -4,10 +4,12 @@ import { toast } from 'sonner'
|
||||
import {
|
||||
Plus, Trash2, Pencil, Check, X, FlaskConical, Search, Wand2, Download,
|
||||
Wrench, Paintbrush, Shapes, HelpCircle, ChevronDown, ChevronRight, Tag,
|
||||
AlertTriangle, Link,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
listMaterials, createMaterial, updateMaterial, deleteMaterial,
|
||||
seedSchaefflerMaterials, addAlias, deleteAlias, seedAliases,
|
||||
batchCreateAliases,
|
||||
} from '../api/materials'
|
||||
import type { Material } from '../api/materials'
|
||||
import MaterialWizard from '../components/MaterialWizard'
|
||||
@@ -132,6 +134,25 @@ export default function MaterialsPage() {
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to remove alias'),
|
||||
})
|
||||
|
||||
const [quickMapTarget, setQuickMapTarget] = useState<Record<string, string>>({})
|
||||
|
||||
const quickMapMut = useMutation({
|
||||
mutationFn: ({ alias, material_id }: { alias: string; material_id: string }) =>
|
||||
batchCreateAliases([{ alias, material_id }]),
|
||||
onSuccess: () => {
|
||||
toast.success('Alias created')
|
||||
qc.invalidateQueries({ queryKey: ['materials'] })
|
||||
setQuickMapTarget({})
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create alias'),
|
||||
})
|
||||
|
||||
// Library materials (have schaeffler_code) for quick-map dropdown
|
||||
const libraryMaterials = useMemo(
|
||||
() => materials.filter((m) => m.schaeffler_code !== null).sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[materials]
|
||||
)
|
||||
|
||||
const startEdit = (mat: Material) => {
|
||||
setEditingId(mat.id)
|
||||
setEditName(mat.name)
|
||||
@@ -389,6 +410,48 @@ export default function MaterialsPage() {
|
||||
{mat.schaeffler_code != null && (
|
||||
<p className="text-xs text-content-muted font-mono">Nr: {mat.schaeffler_code}</p>
|
||||
)}
|
||||
{mat.schaeffler_code == null && mat.aliases.length === 0 && (
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded">
|
||||
<AlertTriangle size={10} /> No alias
|
||||
</span>
|
||||
{quickMapTarget[mat.id] !== undefined ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
className="text-[10px] border border-border-default rounded px-1 py-0.5"
|
||||
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
||||
value={quickMapTarget[mat.id] ?? ''}
|
||||
onChange={(e) => setQuickMapTarget((prev) => ({ ...prev, [mat.id]: e.target.value }))}
|
||||
>
|
||||
<option value="">Select target...</option>
|
||||
{libraryMaterials.map((lm) => (
|
||||
<option key={lm.id} value={lm.id}>{lm.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => quickMapTarget[mat.id] && quickMapMut.mutate({ alias: mat.name, material_id: quickMapTarget[mat.id] })}
|
||||
disabled={!quickMapTarget[mat.id] || quickMapMut.isPending}
|
||||
className="text-[10px] text-accent hover:text-accent-hover font-medium disabled:opacity-40"
|
||||
>
|
||||
Map
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setQuickMapTarget((prev) => { const n = { ...prev }; delete n[mat.id]; return n })}
|
||||
className="text-[10px] text-content-muted hover:text-content"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setQuickMapTarget((prev) => ({ ...prev, [mat.id]: '' }))}
|
||||
className="inline-flex items-center gap-0.5 text-[10px] text-accent hover:text-accent-hover font-medium"
|
||||
>
|
||||
<Link size={10} /> Map to library
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-content-muted truncate">{mat.description || '—'}</p>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Search, Image, Film, Box, Layers, FileCode2,
|
||||
ChevronLeft, ChevronRight, Download, Loader2,
|
||||
CheckSquare, Square, X, ZoomIn, Archive,
|
||||
CheckSquare, Square, X, ZoomIn, Archive, Trash2,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
getMediaAssets,
|
||||
zipDownloadAssets,
|
||||
batchDeleteAssets,
|
||||
} from '../api/media'
|
||||
import { toast } from 'sonner'
|
||||
import type { MediaAssetItem, MediaAssetType } from '../api/media'
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
@@ -142,7 +145,15 @@ function Lightbox({ asset, onClose }: { asset: MediaAssetItem; onClose: () => vo
|
||||
>
|
||||
<div className="flex items-center justify-between max-w-5xl mx-auto">
|
||||
<div className="space-y-0.5">
|
||||
{asset.product_name && <p className="font-medium">{asset.product_name}</p>}
|
||||
{asset.product_name && (
|
||||
asset.product_id ? (
|
||||
<Link to={`/products/${asset.product_id}`} className="font-medium hover:underline">
|
||||
{asset.product_name}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="font-medium">{asset.product_name}</p>
|
||||
)
|
||||
)}
|
||||
<p className="text-xs opacity-70">
|
||||
{asset.asset_type}
|
||||
{asset.product_pim_id && ` · ${asset.product_pim_id}`}
|
||||
@@ -268,9 +279,20 @@ function AssetCard({ asset, selected, onToggleSelect, onPreview }: AssetCardProp
|
||||
)}
|
||||
</div>
|
||||
{asset.product_name && (
|
||||
<p className="text-xs font-medium text-content truncate" title={asset.product_name}>
|
||||
{asset.product_name}
|
||||
</p>
|
||||
asset.product_id ? (
|
||||
<Link
|
||||
to={`/products/${asset.product_id}`}
|
||||
className="text-xs font-medium text-accent hover:text-accent-hover truncate block"
|
||||
title={asset.product_name}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{asset.product_name}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-xs font-medium text-content truncate" title={asset.product_name}>
|
||||
{asset.product_name}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
{asset.product_pim_id && (
|
||||
<p className="text-xs text-content-muted font-mono truncate">{asset.product_pim_id}</p>
|
||||
@@ -326,6 +348,8 @@ export default function MediaBrowserPage() {
|
||||
// Selection
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [zipping, setZipping] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
|
||||
// Lightbox
|
||||
const [previewAsset, setPreviewAsset] = useState<MediaAssetItem | null>(null)
|
||||
@@ -379,6 +403,8 @@ export default function MediaBrowserPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const qc = useQueryClient()
|
||||
|
||||
async function handleZipDownload() {
|
||||
if (selected.size === 0) return
|
||||
setZipping(true)
|
||||
@@ -389,6 +415,22 @@ export default function MediaBrowserPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
if (selected.size === 0) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
const result = await batchDeleteAssets(Array.from(selected))
|
||||
toast.success(`Deleted ${result.deleted} asset${result.deleted !== 1 ? 's' : ''}`)
|
||||
setSelected(new Set())
|
||||
setConfirmDelete(false)
|
||||
qc.invalidateQueries({ queryKey: ['media-browser'] })
|
||||
} catch {
|
||||
toast.error('Failed to delete assets')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Lightbox */}
|
||||
@@ -553,8 +595,39 @@ export default function MediaBrowserPage() {
|
||||
: <><Archive size={14} /> Download ZIP</>
|
||||
}
|
||||
</button>
|
||||
<div className="w-px h-5 bg-border-default" />
|
||||
{confirmDelete ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-red-500 font-medium">Delete {selected.size} asset{selected.size !== 1 ? 's' : ''}?</span>
|
||||
<button
|
||||
onClick={handleBatchDelete}
|
||||
disabled={deleting}
|
||||
className="flex items-center gap-1 text-sm font-medium text-white bg-red-500 hover:bg-red-600 px-2.5 py-1 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{deleting
|
||||
? <><Loader2 size={12} className="animate-spin" /> Deleting…</>
|
||||
: <><Trash2 size={12} /> Confirm</>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(false)}
|
||||
disabled={deleting}
|
||||
className="text-sm text-content-muted hover:text-content transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-red-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={14} /> Delete
|
||||
</button>
|
||||
)}
|
||||
<div className="w-px h-5 bg-border-default" />
|
||||
<button
|
||||
onClick={() => setSelected(new Set())}
|
||||
onClick={() => { setSelected(new Set()); setConfirmDelete(false) }}
|
||||
className="flex items-center gap-1 text-sm text-content-muted hover:text-content transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
XCircle, RotateCw, Info,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { getOrder, submitOrder, deleteOrder, unlinkCadFile, regenerateItemThumbnail, patchOrderItem, removeOrderLine, dispatchRenders, cancelLineRender, cancelOrderRenders, splitMissingStep, generateLinesFromItems, downloadOrderRenders, rejectOrder, resubmitOrder, rejectOrderLine } from '../api/orders'
|
||||
import { getOrder, submitOrder, deleteOrder, unlinkCadFile, regenerateItemThumbnail, patchOrderItem, removeOrderLine, dispatchRenders, cancelLineRender, dispatchLineRender, cancelOrderRenders, splitMissingStep, generateLinesFromItems, downloadOrderRenders, rejectOrder, resubmitOrder, rejectOrderLine } from '../api/orders'
|
||||
import { checkOrderMaterials, type UnmappedMaterial } from '../api/materials'
|
||||
import UnmappedMaterialsDialog from '../components/orders/UnmappedMaterialsDialog'
|
||||
import type { OrderItem, OrderLine } from '../api/orders'
|
||||
import { listOutputTypes } from '../api/outputTypes'
|
||||
import type { OutputType } from '../api/outputTypes'
|
||||
@@ -63,6 +65,9 @@ export default function OrderDetailPage() {
|
||||
const [genLinesOpen, setGenLinesOpen] = useState(false)
|
||||
const [genLinesSelected, setGenLinesSelected] = useState<Record<string, boolean>>({})
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
const [unmappedMaterials, setUnmappedMaterials] = useState<UnmappedMaterial[]>([])
|
||||
const [showMaterialDialog, setShowMaterialDialog] = useState(false)
|
||||
const [checkingMaterials, setCheckingMaterials] = useState(false)
|
||||
const [rejectModalOpen, setRejectModalOpen] = useState(false)
|
||||
const [rejectReason, setRejectReason] = useState('')
|
||||
const [rejectNotifyClient, setRejectNotifyClient] = useState(true)
|
||||
@@ -105,6 +110,24 @@ export default function OrderDetailPage() {
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Dispatch failed'),
|
||||
})
|
||||
|
||||
async function handleDispatch() {
|
||||
setCheckingMaterials(true)
|
||||
try {
|
||||
const result = await checkOrderMaterials(id!)
|
||||
if (result.unmapped.length > 0) {
|
||||
setUnmappedMaterials(result.unmapped)
|
||||
setShowMaterialDialog(true)
|
||||
} else {
|
||||
dispatchMut.mutate()
|
||||
}
|
||||
} catch {
|
||||
// If check fails, proceed with dispatch anyway
|
||||
dispatchMut.mutate()
|
||||
} finally {
|
||||
setCheckingMaterials(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelAllMut = useMutation({
|
||||
mutationFn: () => cancelOrderRenders(id!),
|
||||
onSuccess: (data) => {
|
||||
@@ -288,18 +311,20 @@ export default function OrderDetailPage() {
|
||||
)}
|
||||
{canDispatch && (
|
||||
<button
|
||||
onClick={() => dispatchMut.mutate()}
|
||||
onClick={handleDispatch}
|
||||
className="btn-secondary"
|
||||
disabled={dispatchMut.isPending}
|
||||
disabled={dispatchMut.isPending || checkingMaterials}
|
||||
>
|
||||
{order.status === 'completed' ? <RefreshCw size={16} /> : rp && rp.failed > 0 ? <RefreshCw size={16} /> : <Play size={16} />}
|
||||
{dispatchMut.isPending
|
||||
? 'Dispatching…'
|
||||
: order.status === 'completed'
|
||||
? 'Re-submit Renders'
|
||||
: rp && rp.failed > 0
|
||||
? 'Retry Failed'
|
||||
: 'Dispatch Renders'}
|
||||
{checkingMaterials
|
||||
? 'Checking materials…'
|
||||
: dispatchMut.isPending
|
||||
? 'Dispatching…'
|
||||
: order.status === 'completed'
|
||||
? 'Re-submit Renders'
|
||||
: rp && rp.failed > 0
|
||||
? 'Retry Failed'
|
||||
: 'Dispatch Renders'}
|
||||
</button>
|
||||
)}
|
||||
{canReject && (
|
||||
@@ -753,6 +778,18 @@ export default function OrderDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Unmapped Materials Dialog */}
|
||||
{showMaterialDialog && (
|
||||
<UnmappedMaterialsDialog
|
||||
unmapped={unmappedMaterials}
|
||||
onResolved={() => {
|
||||
setShowMaterialDialog(false)
|
||||
dispatchMut.mutate()
|
||||
}}
|
||||
onCancel={() => setShowMaterialDialog(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reject Order Modal */}
|
||||
{rejectModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
@@ -846,6 +883,15 @@ function OrderLineRow({
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Cancel failed'),
|
||||
})
|
||||
|
||||
const dispatchLineMut = useMutation({
|
||||
mutationFn: () => dispatchLineRender(orderId, line.id),
|
||||
onSuccess: () => {
|
||||
toast.success('Render re-submitted')
|
||||
qc.invalidateQueries({ queryKey: ['order', orderId] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Re-submit failed'),
|
||||
})
|
||||
|
||||
const rejectLineMut = useMutation({
|
||||
mutationFn: () => rejectOrderLine(orderId, line.id, rejectLineReason),
|
||||
onSuccess: () => {
|
||||
@@ -986,6 +1032,19 @@ function OrderLineRow({
|
||||
{cancelMut.isPending ? <Loader2 size={12} className="animate-spin" /> : <Ban size={12} />}
|
||||
</button>
|
||||
)}
|
||||
{isPrivileged && (line.render_status === 'failed' || line.render_status === 'cancelled' || line.render_status === 'pending') && line.output_type_id && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
dispatchLineMut.mutate()
|
||||
}}
|
||||
disabled={dispatchLineMut.isPending}
|
||||
className="text-content-muted hover:text-green-500 transition-colors"
|
||||
title="Re-submit this render"
|
||||
>
|
||||
{dispatchLineMut.isPending ? <Loader2 size={12} className="animate-spin" /> : <RotateCw size={12} />}
|
||||
</button>
|
||||
)}
|
||||
{line.render_log && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
getProduct, updateProduct, uploadProductCad, saveProductCadMaterials, regenerateProduct,
|
||||
reprocessProduct, reassignMaterialsFromExcel, getProductOrders, getProductRenders,
|
||||
createRenderPosition, updateRenderPosition, deleteRenderPosition, deleteProductRender,
|
||||
downloadProductRenders,
|
||||
downloadProductRenders, deleteProduct,
|
||||
} from '../api/products'
|
||||
import type { Product, CadPartMaterial, ProductRender, RenderPosition } from '../api/products'
|
||||
import { listMaterials } from '../api/materials'
|
||||
@@ -20,10 +20,12 @@ import MaterialInput from '../components/shared/MaterialInput'
|
||||
import MaterialWizard from '../components/MaterialWizard'
|
||||
import { useAuthStore, isAdmin as checkIsAdmin, isPrivileged as checkIsPrivileged } from '../store/auth'
|
||||
import { generateGltfGeometry, resetStuckProcessing } from '../api/cad'
|
||||
import { triggerUsdMasterGeneration } from '../api/sceneManifest'
|
||||
import { listMediaAssets as getMediaAssets } from '../api/media'
|
||||
import InlineCadViewer from '../components/cad/InlineCadViewer'
|
||||
import { convertCadPartMaterials, normalizeMeshName } from '../components/cad/cadUtils'
|
||||
import RenderInfoModal from '../components/renders/RenderInfoModal'
|
||||
import ImageLightbox, { type LightboxItem } from '../components/shared/ImageLightbox'
|
||||
|
||||
function GlbDownloadButton({
|
||||
label, url, filename, onGenerate, isGenerating, title,
|
||||
@@ -147,6 +149,7 @@ export default function ProductDetailPage() {
|
||||
const [wizardOpen, setWizardOpen] = useState(false)
|
||||
const [wizardTargetIdx, setWizardTargetIdx] = useState<number | null>(null)
|
||||
const [showCadInfo, setShowCadInfo] = useState(false)
|
||||
const [confirmDeleteProduct, setConfirmDeleteProduct] = useState(false)
|
||||
|
||||
const { data: product, isLoading } = useQuery({
|
||||
queryKey: ['product', id],
|
||||
@@ -221,6 +224,7 @@ export default function ProductDetailPage() {
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
const [filterOutputType, setFilterOutputType] = useState<string | null>(null)
|
||||
const [downloadLoading, setDownloadLoading] = useState(false)
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
|
||||
|
||||
const outputTypeNames = useMemo(() => {
|
||||
const names = renders.map(r => r.output_type_name).filter((n): n is string => n !== null)
|
||||
@@ -232,6 +236,15 @@ export default function ProductDetailPage() {
|
||||
return renders.filter(r => r.output_type_name === filterOutputType)
|
||||
}, [renders, filterOutputType])
|
||||
|
||||
// Build lightbox items from filtered image-only renders
|
||||
const lightboxItems: LightboxItem[] = useMemo(
|
||||
() => filteredRenders.filter(r => !r.is_video).map(r => ({
|
||||
url: r.render_url,
|
||||
label: [r.render_position_name, r.output_type_name].filter(Boolean).join(' — '),
|
||||
})),
|
||||
[filteredRenders],
|
||||
)
|
||||
|
||||
const toggleSelect = (lineId: string) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
@@ -325,6 +338,15 @@ export default function ProductDetailPage() {
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Update failed'),
|
||||
})
|
||||
|
||||
const deleteProductMut = useMutation({
|
||||
mutationFn: () => deleteProduct(id!, true),
|
||||
onSuccess: () => {
|
||||
toast.success('Product deleted permanently')
|
||||
navigate('/products')
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Delete failed'),
|
||||
})
|
||||
|
||||
const cadUploadMut = useMutation({
|
||||
mutationFn: (file: File) => uploadProductCad(id!, file),
|
||||
onSuccess: () => {
|
||||
@@ -360,6 +382,15 @@ export default function ProductDetailPage() {
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to queue GLB export'),
|
||||
})
|
||||
|
||||
const generateUsdMasterMut = useMutation({
|
||||
mutationFn: () => triggerUsdMasterGeneration(product!.cad_file_id!),
|
||||
onSuccess: () => {
|
||||
toast.info('USD master generation queued')
|
||||
qc.invalidateQueries({ queryKey: ['media-assets', cadFileId, 'usd_master'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to queue USD master'),
|
||||
})
|
||||
|
||||
const resetStuckMut = useMutation({
|
||||
mutationFn: () => resetStuckProcessing(product!.cad_file_id!),
|
||||
onSuccess: (res) => {
|
||||
@@ -504,12 +535,40 @@ export default function ProductDetailPage() {
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="btn-secondary text-sm"
|
||||
onClick={() => setEditMode(true)}
|
||||
>
|
||||
<Pencil size={14} /> Edit
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
className="btn-secondary text-sm"
|
||||
onClick={() => setEditMode(true)}
|
||||
>
|
||||
<Pencil size={14} /> Edit
|
||||
</button>
|
||||
{confirmDeleteProduct ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-red-500 font-medium">Delete permanently?</span>
|
||||
<button
|
||||
onClick={() => deleteProductMut.mutate()}
|
||||
disabled={deleteProductMut.isPending}
|
||||
className="px-2.5 py-1.5 text-xs font-medium text-white bg-red-500 hover:bg-red-600 rounded-md transition-colors disabled:opacity-50 flex items-center gap-1"
|
||||
>
|
||||
{deleteProductMut.isPending ? <Loader2 size={12} className="animate-spin" /> : <Trash2 size={12} />}
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDeleteProduct(false)}
|
||||
className="text-xs text-content-muted hover:text-content"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="px-3 py-2 rounded-lg text-sm font-medium border border-red-300 text-red-500 hover:bg-red-50 transition-colors flex items-center gap-1.5"
|
||||
onClick={() => setConfirmDeleteProduct(true)}
|
||||
>
|
||||
<Trash2 size={14} /> Delete
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -742,9 +801,9 @@ export default function ProductDetailPage() {
|
||||
label="USD Master"
|
||||
url={usdMasterUrl}
|
||||
filename={`${product.name ?? product.pim_id}_master.usd`}
|
||||
onGenerate={() => generateGeometryGlbMut.mutate()}
|
||||
isGenerating={generateGeometryGlbMut.isPending}
|
||||
title="USD canonical scene (auto-generated after Viewer GLB)"
|
||||
onGenerate={() => generateUsdMasterMut.mutate()}
|
||||
isGenerating={generateUsdMasterMut.isPending}
|
||||
title="Regenerate USD canonical scene"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -999,10 +1058,12 @@ export default function ProductDetailPage() {
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{filteredRenders.map((r) => {
|
||||
{filteredRenders.map((r, _ri) => {
|
||||
const isConfirming = pendingDelete === r.order_line_id
|
||||
const isDeleting = deleteRenderMut.isPending && isConfirming
|
||||
const isSelected = selectedIds.has(r.order_line_id)
|
||||
// Index into lightboxItems (image-only renders)
|
||||
const imgIdx = r.is_video ? -1 : lightboxItems.findIndex(li => li.url === r.render_url)
|
||||
return (
|
||||
<div
|
||||
key={r.order_line_id}
|
||||
@@ -1048,11 +1109,14 @@ export default function ProductDetailPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative group">
|
||||
<a
|
||||
href={r.render_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => selectMode && e.preventDefault()}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full cursor-pointer"
|
||||
onClick={(e) => {
|
||||
if (selectMode) return
|
||||
e.stopPropagation()
|
||||
if (imgIdx >= 0) setLightboxIndex(imgIdx)
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={r.render_url}
|
||||
@@ -1061,7 +1125,7 @@ export default function ProductDetailPage() {
|
||||
isConfirming ? 'opacity-30' : isSelected ? 'opacity-80' : 'hover:opacity-90'
|
||||
}`}
|
||||
/>
|
||||
</a>
|
||||
</button>
|
||||
|
||||
{/* Select mode: checkbox top-left */}
|
||||
{selectMode && (
|
||||
@@ -1451,6 +1515,15 @@ export default function ProductDetailPage() {
|
||||
title="CAD Thumbnail"
|
||||
renderLog={product.cad_render_log}
|
||||
/>
|
||||
{/* Image lightbox */}
|
||||
{lightboxIndex !== null && (
|
||||
<ImageLightbox
|
||||
items={lightboxItems}
|
||||
index={lightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
onIndexChange={setLightboxIndex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -80,9 +80,8 @@ function WorkerCard({ worker }: { worker: CeleryWorker }) {
|
||||
type ScalableService = ScaleRequest['service']
|
||||
|
||||
const SCALABLE_SERVICES: { service: ScalableService; label: string; description: string }[] = [
|
||||
{ service: 'render-worker', label: 'Render Worker', description: 'Blender renders — concurrency=1' },
|
||||
{ service: 'worker', label: 'Step Worker', description: 'STEP processing — concurrency=8' },
|
||||
{ service: 'worker-thumbnail', label: 'Thumbnail Worker', description: 'Thumbnail rendering' },
|
||||
{ service: 'render-worker', label: 'Render Worker (asset_pipeline)', description: 'Blender renders, thumbnails, GLB, USD — concurrency=1' },
|
||||
{ service: 'worker', label: 'Step Worker (step_processing)', description: 'STEP metadata extraction — concurrency=8' },
|
||||
]
|
||||
|
||||
function ScaleControl({
|
||||
|
||||
Reference in New Issue
Block a user