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,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">&#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)
@@ -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>
)
}