2026-05-22 10:05:54 -04:00
// screens-library.jsx
2026-05-22 08:15:36 -04:00
2026-05-22 10:05:54 -04:00
function Library ( { navigate , onOpenAsset , openProject } ) {
2026-05-22 23:52:30 -04:00
const { BINS , PROJECTS } = window . ZAMPP _DATA ;
2026-05-22 10:05:54 -04:00
const [ view , setView ] = React . useState ( 'grid' ) ;
const [ filter , setFilter ] = React . useState ( 'all' ) ;
2026-05-23 00:02:51 -04:00
const [ search , setSearch ] = React . useState ( window . _dfPendingSearch || '' ) ;
React . useEffect ( ( ) => { delete window . _dfPendingSearch ; } , [ ] ) ;
2026-05-22 23:52:30 -04:00
// Local state lets us re-render after delete / move-to-bin without forcing
// a full app reload — keeps ZAMPP_DATA in sync as the cache of record.
const [ allAssets , setAllAssets ] = React . useState ( window . ZAMPP _DATA . ASSETS || [ ] ) ;
const [ ctxMenu , setCtxMenu ] = React . useState ( null ) ; // { asset, x, y }
const refreshAssets = React . useCallback ( ( ) => {
window . ZAMPP _API . fetch ( '/assets?limit=500' )
. then ( r => {
const list = Array . isArray ( r ) ? r : ( r . assets || [ ] ) ;
const proj = { } ;
( PROJECTS || [ ] ) . forEach ( p => { proj [ p . id ] = p . name ; } ) ;
const normalized = list . map ( a => window . normalizeAsset ? window . normalizeAsset ( a , proj ) : a ) ;
window . ZAMPP _DATA . ASSETS = normalized ;
setAllAssets ( normalized ) ;
} )
. catch ( ( ) => { } ) ;
} , [ PROJECTS ] ) ;
// Dismiss the context menu on any outside click (capture phase so clicking
// a menu item still fires before the menu unmounts).
React . useEffect ( ( ) => {
if ( ! ctxMenu ) return ;
const close = ( ) => setCtxMenu ( null ) ;
window . addEventListener ( 'click' , close ) ;
window . addEventListener ( 'contextmenu' , close ) ;
window . addEventListener ( 'scroll' , close , true ) ;
return ( ) => {
window . removeEventListener ( 'click' , close ) ;
window . removeEventListener ( 'contextmenu' , close ) ;
window . removeEventListener ( 'scroll' , close , true ) ;
} ;
} , [ ctxMenu ] ) ;
const openCtx = ( asset , e ) => {
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
setCtxMenu ( { asset , x : e . clientX , y : e . clientY } ) ;
} ;
2026-05-22 08:15:36 -04:00
2026-05-22 10:05:54 -04:00
let assets = openProject
2026-05-22 23:52:30 -04:00
? allAssets . filter ( function ( a ) { return a . project _id === openProject . id ; } )
: allAssets ;
const ALL _ASSETS = allAssets ;
2026-05-22 16:58:11 -04:00
if ( filter !== 'all' ) assets = assets . filter ( function ( a ) { return a . status === filter ; } ) ;
if ( search ) assets = assets . filter ( function ( a ) { return a . name . toLowerCase ( ) . includes ( search . toLowerCase ( ) ) ; } ) ;
2026-05-22 08:15:36 -04:00
2026-05-22 10:05:54 -04:00
const displayTitle = openProject ? openProject . name : 'All Assets' ;
2026-05-22 16:58:11 -04:00
const errorCount = ALL _ASSETS . filter ( function ( a ) { return a . status === 'error' ; } ) . length ;
const recentCount = ALL _ASSETS . filter ( function ( a ) { return ( Date . now ( ) - new Date ( a . created _at ) ) < 86400000 ; } ) . length ;
2026-05-22 10:05:54 -04:00
2026-05-22 08:15:36 -04:00
return (
< div className = "library-layout" >
< aside className = "library-rail" >
< div >
< h4 > Projects < / h4 >
< div className = "rail-list" >
2026-05-22 16:58:11 -04:00
< div className = { ` rail-item ${ ! openProject ? 'active' : '' } ` } onClick = { function ( ) { navigate ( 'library' ) ; } } style = { { cursor : 'pointer' } } >
2026-05-22 10:05:54 -04:00
< Icon name = "library" size = { 13 } className = "rail-icon" / >
< span > All projects < / span >
< span className = "rail-count" > { ALL _ASSETS . length } < / span >
< / div >
2026-05-22 16:58:11 -04:00
{ PROJECTS . slice ( 0 , 8 ) . map ( function ( p ) {
return (
< div key = { p . id } className = { ` rail-item ${ openProject && openProject . id === p . id ? 'active' : '' } ` } style = { { cursor : 'pointer' } }
onClick = { function ( ) { navigate ( 'projects' ) ; } } >
< span className = "rail-color-dot" style = { { background : p . color } } / >
< span style = { { overflow : 'hidden' , textOverflow : 'ellipsis' , whiteSpace : 'nowrap' } } > { p . name } < / span >
< span className = "rail-count" > { p . assets } < / span >
< / div >
) ;
} ) }
2026-05-22 08:15:36 -04:00
< / div >
< / div >
2026-05-22 10:05:54 -04:00
{ BINS . length > 0 && (
< div >
< h4 > Bins < / h4 >
< div className = "rail-list" >
2026-05-22 16:58:11 -04:00
{ BINS . map ( function ( b ) {
return (
< div key = { b . id } className = "rail-item" >
< Icon name = { binIcon ( b . icon ) } size = { 13 } className = "rail-icon" / >
< span > { b . name } < / span >
< span className = "rail-count" > { b . count } < / span >
< / div >
) ;
} ) }
2026-05-22 10:05:54 -04:00
< / div >
2026-05-22 08:15:36 -04:00
< / div >
2026-05-22 10:05:54 -04:00
) }
2026-05-22 08:15:36 -04:00
< div >
< h4 > Smart filters < / h4 >
< div className = "rail-list" >
2026-05-22 16:58:11 -04:00
{ errorCount > 0 && < div className = "rail-item" onClick = { function ( ) { setFilter ( 'error' ) ; } } style = { { cursor : 'pointer' } } > < Icon name = "alert" size = { 13 } className = "rail-icon" / > < span > Errors < / span > < span className = "rail-count" > { errorCount } < / span > < / div > }
< div className = "rail-item" onClick = { function ( ) { setFilter ( 'all' ) ; } } style = { { cursor : 'pointer' } } > < Icon name = "clock" size = { 13 } className = "rail-icon" / > < span > Last 24 h < / span > < span className = "rail-count" > { recentCount } < / span > < / div >
< div className = "rail-item" onClick = { function ( ) { setFilter ( 'ready' ) ; } } style = { { cursor : 'pointer' } } > < Icon name = "check" size = { 13 } className = "rail-icon" / > < span > Ready < / span > < span className = "rail-count" > { ALL _ASSETS . filter ( function ( a ) { return a . status === 'ready' ; } ) . length } < / span > < / div >
2026-05-22 08:15:36 -04:00
< / div >
< / div >
< / aside >
< div className = "library-main" >
< div className = "library-toolbar" >
2026-05-22 10:05:54 -04:00
< div className = "toolbar-title" > { displayTitle } < / div >
2026-05-22 08:15:36 -04:00
< span className = "count" > · { assets . length } assets < / span >
< div style = { { flex : 1 } } / >
< div className = "search" style = { { width : 220 } } >
< Icon name = "search" className = "search-icon" / >
2026-05-22 16:58:11 -04:00
< input value = { search } onChange = { function ( e ) { setSearch ( e . target . value ) ; } } placeholder = "Filter assets…" / >
2026-05-22 08:15:36 -04:00
< / div >
< div className = "tab-group" >
2026-05-22 16:58:11 -04:00
{ [ 'all' , 'ready' , 'processing' , 'live' , 'error' ] . map ( function ( f ) {
return (
< button key = { f } className = { filter === f ? 'active' : '' } onClick = { function ( ) { setFilter ( f ) ; } } >
{ f === 'all' ? 'All' : f [ 0 ] . toUpperCase ( ) + f . slice ( 1 ) }
< / button >
) ;
} ) }
2026-05-22 08:15:36 -04:00
< / div >
< div className = "tab-group" >
2026-05-22 16:58:11 -04:00
< button className = { view === 'grid' ? 'active' : '' } onClick = { function ( ) { setView ( 'grid' ) ; } } > < Icon name = "grid" size = { 12 } / > < / button >
< button className = { view === 'list' ? 'active' : '' } onClick = { function ( ) { setView ( 'list' ) ; } } > < Icon name = "list" size = { 12 } / > < / button >
2026-05-22 08:15:36 -04:00
< / div >
2026-05-22 16:58:11 -04:00
< button className = "btn primary" onClick = { function ( ) { navigate ( 'upload' ) ; } } > < Icon name = "upload" / > Upload < / button >
2026-05-22 08:15:36 -04:00
< / div >
2026-05-22 10:05:54 -04:00
{ assets . length === 0 ? (
< div style = { { padding : 60 , textAlign : 'center' , color : 'var(--text-3)' } } > No assets match this filter . < / div >
) : view === 'grid' ? (
2026-05-22 08:15:36 -04:00
< div className = "library-grid" >
2026-05-22 23:52:30 -04:00
{ assets . map ( function ( a ) {
return < AssetCard key = { a . id } asset = { a }
onOpen = { function ( ) { onOpenAsset ( a ) ; } }
onContextMenu = { function ( e ) { openCtx ( a , e ) ; } } / > ;
} ) }
2026-05-22 08:15:36 -04:00
< / div >
) : (
< div className = "library-list" >
< div className = "list-row head" >
2026-05-22 10:05:54 -04:00
< div > < / div > < div > Name < / div > < div > Duration < / div > < div > Resolution < / div > < div > Codec < / div > < div > Size < / div > < div > Updated < / div > < div > < / div >
2026-05-22 08:15:36 -04:00
< / div >
2026-05-22 16:58:11 -04:00
{ assets . map ( function ( a ) {
return (
2026-05-22 23:52:30 -04:00
< div key = { a . id } className = "list-row" onClick = { function ( ) { onOpenAsset ( a ) ; } } onContextMenu = { function ( e ) { openCtx ( a , e ) ; } } style = { { cursor : 'pointer' } } >
2026-05-22 16:58:11 -04:00
< div className = "thumb" > < AssetThumb asset = { a } / > < / div >
< div >
< div className = "name" > { a . name } < / div >
< div style = { { display : 'flex' , gap : 6 , marginTop : 2 } } >
< StatusDot status = { a . status } / >
< span style = { { fontSize : 11 , color : 'var(--text-3)' } } > { a . status } < / span >
< / div >
2026-05-22 08:15:36 -04:00
< / div >
2026-05-22 16:58:11 -04:00
< div className = "col-sub" > { a . duration } < / div >
< div className = "col-sub" > { a . res } < / div >
< div className = "col-sub" > { a . codec || '—' } < / div >
< div className = "col-sub" > { a . size } < / div >
< div className = "col-sub" > { a . updated } < / div >
2026-05-22 23:52:30 -04:00
< button className = "icon-btn" onClick = { function ( e ) { e . stopPropagation ( ) ; openCtx ( a , e ) ; } } > < Icon name = "more" / > < / button >
2026-05-22 08:15:36 -04:00
< / div >
2026-05-22 16:58:11 -04:00
) ;
} ) }
2026-05-22 08:15:36 -04:00
< / div >
) }
< / div >
2026-05-22 23:52:30 -04:00
{ ctxMenu && (
< AssetContextMenu
asset = { ctxMenu . asset }
x = { ctxMenu . x }
y = { ctxMenu . y }
bins = { BINS }
onClose = { function ( ) { setCtxMenu ( null ) ; } }
onChanged = { refreshAssets }
onOpen = { function ( ) { onOpenAsset ( ctxMenu . asset ) ; } }
/ >
) }
< / div >
) ;
}
function AssetContextMenu ( { asset , x , y , bins , onClose , onChanged , onOpen } ) {
const ref = React . useRef ( null ) ;
// Pin the menu inside the viewport even if the user right-clicked near
// the bottom-right edge of the grid.
const [ pos , setPos ] = React . useState ( { left : x , top : y } ) ;
React . useLayoutEffect ( ( ) => {
if ( ! ref . current ) return ;
const r = ref . current . getBoundingClientRect ( ) ;
const margin = 8 ;
let nx = x , ny = y ;
if ( x + r . width + margin > window . innerWidth ) nx = window . innerWidth - r . width - margin ;
if ( y + r . height + margin > window . innerHeight ) ny = window . innerHeight - r . height - margin ;
setPos ( { left : Math . max ( margin , nx ) , top : Math . max ( margin , ny ) } ) ;
} , [ x , y ] ) ;
const rename = function ( ) {
onClose ( ) ;
const next = prompt ( 'Rename asset' , asset . display _name || asset . name || '' ) ;
if ( next == null ) return ;
const trimmed = next . trim ( ) ;
if ( ! trimmed || trimmed === ( asset . display _name || asset . name ) ) return ;
window . ZAMPP _API . fetch ( '/assets/' + asset . id , { method : 'PATCH' , body : JSON . stringify ( { display _name : trimmed } ) } )
. then ( onChanged )
. catch ( function ( e ) { alert ( 'Rename failed: ' + e . message ) ; } ) ;
} ;
const moveToBin = function ( binId ) {
onClose ( ) ;
window . ZAMPP _API . fetch ( '/assets/' + asset . id , { method : 'PATCH' , body : JSON . stringify ( { bin _id : binId } ) } )
. then ( onChanged )
. catch ( function ( e ) { alert ( 'Move failed: ' + e . message ) ; } ) ;
} ;
const copyId = function ( ) {
onClose ( ) ;
if ( navigator . clipboard ) navigator . clipboard . writeText ( asset . id ) . catch ( function ( ) { } ) ;
} ;
const remove = function ( ) {
onClose ( ) ;
if ( ! confirm ( 'Delete "' + ( asset . display _name || asset . name ) + '" permanently?\nThis removes the database row and the S3 objects.\nThis cannot be undone.' ) ) return ;
window . ZAMPP _API . fetch ( '/assets/' + asset . id + '?hard=true' , { method : 'DELETE' } )
. then ( onChanged )
. catch ( function ( e ) { alert ( 'Delete failed: ' + e . message ) ; } ) ;
} ;
return (
< div ref = { ref } className = "ctx-menu" style = { { left : pos . left , top : pos . top } }
onClick = { function ( e ) { e . stopPropagation ( ) ; } }
onContextMenu = { function ( e ) { e . preventDefault ( ) ; e . stopPropagation ( ) ; } } >
< div className = "ctx-header" > { asset . display _name || asset . name } < / div >
< button onClick = { function ( ) { onClose ( ) ; onOpen ( ) ; } } > < Icon name = "play" size = { 11 } / > Open < / button >
< button onClick = { rename } > < Icon name = "edit" size = { 11 } / > Rename … < / button >
< div className = "ctx-divider" / >
{ ( bins && bins . length > 0 ) ? (
< >
< div className = "ctx-section-label" > Move to bin < / div >
2026-05-23 00:08:59 -04:00
{ bins
. filter ( function ( b ) { return ! asset . project _id || b . project _id === asset . project _id ; } )
. slice ( 0 , 10 )
. map ( function ( b ) {
const isCurrent = asset . bin _id === b . id ;
return (
< button key = { b . id } onClick = { function ( ) { moveToBin ( b . id ) ; } } disabled = { isCurrent }
title = { b . project _name ? 'in ' + b . project _name : '' } >
< Icon name = "folder" size = { 11 } / > { b . name } { isCurrent && < span style = { { marginLeft : 'auto' , fontSize : 10 , color : 'var(--text-3)' } } > current < / span > }
< / button >
) ;
} ) }
2026-05-22 23:52:30 -04:00
{ asset . bin _id && (
< button onClick = { function ( ) { moveToBin ( null ) ; } } >
< Icon name = "x" size = { 11 } / > Remove from bin
< / button >
) }
< / >
) : (
2026-05-23 00:08:59 -04:00
< div className = "ctx-empty" > No bins — create one inside a project < / div >
2026-05-22 23:52:30 -04:00
) }
< div className = "ctx-divider" / >
< button onClick = { copyId } > < Icon name = "library" size = { 11 } / > Copy asset ID < / button >
< button className = "danger" onClick = { remove } > < Icon name = "trash" size = { 11 } / > Delete permanently < / button >
2026-05-22 08:15:36 -04:00
< / div >
) ;
}
2026-05-22 23:52:30 -04:00
function AssetCard ( { asset , onOpen , onContextMenu } ) {
2026-05-22 16:58:11 -04:00
const [ hoverStream , setHoverStream ] = React . useState ( null ) ;
const [ hovered , setHovered ] = React . useState ( false ) ;
const timerRef = React . useRef ( null ) ;
const hlsRef = React . useRef ( null ) ;
const videoRef = React . useRef ( null ) ;
const handleMouseEnter = function ( ) {
timerRef . current = setTimeout ( function ( ) {
setHovered ( true ) ;
2026-05-22 17:18:56 -04:00
if ( ! hoverStream ) {
2026-05-22 16:58:11 -04:00
window . ZAMPP _API . fetch ( '/assets/' + asset . id + '/stream' )
. then ( function ( r ) { if ( r && r . url ) setHoverStream ( r ) ; } )
. catch ( function ( ) { } ) ;
}
} , 350 ) ;
} ;
const handleMouseLeave = function ( ) {
clearTimeout ( timerRef . current ) ;
setHovered ( false ) ;
} ;
// HLS wiring
React . useEffect ( function ( ) {
if ( ! hovered || ! hoverStream || hoverStream . type !== 'hls' || ! videoRef . current ) return ;
if ( ! window . Hls ) return ;
hlsRef . current = new window . Hls ( { maxBufferLength : 10 } ) ;
hlsRef . current . loadSource ( hoverStream . url ) ;
hlsRef . current . attachMedia ( videoRef . current ) ;
return function ( ) {
if ( hlsRef . current ) { hlsRef . current . destroy ( ) ; hlsRef . current = null ; }
} ;
} , [ hovered , hoverStream ] ) ;
const showVideo = hovered && hoverStream ;
2026-05-22 08:15:36 -04:00
return (
2026-05-22 23:52:30 -04:00
< div className = "asset-card" onClick = { onOpen } onContextMenu = { onContextMenu } onMouseEnter = { handleMouseEnter } onMouseLeave = { handleMouseLeave } >
2026-05-22 16:58:11 -04:00
< div style = { { position : 'relative' } } >
< AssetThumb asset = { asset } / >
{ showVideo && (
< video
key = { hoverStream . url }
ref = { videoRef }
src = { hoverStream . type !== 'hls' ? hoverStream . url : undefined }
autoPlay
muted
loop
playsInline
style = { {
position : 'absolute' ,
inset : 0 ,
width : '100%' ,
height : '100%' ,
objectFit : 'cover' ,
borderRadius : 6 ,
} }
/ >
) }
< / div >
2026-05-22 08:15:36 -04:00
< div className = "thumb-status" >
2026-05-22 10:05:54 -04:00
{ asset . status === 'live' && < span className = "badge live" > LIVE < / span > }
{ asset . status === 'processing' && < span className = "badge warning" > Processing < / span > }
{ asset . status === 'error' && < span className = "badge danger" > Error < / span > }
2026-05-22 08:15:36 -04:00
< / div >
2026-05-22 10:05:54 -04:00
{ ( asset . type === 'video' || ! asset . type ) && asset . duration !== '—' && < div className = "thumb-duration" > { asset . duration } < / div > }
2026-05-22 08:15:36 -04:00
< div className = "meta" >
< div className = "name" > { asset . name } < / div >
< div className = "sub" >
< span > { asset . res } < / span >
< span > · < / span >
< span > { asset . size } < / span >
< / div >
< / div >
< / div >
) ;
}
function binIcon ( name ) {
2026-05-22 10:05:54 -04:00
return { grid : 'library' , live : 'record' , film : 'film' , proxy : 'proxy' , audio : 'audio' , package : 'package' } [ name ] || 'folder' ;
2026-05-22 08:15:36 -04:00
}
window . Library = Library ;
window . AssetCard = AssetCard ;