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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user