feat: sharp edge pipeline V02, tessellation presets, media cache-bust, GMSH plan

Sharp Edge Pipeline V02:
- export_step_to_gltf.py: replace BRep_Tool.Polygon3D_s (returns None in XCAF) with
  GCPnts_UniformAbscissa curve sampling at 0.3mm step — extracts 17,129 segment pairs
- Inject sharp_edge_pairs + sharp_threshold_deg into GLB extras (scenes[0].extras)
  via binary GLB JSON-chunk patching (no extra dependency)
- export_gltf.py: read schaeffler_sharp_edge_pairs from Blender scene custom props,
  apply via KD-tree to mark edges sharp=True + seam=True (OCC mm Z-up → Blender transform)
- tools/restore_sharp_marks.py: dual-pass (dihedral angle + OCC pairs), updated coordinate
  transform (X, -Z, Y) * 0.001

Tessellation:
- Admin UI: Draft / Standard / Fine preset buttons with active-state highlighting
- Default angular deflection: preview 0.5→0.1 rad, production 0.2→0.05 rad
- export_glb.py: read updated defaults from system_settings

Media / Cache:
- media/service.py: get_download_url appends ?v={file_size_bytes} cache-buster
- media/router.py: Cache-Control: no-cache for all download/thumbnail endpoints

Render pipeline:
- still_render.py / turntable_render.py: shared GPU activation + camera improvements
- render_order_line.py: global render position support
- render_thumbnail.py: updated defaults

Frontend:
- InlineCadViewer: file_size_bytes-aware URL update triggers re-fetch on regeneration
- ThreeDViewer: material panel, part selection, PBR mode improvements
- Admin.tsx: tessellation preset cards, GMSH setting dropdown
- MediaBrowser, ProductDetail, OrderDetail, Orders: various UI improvements
- New: MaterialPanel, GlobalRenderPositionsPanel, StepIndicator components
- New: renderPositions.ts API client

