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:
@@ -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>
|
||||
|
||||
@@ -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 ?? {}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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 & 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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)' }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 — Go to Order
|
||||
</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => navigate(`/orders/${createdOrder.id}`)}
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
Done — 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 — Go to Order
|
||||
</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => navigate(`/orders/${createdOrder.id}`)}
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
Done — Go to Order
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user