dragonflight/services/web-ui/public/index.html

1071 lines
42 KiB
HTML
Raw Normal View History

2026-04-07 21:58:23 -04:00
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<title>Library — Z-AMPP</title>
<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);
flex-wrap: wrap;
}
.asset-toolbar-left { display: flex; align-items: center; gap: var(--sp-3); }
.asset-toolbar-right { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.asset-count {
font-size: var(--text-xs);
color: var(--text-tertiary);
white-space: nowrap;
}
.search-input {
width: 200px;
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); }
/* Filter chips */
.filter-chips { display: flex; gap: 3px; }
.filter-chip {
padding: 2px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
border: 1px solid var(--border);
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
transition: all var(--t-fast);
line-height: 20px;
}
.filter-chip:hover { border-color: var(--border-strong); color: var(--text-primary); }
.filter-chip.active {
background: var(--accent-subtle);
border-color: var(--accent-border);
color: var(--accent);
}
/* Sort select */
.sort-select {
height: 26px;
font-size: 12px;
padding: 0 20px 0 8px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--r-md);
color: var(--text-secondary);
outline: none;
cursor: pointer;
}
.sort-select:focus { border-color: var(--accent-border); }
.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;
cursor: text;
}
/* Inline rename input */
.asset-name-input {
width: 100%;
font-size: var(--text-sm);
font-weight: 500;
font-family: inherit;
padding: 1px 4px;
border-radius: 3px;
border: 1px solid var(--accent-border);
background: var(--bg-raised);
color: var(--text-primary);
outline: none;
box-sizing: border-box;
}
.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;
background: oklch(55% 0.20 266 / 0.09);
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 action buttons */
.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; }
.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}
.first-splash-img{width:min(420px,46vw);aspect-ratio:3/2;background-image:url(img/ampp-safe.png?v=hardhat3);background-size:contain;background-position:center;background-repeat:no-repeat;filter:drop-shadow(0 20px 40px rgba(31,58,208,.15))}
.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}
@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}
.badge-live { background: oklch(64% 0.22 25 / 0.18); color: oklch(70% 0.22 25); border: 1px solid oklch(64% 0.22 25 / 0.4); animation: liveBlink 1.4s ease-in-out infinite; }
@keyframes liveBlink { 0%,100% { opacity: 0.7 } 50% { opacity: 1 } }
</style>
2026-04-07 21:58:23 -04:00
</head>
<body>
<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>
<div class="shell">
<!-- Sidebar -->
<nav class="sidebar" aria-label="Main navigation">
<div class="sidebar-brand">
<img src="img/dragon-logo.png?v=1" alt="Wild Dragon" class="sidebar-logo">
<span class="sidebar-brand-name">Z-AMPP</span>
</div>
<nav class="sidebar-nav">
<a href="home.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
Home
</a>
<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="projects.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
Projects
</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>
<a href="editor.html" class="nav-item">
<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>
<div class="sidebar-section-label">Admin</div>
<a href="users.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
Users
</a>
<a href="tokens.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
Tokens
</a>
<a href="containers.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="5" width="14" height="4" rx="1"/><rect x="1" y="10" width="14" height="4" rx="1"/><path d="M4 7h1M4 12h1"/></svg>
Containers
</a>
<a href="cluster.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2"/><circle cx="2" cy="3" r="1.5"/><circle cx="14" cy="3" r="1.5"/><circle cx="2" cy="13" r="1.5"/><circle cx="14" cy="13" r="1.5"/><path d="M3.1 4.1L6.5 6.5M12.9 4.1L9.5 6.5M3.1 11.9L6.5 9.5M12.9 11.9L9.5 9.5"/></svg>
Cluster
</a>
</nav>
<div class="sidebar-footer">
<div class="sidebar-user">
<div class="sidebar-user-avatar" id="userAvatar">?</div>
<div class="sidebar-user-info">
<div class="sidebar-user-name" id="userName"></div>
<div class="sidebar-user-role" id="userRole"></div>
</div>
<button class="btn btn-ghost" id="logoutBtn" title="Sign out" style="padding:0;width:24px;height:24px;flex-shrink:0;">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
</button>
</div>
</div>
</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>
<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">
<div class="filter-chips" id="filterChips" role="group" aria-label="Filter by status">
<button class="filter-chip active" data-status="">All</button>
<button class="filter-chip" data-status="ready">Ready</button>
<button class="filter-chip" data-status="processing">Processing</button>
<button class="filter-chip" data-status="error">Error</button>
<button class="filter-chip" data-status="live">Live</button>
</div>
<select class="sort-select" id="sortSelect" aria-label="Sort order">
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="name">Name AZ</option>
<option value="name-desc">Name ZA</option>
<option value="duration">Longest</option>
<option value="size">Largest</option>
</select>
<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>
<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>
<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>
</div>
<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>
</div>
</div>
</div>
2026-04-07 21:58:23 -04:00
</div>
</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>
<script src="js/api.js?v=6"></script>
<script src="js/topbar-strip.js?v=1"></script>
<script src="js/preview.js?v=4"></script>
<script src="js/selection.js?v=1"></script>
<script>
const state = {
projects: [],
currentProjectId: null,
bins: [],
currentBinId: null,
assets: [],
thumbCache: {},
searchTerm: '',
statusFilter: '',
sortBy: 'newest',
};
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');
img.onerror = () => {
delete state.thumbCache[img.dataset.assetId];
img.classList.remove('loaded');
thumbObserver.observe(img);
};
}
// ── Init ────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
// 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);
}
}
await loadProjects();
setupDrag();
setupSearch();
setupFilters();
setupRenameListener(document.getElementById('assetGrid'));
// 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();
},
});
}
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;
state.statusFilter = '';
state.sortBy = 'newest';
document.querySelectorAll('.filter-chip').forEach(c => c.classList.toggle('active', c.dataset.status === ''));
document.getElementById('sortSelect').value = 'newest';
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');
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;
state.statusFilter = '';
state.sortBy = 'newest';
document.querySelectorAll('.filter-chip').forEach(c => c.classList.toggle('active', c.dataset.status === ''));
document.getElementById('sortSelect').value = 'newest';
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();
// Apply status filter
let filtered = state.statusFilter
? assets.filter(a => {
if (state.statusFilter === 'processing') return a.status === 'processing' || a.status === 'ingesting';
return a.status === state.statusFilter;
})
: assets;
// Apply text search
if (term) filtered = filtered.filter(a =>
a.filename?.toLowerCase().includes(term) || a.display_name?.toLowerCase().includes(term)
);
// Sort
filtered = [...filtered].sort((a, b) => {
switch (state.sortBy) {
case 'oldest': return new Date(a.created_at) - new Date(b.created_at);
case 'name': return (a.display_name || a.filename || '').localeCompare(b.display_name || b.filename || '');
case 'name-desc': return (b.display_name || b.filename || '').localeCompare(a.display_name || a.filename || '');
case 'duration': return (b.duration_ms || 0) - (a.duration_ms || 0);
case 'size': return (b.file_size || 0) - (a.file_size || 0);
default: return new Date(b.created_at) - new Date(a.created_at);
}
});
const grid = document.getElementById('assetGrid');
grid.innerHTML = '';
const count = filtered.length;
const totalCount = assets.length;
document.getElementById('assetCount').textContent =
count === totalCount ? `${count} asset${count !== 1 ? 's' : ''}` :
`${count} of ${totalCount} asset${totalCount !== 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_ms ? `<span class="asset-duration">${formatDuration(asset.duration_ms / 1000)}</span>` : ''}
</div>
<div class="asset-actions">
<button class="asset-action-btn" onclick="openInEditor('${asset.id}', event)" title="Open in Editor">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="3" width="14" height="10" rx="1"/><path d="M1 7h14M5 3v10M5 7l3-2v4l-3-2z"/></svg>
</button>
${asset.status === 'error' ? `<button class="asset-action-btn" onclick="handleRetryAsset('${asset.id}', event)" title="Retry processing" style="color:oklch(74% 0.18 55);"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 8a6 6 0 1 0 1-3.5"/><path d="M1 3v3h3"/></svg></button>` : ''}
<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" data-rename-id="${asset.id}" title="Double-click to rename">${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';
card.addEventListener('click', (e) => {
if (e.target.closest('.asset-action-btn')) return;
if (e.target.closest('.asset-name[data-rename-id]')) return; // let rename handle it
if (window.openAssetPreview) window.openAssetPreview(asset.id);
});
grid.appendChild(card);
});
if (window.SelectionManager) SelectionManager.refreshUI();
}
// ── Inline rename ─────────────────────────
function setupRenameListener(grid) {
grid.addEventListener('dblclick', async e => {
const nameEl = e.target.closest('.asset-name[data-rename-id]');
if (!nameEl) return;
e.stopPropagation();
const assetId = nameEl.dataset.renameId;
const current = nameEl.textContent;
const input = document.createElement('input');
input.type = 'text';
input.value = current;
input.className = 'asset-name-input';
nameEl.style.display = 'none';
nameEl.parentNode.insertBefore(input, nameEl.nextSibling);
input.focus();
input.select();
let saved = false;
const save = async () => {
if (saved) return;
saved = true;
const newName = input.value.trim();
input.remove();
nameEl.style.display = '';
if (!newName || newName === current) return;
const r = await updateAsset(assetId, { display_name: newName });
if (r.success) {
nameEl.textContent = newName;
// Update state cache so re-renders don't revert
const a = state.assets.find(x => x.id === assetId);
if (a) a.display_name = newName;
toast('Renamed', newName, 'success');
} else {
toast('Rename failed', r.error, 'error');
}
};
const cancel = () => {
if (saved) return;
saved = true;
input.remove();
nameEl.style.display = '';
};
input.addEventListener('blur', save);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
});
});
}
function statusBadgeClass(s) {
const map = { live:'badge-live', 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();
if (!confirm('Delete this asset? It will be archived and hidden from the library.')) return;
const r = await deleteAsset(id);
if (r.success) { toast('Asset deleted', '', 'success'); loadAssets(); }
else toast('Delete failed', r.error, 'error');
}
async function handleRetryAsset(id, e) {
e.stopPropagation();
const r = await retryAsset(id);
if (r.success) { toast('Asset queued for reprocessing', '', 'success'); loadAssets(); }
else toast('Retry failed', r.error, 'error');
}
function openInEditor(assetId, e) {
e.stopPropagation();
const projectId = state.currentProjectId;
if (!projectId) { toast('Select a project first', '', 'warning'); return; }
location.href = 'editor.html?project=' + projectId + '&asset=' + assetId;
}
// ── Search ────────────────────────────────
function setupSearch() {
const inp = document.getElementById('searchInput');
inp.addEventListener('input', () => {
state.searchTerm = inp.value;
renderAssets(state.assets);
});
}
// ── Filter chips + sort ───────────────────
function setupFilters() {
document.querySelectorAll('.filter-chip').forEach(chip => {
chip.addEventListener('click', () => {
state.statusFilter = chip.dataset.status;
document.querySelectorAll('.filter-chip').forEach(c =>
c.classList.toggle('active', c.dataset.status === state.statusFilter)
);
renderAssets(state.assets);
});
});
document.getElementById('sortSelect').addEventListener('change', e => {
state.sortBy = e.target.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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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>
<script src="js/auth-guard.js"></script>
2026-04-07 21:58:23 -04:00
</body>
</html>