Plans / Docs:
- plan.md: GMSH Frontal-Delaunay tessellation plan (6 tasks)
- LEARNINGS.md: OCC Polygon3D_s None issue + GCPnts fix
- .gitignore: add backend/core (core dump from root process)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 14:40:36 +01:00
parent 202b06a026
commit ca62319688
70 changed files with 6551 additions and 1130 deletions
+7 -4
View File
@@ -1,8 +1,9 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from './store/auth'
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { useAuthStore, isPrivileged as checkIsPrivileged } from './store/auth'
import { WebSocketProvider } from './contexts/WebSocketContext'
import Layout from './components/layout/Layout'
import LoginPage from './pages/Login'
import NotFoundPage from './pages/NotFound'
import DashboardPage from './pages/Dashboard'
import OrdersPage from './pages/Orders'
import OrderDetailPage from './pages/OrderDetail'
@@ -27,14 +28,15 @@ import AssetLibraryPage from './pages/AssetLibrary'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token)
if (!token) return <Navigate to="/login" replace />
const location = useLocation()
if (!token) return <Navigate to="/login" state={{ from: location.pathname }} replace />
return <>{children}</>
}
function AdminRoute({ children }: { children: React.ReactNode }) {
const { token, user } = useAuthStore()
if (!token) return <Navigate to="/login" replace />
if (user?.role !== 'admin' && user?.role !== 'project_manager') return <Navigate to="/" replace />
if (!checkIsPrivileged(user)) return <Navigate to="/" replace />
return <>{children}</>
}
@@ -123,6 +125,7 @@ export default function App() {
}
/>
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</WebSocketProvider>
</BrowserRouter>
+48
View File
@@ -98,8 +98,56 @@ export async function generateGltfProduction(cadFileId: string): Promise<Generat
return res.data
}
export interface ParsedObjectsResponse {
cad_file_id: string
original_name: string
processing_status: string
parsed_objects: {
dimensions_mm?: { x: number; y: number; z: number }
bbox_center_mm?: { x: number; y: number; z: number }
[key: string]: unknown
} | null
}
/** Return the parsed_objects metadata (dimensions, bbox) for a CAD file. */
export async function getParsedObjects(cadFileId: string): Promise<ParsedObjectsResponse> {
const res = await api.get<ParsedObjectsResponse>(`/cad/${cadFileId}/parsed-objects`)
return res.data
}
/** Force-reset a CAD file stuck in 'processing' to 'failed'. */
export async function resetStuckProcessing(cadFileId: string): Promise<{ status: string; message: string }> {
const res = await api.post<{ status: string; message: string }>(`/cad/${cadFileId}/reset-stuck`)
return res.data
}
// ---------------------------------------------------------------------------
// Part-material assignment
// ---------------------------------------------------------------------------
export interface PartMaterialEntry {
type: 'library' | 'hex'
value: string
}
export type PartMaterialMap = Record<string, PartMaterialEntry>
interface PartMaterialsResponse {
cad_file_id: string
part_materials: PartMaterialMap | null
}
/** Return the saved part-material assignments for a CAD file (empty object if none). */
export async function getPartMaterials(cadFileId: string): Promise<PartMaterialMap> {
const res = await api.get<PartMaterialsResponse>(`/cad/${cadFileId}/part-materials`)
return res.data.part_materials ?? {}
}
/** Replace the part-material assignment map for a CAD file. Returns the updated map. */
export async function savePartMaterials(
cadFileId: string,
map: PartMaterialMap,
): Promise<PartMaterialMap> {
const res = await api.put<PartMaterialsResponse>(`/cad/${cadFileId}/part-materials`, map)
return res.data.part_materials ?? {}
}
+9
View File
@@ -17,6 +17,7 @@ export interface MediaAssetFilters {
category_key?: string
render_status?: string
q?: string
exclude_technical?: boolean
page?: number
page_size?: number
}
@@ -34,6 +35,13 @@ export interface MediaAssetItem {
product_pim_id: string | null
category_key: string | null
render_status: string | null
// Extended product metadata
product_ebene1: string | null
product_ebene2: string | null
product_baureihe: string | null
product_produkt_baureihe: string | null
product_lagertyp: string | null
product_name_cad_modell: string | null
download_url: string | null
thumbnail_url: string | null
}
@@ -52,6 +60,7 @@ export function getMediaAssets(filters: MediaAssetFilters = {}): Promise<MediaAs
if (filters.category_key) params.set('category_key', filters.category_key)
if (filters.render_status) params.set('render_status', filters.render_status)
if (filters.q) params.set('q', filters.q)
if (filters.exclude_technical !== undefined) params.set('exclude_technical', String(filters.exclude_technical))
if (filters.page !== undefined) params.set('page', String(filters.page))
if (filters.page_size !== undefined) params.set('page_size', String(filters.page_size))
return api.get(`/media/assets?${params}`).then(r => r.data)
+36
View File
@@ -2,6 +2,38 @@ import api from './client'
import type { Product } from './products'
import type { OutputType } from './outputTypes'
export interface RenderLog {
renderer?: string
type?: string
format?: string
engine?: string
engine_used?: string
samples?: number
stl_quality?: string
smooth_angle?: number
total_duration_s?: number
stl_duration_s?: number
render_duration_s?: number
ffmpeg_duration_s?: number
stl_size_bytes?: number
output_size_bytes?: number
parts_count?: number
device_used?: string
compute_type?: string
gpu_fallback?: boolean
frame_count?: number
fps?: number
template?: string
lighting_only?: boolean
shadow_catcher?: boolean
material_replace?: boolean
fallback?: boolean
error?: string
started_at?: string
completed_at?: string
log_lines?: string[]
}
export interface OrderLine {
id: string
order_id: string
@@ -21,6 +53,9 @@ export interface OrderLine {
unit_price: number | null
render_position_id: string | null
render_position_name: string | null
render_log: RenderLog | null
render_started_at: string | null
render_completed_at: string | null
notes: string | null
created_at: string
updated_at: string
@@ -30,6 +65,7 @@ export interface OrderLineCreate {
product_id: string
output_type_id?: string | null
render_position_id?: string | null
global_render_position_id?: string | null
gewuenschte_bildnummer?: string | null
notes?: string | null
}
+5 -2
View File
@@ -1,5 +1,5 @@
import api from './client'
import type { Order } from './orders'
import type { Order, RenderLog } from './orders'
export interface RenderPosition {
id: string
@@ -62,9 +62,11 @@ export interface Product {
bbox_center_mm?: { x: number; y: number; z: number }
suggested_smooth_angle?: number
has_mechanical_edges?: boolean
sharp_edge_midpoints?: number[][]
sharp_edge_midpoints?: number[][] // legacy: midpoints only
sharp_edge_pairs?: number[][][] // [[start_xyz, end_xyz], ...] in mm
} | null
arbeitspaket: string | null
cad_render_log?: RenderLog | null
notes: string | null
is_active: boolean
source_excel: string | null
@@ -152,6 +154,7 @@ export interface ProductRender {
is_video: boolean
render_backend: string | null
completed_at: string | null
render_position_name: string | null
}
export async function getProductRenders(id: string): Promise<ProductRender[]> {
+50
View File
@@ -0,0 +1,50 @@
import api from './client'
export interface GlobalRenderPosition {
id: string
name: string
rotation_x: number
rotation_y: number
rotation_z: number
is_default: boolean
sort_order: number
created_at: string
updated_at: string
}
export interface GlobalRenderPositionCreate {
name: string
rotation_x?: number
rotation_y?: number
rotation_z?: number
is_default?: boolean
sort_order?: number
}
export interface GlobalRenderPositionPatch {
name?: string
rotation_x?: number
rotation_y?: number
rotation_z?: number
is_default?: boolean
sort_order?: number
}
export async function listGlobalRenderPositions(): Promise<GlobalRenderPosition[]> {
const res = await api.get<GlobalRenderPosition[]>('/render-positions/global')
return res.data
}
export async function createGlobalRenderPosition(body: GlobalRenderPositionCreate): Promise<GlobalRenderPosition> {
const res = await api.post<GlobalRenderPosition>('/render-positions/global', body)
return res.data
}
export async function updateGlobalRenderPosition(id: string, body: GlobalRenderPositionPatch): Promise<GlobalRenderPosition> {
const res = await api.patch<GlobalRenderPosition>(`/render-positions/global/${id}`, body)
return res.data
}
export async function deleteGlobalRenderPosition(id: string): Promise<void> {
await api.delete(`/render-positions/global/${id}`)
}
@@ -0,0 +1,243 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Pencil, Trash2, Check, X } from 'lucide-react'
import {
listGlobalRenderPositions,
createGlobalRenderPosition,
updateGlobalRenderPosition,
deleteGlobalRenderPosition,
type GlobalRenderPosition,
type GlobalRenderPositionCreate,
} from '../../api/renderPositions'
interface EditState {
id: string | null
name: string
rotation_x: number
rotation_y: number
rotation_z: number
is_default: boolean
sort_order: number
}
const EMPTY_EDIT: EditState = {
id: null,
name: '',
rotation_x: 0,
rotation_y: 0,
rotation_z: 0,
is_default: false,
sort_order: 0,
}
export default function GlobalRenderPositionsPanel() {
const qc = useQueryClient()
const [editing, setEditing] = useState<EditState | null>(null)
const [adding, setAdding] = useState(false)
const { data: positions = [], isLoading } = useQuery({
queryKey: ['global-render-positions'],
queryFn: listGlobalRenderPositions,
})
const createMut = useMutation({
mutationFn: (body: GlobalRenderPositionCreate) => createGlobalRenderPosition(body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['global-render-positions'] }); setAdding(false) },
})
const updateMut = useMutation({
mutationFn: ({ id, body }: { id: string; body: Partial<GlobalRenderPositionCreate> }) =>
updateGlobalRenderPosition(id, body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['global-render-positions'] }); setEditing(null) },
})
const deleteMut = useMutation({
mutationFn: (id: string) => deleteGlobalRenderPosition(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['global-render-positions'] }),
})
function startEdit(pos: GlobalRenderPosition) {
setAdding(false)
setEditing({
id: pos.id,
name: pos.name,
rotation_x: pos.rotation_x,
rotation_y: pos.rotation_y,
rotation_z: pos.rotation_z,
is_default: pos.is_default,
sort_order: pos.sort_order,
})
}
function saveEdit() {
if (!editing) return
if (editing.id) {
const { id, ...body } = editing
updateMut.mutate({ id, body })
}
}
function saveNew() {
if (!editing) return
const { id, ...body } = editing
createMut.mutate(body)
}
function startAdd() {
setEditing({ ...EMPTY_EDIT, sort_order: positions.length })
setAdding(true)
}
function cancelEdit() {
setEditing(null)
setAdding(false)
}
function rotField(label: string, field: keyof Pick<EditState, 'rotation_x' | 'rotation_y' | 'rotation_z'>) {
if (!editing) return null
return (
<div className="flex flex-col gap-0.5">
<label className="text-xs text-content-muted">{label}</label>
<input
type="number"
step="5"
className="input w-20 text-sm"
value={editing[field]}
onChange={(e) => setEditing({ ...editing, [field]: parseFloat(e.target.value) || 0 })}
/>
</div>
)
}
if (isLoading) return <p className="text-sm text-content-muted">Loading</p>
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs text-content-muted">
Global camera rotation presets applied to all products. Per-product positions take priority.
</p>
<button className="btn btn-sm btn-primary flex items-center gap-1" onClick={startAdd}>
<Plus size={14} /> Add position
</button>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-light text-left text-xs text-content-muted">
<th className="pb-1 pr-3">Name</th>
<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">Default</th>
<th className="pb-1 pr-3 text-center">Order</th>
<th className="pb-1" />
</tr>
</thead>
<tbody>
{positions.map((pos) => {
const isEditingThis = editing && editing.id === pos.id
return (
<tr key={pos.id} className="border-b border-border-light/50 hover:bg-surface-alt/30">
{isEditingThis ? (
<>
<td className="py-1 pr-2">
<input
className="input w-32 text-sm"
value={editing!.name}
onChange={(e) => setEditing({ ...editing!, name: e.target.value })}
/>
</td>
<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="checkbox"
checked={editing!.is_default}
onChange={(e) => setEditing({ ...editing!, is_default: e.target.checked })}
/>
</td>
<td className="py-1 pr-2 text-center">
<input
type="number"
className="input w-14 text-sm"
value={editing!.sort_order}
onChange={(e) => setEditing({ ...editing!, sort_order: parseInt(e.target.value) || 0 })}
/>
</td>
<td className="py-1 flex items-center gap-1">
<button className="btn btn-xs btn-primary" onClick={saveEdit} disabled={updateMut.isPending}>
<Check size={12} />
</button>
<button className="btn btn-xs" onClick={cancelEdit}><X size={12} /></button>
</td>
</>
) : (
<>
<td className="py-1.5 pr-3 font-medium">{pos.name}</td>
<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">
{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-red-500"
onClick={() => { if (confirm(`Delete "${pos.name}"?`)) deleteMut.mutate(pos.id) }}
disabled={deleteMut.isPending}
>
<Trash2 size={12} />
</button>
</td>
</>
)}
</tr>
)
})}
{/* New row */}
{adding && editing && (
<tr className="border-b border-border-light bg-surface-alt/20">
<td className="py-1 pr-2">
<input
className="input w-32 text-sm"
placeholder="Name"
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
/>
</td>
<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="checkbox"
checked={editing.is_default}
onChange={(e) => setEditing({ ...editing, is_default: e.target.checked })}
/>
</td>
<td className="py-1 pr-2 text-center">
<input
type="number"
className="input w-14 text-sm"
value={editing.sort_order}
onChange={(e) => setEditing({ ...editing, sort_order: parseInt(e.target.value) || 0 })}
/>
</td>
<td className="py-1 flex items-center gap-1">
<button className="btn btn-xs btn-primary" onClick={saveNew} disabled={createMut.isPending}>
<Check size={12} />
</button>
<button className="btn btn-xs" onClick={cancelEdit}><X size={12} /></button>
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}
+426 -67
View File
@@ -1,14 +1,16 @@
import { Suspense, useEffect, useRef, useState } from 'react'
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Canvas } from '@react-three/fiber'
import { Canvas, useThree } from '@react-three/fiber'
import { OrbitControls, useGLTF, Environment } from '@react-three/drei'
import * as THREE from 'three'
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { Loader2, Box, RefreshCw, Grid3X3, Layers, Sun, Cpu } from 'lucide-react'
import { Loader2, Box, RefreshCw, Grid3X3, Layers, Sun, Cpu, AlertCircle, EyeOff } from 'lucide-react'
import { toast } from 'sonner'
import { listMediaAssets as getMediaAssets } from '../../api/media'
import { generateGltfGeometry } from '../../api/cad'
import { generateGltfGeometry, getPartMaterials, type PartMaterialMap } from '../../api/cad'
import { useAuthStore } from '../../store/auth'
import MaterialPanel, { SCHAEFFLER_COLORS, previewColorForEntry, type IsolateMode } from './MaterialPanel'
import { normalizeMeshName, resolvePartMaterial } from './cadUtils'
type ViewMode = 'solid' | 'wireframe'
type GlbSource = 'geometry' | 'production'
@@ -22,26 +24,96 @@ const LIGHT_PRESETS: { id: LightPreset; label: string }[] = [
{ id: 'city', label: 'City' },
]
function GlbModel({ url, wireframe }: { url: string; wireframe: boolean }) {
// ---------------------------------------------------------------------------
// CameraAutoFit — auto-fits camera to model bounding box on first load
// ---------------------------------------------------------------------------
function CameraAutoFit({
sceneRef,
controlsRef,
fitTrigger,
}: {
sceneRef: React.MutableRefObject<THREE.Object3D | null>
controlsRef: React.RefObject<any>
fitTrigger: number
}) {
const { camera, size } = useThree()
useEffect(() => {
if (fitTrigger === 0 || !sceneRef.current) return
const box = new THREE.Box3()
sceneRef.current.traverse((obj) => {
if ((obj as THREE.Mesh).isMesh) box.expandByObject(obj)
})
if (box.isEmpty()) return
const center = box.getCenter(new THREE.Vector3())
const sizeVec = box.getSize(new THREE.Vector3())
const maxDim = Math.max(sizeVec.x, sizeVec.y, sizeVec.z)
const pc = camera as THREE.PerspectiveCamera
const fovRad = (pc.fov * Math.PI) / 180
const aspect = size.width / size.height
const fovH = 2 * Math.atan(Math.tan(fovRad / 2) * aspect)
const dist = (maxDim / 2) / Math.tan(Math.min(fovRad, fovH) / 2) * 1.6
camera.position.set(center.x + maxDim * 0.05, center.y + maxDim * 0.2, center.z + dist)
camera.near = maxDim * 0.001
camera.far = maxDim * 100
camera.updateProjectionMatrix()
camera.lookAt(center)
if (controlsRef.current) {
controlsRef.current.target.copy(center)
controlsRef.current.minDistance = maxDim * 0.05
controlsRef.current.maxDistance = maxDim * 20
controlsRef.current.update()
}
}, [fitTrigger]) // eslint-disable-line react-hooks/exhaustive-deps
return null
}
// ---------------------------------------------------------------------------
// GlbModelWithFit — loads GLB, stores scene ref, signals ready, pointer events
// ---------------------------------------------------------------------------
function GlbModelWithFit({
url,
wireframe,
sceneRef,
onReady,
onPointerOver,
onPointerOut,
onClick,
}: {
url: string
wireframe: boolean
sceneRef: React.MutableRefObject<THREE.Object3D | null>
onReady: () => void
onPointerOver?: (e: any) => void
onPointerOut?: () => void
onClick?: (e: any) => void
}) {
const { scene } = useGLTF(url)
const cloned = useRef<THREE.Group | null>(null)
if (!cloned.current) {
cloned.current = scene.clone(true)
cloned.current.traverse((obj) => {
if (obj instanceof THREE.Mesh && obj.geometry) {
let geo = obj.geometry.clone()
if (!geo.index) {
// Non-indexed geometry: each triangle has unique vertices,
// so computeVertexNormals() would give per-face normals (flat shading).
// mergeVertices() creates an indexed geometry with shared vertices first,
// so the subsequent normal computation averages across adjacent faces → smooth.
geo = mergeVertices(geo)
if (obj instanceof THREE.Mesh) {
if (obj.geometry) {
let geo = obj.geometry.clone()
if (!geo.index) geo = mergeVertices(geo)
geo.computeVertexNormals()
obj.geometry = geo
}
// Clone materials so emissive / color changes don't affect the shared GLTF cache
if (obj.material) {
obj.material = Array.isArray(obj.material)
? obj.material.map((m: THREE.Material) => m.clone())
: obj.material.clone()
}
// For indexed geometry (Blender GLB): normals are already baked smooth by Blender.
// Recomputing here still works correctly because shared vertices average properly.
geo.computeVertexNormals()
obj.geometry = geo
}
})
}
@@ -58,10 +130,22 @@ function GlbModel({ url, wireframe }: { url: string; wireframe: boolean }) {
})
}, [wireframe])
return <primitive object={cloned.current} />
useEffect(() => {
sceneRef.current = cloned.current
onReady()
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return (
<primitive
object={cloned.current}
onPointerOver={onPointerOver}
onPointerOut={onPointerOut}
onClick={onClick}
/>
)
}
const HEIGHT = 420
const HEIGHT = 560
function ToolbarBtn({
active, onClick, children, title,
@@ -70,7 +154,7 @@ function ToolbarBtn({
<button
title={title}
onClick={onClick}
className={`px-2 py-1 text-[11px] flex items-center gap-1 transition-colors ${
className={`px-2 py-1 text-[11px] flex items-center gap-1 transition-colors rounded ${
active ? 'bg-white/20 text-white' : 'text-white/50 hover:text-white/80 hover:bg-white/10'
}`}
>
@@ -82,19 +166,38 @@ function ToolbarBtn({
export default function InlineCadViewer({
cadFileId,
thumbnailUrl,
initialPartMaterials,
}: {
cadFileId: string
thumbnailUrl?: string | null
initialPartMaterials?: PartMaterialMap
}) {
const token = useAuthStore((s) => s.token)
const qc = useQueryClient()
// GLB source / display state
const [glbBlobUrl, setGlbBlobUrl] = useState<string | null>(null)
const [loadingGlb, setLoadingGlb] = useState(false)
const [generating, setGenerating] = useState(false)
const [viewMode, setViewMode] = useState<ViewMode>('solid')
const [glbSource, setGlbSource] = useState<GlbSource>('geometry')
const [lightPreset, setLightPreset] = useState<LightPreset>('studio')
const [modelReady, setModelReady] = useState(false)
const [fitTrigger, setFitTrigger] = useState(0)
// Material assignment state
const [pinnedPart, setPinnedPart] = useState<string | null>(null)
const [showUnassigned, setShowUnassigned] = useState(false)
const [hideAssigned, setHideAssigned] = useState(false)
const [isolateMode, setIsolateMode] = useState<IsolateMode>('none')
const [totalMeshCount, setTotalMeshCount] = useState(0)
const [glbMeshNames, setGlbMeshNames] = useState<Set<string>>(new Set())
const sceneRef = useRef<THREE.Object3D | null>(null)
const controlsRef = useRef<any>(null)
const hoveredMeshRef = useRef<THREE.Mesh | null>(null)
// Media asset queries
const { data: gltfAssets } = useQuery({
queryKey: ['media-assets', cadFileId, 'gltf_geometry'],
queryFn: () => getMediaAssets({ cad_file_id: cadFileId, asset_types: ['gltf_geometry'] }),
@@ -108,14 +211,33 @@ export default function InlineCadViewer({
staleTime: 0,
})
// Part-material assignments — from CadFile (manual assignments in viewer)
const { data: savedPartMaterials = {} } = useQuery({
queryKey: ['part-materials', cadFileId],
queryFn: () => getPartMaterials(cadFileId),
staleTime: 30_000,
retry: false,
})
// Merge: initialPartMaterials (from Product Excel data) as base; savedPartMaterials overrides
const partMaterials = useMemo(
() => ({ ...initialPartMaterials, ...savedPartMaterials } as PartMaterialMap),
[initialPartMaterials, savedPartMaterials],
)
// Count how many unique GLB mesh types have a resolved material assignment
const assignedCount = useMemo(
() => [...glbMeshNames].filter(n => !!resolvePartMaterial(n, partMaterials)).length,
[glbMeshNames, partMaterials],
)
useEffect(() => {
if (generating && gltfAssets && gltfAssets.length > 0) setGenerating(false)
}, [generating, gltfAssets])
const hasGeometry = (gltfAssets?.length ?? 0) > 0
const hasGeometry = (gltfAssets?.length ?? 0) > 0
const hasProduction = (productionAssets?.length ?? 0) > 0
// Auto-switch to production if it's the only available source
useEffect(() => {
if (!hasGeometry && hasProduction) setGlbSource('production')
}, [hasGeometry, hasProduction])
@@ -125,9 +247,11 @@ export default function InlineCadViewer({
? productionAssets?.[0]?.download_url
: gltfAssets?.[0]?.download_url
// Fetch active GLB as blob URL (needs auth header)
useEffect(() => {
if (!activeDownloadUrl || !token) return
setGlbBlobUrl(null)
setModelReady(false)
setLoadingGlb(true)
let blobUrl = ''
fetch(activeDownloadUrl, { headers: { Authorization: `Bearer ${token}` } })
@@ -138,11 +262,119 @@ export default function InlineCadViewer({
})
.catch(() => toast.error('Failed to load 3D model'))
.finally(() => setLoadingGlb(false))
return () => {
if (blobUrl) URL.revokeObjectURL(blobUrl)
}
return () => { if (blobUrl) URL.revokeObjectURL(blobUrl) }
}, [activeDownloadUrl, token])
// Apply saved material colors after model loads or when assignments change
useEffect(() => {
if (!modelReady || !sceneRef.current) return
sceneRef.current.traverse((obj) => {
const mesh = obj as THREE.Mesh
if (!mesh.isMesh) return
const entry = resolvePartMaterial(normalizeMeshName((mesh.userData?.name as string) || mesh.name), partMaterials as PartMaterialMap)
if (!entry) return
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
if (mat && 'color' in mat) mat.color.set(previewColorForEntry(entry))
})
})
}, [modelReady, partMaterials])
// Unassigned glow — only when at least one assignment exists
useEffect(() => {
if (!modelReady || !sceneRef.current) return
const hasAnyAssignment = Object.keys(partMaterials).length > 0
sceneRef.current.traverse((obj) => {
const mesh = obj as THREE.Mesh
if (!mesh.isMesh) return
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
if (!mat || !('emissive' in mat)) return
if (showUnassigned && hasAnyAssignment) {
const assigned = !!resolvePartMaterial(normalizeMeshName((mesh.userData?.name as string) || mesh.name), partMaterials as PartMaterialMap)
mat.emissive.set(assigned ? 0x000000 : 0xff4400)
mat.emissiveIntensity = assigned ? 0 : 0.8
} else {
mat.emissive.set(0x000000)
mat.emissiveIntensity = 0
}
})
})
}, [modelReady, showUnassigned, partMaterials])
// Reset isolateMode when no part is pinned
useEffect(() => {
if (!pinnedPart) setIsolateMode('none')
}, [pinnedPart])
// Reset hideAssigned when all assignments are cleared
useEffect(() => {
if (Object.keys(partMaterials).length === 0) setHideAssigned(false)
}, [partMaterials])
// Combined visibility effect — handles hideAssigned + isolateMode together to avoid conflicts
useEffect(() => {
if (!modelReady || !sceneRef.current) return
sceneRef.current.traverse((obj) => {
const mesh = obj as THREE.Mesh
if (!mesh.isMesh) return
const normalizedName = normalizeMeshName((mesh.userData?.name as string) || mesh.name)
const isSelected = normalizedName === pinnedPart
const isAssigned = !!resolvePartMaterial(normalizedName, partMaterials)
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
// Default: fully visible + raycasting enabled
mesh.visible = true
mesh.raycast = THREE.Mesh.prototype.raycast
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
if (mat && 'opacity' in mat) { mat.opacity = 1; mat.transparent = false; mat.depthWrite = true; mat.needsUpdate = true }
})
// hideAssigned: hide all assigned meshes (except the currently selected part)
if (hideAssigned && isAssigned && !isSelected) {
mesh.visible = false
mesh.raycast = () => {} // prevent R3F from seeing hidden meshes as hit targets
return
}
// isolateMode: ghost or hide non-selected meshes when a part is pinned
if (!isSelected && pinnedPart && isolateMode !== 'none') {
if (isolateMode === 'hide') {
mesh.visible = false
mesh.raycast = () => {} // prevent R3F from seeing hidden meshes as hit targets
} else {
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
if (mat && 'opacity' in mat) { mat.opacity = 0.08; mat.transparent = true; mat.depthWrite = false; mat.needsUpdate = true }
})
}
}
})
}, [modelReady, pinnedPart, isolateMode, hideAssigned, partMaterials])
// Dev-only: log normalized GLB mesh names vs stored keys to diagnose mismatches
useEffect(() => {
if (!import.meta.env.DEV || !modelReady || !sceneRef.current) return
const names = new Set<string>()
sceneRef.current.traverse(o => {
if ((o as THREE.Mesh).isMesh && o.name) names.add(normalizeMeshName((o.userData?.name as string) || o.name))
})
const keys = Object.keys(partMaterials)
const matched = keys.filter(k => names.has(k))
const unmatched = keys.filter(k => !names.has(k))
console.debug('[CAD] Match status:', {
totalGlbMeshes: names.size,
totalStoredKeys: keys.length,
matched: matched.length,
unmatched: unmatched.length,
unmatchedKeys: unmatched,
glbNames: [...names].sort(),
})
}, [modelReady, partMaterials])
const generateMut = useMutation({
mutationFn: () => generateGltfGeometry(cadFileId),
onSuccess: () => {
@@ -153,63 +385,188 @@ export default function InlineCadViewer({
onError: () => toast.error('Failed to queue GLB generation'),
})
if (glbBlobUrl) {
return (
<div className="w-full rounded-lg overflow-hidden border border-border-default bg-gray-950 relative" style={{ height: HEIGHT }}>
<Canvas camera={{ position: [0, 0, 2], fov: 45 }}>
<Suspense fallback={null}>
<Environment preset={lightPreset} background={false} />
<GlbModel key={glbBlobUrl} url={glbBlobUrl} wireframe={viewMode === 'wireframe'} />
</Suspense>
<OrbitControls makeDefault />
</Canvas>
// Hover highlight
const handlePointerOver = useCallback((e: any) => {
e.stopPropagation()
const mesh = e.object as THREE.Mesh
// Restore previous hovered mesh (correctly preserve unassigned glow)
if (hoveredMeshRef.current && hoveredMeshRef.current !== mesh) {
const prev = hoveredMeshRef.current
const prevMats = Array.isArray(prev.material) ? prev.material : [prev.material]
const hasAny = Object.keys(partMaterials).length > 0
prevMats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
if (!mat || !('emissive' in mat)) return
if (showUnassigned && hasAny && !resolvePartMaterial(normalizeMeshName((prev.userData?.name as string) || prev.name), partMaterials as PartMaterialMap)) {
mat.emissive.set(0xff4400); mat.emissiveIntensity = 0.8
} else {
mat.emissive.set(0x000000); mat.emissiveIntensity = 0
}
})
}
hoveredMeshRef.current = mesh
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
if (mat && 'emissive' in mat) { mat.emissive.set(0x333333); mat.emissiveIntensity = 0.5 }
})
}, [showUnassigned, partMaterials])
{/* Toolbar */}
<div className="absolute top-2 right-2 flex flex-col gap-1 items-end">
{/* Geometry / Production toggle — only when both exist */}
const handlePointerOut = useCallback(() => {
if (hoveredMeshRef.current) {
const mesh = hoveredMeshRef.current
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
const hasAnyAssignment = Object.keys(partMaterials).length > 0
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
if (!mat || !('emissive' in mat)) return
if (showUnassigned && hasAnyAssignment && !resolvePartMaterial(normalizeMeshName((mesh.userData?.name as string) || mesh.name), partMaterials as PartMaterialMap)) {
mat.emissive.set(0xff4400); mat.emissiveIntensity = 0.8
} else {
mat.emissive.set(0x000000); mat.emissiveIntensity = 0
}
})
hoveredMeshRef.current = null
}
}, [showUnassigned, partMaterials])
const handleClick = useCallback((e: any) => {
e.stopPropagation()
const meshObj = e.object as THREE.Mesh
const name = normalizeMeshName((meshObj?.userData?.name as string) || meshObj?.name || '')
if (name) setPinnedPart(name)
}, [])
// ── Render: model loaded ──────────────────────────────────────────────────
if (glbBlobUrl) {
const pm = partMaterials as PartMaterialMap
return (
<div
className="w-full rounded-lg border border-border-default bg-gray-950 flex flex-col overflow-hidden"
style={{ height: HEIGHT }}
onClick={() => setPinnedPart(null)}
>
{/* ── Toolbar row — real block element above the canvas ── */}
<div
className="shrink-0 flex items-center gap-0.5 px-2 py-1 bg-black/70 border-b border-white/10 flex-wrap"
onClick={(e) => e.stopPropagation()}
>
{/* Geo / PBR toggle */}
{hasGeometry && hasProduction && (
<div className="flex rounded-md overflow-hidden border border-white/10 bg-black/50 backdrop-blur-sm">
<ToolbarBtn active={glbSource === 'geometry'} onClick={() => setGlbSource('geometry')} title="Geometry GLB (OCC, no materials)">
<Box size={12} /> Geo
<>
<ToolbarBtn active={glbSource === 'geometry'} onClick={() => setGlbSource('geometry')} title="Geometry GLB (OCC)">
<Box size={11} /> Geo
</ToolbarBtn>
<div className="w-px bg-white/10" />
<ToolbarBtn active={glbSource === 'production'} onClick={() => setGlbSource('production')} title="Production GLB (Blender + PBR materials)">
<Cpu size={12} /> PBR
<ToolbarBtn active={glbSource === 'production'} onClick={() => setGlbSource('production')} title="Production GLB (Blender PBR)">
<Cpu size={11} /> PBR
</ToolbarBtn>
</div>
<div className="w-px h-4 bg-white/10 mx-0.5" />
</>
)}
{/* View mode */}
<div className="flex rounded-md overflow-hidden border border-white/10 bg-black/50 backdrop-blur-sm">
<ToolbarBtn active={viewMode === 'solid'} onClick={() => setViewMode('solid')} title="Solid">
<Layers size={12} /> Solid
</ToolbarBtn>
<div className="w-px bg-white/10" />
<ToolbarBtn active={viewMode === 'wireframe'} onClick={() => setViewMode('wireframe')} title="Wireframe">
<Grid3X3 size={12} /> Wire
</ToolbarBtn>
</div>
<ToolbarBtn active={viewMode === 'solid'} onClick={() => setViewMode('solid')} title="Solid">
<Layers size={11} /> Solid
</ToolbarBtn>
<ToolbarBtn active={viewMode === 'wireframe'} onClick={() => setViewMode('wireframe')} title="Wireframe">
<Grid3X3 size={11} /> Wire
</ToolbarBtn>
{/* Lighting presets */}
<div className="flex rounded-md overflow-hidden border border-white/10 bg-black/50 backdrop-blur-sm">
<span className="px-2 py-1 text-[11px] text-white/30 flex items-center">
<Sun size={11} />
</span>
<div className="w-px bg-white/10" />
{LIGHT_PRESETS.map((p, i) => (
<div key={p.id} className="flex">
{i > 0 && <div className="w-px bg-white/10" />}
<ToolbarBtn active={lightPreset === p.id} onClick={() => setLightPreset(p.id)} title={p.label}>
{p.label}
<div className="w-px h-4 bg-white/10 mx-0.5" />
{/* Lighting */}
<Sun size={11} className="text-white/30 mx-1" />
{LIGHT_PRESETS.map((p) => (
<ToolbarBtn key={p.id} active={lightPreset === p.id} onClick={() => setLightPreset(p.id)} title={p.label}>
{p.label}
</ToolbarBtn>
))}
{/* Show unassigned + hide assigned toggles */}
{modelReady && (
<>
<div className="w-px h-4 bg-white/10 mx-0.5" />
<ToolbarBtn
active={showUnassigned}
onClick={() => setShowUnassigned(v => !v)}
title={`Highlight unassigned parts (${assignedCount}/${totalMeshCount} assigned)`}
>
<AlertCircle size={11} />
<span className="tabular-nums text-[10px]">{assignedCount}/{totalMeshCount}</span>
</ToolbarBtn>
{assignedCount > 0 && (
<ToolbarBtn
active={hideAssigned}
onClick={() => setHideAssigned(v => !v)}
title="Hide parts that already have a material assigned"
>
<EyeOff size={11} />
<span className="text-[10px]">Hide assigned</span>
</ToolbarBtn>
</div>
))}
)}
</>
)}
</div>
{/* ── Canvas area ── */}
<div className="flex-1 relative" onClick={(e) => e.stopPropagation()}>
<Canvas
gl={{ powerPreference: 'high-performance', antialias: true }}
dpr={[1, 1.5]}
camera={{ position: [0, 0, 2], fov: 45 }}
>
<Suspense fallback={null}>
<Environment preset={lightPreset} background={false} />
<GlbModelWithFit
key={glbBlobUrl}
url={glbBlobUrl}
wireframe={viewMode === 'wireframe'}
sceneRef={sceneRef}
onReady={() => {
const names = new Set<string>()
sceneRef.current?.traverse(o => {
if ((o as THREE.Mesh).isMesh && o.name) names.add(normalizeMeshName((o.userData?.name as string) || o.name))
})
setTotalMeshCount(names.size)
setGlbMeshNames(new Set(names))
setModelReady(true)
setFitTrigger(t => t + 1)
}}
onPointerOver={handlePointerOver}
onPointerOut={handlePointerOut}
onClick={handleClick}
/>
</Suspense>
<OrbitControls ref={controlsRef} makeDefault />
<CameraAutoFit sceneRef={sceneRef} controlsRef={controlsRef} fitTrigger={fitTrigger} />
</Canvas>
{/* Material assignment panel */}
{pinnedPart && (
<MaterialPanel
partName={pinnedPart}
cadFileId={cadFileId}
currentEntry={resolvePartMaterial(pinnedPart, pm)}
partMaterials={pm}
onClose={() => setPinnedPart(null)}
isolateMode={isolateMode}
onIsolateModeChange={setIsolateMode}
/>
)}
{/* Hint */}
<div className="absolute bottom-1.5 right-2 text-gray-600 text-[10px] pointer-events-none select-none">
click part to assign material
</div>
</div>
</div>
)
}
// ── Render: loading ───────────────────────────────────────────────────────
if (loadingGlb) {
return (
<div
@@ -224,6 +581,8 @@ export default function InlineCadViewer({
)
}
// ── Render: no GLB yet ────────────────────────────────────────────────────
return (
<div
className="w-full rounded-lg border border-border-default bg-surface-muted flex flex-col items-center justify-center gap-3"
@@ -0,0 +1,290 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { X, Loader2, Palette, Layers, EyeOff } from 'lucide-react'
import { toast } from 'sonner'
import api from '../../api/client'
import { savePartMaterials, type PartMaterialMap, type PartMaterialEntry } from '../../api/cad'
// ---------------------------------------------------------------------------
// SCHAEFFLER_COLORS — viewport preview colors for known library materials
// ---------------------------------------------------------------------------
export const SCHAEFFLER_COLORS: Record<string, string> = {
'SCHAEFFLER_010101_Steel-Bare': '#8a9ca8',
'SCHAEFFLER_010102_Steel-Polished': '#b0c4ce',
'SCHAEFFLER_010103_Steel-Brushed': '#9aabb5',
'SCHAEFFLER_010104_Steel-Painted': '#607080',
'SCHAEFFLER_010201_Stainless-Bare': '#adb9bf',
'SCHAEFFLER_010202_Stainless-Polished': '#cdd8dc',
'SCHAEFFLER_010301_Iron-Cast': '#696969',
'SCHAEFFLER_020101_Aluminium-Bare': '#c8c8c8',
'SCHAEFFLER_020102_Aluminium-Anodized': '#b0b8c0',
'SCHAEFFLER_030101_Brass': '#c9a84c',
'SCHAEFFLER_030201_Bronze': '#a07040',
'SCHAEFFLER_040101_Copper': '#b87333',
'SCHAEFFLER_050101_Plastic-Black': '#202020',
'SCHAEFFLER_050102_Plastic-White': '#f0f0f0',
'SCHAEFFLER_050201_Rubber-Black': '#1a1a1a',
'SCHAEFFLER_060101_Ceramic': '#e8dcc8',
'SCHAEFFLER_070101_Glass': '#88bbcc',
}
export function previewColorForEntry(entry: PartMaterialEntry): string {
if (entry.type === 'hex') return entry.value
return SCHAEFFLER_COLORS[entry.value] ?? '#888888'
}
// ---------------------------------------------------------------------------
// MaterialOut — matches GET /api/materials response
// ---------------------------------------------------------------------------
export interface MaterialOut {
id: string
name: string
description: string | null
schaeffler_code: number | null
source: string
}
// ---------------------------------------------------------------------------
// MaterialPanel — floating panel for assigning a material/color to a part
// ---------------------------------------------------------------------------
export type IsolateMode = 'none' | 'ghost' | 'hide'
export interface MaterialPanelProps {
partName: string
cadFileId: string
currentEntry: PartMaterialEntry | undefined
partMaterials: PartMaterialMap
onClose: () => void
isolateMode?: IsolateMode
onIsolateModeChange?: (mode: IsolateMode) => void
}
export default function MaterialPanel({
partName,
cadFileId,
currentEntry,
partMaterials,
onClose,
isolateMode = 'none',
onIsolateModeChange,
}: MaterialPanelProps) {
const queryClient = useQueryClient()
// Fetch all tenant materials (no filter — user sees their full library)
const { data: allMaterials = [] } = useQuery({
queryKey: ['materials'],
queryFn: async () => {
const res = await api.get<MaterialOut[]>('/materials')
return res.data
},
staleTime: 60_000,
})
const [assignType, setAssignType] = useState<'library' | 'hex'>(
currentEntry?.type ?? 'library',
)
const [hexValue, setHexValue] = useState(
currentEntry?.type === 'hex' ? currentEntry.value : '#888888',
)
const [libValue, setLibValue] = useState(
currentEntry?.type === 'library'
? currentEntry.value
: (allMaterials[0]?.name ?? ''),
)
// Set default library value once materials load
useEffect(() => {
if (!libValue && allMaterials.length > 0) setLibValue(allMaterials[0].name)
}, [allMaterials]) // eslint-disable-line react-hooks/exhaustive-deps
const saveMut = useMutation({
mutationFn: (updated: PartMaterialMap) => savePartMaterials(cadFileId, updated),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['part-materials', cadFileId] })
toast.success(`Material assigned to "${partName}"`)
onClose()
},
onError: () => toast.error('Failed to save material assignment'),
})
const removeMut = useMutation({
mutationFn: (updated: PartMaterialMap) => savePartMaterials(cadFileId, updated),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['part-materials', cadFileId] })
toast.success(`Assignment removed from "${partName}"`)
onClose()
},
onError: () => toast.error('Failed to remove assignment'),
})
function handleAssign() {
const entry: PartMaterialEntry =
assignType === 'hex'
? { type: 'hex', value: hexValue }
: { type: 'library', value: libValue }
saveMut.mutate({ ...partMaterials, [partName]: entry })
}
function handleRemove() {
const updated = { ...partMaterials }
delete updated[partName]
removeMut.mutate(updated)
}
const isBusy = saveMut.isPending || removeMut.isPending
const previewHex = assignType === 'hex'
? hexValue
: (SCHAEFFLER_COLORS[libValue] ?? '#888888')
return (
<div
className="absolute top-2 left-2 z-30 w-72 bg-gray-900 border border-gray-700 rounded-lg shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-700">
<div className="flex items-center gap-2 min-w-0">
<Palette size={13} className="text-accent shrink-0" />
<span className="text-white text-xs font-semibold truncate" title={partName}>
{partName}
</span>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-white p-0.5 shrink-0">
<X size={14} />
</button>
</div>
<div className="p-3 space-y-3">
{/* Isolation toggles — ghost or hide all other parts */}
{onIsolateModeChange && (
<div className="flex gap-1.5">
<button
onClick={() => onIsolateModeChange(isolateMode === 'ghost' ? 'none' : 'ghost')}
title="Ghost other parts (semi-transparent)"
className={`flex-1 flex items-center justify-center gap-1 py-1 rounded text-[11px] font-medium transition-colors ${
isolateMode === 'ghost'
? 'bg-accent text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
}`}
>
<Layers size={11} /> Ghost
</button>
<button
onClick={() => onIsolateModeChange(isolateMode === 'hide' ? 'none' : 'hide')}
title="Hide other parts"
className={`flex-1 flex items-center justify-center gap-1 py-1 rounded text-[11px] font-medium transition-colors ${
isolateMode === 'hide'
? 'bg-accent text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
}`}
>
<EyeOff size={11} /> Hide
</button>
</div>
)}
{/* Type tabs */}
<div className="flex rounded-md overflow-hidden border border-gray-700 text-xs">
{(['library', 'hex'] as const).map((t) => (
<button
key={t}
onClick={() => setAssignType(t)}
className={`flex-1 py-1.5 font-medium transition-colors ${
assignType === t
? 'bg-accent text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
}`}
>
{t === 'library' ? 'Library Material' : 'Hex Color'}
</button>
))}
</div>
{assignType === 'library' ? (
<div>
<label className="block text-gray-400 text-[11px] mb-1">Material</label>
<select
value={libValue}
onChange={(e) => setLibValue(e.target.value)}
className="w-full bg-gray-800 border border-gray-600 text-white text-xs rounded px-2 py-1.5 focus:outline-none focus:border-accent"
>
{allMaterials.map((m) => (
<option key={m.id} value={m.name}>
{m.name}
{m.description ? `${m.description}` : ''}
</option>
))}
{allMaterials.length === 0 && (
<option value="">No materials found</option>
)}
</select>
</div>
) : (
<div>
<label className="block text-gray-400 text-[11px] mb-1">Hex Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={hexValue}
onChange={(e) => setHexValue(e.target.value)}
className="w-10 h-8 rounded border border-gray-600 bg-gray-800 cursor-pointer p-0.5"
/>
<input
type="text"
value={hexValue}
onChange={(e) => setHexValue(e.target.value)}
className="flex-1 bg-gray-800 border border-gray-600 text-white text-xs rounded px-2 py-1.5 font-mono focus:outline-none focus:border-accent"
placeholder="#888888"
/>
</div>
</div>
)}
{/* Preview swatch */}
<div className="flex items-center gap-2 text-[11px] text-gray-400">
<div
className="w-4 h-4 rounded-sm border border-gray-600 shrink-0"
style={{ backgroundColor: previewHex }}
/>
<span>Viewport preview color</span>
</div>
{/* Current assignment */}
{currentEntry && (
<div className="flex items-center gap-2 text-[11px] text-gray-400 bg-gray-800/60 rounded px-2 py-1.5">
<div
className="w-3 h-3 rounded-sm shrink-0 border border-gray-600"
style={{ backgroundColor: previewColorForEntry(currentEntry) }}
/>
<span className="truncate">Current: {currentEntry.value}</span>
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-1">
<button
onClick={handleAssign}
disabled={isBusy || (assignType === 'library' && !libValue)}
className="flex-1 px-3 py-1.5 rounded bg-accent hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed text-white text-xs font-medium transition-colors flex items-center justify-center gap-1"
>
{saveMut.isPending && <Loader2 size={11} className="animate-spin" />}
Assign
</button>
{currentEntry && (
<button
onClick={handleRemove}
disabled={isBusy}
className="px-3 py-1.5 rounded bg-gray-700 hover:bg-red-900 disabled:opacity-40 disabled:cursor-not-allowed text-gray-300 hover:text-white text-xs font-medium transition-colors flex items-center gap-1"
>
{removeMut.isPending && <Loader2 size={11} className="animate-spin" />}
Remove
</button>
)}
</div>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
+87
View File
@@ -0,0 +1,87 @@
import type { PartMaterialEntry, PartMaterialMap } from '../../api/cad'
/**
* Normalize a GLB mesh name by stripping suffixes added by the export pipeline:
* - OCC RWGltf_CafWriter adds "_AF0", "_AF1", … for repeated assembly instances
* - Blender adds ".001", ".002", … for name deduplication on re-import
*
* Mirrors the logic in render-worker/scripts/export_gltf.py (lines 107-114).
*
* Examples:
* "Ring_AF3" → "Ring"
* "Ring_AF0_AF1" → "Ring" (nested suffixes — loop until stable)
* "Cage.001" → "Cage"
* "Cage.001_AF2" → "Cage"
* "KOMP_ASM_1_AF0_ASM" → "KOMP_ASM_1" (_AF0_ASM variant)
* "GE360-HF_000_P_ASM_1_AF0_ASM" → "GE360-HF_000_P_ASM_1"
* "PlainPart" → "PlainPart"
*/
export function normalizeMeshName(name: string): string {
// Strip Blender dedup suffix (.001, .002, …)
let n = name.replace(/\.\d{3}$/, '')
// Strip OCC assembly-instance suffix — handles _AF0, _AF1, _AF0_ASM, _AF1_ASM patterns
// The optional (_ASM)? group catches assembly-node variants like _AF0_ASM
let prev = ''
while (prev !== n) { prev = n; n = n.replace(/_AF\d+(_ASM)?$/i, '') }
return n
}
// ---------------------------------------------------------------------------
// resolvePartMaterial
// ---------------------------------------------------------------------------
/**
* Resolve a material entry for a (already-normalized) GLB mesh name.
*
* OCC's GLB exporter strips certain path suffixes (_ASM_1, _1, _AF\d+_\d+)
* that cadquery keeps when parsing the STEP topology. This means stored keys
* from Excel-imported cad_part_materials may have extra suffixes compared to
* the actual GLB mesh names.
*
* Strategy:
* 1. Exact match: partMaterials[meshKey]
* 2. Prefix match: find shortest stored key that starts with meshKey + '_'
* e.g. GLB "GE360-EIN_HAELFTE" matches stored "GE360-EIN_HAELFTE_AF0_1"
*
* Returns undefined when no match exists.
*/
export function resolvePartMaterial(
meshKey: string,
partMaterials: PartMaterialMap,
): PartMaterialEntry | undefined {
// 1. Exact match
if (partMaterials[meshKey]) return partMaterials[meshKey]
// 2. Shortest stored key that starts with meshKey + '_'
let bestKey: string | undefined
for (const key of Object.keys(partMaterials)) {
if (key.startsWith(meshKey + '_')) {
if (!bestKey || key.length < bestKey.length) bestKey = key
}
}
return bestKey ? partMaterials[bestKey] : undefined
}
// ---------------------------------------------------------------------------
// convertCadPartMaterials
// ---------------------------------------------------------------------------
/**
* Convert Product.cad_part_materials (list of {part_name, material}) to
* the PartMaterialMap format used by the 3D viewers.
*
* - Skips entries with blank part_name or material
* - Detects hex colors (starting with "#") vs library material names
* - Normalizes part names with normalizeMeshName() so they match GLB mesh keys
*/
export function convertCadPartMaterials(
items: Array<{ part_name: string; material: string }>,
): PartMaterialMap {
const result: PartMaterialMap = {}
for (const item of items) {
if (!item.part_name.trim() || !item.material.trim()) continue
const key = normalizeMeshName(item.part_name.trim())
const value = item.material.trim()
result[key] = { type: value.startsWith('#') ? 'hex' : 'library', value }
}
return result
}
@@ -158,7 +158,7 @@ function DashboardGridInner() {
className="btn-secondary text-sm flex items-center gap-1.5 ml-auto"
>
<Settings2 size={14} />
Anpassen
Customize
</button>
</div>
@@ -171,7 +171,7 @@ function DashboardGridInner() {
</div>
) : (widgets ?? []).length === 0 ? (
<div className="rounded-xl border border-border-default p-8 text-center text-content-muted text-sm">
No widgets configured. Click <strong>Anpassen</strong> to add widgets.
No widgets configured. Click <strong>Customize</strong> to add widgets.
</div>
) : (
<div
+66 -133
View File
@@ -18,6 +18,20 @@ const nav = [
{ to: '/upload', icon: Upload, label: 'Upload' },
]
const privilegedNav = [
{ to: '/admin', icon: Settings, label: 'Admin' },
{ to: '/billing', icon: Receipt, label: 'Billing' },
{ to: '/media', icon: Image, label: 'Media Browser' },
{ to: '/workers', icon: Server, label: 'Workers' },
{ to: '/workflows', icon: GitBranch, label: 'Workflows' },
{ to: '/asset-libraries', icon: Library, label: 'Asset Libraries' },
]
const adminOnlyNav = [
{ to: '/notification-settings', icon: BellRing, label: 'Notification Settings' },
{ to: '/tenants', icon: Building2, label: 'Tenants' },
]
export default function Layout() {
const { user, logout } = useAuthStore()
const navigate = useNavigate()
@@ -148,141 +162,60 @@ export default function Layout() {
)
})}
{(checkIsPrivileged(user)) && (
<NavLink
to="/admin"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<Settings size={18} />
Admin
</NavLink>
)}
{(checkIsPrivileged(user)) && (
<NavLink
to="/billing"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<Receipt size={18} />
Billing
</NavLink>
)}
{(checkIsPrivileged(user)) && (
<NavLink
to="/media"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<Image size={18} />
Media Browser
</NavLink>
)}
{(checkIsPrivileged(user)) && (
<NavLink
to="/workers"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<Server size={18} />
Workers
</NavLink>
)}
{(checkIsPrivileged(user)) && (
<NavLink
to="/workflows"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<GitBranch size={18} />
Workflows
</NavLink>
)}
{(checkIsPrivileged(user)) && (
<NavLink
to="/asset-libraries"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<Library size={18} />
Asset Libraries
</NavLink>
{checkIsPrivileged(user) && (
<>
<div className="pt-2 pb-1 px-3">
<span className="text-[10px] font-semibold uppercase tracking-widest text-content-muted">
Management
</span>
</div>
{privilegedNav.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<Icon size={18} />
{label}
</NavLink>
))}
</>
)}
{checkIsAdmin(user) && (
<NavLink
to="/notification-settings"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<BellRing size={18} />
Notification Settings
</NavLink>
)}
{checkIsAdmin(user) && (
<NavLink
to="/tenants"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<Building2 size={18} />
Tenants
</NavLink>
<>
<div className="pt-2 pb-1 px-3">
<span className="text-[10px] font-semibold uppercase tracking-widest text-content-muted">
Admin Only
</span>
</div>
{adminOnlyNav.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<Icon size={18} />
{label}
</NavLink>
))}
</>
)}
</nav>
@@ -0,0 +1,230 @@
import { useState } from 'react'
import { X, Cpu, ChevronDown, ChevronUp, Zap } from 'lucide-react'
import type { RenderLog } from '../../api/orders'
interface Props {
open: boolean
onClose: () => void
title: string
renderLog: RenderLog | null | undefined
renderStartedAt?: string | null
renderCompletedAt?: string | null
}
function formatBytes(n?: number | null): string {
if (n == null) return '—'
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1024 / 1024).toFixed(1)} MB`
}
function formatDuration(s?: number | null): string {
if (s == null) return '—'
if (s < 60) return `${s.toFixed(1)}s`
const m = Math.floor(s / 60)
const rem = (s % 60).toFixed(0)
return `${m}m ${rem}s`
}
function SectionHeader({ children }: { children: React.ReactNode }) {
return (
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide mb-2">
{children}
</p>
)
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex justify-between gap-4 text-sm py-0.5">
<span className="text-content-muted shrink-0">{label}</span>
<span className="text-content font-medium text-right">{value ?? '—'}</span>
</div>
)
}
function BoolPill({ value, trueLabel = 'Yes', falseLabel = 'No' }: { value: boolean | undefined; trueLabel?: string; falseLabel?: string }) {
if (value == null) return <span className="text-content-muted"></span>
return (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${value ? 'bg-status-success-bg text-status-success-text' : 'bg-surface-muted text-content-muted'}`}>
{value ? trueLabel : falseLabel}
</span>
)
}
export default function RenderInfoModal({
open,
onClose,
title,
renderLog,
renderStartedAt,
renderCompletedAt,
}: Props) {
const [logExpanded, setLogExpanded] = useState(false)
if (!open) return null
const rl = renderLog
const isAnimation = rl?.type === 'turntable'
const hasTemplate = !!rl?.template
const hasTimestamps = !!(renderStartedAt || renderCompletedAt)
const hasLog = (rl?.log_lines?.length ?? 0) > 0
const hasError = !!rl?.error
const engineLabel = rl?.engine_used || rl?.engine || '—'
const device = rl?.device_used
const isGpu = device?.toLowerCase().includes('gpu')
const isCpu = device?.toLowerCase().includes('cpu')
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
onClick={onClose}
>
<div
className="relative w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-xl shadow-2xl"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border-light sticky top-0"
style={{ backgroundColor: 'var(--color-bg-surface)' }}>
<h2 className="font-semibold text-content">{title} Render Info</h2>
<button
onClick={onClose}
className="text-content-muted hover:text-content transition-colors p-1 rounded"
>
<X size={18} />
</button>
</div>
{/* Body */}
<div className="px-6 py-5 space-y-5">
{/* Error */}
{hasError && (
<div className="rounded-md p-3 text-sm" style={{ backgroundColor: 'var(--color-status-error-bg)', color: 'var(--color-status-error-text)' }}>
<p className="font-semibold mb-1">Render Error</p>
<pre className="whitespace-pre-wrap text-xs font-mono break-all">{rl!.error}</pre>
</div>
)}
{/* Render Settings */}
{rl && (
<div className="rounded-md p-4" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<SectionHeader>Render Settings</SectionHeader>
<div className="space-y-0.5">
{rl.renderer && <Row label="Renderer" value={rl.renderer} />}
<Row label="Engine" value={engineLabel} />
{device && (
<Row
label="Device"
value={
<span className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-medium ${
isGpu
? 'bg-status-success-bg text-status-success-text'
: isCpu
? 'bg-status-warning-bg text-status-warning-text'
: 'bg-surface-muted text-content-muted'
}`}>
{isGpu ? <Zap size={10} /> : <Cpu size={10} />}
{device}
</span>
}
/>
)}
{rl.samples != null && <Row label="Samples" value={rl.samples} />}
{rl.compute_type && <Row label="Compute Type" value={rl.compute_type} />}
{rl.gpu_fallback != null && (
<Row label="GPU Fallback" value={<BoolPill value={rl.gpu_fallback} trueLabel="Yes (CPU used)" falseLabel="No" />} />
)}
{rl.format && <Row label="Format" value={rl.format.toUpperCase()} />}
{rl.parts_count != null && <Row label="Parts" value={rl.parts_count} />}
{rl.stl_quality && <Row label="STL Quality" value={rl.stl_quality} />}
{rl.smooth_angle != null && <Row label="Smooth Angle" value={`${rl.smooth_angle}°`} />}
</div>
</div>
)}
{/* Timing */}
{rl && (rl.total_duration_s != null || rl.stl_duration_s != null || rl.render_duration_s != null) && (
<div className="rounded-md p-4" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<SectionHeader>Timing</SectionHeader>
<div className="space-y-0.5">
<Row label="Total" value={formatDuration(rl.total_duration_s)} />
{rl.stl_duration_s != null && <Row label="STL Conversion" value={formatDuration(rl.stl_duration_s)} />}
{rl.render_duration_s != null && <Row label="Render" value={formatDuration(rl.render_duration_s)} />}
{isAnimation && rl.ffmpeg_duration_s != null && <Row label="FFmpeg" value={formatDuration(rl.ffmpeg_duration_s)} />}
{isAnimation && rl.frame_count != null && <Row label="Frames" value={rl.frame_count} />}
{isAnimation && rl.fps != null && <Row label="FPS" value={rl.fps} />}
</div>
</div>
)}
{/* Files */}
{rl && (rl.output_size_bytes != null || rl.stl_size_bytes != null) && (
<div className="rounded-md p-4" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<SectionHeader>Files</SectionHeader>
<div className="space-y-0.5">
{rl.output_size_bytes != null && <Row label="Output File" value={formatBytes(rl.output_size_bytes)} />}
{rl.stl_size_bytes != null && <Row label="STL Cache" value={formatBytes(rl.stl_size_bytes)} />}
</div>
</div>
)}
{/* Template */}
{hasTemplate && rl && (
<div className="rounded-md p-4" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<SectionHeader>Template</SectionHeader>
<div className="space-y-0.5">
<Row label="Path" value={<span className="font-mono text-xs break-all">{rl.template}</span>} />
{rl.lighting_only != null && <Row label="Lighting Only" value={<BoolPill value={rl.lighting_only} />} />}
{rl.shadow_catcher != null && <Row label="Shadow Catcher" value={<BoolPill value={rl.shadow_catcher} />} />}
{rl.material_replace != null && <Row label="Material Replace" value={<BoolPill value={rl.material_replace} />} />}
</div>
</div>
)}
{/* Timestamps */}
{hasTimestamps && (
<div className="rounded-md p-4" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<SectionHeader>Timestamps</SectionHeader>
<div className="space-y-0.5">
{renderStartedAt && <Row label="Started" value={new Date(renderStartedAt).toLocaleString()} />}
{renderCompletedAt && <Row label="Completed" value={new Date(renderCompletedAt).toLocaleString()} />}
</div>
</div>
)}
{/* Blender Log */}
{hasLog && rl && (
<div className="rounded-md p-4" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<button
onClick={() => setLogExpanded((v) => !v)}
className="flex items-center gap-2 w-full text-left"
>
<SectionHeader>Blender Log</SectionHeader>
<span className="ml-auto text-content-muted">
{logExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</span>
</button>
{logExpanded && (
<pre className="mt-2 text-xs font-mono whitespace-pre-wrap break-all max-h-64 overflow-y-auto text-content-secondary leading-relaxed">
{rl.log_lines!.join('\n')}
</pre>
)}
{!logExpanded && (
<p className="text-xs text-content-muted mt-1">{rl.log_lines!.length} lines click to expand</p>
)}
</div>
)}
{!rl && (
<p className="text-sm text-content-muted text-center py-4">No render metadata available.</p>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,66 @@
import { CheckCircle2 } from 'lucide-react'
interface StepIndicatorProps {
step: number // current step (1-based)
total: number
labels: string[]
}
export default function StepIndicator({ step, total, labels }: StepIndicatorProps) {
return (
<>
{/* Mobile: simple text */}
<div className="md:hidden flex items-center justify-center py-3">
<span className="text-sm font-medium text-content-secondary">
Step {step} of {total}
{labels[step - 1] ? `${labels[step - 1]}` : ''}
</span>
</div>
{/* Desktop: full step bar */}
<div className="hidden md:flex items-center w-full mb-6">
{Array.from({ length: total }, (_, i) => {
const num = i + 1
const isCompleted = num < step
const isActive = num === step
const isFuture = num > step
return (
<div key={num} className="flex items-center flex-1 last:flex-none">
{/* Step circle + label */}
<div className="flex flex-col items-center gap-1">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-colors ${
isCompleted
? 'bg-accent text-accent-text'
: isActive
? 'bg-accent text-accent-text ring-4 ring-accent-light'
: 'bg-surface-muted text-content-muted border border-border-default'
}`}
>
{isCompleted ? <CheckCircle2 size={16} /> : num}
</div>
<span
className={`text-xs font-medium whitespace-nowrap ${
isActive ? 'text-accent' : isFuture ? 'text-content-muted' : 'text-content-secondary'
}`}
>
{labels[i] ?? `Step ${num}`}
</span>
</div>
{/* Connector line (not after last step) */}
{num < total && (
<div
className={`flex-1 h-0.5 mx-2 mt-[-16px] transition-colors ${
isCompleted ? 'bg-accent' : 'bg-border-default'
}`}
/>
)}
</div>
)
})}
</div>
</>
)
}
+31
View File
@@ -79,6 +79,14 @@
/* Status — Info */
--color-status-info-bg: #dbeafe;
--color-status-info-text: #1e40af;
/* Extended badge colors */
--color-badge-purple-bg: rgba(124, 58, 237, 0.1);
--color-badge-purple-text: #6d28d9;
--color-badge-orange-bg: rgba(234, 88, 12, 0.1);
--color-badge-orange-text: #c2410c;
--color-badge-teal-bg: rgba(13, 148, 136, 0.1);
--color-badge-teal-text: #0f766e;
}
/* ============================================================
@@ -117,6 +125,14 @@
/* Status — Info */
--color-status-info-bg: rgba(59, 130, 246, 0.15);
--color-status-info-text: #60a5fa;
/* Extended badge colors */
--color-badge-purple-bg: rgba(124, 58, 237, 0.2);
--color-badge-purple-text: #a78bfa;
--color-badge-orange-bg: rgba(234, 88, 12, 0.2);
--color-badge-orange-text: #fb923c;
--color-badge-teal-bg: rgba(13, 148, 136, 0.2);
--color-badge-teal-text: #2dd4bf;
}
/* Dark accent-light overrides (rgba instead of solid pastel) */
@@ -230,6 +246,21 @@
background-color: var(--color-bg-muted);
color: var(--color-text-secondary);
}
.badge-purple {
@apply badge;
background-color: var(--color-badge-purple-bg);
color: var(--color-badge-purple-text);
}
.badge-orange {
@apply badge;
background-color: var(--color-badge-orange-bg);
color: var(--color-badge-orange-text);
}
.badge-teal {
@apply badge;
background-color: var(--color-badge-teal-bg);
color: var(--color-badge-teal-text);
}
/* Input base — replaces repeated inline input patterns */
.input-base {
+399 -146
View File
@@ -1,7 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState, useRef } from 'react'
import { toast } from 'sonner'
import { UserPlus, Trash2, Pencil, ChevronDown, ChevronUp, ChevronRight, Settings, RefreshCw, CheckCircle2, XCircle, Clock, DollarSign, Layers, AlertTriangle, Upload, FileBox, Plus, X, LayoutDashboard, Cpu, Zap } from 'lucide-react'
import { UserPlus, Trash2, Pencil, ChevronDown, ChevronUp, ChevronRight, Settings, RefreshCw, CheckCircle2, XCircle, Clock, DollarSign, Layers, AlertTriangle, Upload, FileBox, Plus, X, LayoutDashboard, Cpu, Zap, AlertCircle } from 'lucide-react'
import { Link } from 'react-router-dom'
import api from '../api/client'
import ConfirmModal from '../components/ConfirmModal'
@@ -10,6 +10,7 @@ import TemplateEditor from '../components/admin/TemplateEditor'
import PricingTierTable from '../components/admin/PricingTierTable'
import OutputTypeTable from '../components/admin/OutputTypeTable'
import RenderTemplateTable from '../components/admin/RenderTemplateTable'
import GlobalRenderPositionsPanel from '../components/admin/GlobalRenderPositionsPanel'
import { useAuthStore, isAdmin as checkIsAdmin } from '../store/auth'
import { listPricingTiers } from '../api/pricing'
import { listOutputTypes } from '../api/outputTypes'
@@ -29,6 +30,8 @@ export default function AdminPage() {
const isAdmin = checkIsAdmin(user)
const [showNewUser, setShowNewUser] = useState(false)
const [newUser, setNewUser] = useState({ email: '', password: '', full_name: '', role: 'client' })
const [editingUserId, setEditingUserId] = useState<string | null>(null)
const [editUserDraft, setEditUserDraft] = useState<{ full_name: string; role: string; is_active: boolean }>({ full_name: '', role: 'client', is_active: true })
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null)
const [priorityNewEntry, setPriorityNewEntry] = useState<string>('')
@@ -68,6 +71,17 @@ export default function AdminPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const updateUserMut = useMutation({
mutationFn: ({ id, data }: { id: string; data: { full_name: string; role: string; is_active: boolean } }) =>
api.patch(`/admin/users/${id}`, data),
onSuccess: () => {
toast.success('User updated')
qc.invalidateQueries({ queryKey: ['admin-users'] })
setEditingUserId(null)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
type Settings = {
thumbnail_renderer: string
blender_engine: string
@@ -167,6 +181,15 @@ export default function AdminPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Import failed'),
})
const cleanupOrphanedMut = useMutation({
mutationFn: () => api.post('/media/cleanup-orphaned'),
onSuccess: (res) => {
toast.success(`Cleanup done: ${res.data.deleted} orphaned records deleted (${res.data.checked} checked)`)
qc.invalidateQueries({ queryKey: ['media-browser'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Cleanup failed'),
})
const reextractMetadataMut = useMutation({
mutationFn: () => api.post('/admin/settings/reextract-metadata'),
onSuccess: (res) => {
@@ -175,6 +198,14 @@ export default function AdminPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const cleanupOrphanedCadMut = useMutation({
mutationFn: () => api.post('/admin/settings/cleanup-orphaned-cad-files'),
onSuccess: (res) => {
toast.success(`Deleted ${res.data.deleted_records} orphaned CAD records, freed ${res.data.freed_mb} MB`)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Cleanup failed'),
})
const recoverStuckMut = useMutation({
mutationFn: () => api.post('/admin/settings/recover-stuck-processing'),
onSuccess: (res) => {
@@ -265,19 +296,65 @@ export default function AdminPage() {
)
}
type AdminTab = 'overview' | 'users' | 'render' | 'pricing' | 'libraries' | 'config'
const [activeTab, setActiveTab] = useState<AdminTab>('overview')
const hasUnsavedChanges =
Object.keys(blenderDraft).length > 0 ||
Object.keys(viewerDraft).length > 0 ||
Object.keys(tessellationDraft).length > 0 ||
Object.keys(smtpDraft).length > 0
const TABS: { id: AdminTab; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'users', label: 'Users' },
{ id: 'render', label: 'Render' },
{ id: 'pricing', label: 'Pricing' },
{ id: 'libraries', label: 'Libraries' },
{ id: 'config', label: 'Config' },
]
return (
<div>
{/* Tab header */}
<div className="px-8 pt-6 pb-0 bg-surface border-b border-border-default sticky top-0 z-10">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold text-content">Admin</h1>
{hasUnsavedChanges && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-amber-50 border border-amber-200 rounded-lg text-amber-700 text-sm dark:bg-amber-950 dark:border-amber-800 dark:text-amber-300">
<AlertCircle size={14} />
Unsaved changes
</div>
)}
</div>
<div className="flex gap-1 -mb-px">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-accent text-accent'
: 'border-transparent text-content-secondary hover:text-content hover:border-border-default'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
<div className="p-8 space-y-8">
<h1 className="text-2xl font-bold text-content">Admin</h1>
{/* ------------------------------------------------------------------ */}
{/* Pricing Summary */}
{/* ------------------------------------------------------------------ */}
<PricingSummaryCard />
{activeTab === 'overview' && <PricingSummaryCard />}
{/* ------------------------------------------------------------------ */}
{/* Users (admin only) */}
{/* ------------------------------------------------------------------ */}
{isAdmin && <div className="card">
{activeTab === 'users' && isAdmin && <div className="card">
<div className="p-4 border-b border-border-default flex items-center justify-between">
<h2 className="font-semibold text-content">Users</h2>
<button onClick={() => setShowNewUser(!showNewUser)} className="btn-primary">
@@ -329,35 +406,102 @@ export default function AdminPage() {
)}
<div className="divide-y divide-border-light">
{users?.map((user) => (
<div key={user.id} className="flex items-center px-6 py-3">
<div className="flex-1">
<p className="text-sm font-medium text-content">{user.full_name}</p>
<p className="text-xs text-content-muted">{user.email}</p>
</div>
<span className={`badge mr-4 ${checkIsAdmin(user) ? 'badge-green' : 'badge-gray'}`}>
{user.role}
</span>
<span className={`badge mr-4 ${user.is_active ? 'badge-green' : 'badge-red'}`}>
{user.is_active ? 'active' : 'inactive'}
</span>
<button
onClick={() => {
setConfirmState({
open: true,
title: 'Delete User',
message: `Delete user "${user.email}"? This cannot be undone.`,
onConfirm: () => {
deleteUserMut.mutate(user.id)
setConfirmState((s) => ({ ...s, open: false }))
},
})
}}
className="text-content-muted hover:text-red-500 transition-colors"
title="Delete user"
>
<Trash2 size={16} />
</button>
{users?.map((u) => (
<div key={u.id}>
{editingUserId === u.id ? (
<div className="px-6 py-3 bg-surface-alt space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-content-muted block mb-1">Full name</label>
<input
value={editUserDraft.full_name}
onChange={(e) => setEditUserDraft((d) => ({ ...d, full_name: e.target.value }))}
className="px-3 py-1.5 border border-border-default rounded-md text-sm w-full"
/>
</div>
<div>
<label className="text-xs text-content-muted block mb-1">Role</label>
<select
value={editUserDraft.role}
onChange={(e) => setEditUserDraft((d) => ({ ...d, role: e.target.value }))}
className="px-3 py-1.5 border border-border-default rounded-md text-sm w-full"
>
<option value="client">Client</option>
<option value="project_manager">Project Manager</option>
<option value="admin">Admin</option>
<option value="global_admin">Global Admin</option>
<option value="tenant_admin">Tenant Admin</option>
</select>
</div>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-content cursor-pointer">
<input
type="checkbox"
checked={editUserDraft.is_active}
onChange={(e) => setEditUserDraft((d) => ({ ...d, is_active: e.target.checked }))}
className="rounded"
/>
Active
</label>
<div className="flex gap-2 ml-auto">
<button
onClick={() => setEditingUserId(null)}
className="btn-secondary text-sm"
>
Cancel
</button>
<button
onClick={() => updateUserMut.mutate({ id: u.id, data: editUserDraft })}
disabled={updateUserMut.isPending}
className="btn-primary text-sm"
>
{updateUserMut.isPending ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
) : (
<div className="flex items-center px-6 py-3">
<div className="flex-1">
<p className="text-sm font-medium text-content">{u.full_name}</p>
<p className="text-xs text-content-muted">{u.email}</p>
</div>
<span className={`badge mr-4 ${checkIsAdmin(u) ? 'badge-green' : 'badge-gray'}`}>
{u.role}
</span>
<span className={`badge mr-4 ${u.is_active ? 'badge-green' : 'badge-red'}`}>
{u.is_active ? 'active' : 'inactive'}
</span>
<button
onClick={() => {
setEditingUserId(u.id)
setEditUserDraft({ full_name: u.full_name, role: u.role, is_active: u.is_active })
}}
className="text-content-muted hover:text-accent transition-colors mr-3"
title="Edit user"
>
<Pencil size={16} />
</button>
<button
onClick={() => {
setConfirmState({
open: true,
title: 'Delete User',
message: `Delete user "${u.email}"? This cannot be undone.`,
onConfirm: () => {
deleteUserMut.mutate(u.id)
setConfirmState((s) => ({ ...s, open: false }))
},
})
}}
className="text-content-muted hover:text-red-500 transition-colors"
title="Delete user"
>
<Trash2 size={16} />
</button>
</div>
)}
</div>
))}
</div>
@@ -366,7 +510,7 @@ export default function AdminPage() {
{/* ------------------------------------------------------------------ */}
{/* Blender Render Settings (admin only) */}
{/* ------------------------------------------------------------------ */}
{isAdmin && <div className="card">
{activeTab === 'render' && isAdmin && <div className="card">
<div className="p-4 border-b border-border-default flex items-center justify-between">
<div className="flex items-center gap-2">
<Settings size={16} className="text-content-muted" />
@@ -788,6 +932,34 @@ export default function AdminPage() {
</button>
<p className="text-xs text-content-muted">Registers existing renders &amp; CAD thumbnails in the Media Browser.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => cleanupOrphanedMut.mutate()}
disabled={cleanupOrphanedMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Find and delete all MediaAsset DB records whose backing file is missing on disk"
>
<Trash2 size={14} className={cleanupOrphanedMut.isPending ? 'animate-spin' : ''} />
{cleanupOrphanedMut.isPending ? 'Checking files…' : 'Clean Up Orphaned Media'}
</button>
<p className="text-xs text-content-muted">Removes DB records for renders whose files no longer exist on disk.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => {
if (window.confirm('Delete all orphaned STEP files (not linked to any product)? This cannot be undone.')) {
cleanupOrphanedCadMut.mutate()
}
}}
disabled={cleanupOrphanedCadMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Delete STEP files and thumbnails that are no longer linked to any product"
>
<Trash2 size={14} className={cleanupOrphanedCadMut.isPending ? 'animate-spin' : ''} />
{cleanupOrphanedCadMut.isPending ? 'Deleting…' : 'Clean Up Orphaned STEP Files'}
</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={() => reextractMetadataMut.mutate()}
@@ -817,10 +989,28 @@ export default function AdminPage() {
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Global Render Positions (admin only) */}
{/* ------------------------------------------------------------------ */}
{activeTab === 'render' && isAdmin && <div className="card">
<div className="p-4 border-b border-border-light flex items-center gap-2">
<Settings size={16} className="text-content-muted" />
<div>
<h2 className="font-semibold text-content">Global Render Positions</h2>
<p className="text-xs text-content-muted mt-0.5">
Camera rotation presets available to all products. Per-product positions override these.
</p>
</div>
</div>
<div className="p-4">
<GlobalRenderPositionsPanel />
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Render Templates (admin/PM) */}
{/* ------------------------------------------------------------------ */}
<div className="card">
{activeTab === 'render' && <div className="card">
<div className="p-4 border-b border-border-light flex items-center gap-2">
<FileBox size={16} className="text-content-muted" />
<div>
@@ -836,17 +1026,17 @@ export default function AdminPage() {
<div className="border-t border-border-light p-4">
<MaterialLibraryPanel />
</div>
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Asset Libraries */}
{/* ------------------------------------------------------------------ */}
<AssetLibraryPanel />
{activeTab === 'libraries' && <AssetLibraryPanel />}
{/* ------------------------------------------------------------------ */}
{/* Output Types */}
{/* ------------------------------------------------------------------ */}
<div className="card">
{activeTab === 'pricing' && <div className="card">
<div className="p-4 border-b border-border-light flex items-center gap-2">
<Layers size={16} className="text-content-muted" />
<div>
@@ -857,12 +1047,12 @@ export default function AdminPage() {
</div>
</div>
<OutputTypeTable />
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Pricing Tiers */}
{/* ------------------------------------------------------------------ */}
<div className="card">
{activeTab === 'pricing' && <div className="card">
<div className="p-4 border-b border-border-default flex items-center gap-2">
<DollarSign size={16} className="text-content-muted" />
<div>
@@ -873,12 +1063,12 @@ export default function AdminPage() {
</div>
</div>
<PricingTierTable />
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* E-Mail / SMTP Settings */}
{/* ------------------------------------------------------------------ */}
{isAdmin && (
{activeTab === 'config' && isAdmin && (
<div className="card">
<div className="p-4 border-b border-border-default">
<h2 className="font-semibold text-content">E-Mail Notifications (SMTP)</h2>
@@ -966,7 +1156,7 @@ export default function AdminPage() {
{/* ------------------------------------------------------------------ */}
{/* Templates */}
{/* ------------------------------------------------------------------ */}
<div className="card">
{activeTab === 'libraries' && <div className="card">
<div className="p-4 border-b border-border-default">
<h2 className="font-semibold text-content">Templates</h2>
<p className="text-xs text-content-muted mt-0.5">
@@ -1017,12 +1207,12 @@ export default function AdminPage() {
)
})}
</div>
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Dashboard Widget Configuration (admin only) */}
{/* ------------------------------------------------------------------ */}
{isAdmin && (
{activeTab === 'config' && isAdmin && (
<div className="card">
<div className="p-4 border-b border-border-default flex items-center gap-2">
<LayoutDashboard size={16} className="text-content-muted" />
@@ -1066,7 +1256,7 @@ export default function AdminPage() {
{/* ------------------------------------------------------------------ */}
{/* 3D Viewer & GLB Export Settings */}
{/* ------------------------------------------------------------------ */}
<div className="card">
{activeTab === 'render' && <div className="card">
<div className="p-4 border-b border-border-default">
<h2 className="font-semibold text-content">3D Viewer & GLB Export</h2>
<p className="text-sm text-content-muted mt-0.5">
@@ -1205,12 +1395,12 @@ export default function AdminPage() {
)}
</div>
</div>
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Tessellation Quality */}
{/* ------------------------------------------------------------------ */}
<div className="card">
{activeTab === 'render' && <div className="card">
<div className="p-4 border-b border-border-default">
<h2 className="font-semibold text-content">Tessellation Quality</h2>
<p className="text-sm text-content-muted mt-0.5">
@@ -1218,6 +1408,58 @@ export default function AdminPage() {
</p>
</div>
<div className="p-4 space-y-6">
{/* Presets */}
{(() => {
const PRESETS = [
{
label: 'Draft',
description: 'Fast export, visible faceting on large curves',
color: 'border-amber-400 text-amber-700',
values: { gltf_preview_linear_deflection: 0.2, gltf_preview_angular_deflection: 0.3, gltf_production_linear_deflection: 0.05, gltf_production_angular_deflection: 0.1 },
},
{
label: 'Standard',
description: 'Smooth curves, no fan artifacts recommended',
color: 'border-blue-400 text-blue-700',
values: { gltf_preview_linear_deflection: 0.1, gltf_preview_angular_deflection: 0.1, gltf_production_linear_deflection: 0.03, gltf_production_angular_deflection: 0.05 },
},
{
label: 'Fine',
description: 'Maximum quality, very large files, slow export',
color: 'border-emerald-400 text-emerald-700',
values: { gltf_preview_linear_deflection: 0.05, gltf_preview_angular_deflection: 0.05, gltf_production_linear_deflection: 0.01, gltf_production_angular_deflection: 0.02 },
},
]
const isActive = (preset: typeof PRESETS[0]) =>
tess.gltf_preview_linear_deflection === preset.values.gltf_preview_linear_deflection &&
tess.gltf_preview_angular_deflection === preset.values.gltf_preview_angular_deflection &&
tess.gltf_production_linear_deflection === preset.values.gltf_production_linear_deflection &&
tess.gltf_production_angular_deflection === preset.values.gltf_production_angular_deflection
return (
<div>
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide mb-2">Presets</p>
<div className="flex gap-3">
{PRESETS.map(preset => (
<button
key={preset.label}
onClick={() => setTessellationDraft(preset.values)}
className={`flex-1 p-3 rounded-lg border-2 text-left transition-colors ${isActive(preset) ? preset.color + ' bg-opacity-10' : 'border-border-default text-content hover:border-blue-300'}`}
style={isActive(preset) ? { backgroundColor: 'var(--color-bg-surface-alt)' } : undefined}
>
<div className="font-semibold text-sm">{preset.label}</div>
<div className="text-xs text-content-muted mt-0.5">{preset.description}</div>
<div className="text-xs font-mono text-content-secondary mt-1 space-y-0.5">
<div>preview: {preset.values.gltf_preview_angular_deflection} rad / {preset.values.gltf_preview_linear_deflection} mm</div>
<div>prod: {preset.values.gltf_production_angular_deflection} rad / {preset.values.gltf_production_linear_deflection} mm</div>
</div>
</button>
))}
</div>
</div>
)
})()}
{/* Manual inputs */}
<div className="grid grid-cols-2 gap-6">
<div className="space-y-4">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Preview (Geometry GLB)</p>
@@ -1238,10 +1480,10 @@ export default function AdminPage() {
<label className="text-sm text-content-secondary w-36 shrink-0">Angular deflection</label>
<input
type="number"
step="0.05"
min="0.05"
step="0.01"
min="0.01"
max="1.5"
value={tess.gltf_preview_angular_deflection ?? 0.5}
value={tess.gltf_preview_angular_deflection ?? 0.1}
onChange={e => setTessellationDraft(d => ({ ...d, gltf_preview_angular_deflection: parseFloat(e.target.value) }))}
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
/>
@@ -1268,10 +1510,10 @@ export default function AdminPage() {
<label className="text-sm text-content-secondary w-36 shrink-0">Angular deflection</label>
<input
type="number"
step="0.05"
min="0.05"
step="0.005"
min="0.005"
max="1.5"
value={tess.gltf_production_angular_deflection ?? 0.2}
value={tess.gltf_production_angular_deflection ?? 0.05}
onChange={e => setTessellationDraft(d => ({ ...d, gltf_production_angular_deflection: parseFloat(e.target.value) }))}
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
/>
@@ -1293,12 +1535,12 @@ export default function AdminPage() {
)}
</div>
</div>
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Material Library link */}
{/* ------------------------------------------------------------------ */}
<div className="card p-5 flex items-center justify-between">
{activeTab === 'render' && <div className="card p-5 flex items-center justify-between">
<div>
<h2 className="font-semibold text-content">Material Library</h2>
<p className="text-xs text-content-muted mt-0.5">
@@ -1308,7 +1550,106 @@ export default function AdminPage() {
<Link to="/materials" className="btn-secondary text-sm">
Open Material Library →
</Link>
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* GPU Status */}
{/* ------------------------------------------------------------------ */}
{activeTab === 'render' && isAdmin && (
<div className="card">
<button
className="w-full p-5 flex items-center justify-between text-left"
onClick={() => setGpuProbeExpanded((v) => !v)}
>
<div className="flex items-center gap-3">
<Zap size={18} className="text-content-secondary" />
<div>
<h2 className="font-semibold text-content">GPU Status</h2>
<p className="text-xs text-content-muted mt-0.5">
Verify that the render-worker is using the GPU (not CPU fallback).
</p>
</div>
</div>
<div className="flex items-center gap-3">
{gpuStatusBadge()}
{gpuProbeExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</div>
</button>
{gpuProbeExpanded && (
<div className="px-5 pb-5 space-y-4 border-t border-border-default pt-4">
<div className="flex items-center gap-3">
<button
onClick={handleRunGpuCheck}
disabled={gpuProbing}
className="btn-primary flex items-center gap-2"
>
{gpuProbing ? (
<><RefreshCw size={14} className="animate-spin" /> Running probe…</>
) : (
<><Zap size={14} /> Run GPU Check</>
)}
</button>
{gpuProbeResult && (
<span className="text-xs text-content-muted">
Last checked: {new Date(gpuProbeResult.timestamp).toLocaleString()}
</span>
)}
</div>
{gpuProbeResult && (
<div className="bg-surface-alt rounded-md p-4 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Status</span>
{gpuStatusBadge()}
</div>
{gpuProbeResult.device_type && (
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Device type</span>
<span className="text-xs text-content">{gpuProbeResult.device_type}</span>
</div>
)}
{gpuProbeResult.devices && gpuProbeResult.devices.length > 0 && (
<div className="flex items-start gap-2">
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Devices</span>
<div className="space-y-0.5">
{gpuProbeResult.devices.map((d: string, i: number) => (
<span key={i} className="block text-xs text-content">{d}</span>
))}
</div>
</div>
)}
{gpuProbeResult.render_time_s != null && (
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Render time</span>
<span className="text-xs text-content">{gpuProbeResult.render_time_s.toFixed(2)}s</span>
</div>
)}
{gpuProbeResult.error && (
<div className="flex items-start gap-2">
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Error</span>
<span className="text-xs text-status-error-text font-mono">{gpuProbeResult.error}</span>
</div>
)}
</div>
)}
{!gpuProbeResult && !gpuProbing && (
<p className="text-xs text-content-muted">No probe result yet. Click "Run GPU Check" to trigger a test render.</p>
)}
</div>
)}
</div>
)}
<ConfirmModal
open={confirmState.open}
title={confirmState.title}
message={confirmState.message}
onConfirm={confirmState.onConfirm}
onCancel={() => setConfirmState((s) => ({ ...s, open: false }))}
/>
</div>
</div>
)
}
@@ -1393,6 +1734,7 @@ function AssetLibraryPanel() {
const [newDesc, setNewDesc] = useState('')
const [newFile, setNewFile] = useState<File | null>(null)
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} })
const { data: libraries = [] } = useQuery({
queryKey: ['asset-libraries'],
@@ -1586,95 +1928,6 @@ function AssetLibraryPanel() {
</div>
)}
{/* ------------------------------------------------------------------ */}
{/* GPU Status (admin only) */}
{/* ------------------------------------------------------------------ */}
{isAdmin && (
<div className="card">
<button
className="w-full p-4 flex items-center justify-between text-left"
onClick={() => setGpuProbeExpanded((v) => !v)}
>
<div className="flex items-center gap-2">
<Zap size={16} className="text-content-muted" />
<div>
<h2 className="font-semibold text-content">GPU Status</h2>
<p className="text-xs text-content-muted mt-0.5">
Check Blender GPU availability on the render worker
</p>
</div>
</div>
<div className="flex items-center gap-3">
{gpuStatusBadge()}
{gpuProbeResult?.probed_at && (
<span className="text-xs text-content-muted">
Last checked: {Math.round((Date.now() - new Date(gpuProbeResult.probed_at).getTime()) / 60000)} min ago
</span>
)}
{gpuProbeExpanded ? <ChevronUp size={16} className="text-content-muted" /> : <ChevronDown size={16} className="text-content-muted" />}
</div>
</button>
{gpuProbeExpanded && (
<div className="px-6 pb-6 space-y-4 border-t border-border-default pt-4">
<div className="flex items-center gap-3">
<button
onClick={handleRunGpuCheck}
disabled={gpuProbing}
className="flex items-center gap-2 px-4 py-2 text-sm rounded-md bg-accent text-accent-text font-medium hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{gpuProbing
? <RefreshCw size={14} className="animate-spin" />
: <Zap size={14} />
}
{gpuProbing ? 'Checking…' : 'Run GPU Check'}
</button>
{gpuProbing && (
<span className="text-xs text-content-muted">
Polling for result (up to 45s)
</span>
)}
</div>
{gpuProbeResult && (
<div className="rounded-lg border border-border-default bg-surface-alt p-4 space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-content-secondary w-28 shrink-0">Status</span>
{gpuStatusBadge()}
</div>
{gpuProbeResult.device_type && (
<div className="flex items-center gap-2">
<span className="text-content-secondary w-28 shrink-0">Device type</span>
<span className="text-content font-mono text-xs">{gpuProbeResult.device_type}</span>
</div>
)}
{gpuProbeResult.error && (
<div className="flex items-start gap-2">
<span className="text-content-secondary w-28 shrink-0">Error</span>
<span className="text-status-error-text text-xs">{gpuProbeResult.error}</span>
</div>
)}
{gpuProbeResult.probed_at && (
<div className="flex items-center gap-2">
<span className="text-content-secondary w-28 shrink-0">Probed at</span>
<span className="text-content-muted text-xs">
{new Date(gpuProbeResult.probed_at).toLocaleString()}
</span>
</div>
)}
</div>
)}
{!gpuProbeResult && !gpuProbing && (
<p className="text-sm text-content-muted">
No probe result yet. Click "Run GPU Check" to trigger a check on the render worker.
</p>
)}
</div>
)}
</div>
)}
<ConfirmModal
open={confirmState.open}
title={confirmState.title}
+5 -5
View File
@@ -19,10 +19,10 @@ const formatDate = (iso: string | null) =>
iso ? new Date(iso).toLocaleDateString('de-DE') : '—'
const STATUS_COLORS: Record<string, string> = {
draft: 'bg-gray-100 text-gray-700',
sent: 'bg-blue-100 text-blue-700',
paid: 'bg-green-100 text-green-700',
cancelled: 'bg-red-100 text-red-700',
draft: 'badge-gray',
sent: 'badge-blue',
paid: 'badge-green',
cancelled: 'badge-red',
}
// ── New Invoice Modal ─────────────────────────────────────────────────────
@@ -197,7 +197,7 @@ export default function BillingPage() {
<select
value={inv.status}
onChange={e => statusMutation.mutate({ id: inv.id, status: e.target.value })}
className={`text-xs px-2 py-0.5 rounded-full font-medium border-0 cursor-pointer focus:outline-none focus:ring-1 focus:ring-accent ${STATUS_COLORS[inv.status] || 'bg-gray-100 text-gray-700'}`}
className={`text-xs px-2 py-0.5 rounded-full font-medium border-0 cursor-pointer focus:outline-none focus:ring-1 focus:ring-accent ${STATUS_COLORS[inv.status] || 'badge-gray'}`}
>
{['draft', 'sent', 'paid', 'cancelled'].map(s => (
<option key={s} value={s}>{s}</option>
+24 -9
View File
@@ -1,15 +1,18 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useNavigate, useLocation } from 'react-router-dom'
import { toast } from 'sonner'
import { Eye, EyeOff } from 'lucide-react'
import api from '../api/client'
import { useAuthStore } from '../store/auth'
export default function LoginPage() {
const navigate = useNavigate()
const location = useLocation()
const setAuth = useAuthStore((s) => s.setAuth)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
@@ -17,7 +20,8 @@ export default function LoginPage() {
try {
const res = await api.post('/auth/login', { email, password })
setAuth(res.data.access_token, res.data.user)
navigate('/')
const returnTo = (location.state as any)?.from || '/'
navigate(returnTo)
} catch (err: any) {
toast.error(err.response?.data?.detail || 'Login failed')
} finally {
@@ -50,13 +54,24 @@ export default function LoginPage() {
</div>
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="input-base w-full"
/>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="input-base w-full pr-10"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-content-muted hover:text-content-secondary transition-colors"
tabIndex={-1}
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<button type="submit" disabled={loading} className="btn-primary w-full justify-center">
{loading ? 'Signing in...' : 'Sign In'}
+1 -1
View File
@@ -560,7 +560,7 @@ function SourceBadge({ source }: { source: string }) {
}
if (source === 'cad_import') {
return (
<span className="inline-flex items-center gap-1 text-xs font-medium bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full">
<span className="badge-purple">
CAD import
</span>
)
+323 -52
View File
@@ -3,9 +3,11 @@ import { useQuery } from '@tanstack/react-query'
import {
Search, Image, Film, Box, Layers, FileCode2,
ChevronLeft, ChevronRight, Download, Loader2,
CheckSquare, Square, X, ZoomIn, Archive,
} from 'lucide-react'
import {
getMediaAssets,
zipDownloadAssets,
} from '../api/media'
import type { MediaAssetItem, MediaAssetType } from '../api/media'
@@ -22,24 +24,27 @@ const formatBytes = (bytes: number | null) => {
}
const TYPE_COLORS: Partial<Record<MediaAssetType, string>> = {
thumbnail: 'bg-gray-100 text-gray-700',
still: 'bg-blue-100 text-blue-700',
turntable: 'bg-purple-100 text-purple-700',
stl_low: 'bg-yellow-100 text-yellow-700',
stl_high: 'bg-orange-100 text-orange-700',
gltf_geometry: 'bg-green-100 text-green-700',
gltf_production: 'bg-emerald-100 text-emerald-700',
blend_production: 'bg-pink-100 text-pink-700',
thumbnail: 'badge-gray',
still: 'badge-blue',
turntable: 'badge-purple',
stl_low: 'badge-yellow',
stl_high: 'badge-orange',
gltf_geometry: 'badge-green',
gltf_production: 'badge-teal',
blend_production: 'badge-purple',
}
const ASSET_TYPES = [
{ value: '', label: 'All types' },
const ASSET_TYPES_MEDIA = [
{ value: '', label: 'All media' },
{ value: 'still', label: 'Still' },
{ value: 'turntable', label: 'Turntable' },
{ value: 'thumbnail', label: 'Thumbnail' },
]
const ASSET_TYPES_TECHNICAL = [
{ value: 'gltf_geometry', label: 'glTF Geometry' },
{ value: 'gltf_production', label: 'glTF Production' },
{ value: 'blend_production', label: 'Blend Production' },
{ value: 'blend_production', label: 'Blend (.blend)' },
{ value: 'stl_low', label: 'STL Low' },
{ value: 'stl_high', label: 'STL High' },
]
@@ -67,67 +72,201 @@ const PAGE_SIZE_OPTIONS = [25, 50, 100]
// ── TypeIcon ──────────────────────────────────────────────────────────────────
function TypeIcon({ type }: { type: MediaAssetType }) {
if (type === 'still' || type === 'thumbnail') return <Image size={32} className="text-content-muted" />
if (type === 'turntable') return <Film size={32} className="text-content-muted" />
if (type === 'stl_low' || type === 'stl_high') return <Box size={32} className="text-content-muted" />
if (type === 'gltf_geometry' || type === 'gltf_production') return <FileCode2 size={32} className="text-content-muted" />
return <Layers size={32} className="text-content-muted" />
function TypeIcon({ type, size = 32 }: { type: MediaAssetType; size?: number }) {
if (type === 'still' || type === 'thumbnail') return <Image size={size} className="text-content-muted" />
if (type === 'turntable') return <Film size={size} className="text-content-muted" />
if (type === 'stl_low' || type === 'stl_high') return <Box size={size} className="text-content-muted" />
if (type === 'gltf_geometry' || type === 'gltf_production') return <FileCode2 size={size} className="text-content-muted" />
return <Layers size={size} className="text-content-muted" />
}
// ── Lightbox ──────────────────────────────────────────────────────────────────
function Lightbox({ asset, onClose }: { asset: MediaAssetItem; onClose: () => void }) {
const isVideo = asset.asset_type === 'turntable'
// No-auth thumbnail endpoint serves image/video directly (no Bearer token needed)
const mediaSrc = `/api/media/${asset.id}/thumbnail`
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div
className="fixed inset-0 z-50 flex flex-col items-center justify-center"
style={{ backgroundColor: 'rgba(0,0,0,0.88)' }}
onClick={onClose}
>
{/* Close button */}
<button
className="absolute top-4 right-4 p-2 rounded-full text-white transition-colors"
style={{ backgroundColor: 'rgba(255,255,255,0.15)' }}
onClick={onClose}
title="Close (Esc)"
>
<X size={20} />
</button>
{/* Media */}
<div
className="max-w-5xl max-h-[80vh] w-full mx-6 flex items-center justify-center"
onClick={e => e.stopPropagation()}
>
{isVideo ? (
<video
src={mediaSrc}
controls
autoPlay
loop
className="max-w-full max-h-[80vh] rounded-lg shadow-2xl"
/>
) : asset.thumbnail_url ? (
<img
src={asset.thumbnail_url}
alt={asset.product_name ?? asset.asset_type}
className="max-w-full max-h-[80vh] object-contain rounded-lg shadow-2xl"
/>
) : (
<div className="flex flex-col items-center gap-4 text-white opacity-60">
<TypeIcon type={asset.asset_type} size={64} />
<p className="text-sm">No preview available</p>
</div>
)}
</div>
{/* Caption */}
<div
className="absolute bottom-0 left-0 right-0 px-6 py-4 text-sm text-white"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={e => e.stopPropagation()}
>
<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>}
<p className="text-xs opacity-70">
{asset.asset_type}
{asset.product_pim_id && ` · ${asset.product_pim_id}`}
{asset.product_baureihe && ` · ${asset.product_baureihe}`}
{formatBytes(asset.file_size_bytes) && ` · ${formatBytes(asset.file_size_bytes)}`}
</p>
</div>
{asset.download_url && (
<a
href={asset.download_url}
download
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md font-medium transition-colors"
style={{ backgroundColor: 'rgba(255,255,255,0.2)' }}
onClick={e => e.stopPropagation()}
>
<Download size={13} />
Download
</a>
)}
</div>
</div>
</div>
)
}
// ── AssetCard ─────────────────────────────────────────────────────────────────
function AssetCard({ asset }: { asset: MediaAssetItem }) {
interface AssetCardProps {
asset: MediaAssetItem
selected: boolean
onToggleSelect: (id: string) => void
onPreview: (asset: MediaAssetItem) => void
}
function AssetCard({ asset, selected, onToggleSelect, onPreview }: AssetCardProps) {
const isImage = asset.asset_type === 'still' || asset.asset_type === 'thumbnail'
const isVideo = asset.asset_type === 'turntable'
const typeBadge = TYPE_COLORS[asset.asset_type] ?? 'bg-gray-100 text-gray-700'
// Images need a resolved thumbnail_url; videos always have the no-auth endpoint available
const isPreviewable = isVideo || (isImage && !!asset.thumbnail_url)
const typeBadge = TYPE_COLORS[asset.asset_type] ?? 'badge-gray'
const sizeStr = formatBytes(asset.file_size_bytes)
const handleDownload = () => {
if (asset.download_url) window.open(asset.download_url, '_blank')
}
return (
<div
className="rounded-lg border border-border-default overflow-hidden flex flex-col"
className={`rounded-lg border overflow-hidden flex flex-col relative group transition-all ${
selected ? 'border-accent ring-2 ring-accent ring-offset-1' : 'border-border-default'
}`}
style={{ backgroundColor: 'var(--color-bg-surface)' }}
>
{/* Select checkbox — top-left, always shown when selected, hover otherwise */}
<button
className={`absolute top-2 left-2 z-10 rounded p-0.5 transition-all ${
selected
? 'text-accent opacity-100'
: 'text-white opacity-0 group-hover:opacity-100'
}`}
style={{ backgroundColor: selected ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.45)' }}
onClick={e => { e.stopPropagation(); onToggleSelect(asset.id) }}
title={selected ? 'Deselect' : 'Select'}
>
{selected ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
{/* Preview area */}
<div
className="w-full h-40 flex items-center justify-center overflow-hidden"
className="w-full h-40 flex items-center justify-center overflow-hidden relative cursor-pointer"
style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}
onClick={() => isPreviewable ? onPreview(asset) : onToggleSelect(asset.id)}
>
{isImage && asset.thumbnail_url ? (
<img
src={asset.thumbnail_url}
alt={asset.asset_type}
className="w-full h-full object-contain p-2"
/>
) : isVideo && asset.thumbnail_url ? (
<img
src={asset.thumbnail_url}
alt={asset.asset_type}
className="w-full h-full object-cover opacity-80"
<div className="w-full h-full p-2 flex items-center justify-center">
<img
src={asset.thumbnail_url}
alt={asset.asset_type}
className="max-w-full max-h-full object-contain"
/>
</div>
) : isVideo ? (
<video
src={`/api/media/${asset.id}/thumbnail`}
preload="metadata"
muted
loop
playsInline
className="w-full h-full object-cover"
onMouseEnter={(e) => { e.currentTarget.play().catch(() => {}) }}
onMouseLeave={(e) => { e.currentTarget.pause(); e.currentTarget.currentTime = 0 }}
/>
) : (
<TypeIcon type={asset.asset_type} />
)}
{/* Preview hover overlay */}
{isPreviewable && (
<div
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
>
{isVideo
? <Film size={24} className="text-white" />
: <ZoomIn size={24} className="text-white" />
}
</div>
)}
</div>
{/* Info */}
<div className="p-3 flex-1 flex flex-col gap-1">
<div className="flex items-center justify-between gap-1">
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${typeBadge}`}>
<span className={typeBadge}>
{asset.asset_type}
</span>
{asset.download_url && (
<button
onClick={handleDownload}
<a
href={asset.download_url}
download
className="p-1 rounded hover:bg-surface-hover text-content-muted hover:text-content transition-colors"
title="Download"
onClick={e => e.stopPropagation()}
>
<Download size={14} />
</button>
</a>
)}
</div>
{asset.product_name && (
@@ -138,6 +277,21 @@ function AssetCard({ asset }: { asset: MediaAssetItem }) {
{asset.product_pim_id && (
<p className="text-xs text-content-muted font-mono truncate">{asset.product_pim_id}</p>
)}
{(asset.product_baureihe || asset.product_lagertyp) && (
<p className="text-xs text-content-muted truncate" title={[asset.product_baureihe, asset.product_lagertyp].filter(Boolean).join(' · ')}>
{[asset.product_baureihe, asset.product_lagertyp].filter(Boolean).join(' · ')}
</p>
)}
{(asset.product_ebene1 || asset.product_ebene2) && (
<p className="text-xs truncate" style={{ color: 'var(--color-content-subtle, #9ca3af)' }} title={[asset.product_ebene1, asset.product_ebene2].filter(Boolean).join(' ')}>
{[asset.product_ebene1, asset.product_ebene2].filter(Boolean).join(' ')}
</p>
)}
{(asset.product_name_cad_modell || asset.product_produkt_baureihe) && (
<p className="text-xs text-content-muted font-mono truncate" title={asset.product_name_cad_modell ?? asset.product_produkt_baureihe ?? ''}>
{asset.product_name_cad_modell ?? asset.product_produkt_baureihe}
</p>
)}
<div className="flex items-center gap-2 mt-auto pt-1 text-xs text-content-muted">
<span>{formatDate(asset.created_at)}</span>
{sizeStr && <span>· {sizeStr}</span>}
@@ -167,21 +321,38 @@ export default function MediaBrowserPage() {
const [assetType, setAssetType] = useState('')
const [categoryKey, setCategoryKey] = useState('')
const [renderStatus, setRenderStatus] = useState('')
const [showTechnical, setShowTechnical] = useState(false)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(50)
// Selection
const [selected, setSelected] = useState<Set<string>>(new Set())
const [zipping, setZipping] = useState(false)
// Lightbox
const [previewAsset, setPreviewAsset] = useState<MediaAssetItem | null>(null)
const q = useDebounce(searchInput, 300)
// Reset to page 1 when any filter changes
useEffect(() => { setPage(1) }, [q, assetType, categoryKey, renderStatus, pageSize])
// Reset to page 1 when any filter changes; clear selection on page/filter change
useEffect(() => { setPage(1); setSelected(new Set()) }, [q, assetType, categoryKey, renderStatus, showTechnical, pageSize])
useEffect(() => { setSelected(new Set()) }, [page])
// When switching off technical view, clear any technical type selection
useEffect(() => {
if (!showTechnical && ASSET_TYPES_TECHNICAL.some(t => t.value === assetType)) {
setAssetType('')
}
}, [showTechnical, assetType])
const { data, isLoading, isFetching } = useQuery({
queryKey: ['media-browser', { q, assetType, categoryKey, renderStatus, page, pageSize }],
queryKey: ['media-browser', { q, assetType, categoryKey, renderStatus, showTechnical, page, pageSize }],
queryFn: () => getMediaAssets({
q: q || undefined,
asset_type: assetType || undefined,
category_key: categoryKey || undefined,
render_status: renderStatus || undefined,
exclude_technical: !showTechnical && !assetType ? true : undefined,
page,
page_size: pageSize,
}),
@@ -192,8 +363,41 @@ export default function MediaBrowserPage() {
const total = data?.total ?? 0
const pages = data?.pages ?? 1
const allSelected = items.length > 0 && items.every(i => selected.has(i.id))
function toggleSelect(id: string) {
setSelected(prev => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
function toggleSelectAll() {
if (allSelected) {
setSelected(new Set())
} else {
setSelected(new Set(items.map(i => i.id)))
}
}
async function handleZipDownload() {
if (selected.size === 0) return
setZipping(true)
try {
await zipDownloadAssets(Array.from(selected))
} finally {
setZipping(false)
}
}
return (
<div className="flex flex-col h-full">
{/* Lightbox */}
{previewAsset && (
<Lightbox asset={previewAsset} onClose={() => setPreviewAsset(null)} />
)}
{/* Sticky filter bar */}
<div
className="sticky top-0 z-20 px-6 py-4 border-b border-border-default"
@@ -213,7 +417,7 @@ export default function MediaBrowserPage() {
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-muted pointer-events-none" />
<input
type="text"
placeholder="Search product name or PIM-ID..."
placeholder="Search name, PIM-ID, Baureihe, Ebene…"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-8 pr-3 py-1.5 text-sm border border-border-default rounded-md focus:outline-none focus:ring-1 focus:ring-accent w-64"
@@ -228,9 +432,26 @@ export default function MediaBrowserPage() {
className="text-sm border border-border-default rounded-md px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-accent"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
>
{ASSET_TYPES.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
{ASSET_TYPES_MEDIA.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
{showTechnical && (
<>
<option disabled></option>
{ASSET_TYPES_TECHNICAL.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</>
)}
</select>
{/* Technical files toggle */}
<label className="flex items-center gap-1.5 text-sm text-content-secondary cursor-pointer select-none">
<input
type="checkbox"
checked={showTechnical}
onChange={e => setShowTechnical(e.target.checked)}
className="rounded"
/>
Technical files
</label>
{/* Category */}
<select
value={categoryKey}
@@ -252,12 +473,26 @@ export default function MediaBrowserPage() {
</select>
</div>
{/* Results count + loading indicator */}
<div className="flex items-center gap-2 mt-2 text-xs text-content-muted">
{isFetching && <Loader2 size={12} className="animate-spin" />}
<span>
{total === 0 ? 'No assets' : `${total.toLocaleString()} asset${total !== 1 ? 's' : ''}`}
</span>
{/* Results count + select-all */}
<div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-3 text-xs text-content-muted">
{isFetching && <Loader2 size={12} className="animate-spin" />}
<span>
{total === 0 ? 'No assets' : `${total.toLocaleString()} asset${total !== 1 ? 's' : ''}`}
</span>
{items.length > 0 && (
<button
onClick={toggleSelectAll}
className="flex items-center gap-1 hover:text-content transition-colors"
>
{allSelected ? <CheckSquare size={13} /> : <Square size={13} />}
{allSelected ? 'Deselect all' : 'Select all on page'}
</button>
)}
</div>
{selected.size > 0 && (
<span className="text-xs text-accent font-medium">{selected.size} selected</span>
)}
</div>
</div>
@@ -279,14 +514,50 @@ export default function MediaBrowserPage() {
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{items.map(asset => (
<AssetCard key={asset.id} asset={asset} />
<AssetCard
key={asset.id}
asset={asset}
selected={selected.has(asset.id)}
onToggleSelect={toggleSelect}
onPreview={setPreviewAsset}
/>
))}
</div>
)}
</div>
{/* Floating selection action bar */}
{selected.size > 0 && (
<div
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 px-5 py-3 rounded-xl shadow-2xl border border-border-default"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
>
<span className="text-sm font-medium text-content">
{selected.size} file{selected.size !== 1 ? 's' : ''} selected
</span>
<div className="w-px h-5 bg-border-default" />
<button
onClick={handleZipDownload}
disabled={zipping}
className="flex items-center gap-1.5 text-sm font-medium text-accent hover:text-accent-hover transition-colors disabled:opacity-50"
>
{zipping
? <><Loader2 size={14} className="animate-spin" /> Preparing</>
: <><Archive size={14} /> Download ZIP</>
}
</button>
<button
onClick={() => setSelected(new Set())}
className="flex items-center gap-1 text-sm text-content-muted hover:text-content transition-colors"
>
<X size={14} />
Clear
</button>
</div>
)}
{/* Pagination footer */}
{(total > 0) && (
{total > 0 && (
<div
className="border-t border-border-default px-6 py-3 flex items-center justify-between gap-4"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
+182 -30
View File
@@ -10,7 +10,9 @@ import { listProducts } from '../api/products'
import { listOutputTypes } from '../api/outputTypes'
import { createOrder } from '../api/orders'
import { estimatePrice } from '../api/pricing'
import { listGlobalRenderPositions } from '../api/renderPositions'
import type { Product, RenderPosition } from '../api/products'
import type { GlobalRenderPosition } from '../api/renderPositions'
import type { OutputType } from '../api/outputTypes'
const formatCurrency = (amount: number) =>
@@ -32,6 +34,8 @@ type WizardStep = 1 | 2 | 3
type OutputSelections = Record<string, Set<string>>
// Maps product_id → Set of position_id
type PositionSelections = Record<string, Set<string>>
// Maps product_id → Set of global_render_position_id
type GlobalPositionSelections = Record<string, Set<string>>
export default function NewProductOrderPage() {
const navigate = useNavigate()
@@ -41,6 +45,7 @@ export default function NewProductOrderPage() {
const [selectedProducts, setSelectedProducts] = useState<Map<string, Product>>(new Map())
const [outputSelections, setOutputSelections] = useState<OutputSelections>({})
const [positionSelections, setPositionSelections] = useState<PositionSelections>({})
const [globalPositionSelections, setGlobalPositionSelections] = useState<GlobalPositionSelections>({})
const [notes, setNotes] = useState('')
const [submitting, setSubmitting] = useState(false)
@@ -62,14 +67,26 @@ export default function NewProductOrderPage() {
enabled: step >= 2,
})
function initPositionsForProduct(product: Product) {
const { data: allGlobalPositions = [] } = useQuery({
queryKey: ['global-render-positions'],
queryFn: listGlobalRenderPositions,
})
function initPositionsForProduct(product: Product, globals: GlobalRenderPosition[] = []) {
// Pre-select all per-product positions (if any)
if ((product.render_positions?.length ?? 0) > 0) {
// Default: all positions selected
setPositionSelections((ps) => ({
...ps,
[product.id]: new Set(product.render_positions!.map((p) => p.id)),
}))
}
// Always pre-select all global positions for every product
if (globals.length > 0) {
setGlobalPositionSelections((gs) => ({
...gs,
[product.id]: new Set(globals.map((g) => g.id)),
}))
}
}
function toggleProduct(product: Product) {
@@ -84,7 +101,7 @@ export default function NewProductOrderPage() {
return next
})
if (willSelect) {
initPositionsForProduct(product)
initPositionsForProduct(product, allGlobalPositions)
}
}
@@ -98,7 +115,7 @@ export default function NewProductOrderPage() {
;(products ?? []).forEach((p) => next.set(p.id, p))
return next
})
toInit.forEach(initPositionsForProduct)
toInit.forEach((p) => initPositionsForProduct(p, allGlobalPositions))
}
function deselectAllFiltered() {
@@ -180,7 +197,7 @@ export default function NewProductOrderPage() {
})
}
// Union of all unique position names across selected products that have positions
// Union of all unique per-product position names across selected products that have per-product positions
const globalPositionNames = useMemo(() => {
const seen = new Set<string>()
const result: string[] = []
@@ -195,6 +212,30 @@ export default function NewProductOrderPage() {
return result
}, [selectedProducts])
// Global positions apply to all selected products
const anyProductUsesGlobalPositions = selectedProducts.size > 0
function toggleGlobalPositionForAll(gpId: string) {
// Count how many selected products have this global position selected
const eligibleCount = selectedProducts.size
let selectedCount = 0
for (const [productId] of selectedProducts) {
if (globalPositionSelections[productId]?.has(gpId)) selectedCount++
}
if (eligibleCount === 0) return
const shouldSelect = selectedCount < eligibleCount
setGlobalPositionSelections((prev) => {
const next = { ...prev }
for (const [productId] of selectedProducts) {
const set = new Set(prev[productId] || [])
if (shouldSelect) set.add(gpId)
else set.delete(gpId)
next[productId] = set
}
return next
})
}
function togglePositionGlobal(positionName: string) {
// Count how many products have this position name and how many have it selected
let compatibleCount = 0
@@ -221,47 +262,61 @@ export default function NewProductOrderPage() {
})
}
// Build flat list of order lines for review (Step 3)
// Each (product, outputType, position?) triple becomes one line.
// Build flat list of order lines for review (Step 3).
// Each (product, outputType, position) triple becomes one line.
// Global positions apply to ALL products; per-product positions are additional.
const orderLines = useMemo(() => {
const lines: Array<{
key: string
product: Product
outputType: OutputType
position: RenderPosition | null
globalPosition: GlobalRenderPosition | null
}> = []
for (const [productId, product] of selectedProducts) {
const selectedOts = outputSelections[productId]
if (!selectedOts) continue
const hasPositions = (product.render_positions?.length ?? 0) > 0
for (const otId of selectedOts) {
const ot = allOutputTypes?.find((o) => o.id === otId)
if (!ot) continue
if (hasPositions) {
const selectedPosIds = positionSelections[productId] || new Set()
if (selectedPosIds.size === 0) {
lines.push({ key: `${productId}-${otId}`, product, outputType: ot, position: null })
} else {
for (const posId of selectedPosIds) {
const pos = product.render_positions!.find((p) => p.id === posId)
if (pos) lines.push({ key: `${productId}-${otId}-${posId}`, product, outputType: ot, position: pos })
}
}
const selectedPosIds = positionSelections[productId] || new Set()
const selectedGlobalIds = globalPositionSelections[productId] || new Set()
const hasAny = selectedPosIds.size > 0 || selectedGlobalIds.size > 0
if (!hasAny) {
// No position selected — one unpositioned line
lines.push({ key: `${productId}-${otId}`, product, outputType: ot, position: null, globalPosition: null })
} else {
lines.push({ key: `${productId}-${otId}`, product, outputType: ot, position: null })
// One line per selected global position
for (const gpId of selectedGlobalIds) {
const gp = allGlobalPositions.find((g) => g.id === gpId)
if (gp) lines.push({ key: `${productId}-${otId}-g${gpId}`, product, outputType: ot, position: null, globalPosition: gp })
}
// One line per selected per-product position
for (const posId of selectedPosIds) {
const pos = product.render_positions?.find((p) => p.id === posId)
if (pos) lines.push({ key: `${productId}-${otId}-${posId}`, product, outputType: ot, position: pos, globalPosition: null })
}
}
}
}
return lines
}, [selectedProducts, outputSelections, positionSelections, allOutputTypes])
}, [selectedProducts, outputSelections, positionSelections, globalPositionSelections, allOutputTypes, allGlobalPositions])
function removeLine(productId: string, outputTypeId: string, positionId: string | null) {
function removeLine(productId: string, outputTypeId: string, positionId: string | null, globalPositionId: string | null) {
if (positionId) {
setPositionSelections((prev) => {
const set = new Set(prev[productId] || [])
set.delete(positionId)
return { ...prev, [productId]: set }
})
} else if (globalPositionId) {
setGlobalPositionSelections((prev) => {
const set = new Set(prev[productId] || [])
set.delete(globalPositionId)
return { ...prev, [productId]: set }
})
} else {
setOutputSelections((prev) => {
const set = new Set(prev[productId] || [])
@@ -322,6 +377,7 @@ export default function NewProductOrderPage() {
product_id: l.product.id,
output_type_id: l.outputType.id,
render_position_id: l.position?.id ?? null,
global_render_position_id: l.globalPosition?.id ?? null,
})),
})
toast.success(`Draft order ${result.order_number} created — review and submit`)
@@ -502,7 +558,7 @@ export default function NewProductOrderPage() {
</p>
{/* Global toggles — apply to all products at once */}
{(globalOutputTypes.length > 0 || globalPositionNames.length > 0) && (
{(globalOutputTypes.length > 0 || globalPositionNames.length > 0 || (anyProductUsesGlobalPositions && allGlobalPositions.length > 0)) && (
<div className="card p-4 mb-4 space-y-3">
<p className="text-xs font-semibold text-content-muted uppercase tracking-wide">
Apply to all products
@@ -555,10 +611,10 @@ export default function NewProductOrderPage() {
</div>
)}
{/* Perspectives row */}
{/* Perspectives row — per-product positions (for products that have them) */}
{globalPositionNames.length > 0 && (
<div className="pt-2 border-t border-border-light">
<p className="text-xs text-content-muted mb-1.5">Perspectives</p>
<p className="text-xs text-content-muted mb-1.5">Perspectives (custom)</p>
<div className="flex flex-wrap gap-2">
{globalPositionNames.map((posName) => {
let compatibleCount = 0
@@ -597,6 +653,47 @@ export default function NewProductOrderPage() {
</div>
</div>
)}
{/* Perspectives row — global positions (for products without custom positions) */}
{anyProductUsesGlobalPositions && allGlobalPositions.length > 0 && (
<div className="pt-2 border-t border-border-light">
<p className="text-xs text-content-muted mb-1.5">Perspectives (global)</p>
<div className="flex flex-wrap gap-2">
{allGlobalPositions.map((gp) => {
const eligibleCount = selectedProducts.size
let selectedCount = 0
for (const [productId] of selectedProducts) {
if (globalPositionSelections[productId]?.has(gp.id)) selectedCount++
}
const allSel = selectedCount === eligibleCount && eligibleCount > 0
const someSel = selectedCount > 0 && !allSel
return (
<button
key={gp.id}
onClick={() => toggleGlobalPositionForAll(gp.id)}
title={`${selectedCount} / ${eligibleCount} product${eligibleCount !== 1 ? 's' : ''} selected`}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm font-medium transition-colors ${
allSel
? 'bg-purple-600 text-white border-purple-600'
: someSel
? 'bg-purple-100 text-purple-700 border-purple-400'
: 'bg-surface text-content-secondary border-border-default hover:border-purple-400 hover:text-purple-600'
}`}
>
{allSel && <Check size={12} />}
{gp.name}
{gp.is_default && !allSel && <span className="text-xs opacity-60"></span>}
{selectedProducts.size > 1 && eligibleCount > 0 && (
<span className={`text-xs ${allSel ? 'text-white/70' : someSel ? 'text-purple-500' : 'text-content-muted'}`}>
{selectedCount}/{eligibleCount}
</span>
)}
</button>
)
})}
</div>
</div>
)}
</div>
)}
@@ -610,6 +707,13 @@ export default function NewProductOrderPage() {
onToggle={(otId) => toggleOutputType(product.id, otId)}
selectedPositions={positionSelections[product.id] || new Set()}
onTogglePosition={(posId) => togglePosition(product.id, posId)}
globalPositions={allGlobalPositions}
selectedGlobalPositions={globalPositionSelections[product.id] || new Set()}
onToggleGlobalPosition={(gpId) => setGlobalPositionSelections((prev) => {
const set = new Set(prev[product.id] || [])
if (set.has(gpId)) set.delete(gpId); else set.add(gpId)
return { ...prev, [product.id]: set }
})}
/>
))}
</div>
@@ -685,9 +789,9 @@ export default function NewProductOrderPage() {
<td className="px-4 py-3 text-content-secondary">{line.outputType.name}</td>
<td className="px-4 py-3">
{line.position ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 font-medium">
{line.position.name}
</span>
<span className="badge-purple">{line.position.name}</span>
) : line.globalPosition ? (
<span className="badge-purple opacity-70" title="Global position">{line.globalPosition.name}</span>
) : (
<span className="text-content-muted text-xs"></span>
)}
@@ -706,7 +810,7 @@ export default function NewProductOrderPage() {
</td>
<td className="px-4 py-3">
<button
onClick={() => removeLine(line.product.id, line.outputType.id, line.position?.id ?? null)}
onClick={() => removeLine(line.product.id, line.outputType.id, line.position?.id ?? null, line.globalPosition?.id ?? null)}
className="text-content-muted hover:text-red-500 transition-colors"
title="Remove this render job from the order"
>
@@ -771,6 +875,9 @@ function ProductOutputRow({
onToggle,
selectedPositions,
onTogglePosition,
globalPositions,
selectedGlobalPositions,
onToggleGlobalPosition,
}: {
product: Product
compatibleTypes: OutputType[]
@@ -778,6 +885,9 @@ function ProductOutputRow({
onToggle: (otId: string) => void
selectedPositions: Set<string>
onTogglePosition: (posId: string) => void
globalPositions: GlobalRenderPosition[]
selectedGlobalPositions: Set<string>
onToggleGlobalPosition: (gpId: string) => void
}) {
const [expanded, setExpanded] = useState(true)
@@ -852,11 +962,11 @@ function ProductOutputRow({
</div>
)}
{/* Render position toggles — only shown if product has positions */}
{/* Per-product custom positions */}
{(product.render_positions?.length ?? 0) > 0 && (
<div className="mt-3 pt-3 border-t border-border-light">
<div className="flex items-center gap-2 mb-2">
<p className="text-xs font-medium text-content-muted">Render Positions</p>
<p className="text-xs font-medium text-content-muted">Custom Positions</p>
<button
className="text-xs text-accent hover:underline"
onClick={() => product.render_positions!.forEach((p) => !selectedPositions.has(p.id) && onTogglePosition(p.id))}
@@ -895,6 +1005,48 @@ function ProductOutputRow({
</div>
</div>
)}
{/* Global position toggles — always shown for all products */}
{globalPositions.length > 0 && (
<div className="mt-3 pt-3 border-t border-border-light">
<div className="flex items-center gap-2 mb-2">
<p className="text-xs font-medium text-content-muted">Perspectives</p>
<button
className="text-xs text-accent hover:underline"
onClick={() => globalPositions.forEach((g) => !selectedGlobalPositions.has(g.id) && onToggleGlobalPosition(g.id))}
>
All
</button>
<span className="text-content-muted text-xs">·</span>
<button
className="text-xs text-content-muted hover:underline"
onClick={() => globalPositions.forEach((g) => selectedGlobalPositions.has(g.id) && onToggleGlobalPosition(g.id))}
>
None
</button>
</div>
<div className="flex flex-wrap gap-2">
{globalPositions.map((gp) => {
const active = selectedGlobalPositions.has(gp.id)
return (
<button
key={gp.id}
onClick={() => onToggleGlobalPosition(gp.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm font-medium transition-colors ${
active
? 'bg-purple-600 text-white border-purple-600'
: 'bg-surface text-content-secondary border-border-default hover:border-purple-400 hover:text-purple-600'
}`}
>
{active && <Check size={12} />}
{gp.name}
{gp.is_default && <span className="text-xs opacity-60 ml-0.5"></span>}
</button>
)
})}
</div>
</div>
)}
</div>
)}
</div>
+20
View File
@@ -0,0 +1,20 @@
import { Link } from 'react-router-dom'
import { Home, FileQuestion } from 'lucide-react'
export default function NotFoundPage() {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center px-6">
<div className="w-20 h-20 rounded-full bg-surface-muted flex items-center justify-center mb-6">
<FileQuestion size={36} className="text-content-muted" />
</div>
<h1 className="text-3xl font-bold text-content mb-2">Page not found</h1>
<p className="text-content-secondary mb-8 max-w-sm">
The page you're looking for doesn't exist or has been moved.
</p>
<Link to="/" className="btn-primary">
<Home size={16} />
Go to Dashboard
</Link>
</div>
)
}
+48 -8
View File
@@ -6,9 +6,9 @@ import {
FileBox, AlertTriangle, CheckCircle2, Image as ImageIcon, Unlink,
RotateCcw, LayoutList, LayoutGrid, X,
ChevronDown, ChevronUp, ChevronsUpDown,
Search, SlidersHorizontal, FileSpreadsheet, Box,
Search, SlidersHorizontal, FileSpreadsheet, Box, Film,
Loader2, Play, RefreshCw, ExternalLink, Ban, StopCircle, Scissors, Plus, Wand2, Download,
XCircle, RotateCw,
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 } from '../api/orders'
@@ -19,6 +19,7 @@ import { useAuthStore, isAdmin as checkIsAdmin, isPrivileged as checkIsPrivilege
import StepDropzone from '../components/upload/StepDropzone'
import CadPartMaterials from '../components/orders/CadPartMaterials'
import LiveRenderLog from '../components/LiveRenderLog'
import RenderInfoModal from '../components/renders/RenderInfoModal'
// ── Filter / sort types ───────────────────────────────────────────────────────
@@ -825,6 +826,8 @@ function OrderLineRow({
onRemoved: () => void
}) {
const qc = useQueryClient()
const [showInfo, setShowInfo] = useState(false)
const removeMut = useMutation({
mutationFn: () => removeOrderLine(orderId, line.id),
onSuccess: onRemoved,
@@ -855,11 +858,28 @@ function OrderLineRow({
{/* Thumbnail */}
<td className="px-4 py-2">
{line.thumbnail_url ? (
<img
src={line.thumbnail_url}
alt={line.product.name || ''}
className="w-10 h-10 object-contain rounded border bg-surface"
/>
(() => {
const isVideo = /\.(mp4|webm|mov)$/i.test(line.thumbnail_url)
return isVideo ? (
<video
src={line.thumbnail_url}
preload="metadata"
muted
loop
playsInline
className="w-10 h-10 object-cover rounded border bg-surface-alt"
title="Hover to preview"
onMouseEnter={(e) => { e.currentTarget.play().catch(() => {}) }}
onMouseLeave={(e) => { e.currentTarget.pause(); e.currentTarget.currentTime = 0 }}
/>
) : (
<img
src={line.thumbnail_url}
alt={line.product.name || ''}
className="w-10 h-10 object-contain rounded border bg-surface"
/>
)
})()
) : (line.render_status === 'processing' || line.render_status === 'pending') ? (
<div className="w-10 h-10 rounded border border-dashed border-border-default bg-surface-alt flex items-center justify-center animate-pulse">
<Loader2 size={16} className="text-accent animate-spin" />
@@ -894,7 +914,7 @@ function OrderLineRow({
<span className="text-xs text-content-muted italic">tracking only</span>
)}
{line.render_position_name && (
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 font-medium w-fit">
<span className="badge-purple w-fit">
{line.render_position_name}
</span>
)}
@@ -957,6 +977,18 @@ function OrderLineRow({
{cancelMut.isPending ? <Loader2 size={12} className="animate-spin" /> : <Ban size={12} />}
</button>
)}
{line.render_log && (
<button
onClick={(e) => {
e.stopPropagation()
setShowInfo(true)
}}
className="text-content-muted hover:text-accent transition-colors"
title="Render info"
>
<Info size={12} />
</button>
)}
</div>
<LiveRenderLog
orderLineId={line.id}
@@ -987,6 +1019,14 @@ function OrderLineRow({
</button>
</td>
)}
<RenderInfoModal
open={showInfo}
onClose={() => setShowInfo(false)}
title={line.output_type?.name ?? 'Render Info'}
renderLog={line.render_log}
renderStartedAt={line.render_started_at}
renderCompletedAt={line.render_completed_at}
/>
</tr>
)
}
+83 -7
View File
@@ -42,7 +42,7 @@ export default function OrdersPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [view, setView] = useState<'kanban' | 'list'>('kanban')
const [view, setView] = useState<'kanban' | 'list'>(() => window.innerWidth < 768 ? 'list' : 'kanban')
const [searchInput, setSearchInput] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [selectedStatuses, setSelectedStatuses] = useState<Set<Status>>(new Set())
@@ -52,6 +52,13 @@ export default function OrdersPage() {
const [selected, setSelected] = useState<Set<string>>(new Set())
const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} })
// Auto-switch to list view on narrow screens
useEffect(() => {
const handler = () => { if (window.innerWidth < 768) setView('list') }
window.addEventListener('resize', handler)
return () => window.removeEventListener('resize', handler)
}, [])
// Debounce the search input (400 ms)
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
@@ -163,10 +170,15 @@ export default function OrdersPage() {
const handleDeleteSelected = () => {
const ids = [...selected]
if (!ids.length) return
const ordersMap = Object.fromEntries(orders.map((o) => [o.id, o]))
const submittedCount = ids.filter((id) => ordersMap[id]?.status === 'submitted').length
const message = submittedCount > 0
? `⚠️ ${submittedCount} of ${ids.length} selected order${ids.length > 1 ? 's' : ''} ${submittedCount === 1 ? 'has' : 'have'} been submitted and may be processing. Delete anyway?`
: `Delete ${ids.length} order${ids.length > 1 ? 's' : ''}? This cannot be undone.`
setConfirmState({
open: true,
title: `Delete ${ids.length} order${ids.length > 1 ? 's' : ''}`,
message: 'This cannot be undone.',
message,
onConfirm: () => {
deleteMut.mutate(ids)
setConfirmState((s) => ({ ...s, open: false }))
@@ -323,10 +335,12 @@ export default function OrdersPage() {
</div>
{/* ── Content ──────────────────────────────────────────────────────── */}
{isLoading ? (
{isLoading && !isSearchMode && orders.length === 0 ? (
view === 'kanban' ? <KanbanSkeleton /> : <ListSkeleton />
) : isLoading && isSearchMode ? (
<div className="flex-1 flex items-center justify-center text-content-muted">
<Loader2 size={24} className="animate-spin mr-2" />
{isSearchMode ? 'Searching…' : 'Loading orders…'}
Searching
</div>
) : isSearchMode ? (
<SearchResultsView
@@ -370,9 +384,10 @@ export default function OrdersPage() {
{/* ── Bulk delete bar ───────────────────────────────────────────────── */}
{selected.size > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 ml-[120px] z-50
flex items-center gap-3 px-5 py-3
bg-gray-900 text-white rounded-2xl shadow-2xl ring-1 ring-white/10">
<div
className="fixed bottom-6 z-50 flex items-center gap-3 px-5 py-3 bg-gray-900 text-white rounded-2xl shadow-2xl ring-1 ring-white/10"
style={{ left: 'calc(240px + (100vw - 240px) / 2)', transform: 'translateX(-50%)' }}
>
<span className="text-sm font-medium">
{selected.size} order{selected.size > 1 ? 's' : ''} selected
</span>
@@ -399,6 +414,67 @@ export default function OrdersPage() {
)
}
// ── Skeleton loaders ──────────────────────────────────────────────────────────
function ListSkeleton() {
return (
<div className="flex-1 overflow-y-auto">
<div className="mx-6 my-4 card overflow-hidden animate-pulse">
<div className="grid grid-cols-[2rem_1fr_6rem_5rem_6rem] bg-surface-alt border-b border-border-default px-4 py-2.5">
<div className="h-3 w-3 bg-surface-muted rounded" />
<div className="h-3 w-24 bg-surface-muted rounded" />
<div className="h-3 w-12 bg-surface-muted rounded" />
<div className="h-3 w-14 bg-surface-muted rounded" />
<div className="h-3 w-16 bg-surface-muted rounded" />
</div>
<div className="divide-y divide-border-light">
{Array.from({ length: 5 }, (_, i) => (
<div key={i} className="grid grid-cols-[2rem_1fr_6rem_5rem_6rem] items-center px-4 py-3 gap-x-4">
<div className="h-3.5 w-3.5 bg-surface-muted rounded" />
<div className="space-y-1.5">
<div className="h-3.5 w-32 bg-surface-muted rounded" />
<div className="h-2.5 w-48 bg-surface-muted rounded opacity-60" />
</div>
<div className="h-3 w-12 bg-surface-muted rounded" />
<div className="h-5 w-16 bg-surface-muted rounded-full" />
<div className="h-3 w-14 bg-surface-muted rounded" />
</div>
))}
</div>
</div>
</div>
)
}
function KanbanSkeleton() {
return (
<div className="flex-1 overflow-x-auto min-h-0">
<div className="flex gap-4 p-6 h-full">
{['bg-gray-400', 'bg-blue-400', 'bg-amber-400'].map((color, ci) => (
<div key={ci} className="flex flex-col w-72 min-w-[272px] animate-pulse">
<div className={`${color} rounded-t-xl px-4 py-3 flex items-center gap-2`}>
<div className="h-4 w-4 bg-white/40 rounded" />
<div className="h-3.5 w-20 bg-white/40 rounded" />
<div className="ml-auto h-5 w-6 bg-white/30 rounded-full" />
</div>
<div className="flex-1 bg-surface-muted rounded-b-xl p-2 space-y-2 min-h-[120px]">
{Array.from({ length: 2 }, (_, i) => (
<div key={i} className="bg-surface rounded-lg p-3 border border-border-default border-l-4 border-l-border-default">
<div className="h-3.5 w-24 bg-surface-muted rounded mb-2" />
<div className="flex gap-3">
<div className="h-3 w-16 bg-surface-muted rounded" />
<div className="h-3 w-20 bg-surface-muted rounded" />
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
)
}
// ── Search results view ───────────────────────────────────────────────────────
function SearchResultsView({
+95 -35
View File
@@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useDropzone } from 'react-dropzone'
import {
ArrowLeft, Pencil, Save, X, Box, Image,
RotateCcw, RefreshCw, Upload, ChevronDown, ChevronRight, Wand2, Download, Plus, Trash2, Filter, Cuboid, Ruler, Loader2,
RotateCcw, RefreshCw, Upload, ChevronDown, ChevronRight, Wand2, Download, Plus, Trash2, Filter, Cuboid, Ruler, Loader2, Info, Play,
} from 'lucide-react'
import { toast } from 'sonner'
import {
@@ -15,12 +15,15 @@ import {
} from '../api/products'
import type { Product, CadPartMaterial, ProductRender, RenderPosition } from '../api/products'
import { listMaterials } from '../api/materials'
import { listGlobalRenderPositions } from '../api/renderPositions'
import MaterialInput from '../components/shared/MaterialInput'
import MaterialWizard from '../components/MaterialWizard'
import { useAuthStore, isAdmin as checkIsAdmin, isPrivileged as checkIsPrivileged } from '../store/auth'
import { generateGltfGeometry, generateGltfProduction, resetStuckProcessing } from '../api/cad'
import { listMediaAssets as getMediaAssets } from '../api/media'
import InlineCadViewer from '../components/cad/InlineCadViewer'
import { convertCadPartMaterials } from '../components/cad/cadUtils'
import RenderInfoModal from '../components/renders/RenderInfoModal'
function GlbDownloadButton({
label, url, filename, onGenerate, isGenerating, title,
@@ -57,6 +60,16 @@ function GlbDownloadButton({
}
}
// Always show generating state first — hides stale download button while task runs
if (isGenerating) {
return (
<div className="flex items-center gap-1.5 text-xs text-content-secondary px-2 py-1.5 rounded border border-border-light" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<Loader2 size={12} className="animate-spin shrink-0 text-accent" />
<span>Generating {label}</span>
</div>
)
}
if (url) {
return (
<div className="flex gap-1 w-full">
@@ -73,12 +86,9 @@ function GlbDownloadButton({
<button
className="btn-secondary text-xs px-2 shrink-0"
onClick={onGenerate}
disabled={isGenerating}
title={`Re-generate ${label}`}
>
{isGenerating
? <Loader2 size={12} className="animate-spin" />
: <RotateCcw size={12} />}
<RotateCcw size={12} />
</button>
</div>
)
@@ -88,12 +98,9 @@ function GlbDownloadButton({
<button
className="btn-secondary text-xs w-full justify-start"
onClick={onGenerate}
disabled={isGenerating}
title={title}
>
{isGenerating
? <><Loader2 size={12} className="animate-spin" />Queuing</>
: <><Download size={12} />Generate {label}</>}
<Download size={12} />Generate {label}
</button>
)
}
@@ -139,6 +146,7 @@ export default function ProductDetailPage() {
const [materialsDirty, setMaterialsDirty] = useState(false)
const [wizardOpen, setWizardOpen] = useState(false)
const [wizardTargetIdx, setWizardTargetIdx] = useState<number | null>(null)
const [showCadInfo, setShowCadInfo] = useState(false)
const { data: product, isLoading } = useQuery({
queryKey: ['product', id],
@@ -178,13 +186,23 @@ export default function ProductDetailPage() {
staleTime: 0,
})
const [productionGlbGenerating, setProductionGlbGenerating] = useState(false)
const { data: productionGlbAssets = [] } = useQuery({
queryKey: ['media-assets', cadFileId, 'gltf_production'],
queryFn: () => getMediaAssets({ cad_file_id: cadFileId!, asset_types: ['gltf_production'] }),
enabled: !!cadFileId,
staleTime: 0,
refetchInterval: productionGlbGenerating ? 3000 : false,
})
// Stop polling once the freshly-generated asset has arrived
useEffect(() => {
if (productionGlbGenerating && productionGlbAssets.length > 0) {
setProductionGlbGenerating(false)
}
}, [productionGlbAssets, productionGlbGenerating])
const geometryGlbUrl = geometryGlbAssets[0]?.download_url ?? null
const productionGlbUrl = productionGlbAssets[0]?.download_url ?? null
@@ -343,7 +361,9 @@ export default function ProductDetailPage() {
mutationFn: () => generateGltfProduction(product!.cad_file_id!),
onSuccess: () => {
toast.info('Production GLB export queued')
qc.invalidateQueries({ queryKey: ['media-assets', cadFileId, 'gltf_production'] })
setProductionGlbGenerating(true)
// Remove stale asset immediately so the button doesn't show an outdated download
qc.removeQueries({ queryKey: ['media-assets', cadFileId, 'gltf_production'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to queue production GLB export'),
})
@@ -415,11 +435,10 @@ export default function ProductDetailPage() {
const [editPositionDraft, setEditPositionDraft] = useState<Partial<RenderPosition>>({})
const POSITION_PRESETS = [
{ label: 'Beauty', rx: 0, ry: 0, rz: 0 },
{ label: '3/4 Front', rx: -15, ry: 45, rz: 0 },
{ label: '3/4 Back', rx: -15, ry: -135, rz: 0 },
]
const { data: globalPositions = [] } = useQuery({
queryKey: ['global-render-positions'],
queryFn: listGlobalRenderPositions,
})
const onDrop = useCallback(
(files: File[]) => { if (files[0]) cadUploadMut.mutate(files[0]) },
@@ -647,11 +666,22 @@ export default function ProductDetailPage() {
{/* Two-column: viewer left, actions right */}
<div className="flex gap-4 items-start">
{/* Left: Inline 3D Viewer */}
<div className="flex-1 min-w-0">
<div className="flex-1 min-w-0 relative group">
<InlineCadViewer
cadFileId={product.cad_file_id}
thumbnailUrl={product.render_image_url || product.thumbnail_url}
initialPartMaterials={convertCadPartMaterials(product.cad_part_materials ?? [])}
/>
{product.cad_render_log && (
<button
onClick={() => setShowCadInfo(true)}
className="absolute bottom-1 right-1 p-1 rounded text-white transition-colors"
style={{ backgroundColor: 'rgba(0,0,0,0.4)' }}
title="Render info"
>
<Info size={12} />
</button>
)}
</div>
{/* Right: Action buttons */}
@@ -720,7 +750,7 @@ export default function ProductDetailPage() {
url={productionGlbUrl}
filename={`${product.name ?? product.pim_id}_production.glb`}
onGenerate={() => generateProductionGlbMut.mutate()}
isGenerating={generateProductionGlbMut.isPending}
isGenerating={generateProductionGlbMut.isPending || productionGlbGenerating}
title="Export production GLB with PBR materials via Blender"
/>
</div>
@@ -770,7 +800,7 @@ export default function ProductDetailPage() {
<div className="space-y-1.5">
{materialRows.map((row, i) => (
<div key={i} className="flex items-center gap-2">
<span className="text-xs text-content-secondary w-40 truncate shrink-0" title={row.part_name}>
<span className="text-xs text-content-secondary w-64 truncate shrink-0" title={row.part_name}>
{row.part_name}
</span>
<div className="flex-1">
@@ -994,13 +1024,24 @@ export default function ProductDetailPage() {
>
{/* ── Media area ───────────────────────────────── */}
{r.is_video ? (
<div className="relative">
<div className="relative group/video">
<video
src={r.render_url}
controls
preload="metadata"
muted
loop
playsInline
className="w-full aspect-video object-contain bg-black"
onMouseEnter={(e) => { e.currentTarget.play().catch(() => {}) }}
onMouseLeave={(e) => { e.currentTarget.pause(); e.currentTarget.currentTime = 0 }}
onClick={(e) => selectMode && e.preventDefault()}
/>
{/* Play hint — visible until first hover */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none group-hover/video:opacity-0 transition-opacity">
<div className="w-10 h-10 rounded-full bg-black/40 flex items-center justify-center">
<Play size={18} className="text-white ml-0.5" />
</div>
</div>
{/* Select mode checkbox overlay for videos */}
{selectMode && (
<div className="absolute top-2 left-2 pointer-events-none">
@@ -1093,6 +1134,11 @@ export default function ProductDetailPage() {
{r.output_type_name}
</span>
)}
{r.render_position_name && (
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 font-medium">
{r.render_position_name}
</span>
)}
{r.render_backend && (
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
r.render_backend === 'flamenco' ? 'bg-status-warning-bg text-status-warning-text' : 'bg-status-info-bg text-status-info-text'
@@ -1179,29 +1225,36 @@ export default function ProductDetailPage() {
</button>
{showPositions && (
<div className="px-4 pb-4 space-y-3">
{/* Preset buttons */}
<div className="flex items-center gap-2 pt-1">
<span className="text-xs text-content-muted">Presets:</span>
{POSITION_PRESETS.map((preset) => (
<button
key={preset.label}
className="text-xs px-2 py-1 rounded border border-border-default text-content-secondary hover:border-accent hover:text-accent transition-colors"
onClick={() => setPositionForm({ name: preset.label, rotation_x: preset.rx, rotation_y: preset.ry, rotation_z: preset.rz })}
>
{preset.label}
</button>
))}
{/* Global positions (read-only reference) */}
{globalPositions.length > 0 && (
<div className="pt-1">
<p className="text-xs text-content-muted mb-1">Global positions (shared across all products):</p>
<div className="flex flex-wrap gap-1.5">
{globalPositions.map((gp) => (
<span
key={gp.id}
className="text-xs px-2 py-0.5 rounded-full border border-border-default text-content-muted"
title={`X: ${gp.rotation_x}° · Y: ${gp.rotation_y}° · Z: ${gp.rotation_z}°`}
>
{gp.name}
{gp.is_default && <span className="ml-1 text-accent"></span>}
</span>
))}
</div>
</div>
)}
<div className="flex justify-end pt-1">
<button
className="ml-auto btn-secondary text-xs"
className="btn-secondary text-xs"
onClick={() => setPositionForm({ name: '', rotation_x: 0, rotation_y: 0, rotation_z: 0 })}
>
<Plus size={12} /> Add Position
<Plus size={12} /> Add custom position
</button>
</div>
{/* Existing positions list */}
{(product.render_positions?.length ?? 0) === 0 && !positionForm && (
<p className="text-xs text-content-muted py-2">No positions defined. Click "Add Position" or a preset above.</p>
<p className="text-xs text-content-muted py-2">No custom positions defined. Global positions apply automatically.</p>
)}
{(product.render_positions ?? []).map((pos) => (
<div key={pos.id} className="border border-border-default rounded-lg p-3">
@@ -1398,6 +1451,13 @@ export default function ProductDetailPage() {
setWizardTargetIdx(null)
}}
/>
<RenderInfoModal
open={showCadInfo}
onClose={() => setShowCadInfo(false)}
title="CAD Thumbnail"
renderLog={product.cad_render_log}
/>
</div>
)
}
+71 -13
View File
@@ -1,9 +1,9 @@
import { useState } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import {
Library, Search, Box, CheckCircle2, Clock, AlertTriangle,
LayoutGrid, List, Trash2, X,
LayoutGrid, List, Trash2, X, ArrowUpDown,
} from 'lucide-react'
import { toast } from 'sonner'
import { listProducts, deleteProduct } from '../api/products'
@@ -77,6 +77,7 @@ function ProductCard({ product, onClick, selected, onSelect }: {
src={product.render_image_url || product.thumbnail_url!}
alt={product.name || product.pim_id}
className="h-full w-full object-contain"
loading="lazy"
/>
) : (
<Box size={48} className="text-content-muted" />
@@ -114,18 +115,26 @@ function ProductCard({ product, onClick, selected, onSelect }: {
export default function ProductLibraryPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [search, setSearch] = useState('')
const [searchInput, setSearchInput] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
const [hasCadFilter, setHasCadFilter] = useState<string>('')
const [materialsFilter, setMaterialsFilter] = useState('')
const [sortBy, setSortBy] = useState<'pim_id' | 'name' | 'status'>('pim_id')
const [view, setView] = useState<'grid' | 'table'>('grid')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} })
const { data: products, isLoading } = useQuery({
queryKey: ['products', { search, categoryFilter, hasCadFilter, materialsFilter }],
// Debounce search input (300ms)
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 300)
return () => clearTimeout(t)
}, [searchInput])
const { data: rawProducts, isLoading } = useQuery({
queryKey: ['products', { debouncedSearch, categoryFilter, hasCadFilter, materialsFilter }],
queryFn: () => listProducts({
q: search || undefined,
q: debouncedSearch || undefined,
category_key: categoryFilter || undefined,
has_cad: hasCadFilter === 'yes' ? true : hasCadFilter === 'no' ? false : undefined,
materials_filter: materialsFilter || undefined,
@@ -133,6 +142,16 @@ export default function ProductLibraryPage() {
}),
})
// Client-side sort
const products = useMemo(() => {
if (!rawProducts) return rawProducts
return [...rawProducts].sort((a, b) => {
if (sortBy === 'name') return (a.name || '').localeCompare(b.name || '')
if (sortBy === 'status') return (a.processing_status || '').localeCompare(b.processing_status || '')
return (a.pim_id || '').localeCompare(b.pim_id || '')
})
}, [rawProducts, sortBy])
// ── Selection helpers ──────────────────────────────────────────────────
const toggleOne = (id: string) => {
setSelected((prev) => {
@@ -186,7 +205,7 @@ export default function ProductLibraryPage() {
<div>
<h1 className="text-2xl font-bold text-content">Product Library</h1>
<p className="text-sm text-content-muted">
{products ? `${products.length} products` : 'Loading\u2026'}
{products ? `${products.length} products` : isLoading ? 'Loading…' : '0 products'}
</p>
</div>
</div>
@@ -226,11 +245,19 @@ export default function ProductLibraryPage() {
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted" />
<input
type="text"
placeholder="Search by PIM-ID or name\u2026"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-3 py-2 border border-border-default rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-accent"
placeholder="Search by PIM-ID or name"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="w-full pl-9 pr-8 py-2 border border-border-default rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
{searchInput && (
<button
onClick={() => setSearchInput('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-content-muted hover:text-content transition-colors"
>
<X size={13} />
</button>
)}
</div>
<select
@@ -266,11 +293,38 @@ export default function ProductLibraryPage() {
<option value="complete"> All materials assigned</option>
<option value="incomplete"> Incomplete materials</option>
</select>
<div className="flex items-center gap-1.5 ml-auto text-sm text-content-secondary shrink-0">
<ArrowUpDown size={13} className="text-content-muted" />
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
className="border-0 bg-transparent text-sm text-content-secondary focus:outline-none cursor-pointer pr-1"
>
<option value="pim_id">Sort: PIM ID</option>
<option value="name">Sort: Name</option>
<option value="status">Sort: Status</option>
</select>
</div>
</div>
{/* Content */}
{isLoading ? (
<div className="text-center py-16 text-content-muted">Loading products\u2026</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 animate-pulse">
{Array.from({ length: 12 }, (_, i) => (
<div key={i} className="card overflow-hidden">
<div className="h-40 bg-surface-muted" />
<div className="p-3 space-y-2">
<div className="h-4 w-16 bg-surface-muted rounded" />
<div className="h-4 w-32 bg-surface-muted rounded" />
<div className="flex gap-2">
<div className="h-4 w-14 bg-surface-muted rounded-full" />
<div className="h-4 w-20 bg-surface-muted rounded" />
</div>
</div>
</div>
))}
</div>
) : !products?.length ? (
<div className="text-center py-16 text-content-muted">
<Library size={48} className="mx-auto mb-3 opacity-30" />
@@ -337,6 +391,7 @@ export default function ProductLibraryPage() {
src={product.render_image_url || product.thumbnail_url!}
alt=""
className="w-full h-full object-contain"
loading="lazy"
/>
) : (
<Box size={18} className="text-content-muted" />
@@ -379,7 +434,10 @@ export default function ProductLibraryPage() {
{/* ── Floating action bar ───────────────────────────────────────── */}
{selected.size > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 ml-[120px] bg-gray-900 text-white rounded-lg shadow-xl px-5 py-3 flex items-center gap-4 z-50">
<div
className="fixed bottom-6 z-50 flex items-center gap-4 px-5 py-3 bg-gray-900 text-white rounded-2xl shadow-2xl ring-1 ring-white/10"
style={{ left: 'calc(240px + (100vw - 240px) / 2)', transform: 'translateX(-50%)' }}
>
<span className="text-sm font-medium">
{selected.size} selected
</span>
+63 -56
View File
@@ -13,6 +13,8 @@ import { listOutputTypes } from '../api/outputTypes'
import type { OutputType } from '../api/outputTypes'
import api from '../api/client'
import StepDropzone from '../components/upload/StepDropzone'
import StepIndicator from '../components/shared/StepIndicator'
import HelpTooltip from '../components/HelpTooltip'
function StatCard({ icon, value, label, description, color }: {
icon: React.ReactNode
@@ -238,6 +240,8 @@ export default function UploadPage() {
</p>
</div>
<StepIndicator step={step} total={4} labels={['Upload', 'Review', 'Configure', 'STEP Files']} />
{/* ── Step 1: Excel drop zone ─────────────────────────────────────── */}
{step === 1 && (
<div
@@ -379,9 +383,12 @@ export default function UploadPage() {
</th>
<th className="px-4 py-2 font-medium text-content-secondary">PIM-ID</th>
<th className="px-4 py-2 font-medium text-content-secondary">Series</th>
<th className="px-4 py-2 font-medium text-content-secondary"
title="Gew\u00e4hltes Produkt \u2014 the specific material/coating variant from the Excel"
>Gew. Produkt</th>
<th className="px-4 py-2 font-medium text-content-secondary">
<span className="flex items-center gap-1">
Gew. Produkt
<HelpTooltip help={{ title: 'Gew. Produkt', body: 'Gewähltes Produkt the specific product variant selected in the Excel file.' }} />
</span>
</th>
<th className="px-4 py-2 font-medium text-content-secondary">Category</th>
<th className="px-4 py-2 font-medium text-content-secondary">Status</th>
<th className="px-4 py-2 font-medium text-content-secondary text-center" title="Whether a STEP/CAD file is already linked to this product">STEP</th>
@@ -477,59 +484,6 @@ export default function UploadPage() {
</div>
)}
{/* ── Step 4: Upload STEP Files ────────────────────────────────────── */}
{step === 4 && createdOrder && (
<div className="space-y-4">
<div className="card p-4">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<FileBox size={18} className="text-content-secondary" />
<h2 className="font-semibold text-content">
Upload STEP Files — {createdOrder.order_number}
</h2>
</div>
</div>
<p className="text-sm text-content-secondary">
Drop one or more <strong>.stp / .step</strong> files below.
Each file is matched to an order item by filename stem (case-insensitive).
You can also skip this and upload STEP files later from the order detail page.
</p>
</div>
<div className="card p-6">
<StepDropzone
orderId={createdOrder.id}
onMatchComplete={() => qc.invalidateQueries({ queryKey: ['order', createdOrder.id] })}
/>
</div>
<div className="flex justify-end gap-3">
<button
className="btn-secondary"
onClick={() => navigate(`/orders/${createdOrder.id}`)}
>
Skip &mdash; Go to Order
</button>
<button
className="btn-primary"
onClick={() => navigate(`/orders/${createdOrder.id}`)}
>
<ArrowRight size={16} />
Done &mdash; Go to Order
</button>
</div>
</div>
)}
{/* ── Validation Dialog ────────────────────────────────────────────── */}
{showValidationDialog && (
<ValidationDialog
validation={validationData}
onClose={() => setShowValidationDialog(false)}
onSaveAlias={(alias, suggestion) => saveAlias.mutate({ alias, materialName: suggestion })}
/>
)}
{/* ── Step 3: Output Type Selection ───────────────────────────────── */}
{step === 3 && previewResult && (
<div className="space-y-4">
@@ -684,6 +638,59 @@ export default function UploadPage() {
</div>
</div>
)}
{/* ── Validation Dialog ────────────────────────────────────────────── */}
{showValidationDialog && (
<ValidationDialog
validation={validationData}
onClose={() => setShowValidationDialog(false)}
onSaveAlias={(alias, suggestion) => saveAlias.mutate({ alias, materialName: suggestion })}
/>
)}
{/* ── Step 4: Upload STEP Files ────────────────────────────────────── */}
{step === 4 && createdOrder && (
<div className="space-y-4">
<div className="card p-4">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<FileBox size={18} className="text-content-secondary" />
<h2 className="font-semibold text-content">
Upload STEP Files — {createdOrder.order_number}
</h2>
</div>
</div>
<p className="text-sm text-content-secondary">
Drop one or more <strong>.stp / .step</strong> files below.
Each file is matched to an order item by filename stem (case-insensitive).
You can also skip this and upload STEP files later from the order detail page.
</p>
</div>
<div className="card p-6">
<StepDropzone
orderId={createdOrder.id}
onMatchComplete={() => qc.invalidateQueries({ queryKey: ['order', createdOrder.id] })}
/>
</div>
<div className="flex justify-end gap-3">
<button
className="btn-secondary"
onClick={() => navigate(`/orders/${createdOrder.id}`)}
>
Skip &mdash; Go to Order
</button>
<button
className="btn-primary"
onClick={() => navigate(`/orders/${createdOrder.id}`)}
>
<ArrowRight size={16} />
Done &mdash; Go to Order
</button>
</div>
</div>
)}
</div>
)
}
+42 -9
View File
@@ -4,7 +4,7 @@ import { toast } from 'sonner'
import {
Activity, CheckCircle2, XCircle, Loader2, Clock, RefreshCw,
ChevronDown, ChevronRight, RotateCcw, Terminal, Cpu, Image,
Trash2, Ban, ListOrdered, FileCode2,
Trash2, Ban, ListOrdered, FileCode2, ChevronUp,
} from 'lucide-react'
import { Link } from 'react-router-dom'
import {
@@ -12,6 +12,7 @@ import {
getQueueStatus, purgeQueue, cancelTask, QueueTask,
} from '../api/worker'
import LiveRenderLog from '../components/LiveRenderLog'
import ConfirmModal from '../components/ConfirmModal'
type UnifiedEvent =
| { kind: 'cad'; ts: number; entry: CadActivityEntry }
@@ -20,6 +21,7 @@ type UnifiedEvent =
export default function WorkerActivityPage() {
const qc = useQueryClient()
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [logExpanded, setLogExpanded] = useState<Set<string>>(new Set())
const { data, isLoading, dataUpdatedAt } = useQuery({
queryKey: ['worker-activity'],
@@ -94,9 +96,18 @@ export default function WorkerActivityPage() {
</div>
)}
{isLoading && (
<div className="flex items-center gap-2 text-content-muted py-12 justify-center">
<Loader2 size={18} className="animate-spin" /> Loading activity
{isLoading && events.length === 0 && (
<div className="card overflow-hidden divide-y divide-border-light">
{[0,1,2,3].map((i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3">
<div className="w-5 h-5 rounded-full bg-surface-muted animate-pulse shrink-0" />
<div className="flex-1 space-y-1.5">
<div className="h-3 bg-surface-muted animate-pulse rounded w-2/3" />
<div className="h-2.5 bg-surface-muted animate-pulse rounded w-1/3" />
</div>
<div className="w-16 h-3 bg-surface-muted animate-pulse rounded" />
</div>
))}
</div>
)}
@@ -127,6 +138,7 @@ export default function WorkerActivityPage() {
)}
</div>
)}
</div>
)
}
@@ -232,6 +244,7 @@ function firstArg(task: QueueTask): string {
function QueuePanel() {
const qc = useQueryClient()
const [purgeConfirmOpen, setPurgeConfirmOpen] = useState(false)
const { data: queue, isLoading } = useQuery({
queryKey: ['worker-queue'],
@@ -285,9 +298,7 @@ function QueuePanel() {
{totalPending > 0 && (
<button
onClick={() => {
if (confirm(`Purge all ${totalPending} pending task(s) from the queue?`)) {
purgeMut.mutate()
}
setPurgeConfirmOpen(true)
}}
disabled={purgeMut.isPending}
className="flex items-center gap-1 px-2.5 py-1 rounded border border-red-200 text-red-600 text-xs font-medium hover:bg-red-50 transition-colors"
@@ -395,6 +406,14 @@ function QueuePanel() {
</div>
)}
</div>
<ConfirmModal
open={purgeConfirmOpen}
title="Purge Queue"
message={`Purge all ${totalPending} pending task${totalPending !== 1 ? 's' : ''} from the queue? This cannot be undone.`}
onConfirm={() => { purgeMut.mutate(); setPurgeConfirmOpen(false) }}
onCancel={() => setPurgeConfirmOpen(false)}
/>
</div>
)
}
@@ -577,8 +596,12 @@ function KV({ label, value, mono, highlight }: {
}
function BlenderLog({ lines }: { lines: string[] }) {
const [expanded, setExpanded] = useState(false)
return (
<div className="bg-gray-900 rounded-md overflow-auto max-h-64">
<div>
<div
className={`bg-gray-900 rounded-md overflow-auto transition-all ${expanded ? 'max-h-[600px]' : 'max-h-64'}`}
>
<pre className="text-xs text-gray-200 p-3 leading-5 whitespace-pre-wrap">
{lines.map((l, i) => {
const color =
@@ -592,6 +615,16 @@ function BlenderLog({ lines }: { lines: string[] }) {
)
})}
</pre>
</div>
{lines.length > 20 && (
<button
onClick={() => setExpanded((v) => !v)}
className="mt-1 flex items-center gap-1 text-xs text-content-muted hover:text-content-secondary transition-colors"
>
{expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
{expanded ? 'Collapse log' : `Expand log (${lines.length} lines)`}
</button>
)}
</div>
)
}
@@ -610,7 +643,7 @@ function RendererBadge({ log }: { log: RenderLog }) {
}
if (log.renderer === 'threejs') {
return (
<span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded font-medium">
<span className="badge-purple text-xs px-1.5 py-0.5 rounded font-medium">
Three.js
</span>
)