2026-04-07 21:58:23 -04:00
<!DOCTYPE html>
< html lang = "en" >
< head >
2026-05-16 13:04:45 -04:00
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > Library — Wild Dragon< / title >
< link rel = "preconnect" href = "https://fonts.googleapis.com" >
< link rel = "preconnect" href = "https://fonts.gstatic.com" crossorigin >
< link href = "https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap" rel = "stylesheet" >
< link rel = "stylesheet" href = "css/common.css" >
< style >
/* ── Library layout ── */
.library-shell {
display: flex;
flex: 1;
overflow: hidden;
height: 100%;
}
/* Bin tree panel */
.bin-panel {
width: 220px;
flex-shrink: 0;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
background: var(--bg-panel);
overflow: hidden;
}
.bin-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.bin-panel-title {
font-size: var(--text-xs);
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.bin-tree {
flex: 1;
overflow-y: auto;
padding: var(--sp-2) 0;
}
.bin-tree::-webkit-scrollbar { width: 4px; }
.bin-tree::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.bin-item {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-4);
font-size: var(--text-sm);
color: var(--text-secondary);
cursor: pointer;
transition: color var(--t-fast), background var(--t-fast);
white-space: nowrap;
overflow: hidden;
}
.bin-item:hover { color: var(--text-primary); background: var(--bg-hover); }
.bin-item.active {
color: var(--accent);
background: var(--accent-subtle);
font-weight: 500;
}
.bin-item svg { width: 14px; height: 14px; flex-shrink: 0; opacity: 0.6; }
.bin-item.active svg { opacity: 1; }
.bin-item span { overflow: hidden; text-overflow: ellipsis; }
/* Asset grid */
.asset-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.asset-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-3) var(--sp-5);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
gap: var(--sp-3);
}
.asset-toolbar-left { display: flex; align-items: center; gap: var(--sp-3); }
.asset-toolbar-right { display: flex; align-items: center; gap: var(--sp-2); }
.asset-count {
font-size: var(--text-xs);
color: var(--text-tertiary);
white-space: nowrap;
}
.search-input {
width: 220px;
height: 28px;
padding: 0 var(--sp-3);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--r-md);
color: var(--text-primary);
font-size: var(--text-sm);
outline: none;
transition: border-color var(--t-fast);
}
.search-input:focus { border-color: var(--accent-border); }
.search-input::placeholder { color: var(--text-tertiary); }
.asset-grid-wrap {
flex: 1;
overflow-y: auto;
padding: var(--sp-5);
}
.asset-grid-wrap::-webkit-scrollbar { width: 5px; }
.asset-grid-wrap::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
.asset-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(188px, 1fr));
gap: var(--sp-4);
}
/* Asset card */
.asset-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
overflow: hidden;
cursor: pointer;
transition: border-color var(--t-fast), background var(--t-fast);
container-type: inline-size;
}
.asset-card:hover {
border-color: var(--border-strong);
background: var(--bg-raised);
}
.asset-card.selected {
border-color: var(--accent-border);
background: var(--accent-subtle);
}
/* Thumbnail */
.asset-thumb {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: var(--bg-base);
overflow: hidden;
}
.asset-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity 300ms ease-out;
opacity: 0;
}
.asset-thumb img.loaded { opacity: 1; }
.asset-thumb-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
}
.asset-thumb-placeholder svg { width: 28px; height: 28px; }
.asset-thumb-overlay {
position: absolute;
top: var(--sp-2);
left: var(--sp-2);
right: var(--sp-2);
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--sp-1);
}
.asset-duration {
font-size: 10px;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: oklch(93% 0.008 250);
background: oklch(8% 0.010 250 / 0.75);
padding: 1px 5px;
border-radius: 3px;
}
/* Asset metadata */
.asset-meta {
padding: var(--sp-3);
display: flex;
flex-direction: column;
gap: var(--sp-1);
}
.asset-name {
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-info {
display: flex;
align-items: center;
justify-content: space-between;
}
.asset-type {
font-size: var(--text-xs);
color: var(--text-tertiary);
}
/* Drag-over overlay */
.drop-overlay {
position: fixed;
inset: 0;
2026-05-17 14:48:23 -04:00
background: oklch(55% 0.20 266 / 0.09);
2026-05-16 13:04:45 -04:00
border: 2px dashed var(--accent);
pointer-events: none;
opacity: 0;
transition: opacity var(--t-fast);
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
}
.drop-overlay.active { opacity: 1; }
.drop-overlay-label {
font-size: var(--text-xl);
font-weight: 500;
color: var(--accent);
}
/* Project selector in topbar */
.project-select {
height: 28px;
font-size: var(--text-sm);
padding: 0 24px 0 var(--sp-3);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--r-md);
color: var(--text-primary);
min-width: 160px;
}
/* Asset detail popover */
.asset-actions {
position: absolute;
top: var(--sp-2);
right: var(--sp-2);
display: none;
gap: var(--sp-1);
}
.asset-card:hover .asset-actions { display: flex; }
.asset-action-btn {
width: 26px;
height: 26px;
border-radius: var(--r-sm);
background: oklch(8% 0.010 250 / 0.75);
border: none;
display: flex;
align-items: center;
justify-content: center;
color: oklch(93% 0.008 250);
cursor: pointer;
transition: background var(--t-fast);
}
.asset-action-btn:hover { background: oklch(8% 0.010 250 / 0.9); }
.asset-action-btn svg { width: 13px; height: 13px; }
2026-05-17 13:10:36 -04:00
.first-splash{position:fixed;inset:0;z-index:60;background:radial-gradient(ellipse at 50% 45%,#1a1d28 0%,#08090d 70%);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:24px;opacity:1;transition:opacity .55s ease-out, visibility .55s}
.first-splash.hidden{opacity:0;visibility:hidden;pointer-events:none}
2026-05-17 18:33:42 -04:00
.first-splash-img{width:min(420px,46vw);aspect-ratio:1963/1236;background-image:url(img/ampp-safe.png);background-size:contain;background-position:center;background-repeat:no-repeat;filter:drop-shadow(0 20px 40px rgba(31,58,208,.15))}
2026-05-17 14:48:23 -04:00
.first-splash-stamp{display:flex;align-items:center;gap:10px;font-size:11px;font-weight:600;letter-spacing:.14em;text-transform:uppercase;color:oklch(55% 0.20 266)}
.first-splash-dot{width:8px;height:8px;background:oklch(55% 0.20 266);border-radius:50%;animation:fsPulse 1.4s ease-in-out infinite}
2026-05-17 13:10:36 -04:00
@keyframes fsPulse{0%,100%{opacity:.35;transform:scale(.9)}50%{opacity:1;transform:scale(1.1)}}
.first-splash-title{font-size:13px;color:var(--text-secondary);letter-spacing:.04em}
2026-05-16 13:04:45 -04:00
< / style >
2026-04-07 21:58:23 -04:00
< / head >
< body >
2026-05-17 13:10:36 -04:00
< div id = "firstSplash" class = "first-splash" aria-hidden = "true" >
< div class = "first-splash-img" > < / div >
< div class = "first-splash-stamp" > < span class = "first-splash-dot" > < / span > < span > AMPP Safe< / span > < / div >
< div class = "first-splash-title" > Z-AMPP — Media Asset Management< / div >
< / div >
2026-05-16 13:04:45 -04:00
< div class = "shell" >
<!-- Sidebar -->
< nav class = "sidebar" aria-label = "Main navigation" >
< div class = "sidebar-brand" >
< div class = "sidebar-brand-mark" >
< svg viewBox = "0 0 16 16" fill = "currentColor" width = "12" height = "12" > < path d = "M8 1L2 5v6l6 4 6-4V5L8 1zm0 2.2L12 6v4l-4 2.7L4 10V6l4-2.8z" / > < / svg >
< / div >
< span class = "sidebar-brand-name" > Wild Dragon< / span >
< / div >
< nav class = "sidebar-nav" >
< a href = "index.html" class = "nav-item active" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < rect x = "1" y = "1" width = "6" height = "6" rx = "1" / > < rect x = "9" y = "1" width = "6" height = "6" rx = "1" / > < rect x = "1" y = "9" width = "6" height = "6" rx = "1" / > < rect x = "9" y = "9" width = "6" height = "6" rx = "1" / > < / svg >
Library
< / a >
< a href = "upload.html" class = "nav-item" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < path d = "M8 11V3M5 6l3-3 3 3" / > < path d = "M2 13h12" / > < / svg >
Ingest
< / a >
< a href = "recorders.html" class = "nav-item" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < rect x = "1" y = "4" width = "10" height = "8" rx = "1" / > < path d = "M11 7l4-2v6l-4-2" / > < / svg >
Recorders
< / a >
< a href = "capture.html" class = "nav-item" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < circle cx = "8" cy = "8" r = "3" / > < circle cx = "8" cy = "8" r = "6.5" / > < / svg >
Capture
< / a >
< a href = "jobs.html" class = "nav-item" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < path d = "M2 4h12M2 8h8M2 12h5" / > < / svg >
Jobs
< / a >
2026-05-17 21:44:15 -04:00
< a href = "#" class = "nav-item" target = "_blank" onclick = "window.open(location.protocol + '//' + location.hostname + ':47435/', '_blank'); return false;" rel = "noopener" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < path d = "M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3" / > < / svg >
Editor
< / a >
2026-05-16 13:04:45 -04:00
< / nav >
< / nav >
<!-- Main -->
< div class = "main" >
<!-- Topbar -->
< header class = "topbar" >
< div class = "topbar-left" >
< span class = "page-title" > Library< / span >
< span class = "topbar-sep" > /< / span >
< select class = "project-select" id = "projectSelect" aria-label = "Select project" >
< option value = "" > No projects< / option >
< / select >
< / div >
< div class = "topbar-right" >
< button class = "btn btn-ghost btn-sm" id = "newProjectBtn" title = "New project" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < path d = "M8 2v12M2 8h12" / > < / svg >
New project
< / button >
< button class = "btn btn-primary btn-sm" id = "uploadBtn" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < path d = "M8 11V3M5 6l3-3 3 3" / > < path d = "M2 13h12" / > < / svg >
Upload
< / button >
< / div >
< / header >
<!-- Library content -->
< div class = "library-shell" >
<!-- Bin panel -->
< div class = "bin-panel" >
< div class = "bin-panel-header" >
< span class = "bin-panel-title" > Bins< / span >
< button class = "btn btn-ghost btn-sm" id = "newBinBtn" title = "New bin" style = "padding:0;width:24px;height:24px;" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < path d = "M8 2v12M2 8h12" / > < / svg >
< / button >
2026-04-07 21:58:23 -04:00
< / div >
2026-05-16 13:04:45 -04:00
< div class = "bin-tree" id = "binTree" >
< div class = "bin-item active" data-bin-id = "" id = "allAssetsItem" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < rect x = "1" y = "4" width = "14" height = "10" rx = "1" / > < path d = "M1 4l3-3h8l3 3" / > < / svg >
< span > All assets< / span >
< / div >
< / div >
< / div >
<!-- Asset area -->
< div class = "asset-area" >
< div class = "asset-toolbar" >
< div class = "asset-toolbar-left" >
< span class = "asset-count" id = "assetCount" > 0 assets< / span >
< / div >
< div class = "asset-toolbar-right" >
< input class = "search-input" id = "searchInput" type = "text" placeholder = "Search assets…" aria-label = "Search assets" >
< / div >
2026-04-07 21:58:23 -04:00
< / div >
2026-05-16 13:04:45 -04:00
< div class = "asset-grid-wrap" id = "assetGridWrap" >
< div id = "assetGrid" class = "asset-grid" > < / div >
< div id = "assetEmpty" class = "empty-state" style = "display:none;" >
< div class = "empty-state-icon" >
< svg viewBox = "0 0 40 40" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < rect x = "4" y = "8" width = "32" height = "26" rx = "2" / > < path d = "M4 14h32" / > < circle cx = "20" cy = "25" r = "6" / > < path d = "M16 25l2 2 4-4" / > < / svg >
2026-04-07 21:58:23 -04:00
< / div >
2026-05-16 13:04:45 -04:00
< div class = "empty-state-title" > No assets yet< / div >
< div class = "empty-state-body" > Upload media to this project or select a different bin.< / div >
< div class = "empty-state-actions" >
< button class = "btn btn-primary btn-sm" id = "emptyUploadBtn" > Upload files< / button >
2026-04-07 21:58:23 -04:00
< / div >
2026-05-16 13:04:45 -04:00
< / div >
2026-05-17 14:48:23 -04:00
< div id = "assetLoading" class = "ampp-loading ampp-loading--sm" style = "display:none;" >
< div class = "ampp-loading-img" > < / div >
< div class = "ampp-loading-label" > < span class = "ampp-loading-dot" > < / span > < span > Loading assets< / span > < / div >
2026-05-16 13:04:45 -04:00
< / div >
< / div >
< / div >
2026-04-07 21:58:23 -04:00
< / div >
2026-05-16 13:04:45 -04:00
< / div >
< / div >
<!-- Drag - to - upload overlay -->
< div class = "drop-overlay" id = "dropOverlay" >
< div class = "drop-overlay-label" > Drop to upload< / div >
< / div >
<!-- Toasts -->
< div class = "toast-container" id = "toastContainer" aria-live = "polite" > < / div >
<!-- New project dialog (inline, not modal) -->
< div class = "slide-overlay" id = "projectOverlay" > < / div >
< div class = "slide-panel" id = "projectPanel" >
< div class = "slide-panel-header" >
< span class = "slide-panel-title" > New project< / span >
< button class = "btn btn-ghost btn-sm" id = "closeProjectPanel" style = "padding:0;width:28px;height:28px;" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < path d = "M3 3l10 10M13 3L3 13" / > < / svg >
< / button >
< / div >
< div class = "slide-panel-body" >
< div class = "form-group" >
< label class = "form-label" for = "newProjectName" > Project name< / label >
< input type = "text" id = "newProjectName" placeholder = "e.g. Evening News 2026-05" >
< / div >
< div class = "form-group" >
< label class = "form-label" for = "newProjectDesc" > Description< / label >
< textarea id = "newProjectDesc" rows = "3" placeholder = "Optional description" > < / textarea >
< / div >
< / div >
< div class = "slide-panel-footer" >
< button class = "btn btn-ghost" id = "cancelProjectBtn" > Cancel< / button >
< button class = "btn btn-primary" id = "saveProjectBtn" > Create project< / button >
< / div >
< / div >
<!-- New bin panel -->
< div class = "slide-overlay" id = "binOverlay" > < / div >
< div class = "slide-panel" id = "binPanel" >
< div class = "slide-panel-header" >
< span class = "slide-panel-title" > New bin< / span >
< button class = "btn btn-ghost btn-sm" id = "closeBinPanel" style = "padding:0;width:28px;height:28px;" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < path d = "M3 3l10 10M13 3L3 13" / > < / svg >
< / button >
< / div >
< div class = "slide-panel-body" >
< div class = "form-group" >
< label class = "form-label" for = "newBinName" > Bin name< / label >
< input type = "text" id = "newBinName" placeholder = "e.g. Interviews" >
< / div >
< / div >
< div class = "slide-panel-footer" >
< button class = "btn btn-ghost" id = "cancelBinBtn" > Cancel< / button >
< button class = "btn btn-primary" id = "saveBinBtn" > Create bin< / button >
< / div >
< / div >
2026-05-17 12:55:36 -04:00
< script src = "js/api.js?v=5" > < / script >
feat(design): broadcast ops console redesign sweep
Confirmed shape brief: ops-console direction, balanced density, blue committed accent, readability emphasized.
Design system: surface scale to 5 steps tinted toward hue 266; text contrast lifted (secondary 62->72%, tertiary 44->52, added text-disabled); borders gained faint variant; status tokens renamed semantically (signal-good/warn/bad/idle); typography upgraded (Inter 400/500/600, JetBrains Mono for numerics, new 2xl/3xl/tc scale steps).
New components: tc-display timecode classes with blue glow; tally-word with good/warn/bad/rec variants; signal-strip flutter bar with modifiers; chip dense monospaced pill; manifest table styles.
Topbar status strip: new js/topbar-strip.js auto-injects a 28px strip on every page with wall clock, page name, live API latency, and system-pulse dot.
Per-page: recorders gain live signal-strip above fps line; capture timecode bumped 38->64px with blue glow; ingest drop zone slimmed 200->88px side-by-side layout so manifest gets the real estate.
2026-05-17 19:04:38 -04:00
< script src = "js/topbar-strip.js?v=1" > < / script >
2026-05-17 21:44:15 -04:00
< script src = "js/preview.js?v=3" > < / script >
2026-05-17 14:48:23 -04:00
< script src = "js/selection.js?v=1" > < / script >
2026-05-16 13:04:45 -04:00
< script >
const state = {
projects: [],
currentProjectId: null,
bins: [],
currentBinId: null,
assets: [],
thumbCache: {},
searchTerm: '',
};
const thumbObserver = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (e.isIntersecting) {
loadThumb(e.target);
thumbObserver.unobserve(e.target);
}
});
}, { rootMargin: '100px' });
async function loadThumb(img) {
const id = img.dataset.assetId;
if (!id) return;
if (state.thumbCache[id]) { setImgSrc(img, state.thumbCache[id]); return; }
try {
const r = await api(`/assets/${id}/thumbnail`);
if (r.success & & r.data?.url) {
state.thumbCache[id] = r.data.url;
setImgSrc(img, r.data.url);
}
} catch (_) {}
}
function setImgSrc(img, src) {
img.src = src;
img.onload = () => img.classList.add('loaded');
}
// ── Init ────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
2026-05-17 13:10:36 -04:00
// First-visit splash. After first dismiss in a session we skip it.
const splash = document.getElementById('firstSplash');
if (splash) {
if (sessionStorage.getItem('splashShown')) {
splash.remove();
} else {
sessionStorage.setItem('splashShown', '1');
setTimeout(() => splash.classList.add('hidden'), 1400);
setTimeout(() => splash.remove(), 2000);
}
}
2026-05-16 13:04:45 -04:00
await loadProjects();
setupDrag();
setupSearch();
2026-05-17 14:48:23 -04:00
// Multi-select bulk actions
if (window.SelectionManager) {
SelectionManager.attach({
getProjectId: () => state.currentProjectId,
getBins: () => state.bins,
getProjects: () => state.projects,
onChange: (info) => {
if (info.action) {
const verb = ({move:'moved',copy:'copied',delete:'deleted'})[info.action] || info.action;
toast(`${info.ok} ${verb}` + (info.fail ? ` · ${info.fail} failed` : ''), '', info.fail ? 'warning' : 'success');
}
loadAssets();
},
});
}
2026-05-16 13:04:45 -04:00
document.getElementById('uploadBtn').onclick = () => location.href = 'upload.html' + (state.currentProjectId ? `?project=${state.currentProjectId}` : '');
document.getElementById('emptyUploadBtn').onclick = () => document.getElementById('uploadBtn').click();
document.getElementById('newProjectBtn').onclick = () => openPanel('project');
document.getElementById('closeProjectPanel').onclick = () => closePanel('project');
document.getElementById('cancelProjectBtn').onclick = () => closePanel('project');
document.getElementById('projectOverlay').onclick = () => closePanel('project');
document.getElementById('saveProjectBtn').onclick = saveProject;
document.getElementById('newBinBtn').onclick = () => openPanel('bin');
document.getElementById('closeBinPanel').onclick = () => closePanel('bin');
document.getElementById('cancelBinBtn').onclick = () => closePanel('bin');
document.getElementById('binOverlay').onclick = () => closePanel('bin');
document.getElementById('saveBinBtn').onclick = saveBin;
document.getElementById('allAssetsItem').onclick = () => selectBin(null);
const params = new URLSearchParams(location.search);
if (params.get('project')) {
document.getElementById('projectSelect').value = params.get('project');
handleProjectChange();
}
});
// ── Projects ──────────────────────────────
async function loadProjects() {
const r = await getProjects();
if (!r.success) return;
state.projects = r.data;
const sel = document.getElementById('projectSelect');
sel.innerHTML = state.projects.length
? state.projects.map(p => `< option value = "${p.id}" > ${escHtml(p.name)}< / option > `).join('')
: '< option value = "" > No projects — create one< / option > ';
sel.onchange = handleProjectChange;
if (state.projects.length) { state.currentProjectId = state.projects[0].id; await loadBinsAndAssets(); }
}
function handleProjectChange() {
state.currentProjectId = document.getElementById('projectSelect').value || null;
state.currentBinId = null;
loadBinsAndAssets();
}
async function loadBinsAndAssets() {
await Promise.all([loadBins(), loadAssets()]);
}
async function loadBins() {
if (!state.currentProjectId) { renderBins([]); return; }
const r = await getBins(state.currentProjectId);
state.bins = r.success ? r.data : [];
renderBins(state.bins);
}
function renderBins(bins) {
const tree = document.getElementById('binTree');
const allItem = document.getElementById('allAssetsItem');
tree.querySelectorAll('.bin-item:not(#allAssetsItem)').forEach(n => n.remove());
bins.forEach(bin => {
const el = document.createElement('a');
el.className = 'bin-item';
el.dataset.binId = bin.id;
el.innerHTML = `
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" >
< rect x = "1" y = "5" width = "14" height = "9" rx = "1" / >
< path d = "M1 5l2.5-3h9L15 5" / >
< / svg >
< span > ${escHtml(bin.name)}< / span > `;
el.onclick = () => selectBin(bin.id);
tree.appendChild(el);
});
updateBinActive();
}
function selectBin(binId) {
state.currentBinId = binId;
updateBinActive();
loadAssets();
}
function updateBinActive() {
document.querySelectorAll('.bin-item').forEach(el => {
const id = el.dataset.binId || null;
el.classList.toggle('active', id === state.currentBinId);
});
}
// ── Assets ────────────────────────────────
async function loadAssets() {
if (!state.currentProjectId) { renderAssets([]); return; }
document.getElementById('assetLoading').style.display = 'flex';
document.getElementById('assetEmpty').style.display = 'none';
document.getElementById('assetGrid').innerHTML = '';
const filters = { project_id: state.currentProjectId };
if (state.currentBinId) filters.bin_id = state.currentBinId;
const r = await getAssets(filters);
document.getElementById('assetLoading').style.display = 'none';
state.assets = r.success ? r.data : [];
renderAssets(state.assets);
}
function renderAssets(assets) {
const term = state.searchTerm.toLowerCase();
const filtered = term ? assets.filter(a => a.filename?.toLowerCase().includes(term) || a.display_name?.toLowerCase().includes(term)) : assets;
const grid = document.getElementById('assetGrid');
grid.innerHTML = '';
const count = filtered.length;
document.getElementById('assetCount').textContent = `${count} asset${count !== 1 ? 's' : ''}`;
document.getElementById('assetEmpty').style.display = count === 0 ? 'flex' : 'none';
filtered.forEach(asset => {
const card = document.createElement('div');
card.className = 'asset-card';
card.dataset.assetId = asset.id;
const statusClass = statusBadgeClass(asset.status);
card.innerHTML = `
< div class = "asset-thumb" >
< div class = "asset-thumb-placeholder" >
${mediaIcon(asset.media_type)}
< / div >
< img data-asset-id = "${asset.id}" alt = "${escHtml(asset.display_name || asset.filename)}" aria-hidden = "false" >
< div class = "asset-thumb-overlay" >
< span class = "badge ${statusClass}" > ${escHtml(asset.status)}< / span >
${asset.duration ? `< span class = "asset-duration" > ${formatDuration(asset.duration)}< / span > ` : ''}
< / div >
< div class = "asset-actions" >
< button class = "asset-action-btn" onclick = "deleteAssetPrompt('${asset.id}', event)" title = "Delete" >
< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < path d = "M3 4h10M6 4V2h4v2M5 4v9h6V4" / > < / svg >
< / button >
< / div >
< / div >
< div class = "asset-meta" >
< div class = "asset-name" > ${escHtml(asset.display_name || asset.filename)}< / div >
< div class = "asset-info" >
< span class = "asset-type" > ${escHtml(asset.media_type || '')}< / span >
< span class = "text-tertiary text-xs" > ${asset.file_size ? formatFileSize(asset.file_size) : ''}< / span >
< / div >
< / div > `;
const img = card.querySelector('img');
if (asset.thumbnail_s3_key) thumbObserver.observe(img);
else img.style.display = 'none';
2026-05-17 08:55:04 -04:00
card.addEventListener('click', (e) => {
if (e.target.closest('.asset-action-btn')) return; // delete button etc.
if (window.openAssetPreview) window.openAssetPreview(asset.id);
});
2026-05-16 13:04:45 -04:00
grid.appendChild(card);
});
2026-05-17 14:48:23 -04:00
if (window.SelectionManager) SelectionManager.refreshUI();
2026-05-16 13:04:45 -04:00
}
function statusBadgeClass(s) {
const map = { ingesting:'badge-ingesting', processing:'badge-processing', ready:'badge-ready', error:'badge-error', archived:'badge-archived' };
return map[s] || 'badge-idle';
}
function mediaIcon(type) {
if (type === 'video') return `< svg viewBox = "0 0 28 28" fill = "none" stroke = "currentColor" stroke-width = "1.2" width = "28" height = "28" > < rect x = "2" y = "6" width = "18" height = "16" rx = "2" / > < path d = "M20 11l6-3v12l-6-3" / > < / svg > `;
if (type === 'audio') return `< svg viewBox = "0 0 28 28" fill = "none" stroke = "currentColor" stroke-width = "1.2" width = "28" height = "28" > < path d = "M14 3v16M10 7v8M6 10v4M18 7v8M22 10v4" / > < / svg > `;
if (type === 'image') return `< svg viewBox = "0 0 28 28" fill = "none" stroke = "currentColor" stroke-width = "1.2" width = "28" height = "28" > < rect x = "3" y = "3" width = "22" height = "22" rx = "2" / > < circle cx = "10" cy = "10" r = "2.5" / > < path d = "M3 20l6-5 5 4 4-3 7 5" / > < / svg > `;
return `< svg viewBox = "0 0 28 28" fill = "none" stroke = "currentColor" stroke-width = "1.2" width = "28" height = "28" > < path d = "M8 4H5v20h18V10l-6-6H8z" / > < path d = "M17 4v6h5" / > < / svg > `;
}
async function deleteAssetPrompt(id, e) {
e.stopPropagation();
2026-05-17 12:55:36 -04:00
if (!confirm('Delete this asset? It will be archived and hidden from the library.')) return;
const r = await deleteAsset(id);
2026-05-16 13:04:45 -04:00
if (r.success) { toast('Asset deleted', '', 'success'); loadAssets(); }
else toast('Delete failed', r.error, 'error');
}
// ── Search ────────────────────────────────
function setupSearch() {
const inp = document.getElementById('searchInput');
inp.addEventListener('input', () => {
state.searchTerm = inp.value;
renderAssets(state.assets);
});
}
// ── New project ───────────────────────────
async function saveProject() {
const name = document.getElementById('newProjectName').value.trim();
if (!name) return;
const r = await createProject(name, document.getElementById('newProjectDesc').value.trim());
if (r.success) {
toast('Project created', name, 'success');
closePanel('project');
document.getElementById('newProjectName').value = '';
document.getElementById('newProjectDesc').value = '';
await loadProjects();
document.getElementById('projectSelect').value = r.data.id;
handleProjectChange();
} else toast('Failed to create project', r.error, 'error');
}
// ── New bin ──────────────────────────────
async function saveBin() {
if (!state.currentProjectId) { toast('Select a project first', '', 'warning'); return; }
const name = document.getElementById('newBinName').value.trim();
if (!name) return;
const r = await createBin(state.currentProjectId, name);
if (r.success) {
toast('Bin created', name, 'success');
closePanel('bin');
document.getElementById('newBinName').value = '';
await loadBins();
} else toast('Failed to create bin', r.error, 'error');
}
// ── Drag upload ───────────────────────────
function setupDrag() {
let dragCount = 0;
const overlay = document.getElementById('dropOverlay');
document.addEventListener('dragenter', e => { e.preventDefault(); dragCount++; overlay.classList.add('active'); });
document.addEventListener('dragleave', () => { if (--dragCount < = 0) { dragCount = 0; overlay.classList.remove('active'); } });
document.addEventListener('dragover', e => e.preventDefault());
document.addEventListener('drop', e => {
e.preventDefault();
dragCount = 0;
overlay.classList.remove('active');
if (!state.currentProjectId) { toast('Select a project first', '', 'warning'); return; }
const files = [...e.dataTransfer.files].filter(f => f.type.startsWith('video/') || f.type.startsWith('audio/') || f.type.startsWith('image/'));
if (files.length === 0) { toast('No supported media files', '', 'warning'); return; }
sessionStorage.setItem('pendingFiles', JSON.stringify(files.map(f => f.name)));
location.href = `upload.html?project=${state.currentProjectId}`;
});
}
// ── Panel helpers ─────────────────────────
function openPanel(name) {
document.getElementById(name + 'Panel').classList.add('open');
document.getElementById(name + 'Overlay').classList.add('open');
}
function closePanel(name) {
document.getElementById(name + 'Panel').classList.remove('open');
document.getElementById(name + 'Overlay').classList.remove('open');
}
// ── Toast ────────────────────────────────
function toast(title, msg, type = 'info') {
const icons = {
success: `< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < circle cx = "8" cy = "8" r = "6.5" / > < path d = "M5 8l2 2 4-4" / > < / svg > `,
error: `< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < circle cx = "8" cy = "8" r = "6.5" / > < path d = "M8 5v4M8 11v.5" / > < / svg > `,
warning: `< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < path d = "M8 2L1 14h14L8 2z" / > < path d = "M8 7v3M8 12v.5" / > < / svg > `,
info: `< svg viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" stroke-width = "1.5" > < circle cx = "8" cy = "8" r = "6.5" / > < path d = "M8 7v5M8 5v.5" / > < / svg > `,
};
const el = document.createElement('div');
el.className = `toast toast--${type}`;
el.innerHTML = `< div class = "toast-icon" > ${icons[type]||icons.info}< / div > < div class = "toast-body" > < div class = "toast-title" > ${escHtml(title)}< / div > ${msg?`< div class = "toast-msg" > ${escHtml(msg)}< / div > `:''}< / div > `;
document.getElementById('toastContainer').appendChild(el);
setTimeout(() => el.remove(), 4000);
}
function escHtml(s) {
if (!s) return '';
return String(s).replace(/&/g,'& ').replace(/< /g,'< ').replace(/>/g,'> ').replace(/"/g,'" ');
}
function formatFileSize(bytes) {
if (!bytes) return '';
if (bytes < 1024 ) return bytes + ' B ' ;
if (bytes < 1024 * 1024 ) return ( bytes / 1024 ) . toFixed ( 1 ) + ' KB ' ;
if (bytes < 1024 * 1024 * 1024 ) return ( bytes / 1024 / 1024 ) . toFixed ( 1 ) + ' MB ' ;
return (bytes/1024/1024/1024).toFixed(2) + ' GB';
}
function formatDuration(seconds) {
if (!seconds) return '';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
return `${m}:${String(s).padStart(2,'0')}`;
}
< / script >
2026-04-07 21:58:23 -04:00
< / body >
< / html >