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:
2026-03-07 00:09:27 +01:00
parent 9bf6e72718
commit f5ca91ee02
25 changed files with 792 additions and 299 deletions
+20
View File
@@ -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 &amp; CAD thumbnails in the Media Browser.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => generateMissingStlsMut.mutate()}
+4 -6
View File
@@ -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={() => {
+79 -4
View File
@@ -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}
+85 -30
View File
@@ -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>