@@ -1,67 +1,27 @@
import { useState , useEffect } from 'react'
import { useQuery , useMutation , useQueryClient } from '@tanstack/react-query'
import { useState , useEffect , useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
LayoutGrid , LayoutList , Download , Archive , Image , Film , Box , FileCode2 , Layers ,
ChevronLeft , ChevronRight , Search , ChevronDown , ChevronUp , Trash2 , ArrowUpDown ,
Loader2 ,
Search , Image , Film , Box , Layers , FileCode2 ,
ChevronLeft , ChevronRight , Download , Loader2 ,
} from 'lucide-react'
import { toast } from 'sonner'
import {
getMediaAssets , zipDownloadAssets , archiveMediaAsset , deleteMediaAssetPermanent ,
getMediaAssets ,
} 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
}
import type { MediaAssetItem , MediaAssetType } from '../api/media'
// ── Helpers ───────────────────────────────────────────────────────────────────
const formatBytes = ( bytes : number ) = > {
const formatDate = ( iso : string ) = >
new Date ( iso ) . toLocaleDateString ( 'de-DE' , { day : '2-digit' , month : '2-digit' , year : 'numeric' } )
const formatBytes = ( bytes : number | null ) = > {
if ( bytes == null ) return null
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 > = {
const TYPE_COLORS : Partial < Record < MediaAssetType , string > > = {
thumbnail : 'bg-gray-100 text-gray-700' ,
still : 'bg-blue-100 text-blue-700' ,
turntable : 'bg-purple-100 text-purple-700' ,
@@ -72,493 +32,303 @@ const TYPE_COLORS: Record<MediaAssetType, string> = {
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' , 't urntable' ] )
const ASSET_TYPES = [
{ value : '' , label : 'All types' } ,
{ value : 'still' , label : 'Still' } ,
{ value : 'turntable' , label : 'T urntable' } ,
{ value : 'thumbnail' , label : 'Thumbnail' } ,
{ value : 'gltf_geometry' , label : 'glTF Geometry' } ,
{ value : 'gltf_production' , label : 'glTF Production' } ,
{ value : 'blend_production' , label : 'Blend Production' } ,
{ value : 'stl_low' , label : 'STL Low' } ,
{ value : 'stl_high' , label : 'STL High' } ,
]
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 )
const CATEGORIES = [
{ value : '' , label : 'All categories' } ,
{ value : 'TRB' , label : 'TRB' } ,
{ value : 'Kugellager' , label : 'Kugellager' } ,
{ value : 'CRB' , label : 'CRB' } ,
{ value : 'Gleitlager' , label : 'Gleitlager' } ,
{ value : 'SRB_TORB' , label : 'SRB / TORB' } ,
{ value : 'Linear_schiene' , label : 'Linear-Schiene' } ,
{ value : 'Anschlagplatten' , label : 'Anschlagplatten' } ,
]
// ── TypeIcon ─────────────────────────────────────────────────────────────────
const RENDER_STATUSES = [
{ value : '' , label : 'All statuses' } ,
{ value : 'completed' , label : 'Completed' } ,
{ value : 'failed' , label : 'Failed' } ,
{ value : 'processing' , label : 'Processing' } ,
{ value : 'pending' , label : 'Pending' } ,
]
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 " / >
const PAGE_SIZE_OPTIONS = [ 25 , 50 , 100 ]
// ── TypeIcon ──────────────────────────────────────────────────────────────────
function TypeIcon ( { type } : { type : MediaAssetType } ) {
if ( type === 'still' || type === 'thumbnail' ) return < Image size = { 32 } className = "text-content-muted " / >
if ( type === 'turntable' ) return < Film size = { 32 } className = "text-content-muted" / >
if ( type === 'stl_low' || type === 'stl_high' ) return < Box size = { 32 } className = "text-content-muted" / >
if ( type === 'gltf_geometry' || type === 'gltf_production' ) return < FileCode2 size = { 32 } className = "text-content-muted" / >
return < Layers size = { 32 } className = "text-content-muted" / >
}
// ── AssetCard (Grid) ──────────────────────────────────────────────────────────
// ── AssetCard ─────── ──────────────────────────────────────────────────────────
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 )
function AssetCard ( { asset } : { asset : MediaAssetItem } ) {
const isImage = asset . asset_type === 'still' || asset . asset_type === 'thumbnail'
const isVideo = asset . asset_type === 'turntable'
const typeBadge = TYPE_COLORS [ asset . asset_type ] ? ? 'bg-gray-100 text-gray-700'
const sizeStr = formatBytes ( asset . file_size_bytes )
const showImage = isImg && ! ! authImgUrl
const showImgLoading = isImg && ! authImgUrl && ! ! asset . download_url
const showThumb = ! isImg && ! isVideoAsset ( asset . asset_type , asset . mime_type ) && asset . thumbnail_url
const handleDownload = ( ) = > {
if ( asset . download_url ) window . open ( asset . download_url , '_blank' )
}
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 }
className = " rounded-lg border border-border-default overflow-hidden flex flex-col"
style = { { backgroundColor : 'var(--color-bg-surface)' } }
>
< 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 >
{ /* Preview area */ }
< div
className = "w-full h-40 flex items-center justify-center overflow-hidden"
style = { { backgroundColor : 'var(--color-bg-surface-alt)' } }
>
{ isImage && asset . thumbnail_url ? (
< img
src = { asset . thumbnail_url }
alt = { asset . asset_type }
className = "w-full h-full object-contain p-2"
/ >
) : isVideo && asset . thumbnail_url ? (
< img
src = { asset . thumbnail_url }
alt = { asset . asset_type }
className = "w-full h-full object-cover opacity-80"
/ >
) : (
< TypeIcon type = { asset . asset_type } / >
) }
< p className = "text-xs text-gray-400" > { formatDate ( asset . created_at ) } < / p >
< / div >
{ /* Info */ }
< div className = "p-3 flex-1 flex flex-col gap-1" >
< div className = "flex items-center justify-between gap-1" >
< span className = { ` text-xs px-2 py-0.5 rounded-full font-medium ${ typeBadge } ` } >
{ asset . asset_type }
< / span >
{ asset . download_url && (
< button
onClick = { handleDownload }
className = "p-1 rounded hover:bg-surface-hover text-content-muted hover:text-content transition-colors"
title = "Download"
>
< Download size = { 14 } / >
< / button >
) }
< / div >
{ asset . product_name && (
< p className = "text-xs font-medium text-content truncate" title = { asset . product_name } >
{ asset . product_name }
< / p >
) }
{ asset . product_pim_id && (
< p className = "text-xs text-content-muted font-mono truncate" > { asset . product_pim_id } < / p >
) }
< div className = "flex items-center gap-2 mt-auto pt-1 text-xs text-content-muted" >
< span > { formatDate ( asset . created_at ) } < / span >
{ sizeStr && < span > · { sizeStr } < / span > }
< / div >
< / div >
< / div >
)
}
// ── AssetRow (List) ───────────────────────────────────────────────────────────
// ── useDebounce ──── ───────────────────────────────────────────────────────────
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 >
)
function useDebounce < T > ( value : T , delay : number ) : T {
const [ debounced , setDebounced ] = useState ( value )
const timer = useRef < ReturnType < typeof setTimeout > | null > ( null )
useEffect ( ( ) = > {
if ( timer . current ) clearTimeout ( timer . current )
timer . current = setTimeout ( ( ) = > setDebounced ( value ) , delay )
return ( ) = > { if ( timer . current ) clearTimeout ( timer . current ) }
} , [ value , delay ] )
return debounced
}
// ── Page ──────────────────────────────────────────────────────────────────────
const PAGE_SIZE = 50
export default function MediaBrowserPage() {
const qc = useQueryClient ( )
const [ searchInput , setSearchInput ] = useState ( '' )
const [ assetType , setAssetType ] = useState ( '' )
const [ categoryKey , setCategoryKey ] = useState ( '' )
const [ renderStatus , setRenderStatus ] = useState ( '' )
const [ page , setPage ] = useState ( 1 )
const [ pageSize , setPageSize ] = useState ( 50 )
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 q = useDebounce ( searchInput , 300 )
const toggleType = ( t : MediaAssetType ) = > {
setActiveTypes ( prev = > {
const next = new Set ( prev )
next . has ( t ) ? next . delete ( t ) : next . add ( t )
return next
} )
setPage ( 0 )
}
// Reset to page 1 when any filter changes
useEffect ( ( ) = > { setPage ( 1 ) } , [ q , assetType , categoryKey , renderStatus , pageSize ] )
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 { data , isLoading , isFetching } = useQuery ( {
queryKey : [ 'media-browser' , { q , assetType , categoryKey , renderStatus , page , pageSize } ] ,
queryFn : ( ) = > getMediaAssets ( {
q : q || undefined ,
asset_type : assetType || undefined ,
category_key : categoryKey || undefined ,
render_status : renderStatus || undefined ,
page ,
page_size : pageSize ,
} ) ,
placeholderData : prev = > prev ,
} )
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' )
}
}
const items = data ? . items ? ? [ ]
const total = data ? . total ? ? 0
const pages = data ? . pages ? ? 1
return (
< div className = "p-6 space-y-5 " >
{ /* Heade r */ }
< 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 -ce nter 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 A – Z < / option >
< option value = "storage_key:desc" > Name Z – A < / option >
< option value = "file_size_bytes:desc" > Largest first < / option >
< option value = "file_size_bytes:asc" > Smallest first < / option >
< / select >
< div className = "flex flex-col h-full " >
{ /* Sticky filter ba r */ }
< div
className = "sticky top-0 z-20 px-6 py-4 border-b border-border-default"
style = { { backgroundColor : 'var(--color-bg-surface)' } }
>
< div className = "flex items-start gap-3 flex-wrap" >
{ /* Header */ }
< div className = "flex-1 min-w-0 mr-2" >
< h1 className = "text-xl font-semibold text -co ntent" > Media Browser < / h1 >
< p className = "text-sm text-content-muted mt-0.5" >
Browse and download rendered media assets
< / p >
< / 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 "
{ /* Search */ }
< div className = "relative" >
< Search size = { 14 } className = "absolute left-2.5 top-1/2 -translate-y-1/2 text-content-muted pointer-events-none" / >
< input
type = "text "
placeholder = "Search product name or PIM-ID..."
value = { searchInput }
onChange = { e = > setSearchInput ( e . target . value ) }
className = "pl-8 pr-3 py-1.5 text-sm border border-border-default rounded-md focus:outline-none focus:ring-1 focus:ring-accent w-64"
style = { { backgroundColor : 'var(--color-bg-surface)' } }
/ >
< / div >
{ /* Asset type */ }
< select
value = { assetType }
onChange = { e = > setAssetType ( e . target . value ) }
className = "text-sm border border-border-default rounded-md px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-accent"
style = { { backgroundColor : 'var(--color-bg-surface)' } }
>
< 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 "
{ ASSET_TYPES . map ( o = > < option key = { o . value } value = { o . value } > { o . label } < / option > ) }
< / select >
{ /* Category */ }
< select
value = { categoryKey }
onChange = { e = > setCategoryKey ( e . target . value ) }
className = "text-sm border border-border-default rounded-md px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-accent "
style = { { backgroundColor : 'var(--color-bg-surface)' } }
>
< LayoutList size = { 18 } / >
< / button >
{ CATEGORIES . map ( o = > < option key = { o . value } value = { o . value } > { o . label } < / option > ) }
< / select >
{ /* Render status */ }
< select
value = { renderStatus }
onChange = { e = > setRenderStatus ( e . target . value ) }
className = "text-sm border border-border-default rounded-md px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-accent"
style = { { backgroundColor : 'var(--color-bg-surface)' } }
>
{ RENDER_STATUSES . map ( o = > < option key = { o . value } value = { o . value } > { o . label } < / option > ) }
< / select >
< / div >
{ /* Results count + loading indicator */ }
< div className = "flex items-center gap-2 mt-2 text-xs text-content-muted" >
{ isFetching && < Loader2 size = { 12 } className = "animate-spin" / > }
< span >
{ total === 0 ? 'No assets' : ` ${ total . toLocaleString ( ) } asset ${ total !== 1 ? 's' : '' } ` }
< / span >
< / 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"
/ >
{ /* Content */ }
< div className = "flex-1 overflow-y-auto px-6 p y-5 " >
{ isLoading ? (
< div className = "flex items-center justify-center h-64 text-content-muted gap-3 " >
< Loader2 size = { 24 } className = "animate-spin " / >
< span className = "text-sm" > Loading assets … < / span >
< / 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 >
) : items . length === 0 ? (
< div className = "flex flex-col items-center justify-center h-64 text-content-muted gap-3" >
< Image size = { 48 } className = "opacity-25" / >
< p className = "text-sm font-medium" > No media assets found . < / p >
< p className = "text-xs text-center max-w-xs" >
Renders will appear here once orders are completed . Try adjusting your filters .
< / p >
< / div >
) : (
< div className = "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4" >
{ items . map ( asset = > (
< AssetCard key = { asset . id } asset = { asset } / >
) ) }
< / 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 footer */ }
{ ( total > 0 ) && (
< div
className = "border-t border-border-default px-6 py-3 flex items-center justify-between gap-4"
style = { { backgroundColor : 'var(--color-bg-surface)' } }
>
{ /* Page size selector */ }
< div className = "flex items-center gap-2 text-sm text-content-muted" >
< span > Per page : < / span >
{ PAGE_SIZE_OPTIONS . map ( size = > (
< button
key = { size }
onClick = { ( ) = > setPageSize ( size ) }
className = { ` px-2.5 py-1 rounded text-xs font-medium transition-colors ${
pageSize === size
? 'bg-accent text-white'
: 'border border-border-default hover:bg-surface-hover text-content'
} ` }
>
{ size }
< / button >
) ) }
< / 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 >
{ /* Page nav */ }
< div className = "flex items-center gap-2" >
< button
disabled = { page === 1 }
onClick = { ( ) = > setPage ( p = > Math . max ( 1 , p - 1 ) ) }
className = "p-1.5 rounded 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 whitespace-nowrap" >
Page { page } of { pages }
< / span >
< button
disabled = { page >= pages }
onClick = { ( ) = > setPage ( p = > Math . min ( pages , p + 1 ) ) }
className = "p-1.5 rounded border border-border-default hover:bg-surface-hover disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
< ChevronRight size = { 16 } / >
< / button >
< / div >
< / div >
) }
< / div >