834 lines
32 KiB
HTML
834 lines
32 KiB
HTML
<!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 — 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: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="#" class="nav-item" target="_blank" onclick="window.open(location.protocol + '//' + location.hostname + ':47435/', '_blank'); return false;" rel="noopener">
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
|
|
Editor
|
|
</a>
|
|
</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/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: '',
|
|
};
|
|
|
|
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 = { 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');
|
|
}
|
|
|
|
// ── Search ────────────────────────────────
|
|
function setupSearch() {
|
|
const inp = document.getElementById('searchInput');
|
|
inp.addEventListener('input', () => {
|
|
state.searchTerm = inp.value;
|
|
renderAssets(state.assets);
|
|
});
|
|
}
|
|
|
|
// ── New project ───────────────────────────
|
|
async function saveProject() {
|
|
const name = document.getElementById('newProjectName').value.trim();
|
|
if (!name) return;
|
|
const r = await createProject(name, document.getElementById('newProjectDesc').value.trim());
|
|
if (r.success) {
|
|
toast('Project created', name, 'success');
|
|
closePanel('project');
|
|
document.getElementById('newProjectName').value = '';
|
|
document.getElementById('newProjectDesc').value = '';
|
|
await loadProjects();
|
|
document.getElementById('projectSelect').value = r.data.id;
|
|
handleProjectChange();
|
|
} else toast('Failed to create project', r.error, 'error');
|
|
}
|
|
|
|
// ── New bin ──────────────────────────────
|
|
async function saveBin() {
|
|
if (!state.currentProjectId) { toast('Select a project first', '', 'warning'); return; }
|
|
const name = document.getElementById('newBinName').value.trim();
|
|
if (!name) return;
|
|
const r = await createBin(state.currentProjectId, name);
|
|
if (r.success) {
|
|
toast('Bin created', name, 'success');
|
|
closePanel('bin');
|
|
document.getElementById('newBinName').value = '';
|
|
await loadBins();
|
|
} else toast('Failed to create bin', r.error, 'error');
|
|
}
|
|
|
|
// ── Drag upload ───────────────────────────
|
|
function setupDrag() {
|
|
let dragCount = 0;
|
|
const overlay = document.getElementById('dropOverlay');
|
|
document.addEventListener('dragenter', e => { e.preventDefault(); dragCount++; overlay.classList.add('active'); });
|
|
document.addEventListener('dragleave', () => { if (--dragCount <= 0) { dragCount = 0; overlay.classList.remove('active'); } });
|
|
document.addEventListener('dragover', e => e.preventDefault());
|
|
document.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
dragCount = 0;
|
|
overlay.classList.remove('active');
|
|
if (!state.currentProjectId) { toast('Select a project first', '', 'warning'); return; }
|
|
const files = [...e.dataTransfer.files].filter(f => f.type.startsWith('video/') || f.type.startsWith('audio/') || f.type.startsWith('image/'));
|
|
if (files.length === 0) { toast('No supported media files', '', 'warning'); return; }
|
|
sessionStorage.setItem('pendingFiles', JSON.stringify(files.map(f => f.name)));
|
|
location.href = `upload.html?project=${state.currentProjectId}`;
|
|
});
|
|
}
|
|
|
|
// ── Panel helpers ─────────────────────────
|
|
function openPanel(name) {
|
|
document.getElementById(name + 'Panel').classList.add('open');
|
|
document.getElementById(name + 'Overlay').classList.add('open');
|
|
}
|
|
function closePanel(name) {
|
|
document.getElementById(name + 'Panel').classList.remove('open');
|
|
document.getElementById(name + 'Overlay').classList.remove('open');
|
|
}
|
|
|
|
// ── Toast ────────────────────────────────
|
|
function toast(title, msg, type = 'info') {
|
|
const icons = {
|
|
success: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.5"/><path d="M5 8l2 2 4-4"/></svg>`,
|
|
error: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.5"/><path d="M8 5v4M8 11v.5"/></svg>`,
|
|
warning: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2L1 14h14L8 2z"/><path d="M8 7v3M8 12v.5"/></svg>`,
|
|
info: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.5"/><path d="M8 7v5M8 5v.5"/></svg>`,
|
|
};
|
|
const el = document.createElement('div');
|
|
el.className = `toast toast--${type}`;
|
|
el.innerHTML = `<div class="toast-icon">${icons[type]||icons.info}</div><div class="toast-body"><div class="toast-title">${escHtml(title)}</div>${msg?`<div class="toast-msg">${escHtml(msg)}</div>`:''}</div>`;
|
|
document.getElementById('toastContainer').appendChild(el);
|
|
setTimeout(() => el.remove(), 4000);
|
|
}
|
|
|
|
function escHtml(s) {
|
|
if (!s) return '';
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
function formatFileSize(bytes) {
|
|
if (!bytes) return '';
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB';
|
|
if (bytes < 1024*1024*1024) return (bytes/1024/1024).toFixed(1) + ' MB';
|
|
return (bytes/1024/1024/1024).toFixed(2) + ' GB';
|
|
}
|
|
|
|
function formatDuration(seconds) {
|
|
if (!seconds) return '';
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
if (h > 0) return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
|
|
return `${m}:${String(s).padStart(2,'0')}`;
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|