Files
HartOMat/frontend/src/pages/MediaBrowser.tsx
T
Hartmut bfd58e3419 fix: media thumbnails, product dimensions, inline 3D viewer, GLB export
Bug A: Media Library thumbnails were gray because <img src> cannot send
JWT auth headers. Added useAuthBlob() hook (fetch + createObjectURL) in
MediaBrowser.tsx. Also fixed publish_asset Celery task to populate
product_id + cad_file_id on MediaAsset for thumbnail fallback resolution.

Bug B: Product dimensions now shown in Product Details card with Ruler
icon and "from CAD" label when cad_mesh_attributes.dimensions_mm exists.

Bug C: Replaced 128×128 CAD thumbnail with InlineCadViewer component.
Queries gltf_geometry MediaAssets, fetches GLB via auth fetch → blob URL
→ Three.js Canvas with OrbitControls. Falls back to thumbnail + "Load 3D
Model" button. Polling when GLB generation is in progress.

Bug D: trimesh was in [cad] optional extra but Dockerfile only installed
[dev]. Changed to pip install -e ".[dev,cad]" — trimesh now available in
backend container, GLB + Colors export works.

Also added bbox extraction (STL-first numpy parsing) in render_step_thumbnail
and admin "Re-extract CAD Metadata" bulk endpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 13:27:46 +01:00

