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

1070 lines
42 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>
</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>
</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>
</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>
</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>
</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>
</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>
</body>
</html>