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-23 00:27:23 -04:00
const PROJECTS = window . ZAMPP _DATA ? . PROJECTS || [ ] ;
const [ bins , setBins ] = React . useState ( window . ZAMPP _DATA ? . BINS || [ ] ) ;
const BINS = bins ; // legacy local name; keep so the rest of the function reads unchanged
// Re-fetch bins on mount + whenever the open project changes; surfaces
// every-project bins when the global view is on, project-scoped otherwise.
React . useEffect ( ( ) => {
const qs = openProject ? '?project_id=' + openProject . id : '' ;
window . ZAMPP _API . fetch ( '/bins' + qs )
. then ( list => {
const normalized = ( list || [ ] ) . map ( b => ( {
... b ,
count : b . asset _count != null ? b . asset _count : ( b . count || 0 ) ,
icon : b . type || 'grid' ,
} ) ) ;
if ( ! openProject ) window . ZAMPP _DATA . BINS = normalized ;
setBins ( normalized ) ;
} )
. catch ( ( ) => { } ) ;
} , [ openProject ] ) ;
const createBin = ( ) => {
2026-05-23 09:02:09 -04:00
if ( ! openProject ) { window . alert ( 'Open a project first (Projects → click a project), then create a bin inside it.' ) ; return ; }
setNewBinName ( '' ) ; setCreatingBin ( true ) ;
} ;
const submitBin = ( name ) => {
if ( ! name || ! name . trim ( ) ) { setCreatingBin ( false ) ; return ; }
setCreatingBin ( false ) ;
2026-05-23 00:27:23 -04:00
window . ZAMPP _API . fetch ( '/bins' , {
method : 'POST' ,
body : JSON . stringify ( { project _id : openProject . id , name : name . trim ( ) } ) ,
} )
. then ( ( ) => {
window . ZAMPP _API . fetch ( '/bins?project_id=' + openProject . id )
. then ( list => setBins ( ( list || [ ] ) . map ( b => ( { ... b , count : b . asset _count || 0 , icon : b . type || 'grid' } ) ) ) ) ;
} )
. catch ( e => window . alert ( 'Could not create bin: ' + e . message ) ) ;
} ;
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 }
2026-05-23 09:02:09 -04:00
const [ renamingAsset , setRenamingAsset ] = React . useState ( null ) ;
const [ creatingBin , setCreatingBin ] = React . useState ( false ) ;
const [ newBinName , setNewBinName ] = React . useState ( '' ) ;
2026-05-24 13:27:24 -04:00
const [ draggingAssetId , setDraggingAssetId ] = React . useState ( null ) ;
const [ recentlyMovedId , setRecentlyMovedId ] = React . useState ( null ) ;
// Rename project state
const [ renamingProject , setRenamingProject ] = React . useState ( null ) ;
2026-05-22 23:52:30 -04:00
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 ] ) ;
2026-05-23 14:52:04 -04:00
// Auto-refresh: poll the library while it's open so live recordings flip
// to 'ready' (with thumbnail) without a manual reload. Speed up while
// anything is mid-flight so the operator sees the transition right away.
const hasLive = React . useMemo (
( ) => allAssets . some ( a => a . status === 'live' || a . status === 'processing' ) ,
[ allAssets ]
) ;
React . useEffect ( ( ) => {
const tick = hasLive ? 4000 : 15000 ;
const id = setInterval ( refreshAssets , tick ) ;
const onAssetsChanged = ( ) => refreshAssets ( ) ;
window . addEventListener ( 'df:assets-changed' , onAssetsChanged ) ;
return ( ) => {
clearInterval ( id ) ;
window . removeEventListener ( 'df:assets-changed' , onAssetsChanged ) ;
} ;
} , [ hasLive , refreshAssets ] ) ;
2026-05-22 23:52:30 -04:00
// 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-24 13:27:24 -04:00
// Drag-and-drop: asset → bin
const onAssetDragStart = function ( assetId , e ) {
e . dataTransfer . setData ( 'text/plain' , assetId ) ;
e . dataTransfer . effectAllowed = 'move' ;
setDraggingAssetId ( assetId ) ;
} ;
const onBinDragOver = function ( e ) {
e . preventDefault ( ) ;
e . dataTransfer . dropEffect = 'move' ;
} ;
const onBinDrop = function ( binId , e ) {
e . preventDefault ( ) ;
setDraggingAssetId ( null ) ;
var assetId = e . dataTransfer . getData ( 'text/plain' ) ;
if ( ! assetId ) return ;
window . ZAMPP _API . fetch ( '/assets/' + assetId , { method : 'PATCH' , body : JSON . stringify ( { bin _id : binId } ) } )
. then ( function ( ) {
setRecentlyMovedId ( assetId ) ;
refreshAssets ( ) ;
setTimeout ( function ( ) { setRecentlyMovedId ( null ) ; } , 2000 ) ;
} )
. catch ( function ( e2 ) { alert ( 'Move failed: ' + e2 . message ) ; } ) ;
} ;
const onBinDragLeave = function ( e ) {
// Remove highlight — handled via CSS :hover + drag state
} ;
// Project rename
const renameProject = function ( p ) { setRenamingProject ( p ) ; } ;
// Close drag state when drag ends anywhere
React . useEffect ( function ( ) {
if ( ! draggingAssetId ) return ;
var onEnd = function ( ) { setDraggingAssetId ( null ) ; } ;
window . addEventListener ( 'dragend' , onEnd ) ;
return function ( ) { window . removeEventListener ( 'dragend' , onEnd ) ; } ;
} , [ draggingAssetId ] ) ;
// Project context menu state
var [ projectCtx , setProjectCtx ] = React . useState ( null ) ;
React . useEffect ( function ( ) {
if ( ! projectCtx ) return ;
var close = function ( ) { setProjectCtx ( null ) ; } ;
window . addEventListener ( 'click' , close ) ;
return function ( ) { window . removeEventListener ( 'click' , close ) ; } ;
} , [ projectCtx ] ) ;
var openProjectCtx = function ( p , e ) {
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
setProjectCtx ( { project : p , x : e . clientX , y : e . clientY } ) ;
} ;
2026-05-23 09:02:09 -04:00
const [ selectedBinId , setSelectedBinId ] = React . useState ( null ) ;
// Clear bin filter on project change so a stale id doesn't hide everything.
React . useEffect ( ( ) => { setSelectedBinId ( null ) ; } , [ openProject ? . id ] ) ;
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-23 09:02:09 -04:00
if ( selectedBinId ) assets = assets . filter ( function ( a ) { return a . bin _id === selectedBinId ; } ) ;
2026-05-22 08:15:36 -04:00
2026-05-23 09:02:09 -04:00
const activeBin = selectedBinId ? BINS . find ( b => b . id === selectedBinId ) : null ;
const displayTitle = activeBin
? ( openProject ? openProject . name + ' · ' : '' ) + activeBin . name
: ( 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' } }
2026-05-24 13:27:24 -04:00
onClick = { function ( ) { navigate ( 'projects' ) ; } }
onContextMenu = { function ( e ) { openProjectCtx ( p , e ) ; } } >
2026-05-22 16:58:11 -04:00
< 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-23 00:27:23 -04:00
< div >
< div style = { { display : 'flex' , alignItems : 'center' } } >
< h4 style = { { flex : 1 , margin : 0 } } > Bins < / h4 >
< button className = "icon-btn" onClick = { createBin }
title = { openProject ? 'Create bin in this project' : 'Open a project to create a bin' }
style = { { opacity : openProject ? 1 : 0.5 } } >
< Icon name = "plus" size = { 11 } / >
< / button >
< / div >
< div className = "rail-list" >
2026-05-23 09:02:09 -04:00
{ creatingBin && (
< div style = { { padding : '4px 6px' , display : 'flex' , gap : 4 , alignItems : 'center' } } >
< input
className = "field-input"
autoFocus
value = { newBinName }
onChange = { function ( e ) { setNewBinName ( e . target . value ) ; } }
onKeyDown = { function ( e ) {
if ( e . key === 'Enter' ) submitBin ( newBinName ) ;
if ( e . key === 'Escape' ) { setCreatingBin ( false ) ; }
} }
onBlur = { function ( ) { submitBin ( newBinName ) ; } }
placeholder = "Bin name"
style = { { fontSize : 12 , height : 26 , padding : '0 6px' , flex : 1 } }
/ >
< / div >
) }
{ ! creatingBin && BINS . length === 0 ? (
2026-05-23 00:27:23 -04:00
< div style = { { fontSize : 11 , color : 'var(--text-3)' , padding : '6px 8px' , fontStyle : 'italic' } } >
{ openProject ? 'No bins yet — click + to create one.' : 'Open a project to manage bins.' }
< / div >
) : BINS . map ( function ( b ) {
2026-05-23 09:02:09 -04:00
const isActive = selectedBinId === b . id ;
2026-05-24 13:27:24 -04:00
const isDragTarget = draggingAssetId !== null ;
2026-05-23 00:27:23 -04:00
return (
2026-05-23 09:02:09 -04:00
< div key = { b . id }
2026-05-24 13:27:24 -04:00
className = { 'rail-item' + ( isActive ? ' active' : '' ) + ( isDragTarget ? ' droppable' : '' ) }
2026-05-23 09:02:09 -04:00
onClick = { function ( ) { setSelectedBinId ( isActive ? null : b . id ) ; } }
2026-05-24 13:27:24 -04:00
onDragOver = { onBinDragOver }
onDrop = { function ( e ) { onBinDrop ( b . id , e ) ; } }
onDragLeave = { onBinDragLeave }
2026-05-23 09:02:09 -04:00
style = { { cursor : 'pointer' } }
2026-05-24 13:27:24 -04:00
title = { isActive ? 'Click to clear bin filter' : 'Filter to this bin' }
2026-05-23 00:27:23 -04:00
< 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 08:15:36 -04:00
< / div >
2026-05-23 00:27:23 -04:00
< / div >
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 ) ; } }
2026-05-24 13:27:24 -04:00
onContextMenu = { function ( e ) { openCtx ( a , e ) ; } }
onDragStart = { function ( e ) { onAssetDragStart ( a . id , e ) ; } }
draggable = { true } / > ;
2026-05-22 23:52:30 -04:00
} ) }
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-24 13:27:24 -04:00
< div key = { a . id } className = "list-row" onClick = { function ( ) { onOpenAsset ( a ) ; } } onContextMenu = { function ( e ) { openCtx ( a , e ) ; } } style = { { cursor : 'pointer' } }
draggable = "true" onDragStart = { function ( e ) { onAssetDragStart ( a . id , e ) ; } } >
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 ) ; } }
2026-05-23 09:02:09 -04:00
onRename = { function ( a ) { setCtxMenu ( null ) ; setRenamingAsset ( a ) ; } }
/ >
) }
{ renamingAsset && (
< RenameAssetModal
asset = { renamingAsset }
onClose = { function ( ) { setRenamingAsset ( null ) ; } }
onSaved = { function ( ) { setRenamingAsset ( null ) ; refreshAssets ( ) ; } }
2026-05-22 23:52:30 -04:00
/ >
) }
2026-05-24 13:27:24 -04:00
{ projectCtx && (
< ProjectContextMenu
project = { projectCtx . project }
x = { projectCtx . x }
y = { projectCtx . y }
onClose = { function ( ) { setProjectCtx ( null ) ; } }
onRename = { function ( p ) { setProjectCtx ( null ) ; setRenamingProject ( p ) ; } }
/ >
) }
{ renamingProject && (
< RenameProjectModal
project = { renamingProject }
onClose = { function ( ) { setRenamingProject ( null ) ; } }
onSaved = { function ( ) { setRenamingProject ( null ) ; } }
/ >
) }
2026-05-22 23:52:30 -04:00
< / div >
) ;
}
2026-05-23 09:02:09 -04:00
function AssetContextMenu ( { asset , x , y , bins , onClose , onChanged , onOpen , onRename } ) {
2026-05-22 23:52:30 -04:00
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 ] ) ;
2026-05-23 09:02:09 -04:00
const rename = function ( ) { if ( onRename ) onRename ( asset ) ; else onClose ( ) ; } ;
2026-05-22 23:52:30 -04:00
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-24 13:27:24 -04:00
function AssetCard ( { asset , onOpen , onContextMenu , onDragStart , draggable } ) {
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-24 13:27:24 -04:00
< div className = "asset-card" onClick = { onOpen } onContextMenu = { onContextMenu } onMouseEnter = { handleMouseEnter } onMouseLeave = { handleMouseLeave }
draggable = { draggable } onDragStart = { onDragStart } >
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 >
) ;
}
2026-05-24 13:27:24 -04:00
function ProjectContextMenu ( { project , x , y , onClose , onRename } ) {
var ref = React . useRef ( null ) ;
var [ pos , setPos ] = React . useState ( { left : x , top : y } ) ;
React . useLayoutEffect ( function ( ) {
if ( ! ref . current ) return ;
var r = ref . current . getBoundingClientRect ( ) ;
var margin = 8 ;
var 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 ] ) ;
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" > { project . name } < / div >
< button onClick = { function ( ) { onClose ( ) ; onRename ( project ) ; } } > < Icon name = "edit" size = { 11 } / > Rename project … < / button >
< button onClick = { function ( ) { onClose ( ) ; window . ZAMPP _API . fetch ( '/projects/' + project . id , { method : 'DELETE' } ) . then ( function ( ) { window . location . reload ( ) ; } ) . catch ( function ( e ) { alert ( 'Delete failed: ' + e . message ) ; } ) ; } } className = "danger" > < Icon name = "trash" size = { 11 } / > Delete project < / button >
< / div >
) ;
}
2026-05-22 08:15:36 -04:00
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
}
2026-05-23 09:02:09 -04:00
function RenameAssetModal ( { asset , onClose , onSaved } ) {
const [ name , setName ] = React . useState ( asset . display _name || asset . name || '' ) ;
const [ saving , setSaving ] = React . useState ( false ) ;
const [ err , setErr ] = React . useState ( null ) ;
const original = asset . display _name || asset . name || '' ;
const submit = function ( ) {
const trimmed = name . trim ( ) ;
if ( ! trimmed || trimmed === original ) { onClose ( ) ; return ; }
setSaving ( true ) ; setErr ( null ) ;
window . ZAMPP _API . fetch ( '/assets/' + asset . id , { method : 'PATCH' , body : JSON . stringify ( { display _name : trimmed } ) } )
. then ( onSaved )
. catch ( function ( e ) { setSaving ( false ) ; setErr ( e . message ) ; } ) ;
} ;
return (
< div className = "modal-backdrop" onClick = { onClose } >
< div className = "modal" style = { { width : 420 } } onClick = { function ( e ) { e . stopPropagation ( ) ; } } >
< div className = "modal-head" >
< div style = { { fontSize : 15 , fontWeight : 600 } } > Rename asset < / div >
< button className = "icon-btn" onClick = { onClose } > < Icon name = "x" / > < / button >
< / div >
< div className = "modal-body" >
< div className = "field" >
< label className = "field-label" > Display name < / label >
< input className = "field-input" autoFocus value = { name }
onChange = { function ( e ) { setName ( e . target . value ) ; } }
onKeyDown = { function ( e ) { if ( e . key === 'Enter' ) submit ( ) ; if ( e . key === 'Escape' ) onClose ( ) ; } } / >
< / div >
{ err && < div style = { { fontSize : 12 , color : 'var(--danger)' , marginTop : 4 } } > { err } < / div > }
< / div >
< div className = "modal-foot" >
< button className = "btn ghost sm" onClick = { onClose } > Cancel < / button >
< button className = "btn primary sm" onClick = { submit } disabled = { saving || ! name . trim ( ) } > { saving ? 'Saving…' : 'Rename' } < / button >
< / div >
< / div >
< / div >
) ;
}
2026-05-22 08:15:36 -04:00
window . Library = Library ;
window . AssetCard = AssetCard ;