Double-clicking a clip name in the library shows an in-place text input. Enter/blur commits the new display_name via PATCH; Escape cancels. Clicking the card body or action buttons still work normally.
1062 lines
41 KiB
HTML
1062 lines
41 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 — Z-AMPP</title>
|
||
<link rel="stylesheet" href="css/common.css">
|
||
<style>
|
||
/* ── Library layout ── */
|
||
.library-shell {
|
||
display: flex;
|
||
flex: 1;
|
||
overflow: hidden;
|
||
height: 100%;
|
||
}
|
||
|
||
/* Bin tree panel */
|
||
.bin-panel {
|
||
width: 220px;
|
||
flex-shrink: 0;
|
||
border-right: 1px solid var(--border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: var(--bg-panel);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.bin-panel-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: var(--sp-3) var(--sp-4);
|
||
border-bottom: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.bin-panel-title {
|
||
font-size: var(--text-xs);
|
||
font-weight: 500;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: var(--text-tertiary);
|
||
}
|
||
|
||
.bin-tree {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: var(--sp-2) 0;
|
||
}
|
||
|
||
.bin-tree::-webkit-scrollbar { width: 4px; }
|
||
.bin-tree::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||
|
||
.bin-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--sp-2);
|
||
padding: var(--sp-2) var(--sp-4);
|
||
font-size: var(--text-sm);
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
transition: color var(--t-fast), background var(--t-fast);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.bin-item:hover { color: var(--text-primary); background: var(--bg-hover); }
|
||
|
||
.bin-item.active {
|
||
color: var(--accent);
|
||
background: var(--accent-subtle);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.bin-item svg { width: 14px; height: 14px; flex-shrink: 0; opacity: 0.6; }
|
||
.bin-item.active svg { opacity: 1; }
|
||
.bin-item span { overflow: hidden; text-overflow: ellipsis; }
|
||
|
||
/* Asset grid */
|
||
.asset-area {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.asset-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: var(--sp-3) var(--sp-5);
|
||
border-bottom: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
gap: var(--sp-3);
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.asset-toolbar-left { display: flex; align-items: center; gap: var(--sp-3); }
|
||
.asset-toolbar-right { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
||
|
||
.asset-count {
|
||
font-size: var(--text-xs);
|
||
color: var(--text-tertiary);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.search-input {
|
||
width: 200px;
|
||
height: 28px;
|
||
padding: 0 var(--sp-3);
|
||
background: var(--bg-surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--r-md);
|
||
color: var(--text-primary);
|
||
font-size: var(--text-sm);
|
||
outline: none;
|
||
transition: border-color var(--t-fast);
|
||
}
|
||
.search-input:focus { border-color: var(--accent-border); }
|
||
.search-input::placeholder { color: var(--text-tertiary); }
|
||
|
||
/* Filter chips */
|
||
.filter-chips { display: flex; gap: 3px; }
|
||
.filter-chip {
|
||
padding: 2px 9px;
|
||
border-radius: 999px;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
border: 1px solid var(--border);
|
||
background: transparent;
|
||
color: var(--text-tertiary);
|
||
cursor: pointer;
|
||
transition: all var(--t-fast);
|
||
line-height: 20px;
|
||
}
|
||
.filter-chip:hover { border-color: var(--border-strong); color: var(--text-primary); }
|
||
.filter-chip.active {
|
||
background: var(--accent-subtle);
|
||
border-color: var(--accent-border);
|
||
color: var(--accent);
|
||
}
|
||
|
||
/* Sort select */
|
||
.sort-select {
|
||
height: 26px;
|
||
font-size: 12px;
|
||
padding: 0 20px 0 8px;
|
||
background: var(--bg-surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--r-md);
|
||
color: var(--text-secondary);
|
||
outline: none;
|
||
cursor: pointer;
|
||
}
|
||
.sort-select:focus { border-color: var(--accent-border); }
|
||
|
||
.asset-grid-wrap {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: var(--sp-5);
|
||
}
|
||
|
||
.asset-grid-wrap::-webkit-scrollbar { width: 5px; }
|
||
.asset-grid-wrap::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
|
||
|
||
.asset-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(188px, 1fr));
|
||
gap: var(--sp-4);
|
||
}
|
||
|
||
/* Asset card */
|
||
.asset-card {
|
||
background: var(--bg-surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--r-lg);
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
transition: border-color var(--t-fast), background var(--t-fast);
|
||
container-type: inline-size;
|
||
}
|
||
|
||
.asset-card:hover {
|
||
border-color: var(--border-strong);
|
||
background: var(--bg-raised);
|
||
}
|
||
|
||
.asset-card.selected {
|
||
border-color: var(--accent-border);
|
||
background: var(--accent-subtle);
|
||
}
|
||
|
||
/* Thumbnail */
|
||
.asset-thumb {
|
||
position: relative;
|
||
width: 100%;
|
||
aspect-ratio: 16 / 9;
|
||
background: var(--bg-base);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.asset-thumb img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
transition: opacity 300ms ease-out;
|
||
opacity: 0;
|
||
}
|
||
|
||
.asset-thumb img.loaded { opacity: 1; }
|
||
|
||
.asset-thumb-placeholder {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--text-tertiary);
|
||
}
|
||
|
||
.asset-thumb-placeholder svg { width: 28px; height: 28px; }
|
||
|
||
.asset-thumb-overlay {
|
||
position: absolute;
|
||
top: var(--sp-2);
|
||
left: var(--sp-2);
|
||
right: var(--sp-2);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: var(--sp-1);
|
||
}
|
||
|
||
.asset-duration {
|
||
font-size: 10px;
|
||
font-weight: 500;
|
||
font-variant-numeric: tabular-nums;
|
||
color: oklch(93% 0.008 250);
|
||
background: oklch(8% 0.010 250 / 0.75);
|
||
padding: 1px 5px;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
/* Asset metadata */
|
||
.asset-meta {
|
||
padding: var(--sp-3);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--sp-1);
|
||
}
|
||
|
||
.asset-name {
|
||
font-size: var(--text-sm);
|
||
font-weight: 500;
|
||
color: var(--text-primary);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
cursor: text;
|
||
}
|
||
|
||
/* Inline rename input */
|
||
.asset-name-input {
|
||
width: 100%;
|
||
font-size: var(--text-sm);
|
||
font-weight: 500;
|
||
font-family: inherit;
|
||
padding: 1px 4px;
|
||
border-radius: 3px;
|
||
border: 1px solid var(--accent-border);
|
||
background: var(--bg-raised);
|
||
color: var(--text-primary);
|
||
outline: none;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.asset-info {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.asset-type {
|
||
font-size: var(--text-xs);
|
||
color: var(--text-tertiary);
|
||
}
|
||
|
||
/* Drag-over overlay */
|
||
.drop-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: oklch(55% 0.20 266 / 0.09);
|
||
border: 2px dashed var(--accent);
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
transition: opacity var(--t-fast);
|
||
z-index: 10;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.drop-overlay.active { opacity: 1; }
|
||
.drop-overlay-label {
|
||
font-size: var(--text-xl);
|
||
font-weight: 500;
|
||
color: var(--accent);
|
||
}
|
||
|
||
/* Project selector in topbar */
|
||
.project-select {
|
||
height: 28px;
|
||
font-size: var(--text-sm);
|
||
padding: 0 24px 0 var(--sp-3);
|
||
background: var(--bg-surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--r-md);
|
||
color: var(--text-primary);
|
||
min-width: 160px;
|
||
}
|
||
|
||
/* Asset action buttons */
|
||
.asset-actions {
|
||
position: absolute;
|
||
top: var(--sp-2);
|
||
right: var(--sp-2);
|
||
display: none;
|
||
gap: var(--sp-1);
|
||
}
|
||
.asset-card:hover .asset-actions { display: flex; }
|
||
|
||
.asset-action-btn {
|
||
width: 26px;
|
||
height: 26px;
|
||
border-radius: var(--r-sm);
|
||
background: oklch(8% 0.010 250 / 0.75);
|
||
border: none;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: oklch(93% 0.008 250);
|
||
cursor: pointer;
|
||
transition: background var(--t-fast);
|
||
}
|
||
.asset-action-btn:hover { background: oklch(8% 0.010 250 / 0.9); }
|
||
.asset-action-btn svg { width: 13px; height: 13px; }
|
||
|
||
.first-splash{position:fixed;inset:0;z-index:60;background:radial-gradient(ellipse at 50% 45%,#1a1d28 0%,#08090d 70%);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:24px;opacity:1;transition:opacity .55s ease-out, visibility .55s}
|
||
.first-splash.hidden{opacity:0;visibility:hidden;pointer-events:none}
|
||
.first-splash-img{width:min(420px,46vw);aspect-ratio:3/2;background-image:url(img/ampp-safe.png?v=hardhat3);background-size:contain;background-position:center;background-repeat:no-repeat;filter:drop-shadow(0 20px 40px rgba(31,58,208,.15))}
|
||
.first-splash-stamp{display:flex;align-items:center;gap:10px;font-size:11px;font-weight:600;letter-spacing:.14em;text-transform:uppercase;color:oklch(55% 0.20 266)}
|
||
.first-splash-dot{width:8px;height:8px;background:oklch(55% 0.20 266);border-radius:50%;animation:fsPulse 1.4s ease-in-out infinite}
|
||
@keyframes fsPulse{0%,100%{opacity:.35;transform:scale(.9)}50%{opacity:1;transform:scale(1.1)}}
|
||
.first-splash-title{font-size:13px;color:var(--text-secondary);letter-spacing:.04em}
|
||
.badge-live { background: oklch(64% 0.22 25 / 0.18); color: oklch(70% 0.22 25); border: 1px solid oklch(64% 0.22 25 / 0.4); animation: liveBlink 1.4s ease-in-out infinite; }
|
||
@keyframes liveBlink { 0%,100% { opacity: 0.7 } 50% { opacity: 1 } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="firstSplash" class="first-splash" aria-hidden="true">
|
||
<div class="first-splash-img"></div>
|
||
<div class="first-splash-stamp"><span class="first-splash-dot"></span><span>AMPP Safe</span></div>
|
||
<div class="first-splash-title">Z-AMPP — Media Asset Management</div>
|
||
</div>
|
||
<div class="shell">
|
||
<!-- Sidebar -->
|
||
<nav class="sidebar" aria-label="Main navigation">
|
||
<div class="sidebar-brand">
|
||
<img src="img/dragon-logo.png?v=1" alt="Wild Dragon" class="sidebar-logo">
|
||
<span class="sidebar-brand-name">Z-AMPP</span>
|
||
</div>
|
||
<nav class="sidebar-nav">
|
||
<a href="home.html" class="nav-item">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
|
||
Home
|
||
</a>
|
||
<a href="index.html" class="nav-item active">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
|
||
Library
|
||
</a>
|
||
<a href="projects.html" class="nav-item">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
|
||
Projects
|
||
</a>
|
||
<a href="upload.html" class="nav-item">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
|
||
Ingest
|
||
</a>
|
||
<a href="recorders.html" class="nav-item">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
|
||
Recorders
|
||
</a>
|
||
<a href="capture.html" class="nav-item">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
|
||
Capture
|
||
</a>
|
||
<a href="jobs.html" class="nav-item">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
|
||
Jobs
|
||
</a>
|
||
<a href="editor.html" class="nav-item">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
|
||
Editor
|
||
</a>
|
||
<div class="sidebar-section-label">Admin</div>
|
||
<a href="users.html" class="nav-item">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
|
||
Users
|
||
</a>
|
||
<a href="tokens.html" class="nav-item">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
|
||
Tokens
|
||
</a>
|
||
</nav>
|
||
<div class="sidebar-footer">
|
||
<div class="sidebar-user">
|
||
<div class="sidebar-user-avatar" id="userAvatar">?</div>
|
||
<div class="sidebar-user-info">
|
||
<div class="sidebar-user-name" id="userName">—</div>
|
||
<div class="sidebar-user-role" id="userRole"></div>
|
||
</div>
|
||
<button class="btn btn-ghost" id="logoutBtn" title="Sign out" style="padding:0;width:24px;height:24px;flex-shrink:0;">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
|
||
<!-- Main -->
|
||
<div class="main">
|
||
<!-- Topbar -->
|
||
<header class="topbar">
|
||
<div class="topbar-left">
|
||
<span class="page-title">Library</span>
|
||
<span class="topbar-sep">/</span>
|
||
<select class="project-select" id="projectSelect" aria-label="Select project">
|
||
<option value="">No projects</option>
|
||
</select>
|
||
</div>
|
||
<div class="topbar-right">
|
||
<button class="btn btn-ghost btn-sm" id="newProjectBtn" title="New project">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v12M2 8h12"/></svg>
|
||
New project
|
||
</button>
|
||
<button class="btn btn-primary btn-sm" id="uploadBtn">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
|
||
Upload
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Library content -->
|
||
<div class="library-shell">
|
||
<!-- Bin panel -->
|
||
<div class="bin-panel">
|
||
<div class="bin-panel-header">
|
||
<span class="bin-panel-title">Bins</span>
|
||
<button class="btn btn-ghost btn-sm" id="newBinBtn" title="New bin" style="padding:0;width:24px;height:24px;">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v12M2 8h12"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="bin-tree" id="binTree">
|
||
<div class="bin-item active" data-bin-id="" id="allAssetsItem">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="14" height="10" rx="1"/><path d="M1 4l3-3h8l3 3"/></svg>
|
||
<span>All assets</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Asset area -->
|
||
<div class="asset-area">
|
||
<div class="asset-toolbar">
|
||
<div class="asset-toolbar-left">
|
||
<span class="asset-count" id="assetCount">0 assets</span>
|
||
</div>
|
||
<div class="asset-toolbar-right">
|
||
<div class="filter-chips" id="filterChips" role="group" aria-label="Filter by status">
|
||
<button class="filter-chip active" data-status="">All</button>
|
||
<button class="filter-chip" data-status="ready">Ready</button>
|
||
<button class="filter-chip" data-status="processing">Processing</button>
|
||
<button class="filter-chip" data-status="error">Error</button>
|
||
<button class="filter-chip" data-status="live">Live</button>
|
||
</div>
|
||
<select class="sort-select" id="sortSelect" aria-label="Sort order">
|
||
<option value="newest">Newest</option>
|
||
<option value="oldest">Oldest</option>
|
||
<option value="name">Name A–Z</option>
|
||
<option value="name-desc">Name Z–A</option>
|
||
<option value="duration">Longest</option>
|
||
<option value="size">Largest</option>
|
||
</select>
|
||
<input class="search-input" id="searchInput" type="text" placeholder="Search assets…" aria-label="Search assets">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="asset-grid-wrap" id="assetGridWrap">
|
||
<div id="assetGrid" class="asset-grid"></div>
|
||
<div id="assetEmpty" class="empty-state" style="display:none;">
|
||
<div class="empty-state-icon">
|
||
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="4" y="8" width="32" height="26" rx="2"/><path d="M4 14h32"/><circle cx="20" cy="25" r="6"/><path d="M16 25l2 2 4-4"/></svg>
|
||
</div>
|
||
<div class="empty-state-title">No assets yet</div>
|
||
<div class="empty-state-body">Upload media to this project or select a different bin.</div>
|
||
<div class="empty-state-actions">
|
||
<button class="btn btn-primary btn-sm" id="emptyUploadBtn">Upload files</button>
|
||
</div>
|
||
</div>
|
||
<div id="assetLoading" class="ampp-loading ampp-loading--sm" style="display:none;">
|
||
<div class="ampp-loading-img"></div>
|
||
<div class="ampp-loading-label"><span class="ampp-loading-dot"></span><span>Loading assets</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Drag-to-upload overlay -->
|
||
<div class="drop-overlay" id="dropOverlay">
|
||
<div class="drop-overlay-label">Drop to upload</div>
|
||
</div>
|
||
|
||
<!-- Toasts -->
|
||
<div class="toast-container" id="toastContainer" aria-live="polite"></div>
|
||
|
||
<!-- New project dialog (inline, not modal) -->
|
||
<div class="slide-overlay" id="projectOverlay"></div>
|
||
<div class="slide-panel" id="projectPanel">
|
||
<div class="slide-panel-header">
|
||
<span class="slide-panel-title">New project</span>
|
||
<button class="btn btn-ghost btn-sm" id="closeProjectPanel" style="padding:0;width:28px;height:28px;">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="slide-panel-body">
|
||
<div class="form-group">
|
||
<label class="form-label" for="newProjectName">Project name</label>
|
||
<input type="text" id="newProjectName" placeholder="e.g. Evening News 2026-05">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" for="newProjectDesc">Description</label>
|
||
<textarea id="newProjectDesc" rows="3" placeholder="Optional description"></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="slide-panel-footer">
|
||
<button class="btn btn-ghost" id="cancelProjectBtn">Cancel</button>
|
||
<button class="btn btn-primary" id="saveProjectBtn">Create project</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- New bin panel -->
|
||
<div class="slide-overlay" id="binOverlay"></div>
|
||
<div class="slide-panel" id="binPanel">
|
||
<div class="slide-panel-header">
|
||
<span class="slide-panel-title">New bin</span>
|
||
<button class="btn btn-ghost btn-sm" id="closeBinPanel" style="padding:0;width:28px;height:28px;">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="slide-panel-body">
|
||
<div class="form-group">
|
||
<label class="form-label" for="newBinName">Bin name</label>
|
||
<input type="text" id="newBinName" placeholder="e.g. Interviews">
|
||
</div>
|
||
</div>
|
||
<div class="slide-panel-footer">
|
||
<button class="btn btn-ghost" id="cancelBinBtn">Cancel</button>
|
||
<button class="btn btn-primary" id="saveBinBtn">Create bin</button>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="js/api.js?v=6"></script>
|
||
<script src="js/topbar-strip.js?v=1"></script>
|
||
<script src="js/preview.js?v=4"></script>
|
||
<script src="js/selection.js?v=1"></script>
|
||
<script>
|
||
const state = {
|
||
projects: [],
|
||
currentProjectId: null,
|
||
bins: [],
|
||
currentBinId: null,
|
||
assets: [],
|
||
thumbCache: {},
|
||
searchTerm: '',
|
||
statusFilter: '',
|
||
sortBy: 'newest',
|
||
};
|
||
|
||
const thumbObserver = new IntersectionObserver((entries) => {
|
||
entries.forEach(e => {
|
||
if (e.isIntersecting) {
|
||
loadThumb(e.target);
|
||
thumbObserver.unobserve(e.target);
|
||
}
|
||
});
|
||
}, { rootMargin: '100px' });
|
||
|
||
async function loadThumb(img) {
|
||
const id = img.dataset.assetId;
|
||
if (!id) return;
|
||
if (state.thumbCache[id]) { setImgSrc(img, state.thumbCache[id]); return; }
|
||
try {
|
||
const r = await api(`/assets/${id}/thumbnail`);
|
||
if (r.success && r.data?.url) {
|
||
state.thumbCache[id] = r.data.url;
|
||
setImgSrc(img, r.data.url);
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
function setImgSrc(img, src) {
|
||
img.src = src;
|
||
img.onload = () => img.classList.add('loaded');
|
||
img.onerror = () => {
|
||
delete state.thumbCache[img.dataset.assetId];
|
||
img.classList.remove('loaded');
|
||
thumbObserver.observe(img);
|
||
};
|
||
}
|
||
|
||
// ── Init ────────────────────────────────
|
||
document.addEventListener('DOMContentLoaded', async () => {
|
||
// First-visit splash. After first dismiss in a session we skip it.
|
||
const splash = document.getElementById('firstSplash');
|
||
if (splash) {
|
||
if (sessionStorage.getItem('splashShown')) {
|
||
splash.remove();
|
||
} else {
|
||
sessionStorage.setItem('splashShown', '1');
|
||
setTimeout(() => splash.classList.add('hidden'), 1400);
|
||
setTimeout(() => splash.remove(), 2000);
|
||
}
|
||
}
|
||
await loadProjects();
|
||
setupDrag();
|
||
setupSearch();
|
||
setupFilters();
|
||
setupRenameListener(document.getElementById('assetGrid'));
|
||
|
||
// Multi-select bulk actions
|
||
if (window.SelectionManager) {
|
||
SelectionManager.attach({
|
||
getProjectId: () => state.currentProjectId,
|
||
getBins: () => state.bins,
|
||
getProjects: () => state.projects,
|
||
onChange: (info) => {
|
||
if (info.action) {
|
||
const verb = ({move:'moved',copy:'copied',delete:'deleted'})[info.action] || info.action;
|
||
toast(`${info.ok} ${verb}` + (info.fail ? ` · ${info.fail} failed` : ''), '', info.fail ? 'warning' : 'success');
|
||
}
|
||
loadAssets();
|
||
},
|
||
});
|
||
}
|
||
|
||
document.getElementById('uploadBtn').onclick = () => location.href = 'upload.html' + (state.currentProjectId ? `?project=${state.currentProjectId}` : '');
|
||
document.getElementById('emptyUploadBtn').onclick = () => document.getElementById('uploadBtn').click();
|
||
document.getElementById('newProjectBtn').onclick = () => openPanel('project');
|
||
document.getElementById('closeProjectPanel').onclick = () => closePanel('project');
|
||
document.getElementById('cancelProjectBtn').onclick = () => closePanel('project');
|
||
document.getElementById('projectOverlay').onclick = () => closePanel('project');
|
||
document.getElementById('saveProjectBtn').onclick = saveProject;
|
||
document.getElementById('newBinBtn').onclick = () => openPanel('bin');
|
||
document.getElementById('closeBinPanel').onclick = () => closePanel('bin');
|
||
document.getElementById('cancelBinBtn').onclick = () => closePanel('bin');
|
||
document.getElementById('binOverlay').onclick = () => closePanel('bin');
|
||
document.getElementById('saveBinBtn').onclick = saveBin;
|
||
document.getElementById('allAssetsItem').onclick = () => selectBin(null);
|
||
|
||
const params = new URLSearchParams(location.search);
|
||
if (params.get('project')) {
|
||
document.getElementById('projectSelect').value = params.get('project');
|
||
handleProjectChange();
|
||
}
|
||
});
|
||
|
||
// ── Projects ──────────────────────────────
|
||
async function loadProjects() {
|
||
const r = await getProjects();
|
||
if (!r.success) return;
|
||
state.projects = r.data;
|
||
const sel = document.getElementById('projectSelect');
|
||
sel.innerHTML = state.projects.length
|
||
? state.projects.map(p => `<option value="${p.id}">${escHtml(p.name)}</option>`).join('')
|
||
: '<option value="">No projects — create one</option>';
|
||
sel.onchange = handleProjectChange;
|
||
if (state.projects.length) { state.currentProjectId = state.projects[0].id; await loadBinsAndAssets(); }
|
||
}
|
||
|
||
function handleProjectChange() {
|
||
state.currentProjectId = document.getElementById('projectSelect').value || null;
|
||
state.currentBinId = null;
|
||
state.statusFilter = '';
|
||
state.sortBy = 'newest';
|
||
document.querySelectorAll('.filter-chip').forEach(c => c.classList.toggle('active', c.dataset.status === ''));
|
||
document.getElementById('sortSelect').value = 'newest';
|
||
loadBinsAndAssets();
|
||
}
|
||
|
||
async function loadBinsAndAssets() {
|
||
await Promise.all([loadBins(), loadAssets()]);
|
||
}
|
||
|
||
async function loadBins() {
|
||
if (!state.currentProjectId) { renderBins([]); return; }
|
||
const r = await getBins(state.currentProjectId);
|
||
state.bins = r.success ? r.data : [];
|
||
renderBins(state.bins);
|
||
}
|
||
|
||
function renderBins(bins) {
|
||
const tree = document.getElementById('binTree');
|
||
tree.querySelectorAll('.bin-item:not(#allAssetsItem)').forEach(n => n.remove());
|
||
bins.forEach(bin => {
|
||
const el = document.createElement('a');
|
||
el.className = 'bin-item';
|
||
el.dataset.binId = bin.id;
|
||
el.innerHTML = `
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<rect x="1" y="5" width="14" height="9" rx="1"/>
|
||
<path d="M1 5l2.5-3h9L15 5"/>
|
||
</svg>
|
||
<span>${escHtml(bin.name)}</span>`;
|
||
el.onclick = () => selectBin(bin.id);
|
||
tree.appendChild(el);
|
||
});
|
||
updateBinActive();
|
||
}
|
||
|
||
function selectBin(binId) {
|
||
state.currentBinId = binId;
|
||
state.statusFilter = '';
|
||
state.sortBy = 'newest';
|
||
document.querySelectorAll('.filter-chip').forEach(c => c.classList.toggle('active', c.dataset.status === ''));
|
||
document.getElementById('sortSelect').value = 'newest';
|
||
updateBinActive();
|
||
loadAssets();
|
||
}
|
||
|
||
function updateBinActive() {
|
||
document.querySelectorAll('.bin-item').forEach(el => {
|
||
const id = el.dataset.binId || null;
|
||
el.classList.toggle('active', id === state.currentBinId);
|
||
});
|
||
}
|
||
|
||
// ── Assets ────────────────────────────────
|
||
async function loadAssets() {
|
||
if (!state.currentProjectId) { renderAssets([]); return; }
|
||
document.getElementById('assetLoading').style.display = 'flex';
|
||
document.getElementById('assetEmpty').style.display = 'none';
|
||
document.getElementById('assetGrid').innerHTML = '';
|
||
|
||
const filters = { project_id: state.currentProjectId };
|
||
if (state.currentBinId) filters.bin_id = state.currentBinId;
|
||
const r = await getAssets(filters);
|
||
document.getElementById('assetLoading').style.display = 'none';
|
||
state.assets = r.success ? r.data : [];
|
||
renderAssets(state.assets);
|
||
}
|
||
|
||
function renderAssets(assets) {
|
||
const term = state.searchTerm.toLowerCase();
|
||
|
||
// Apply status filter
|
||
let filtered = state.statusFilter
|
||
? assets.filter(a => {
|
||
if (state.statusFilter === 'processing') return a.status === 'processing' || a.status === 'ingesting';
|
||
return a.status === state.statusFilter;
|
||
})
|
||
: assets;
|
||
|
||
// Apply text search
|
||
if (term) filtered = filtered.filter(a =>
|
||
a.filename?.toLowerCase().includes(term) || a.display_name?.toLowerCase().includes(term)
|
||
);
|
||
|
||
// Sort
|
||
filtered = [...filtered].sort((a, b) => {
|
||
switch (state.sortBy) {
|
||
case 'oldest': return new Date(a.created_at) - new Date(b.created_at);
|
||
case 'name': return (a.display_name || a.filename || '').localeCompare(b.display_name || b.filename || '');
|
||
case 'name-desc': return (b.display_name || b.filename || '').localeCompare(a.display_name || a.filename || '');
|
||
case 'duration': return (b.duration_ms || 0) - (a.duration_ms || 0);
|
||
case 'size': return (b.file_size || 0) - (a.file_size || 0);
|
||
default: return new Date(b.created_at) - new Date(a.created_at);
|
||
}
|
||
});
|
||
|
||
const grid = document.getElementById('assetGrid');
|
||
grid.innerHTML = '';
|
||
|
||
const count = filtered.length;
|
||
const totalCount = assets.length;
|
||
document.getElementById('assetCount').textContent =
|
||
count === totalCount ? `${count} asset${count !== 1 ? 's' : ''}` :
|
||
`${count} of ${totalCount} asset${totalCount !== 1 ? 's' : ''}`;
|
||
document.getElementById('assetEmpty').style.display = count === 0 ? 'flex' : 'none';
|
||
|
||
filtered.forEach(asset => {
|
||
const card = document.createElement('div');
|
||
card.className = 'asset-card';
|
||
card.dataset.assetId = asset.id;
|
||
const statusClass = statusBadgeClass(asset.status);
|
||
card.innerHTML = `
|
||
<div class="asset-thumb">
|
||
<div class="asset-thumb-placeholder">
|
||
${mediaIcon(asset.media_type)}
|
||
</div>
|
||
<img data-asset-id="${asset.id}" alt="${escHtml(asset.display_name || asset.filename)}" aria-hidden="false">
|
||
<div class="asset-thumb-overlay">
|
||
<span class="badge ${statusClass}">${escHtml(asset.status)}</span>
|
||
${asset.duration_ms ? `<span class="asset-duration">${formatDuration(asset.duration_ms / 1000)}</span>` : ''}
|
||
</div>
|
||
<div class="asset-actions">
|
||
<button class="asset-action-btn" onclick="openInEditor('${asset.id}', event)" title="Open in Editor">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="3" width="14" height="10" rx="1"/><path d="M1 7h14M5 3v10M5 7l3-2v4l-3-2z"/></svg>
|
||
</button>
|
||
${asset.status === 'error' ? `<button class="asset-action-btn" onclick="handleRetryAsset('${asset.id}', event)" title="Retry processing" style="color:oklch(74% 0.18 55);"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 8a6 6 0 1 0 1-3.5"/><path d="M1 3v3h3"/></svg></button>` : ''}
|
||
<button class="asset-action-btn" onclick="deleteAssetPrompt('${asset.id}', event)" title="Delete">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 4h10M6 4V2h4v2M5 4v9h6V4"/></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="asset-meta">
|
||
<div class="asset-name" data-rename-id="${asset.id}" title="Double-click to rename">${escHtml(asset.display_name || asset.filename)}</div>
|
||
<div class="asset-info">
|
||
<span class="asset-type">${escHtml(asset.media_type || '')}</span>
|
||
<span class="text-tertiary text-xs">${asset.file_size ? formatFileSize(asset.file_size) : ''}</span>
|
||
</div>
|
||
</div>`;
|
||
|
||
const img = card.querySelector('img');
|
||
if (asset.thumbnail_s3_key) thumbObserver.observe(img);
|
||
else img.style.display = 'none';
|
||
|
||
card.addEventListener('click', (e) => {
|
||
if (e.target.closest('.asset-action-btn')) return;
|
||
if (e.target.closest('.asset-name[data-rename-id]')) return; // let rename handle it
|
||
if (window.openAssetPreview) window.openAssetPreview(asset.id);
|
||
});
|
||
|
||
grid.appendChild(card);
|
||
});
|
||
if (window.SelectionManager) SelectionManager.refreshUI();
|
||
}
|
||
|
||
// ── Inline rename ─────────────────────────
|
||
function setupRenameListener(grid) {
|
||
grid.addEventListener('dblclick', async e => {
|
||
const nameEl = e.target.closest('.asset-name[data-rename-id]');
|
||
if (!nameEl) return;
|
||
e.stopPropagation();
|
||
|
||
const assetId = nameEl.dataset.renameId;
|
||
const current = nameEl.textContent;
|
||
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.value = current;
|
||
input.className = 'asset-name-input';
|
||
nameEl.style.display = 'none';
|
||
nameEl.parentNode.insertBefore(input, nameEl.nextSibling);
|
||
input.focus();
|
||
input.select();
|
||
|
||
let saved = false;
|
||
|
||
const save = async () => {
|
||
if (saved) return;
|
||
saved = true;
|
||
const newName = input.value.trim();
|
||
input.remove();
|
||
nameEl.style.display = '';
|
||
if (!newName || newName === current) return;
|
||
const r = await updateAsset(assetId, { display_name: newName });
|
||
if (r.success) {
|
||
nameEl.textContent = newName;
|
||
// Update state cache so re-renders don't revert
|
||
const a = state.assets.find(x => x.id === assetId);
|
||
if (a) a.display_name = newName;
|
||
toast('Renamed', newName, 'success');
|
||
} else {
|
||
toast('Rename failed', r.error, 'error');
|
||
}
|
||
};
|
||
|
||
const cancel = () => {
|
||
if (saved) return;
|
||
saved = true;
|
||
input.remove();
|
||
nameEl.style.display = '';
|
||
};
|
||
|
||
input.addEventListener('blur', save);
|
||
input.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
|
||
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
|
||
});
|
||
});
|
||
}
|
||
|
||
function statusBadgeClass(s) {
|
||
const map = { live:'badge-live', ingesting:'badge-ingesting', processing:'badge-processing', ready:'badge-ready', error:'badge-error', archived:'badge-archived' };
|
||
return map[s] || 'badge-idle';
|
||
}
|
||
|
||
function mediaIcon(type) {
|
||
if (type === 'video') return `<svg viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="1.2" width="28" height="28"><rect x="2" y="6" width="18" height="16" rx="2"/><path d="M20 11l6-3v12l-6-3"/></svg>`;
|
||
if (type === 'audio') return `<svg viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="1.2" width="28" height="28"><path d="M14 3v16M10 7v8M6 10v4M18 7v8M22 10v4"/></svg>`;
|
||
if (type === 'image') return `<svg viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="1.2" width="28" height="28"><rect x="3" y="3" width="22" height="22" rx="2"/><circle cx="10" cy="10" r="2.5"/><path d="M3 20l6-5 5 4 4-3 7 5"/></svg>`;
|
||
return `<svg viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="1.2" width="28" height="28"><path d="M8 4H5v20h18V10l-6-6H8z"/><path d="M17 4v6h5"/></svg>`;
|
||
}
|
||
|
||
async function deleteAssetPrompt(id, e) {
|
||
e.stopPropagation();
|
||
if (!confirm('Delete this asset? It will be archived and hidden from the library.')) return;
|
||
const r = await deleteAsset(id);
|
||
if (r.success) { toast('Asset deleted', '', 'success'); loadAssets(); }
|
||
else toast('Delete failed', r.error, 'error');
|
||
}
|
||
|
||
async function handleRetryAsset(id, e) {
|
||
e.stopPropagation();
|
||
const r = await retryAsset(id);
|
||
if (r.success) { toast('Asset queued for reprocessing', '', 'success'); loadAssets(); }
|
||
else toast('Retry failed', r.error, 'error');
|
||
}
|
||
|
||
function openInEditor(assetId, e) {
|
||
e.stopPropagation();
|
||
const projectId = state.currentProjectId;
|
||
if (!projectId) { toast('Select a project first', '', 'warning'); return; }
|
||
location.href = 'editor.html?project=' + projectId + '&asset=' + assetId;
|
||
}
|
||
|
||
// ── Search ────────────────────────────────
|
||
function setupSearch() {
|
||
const inp = document.getElementById('searchInput');
|
||
inp.addEventListener('input', () => {
|
||
state.searchTerm = inp.value;
|
||
renderAssets(state.assets);
|
||
});
|
||
}
|
||
|
||
// ── Filter chips + sort ───────────────────
|
||
function setupFilters() {
|
||
document.querySelectorAll('.filter-chip').forEach(chip => {
|
||
chip.addEventListener('click', () => {
|
||
state.statusFilter = chip.dataset.status;
|
||
document.querySelectorAll('.filter-chip').forEach(c =>
|
||
c.classList.toggle('active', c.dataset.status === state.statusFilter)
|
||
);
|
||
renderAssets(state.assets);
|
||
});
|
||
});
|
||
|
||
document.getElementById('sortSelect').addEventListener('change', e => {
|
||
state.sortBy = e.target.value;
|
||
renderAssets(state.assets);
|
||
});
|
||
}
|
||
|
||
// ── New project ───────────────────────────
|
||
async function saveProject() {
|
||
const name = document.getElementById('newProjectName').value.trim();
|
||
if (!name) return;
|
||
const r = await createProject(name, document.getElementById('newProjectDesc').value.trim());
|
||
if (r.success) {
|
||
toast('Project created', name, 'success');
|
||
closePanel('project');
|
||
document.getElementById('newProjectName').value = '';
|
||
document.getElementById('newProjectDesc').value = '';
|
||
await loadProjects();
|
||
document.getElementById('projectSelect').value = r.data.id;
|
||
handleProjectChange();
|
||
} else toast('Failed to create project', r.error, 'error');
|
||
}
|
||
|
||
// ── New bin ──────────────────────────────
|
||
async function saveBin() {
|
||
if (!state.currentProjectId) { toast('Select a project first', '', 'warning'); return; }
|
||
const name = document.getElementById('newBinName').value.trim();
|
||
if (!name) return;
|
||
const r = await createBin(state.currentProjectId, name);
|
||
if (r.success) {
|
||
toast('Bin created', name, 'success');
|
||
closePanel('bin');
|
||
document.getElementById('newBinName').value = '';
|
||
await loadBins();
|
||
} else toast('Failed to create bin', r.error, 'error');
|
||
}
|
||
|
||
// ── Drag upload ───────────────────────────
|
||
function setupDrag() {
|
||
let dragCount = 0;
|
||
const overlay = document.getElementById('dropOverlay');
|
||
document.addEventListener('dragenter', e => { e.preventDefault(); dragCount++; overlay.classList.add('active'); });
|
||
document.addEventListener('dragleave', () => { if (--dragCount <= 0) { dragCount = 0; overlay.classList.remove('active'); } });
|
||
document.addEventListener('dragover', e => e.preventDefault());
|
||
document.addEventListener('drop', e => {
|
||
e.preventDefault();
|
||
dragCount = 0;
|
||
overlay.classList.remove('active');
|
||
if (!state.currentProjectId) { toast('Select a project first', '', 'warning'); return; }
|
||
const files = [...e.dataTransfer.files].filter(f => f.type.startsWith('video/') || f.type.startsWith('audio/') || f.type.startsWith('image/'));
|
||
if (files.length === 0) { toast('No supported media files', '', 'warning'); return; }
|
||
sessionStorage.setItem('pendingFiles', JSON.stringify(files.map(f => f.name)));
|
||
location.href = `upload.html?project=${state.currentProjectId}`;
|
||
});
|
||
}
|
||
|
||
// ── Panel helpers ─────────────────────────
|
||
function openPanel(name) {
|
||
document.getElementById(name + 'Panel').classList.add('open');
|
||
document.getElementById(name + 'Overlay').classList.add('open');
|
||
}
|
||
function closePanel(name) {
|
||
document.getElementById(name + 'Panel').classList.remove('open');
|
||
document.getElementById(name + 'Overlay').classList.remove('open');
|
||
}
|
||
|
||
// ── Toast ────────────────────────────────
|
||
function toast(title, msg, type = 'info') {
|
||
const icons = {
|
||
success: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.5"/><path d="M5 8l2 2 4-4"/></svg>`,
|
||
error: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.5"/><path d="M8 5v4M8 11v.5"/></svg>`,
|
||
warning: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2L1 14h14L8 2z"/><path d="M8 7v3M8 12v.5"/></svg>`,
|
||
info: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.5"/><path d="M8 7v5M8 5v.5"/></svg>`,
|
||
};
|
||
const el = document.createElement('div');
|
||
el.className = `toast toast--${type}`;
|
||
el.innerHTML = `<div class="toast-icon">${icons[type]||icons.info}</div><div class="toast-body"><div class="toast-title">${escHtml(title)}</div>${msg?`<div class="toast-msg">${escHtml(msg)}</div>`:''}</div>`;
|
||
document.getElementById('toastContainer').appendChild(el);
|
||
setTimeout(() => el.remove(), 4000);
|
||
}
|
||
|
||
function escHtml(s) {
|
||
if (!s) return '';
|
||
return String(s).replace(/&/g,'&').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>
|
||
<script src="js/auth-guard.js"></script>
|
||
</body>
|
||
</html>
|