dragonflight/services/web-ui/public/index.html
Zac f2b8d5dc4b feat(splash): transparent PNG so the subject composites cleanly
The source image had a black border baked in. Knocked out the dark pixels into an alpha channel so the figure now floats on whatever surface is behind it — the dark gradient on the splash, the panel surface on the loading indicator, anywhere.

Pipeline: source -> resize 1200w -> python/PIL alpha-from-luminance with soft 22-55 luminance ramp -> 8-bit RGBA PNG (267KB).
2026-05-17 18:39:21 -04:00

826 lines
31 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<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;
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 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; }
.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: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))}
.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}
</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">
<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>
</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>
</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">
<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=5"></script>
<script src="js/preview.js?v=1"></script>
<script src="js/selection.js?v=1"></script>
<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 () => {
// 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();
// 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;
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';
card.addEventListener('click', (e) => {
if (e.target.closest('.asset-action-btn')) return; // delete button etc.
if (window.openAssetPreview) window.openAssetPreview(asset.id);
});
grid.appendChild(card);
});
if (window.SelectionManager) SelectionManager.refreshUI();
}
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();
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');
}
// ── 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,'&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>
</body>
</html>