567 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
LayoutGrid, LayoutList, Download, Archive, Image, Film, Box, FileCode2, Layers,
ChevronLeft, ChevronRight, Search, ChevronDown, ChevronUp, Trash2, ArrowUpDown,
Loader2,
} from 'lucide-react'
import { toast } from 'sonner'
import {
getMediaAssets, zipDownloadAssets, archiveMediaAsset, deleteMediaAssetPermanent,
} from '../api/media'
import type { MediaAsset, MediaAssetType, MediaFilter } from '../api/media'
import { useAuthStore } from '../store/auth'
// ── useAuthBlob ───────────────────────────────────────────────────────────────
function useAuthBlob(url: string | null | undefined, enabled: boolean): string | null {
const token = useAuthStore(s => s.token)
const [blobUrl, setBlobUrl] = useState<string | null>(null)
useEffect(() => {
if (!enabled || !url || !token) {
setBlobUrl(null)
return
}
let objectUrl: string | null = null
let cancelled = false
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.blob()
})
.then(blob => {
if (cancelled) return
objectUrl = URL.createObjectURL(blob)
setBlobUrl(objectUrl)
})
.catch(() => {
if (!cancelled) setBlobUrl(null)
})
return () => {
cancelled = true
if (objectUrl) URL.revokeObjectURL(objectUrl)
}
}, [url, token, enabled])
return blobUrl
}
// ── Helpers ───────────────────────────────────────────────────────────────────
const formatBytes = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
const TYPE_COLORS: 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',
}
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(['thumbnail', 'still', 'turntable'])
const isImageAsset = (type: MediaAssetType, mime?: string | null) =>
type === 'thumbnail' || type === 'still' || (mime?.startsWith('image/') ?? false)
const isVideoAsset = (type: MediaAssetType, mime?: string | null) =>
type === 'turntable' && (mime?.startsWith('video/') ?? true)
// ── TypeIcon ─────────────────────────────────────────────────────────────────
function TypeIcon({ type, mime }: { type: MediaAssetType; mime?: string | null }) {
if (isImageAsset(type, mime)) return <Image size={32} className="text-gray-400" />
if (isVideoAsset(type, mime)) return <Film size={32} className="text-gray-400" />
if (type === 'stl_low' || type === 'stl_high') return <Box size={32} className="text-gray-400" />
if (type === 'gltf_geometry' || type === 'gltf_production') return <FileCode2 size={32} className="text-gray-400" />
return <Layers size={32} className="text-gray-400" />
}
// ── AssetCard (Grid) ──────────────────────────────────────────────────────────
function AssetCard({
asset,
selected,
onToggle,
}: {
asset: MediaAsset
selected: boolean
onToggle: () => void
}) {
const isImg = isImageAsset(asset.asset_type, asset.mime_type)
const authImgUrl = useAuthBlob(asset.download_url, isImg)
const showImage = isImg && !!authImgUrl
const showImgLoading = isImg && !authImgUrl && !!asset.download_url
const showThumb = !isImg && !isVideoAsset(asset.asset_type, asset.mime_type) && asset.thumbnail_url
return (
<div
className={`relative rounded-lg border-2 overflow-hidden cursor-pointer transition-colors ${
selected ? 'border-blue-500' : 'border-border-default hover:border-accent'
}`}
onClick={onToggle}
>
<input
type="checkbox"
checked={selected}
onChange={onToggle}
onClick={e => e.stopPropagation()}
className="absolute top-2 left-2 z-10 w-4 h-4 cursor-pointer"
/>
{showImage ? (
<img
src={authImgUrl!}
alt={asset.asset_type}
className="w-full h-44 object-contain p-2"
style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}
/>
) : showImgLoading ? (
<div className="w-full h-44 flex items-center justify-center" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<Loader2 size={28} className="text-gray-400 animate-spin" />
</div>
) : isVideoAsset(asset.asset_type, asset.mime_type) && asset.download_url ? (
<video
src={asset.download_url}
poster={asset.thumbnail_url ?? undefined}
className="w-full h-44 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 }}
/>
) : showThumb ? (
<img
src={asset.thumbnail_url!}
alt={asset.asset_type}
className="w-full h-44 object-contain p-2 opacity-80"
style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}
/>
) : (
<div className="w-full h-44 flex items-center justify-center" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<TypeIcon type={asset.asset_type} mime={asset.mime_type} />
</div>
)}
<div className="p-2 space-y-1">
<span
className={`inline-block text-xs px-2 py-0.5 rounded-full font-medium ${TYPE_COLORS[asset.asset_type]}`}
>
{asset.asset_type}
</span>
{asset.file_size_bytes != null && (
<p className="text-xs text-gray-500">{formatBytes(asset.file_size_bytes)}</p>
)}
<p className="text-xs text-gray-400">{formatDate(asset.created_at)}</p>
</div>
</div>
)
}
// ── AssetRow (List) ───────────────────────────────────────────────────────────
function AssetRow({
asset,
selected,
onToggle,
onArchive,
onDownload,
}: {
asset: MediaAsset
selected: boolean
onToggle: () => void
onArchive: () => void
onDownload: () => void
}) {
return (
<tr className={`border-b border-gray-100 hover:bg-gray-50 transition-colors ${selected ? 'bg-blue-50' : ''}`}>
<td className="px-4 py-3">
<input type="checkbox" checked={selected} onChange={onToggle} className="w-4 h-4" />
</td>
<td className="px-4 py-3">
<span className={`inline-block text-xs px-2 py-0.5 rounded-full font-medium ${TYPE_COLORS[asset.asset_type]}`}>
{asset.asset_type}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-700 font-mono truncate max-w-xs">
{asset.storage_key}
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{asset.file_size_bytes != null ? formatBytes(asset.file_size_bytes) : '—'}
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{asset.mime_type ?? '—'}
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{formatDate(asset.created_at)}
</td>
<td className="px-4 py-3 flex items-center gap-2">
{asset.download_url && (
<button
onClick={onDownload}
className="p-1.5 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
title="Download"
>
<Download size={15} />
</button>
)}
<button
onClick={onArchive}
className="p-1.5 rounded hover:bg-red-100 text-gray-500 hover:text-red-600 transition-colors"
title="Archive"
>
<Archive size={15} />
</button>
</td>
</tr>
)
}
// ── Page ──────────────────────────────────────────────────────────────────────
const PAGE_SIZE = 50
export default function MediaBrowserPage() {
const qc = useQueryClient()
const [view, setView] = useState<'grid' | 'list'>('grid')
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 [sortBy, setSortBy] = useState('created_at')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
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_types: activeTypes.size > 0 ? [...activeTypes] : ALL_TYPES,
product_id: productIdInput.trim() || undefined,
skip: page * PAGE_SIZE,
limit: PAGE_SIZE,
sort_by: sortBy,
sort_dir: sortDir,
}
const { data: assets = [], isLoading } = useQuery({
queryKey: ['media', filter],
queryFn: () => getMediaAssets(filter),
})
const archiveMutation = useMutation({
mutationFn: archiveMediaAsset,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['media'] })
toast.success('Asset archived')
},
onError: () => toast.error('Failed to archive asset'),
})
const deleteMutation = useMutation({
mutationFn: deleteMediaAssetPermanent,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['media'] })
},
onError: () => toast.error('Failed to delete asset'),
})
const toggleSelect = (id: string) => {
setSelectedIds(prev => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
const toggleAll = () => {
if (selectedIds.size === assets.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(assets.map(a => a.id)))
}
}
const handleZipDownload = async () => {
try {
await zipDownloadAssets([...selectedIds])
toast.success('ZIP download started')
} catch {
toast.error('ZIP download failed')
}
}
const handleArchiveSelected = async () => {
for (const id of selectedIds) {
await archiveMutation.mutateAsync(id)
}
setSelectedIds(new Set())
}
const handleDeleteSelected = async () => {
if (!confirm(`Permanently delete ${selectedIds.size} asset(s)? This cannot be undone.`)) return
let deleted = 0
for (const id of selectedIds) {
try {
await deleteMutation.mutateAsync(id)
deleted++
} catch { /* already toasted per item */ }
}
setSelectedIds(new Set())
toast.success(`${deleted} asset(s) permanently deleted`)
}
const handleDownload = (asset: MediaAsset) => {
if (asset.download_url) {
window.open(asset.download_url, '_blank')
}
}
return (
<div className="p-6 space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-content">Media Browser</h1>
<p className="text-sm text-content-muted mt-0.5">
Browse and manage rendered media assets
</p>
</div>
<div className="flex items-center gap-2">
{/* Sort dropdown */}
<div className="flex items-center gap-1 border border-border-default rounded-md px-2 py-1">
<ArrowUpDown size={14} className="text-content-muted shrink-0" />
<select
value={`${sortBy}:${sortDir}`}
onChange={e => {
const [by, dir] = e.target.value.split(':')
setSortBy(by)
setSortDir(dir as 'asc' | 'desc')
setPage(0)
}}
className="text-xs text-content bg-transparent focus:outline-none cursor-pointer"
>
<option value="created_at:desc">Newest first</option>
<option value="created_at:asc">Oldest first</option>
<option value="storage_key:asc">Name AZ</option>
<option value="storage_key:desc">Name ZA</option>
<option value="file_size_bytes:desc">Largest first</option>
<option value="file_size_bytes:asc">Smallest first</option>
</select>
</div>
<button
onClick={() => setView('grid')}
className={`p-2 rounded-md transition-colors ${
view === 'grid' ? 'bg-accent-light text-accent' : 'text-content-secondary hover:bg-surface-hover'
}`}
title="Grid view"
>
<LayoutGrid size={18} />
</button>
<button
onClick={() => setView('list')}
className={`p-2 rounded-md transition-colors ${
view === 'list' ? 'bg-accent-light text-accent' : 'text-content-secondary hover:bg-surface-hover'
}`}
title="List view"
>
<LayoutList size={18} />
</button>
</div>
</div>
{/* Filters */}
<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-surface-alt text-content-muted border-border-default hover:bg-surface-hover'
}`}
>
{t}
</button>
))}
<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-surface-alt text-content-muted border-border-default hover:bg-surface-hover'
}`}
>
{t}
</button>
))}
</div>
)}
</div>
{/* Content */}
{isLoading ? (
<div className="flex items-center justify-center h-48 text-content-muted text-sm">
Loading assets
</div>
) : assets.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-content-muted">
<Image size={40} className="mb-3 opacity-30" />
<p className="text-sm">No assets found</p>
</div>
) : view === 'grid' ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{assets.map(asset => (
<AssetCard
key={asset.id}
asset={asset}
selected={selectedIds.has(asset.id)}
onToggle={() => toggleSelect(asset.id)}
/>
))}
</div>
) : (
<div className="bg-surface border border-border-default rounded-lg overflow-hidden">
<table className="w-full text-left">
<thead className="bg-surface-alt border-b border-border-default">
<tr>
<th className="px-4 py-3">
<input
type="checkbox"
checked={assets.length > 0 && selectedIds.size === assets.length}
onChange={toggleAll}
className="w-4 h-4"
/>
</th>
<th className="px-4 py-3 text-xs font-semibold text-content-muted uppercase tracking-wide">Type</th>
<th className="px-4 py-3 text-xs font-semibold text-content-muted uppercase tracking-wide">Storage Key</th>
<th className="px-4 py-3 text-xs font-semibold text-content-muted uppercase tracking-wide">Size</th>
<th className="px-4 py-3 text-xs font-semibold text-content-muted uppercase tracking-wide">MIME</th>
<th className="px-4 py-3 text-xs font-semibold text-content-muted uppercase tracking-wide">Created</th>
<th className="px-4 py-3 text-xs font-semibold text-content-muted uppercase tracking-wide">Actions</th>
</tr>
</thead>
<tbody>
{assets.map(asset => (
<AssetRow
key={asset.id}
asset={asset}
selected={selectedIds.has(asset.id)}
onToggle={() => toggleSelect(asset.id)}
onArchive={() => archiveMutation.mutate(asset.id)}
onDownload={() => handleDownload(asset)}
/>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{(assets.length === PAGE_SIZE || page > 0) && (
<div className="flex items-center gap-3 justify-center">
<button
disabled={page === 0}
onClick={() => setPage(p => Math.max(0, p - 1))}
className="p-2 rounded-md border border-border-default hover:bg-surface-hover disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft size={16} />
</button>
<span className="text-sm text-content-muted">Page {page + 1}</span>
<button
disabled={assets.length < PAGE_SIZE}
onClick={() => setPage(p => p + 1)}
className="p-2 rounded-md border border-border-default hover:bg-surface-hover disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight size={16} />
</button>
</div>
)}
{/* Floating Action Bar */}
{selectedIds.size > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-gray-900 text-white rounded-xl shadow-2xl px-6 py-3 flex items-center gap-4">
<span className="text-sm font-medium">{selectedIds.size} selected</span>
<button
onClick={handleZipDownload}
className="flex items-center gap-2 text-sm bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg transition-colors"
>
<Download size={16} />
ZIP download
</button>
<button
onClick={handleArchiveSelected}
className="flex items-center gap-2 text-sm bg-amber-600 hover:bg-amber-700 px-4 py-2 rounded-lg transition-colors"
>
<Archive size={16} />
Archive
</button>
<button
onClick={handleDeleteSelected}
className="flex items-center gap-2 text-sm bg-red-600 hover:bg-red-700 px-4 py-2 rounded-lg transition-colors"
>
<Trash2 size={16} />
Delete
</button>
<button
onClick={() => setSelectedIds(new Set())}
className="text-gray-400 hover:text-white transition-colors text-lg leading-none"
title="Clear selection"
>
×
</button>
</div>
)}
</div>
)
}