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