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(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 = { 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 = 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 if (isVideoAsset(type, mime)) return if (type === 'stl_low' || type === 'stl_high') return if (type === 'gltf_geometry' || type === 'gltf_production') return return } // ── 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 (
e.stopPropagation()} className="absolute top-2 left-2 z-10 w-4 h-4 cursor-pointer" /> {showImage ? ( {asset.asset_type} ) : showImgLoading ? (
) : isVideoAsset(asset.asset_type, asset.mime_type) && asset.download_url ? (
) } // ── AssetRow (List) ─────────────────────────────────────────────────────────── function AssetRow({ asset, selected, onToggle, onArchive, onDownload, }: { asset: MediaAsset selected: boolean onToggle: () => void onArchive: () => void onDownload: () => void }) { return ( {asset.asset_type} {asset.storage_key} {asset.file_size_bytes != null ? formatBytes(asset.file_size_bytes) : '—'} {asset.mime_type ?? '—'} {formatDate(asset.created_at)} {asset.download_url && ( )} ) } // ── Page ────────────────────────────────────────────────────────────────────── const PAGE_SIZE = 50 export default function MediaBrowserPage() { const qc = useQueryClient() const [view, setView] = useState<'grid' | 'list'>('grid') const [activeTypes, setActiveTypes] = useState>(new Set(DEFAULT_TYPES)) const [showAdvanced, setShowAdvanced] = useState(false) const [productIdInput, setProductIdInput] = useState('') const [page, setPage] = useState(0) const [selectedIds, setSelectedIds] = useState>(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 (
{/* Header */}

Media Browser

Browse and manage rendered media assets

{/* Sort dropdown */}
{/* Filters */}
{ 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" />
{/* Primary type chips */} {PRIMARY_TYPES.map(t => ( ))} {selectedIds.size > 0 && ( {selectedIds.size} selected )}
{showAdvanced && (
{ADVANCED_TYPES.map(t => ( ))}
)}
{/* Content */} {isLoading ? (
Loading assets…
) : assets.length === 0 ? (

No assets found

) : view === 'grid' ? (
{assets.map(asset => ( toggleSelect(asset.id)} /> ))}
) : (
{assets.map(asset => ( toggleSelect(asset.id)} onArchive={() => archiveMutation.mutate(asset.id)} onDownload={() => handleDownload(asset)} /> ))}
0 && selectedIds.size === assets.length} onChange={toggleAll} className="w-4 h-4" /> Type Storage Key Size MIME Created Actions
)} {/* Pagination */} {(assets.length === PAGE_SIZE || page > 0) && (
Page {page + 1}
)} {/* Floating Action Bar */} {selectedIds.size > 0 && (
{selectedIds.size} selected
)}
) }