feat: layout hamburger, media browser filters+previews, billing fixes
- Layout: mobile hamburger menu + overlay backdrop + close button; content area always full-width - Media browser: filter chips (default still+turntable); advanced toggle for GLB/STL; thumbnail_url previews for non-image types; video hover-play for turntable - Backend: asset_types multi-filter, thumbnail_url in MediaAssetOut, download proxy endpoint for MinIO/local files - Admin: "Import Existing Media" button → POST /api/admin/import-media-assets - Billing: fix invoice create 500 (MissingGreenlet — use selectinload after commit); PDF download uses axios blob instead of bare <a href> (auth header missing); fix storage.upload() accepting str|Path - SSE task logs: task_logs.py core + router, LiveRenderLog component - CadPreview: fix infinite loop when no gltf_geometry assets; loading screen before ThreeDViewer render - render-worker: add trimesh layer to Dockerfile Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -60,6 +60,12 @@ export async function deleteInvoice(id: string): Promise<void> {
|
||||
await api.delete(`/billing/invoices/${id}`)
|
||||
}
|
||||
|
||||
export function getInvoicePdfUrl(id: string): string {
|
||||
return `/api/billing/invoices/${id}/pdf`
|
||||
export async function downloadInvoicePdf(id: string): Promise<void> {
|
||||
const res = await api.get(`/billing/invoices/${id}/pdf`, { responseType: 'blob' })
|
||||
const url = URL.createObjectURL(res.data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `invoice-${id}.pdf`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
@@ -28,13 +28,14 @@ export interface MediaAsset {
|
||||
is_archived: boolean
|
||||
created_at: string
|
||||
download_url: string | null
|
||||
thumbnail_url: string | null
|
||||
}
|
||||
|
||||
export interface MediaFilter {
|
||||
product_id?: string
|
||||
order_line_id?: string
|
||||
cad_file_id?: string
|
||||
asset_type?: MediaAssetType
|
||||
asset_types?: MediaAssetType[]
|
||||
skip?: number
|
||||
limit?: number
|
||||
}
|
||||
@@ -44,10 +45,10 @@ export const getMediaAssets = (filters: MediaFilter = {}): Promise<MediaAsset[]>
|
||||
if (filters.product_id) params.set('product_id', filters.product_id)
|
||||
if (filters.order_line_id) params.set('order_line_id', filters.order_line_id)
|
||||
if (filters.cad_file_id) params.set('cad_file_id', filters.cad_file_id)
|
||||
if (filters.asset_type) params.set('asset_type', filters.asset_type)
|
||||
if (filters.asset_types?.length) filters.asset_types.forEach(t => params.append('asset_types', t))
|
||||
if (filters.skip !== undefined) params.set('skip', String(filters.skip))
|
||||
if (filters.limit !== undefined) params.set('limit', String(filters.limit))
|
||||
return api.get(`/media?${params}`).then(r => r.data)
|
||||
return api.get(`/media/?${params}`).then(r => r.data)
|
||||
}
|
||||
|
||||
export const getMediaAsset = (id: string): Promise<MediaAsset> =>
|
||||
|
||||
@@ -218,8 +218,6 @@ export default function ThreeDViewer({
|
||||
productionGltfUrl,
|
||||
downloadUrls,
|
||||
}: ThreeDViewerProps) {
|
||||
const defaultUrl = `/api/cad/${cadFileId}/model`
|
||||
|
||||
const [mode, setMode] = useState<ViewMode>('geometry')
|
||||
const [wireframe, setWireframe] = useState(false)
|
||||
const [envPreset, setEnvPreset] = useState<EnvPreset>('city')
|
||||
@@ -231,7 +229,7 @@ export default function ThreeDViewer({
|
||||
const activeUrl =
|
||||
mode === 'production' && productionGltfUrl
|
||||
? productionGltfUrl
|
||||
: geometryGltfUrl || defaultUrl
|
||||
: geometryGltfUrl
|
||||
|
||||
const handleModelReady = useCallback(() => setModelReady(true), [])
|
||||
const handleError = useCallback((msg: string) => setLoadError(msg), [])
|
||||
@@ -372,16 +370,18 @@ export default function ThreeDViewer({
|
||||
<directionalLight position={[5, 10, 7]} intensity={1.0} castShadow />
|
||||
<directionalLight position={[-5, -5, -5]} intensity={0.25} />
|
||||
|
||||
<GltfErrorBoundary onError={handleError}>
|
||||
<Suspense fallback={null}>
|
||||
<ModelWithReady
|
||||
key={activeUrl}
|
||||
url={activeUrl}
|
||||
wireframe={wireframe}
|
||||
onReady={handleModelReady}
|
||||
/>
|
||||
</Suspense>
|
||||
</GltfErrorBoundary>
|
||||
{activeUrl && (
|
||||
<GltfErrorBoundary onError={handleError}>
|
||||
<Suspense fallback={null}>
|
||||
<ModelWithReady
|
||||
key={activeUrl}
|
||||
url={activeUrl}
|
||||
wireframe={wireframe}
|
||||
onReady={handleModelReady}
|
||||
/>
|
||||
</Suspense>
|
||||
</GltfErrorBoundary>
|
||||
)}
|
||||
|
||||
<OrbitControls enablePan enableZoom enableRotate minDistance={0.3} maxDistance={100} />
|
||||
<Environment preset={envPreset} />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Outlet, NavLink, useNavigate, Link } from 'react-router-dom'
|
||||
import { LayoutDashboard, Package, Settings, LogOut, FlaskConical, Activity, Library, Plus, SlidersHorizontal, Building2, GitBranch, Image, BellRing, Receipt, Server, Upload } from 'lucide-react'
|
||||
import { LayoutDashboard, Package, Settings, LogOut, FlaskConical, Activity, Library, Plus, SlidersHorizontal, Building2, GitBranch, Image, BellRing, Receipt, Server, Upload, Menu, X } from 'lucide-react'
|
||||
import { useAuthStore } from '../../store/auth'
|
||||
import { clsx } from 'clsx'
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getWorkerActivity } from '../../api/worker'
|
||||
import { listOrders } from '../../api/orders'
|
||||
@@ -20,6 +21,7 @@ const nav = [
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
const { data: activity } = useQuery({
|
||||
queryKey: ['worker-activity'],
|
||||
@@ -43,8 +45,36 @@ export default function Layout() {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-surface-alt">
|
||||
{/* Mobile top header bar */}
|
||||
<header className="fixed top-0 left-0 right-0 z-30 md:hidden bg-surface border-b border-border-default h-12 flex items-center px-4 gap-3">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="text-content-secondary hover:text-content transition-colors"
|
||||
aria-label="Open navigation"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<span className="flex-1 text-sm font-semibold text-content">Schaeffler Automat</span>
|
||||
<NotificationCenter />
|
||||
</header>
|
||||
|
||||
{/* Overlay backdrop (mobile only) */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-30 md:hidden"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)' }}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="w-60 flex-shrink-0 bg-surface border-r border-border-default flex flex-col">
|
||||
<aside
|
||||
className={clsx(
|
||||
'fixed left-0 top-0 h-full z-40 w-60 bg-surface border-r border-border-default flex flex-col transform transition-transform duration-200',
|
||||
'md:relative md:translate-x-0 md:flex-shrink-0',
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
)}
|
||||
>
|
||||
<div className="p-5 border-b border-border-default">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-accent rounded flex items-center justify-center">
|
||||
@@ -54,14 +84,26 @@ export default function Layout() {
|
||||
<p className="font-semibold text-content text-sm">Schaeffler</p>
|
||||
<p className="text-xs text-content-muted">Automat</p>
|
||||
</div>
|
||||
<NotificationCenter />
|
||||
{/* NotificationCenter in sidebar header (desktop); hidden on mobile (shown in top bar) */}
|
||||
<span className="hidden md:block">
|
||||
<NotificationCenter />
|
||||
</span>
|
||||
{/* Close button — mobile only */}
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="md:hidden text-content-secondary hover:text-content transition-colors"
|
||||
aria-label="Close navigation"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-3 space-y-1">
|
||||
<nav className="flex-1 p-3 space-y-1 overflow-y-auto">
|
||||
{/* New Order — primary CTA at the top */}
|
||||
<Link
|
||||
to="/orders/new"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="flex items-center gap-2 px-3 py-2.5 mb-3 rounded-md text-sm font-semibold bg-accent text-accent-text hover:bg-accent-hover transition-colors shadow-sm"
|
||||
>
|
||||
<Plus size={18} />
|
||||
@@ -79,6 +121,7 @@ export default function Layout() {
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
@@ -108,6 +151,7 @@ export default function Layout() {
|
||||
{(user?.role === 'admin' || user?.role === 'project_manager') && (
|
||||
<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',
|
||||
@@ -124,6 +168,7 @@ export default function Layout() {
|
||||
{(user?.role === 'admin' || user?.role === 'project_manager') && (
|
||||
<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',
|
||||
@@ -140,6 +185,7 @@ export default function Layout() {
|
||||
{(user?.role === 'admin' || user?.role === 'project_manager') && (
|
||||
<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',
|
||||
@@ -156,6 +202,7 @@ export default function Layout() {
|
||||
{(user?.role === 'admin' || user?.role === 'project_manager') && (
|
||||
<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',
|
||||
@@ -172,6 +219,7 @@ export default function Layout() {
|
||||
{(user?.role === 'admin' || user?.role === 'project_manager') && (
|
||||
<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',
|
||||
@@ -188,6 +236,7 @@ export default function Layout() {
|
||||
{(user?.role === 'admin' || user?.role === 'project_manager') && (
|
||||
<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',
|
||||
@@ -204,6 +253,7 @@ export default function Layout() {
|
||||
{user?.role === 'admin' && (
|
||||
<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',
|
||||
@@ -220,6 +270,7 @@ export default function Layout() {
|
||||
{user?.role === 'admin' && (
|
||||
<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',
|
||||
@@ -258,7 +309,7 @@ export default function Layout() {
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<main className="flex-1 overflow-auto min-w-0 pt-12 md:pt-0">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Loader2, Terminal } from 'lucide-react'
|
||||
|
||||
interface LogEntry {
|
||||
ts: number
|
||||
level: 'info' | 'error' | 'done' | 'warning'
|
||||
msg: string
|
||||
task_id?: string
|
||||
}
|
||||
|
||||
interface LiveRenderLogProps {
|
||||
taskId: string | null
|
||||
title?: string
|
||||
maxLines?: number
|
||||
}
|
||||
|
||||
export default function LiveRenderLog({ taskId, title = 'Task Log', maxLines = 200 }: LiveRenderLogProps) {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) return
|
||||
setLogs([])
|
||||
setConnected(false)
|
||||
setDone(false)
|
||||
|
||||
const controller = new AbortController()
|
||||
const token = localStorage.getItem('token') ?? ''
|
||||
|
||||
fetch(`/api/tasks/${taskId}/logs`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: controller.signal,
|
||||
}).then(async (res) => {
|
||||
if (!res.ok || !res.body) return
|
||||
setConnected(true)
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
while (true) {
|
||||
const { done: streamDone, value } = await reader.read()
|
||||
if (streamDone) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const parts = buffer.split('\n\n')
|
||||
buffer = parts.pop() ?? ''
|
||||
for (const part of parts) {
|
||||
const line = part.trim()
|
||||
if (!line.startsWith('data:')) continue
|
||||
const raw = line.slice(5).trim()
|
||||
try {
|
||||
const entry = JSON.parse(raw) as LogEntry & { type?: string }
|
||||
if (entry.type === 'connected') continue
|
||||
setLogs((prev) => [...prev.slice(-maxLines + 1), entry])
|
||||
if (entry.level === 'done') setDone(true)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}).catch(() => {})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [taskId, maxLines])
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [logs])
|
||||
|
||||
if (!taskId) return null
|
||||
|
||||
const levelColor = (level: string) => {
|
||||
if (level === 'error') return 'text-red-400'
|
||||
if (level === 'done') return 'text-green-400'
|
||||
if (level === 'warning') return 'text-yellow-400'
|
||||
return 'text-gray-300'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-700 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-gray-800 border-b border-gray-700">
|
||||
<Terminal size={14} className="text-gray-400" />
|
||||
<span className="text-xs font-medium text-gray-300">{title}</span>
|
||||
{!done && !connected && <Loader2 size={12} className="animate-spin text-gray-400 ml-auto" />}
|
||||
{done && <span className="ml-auto text-xs text-green-400">Done</span>}
|
||||
{connected && !done && <span className="ml-auto text-xs text-blue-400">Live</span>}
|
||||
</div>
|
||||
<div className="bg-gray-950 p-3 max-h-64 overflow-y-auto font-mono text-xs">
|
||||
{logs.length === 0 && (
|
||||
<span className="text-gray-600">Waiting for log output…</span>
|
||||
)}
|
||||
{logs.map((entry, i) => (
|
||||
<div key={i} className={`${levelColor(entry.level)}`}>
|
||||
<span className="text-gray-600 mr-2">
|
||||
{new Date(entry.ts * 1000).toLocaleTimeString()}
|
||||
</span>
|
||||
{entry.msg}
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -141,6 +141,14 @@ export default function AdminPage() {
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
|
||||
})
|
||||
|
||||
const importMediaAssetsMut = useMutation({
|
||||
mutationFn: () => api.post('/admin/import-media-assets'),
|
||||
onSuccess: (res) => {
|
||||
toast.success(`Imported: ${res.data.created} created, ${res.data.skipped} skipped`)
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Import failed'),
|
||||
})
|
||||
|
||||
const generateMissingStlsMut = useMutation({
|
||||
mutationFn: () => api.post('/admin/settings/generate-missing-stls'),
|
||||
onSuccess: (res) => {
|
||||
@@ -666,6 +674,18 @@ export default function AdminPage() {
|
||||
</button>
|
||||
<p className="text-xs text-content-muted">Re-renders thumbnails for all completed CAD files.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => importMediaAssetsMut.mutate()}
|
||||
disabled={importMediaAssetsMut.isPending}
|
||||
className="btn-secondary text-sm w-full justify-start"
|
||||
title="Create MediaAsset records for all existing CAD thumbnails and order line renders"
|
||||
>
|
||||
<RefreshCw size={14} className={importMediaAssetsMut.isPending ? 'animate-spin' : ''} />
|
||||
{importMediaAssetsMut.isPending ? 'Importing…' : 'Import Existing Media'}
|
||||
</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={() => generateMissingStlsMut.mutate()}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Receipt, Download, Trash2, Plus, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
getInvoices, createInvoice, updateInvoiceStatus, deleteInvoice, getInvoicePdfUrl,
|
||||
getInvoices, createInvoice, updateInvoiceStatus, deleteInvoice, downloadInvoicePdf,
|
||||
type Invoice, type InvoiceCreate,
|
||||
} from '../api/billing'
|
||||
|
||||
@@ -196,15 +196,13 @@ export default function BillingPage() {
|
||||
<td className="px-4 py-3 text-sm text-content-secondary">{formatDate(inv.due_at)}</td>
|
||||
<td className="px-4 py-3 text-sm text-content">{formatCurrency(inv.total_net, inv.currency)}</td>
|
||||
<td className="px-4 py-3 flex items-center gap-1">
|
||||
<a
|
||||
href={getInvoicePdfUrl(inv.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<button
|
||||
onClick={() => downloadInvoicePdf(inv.id).catch(() => toast.error('PDF download failed'))}
|
||||
className="p-1.5 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
title="Download PDF"
|
||||
>
|
||||
<Download size={15} />
|
||||
</a>
|
||||
</button>
|
||||
{inv.status === 'draft' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
||||
import { Box, Loader2, X } from 'lucide-react'
|
||||
import ThreeDViewer from '../components/cad/ThreeDViewer'
|
||||
import { getMediaAssets } from '../api/media'
|
||||
import { generateGltfGeometry } from '../api/cad'
|
||||
|
||||
/**
|
||||
* Route: /cad/:id
|
||||
*
|
||||
* Full-screen 3D viewer for a CAD file.
|
||||
* Passes production GLB URL if a gltf_geometry MediaAsset exists for this CAD file.
|
||||
* If no geometry GLB exists yet, offers to generate one on demand.
|
||||
*/
|
||||
export default function CadPreviewPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [generating, setGenerating] = useState(false)
|
||||
|
||||
// Load any geometry GLB that was generated for this CAD file
|
||||
const { data: gltfAssets } = useQuery({
|
||||
// Poll every 3s while generating so it appears automatically
|
||||
const { data: gltfAssets, isLoading: gltfLoading } = useQuery({
|
||||
queryKey: ['media-assets', id, 'gltf_geometry'],
|
||||
queryFn: () => getMediaAssets({ cad_file_id: id!, asset_type: 'gltf_geometry' }),
|
||||
enabled: !!id,
|
||||
staleTime: 30_000,
|
||||
staleTime: 5_000,
|
||||
refetchInterval: generating ? 3_000 : false,
|
||||
})
|
||||
|
||||
// Load production GLB if available
|
||||
@@ -37,6 +43,20 @@ export default function CadPreviewPage() {
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: () => generateGltfGeometry(id!),
|
||||
onSuccess: () => {
|
||||
setGenerating(true)
|
||||
},
|
||||
})
|
||||
|
||||
// Stop polling once asset appears
|
||||
useEffect(() => {
|
||||
if (generating && gltfAssets && gltfAssets.length > 0) {
|
||||
setGenerating(false)
|
||||
}
|
||||
}, [generating, gltfAssets])
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-content-muted p-8">
|
||||
@@ -49,6 +69,61 @@ export default function CadPreviewPage() {
|
||||
const latestProduction = productionAssets?.[0]
|
||||
const latestBlend = blendAssets?.[0]
|
||||
|
||||
// While checking for assets, show a neutral loading screen (don't attempt to render ThreeDViewer)
|
||||
if (gltfLoading) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-gray-950 gap-3">
|
||||
<Loader2 size={36} className="animate-spin text-gray-400" />
|
||||
<p className="text-gray-400 text-sm">Checking for 3D model…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// No GLB available yet — show generate prompt
|
||||
if (!latestGltf) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-gray-950">
|
||||
<div className="flex items-center justify-between px-5 py-3 bg-gray-900 border-b border-gray-800">
|
||||
<span className="text-white font-semibold tracking-wide">3D Viewer</span>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="p-1.5 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 text-center px-8">
|
||||
<Box size={48} className="text-gray-600" />
|
||||
<p className="text-white text-lg font-semibold">No 3D model available yet</p>
|
||||
<p className="text-gray-400 text-sm max-w-sm">
|
||||
Generate a GLB file from the STEP cache to enable the 3D viewer.
|
||||
The STL cache must exist (process the STEP file first).
|
||||
</p>
|
||||
{generating ? (
|
||||
<div className="flex items-center gap-2 text-gray-300 text-sm">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Generating… checking every 3s
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => generateMutation.mutate()}
|
||||
disabled={generateMutation.isPending}
|
||||
className="px-5 py-2 rounded-md bg-accent hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors flex items-center gap-2"
|
||||
>
|
||||
{generateMutation.isPending && <Loader2 size={14} className="animate-spin" />}
|
||||
Generate 3D Model
|
||||
</button>
|
||||
)}
|
||||
{generateMutation.isError && (
|
||||
<p className="text-red-400 text-sm">
|
||||
Failed to start generation. Check that the STL cache exists.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ThreeDViewer
|
||||
cadFileId={id}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
LayoutGrid, LayoutList, Download, Archive, Image, Film, Box, FileCode2, Layers,
|
||||
ChevronLeft, ChevronRight, Search,
|
||||
ChevronLeft, ChevronRight, Search, ChevronDown, ChevronUp,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -32,11 +32,10 @@ const TYPE_COLORS: Record<MediaAssetType, string> = {
|
||||
blend_production: 'bg-pink-100 text-pink-700',
|
||||
}
|
||||
|
||||
const ALL_TYPES: MediaAssetType[] = [
|
||||
'thumbnail', 'still', 'turntable',
|
||||
'stl_low', 'stl_high',
|
||||
'gltf_geometry', 'gltf_production', 'blend_production',
|
||||
]
|
||||
const PRIMARY_TYPES: MediaAssetType[] = ['still', 'turntable', 'thumbnail']
|
||||
const ADVANCED_TYPES: MediaAssetType[] = ['gltf_geometry', 'gltf_production', 'blend_production', 'stl_low', 'stl_high']
|
||||
const ALL_TYPES: MediaAssetType[] = [...PRIMARY_TYPES, ...ADVANCED_TYPES]
|
||||
const DEFAULT_TYPES: Set<MediaAssetType> = new Set(['still', 'turntable'])
|
||||
|
||||
const isImageAsset = (type: MediaAssetType) => type === 'thumbnail' || type === 'still'
|
||||
const isVideoAsset = (type: MediaAssetType) => type === 'turntable'
|
||||
@@ -82,6 +81,22 @@ function AssetCard({
|
||||
alt={asset.asset_type}
|
||||
className="w-full h-40 object-cover bg-gray-50"
|
||||
/>
|
||||
) : isVideoAsset(asset.asset_type) && asset.download_url ? (
|
||||
<video
|
||||
src={asset.download_url}
|
||||
poster={asset.thumbnail_url ?? undefined}
|
||||
className="w-full h-40 object-cover bg-gray-900"
|
||||
loop
|
||||
muted
|
||||
onMouseEnter={e => (e.currentTarget as HTMLVideoElement).play()}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLVideoElement).pause(); (e.currentTarget as HTMLVideoElement).currentTime = 0 }}
|
||||
/>
|
||||
) : asset.thumbnail_url ? (
|
||||
<img
|
||||
src={asset.thumbnail_url}
|
||||
alt={asset.asset_type}
|
||||
className="w-full h-40 object-cover bg-gray-50 opacity-80"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-40 flex items-center justify-center bg-gray-50">
|
||||
<TypeIcon type={asset.asset_type} />
|
||||
@@ -169,13 +184,23 @@ export default function MediaBrowserPage() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
const [view, setView] = useState<'grid' | 'list'>('grid')
|
||||
const [assetType, setAssetType] = useState<MediaAssetType | ''>('')
|
||||
const [activeTypes, setActiveTypes] = useState<Set<MediaAssetType>>(new Set(DEFAULT_TYPES))
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [productIdInput, setProductIdInput] = useState('')
|
||||
const [page, setPage] = useState(0)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const toggleType = (t: MediaAssetType) => {
|
||||
setActiveTypes(prev => {
|
||||
const next = new Set(prev)
|
||||
next.has(t) ? next.delete(t) : next.add(t)
|
||||
return next
|
||||
})
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
const filter: MediaFilter = {
|
||||
asset_type: assetType || undefined,
|
||||
asset_types: activeTypes.size > 0 ? [...activeTypes] : ALL_TYPES,
|
||||
product_id: productIdInput.trim() || undefined,
|
||||
skip: page * PAGE_SIZE,
|
||||
limit: PAGE_SIZE,
|
||||
@@ -266,29 +291,59 @@ export default function MediaBrowserPage() {
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<div className="relative">
|
||||
<Search size={15} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by product ID..."
|
||||
value={productIdInput}
|
||||
onChange={e => { setProductIdInput(e.target.value); setPage(0) }}
|
||||
className="pl-8 pr-3 py-2 text-sm border border-border-default rounded-md bg-surface focus:outline-none focus:ring-1 focus:ring-accent w-64"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={assetType}
|
||||
onChange={e => { setAssetType(e.target.value as MediaAssetType | ''); setPage(0) }}
|
||||
className="px-3 py-2 text-sm border border-border-default rounded-md bg-surface focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
>
|
||||
<option value="">All types</option>
|
||||
{ALL_TYPES.map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<div className="relative">
|
||||
<Search size={15} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by product ID..."
|
||||
value={productIdInput}
|
||||
onChange={e => { setProductIdInput(e.target.value); setPage(0) }}
|
||||
className="pl-8 pr-3 py-1.5 text-sm border border-border-default rounded-md bg-surface focus:outline-none focus:ring-1 focus:ring-accent w-56"
|
||||
/>
|
||||
</div>
|
||||
{/* Primary type chips */}
|
||||
{PRIMARY_TYPES.map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => toggleType(t)}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-full border transition-colors ${
|
||||
activeTypes.has(t)
|
||||
? `${TYPE_COLORS[t]} border-transparent`
|
||||
: 'bg-gray-50 text-gray-400 border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</select>
|
||||
{selectedIds.size > 0 && (
|
||||
<span className="text-sm text-content-muted">{selectedIds.size} selected</span>
|
||||
<button
|
||||
onClick={() => setShowAdvanced(v => !v)}
|
||||
className="flex items-center gap-1 px-3 py-1 text-xs text-content-secondary border border-border-default rounded-full hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
Advanced
|
||||
{showAdvanced ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
{selectedIds.size > 0 && (
|
||||
<span className="text-sm text-content-muted ml-1">{selectedIds.size} selected</span>
|
||||
)}
|
||||
</div>
|
||||
{showAdvanced && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ADVANCED_TYPES.map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => toggleType(t)}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-full border transition-colors ${
|
||||
activeTypes.has(t)
|
||||
? `${TYPE_COLORS[t]} border-transparent`
|
||||
: 'bg-gray-50 text-gray-400 border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user