Projects: - Per-row 3-dot menu in list view: Open / Rename / Delete (PATCH + DELETE) - ProjectCard's bottom bar now shows real ready/in-flight/error counts for the project's assets instead of fake 70/20 segments - After mutations, project list refreshes from /projects + recomputes asset counts client-side Bins: - GET /api/v1/bins now returns every bin across every project when no project_id is supplied; result rows include project_name + asset_count - Asset right-click 'Move to bin' filters to bins in the same project as the asset and surfaces project_name as a tooltip Jobs: - 'Retry all failed' button in the header appears when there are failed jobs and POSTs /retry for each one in parallel - Failed-row error message now clips with title= tooltip so 3KB ffmpeg stderr doesn't blow out the row layout window.PROJECT_COLORS exposed for cross-screen access.
355 lines
15 KiB
JavaScript
355 lines
15 KiB
JavaScript
// screens-library.jsx
|
|
|
|
function Library({ navigate, onOpenAsset, openProject }) {
|
|
const { BINS, PROJECTS } = window.ZAMPP_DATA;
|
|
const [view, setView] = React.useState('grid');
|
|
const [filter, setFilter] = React.useState('all');
|
|
const [search, setSearch] = React.useState(window._dfPendingSearch || '');
|
|
React.useEffect(() => { delete window._dfPendingSearch; }, []);
|
|
// 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 });
|
|
};
|
|
|
|
let assets = openProject
|
|
? allAssets.filter(function(a) { return a.project_id === openProject.id; })
|
|
: allAssets;
|
|
const ALL_ASSETS = allAssets;
|
|
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()); });
|
|
|
|
const displayTitle = openProject ? openProject.name : 'All Assets';
|
|
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;
|
|
|
|
return (
|
|
<div className="library-layout">
|
|
<aside className="library-rail">
|
|
<div>
|
|
<h4>Projects</h4>
|
|
<div className="rail-list">
|
|
<div className={`rail-item ${!openProject ? 'active' : ''}`} onClick={function() { navigate('library'); }} style={{ cursor: 'pointer' }}>
|
|
<Icon name="library" size={13} className="rail-icon" />
|
|
<span>All projects</span>
|
|
<span className="rail-count">{ALL_ASSETS.length}</span>
|
|
</div>
|
|
{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>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
{BINS.length > 0 && (
|
|
<div>
|
|
<h4>Bins</h4>
|
|
<div className="rail-list">
|
|
{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>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<h4>Smart filters</h4>
|
|
<div className="rail-list">
|
|
{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 24h</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>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<div className="library-main">
|
|
<div className="library-toolbar">
|
|
<div className="toolbar-title">{displayTitle}</div>
|
|
<span className="count">· {assets.length} assets</span>
|
|
<div style={{ flex: 1 }} />
|
|
<div className="search" style={{ width: 220 }}>
|
|
<Icon name="search" className="search-icon" />
|
|
<input value={search} onChange={function(e) { setSearch(e.target.value); }} placeholder="Filter assets…" />
|
|
</div>
|
|
<div className="tab-group">
|
|
{['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>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="tab-group">
|
|
<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>
|
|
</div>
|
|
<button className="btn primary" onClick={function() { navigate('upload'); }}><Icon name="upload" />Upload</button>
|
|
</div>
|
|
|
|
{assets.length === 0 ? (
|
|
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>No assets match this filter.</div>
|
|
) : view === 'grid' ? (
|
|
<div className="library-grid">
|
|
{assets.map(function(a) {
|
|
return <AssetCard key={a.id} asset={a}
|
|
onOpen={function() { onOpenAsset(a); }}
|
|
onContextMenu={function(e) { openCtx(a, e); }} />;
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="library-list">
|
|
<div className="list-row head">
|
|
<div></div><div>Name</div><div>Duration</div><div>Resolution</div><div>Codec</div><div>Size</div><div>Updated</div><div></div>
|
|
</div>
|
|
{assets.map(function(a) {
|
|
return (
|
|
<div key={a.id} className="list-row" onClick={function() { onOpenAsset(a); }} onContextMenu={function(e) { openCtx(a, e); }} style={{ cursor: 'pointer' }}>
|
|
<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>
|
|
</div>
|
|
<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>
|
|
<button className="icon-btn" onClick={function(e) { e.stopPropagation(); openCtx(a, e); }}><Icon name="more" /></button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{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>
|
|
{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>
|
|
);
|
|
})}
|
|
{asset.bin_id && (
|
|
<button onClick={function() { moveToBin(null); }}>
|
|
<Icon name="x" size={11} />Remove from bin
|
|
</button>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="ctx-empty">No bins — create one inside a project</div>
|
|
)}
|
|
<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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AssetCard({ asset, onOpen, onContextMenu }) {
|
|
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);
|
|
if (!hoverStream) {
|
|
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;
|
|
|
|
return (
|
|
<div className="asset-card" onClick={onOpen} onContextMenu={onContextMenu} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
|
<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>
|
|
<div className="thumb-status">
|
|
{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>}
|
|
</div>
|
|
{(asset.type === 'video' || !asset.type) && asset.duration !== '—' && <div className="thumb-duration">{asset.duration}</div>}
|
|
<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) {
|
|
return { grid: 'library', live: 'record', film: 'film', proxy: 'proxy', audio: 'audio', package: 'package' }[name] || 'folder';
|
|
}
|
|
|
|
window.Library = Library;
|
|
window.AssetCard = AssetCard;
|