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
+13 -13
View File
@@ -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} />
+56 -5
View File
@@ -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>
)
}