chore(web-ui): delete legacy standalone HTML pages; SPA is the only entry #27

Merged
zgaetano merged 1 commit from chore/cleanup-legacy-html into main 2026-05-23 16:49:39 -04:00
18 changed files with 3 additions and 9234 deletions

View file

@ -95,9 +95,9 @@ server {
proxy_request_buffering off;
}
# SPA fallback - try to serve file, else route to index.html
# SPA fallback - try to serve file, else route to the React shell.
location / {
try_files $uri $uri/ /home.html;
try_files $uri $uri/ /index.html;
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}

View file

@ -1,226 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Primitives smoke test</title>
<link rel="stylesheet" href="/dist/app.css">
</head>
<body>
<div style="display:flex; min-height:100vh;">
<nav class="wd-sidebar">
<div class="wd-sidebar-header">
<div style="width:18px;height:18px;background:var(--accent);border-radius:3px"></div>
<span class="wd-sidebar-brand">Dragonflight</span>
</div>
<div class="wd-sidebar-nav">
<a href="#" class="wd-nav-item is-active"><span style="width:14px;height:14px;background:currentColor;opacity:0.6"></span>Home</a>
<a href="#" class="wd-nav-item"><span style="width:14px;height:14px;background:currentColor;opacity:0.6"></span>Library</a>
<a href="#" class="wd-nav-item"><span style="width:14px;height:14px;background:currentColor;opacity:0.6"></span>Recorders</a>
<div class="wd-sidebar-section">Admin</div>
<a href="#" class="wd-nav-item"><span style="width:14px;height:14px;background:currentColor;opacity:0.6"></span>Settings <span class="nav-dev-badge">IN DEV</span></a>
</div>
</nav>
<div style="flex:1;display:flex;flex-direction:column;">
<header class="wd-topbar">
<div class="wd-topbar-left">
<nav class="wd-breadcrumb">
<span class="wd-breadcrumb-crumb">Library</span>
<svg class="wd-breadcrumb-sep" viewBox="0 0 10 10"><path d="M3 1l4 4-4 4" fill="none" stroke="currentColor"/></svg>
<span class="wd-breadcrumb-crumb">Project Alpha</span>
<svg class="wd-breadcrumb-sep" viewBox="0 0 10 10"><path d="M3 1l4 4-4 4" fill="none" stroke="currentColor"/></svg>
<span class="wd-breadcrumb-crumb">Key Scenes</span>
</nav>
</div>
<div class="wd-topbar-center">
<div class="wd-topbar-search">
<svg viewBox="0 0 12 12"><circle cx="5" cy="5" r="3.5" fill="none" stroke="currentColor"/><path d="M8 8l3 3" stroke="currentColor"/></svg>
<input type="text" placeholder="Search in Key Scenes...">
</div>
</div>
<div class="wd-topbar-right">
<button class="wd-btn wd-btn--ghost wd-btn--sm wd-btn--icon" aria-label="Filter">f</button>
<button class="wd-btn wd-btn--ghost wd-btn--sm wd-btn--icon" aria-label="Sort">s</button>
<div class="wd-topbar-divider"></div>
<button class="wd-btn wd-btn--primary wd-btn--sm">+ New asset</button>
</div>
</header>
<main style="flex:1; padding:20px 20px 32px;">
<h2 style="font:600 13px/1 var(--font); letter-spacing:0.08em; text-transform:uppercase; color:var(--text-tertiary); margin:0 0 12px;">Asset cards</h2>
<div class="wd-card-asset-grid" style="margin-bottom:32px;">
<article class="wd-card-asset">
<div class="wd-card-asset-thumb" style="background:linear-gradient(135deg,#333,#111)">
<span class="wd-card-asset-chip wd-card-asset-chip--duration">00:30</span>
<span class="wd-card-asset-chip wd-card-asset-chip--comments">2</span>
</div>
<div class="wd-card-asset-meta">
<div class="wd-card-asset-name">DRP_B004_081606_V1_0099.mov</div>
<div class="wd-card-asset-sub">Alissa Morris · Oct 14th, 2024</div>
</div>
<button class="wd-card-asset-role" style="color:var(--signal-warn)">Coloring</button>
</article>
<article class="wd-card-asset">
<div class="wd-card-asset-thumb" style="background:linear-gradient(135deg,#553,#221)">
<span class="wd-card-asset-chip wd-card-asset-chip--duration">00:05</span>
<span class="wd-card-asset-chip wd-card-asset-chip--version">V2</span>
</div>
<div class="wd-card-asset-meta">
<div class="wd-card-asset-name">DRP_A015_0815OF_V1_0023.mov</div>
<div class="wd-card-asset-sub">Alissa Morris · Oct 14th, 2024</div>
</div>
<button class="wd-card-asset-role wd-card-asset-role--unset">Select role</button>
</article>
</div>
<h2 style="font:600 13px/1 var(--font); letter-spacing:0.08em; text-transform:uppercase; color:var(--text-tertiary); margin:0 0 12px;">Operational cards</h2>
<div class="wd-card-op-grid" style="margin-bottom:32px;">
<article class="wd-card-op is-active">
<header class="wd-card-op-header">
<span class="wd-card-op-name">Studio A SRT</span>
<span class="wd-badge wd-badge--bad"><span class="wd-dot wd-dot--bad"></span>Recording</span>
</header>
<div class="wd-card-op-content">
<div class="wd-signal-strip"><div class="wd-signal-strip-fill wd-sweep"></div></div>
<div style="display:flex; justify-content:space-between; align-items:center;">
<span>Receiving · 12450 fr · 30 fps</span>
<span style="font-family:var(--font-mono); font-variant-numeric:tabular-nums; color:var(--signal-bad); font-weight:600;">00:23:14</span>
</div>
</div>
<footer class="wd-card-op-footer">
<span class="wd-card-op-meta">Last: Oct 14th, 2024 4:00 PM</span>
<div class="wd-card-op-actions">
<button class="wd-btn wd-btn--danger wd-btn--sm">Stop</button>
</div>
</footer>
</article>
<article class="wd-card-op">
<header class="wd-card-op-header">
<span class="wd-card-op-name">zampp2</span>
<span class="wd-badge wd-badge--good"><span class="wd-dot wd-dot--good"></span>Online</span>
</header>
<div class="wd-card-op-content">
<div>
<div style="display:flex; justify-content:space-between; font:400 11px/1 var(--font-mono); color:var(--text-tertiary); margin-bottom:4px;">
<span>CPU</span><span>42%</span>
</div>
<div class="wd-mini-bar"><div class="wd-mini-bar-fill" style="width:42%"></div></div>
</div>
<div>
<div style="display:flex; justify-content:space-between; font:400 11px/1 var(--font-mono); color:var(--text-tertiary); margin-bottom:4px;">
<span>Memory</span><span>71%</span>
</div>
<div class="wd-mini-bar"><div class="wd-mini-bar-fill wd-mini-bar-fill--warn" style="width:71%"></div></div>
</div>
</div>
<footer class="wd-card-op-footer">
<span class="wd-card-op-meta">DeckLink Duo 2 · 4 ports</span>
<div class="wd-card-op-actions">
<button class="wd-btn wd-btn--ghost wd-btn--sm">Ping</button>
</div>
</footer>
</article>
</div>
<h2 style="font:600 13px/1 var(--font); letter-spacing:0.08em; text-transform:uppercase; color:var(--text-tertiary); margin:0 0 12px;">List rows</h2>
<div class="wd-list" style="margin-bottom:32px; border:1px solid var(--border-faint); border-radius:6px; overflow:hidden;">
<div class="wd-list-header">
<span style="flex:1">Name</span>
<span>Image</span>
<span>Status</span>
<span>Actions</span>
</div>
<div class="wd-list-row is-selected">
<span class="wd-list-cell wd-list-cell--name">wild-dragon-mam-api-1</span>
<span class="wd-list-cell wd-list-cell--meta">wild-dragon-mam-api:latest</span>
<span><span class="wd-badge wd-badge--good">Up</span></span>
<span class="wd-list-cell--actions"><button class="wd-btn wd-btn--ghost wd-btn--sm">Logs</button></span>
</div>
<div class="wd-list-row">
<span class="wd-list-cell wd-list-cell--name">wild-dragon-web-ui-1</span>
<span class="wd-list-cell wd-list-cell--meta">wild-dragon-web-ui:latest</span>
<span><span class="wd-badge wd-badge--good">Up</span></span>
<span class="wd-list-cell--actions"><button class="wd-btn wd-btn--ghost wd-btn--sm">Logs</button></span>
</div>
</div>
<h2 style="font:600 13px/1 var(--font); letter-spacing:0.08em; text-transform:uppercase; color:var(--text-tertiary); margin:0 0 12px;">Buttons</h2>
<div style="display:flex; gap:12px; flex-wrap:wrap; margin-bottom:32px;">
<button class="wd-btn wd-btn--primary wd-btn--sm">Primary sm</button>
<button class="wd-btn wd-btn--primary wd-btn--md">Primary md</button>
<button class="wd-btn wd-btn--secondary wd-btn--md">Secondary md</button>
<button class="wd-btn wd-btn--ghost wd-btn--md">Ghost md</button>
<button class="wd-btn wd-btn--danger wd-btn--md">Danger md</button>
<button class="wd-btn wd-btn--primary wd-btn--md" disabled>Disabled</button>
</div>
<h2 style="font:600 13px/1 var(--font); letter-spacing:0.08em; text-transform:uppercase; color:var(--text-tertiary); margin:0 0 12px;">Form controls</h2>
<div style="max-width:460px; display:flex; flex-direction:column; gap:14px; margin-bottom:32px;">
<div class="wd-form-group">
<label class="wd-label" for="smoke-input">Recorder name</label>
<input class="wd-input" id="smoke-input" placeholder="e.g. Studio A SRT">
<div class="wd-hint">Letters, numbers, dashes. Used in clip filenames.</div>
</div>
<div class="wd-form-row">
<div class="wd-form-group">
<label class="wd-label" for="smoke-select">Codec</label>
<select class="wd-select" id="smoke-select">
<option>ProRes 422 HQ</option>
<option>H.264 NVENC</option>
</select>
</div>
<div class="wd-form-group">
<label class="wd-label" for="smoke-fps">Framerate</label>
<input class="wd-input" id="smoke-fps" value="29.97">
</div>
</div>
<label class="wd-toggle">
<input type="checkbox" checked>
<span class="wd-toggle-track"></span>
<span class="wd-toggle-label">Generate proxy</span>
</label>
</div>
<h2 style="font:600 13px/1 var(--font); letter-spacing:0.08em; text-transform:uppercase; color:var(--text-tertiary); margin:0 0 12px;">Field group</h2>
<div style="max-width:460px; margin-bottom:32px;">
<div class="wd-field-group">
<div class="wd-field-group-header">
<span class="wd-field-group-title">Master recording</span>
</div>
<div class="wd-tabs">
<button class="wd-tab is-active">Video</button>
<button class="wd-tab">Audio</button>
<button class="wd-tab">Container</button>
</div>
<div class="wd-tab-panel is-active">
<div class="wd-form-row">
<div class="wd-form-group">
<label class="wd-label">Codec</label>
<select class="wd-select"><option>ProRes 422 HQ</option></select>
</div>
<div class="wd-form-group">
<label class="wd-label">Resolution</label>
<select class="wd-select"><option>1920x1080</option></select>
</div>
</div>
</div>
</div>
</div>
<h2 style="font:600 13px/1 var(--font); letter-spacing:0.08em; text-transform:uppercase; color:var(--text-tertiary); margin:0 0 12px;">Empty state</h2>
<div class="wd-empty">
<svg class="wd-empty-icon" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="6" width="18" height="16" rx="2"/><path d="M20 11l6-3v12l-6-3"/>
</svg>
<div class="wd-empty-title">No recorders yet</div>
<div class="wd-empty-body">Create a recorder to ingest live streams via SRT, RTMP, or SDI.</div>
<div class="wd-empty-actions">
<button class="wd-btn wd-btn--primary wd-btn--sm">+ New recorder</button>
</div>
</div>
</main>
</div>
</div>
</body>
</html>

View file

@ -1,352 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Tokens — Dragonflight</title>
<link rel="stylesheet" href="/dist/app.css">
<style>
.token-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: var(--sp-4);
display: flex;
align-items: center;
gap: var(--sp-4);
}
.token-card-icon {
width: 36px;
height: 36px;
border-radius: var(--r-md);
background: var(--accent-subtle);
border: 1px solid var(--accent-border);
display: flex;
align-items: center;
justify-content: center;
color: var(--accent);
flex-shrink: 0;
}
.token-card-body { flex: 1; min-width: 0; }
.token-card-name { font-weight: 500; font-size: var(--text-sm); }
.token-card-meta { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; }
.token-prefix {
font-family: 'SF Mono', 'Consolas', monospace;
font-size: var(--text-xs);
color: var(--text-secondary);
background: var(--bg-raised);
padding: 1px 6px;
border-radius: var(--r-sm);
border: 1px solid var(--border);
}
.tokens-list {
display: flex;
flex-direction: column;
gap: var(--sp-2);
max-width: 680px;
}
.new-token-banner {
background: var(--status-green-bg);
border: 1px solid oklch(68% 0.18 148 / 0.30);
border-radius: var(--r-lg);
padding: var(--sp-4) var(--sp-5);
margin-bottom: var(--sp-5);
max-width: 680px;
}
.new-token-banner-title {
font-size: var(--text-sm);
font-weight: 500;
color: var(--status-green);
margin-bottom: var(--sp-2);
display: flex;
align-items: center;
gap: var(--sp-2);
}
.new-token-banner-warning {
font-size: var(--text-xs);
color: var(--text-secondary);
margin-top: var(--sp-3);
}
.copy-btn {
margin-top: var(--sp-3);
}
</style>
</head>
<body>
<div class="wd-shell" style="display:flex;min-height:100vh;">
<!-- Sidebar -->
<nav class="wd-sidebar" aria-label="Main navigation">
<div class="wd-sidebar-header">
<img src="img/dragon-logo.png?v=1" alt="Dragonflight" style="width:18px;height:18px;">
<span class="wd-sidebar-brand">Dragonflight</span>
</div>
<div class="wd-sidebar-nav">
<a href="home.html" class="wd-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="wd-nav-item">
<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="wd-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="wd-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="wd-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="wd-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="wd-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="edit.html" class="wd-nav-item" target="_blank" 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>
<div class="wd-sidebar-section">Admin</div>
<a href="users.html" class="wd-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="wd-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>
</div>
<div class="wd-sidebar-footer">
<div class="wd-sidebar-user">
<div class="wd-sidebar-user-avatar" id="userAvatar">?</div>
<div class="wd-sidebar-user-info">
<div class="wd-sidebar-user-name" id="userName"></div>
<div class="wd-sidebar-user-role" id="userRole"></div>
</div>
<button class="wd-btn wd-btn--ghost wd-btn--sm wd-btn--icon wd-sidebar-user-logout" id="logoutBtn" title="Sign out">
<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 style="flex:1;display:flex;flex-direction:column;">
<header class="wd-topbar">
<div class="wd-topbar-left">
<span class="page-title">API Tokens</span>
</div>
<div class="wd-topbar-right">
<button class="wd-btn wd-btn--primary wd-btn--sm" onclick="openNewTokenPanel()">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v12M2 8h12"/></svg>
New token
</button>
</div>
</header>
<div style="flex:1;overflow:auto;padding:24px;">
<div style="max-width:680px;margin-bottom:var(--sp-5);">
<p class="text-sm text-secondary" style="line-height:1.7;">
API tokens let scripts and integrations authenticate as you without using your password.
Tokens are shown once at creation — store them securely.
Use <code style="font-family:monospace;font-size:11px;background:var(--bg-surface);padding:1px 5px;border-radius:3px;border:1px solid var(--border)">Authorization: Bearer &lt;token&gt;</code> in your requests.
</p>
</div>
<!-- New token reveal (shown after creation) -->
<div id="newTokenBanner" style="display:none;" class="new-token-banner">
<div class="new-token-banner-title">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><circle cx="8" cy="8" r="6.5"/><path d="M5 8l2 2 4-4"/></svg>
Token created
</div>
<div class="token-reveal" id="newTokenValue"></div>
<div class="new-token-banner-warning">
⚠ This is the only time this token will be shown. Copy it now.
</div>
<button class="wd-btn wd-btn--secondary wd-btn--sm copy-btn" onclick="copyToken()">Copy to clipboard</button>
</div>
<!-- Token list -->
<div class="tokens-list" id="tokensList">
<div style="color:var(--text-tertiary);font-size:var(--text-sm)">Loading…</div>
</div>
<div id="tokensEmpty" class="wd-empty" style="display:none;padding:var(--sp-12) 0;">
<div class="wd-empty-icon">
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="15" cy="25" r="8"/><path d="M21 19l10-10M28 10l3 3M30 8l2 2"/></svg>
</div>
<div class="wd-empty-title">No tokens yet</div>
<div class="wd-empty-body">Create a token to authenticate API requests without a password.</div>
<div class="wd-empty-actions">
<button class="wd-btn wd-btn--primary wd-btn--sm" onclick="openNewTokenPanel()">New token</button>
</div>
</div>
</div>
</div>
</div>
<!-- New token slide panel -->
<div class="wd-slide-overlay" id="tokenOverlay" onclick="closeNewTokenPanel()"></div>
<div class="wd-slide-panel" id="tokenPanel">
<div class="wd-slide-panel-header">
<span class="wd-slide-panel-title">New API token</span>
<button class="wd-btn wd-btn--ghost wd-btn--sm" onclick="closeNewTokenPanel()" 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="wd-slide-panel-body">
<div class="wd-form-group">
<label class="wd-label" for="tokenName">Token name</label>
<input type="text" id="tokenName" placeholder="e.g. Premiere Plugin, CI/CD Script">
<div class="wd-hint">A label to help you remember what this token is for.</div>
</div>
<div class="wd-form-group">
<label class="wd-label" for="tokenExpiry">Expiry</label>
<select id="tokenExpiry" class="wd-select">
<option value="">No expiry</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
<option value="365">1 year</option>
</select>
</div>
</div>
<div class="wd-slide-panel-footer">
<button class="wd-btn wd-btn--ghost" onclick="closeNewTokenPanel()">Cancel</button>
<button class="wd-btn wd-btn--primary" id="createTokenBtn" onclick="createNewToken()">Create token</button>
</div>
</div>
<div class="wd-toast-container" id="toastContainer" aria-live="polite"></div>
<script src="js/api.js?v=6"></script>
<script>
let latestToken = null;
document.addEventListener('DOMContentLoaded', loadTokens);
async function loadTokens() {
const r = await getTokens();
const list = document.getElementById('tokensList');
const empty = document.getElementById('tokensEmpty');
if (!r.success) {
list.innerHTML = `<div class="text-sm text-tertiary">Could not load tokens.</div>`;
return;
}
const tokens = r.data;
if (!tokens.length) {
list.style.display = 'none';
empty.style.display = 'flex';
return;
}
list.style.display = 'flex';
empty.style.display = 'none';
list.innerHTML = tokens.map(t => {
const created = t.created_at ? new Date(t.created_at).toLocaleDateString() : '—';
const lastUsed = t.last_used_at ? new Date(t.last_used_at).toLocaleDateString() : 'Never';
const expires = t.expires_at ? new Date(t.expires_at).toLocaleDateString() : 'Never';
const isExpired = t.expires_at && new Date(t.expires_at) < new Date();
return `
<div class="token-card">
<div class="token-card-icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16"><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>
</div>
<div class="token-card-body">
<div class="token-card-name">${esc(t.name)}</div>
<div class="token-card-meta">
<span class="token-prefix">${esc(t.token_prefix)}…</span>
&nbsp;·&nbsp; Created ${created}
&nbsp;·&nbsp; Last used: ${lastUsed}
&nbsp;·&nbsp; Expires: ${isExpired ? '<span style="color:var(--status-red)">Expired</span>' : expires}
</div>
</div>
<button class="wd-btn wd-btn--danger wd-btn--sm" onclick="confirmRevoke('${t.id}',${esc(JSON.stringify(t.name))})">Revoke</button>
</div>`;
}).join('');
}
function openNewTokenPanel() {
document.getElementById('tokenName').value = '';
document.getElementById('tokenExpiry').value = '';
document.getElementById('tokenPanel').classList.add('is-open');
document.getElementById('tokenOverlay').classList.add('is-open');
}
function closeNewTokenPanel() {
document.getElementById('tokenPanel').classList.remove('is-open');
document.getElementById('tokenOverlay').classList.remove('is-open');
}
async function createNewToken() {
const name = document.getElementById('tokenName').value.trim();
if (!name) { toast('Token name required', '', 'warning'); return; }
const expires_in_days = document.getElementById('tokenExpiry').value || null;
const btn = document.getElementById('createTokenBtn');
btn.disabled = true;
const r = await createToken({ name, expires_in_days: expires_in_days ? parseInt(expires_in_days) : null });
btn.disabled = false;
if (r.success) {
closeNewTokenPanel();
latestToken = r.data.token;
document.getElementById('newTokenValue').textContent = latestToken;
document.getElementById('newTokenBanner').style.display = 'block';
document.getElementById('newTokenBanner').scrollIntoView({ behavior: 'smooth' });
toast('Token created', name, 'success');
loadTokens();
} else {
toast('Failed to create token', r.error, 'error');
}
}
async function confirmRevoke(id, name) {
if (!confirm(`Revoke token "${name}"? Any scripts using it will stop working.`)) return;
const r = await revokeToken(id);
if (r.success) { toast('Token revoked', name, 'success'); loadTokens(); }
else toast('Failed to revoke token', r.error, 'error');
}
function copyToken() {
if (!latestToken) return;
navigator.clipboard.writeText(latestToken).then(() => {
toast('Copied to clipboard', '', 'success');
}).catch(() => {
toast('Copy failed — select and copy manually', '', 'warning');
});
}
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 = `wd-toast wd-toast--${type}`;
el.innerHTML = `<div class="toast-icon">${icons[type]||icons.info}</div><div class="toast-body"><div class="toast-title">${esc(title)}</div>${msg?`<div class="toast-msg">${esc(msg)}</div>`:''}</div>`;
document.getElementById('toastContainer').appendChild(el);
setTimeout(() => el.remove(), 4000);
}
function esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
<script src="js/auth-guard.js"></script>
</body>
</html>

View file

@ -1,562 +0,0 @@
<!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>Capture — Dragonflight</title>
<link rel="stylesheet" href="/dist/app.css">
<style>
.capture-layout {
display: grid;
grid-template-columns: 340px 1fr;
gap: var(--sp-6);
max-width: 960px;
}
/* Control panel */
.capture-controls {
display: flex;
flex-direction: column;
gap: var(--sp-5);
}
/* Timecode display */
.timecode-block {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: var(--sp-5) var(--sp-6);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-3);
}
.timecode-label {
font-size: var(--text-xs);
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.timecode-display {
font-family: var(--font-mono);
font-size: 64px;
font-weight: 500;
font-variant-numeric: tabular-nums;
letter-spacing: 0.05em;
color: var(--accent);
text-shadow: 0 0 18px oklch(55% 0.20 32 / 0.30);
line-height: 1;
}
.timecode-display.inactive {
color: var(--text-tertiary);
text-shadow: none;
}
.timecode-status {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--text-xs);
color: var(--text-secondary);
}
/* Record button */
.record-btn-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-3);
}
.record-btn {
width: 80px;
height: 80px;
border-radius: 50%;
background: oklch(20% 0.010 25);
border: 3px solid oklch(30% 0.010 25);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast);
}
.record-btn:hover {
background: oklch(25% 0.012 25);
border-color: var(--status-red);
}
.record-btn.recording {
background: var(--status-red);
border-color: var(--status-red);
box-shadow: 0 0 0 4px oklch(62% 0.22 25 / 0.20);
animation: rec-pulse 2s ease-out infinite;
}
.record-btn-inner {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--status-red);
transition: border-radius var(--t-fast), width var(--t-fast), height var(--t-fast);
}
.record-btn.recording .record-btn-inner {
border-radius: var(--r-sm);
width: 22px;
height: 22px;
background: oklch(98% 0.005 25);
}
@keyframes rec-pulse {
0% { box-shadow: 0 0 0 4px oklch(62% 0.22 25 / 0.25); }
50% { box-shadow: 0 0 0 10px oklch(62% 0.22 25 / 0.08); }
100% { box-shadow: 0 0 0 4px oklch(62% 0.22 25 / 0.25); }
}
.record-btn-label {
font-size: var(--text-sm);
color: var(--text-secondary);
font-weight: 500;
}
.record-btn-label.recording { color: var(--status-red); }
/* Settings panel */
.capture-settings {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: var(--sp-4);
display: flex;
flex-direction: column;
gap: var(--sp-4);
}
.settings-title {
font-size: var(--text-xs);
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
padding-bottom: var(--sp-3);
border-bottom: 1px solid var(--border);
}
/* Status bar */
.status-bar {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
font-size: var(--text-xs);
color: var(--text-secondary);
}
.status-bar-dot { flex-shrink: 0; }
.status-bar-text { flex: 1; }
.status-bar-file { font-family: 'SF Mono', monospace; font-size: 11px; color: var(--text-tertiary); }
/* Recent captures table */
.recent-section { display: flex; flex-direction: column; gap: var(--sp-3); }
.recent-title {
font-size: var(--text-xs);
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
}
</style>
</head>
<body>
<div class="wd-shell" style="display:flex;min-height:100vh;">
<nav class="wd-sidebar" aria-label="Main navigation">
<div class="wd-sidebar-header">
<img src="img/dragon-logo.png?v=1" alt="Dragonflight" style="width:18px;height:18px;">
<span class="wd-sidebar-brand">Dragonflight</span>
</div>
<div class="wd-sidebar-nav">
<a href="home.html" class="wd-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="wd-nav-item">
<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="wd-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="wd-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="wd-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="wd-nav-item is-active">
<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="wd-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="wd-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="wd-sidebar-section">Admin</div>
<a href="users.html" class="wd-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="wd-nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
Tokens
</a>
<a href="containers.html" class="wd-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="wd-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>
<a href="settings.html" class="wd-nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M2.9 2.9l1.1 1.1M12 12l1.1 1.1M2.9 13.1L4 12M12 4l1.1-1.1"/></svg>
Settings
</a>
</div>
<div class="wd-sidebar-footer">
<div class="wd-sidebar-user">
<div class="wd-sidebar-user-avatar" id="userAvatar">?</div>
<div class="wd-sidebar-user-info">
<div class="wd-sidebar-user-name" id="userName"></div>
<div class="wd-sidebar-user-role" id="userRole"></div>
</div>
<button class="wd-btn wd-btn--ghost wd-btn--sm wd-btn--icon wd-sidebar-user-logout" id="logoutBtn" title="Sign out">
<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>
<div style="flex:1;display:flex;flex-direction:column;">
<header class="wd-topbar">
<div class="wd-topbar-left">
<span class="page-title">Capture</span>
<span class="wd-topbar-divider">/</span>
<span class="text-sm text-secondary">Direct SDI</span>
</div>
<div class="wd-topbar-right">
<span class="text-xs text-tertiary" id="deviceStatus">Loading devices…</span>
</div>
</header>
<div style="flex:1;overflow:auto;padding:24px;">
<!-- No-device empty state (shown when no SDI cards found) -->
<div id="noDeviceState" class="wd-empty" style="display:none;">
<div class="wd-empty-icon">
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="10" width="34" height="22" rx="2"/><path d="M13 32v4M27 32v4M9 36h22"/><path d="M16 19l4-3 4 3-1.5 5h-5L16 19z" stroke-opacity="0.5"/></svg>
</div>
<div class="wd-empty-title">No SDI devices found</div>
<div class="wd-empty-body">This machine has no DeckLink cards installed. To ingest live streams via SRT or RTMP, use the Recorders page instead.</div>
<div class="wd-empty-actions">
<a href="recorders.html" class="wd-btn wd-btn--primary wd-btn--sm">Go to Recorders</a>
<button class="wd-btn wd-btn--ghost wd-btn--sm" onclick="initCapture()">Retry</button>
</div>
</div>
<!-- Main capture layout (shown when devices found) -->
<div class="capture-layout" id="captureLayout" style="display:none;">
<!-- Left: controls -->
<div class="capture-controls">
<!-- Timecode -->
<div class="timecode-block">
<div class="timecode-label">Elapsed</div>
<div class="timecode-display inactive" id="timecodeDisplay">00:00:00:00</div>
<div class="timecode-status">
<span class="status-dot status-dot--idle" id="recStatusDot"></span>
<span id="recStatusLabel">Ready</span>
</div>
</div>
<!-- Record button -->
<div class="record-btn-wrap">
<button class="record-btn" id="recordBtn" onclick="toggleRecord()" aria-label="Start recording" title="Start/stop recording">
<div class="record-btn-inner"></div>
</button>
<div class="record-btn-label" id="recordBtnLabel">Record</div>
</div>
<!-- Status bar -->
<div class="status-bar" id="statusBar">
<span class="status-bar-dot status-dot status-dot--idle" id="statusDot"></span>
<span class="status-bar-text" id="statusText">Select device and project to begin</span>
</div>
</div>
<!-- Right: settings + recent -->
<div style="display:flex;flex-direction:column;gap:var(--sp-5);">
<!-- Settings -->
<div class="capture-settings">
<div class="settings-title">Source</div>
<div class="wd-form-group">
<label class="wd-label" for="deviceSelect">Device</label>
<select id="deviceSelect"></select>
</div>
</div>
<div class="capture-settings">
<div class="settings-title">Destination</div>
<div class="wd-form-group">
<label class="wd-label" for="projectSelect">Project</label>
<select id="projectSelect">
<option value="">Select project…</option>
</select>
</div>
<div class="wd-form-group">
<label class="wd-label" for="binSelect">Bin</label>
<select id="binSelect">
<option value="">Project root</option>
</select>
</div>
<div class="wd-form-group">
<label class="wd-label" for="clipName">Clip name</label>
<input type="text" id="clipName" placeholder="Auto-generated if blank">
</div>
</div>
<!-- Recent captures -->
<div class="recent-section">
<div class="recent-title">Recent captures</div>
<div id="recentList">
<div class="wd-empty" style="padding:var(--sp-8) 0;">
<div class="wd-empty-body">No recent captures</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="wd-toast-container" id="toastContainer" aria-live="polite"></div>
<script src="js/api.js?v=6"></script>
<script src="js/topbar-strip.js?v=1"></script>
<script>
const cState = {
devices: [],
projects: [],
currentDevice: null,
isRecording: false,
startedAt: null,
tcInterval: null,
};
document.addEventListener('DOMContentLoaded', () => {
initCapture();
document.getElementById('projectSelect').onchange = handleProjectChange;
});
async function initCapture() {
document.getElementById('deviceStatus').textContent = 'Loading devices…';
const [devRes, projRes, recRes] = await Promise.all([
getCaptureDevices(),
getProjects(),
getRecentCaptures(8),
]);
if (devRes.success) {
cState.devices = devRes.data;
renderDevices();
}
if (projRes.success) {
cState.projects = projRes.data;
const sel = document.getElementById('projectSelect');
sel.innerHTML = '<option value="">Select project…</option>' +
projRes.data.map(p => `<option value="${p.id}">${esc(p.name)}</option>`).join('');
}
if (recRes.success) renderRecent(recRes.data);
if (!cState.devices.length) {
document.getElementById('noDeviceState').style.display = 'flex';
document.getElementById('captureLayout').style.display = 'none';
document.getElementById('deviceStatus').textContent = 'No devices';
} else {
document.getElementById('noDeviceState').style.display = 'none';
document.getElementById('captureLayout').style.display = 'grid';
}
// Check if already recording
const statusRes = await getRecordingStatus();
if (statusRes.success && statusRes.data?.recording) {
cState.isRecording = true;
cState.startedAt = new Date(statusRes.data.started_at || Date.now());
updateRecordingUI();
startTimecodeUpdate();
}
}
function renderDevices() {
const sel = document.getElementById('deviceSelect');
const status = document.getElementById('deviceStatus');
if (!cState.devices.length) { status.textContent = 'No devices'; return; }
sel.innerHTML = cState.devices.map(d => `<option value="${d.id}">${esc(d.name)}</option>`).join('');
cState.currentDevice = cState.devices[0].id;
status.textContent = `${cState.devices.length} device${cState.devices.length > 1 ? 's' : ''} available`;
}
async function handleProjectChange() {
const pid = document.getElementById('projectSelect').value;
const binSel = document.getElementById('binSelect');
binSel.innerHTML = '<option value="">Project root</option>';
if (!pid) return;
const r = await getBins(pid);
if (r.success) r.data.forEach(b => binSel.innerHTML += `<option value="${b.id}">${esc(b.name)}</option>`);
}
async function toggleRecord() {
if (cState.isRecording) {
await stopCap();
} else {
await startCap();
}
}
async function startCap() {
const device = document.getElementById('deviceSelect').value;
const projectId = document.getElementById('projectSelect').value;
if (!device) { toast('Select a device', '', 'warning'); return; }
setStatus('Starting…', 'processing');
const r = await startRecording(
device,
projectId || null,
document.getElementById('binSelect').value || null,
document.getElementById('clipName').value || null
);
if (r.success) {
cState.isRecording = true;
cState.startedAt = new Date();
updateRecordingUI();
startTimecodeUpdate();
setStatus('Recording', 'recording');
toast('Recording started', '', 'success');
} else {
setStatus('Error: ' + (r.error || 'failed to start'), 'error');
toast('Failed to start', r.error, 'error');
}
}
async function stopCap() {
setStatus('Stopping…', 'processing');
const r = await stopRecording();
if (r.success) {
cState.isRecording = false;
clearInterval(cState.tcInterval);
cState.tcInterval = null;
updateRecordingUI();
setStatus('Stopped — file saved', 'idle');
toast('Recording stopped', r.data?.filename || '', 'success');
setTimeout(() => initCapture(), 1500);
} else {
setStatus('Stop failed: ' + r.error, 'error');
toast('Failed to stop', r.error, 'error');
}
}
function updateRecordingUI() {
const btn = document.getElementById('recordBtn');
const label = document.getElementById('recordBtnLabel');
const tc = document.getElementById('timecodeDisplay');
const dot = document.getElementById('recStatusDot');
const statusLabel = document.getElementById('recStatusLabel');
if (cState.isRecording) {
btn.classList.add('recording');
btn.setAttribute('aria-label', 'Stop recording');
label.textContent = 'Stop';
label.classList.add('recording');
tc.classList.remove('inactive');
dot.className = 'status-dot status-dot--recording';
statusLabel.textContent = 'Recording';
} else {
btn.classList.remove('recording');
btn.setAttribute('aria-label', 'Start recording');
label.textContent = 'Record';
label.classList.remove('recording');
tc.classList.add('inactive');
tc.textContent = '00:00:00:00';
dot.className = 'status-dot status-dot--idle';
statusLabel.textContent = 'Ready';
}
}
function startTimecodeUpdate() {
if (cState.tcInterval) clearInterval(cState.tcInterval);
cState.tcInterval = setInterval(() => {
const elapsed = Math.floor((Date.now() - cState.startedAt) / 1000);
const h = Math.floor(elapsed / 3600);
const m = Math.floor((elapsed % 3600) / 60);
const s = elapsed % 60;
const f = Math.floor((Date.now() - cState.startedAt) % 1000 / (1000 / 30));
document.getElementById('timecodeDisplay').textContent =
[h, m, s, f].map(v => String(v).padStart(2,'0')).join(':');
}, 33);
}
function setStatus(text, type) {
document.getElementById('statusText').textContent = text;
const dot = document.getElementById('statusDot');
const dotClass = { recording:'status-dot--recording', processing:'status-dot--processing', error:'status-dot--error', idle:'status-dot--idle' }[type] || 'status-dot--idle';
dot.className = `status-bar-dot status-dot ${dotClass}`;
}
function renderRecent(captures) {
const list = document.getElementById('recentList');
if (!captures?.length) return;
list.innerHTML = `<table class="data-table">
<thead><tr><th>File</th><th>Duration</th><th>Date</th></tr></thead>
<tbody>${captures.map(c => `
<tr>
<td class="truncate" style="max-width:200px">${esc(c.display_name || c.filename || 'untitled')}</td>
<td>${c.duration_ms ? formatDuration(c.duration_ms / 1000) : '—'}</td>
<td>${c.created_at ? new Date(c.created_at).toLocaleString() : '—'}</td>
</tr>`).join('')}
</tbody>
</table>`;
}
function toast(title, msg, type = 'info') {
const el = document.createElement('div');
el.className = `wd-toast wd-toast--${type}`;
el.innerHTML = `<div class="toast-body"><div class="toast-title">${esc(title)}</div>${msg ? `<div class="toast-msg">${esc(msg)}</div>` : ''}</div>`;
document.getElementById('toastContainer').appendChild(el);
setTimeout(() => el.remove(), 4000);
}
function esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
<script src="js/auth-guard.js"></script>
</body>
</html>

View file

@ -1,396 +0,0 @@
<!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>Cluster — Dragonflight</title>
<link rel="stylesheet" href="/dist/app.css">
<style>
.page-body {
flex: 1;
overflow: auto;
padding: 24px;
}
.z-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.z-table th {
padding: 8px 12px;
text-align: left;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--text-tertiary);
border-bottom: 1px solid var(--border);
background: var(--bg-panel);
position: sticky;
top: 0;
z-index: 1;
white-space: nowrap;
}
.z-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
color: var(--text-secondary);
}
.z-table tr:hover td { background: var(--bg-hover); }
.z-table .empty-row {
text-align: center;
padding: 48px;
color: var(--text-tertiary);
}
.node-status-dot {
width: 8px; height: 8px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.status-online { background: oklch(62% 0.20 145); box-shadow: 0 0 5px oklch(62% 0.20 145 / 0.6); }
.status-warn { background: oklch(68% 0.18 80); box-shadow: 0 0 5px oklch(68% 0.18 80 / 0.6); }
.status-offline { background: oklch(55% 0.16 25); }
.role-badge {
display: inline-flex;
align-items: center;
padding: 2px 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 600;
letter-spacing: .04em;
text-transform: uppercase;
}
.role-primary { background: oklch(22% 0.07 32 / 0.6); color: oklch(68% 0.18 32); border: 1px solid oklch(62% 0.22 32 / 0.5); }
.role-worker { background: oklch(18% 0.03 30 / 0.6); color: oklch(55% 0.06 30); border: 1px solid oklch(35% 0.05 30 / 0.5); }
.mem-bar-wrap {
width: 80px;
height: 4px;
background: var(--bg-surface);
border-radius: 2px;
overflow: hidden;
margin-top: 3px;
}
.mem-bar {
height: 100%;
border-radius: 2px;
background: oklch(62% 0.22 32);
transition: width 0.4s ease;
}
.refresh-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.refresh-dot {
width: 7px; height: 7px;
border-radius: 50%;
background: oklch(65% 0.18 80);
flex-shrink: 0;
}
.refresh-dot.live {
background: oklch(62% 0.18 145);
animation: rd-pulse 2s ease-in-out infinite;
}
@keyframes rd-pulse { 0%,100%{opacity:.75;} 50%{opacity:1;} }
.cluster-summary {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.cluster-stat {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 20px;
min-width: 100px;
}
.cluster-stat-value {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
line-height: 1;
}
.cluster-stat-label {
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--text-tertiary);
margin-top: 4px;
}
</style>
</head>
<body>
<div class="wd-shell" style="display:flex;min-height:100vh;">
<nav class="wd-sidebar" aria-label="Main navigation">
<div class="wd-sidebar-header">
<img src="img/dragon-logo.png?v=1" alt="Dragonflight" style="width:18px;height:18px;">
<span class="wd-sidebar-brand">Dragonflight</span>
</div>
<div class="wd-sidebar-nav">
<a href="home.html" class="wd-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="wd-nav-item">
<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="wd-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="wd-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="wd-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="wd-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="wd-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="wd-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="wd-sidebar-section">Admin</div>
<a href="users.html" class="wd-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="wd-nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
Tokens
</a>
<a href="containers.html" class="wd-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="wd-nav-item is-active">
<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>
<a href="settings.html" class="wd-nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M2.9 2.9l1.1 1.1M12 12l1.1 1.1M2.9 13.1L4 12M12 4l1.1-1.1"/></svg>
Settings
</a>
</div>
<div class="wd-sidebar-footer">
<div class="wd-sidebar-user">
<div class="wd-sidebar-user-avatar" id="userAvatar">?</div>
<div class="wd-sidebar-user-info">
<div class="wd-sidebar-user-name" id="userName">&#8212;</div>
<div class="wd-sidebar-user-role" id="userRole"></div>
</div>
<button class="wd-btn wd-btn--ghost wd-btn--sm wd-btn--icon wd-sidebar-user-logout" id="logoutBtn" title="Sign out">
<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>
<div style="flex:1;display:flex;flex-direction:column;">
<header class="wd-topbar">
<div class="wd-topbar-left">
<span class="page-title">Cluster</span>
</div>
<div class="wd-topbar-right">
<div class="refresh-indicator">
<span class="refresh-dot" id="refreshDot"></span>
<span id="refreshText">Loading&hellip;</span>
</div>
<button class="wd-btn wd-btn--ghost wd-btn--sm" id="refreshBtn" style="margin-left:8px;">Refresh</button>
</div>
</header>
<div class="page-body">
<div class="cluster-summary">
<div class="cluster-stat">
<div class="cluster-stat-value" id="statTotal"></div>
<div class="cluster-stat-label">Total nodes</div>
</div>
<div class="cluster-stat">
<div class="cluster-stat-value" id="statOnline" style="color:oklch(62% 0.20 145);"></div>
<div class="cluster-stat-label">Online</div>
</div>
<div class="cluster-stat">
<div class="cluster-stat-value" id="statOffline" style="color:oklch(55% 0.16 25);"></div>
<div class="cluster-stat-label">Offline</div>
</div>
</div>
<table class="z-table">
<thead>
<tr>
<th style="width:16px"></th>
<th>Node</th>
<th>IP</th>
<th>Role</th>
<th>CPU</th>
<th>Memory</th>
<th>Version</th>
<th>Last seen</th>
<th style="text-align:right">Actions</th>
</tr>
</thead>
<tbody id="clusterBody">
<tr><td colspan="9" class="empty-row">Loading&hellip;</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="wd-toast-container" id="toastContainer" aria-live="polite"></div>
<script src="js/api.js?v=6"></script>
<script>
let nodes = [];
async function loadNodes() {
try {
const r = await fetch('/api/v1/cluster', { credentials: 'include' });
if (!r.ok) throw new Error('HTTP ' + r.status);
nodes = await r.json();
renderNodes();
setDot('live', 'Live');
} catch (err) {
setDot('error', 'Error');
console.error('Cluster load failed:', err);
}
}
function statusClass(stale) {
if (stale < 60) return 'online';
if (stale < 300) return 'warn';
return 'offline';
}
function relTime(isoStr) {
if (!isoStr) return '—';
const diff = (Date.now() - new Date(isoStr)) / 1000;
if (diff < 5) return 'just now';
if (diff < 60) return Math.round(diff) + 's ago';
if (diff < 3600) return Math.round(diff / 60) + 'm ago';
if (diff < 86400) return Math.round(diff / 3600) + 'h ago';
return Math.round(diff / 86400) + 'd ago';
}
function renderNodes() {
const tbody = document.getElementById('clusterBody');
const online = nodes.filter(n => Number(n.stale_seconds) < 120).length;
const offline = nodes.length - online;
document.getElementById('statTotal').textContent = nodes.length;
document.getElementById('statOnline').textContent = online;
document.getElementById('statOffline').textContent = offline;
if (!nodes.length) {
tbody.innerHTML = '<tr><td colspan="9" class="empty-row">No cluster nodes registered.</td></tr>';
return;
}
tbody.innerHTML = nodes.map(n => {
const stale = Number(n.stale_seconds || 9999);
const sc = statusClass(stale);
const memPct = n.mem_total_mb ? Math.round((n.mem_used_mb / n.mem_total_mb) * 100) : 0;
const memTxt = n.mem_total_mb
? `${Math.round(n.mem_used_mb || 0)} / ${Math.round(n.mem_total_mb)} MB`
: '—';
const cpuTxt = n.cpu_usage != null ? parseFloat(n.cpu_usage).toFixed(2) + '%' : '—';
const roleCls = n.role === 'primary' ? 'role-primary' : 'role-worker';
return `
<tr>
<td><span class="node-status-dot status-${sc}" title="${sc}"></span></td>
<td>
<div style="font-weight:500;color:var(--text-primary);">${esc(n.hostname)}</div>
${n.api_url ? `<div style="font-size:10px;color:var(--text-tertiary);font-family:var(--font-mono);">${esc(n.api_url)}</div>` : ''}
</td>
<td style="font-family:var(--font-mono);font-size:11px;">${esc(n.ip_address || '—')}</td>
<td><span class="role-badge ${roleCls}">${esc(n.role)}</span></td>
<td style="font-family:var(--font-mono);font-size:11px;">${cpuTxt}</td>
<td>
<div style="font-size:11px;font-family:var(--font-mono);">${memTxt}</div>
${n.mem_total_mb ? `<div class="mem-bar-wrap"><div class="mem-bar" style="width:${memPct}%"></div></div>` : ''}
</td>
<td style="font-family:var(--font-mono);font-size:11px;">${esc(n.version || '—')}</td>
<td style="font-size:11px;color:var(--text-tertiary);">${relTime(n.last_seen)}</td>
<td style="text-align:right">
<button class="wd-btn wd-btn--ghost wd-btn--sm"
style="${n.role === 'primary' ? 'opacity:0.4;' : ''}"
onclick="removeNode('${esc(n.id)}','${esc(n.hostname)}',this)"
${n.role === 'primary' ? 'title="Cannot remove the primary node"' : 'title="Remove node"'}>
Remove
</button>
</td>
</tr>`;
}).join('');
}
async function removeNode(id, hostname, btn) {
if (btn.disabled) return;
if (!confirm(`Remove node "${hostname}" from the cluster registry?`)) return;
btn.disabled = true;
try {
const r = await fetch(`/api/v1/cluster/${id}`, { method: 'DELETE', credentials: 'include' });
if (!r.ok) {
const j = await r.json().catch(() => ({}));
throw new Error(j.error || 'HTTP ' + r.status);
}
toast('Node removed', hostname, 'success');
await loadNodes();
} catch (err) {
toast('Remove failed', err.message, 'error');
btn.disabled = false;
}
}
function setDot(state, label) {
const dot = document.getElementById('refreshDot');
document.getElementById('refreshText').textContent = label;
dot.className = 'refresh-dot' + (state === 'live' ? ' live' : '');
}
function toast(title, msg, type) {
const el = document.createElement('div');
el.className = 'wd-toast wd-toast--' + (type || 'info');
el.innerHTML = '<div class="toast-body"><div class="toast-title">' + esc(title) + '</div>' +
(msg ? '<div class="toast-msg">' + esc(msg) + '</div>' : '') + '</div>';
document.getElementById('toastContainer').appendChild(el);
setTimeout(() => el.remove(), 4000);
}
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
document.addEventListener('DOMContentLoaded', () => {
loadNodes();
setInterval(loadNodes, 30000);
setInterval(() => { if (nodes.length) renderNodes(); }, 60000);
document.getElementById('refreshBtn').onclick = loadNodes;
});
</script>
<script src="js/auth-guard.js"></script>
</body>
</html>

View file

@ -1,283 +0,0 @@
<!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>Containers — Dragonflight</title>
<link rel="stylesheet" href="/dist/app.css">
<style>
body { margin: 0; }
/* Page-only auto-refresh indicator (preserves original IDs + look-and-feel) */
.refresh-indicator {
display: inline-flex;
align-items: center;
gap: 6px;
font: 400 11px/1 var(--font);
color: var(--text-tertiary);
margin-right: 10px;
}
.refresh-dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--signal-warn);
flex-shrink: 0;
}
.refresh-dot.live {
background: var(--signal-good);
animation: rd-pulse 2s ease-in-out infinite;
}
@keyframes rd-pulse { 0%,100%{opacity:.75;} 50%{opacity:1;} }
.container-name { font-weight: 500; color: var(--text-primary); }
.container-svc { font: 400 10px/1.2 var(--font-mono); color: var(--text-tertiary); margin-top: 2px; }
.container-image { font: 400 11px/1.2 var(--font-mono); color: var(--text-secondary); max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.container-ports { font: 400 10px/1.2 var(--font-mono); color: var(--text-secondary); }
.container-status-sub { font: 400 10px/1.2 var(--font); color: var(--text-tertiary); margin-top: 3px; }
.wd-list-row.container-row {
display: grid;
grid-template-columns: minmax(180px, 1.4fr) minmax(160px, 1.4fr) minmax(120px, 1fr) minmax(120px, 1fr) auto;
gap: 16px;
align-items: center;
}
.wd-list-row.container-row.header {
font: 600 10px/1 var(--font);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.empty-row {
padding: 48px 18px;
text-align: center;
color: var(--text-tertiary);
font-size: 13px;
}
</style>
</head>
<body>
<div class="wd-shell" style="display:flex;min-height:100vh;">
<nav class="wd-sidebar" aria-label="Main navigation">
<div class="wd-sidebar-header">
<img src="img/dragon-logo.png?v=1" alt="Dragonflight" style="width:18px;height:18px;">
<span class="wd-sidebar-brand">Dragonflight</span>
</div>
<div class="wd-sidebar-nav">
<a href="home.html" class="wd-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="wd-nav-item">
<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="wd-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="wd-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="wd-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="wd-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="wd-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="wd-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="wd-sidebar-section">Admin</div>
<a href="users.html" class="wd-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="wd-nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
Tokens
</a>
<a href="containers.html" class="wd-nav-item is-active">
<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="wd-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>
<a href="settings.html" class="wd-nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M2.9 2.9l1.1 1.1M12 12l1.1 1.1M2.9 13.1L4 12M12 4l1.1-1.1"/></svg>
Settings
</a>
</div>
<div class="wd-sidebar-footer">
<div class="wd-sidebar-user">
<div class="wd-sidebar-user-avatar" id="userAvatar">?</div>
<div class="wd-sidebar-user-info">
<div class="wd-sidebar-user-name" id="userName">&#8212;</div>
<div class="wd-sidebar-user-role" id="userRole"></div>
</div>
<button class="wd-btn wd-btn--ghost wd-btn--sm wd-btn--icon wd-sidebar-user-logout" id="logoutBtn" title="Sign out">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"><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>
<div style="flex:1;display:flex;flex-direction:column;">
<header class="wd-topbar">
<div class="wd-topbar-left">
<nav class="wd-breadcrumb"><span class="wd-breadcrumb-crumb">Containers</span></nav>
</div>
<div class="wd-topbar-right">
<div class="refresh-indicator">
<span class="refresh-dot" id="refreshDot"></span>
<span id="refreshText">Loading&hellip;</span>
</div>
<button class="wd-btn wd-btn--ghost wd-btn--sm" id="refreshBtn">Refresh</button>
</div>
</header>
<main style="flex:1;padding:20px 20px 32px;overflow:auto;">
<div class="wd-list">
<div class="wd-list-row container-row header">
<span>Container</span>
<span>Image</span>
<span>State</span>
<span>Ports</span>
<span style="text-align:right">Actions</span>
</div>
<div id="containerBody">
<div class="empty-row">Loading&hellip;</div>
</div>
</div>
</main>
</div>
</div>
<div class="wd-toast-container" id="toastContainer" aria-live="polite"></div>
<script src="js/api.js?v=6"></script>
<script>
let containers = [];
async function loadContainers() {
try {
const r = await fetch('/api/v1/system/containers', { credentials: 'include' });
if (!r.ok) throw new Error('HTTP ' + r.status);
containers = await r.json();
renderContainers();
setDot('live', 'Live');
} catch (err) {
setDot('error', 'Error');
console.error('Container load failed:', err);
}
}
function renderContainers() {
const tbody = document.getElementById('containerBody');
if (!containers.length) {
tbody.innerHTML = '<div class="empty-row">No containers found for this compose project.</div>';
return;
}
const sorted = containers.slice().sort((a, b) => (a.service || a.name).localeCompare(b.service || b.name));
tbody.innerHTML = sorted.map(c => {
const state = (c.state || 'exited').toLowerCase();
const running = state === 'running';
const badgeMod = running ? 'wd-badge--good'
: state === 'restarting' ? 'wd-badge--warn'
: state === 'paused' ? 'wd-badge--warn'
: state === 'dead' ? 'wd-badge--bad'
: 'wd-badge--idle';
const svcLbl = c.service && c.service !== c.name
? `<div class="container-svc">${esc(c.service)}</div>` : '';
const actionBtn = running
? `<button class="wd-btn wd-btn--ghost wd-btn--sm" onclick="containerAction('${esc(c.id)}','stop',this)" title="Stop container">Stop</button>`
: `<button class="wd-btn wd-btn--ghost wd-btn--sm" onclick="containerAction('${esc(c.id)}','start',this)" title="Start container">Start</button>`;
return `
<div class="wd-list-row container-row">
<div class="wd-list-cell wd-list-cell--name">
<div class="container-name">${esc(c.name)}</div>
${svcLbl}
</div>
<div class="wd-list-cell wd-list-cell--meta"><div class="container-image" title="${esc(c.image)}">${esc(c.image)}</div></div>
<div class="wd-list-cell">
<span class="wd-badge ${badgeMod}">${esc(c.state)}</span>
<div class="container-status-sub">${esc(c.status)}</div>
</div>
<div class="wd-list-cell"><span class="container-ports">${c.ports ? esc(c.ports) : '&mdash;'}</span></div>
<div class="wd-list-cell wd-list-cell--actions" style="text-align:right">
<div style="display:flex;gap:4px;justify-content:flex-end;">
${actionBtn}
<button class="wd-btn wd-btn--ghost wd-btn--sm" onclick="containerAction('${esc(c.id)}','restart',this)" title="Restart container">Restart</button>
</div>
</div>
</div>`;
}).join('');
}
async function containerAction(id, action, btn) {
const origText = btn.textContent;
btn.disabled = true;
btn.textContent = '…';
try {
const r = await fetch(`/api/v1/system/containers/${id}/${action}`, {
method: 'POST',
credentials: 'include',
});
if (!r.ok) {
const j = await r.json().catch(() => ({}));
throw new Error(j.error || 'HTTP ' + r.status);
}
const label = action.charAt(0).toUpperCase() + action.slice(1);
toast(label + ' sent', id, 'success');
setTimeout(loadContainers, 2500);
} catch (err) {
toast('Action failed', err.message, 'error');
btn.disabled = false;
btn.textContent = origText;
}
}
function setDot(state, label) {
const dot = document.getElementById('refreshDot');
const txt = document.getElementById('refreshText');
dot.className = 'refresh-dot' + (state === 'live' ? ' live' : '');
txt.textContent = label;
}
function toast(title, msg, type) {
const el = document.createElement('div');
el.className = 'wd-toast wd-toast--' + (type || 'info');
el.innerHTML = '<div class="wd-toast-body"><div class="wd-toast-title">' + esc(title) + '</div>' +
(msg ? '<div class="wd-toast-msg">' + esc(msg) + '</div>' : '') + '</div>';
document.getElementById('toastContainer').appendChild(el);
setTimeout(() => el.remove(), 4000);
}
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
document.addEventListener('DOMContentLoaded', () => {
loadContainers();
setInterval(loadContainers, 10000);
document.getElementById('refreshBtn').onclick = loadContainers;
});
</script>
<script src="js/auth-guard.js"></script>
</body>
</html>

View file

@ -1,440 +0,0 @@
<!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>Editor — Dragonflight</title>
<link rel="stylesheet" href="/dist/app.css">
<style>
/* ── Editor layout ── */
.edit-shell { display: flex; flex: 1; overflow: hidden; min-height: 0; }
/* Left rail: asset library */
.edit-assets {
width: 280px;
flex-shrink: 0;
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
background: oklch(11% 0.018 250 / 0.7);
min-height: 0;
}
.edit-assets-head {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
display: flex; flex-direction: column; gap: 8px;
}
.edit-assets-title {
font-size: 11px; font-weight: 600;
letter-spacing: 0.16em; text-transform: uppercase;
color: var(--text-tertiary);
}
.edit-assets-search input {
width: 100%; padding: 7px 10px;
background: oklch(15% 0.020 250);
border: 1px solid var(--border);
border-radius: var(--r-sm);
color: var(--text-primary); font-size: 13px;
}
.edit-assets-list {
flex: 1; overflow: auto; padding: 6px;
}
.edit-asset {
display: flex; gap: 10px; align-items: center;
padding: 6px;
border-radius: var(--r-sm);
cursor: grab;
border: 1px solid transparent;
}
.edit-asset:hover { background: oklch(15% 0.020 250 / 0.7); border-color: var(--border); }
.edit-asset:active { cursor: grabbing; }
.edit-asset-thumb {
width: 56px; height: 32px;
background: #000; border-radius: 3px;
object-fit: cover; flex-shrink: 0;
}
.edit-asset-name {
font-size: 12px; line-height: 1.3;
color: var(--text-primary);
overflow: hidden; text-overflow: ellipsis;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
}
/* Middle: preview + timeline */
.edit-stage {
flex: 1; display: flex; flex-direction: column;
min-width: 0; min-height: 0;
background: var(--bg-base);
}
.edit-preview-wrap {
flex: 1; display: flex; flex-direction: column;
padding: 18px 24px 0;
min-height: 0;
}
.edit-preview {
flex: 1;
background: #000;
border-radius: 8px;
overflow: hidden;
position: relative;
display: flex; align-items: center; justify-content: center;
border: 1px solid oklch(28% 0.04 260 / 0.4);
min-height: 200px;
}
.edit-preview video {
width: 100%; height: 100%;
object-fit: contain;
background: #000;
}
.edit-preview-empty {
color: var(--text-tertiary); font-size: 13px;
display: flex; flex-direction: column; align-items: center; gap: 10px;
padding: 24px;
}
.edit-transport {
display: flex; align-items: center; gap: 10px;
padding: 12px 0 14px;
flex-wrap: wrap;
}
.edit-transport-btn {
width: 32px; height: 32px;
display: inline-flex; align-items: center; justify-content: center;
background: oklch(15% 0.020 250);
border: 1px solid var(--border);
border-radius: var(--r-sm);
color: var(--text-primary);
cursor: pointer;
}
.edit-transport-btn:hover { background: oklch(20% 0.030 260); border-color: oklch(45% 0.20 32 / 0.5); }
.edit-transport-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.edit-scrubber {
flex: 1; min-width: 200px;
position: relative;
height: 32px;
display: flex; align-items: center;
}
.edit-scrubber input[type=range] {
width: 100%;
-webkit-appearance: none; appearance: none;
background: transparent;
height: 6px;
}
.edit-scrubber input[type=range]::-webkit-slider-runnable-track {
height: 6px;
background: linear-gradient(90deg,
oklch(25% 0.05 260) 0%,
oklch(25% 0.05 260) var(--in-pct, 0%),
oklch(55% 0.20 32) var(--in-pct, 0%),
oklch(55% 0.20 32) var(--out-pct, 100%),
oklch(25% 0.05 260) var(--out-pct, 100%),
oklch(25% 0.05 260) 100%);
border-radius: 3px;
}
.edit-scrubber input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 14px; height: 14px;
background: var(--text-primary);
border-radius: 50%;
border: 2px solid oklch(70% 0.18 32);
margin-top: -4px;
cursor: pointer;
}
.edit-time {
font-family: var(--font-mono); font-size: 12px;
color: var(--text-secondary);
min-width: 110px; text-align: right;
letter-spacing: 0.04em;
}
.edit-marker-btn {
padding: 6px 12px;
background: oklch(15% 0.020 250);
border: 1px solid var(--border);
border-radius: var(--r-sm);
color: var(--text-primary);
font-size: 12px; font-weight: 500;
cursor: pointer;
}
.edit-marker-btn:hover { border-color: oklch(45% 0.20 32 / 0.6); }
.edit-marker-btn.in { color: oklch(70% 0.18 32); }
.edit-marker-btn.out { color: oklch(70% 0.18 32); }
/* Timeline strip */
.edit-timeline {
flex-shrink: 0;
height: 140px;
padding: 12px 24px 18px;
border-top: 1px solid var(--border);
background: oklch(10% 0.015 250 / 0.6);
display: flex; flex-direction: column; gap: 8px;
overflow: hidden;
}
.edit-timeline-head {
display: flex; justify-content: space-between; align-items: baseline;
}
.edit-timeline-title {
font-size: 11px; font-weight: 600;
letter-spacing: 0.16em; text-transform: uppercase;
color: var(--text-tertiary);
}
.edit-timeline-total {
font-family: var(--font-mono); font-size: 11px;
color: var(--text-secondary); letter-spacing: 0.04em;
}
.edit-track {
flex: 1;
display: flex; gap: 4px;
overflow-x: auto;
align-items: stretch;
padding: 4px 0;
}
.edit-track-empty {
flex: 1;
display: flex; align-items: center; justify-content: center;
color: var(--text-tertiary); font-size: 12px;
border: 1px dashed var(--border);
border-radius: var(--r-sm);
transition: background 120ms ease, border-color 120ms ease;
}
.edit-track-empty.drag-over {
background: oklch(20% 0.05 32 / 0.4);
border-color: oklch(55% 0.20 32 / 0.8);
color: var(--accent-strong);
}
.edit-clip {
position: relative;
flex-shrink: 0;
display: flex; flex-direction: column;
width: 140px;
background: oklch(15% 0.025 250);
border: 1px solid oklch(28% 0.04 260 / 0.5);
border-radius: var(--r-sm);
overflow: hidden;
cursor: pointer;
transition: border-color 100ms ease;
}
.edit-clip:hover { border-color: oklch(45% 0.20 32 / 0.5); }
.edit-clip.active {
border-color: oklch(70% 0.18 32);
box-shadow: 0 0 0 1px oklch(70% 0.18 32 / 0.4);
}
.edit-clip-thumb {
width: 100%; height: 64px;
background: #000;
object-fit: cover;
}
.edit-clip-bar {
position: relative;
height: 6px;
background: oklch(20% 0.04 260);
margin: 4px 6px 0;
border-radius: 2px;
overflow: hidden;
}
.edit-clip-bar-fill {
position: absolute;
top: 0; bottom: 0;
background: oklch(55% 0.20 32);
}
.edit-clip-meta {
padding: 4px 8px 6px;
font-size: 10px; font-family: var(--font-mono);
color: var(--text-tertiary);
display: flex; justify-content: space-between;
letter-spacing: 0.02em;
}
.edit-clip-name {
padding: 2px 8px;
font-size: 11px;
color: var(--text-primary);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.edit-clip-remove {
position: absolute; top: 4px; right: 4px;
width: 18px; height: 18px;
background: rgba(0,0,0,0.6); color: #fff;
border: 0; border-radius: 50%;
font-size: 11px; line-height: 1;
cursor: pointer; display: none;
align-items: center; justify-content: center;
}
.edit-clip:hover .edit-clip-remove { display: flex; }
/* Right: clip inspector */
.edit-inspector {
width: 280px;
flex-shrink: 0;
border-left: 1px solid var(--border);
background: oklch(11% 0.018 250 / 0.7);
padding: 14px;
display: flex; flex-direction: column; gap: 14px;
overflow: auto;
}
.edit-inspector-empty {
color: var(--text-tertiary); font-size: 13px;
text-align: center; padding: 32px 12px;
}
.edit-inspector-section {
display: flex; flex-direction: column; gap: 8px;
}
.edit-inspector-label {
font-size: 10px; font-weight: 600;
letter-spacing: 0.16em; text-transform: uppercase;
color: var(--text-tertiary);
}
.edit-inspector-clipname {
font-size: 14px; font-weight: 500;
color: var(--text-primary);
word-break: break-all;
}
.edit-inspector-stats {
display: grid; grid-template-columns: auto 1fr;
gap: 4px 12px;
font-family: var(--font-mono); font-size: 12px;
}
.edit-inspector-stats dt {
color: var(--text-tertiary); letter-spacing: 0.04em;
text-transform: uppercase; font-size: 10px;
align-self: center;
}
.edit-inspector-stats dd { color: var(--text-primary); margin: 0; }
.edit-inspector-actions {
display: flex; gap: 6px; flex-wrap: wrap;
}
</style>
</head>
<body>
<div class="wd-shell" style="display:flex;min-height:100vh;">
<nav class="wd-sidebar" aria-label="Main navigation">
<div class="wd-sidebar-header">
<img src="img/dragon-logo.png?v=1" alt="Dragonflight" style="width:18px;height:18px;">
<span class="wd-sidebar-brand">Dragonflight</span>
</div>
<div class="wd-sidebar-nav">
<a href="home.html" class="wd-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="wd-nav-item">
<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="wd-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="wd-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="wd-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="wd-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="wd-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="#" id="editor-nav-link" class="wd-nav-item is-active" target="_blank" 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>
<div class="wd-sidebar-section">Admin</div>
<a href="users.html" class="wd-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="wd-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>
</div>
<div class="wd-sidebar-footer">
<div class="wd-sidebar-user">
<div class="wd-sidebar-user-avatar" id="userAvatar">?</div>
<div class="wd-sidebar-user-info">
<div class="wd-sidebar-user-name" id="userName"></div>
<div class="wd-sidebar-user-role" id="userRole"></div>
</div>
<button class="wd-btn wd-btn--ghost wd-btn--sm wd-btn--icon wd-sidebar-user-logout" id="logoutBtn" title="Sign out">
<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>
<div style="flex:1;display:flex;flex-direction:column;">
<header class="wd-topbar">
<div class="wd-topbar-left">
<span class="page-title">Editor</span>
<span class="wd-topbar-divider">/</span>
<span class="text-sm" style="color:var(--text-tertiary)">Phase A · single-track</span>
</div>
<div class="wd-topbar-right">
<button class="wd-btn wd-btn--ghost wd-btn--sm" onclick="saveEDL()">Save draft</button>
<button class="wd-btn wd-btn--primary wd-btn--sm" onclick="exportEDL()">Export</button>
</div>
</header>
<div class="edit-shell" style="flex:1;overflow:hidden;min-height:0;">
<!-- Left: asset library -->
<aside class="edit-assets">
<div class="edit-assets-head">
<span class="edit-assets-title">Library</span>
<div class="edit-assets-search"><input type="text" id="assetSearch" placeholder="Search assets…" /></div>
</div>
<div class="edit-assets-list" id="assetList">
<div class="edit-inspector-empty">Loading…</div>
</div>
</aside>
<!-- Middle: preview + timeline -->
<section class="edit-stage">
<div class="edit-preview-wrap">
<div class="edit-preview" id="previewWrap">
<div class="edit-preview-empty" id="previewEmpty">
<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1" width="36" height="36" style="opacity:0.4"><polygon points="11,8 24,16 11,24"/></svg>
<div>Drag a clip onto the timeline below, then click it to preview.</div>
</div>
<video id="previewVideo" style="display:none" playsinline></video>
</div>
<div class="edit-transport">
<button class="edit-transport-btn" id="btnPlay" title="Play / pause (space)" disabled>
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"><polygon points="4,3 13,8 4,13"/></svg>
</button>
<div class="edit-scrubber"><input type="range" id="scrubber" min="0" max="100" value="0" step="0.01" disabled></div>
<span class="edit-time" id="timeDisplay">--:--.-- / --:--.--</span>
<button class="edit-marker-btn in" id="btnSetIn" title="Set In (I)" disabled>I IN</button>
<button class="edit-marker-btn out" id="btnSetOut" title="Set Out (O)" disabled>O OUT</button>
<button class="edit-marker-btn" id="btnSplit" title="Split at playhead (B)" disabled style="color:oklch(70% 0.18 80)">B SPLIT</button>
</div>
</div>
<div class="edit-timeline">
<div class="edit-timeline-head">
<span class="edit-timeline-title">Timeline · Track 1</span>
<span class="edit-timeline-total" id="timelineTotal">0 clips · 00:00.00</span>
</div>
<div class="edit-track" id="track">
<div class="edit-track-empty" id="trackEmpty">Drag clips here from the Library</div>
</div>
</div>
</section>
<!-- Right: inspector -->
<aside class="edit-inspector" id="inspector">
<div class="edit-inspector-empty">Select a clip on the timeline to inspect.</div>
</aside>
</div>
</div>
</div>
<script src="js/api.js?v=6"></script>
<script src="js/topbar-strip.js?v=1"></script>
<script src="js/edit.js?v=2"></script>
</body>
</html>

View file

@ -1,201 +0,0 @@
<!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>Editor (in development) — Dragonflight</title>
<link rel="stylesheet" href="/dist/app.css">
<style>
#editor-main {
background:
radial-gradient(ellipse 70% 50% at 50% 0%, oklch(28% 0.10 32 / 0.35), transparent 60%),
radial-gradient(ellipse 60% 40% at 90% 100%, oklch(35% 0.16 32 / 0.18), transparent 65%),
var(--bg-base);
}
.uc-wrap {
min-height: calc(100vh - 56px);
display: flex; align-items: center; justify-content: center;
padding: 48px 24px;
}
.uc-card {
width: 100%;
max-width: 560px;
background: oklch(13% 0.018 250 / 0.7);
border: 1px solid oklch(28% 0.04 260 / 0.5);
border-radius: 16px;
padding: 36px 36px 32px;
text-align: center;
backdrop-filter: blur(8px);
box-shadow: 0 24px 60px -24px oklch(0% 0 0 / 0.5);
}
.uc-icon-wrap {
width: 64px; height: 64px;
margin: 0 auto 18px;
border-radius: 16px;
display: inline-flex; align-items: center; justify-content: center;
background: oklch(20% 0.08 80 / 0.35);
border: 1px solid oklch(50% 0.16 80 / 0.5);
color: oklch(85% 0.16 85);
}
.uc-icon-wrap svg { width: 32px; height: 32px; }
.uc-pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 10px; font-weight: 700;
letter-spacing: 0.14em; text-transform: uppercase;
background: oklch(28% 0.14 80 / 0.45);
color: oklch(85% 0.16 85);
border: 1px solid oklch(50% 0.16 80 / 0.55);
margin-bottom: 14px;
}
.uc-pill::before {
content: '';
width: 6px; height: 6px;
border-radius: 50%;
background: oklch(85% 0.16 85);
box-shadow: 0 0 8px oklch(85% 0.16 85 / 0.6);
}
.uc-title {
font-size: 22px; font-weight: 600;
letter-spacing: -0.01em;
color: var(--text-primary);
margin: 0 0 10px;
}
.uc-body {
font-size: 14px; line-height: 1.6;
color: var(--text-secondary);
margin: 0 0 24px;
}
.uc-actions {
display: flex; gap: 10px; justify-content: center; flex-wrap: wrap;
}
.uc-stripes {
margin-top: 28px;
height: 8px; border-radius: 6px;
background: repeating-linear-gradient(
135deg,
oklch(28% 0.14 80 / 0.45) 0 12px,
oklch(18% 0.04 260 / 0.5) 12px 24px
);
opacity: 0.6;
}
</style>
</head>
<body>
<div class="wd-shell" style="display:flex;min-height:100vh;">
<!-- Sidebar (kept identical to other pages so navigation works) -->
<nav class="wd-sidebar" aria-label="Main navigation">
<div class="wd-sidebar-header">
<img src="img/dragon-logo.png?v=1" alt="Dragonflight" style="width:18px;height:18px;">
<span class="wd-sidebar-brand">Dragonflight</span>
</div>
<div class="wd-sidebar-nav">
<a href="home.html" class="wd-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="wd-nav-item">
<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="wd-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="wd-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="wd-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="wd-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="wd-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="wd-nav-item is-active">
<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="wd-sidebar-section">Admin</div>
<a href="users.html" class="wd-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="wd-nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
Tokens
</a>
<a href="containers.html" class="wd-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="wd-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>
<a href="settings.html" class="wd-nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M2.9 2.9l1.1 1.1M12 12l1.1 1.1M2.9 13.1L4 12M12 4l1.1-1.1"/></svg>
Settings
</a>
</div>
<div class="wd-sidebar-footer">
<div class="wd-sidebar-user">
<div class="wd-sidebar-user-avatar" id="userAvatar">?</div>
<div class="wd-sidebar-user-info">
<div class="wd-sidebar-user-name" id="userName"></div>
<div class="wd-sidebar-user-role" id="userRole"></div>
</div>
<button class="wd-btn wd-btn--ghost wd-btn--sm wd-btn--icon wd-sidebar-user-logout" id="logoutBtn" title="Sign out">
<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>
<div id="editor-main" style="flex:1;display:flex;flex-direction:column;">
<header class="wd-topbar">
<div class="wd-topbar-left">
<span class="page-title">Editor</span>
</div>
<div class="wd-topbar-right"></div>
</header>
<div class="uc-wrap">
<section class="uc-card" role="status" aria-live="polite">
<div class="uc-icon-wrap" aria-hidden="true">
<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 26h20"/>
<path d="M9 22h14l-1.5-8h-11z"/>
<path d="M11 14l1-4h8l1 4"/>
<path d="M14 6h4"/>
</svg>
</div>
<span class="uc-pill">In Development</span>
<h1 class="uc-title">The editor is under construction</h1>
<p class="uc-body">
We're still wiring up the timeline editor — clip trimming, sequence
rendering, and Premiere round-tripping. It's not ready for use yet,
but it's coming. Use the sidebar to jump to Library, Recorders,
or Projects in the meantime.
</p>
<div class="uc-actions">
<a href="home.html" class="wd-btn wd-btn--primary wd-btn--sm">Back to home</a>
<a href="index.html" class="wd-btn wd-btn--secondary wd-btn--sm">Open library</a>
</div>
<div class="uc-stripes" aria-hidden="true"></div>
</section>
</div>
</div>
</div>
<script src="js/auth-guard.js"></script>
</body>
</html>

View file

@ -1,563 +0,0 @@
<!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>Home — Dragonflight</title>
<link rel="stylesheet" href="/dist/app.css">
<style>
/* Page-only layout. Sidebar + all chrome is from /dist/app.css primitives.
Hero / cards / SVG art are bespoke to the home landing page. */
body { margin: 0; }
.wd-shell { display: flex; min-height: 100vh; }
.home-main {
flex: 1;
overflow: auto;
position: relative;
background:
radial-gradient(ellipse 70% 50% at 50% 0%, oklch(35% 0.16 32 / 0.55), transparent 65%),
radial-gradient(ellipse 80% 60% at 30% 90%, oklch(40% 0.20 32 / 0.45), transparent 70%),
radial-gradient(ellipse 60% 50% at 80% 80%, oklch(45% 0.18 20 / 0.30), transparent 65%),
linear-gradient(135deg, oklch(20% 0.05 30), var(--bg-base) 100%);
display: flex;
flex-direction: column;
}
.home-stage {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: safe center;
padding: 56px 32px 32px;
gap: 36px;
}
.home-brandmark {
display: flex; flex-direction: column; align-items: center; gap: 14px;
}
.home-portrait {
width: 120px; height: 120px;
border-radius: 50%;
overflow: hidden; position: relative;
background:
radial-gradient(ellipse at 40% 30%, oklch(35% 0.18 32 / 0.4), transparent 65%),
linear-gradient(135deg, var(--bg-surface), var(--bg-deep));
border: 2px solid var(--accent-border);
box-shadow:
0 20px 50px -10px oklch(62% 0.22 32 / 0.4),
inset 0 1px 0 oklch(100% 0 0 / 0.08);
}
.home-portrait img {
position: absolute; inset: 0;
width: 100%; height: 100%;
object-fit: cover;
object-position: 50% 30%;
}
.home-portrait-dot {
position: absolute; bottom: 4px; right: 4px;
width: 18px; height: 18px;
background: var(--signal-bad);
border: 3px solid var(--bg-panel);
border-radius: 50%;
box-shadow: 0 0 12px var(--signal-bad);
animation: live-pulse 1.6s ease-in-out infinite;
}
@keyframes live-pulse {
0%, 100% { opacity: 0.85; transform: scale(1); }
50% { opacity: 1; transform: scale(1.1); }
}
.home-wordmark-svg {
width: min(520px, 80vw);
height: auto;
filter: drop-shadow(0 16px 30px oklch(50% 0.22 32 / 0.45));
margin-bottom: -10px;
}
.home-tagline {
display: flex; align-items: center; gap: 12px;
font: 500 17px/1 var(--font);
color: oklch(75% 0.10 32);
letter-spacing: 0.005em;
}
.home-tagline::before {
content: ''; width: 3px; height: 18px;
background: var(--accent-bright);
border-radius: 2px;
}
.home-cards {
display: flex;
gap: 18px;
width: 100%;
max-width: 1680px;
padding: 0 24px;
flex-wrap: wrap;
justify-content: center;
}
.home-card {
flex: 1 1 0;
min-width: 200px;
max-width: 220px;
background: oklch(12% 0.020 30 / 0.85);
border: 1px solid var(--border-faint);
border-radius: 10px;
overflow: hidden;
text-decoration: none;
color: var(--text-primary);
display: flex;
flex-direction: column;
cursor: pointer;
backdrop-filter: blur(6px);
transition: transform var(--dur-slide) var(--ease-out-quart),
border-color var(--dur-slide) var(--ease-out-quart),
box-shadow var(--dur-slide) var(--ease-out-quart);
}
.home-card:hover {
transform: translateY(-4px);
border-color: var(--accent-border);
box-shadow: 0 24px 50px -16px oklch(62% 0.22 32 / 0.4);
}
.home-card-title {
padding: 12px 16px 10px;
font: 500 15px/1.2 var(--font);
letter-spacing: 0.005em;
color: var(--text-primary);
}
.home-card-preview {
position: relative;
aspect-ratio: 16/10;
background: linear-gradient(135deg, var(--bg-surface), var(--bg-base));
overflow: hidden;
border-top: 1px solid var(--border-faint);
border-bottom: 1px solid var(--border-faint);
}
.home-card-preview svg.preview-art {
position: absolute; inset: 0;
width: 100%; height: 100%;
}
.home-card-stats {
position: absolute; bottom: 8px; left: 10px;
display: inline-flex; align-items: center; gap: 6px;
background: var(--overlay);
backdrop-filter: blur(6px);
padding: 3px 8px;
border-radius: 999px;
border: 1px solid var(--border-faint);
font: 400 10px/1 var(--font-mono);
letter-spacing: 0.04em;
color: var(--text-secondary);
}
.home-card-stats b { color: var(--text-primary); font-weight: 600; }
.home-card-stats-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--signal-good); box-shadow: 0 0 6px var(--signal-good);
}
.home-card-desc {
padding: 12px 16px 16px;
font: 400 11.5px/1.5 var(--font);
color: var(--text-secondary);
}
.home-footer {
padding: 24px;
display: flex; align-items: center; gap: 10px;
justify-content: center;
color: var(--text-tertiary);
font: 400 11px/1 var(--font);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.home-footer-mark {
display: inline-flex; align-items: center; gap: 8px;
font-weight: 700;
color: var(--text-secondary);
}
.home-footer-mark-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--accent-bright);
}
@media (max-width: 900px) {
.home-stage { padding: 32px 16px; gap: 24px; }
.home-portrait { width: 96px; height: 96px; }
.home-cards { gap: 12px; padding: 0 12px; }
.home-card { min-width: 150px; }
}
</style>
</head>
<body>
<div class="wd-shell">
<nav class="wd-sidebar" aria-label="Main navigation">
<div class="wd-sidebar-header">
<img src="img/dragon-logo.png?v=1" alt="Dragonflight" style="width:18px;height:18px;">
<span class="wd-sidebar-brand">Dragonflight</span>
</div>
<div class="wd-sidebar-nav">
<a href="home.html" class="wd-nav-item is-active">
<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="wd-nav-item">
<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="wd-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="wd-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="wd-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="wd-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="wd-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="wd-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="wd-sidebar-section">Admin</div>
<a href="users.html" class="wd-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="wd-nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
Tokens
</a>
<a href="containers.html" class="wd-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="wd-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>
<a href="settings.html" class="wd-nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M2.9 2.9l1.1 1.1M12 12l1.1 1.1M2.9 13.1L4 12M12 4l1.1-1.1"/></svg>
Settings
</a>
</div>
<div class="wd-sidebar-footer">
<div class="wd-sidebar-user">
<div class="wd-sidebar-user-avatar" id="userAvatar">?</div>
<div class="wd-sidebar-user-info">
<div class="wd-sidebar-user-name" id="userName"></div>
<div class="wd-sidebar-user-role" id="userRole"></div>
</div>
<button class="wd-btn wd-btn--ghost wd-btn--sm wd-btn--icon wd-sidebar-user-logout" id="logoutBtn" title="Sign out">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"><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>
<div class="home-main">
<div class="home-stage">
<div class="home-brandmark">
<div class="home-portrait">
<img src="img/ampp-safe.png?v=hardhat3" alt="Zac in hardhat">
<span class="home-portrait-dot" title="On duty"></span>
</div>
<svg class="home-wordmark-svg" viewBox="0 0 760 132" xmlns="http://www.w3.org/2000/svg" aria-label="Dragonflight">
<defs>
<linearGradient id="dfwm" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="oklch(88% 0.16 48)"/>
<stop offset="0.5" stop-color="oklch(68% 0.22 32)"/>
<stop offset="1" stop-color="oklch(42% 0.18 22)"/>
</linearGradient>
</defs>
<text x="0" y="108" font-family="Inter, system-ui, sans-serif" font-weight="900" font-size="120" letter-spacing="-6" fill="url(#dfwm)">Dragonflight</text>
</svg>
</div>
<div class="home-tagline">Please select an option below to get started</div>
<div class="home-cards">
<a href="index.html" class="home-card">
<div class="home-card-title">Library</div>
<div class="home-card-preview">
<svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 30)"/>
<g fill="oklch(25% 0.05 30)" stroke="oklch(45% 0.10 32 / 0.4)" stroke-width="0.5">
<rect x="12" y="14" width="40" height="28" rx="2"/>
<rect x="58" y="14" width="40" height="28" rx="2"/>
<rect x="104" y="14" width="40" height="28" rx="2"/>
<rect x="150" y="14" width="40" height="28" rx="2"/>
<rect x="12" y="48" width="40" height="28" rx="2"/>
<rect x="58" y="48" width="40" height="28" rx="2"/>
<rect x="104" y="48" width="40" height="28" rx="2"/>
<rect x="150" y="48" width="40" height="28" rx="2"/>
<rect x="12" y="82" width="40" height="28" rx="2"/>
<rect x="58" y="82" width="40" height="28" rx="2"/>
<rect x="104" y="82" width="40" height="28" rx="2"/>
<rect x="150" y="82" width="40" height="28" rx="2"/>
</g>
<g fill="oklch(62% 0.22 32 / 0.8)"><circle cx="32" cy="28" r="3"/><circle cx="124" cy="62" r="3"/><circle cx="170" cy="96" r="3"/></g>
</svg>
<span class="home-card-stats"><span class="home-card-stats-dot"></span><b id="assetCount">--</b>&nbsp;assets</span>
</div>
<div class="home-card-desc">Browse, organize, and preview every asset across all projects.</div>
</a>
<a href="projects.html" class="home-card">
<div class="home-card-title">Projects</div>
<div class="home-card-preview">
<svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 30)"/>
<g fill="oklch(20% 0.04 30)" stroke="oklch(45% 0.10 32 / 0.4)" stroke-width="0.5">
<path d="M12 24 L12 100 L188 100 L188 36 L92 36 L80 24 Z" />
<path d="M28 56 L184 56" stroke-dasharray="2,3"/>
<path d="M28 76 L184 76" stroke-dasharray="2,3"/>
</g>
<g fill="oklch(62% 0.22 32 / 0.6)">
<rect x="32" y="46" width="6" height="6" rx="1"/>
<rect x="32" y="66" width="6" height="6" rx="1"/>
<rect x="32" y="86" width="6" height="6" rx="1"/>
</g>
</svg>
<span class="home-card-stats"><span class="home-card-stats-dot"></span><b id="projectCount">--</b>&nbsp;projects</span>
</div>
<div class="home-card-desc">Create projects, organize bins, and manage who owns what footage.</div>
</a>
<a href="upload.html" class="home-card">
<div class="home-card-title">Ingest</div>
<div class="home-card-preview">
<svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 30)"/>
<g stroke="oklch(62% 0.22 32 / 0.6)" stroke-width="2" fill="none">
<path d="M100 80 V 30 M85 45 L 100 30 L 115 45" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="30" y1="100" x2="170" y2="100" stroke-dasharray="3,3" stroke-width="1"/>
</g>
<rect x="40" y="92" width="120" height="6" rx="3" fill="oklch(25% 0.05 30)"/>
<rect x="40" y="92" width="68" height="6" rx="3" fill="oklch(62% 0.22 32 / 0.9)"/>
</svg>
<span class="home-card-stats"><span class="home-card-stats-dot"></span><b id="ingestCount">--</b>&nbsp;processing</span>
</div>
<div class="home-card-desc">Upload finished files. MOV, MP4, MXF, ProRes, drop them, we proxy them.</div>
</a>
<a href="recorders.html" class="home-card">
<div class="home-card-title">Recorders</div>
<div class="home-card-preview">
<svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 30)"/>
<rect x="20" y="22" width="120" height="80" rx="4" fill="oklch(8% 0.01 30)" stroke="oklch(45% 0.10 32 / 0.4)" stroke-width="0.5"/>
<g opacity="0.5"><path d="M30 50 q15 -12 30 0 t30 0 t30 0 t30 0" stroke="oklch(62% 0.22 32)" stroke-width="1" fill="none"/><path d="M30 70 q15 -8 30 0 t30 0 t30 0 t30 0" stroke="oklch(62% 0.22 32)" stroke-width="1" fill="none"/></g>
<path d="M140 62 L182 42 L182 82 Z" fill="oklch(20% 0.04 30)" stroke="oklch(45% 0.10 32 / 0.4)" stroke-width="0.5"/>
<circle cx="160" cy="62" r="6" fill="oklch(62% 0.22 25)"><animate attributeName="opacity" values="1;0.5;1" dur="1.6s" repeatCount="indefinite"/></circle>
</svg>
<span class="home-card-stats"><span class="home-card-stats-dot" style="background:var(--signal-bad);box-shadow:0 0 6px var(--signal-bad)"></span><b id="recorderCount">--</b>&nbsp;live</span>
</div>
<div class="home-card-desc">Pull SRT, RTMP, and SDI feeds straight to ProRes with a live HLS preview.</div>
</a>
<a href="capture.html" class="home-card">
<div class="home-card-title">Capture</div>
<div class="home-card-preview">
<svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 30)"/>
<circle cx="100" cy="62" r="34" fill="none" stroke="oklch(45% 0.10 32 / 0.3)" stroke-width="0.5"/>
<circle cx="100" cy="62" r="22" fill="none" stroke="oklch(45% 0.10 32 / 0.5)" stroke-width="0.5"/>
<circle cx="100" cy="62" r="12" fill="oklch(20% 0.04 30)" stroke="oklch(62% 0.22 32 / 0.7)" stroke-width="1"/>
<circle cx="100" cy="62" r="4" fill="oklch(76% 0.20 32)"/>
</svg>
<span class="home-card-stats"><span class="home-card-stats-dot"></span><b id="captureStatus">Idle</b></span>
</div>
<div class="home-card-desc">DeckLink SDI capture with manual scene control and per-device routing.</div>
</a>
<a href="editor.html" class="home-card">
<div class="home-card-title">Editor</div>
<div class="home-card-preview">
<svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 30)"/>
<rect x="14" y="14" width="172" height="44" rx="3" fill="oklch(8% 0.01 30)" stroke="oklch(45% 0.10 32 / 0.4)" stroke-width="0.5"/>
<g stroke="oklch(45% 0.10 32 / 0.4)" stroke-width="0.5">
<line x1="14" y1="74" x2="186" y2="74"/>
<line x1="14" y1="92" x2="186" y2="92"/>
<line x1="14" y1="110" x2="186" y2="110"/>
</g>
<rect x="22" y="68" width="60" height="12" rx="2" fill="oklch(62% 0.22 32 / 0.8)"/>
<rect x="90" y="68" width="38" height="12" rx="2" fill="oklch(62% 0.22 32 / 0.5)"/>
<rect x="40" y="86" width="90" height="12" rx="2" fill="oklch(76% 0.20 32 / 0.6)"/>
<rect x="30" y="104" width="80" height="12" rx="2" fill="oklch(62% 0.22 32 / 0.7)"/>
</svg>
<span class="home-card-stats"><span class="home-card-stats-dot"></span>Web editor &nbsp;</span>
</div>
<div class="home-card-desc">Pull a clip from the library into the in-browser editor. Trim, cut, export.</div>
</a>
<a href="jobs.html" class="home-card">
<div class="home-card-title">Jobs</div>
<div class="home-card-preview">
<svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 30)"/>
<g stroke="oklch(45% 0.10 32 / 0.4)" stroke-width="0.5">
<line x1="14" y1="28" x2="186" y2="28"/>
<line x1="14" y1="46" x2="186" y2="46"/>
<line x1="14" y1="64" x2="186" y2="64"/>
<line x1="14" y1="82" x2="186" y2="82"/>
<line x1="14" y1="100" x2="186" y2="100"/>
</g>
<rect x="22" y="20" width="38" height="6" rx="3" fill="oklch(45% 0.16 145 / 0.8)"/>
<rect x="22" y="38" width="80" height="6" rx="3" fill="oklch(45% 0.16 145 / 0.6)"/>
<rect x="22" y="56" width="120" height="6" rx="3" fill="oklch(70% 0.18 80 / 0.8)"/>
<rect x="22" y="74" width="60" height="6" rx="3" fill="oklch(45% 0.16 145 / 0.7)"/>
<rect x="22" y="92" width="100" height="6" rx="3" fill="oklch(45% 0.16 145 / 0.5)"/>
</svg>
<span class="home-card-stats"><span class="home-card-stats-dot"></span><b id="jobCount">--</b>&nbsp;active</span>
</div>
<div class="home-card-desc">Track proxy generation, thumbnails, and folder sync as they run.</div>
</a>
<a href="containers.html" class="home-card">
<div class="home-card-title">Containers</div>
<div class="home-card-preview">
<svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 30)"/>
<g fill="oklch(20% 0.04 30)" stroke="oklch(45% 0.10 32 / 0.45)" stroke-width="0.6">
<rect x="20" y="18" width="160" height="22" rx="3"/>
<rect x="20" y="46" width="160" height="22" rx="3"/>
<rect x="20" y="74" width="160" height="22" rx="3"/>
<rect x="20" y="102" width="160" height="16" rx="3"/>
</g>
<circle cx="34" cy="29" r="3.5" fill="oklch(62% 0.22 145)"/>
<circle cx="34" cy="57" r="3.5" fill="oklch(62% 0.22 145)"/>
<circle cx="34" cy="85" r="3.5" fill="oklch(62% 0.22 145)"/>
<circle cx="34" cy="110" r="3.5" fill="oklch(62% 0.22 25)"/>
<rect x="46" y="25" width="60" height="5" rx="2" fill="oklch(50% 0.12 32 / 0.7)"/>
<rect x="46" y="53" width="80" height="5" rx="2" fill="oklch(50% 0.12 32 / 0.7)"/>
<rect x="46" y="81" width="50" height="5" rx="2" fill="oklch(50% 0.12 32 / 0.7)"/>
<rect x="46" y="107" width="70" height="4" rx="2" fill="oklch(40% 0.10 32 / 0.5)"/>
</svg>
<span class="home-card-stats"><span class="home-card-stats-dot"></span><b id="containerCount">--</b>&nbsp;running</span>
</div>
<div class="home-card-desc">Manage Docker Compose services, start, stop, and restart containers.</div>
</a>
<a href="cluster.html" class="home-card">
<div class="home-card-title">Cluster</div>
<div class="home-card-preview">
<svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 30)"/>
<circle cx="100" cy="62" r="10" fill="oklch(20% 0.04 30)" stroke="oklch(62% 0.22 32 / 0.7)" stroke-width="1"/>
<circle cx="100" cy="62" r="4" fill="oklch(76% 0.20 32)"/>
<line x1="100" y1="62" x2="36" y2="22" stroke="oklch(45% 0.12 32 / 0.4)" stroke-width="1"/>
<line x1="100" y1="62" x2="164" y2="22" stroke="oklch(45% 0.12 32 / 0.4)" stroke-width="1"/>
<line x1="100" y1="62" x2="24" y2="80" stroke="oklch(45% 0.12 32 / 0.4)" stroke-width="1"/>
<line x1="100" y1="62" x2="176" y2="80" stroke="oklch(45% 0.12 32 / 0.4)" stroke-width="1"/>
<line x1="100" y1="62" x2="100" y2="108" stroke="oklch(45% 0.12 32 / 0.4)" stroke-width="1"/>
<circle cx="36" cy="22" r="7" fill="oklch(18% 0.03 30)" stroke="oklch(45% 0.10 32 / 0.5)" stroke-width="0.6"/>
<circle cx="164" cy="22" r="7" fill="oklch(18% 0.03 30)" stroke="oklch(45% 0.10 32 / 0.5)" stroke-width="0.6"/>
<circle cx="24" cy="80" r="7" fill="oklch(18% 0.03 30)" stroke="oklch(45% 0.10 32 / 0.5)" stroke-width="0.6"/>
<circle cx="176" cy="80" r="7" fill="oklch(18% 0.03 30)" stroke="oklch(45% 0.10 32 / 0.5)" stroke-width="0.6"/>
<circle cx="100" cy="108" r="7" fill="oklch(18% 0.03 30)" stroke="oklch(45% 0.10 32 / 0.5)" stroke-width="0.6"/>
<circle cx="36" cy="22" r="2.5" fill="oklch(62% 0.22 145)"/>
<circle cx="164" cy="22" r="2.5" fill="oklch(62% 0.22 145)"/>
<circle cx="24" cy="80" r="2.5" fill="oklch(62% 0.22 145)"/>
<circle cx="176" cy="80" r="2.5" fill="oklch(55% 0.18 80)"/>
<circle cx="100" cy="108" r="2.5" fill="oklch(45% 0.10 32 / 0.4)"/>
</svg>
<span class="home-card-stats"><span class="home-card-stats-dot"></span><b id="nodeCount">--</b>&nbsp;nodes</span>
</div>
<div class="home-card-desc">Multi-server cluster registry. Monitor, federate, and scale Dragonflight nodes.</div>
</a>
<a href="tokens.html" class="home-card" title="Just kidding">
<div class="home-card-title">Tokens</div>
<div class="home-card-preview">
<svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 30)"/>
<g stroke="oklch(45% 0.10 32 / 0.5)" stroke-width="0.5"><line x1="14" y1="100" x2="186" y2="100"/><line x1="14" y1="80" x2="186" y2="80" stroke-dasharray="2,3"/><line x1="14" y1="60" x2="186" y2="60" stroke-dasharray="2,3"/></g>
<polyline points="14,90 38,72 62,80 86,55 110,62 134,40 158,48 186,28" fill="none" stroke="oklch(70% 0.18 200)" stroke-width="2"/>
<polyline points="14,95 38,86 62,90 86,76 110,80 134,68 158,72 186,58" fill="none" stroke="oklch(62% 0.15 145)" stroke-width="2"/>
<circle cx="186" cy="28" r="3" fill="oklch(62% 0.22 25)"/>
</svg>
<span class="home-card-stats"><span class="home-card-stats-dot" style="background:var(--signal-bad);box-shadow:0 0 6px var(--signal-bad)"></span><b id="tokenBurn"></b>&nbsp;burning</span>
</div>
<div class="home-card-desc">Token-metered pricing parody. Click for a giggle. (You actually pay $0.)</div>
</a>
</div>
</div>
<div class="home-footer">
<span class="home-footer-mark"><span class="home-footer-mark-dot"></span>Wild Dragon</span>
<span>·</span>
<span id="systemBuild">Dragonflight Operator Console</span>
</div>
</div>
</div>
<script src="js/api.js?v=6"></script>
<script src="js/topbar-strip.js?v=1"></script>
<script>
async function loadStats() {
const setText = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
try {
const [aRes, pRes, rRes, jRes, cRes, nRes] = await Promise.allSettled([
fetch('/api/v1/assets?limit=1', { credentials: 'include' }),
fetch('/api/v1/projects', { credentials: 'include' }),
fetch('/api/v1/recorders', { credentials: 'include' }),
fetch('/api/v1/jobs?status=active', { credentials: 'include' }),
fetch('/api/v1/system/containers', { credentials: 'include' }),
fetch('/api/v1/cluster', { credentials: 'include' }),
]);
if (aRes.status === 'fulfilled' && aRes.value.ok) {
const j = await aRes.value.json();
setText('assetCount', j.total ?? (j.assets?.length ?? '--'));
}
if (pRes.status === 'fulfilled' && pRes.value.ok) {
const j = await pRes.value.json();
const arr = Array.isArray(j) ? j : (j.projects ?? []);
setText('projectCount', arr.length);
}
if (rRes.status === 'fulfilled' && rRes.value.ok) {
const j = await rRes.value.json();
const arr = Array.isArray(j) ? j : [];
const recording = arr.filter(r => r.status === 'recording').length;
setText('recorderCount', recording);
}
if (jRes.status === 'fulfilled' && jRes.value.ok) {
const j = await jRes.value.json();
const arr = Array.isArray(j) ? j : (j.jobs ?? []);
setText('jobCount', arr.length);
setText('ingestCount', arr.filter(x => (x.type || '').toLowerCase().includes('proxy')).length || arr.length);
}
if (cRes.status === 'fulfilled' && cRes.value.ok) {
const j = await cRes.value.json();
const arr = Array.isArray(j) ? j : (j.containers ?? []);
const running = arr.filter(c => c.state === 'running').length;
setText('containerCount', running);
}
if (nRes.status === 'fulfilled' && nRes.value.ok) {
const j = await nRes.value.json();
const arr = Array.isArray(j) ? j : (j.nodes ?? []);
const online = arr.filter(n => n.online).length;
setText('nodeCount', arr.length > 0 ? online + '/' + arr.length : '--');
}
const tb = document.getElementById('tokenBurn'); if (tb) { tb.textContent = (14000 + Math.round(Math.random() * 8000)).toLocaleString(); }
} catch (_) { /* leave dashes */ }
}
loadStats();
setInterval(loadStats, 15000);
</script>
<script src="js/auth-guard.js"></script>
</body>
</html>

View file

@ -1,914 +0,0 @@
<!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>Jobs — Dragonflight</title>
<link rel="stylesheet" href="/dist/app.css">
<style>
/* ── page layout ─────────────────────────────────────────── */
.page-body {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* ── filter bar ──────────────────────────────────────────── */
.filter-bar {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-4) var(--sp-6);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
background: var(--bg-panel);
}
.filter-bar .tabs {
display: flex;
gap: 2px;
background: var(--bg-base);
padding: 3px;
border-radius: var(--r-md);
border: 1px solid var(--border);
}
.filter-bar .tab-btn {
padding: 5px 14px;
border-radius: calc(var(--r-md) - 1px);
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-secondary);
background: transparent;
border: none;
cursor: pointer;
transition: background 0.15s, color 0.15s;
display: flex;
align-items: center;
gap: var(--sp-2);
}
.filter-bar .tab-btn:hover {
color: var(--text-primary);
background: var(--bg-surface);
}
.filter-bar .tab-btn.active {
background: var(--bg-surface);
color: var(--text-primary);
}
.filter-bar .tab-btn .count {
font-size: var(--text-xs);
background: var(--bg-raised);
color: var(--text-secondary);
padding: 1px 6px;
border-radius: 10px;
min-width: 20px;
text-align: center;
}
.filter-bar .tab-btn.active .count {
background: var(--accent-subtle);
color: var(--accent);
}
.filter-bar .spacer { flex: 1; }
.filter-bar .type-select {
padding: 5px 10px;
font-size: var(--text-sm);
min-width: 140px;
}
.refresh-indicator {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.refresh-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--status-gray);
transition: background 0.2s;
}
.refresh-dot.live {
background: var(--status-green);
animation: pulse-green 2s infinite;
}
@keyframes pulse-green {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ── jobs area ───────────────────────────────────────────── */
.jobs-area {
flex: 1;
overflow-y: auto;
padding: var(--sp-6);
}
/* ── job table ───────────────────────────────────────────── */
.jobs-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.jobs-table thead tr {
border-bottom: 1px solid var(--border);
}
.jobs-table th {
padding: var(--sp-2) var(--sp-4);
text-align: left;
font-size: var(--text-xs);
font-weight: 500;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.06em;
white-space: nowrap;
}
.jobs-table th:first-child { padding-left: 0; }
.jobs-table th:last-child { padding-right: 0; text-align: right; }
.jobs-table tbody tr {
border-bottom: 1px solid var(--border);
transition: background 0.1s;
}
.jobs-table tbody tr:hover {
background: var(--bg-surface);
}
.jobs-table td {
padding: var(--sp-3) var(--sp-4);
color: var(--text-primary);
vertical-align: middle;
}
.jobs-table td:first-child { padding-left: 0; }
.jobs-table td:last-child { padding-right: 0; text-align: right; }
/* ── type chip ───────────────────────────────────────────── */
.type-chip {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: var(--text-xs);
font-weight: 500;
padding: 2px 8px;
border-radius: var(--r-sm);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.type-chip.transcode { background: oklch(65% 0.16 245 / 0.12); color: var(--status-blue); }
.type-chip.proxy { background: oklch(60% 0.14 290 / 0.12); color: oklch(70% 0.14 290); }
.type-chip.thumbnail { background: oklch(68% 0.18 148 / 0.10); color: var(--status-green); }
.type-chip.conform { background: var(--accent-subtle); color: var(--accent); }
.type-chip.ingest { background: oklch(62% 0.22 25 / 0.10); color: oklch(72% 0.18 40); }
/* ── progress in table ───────────────────────────────────── */
.progress-cell {
min-width: 140px;
}
.inline-progress {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.inline-progress .bar-track {
flex: 1;
height: 4px;
background: var(--bg-raised);
border-radius: 2px;
overflow: hidden;
}
.inline-progress .bar-fill {
height: 100%;
border-radius: 2px;
background: var(--accent);
transition: width 0.4s ease;
}
.inline-progress .bar-fill.complete {
background: var(--status-green);
}
.inline-progress .bar-fill.failed {
background: var(--status-red);
}
.inline-progress .pct {
font-size: var(--text-xs);
color: var(--text-secondary);
min-width: 28px;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* ── asset link ──────────────────────────────────────────── */
.asset-link {
font-family: 'Inter', monospace;
font-size: var(--text-xs);
color: var(--text-secondary);
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
.asset-name {
color: var(--text-primary);
font-size: var(--text-sm);
font-weight: 500;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
/* ── duration / time ─────────────────────────────────────── */
.time-cell {
font-size: var(--text-xs);
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.time-cell .rel {
color: var(--text-tertiary);
font-size: 11px;
margin-top: 1px;
}
/* ── job detail panel ────────────────────────────────────── */
.job-detail-panel .panel-section {
margin-bottom: var(--sp-6);
}
.job-detail-panel .detail-label {
font-size: var(--text-xs);
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: var(--sp-2);
}
.job-detail-panel .detail-value {
font-size: var(--text-sm);
color: var(--text-primary);
word-break: break-all;
}
.log-block {
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: var(--sp-4);
font-family: 'Courier New', monospace;
font-size: 11px;
color: var(--text-secondary);
max-height: 280px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
line-height: 1.6;
}
.progress-large {
margin: var(--sp-4) 0;
}
.progress-large .bar-track {
height: 8px;
background: var(--bg-raised);
border-radius: 4px;
overflow: hidden;
}
.progress-large .bar-fill {
height: 100%;
border-radius: 4px;
background: var(--accent);
transition: width 0.4s ease;
}
.progress-large .bar-fill.complete { background: var(--status-green); }
.progress-large .bar-fill.failed { background: var(--status-red); }
.progress-large .pct-label {
font-size: var(--text-xl);
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--text-primary);
margin-bottom: var(--sp-2);
}
/* ── stats strip ─────────────────────────────────────────── */
.stats-strip {
display: flex;
gap: var(--sp-6);
padding: var(--sp-4) var(--sp-6);
border-bottom: 1px solid var(--border);
background: var(--bg-base);
flex-shrink: 0;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.stat-item .stat-val {
font-size: var(--text-lg);
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--text-primary);
}
.stat-item .stat-val.amber { color: var(--accent); }
.stat-item .stat-val.green { color: var(--status-green); }
.stat-item .stat-val.red { color: var(--status-red); }
.stat-item .stat-label {
font-size: var(--text-xs);
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.06em;
}
</style>
</head>
<body>
<div class="wd-shell" style="display:flex;min-height:100vh;">
<!-- ── Sidebar ─────────────────────────────────────────── -->
<nav class="wd-sidebar" aria-label="Main navigation">
<div class="wd-sidebar-header">
<img src="img/dragon-logo.png?v=1" alt="Dragonflight" style="width:18px;height:18px;">
<span class="wd-sidebar-brand">Dragonflight</span>
</div>
<div class="wd-sidebar-nav">
<a href="home.html" class="wd-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="wd-nav-item">
<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="wd-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="wd-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="wd-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="wd-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="wd-nav-item is-active">
<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="wd-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="wd-sidebar-section">Admin</div>
<a href="users.html" class="wd-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="wd-nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
Tokens
</a>
<a href="containers.html" class="wd-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="wd-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>
<a href="settings.html" class="wd-nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M2.9 2.9l1.1 1.1M12 12l1.1 1.1M2.9 13.1L4 12M12 4l1.1-1.1"/></svg>
Settings
</a>
</div>
<div class="wd-sidebar-footer">
<div class="wd-sidebar-user">
<div class="wd-sidebar-user-avatar" id="userAvatar">?</div>
<div class="wd-sidebar-user-info">
<div class="wd-sidebar-user-name" id="userName"></div>
<div class="wd-sidebar-user-role" id="userRole"></div>
</div>
<button class="wd-btn wd-btn--ghost wd-btn--sm wd-btn--icon wd-sidebar-user-logout" id="logoutBtn" title="Sign out">
<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 area ────────────────────────────────────────── -->
<div style="flex:1;display:flex;flex-direction:column;">
<!-- Topbar -->
<header class="wd-topbar">
<div class="wd-topbar-left">
<span class="page-title">Jobs</span>
</div>
<div class="wd-topbar-right">
<button class="wd-btn wd-btn--ghost wd-btn--sm" id="btn-clear-done" onclick="clearCompleted()">
Clear completed
</button>
</div>
</header>
<!-- Page body -->
<div class="page-body">
<!-- Stats strip -->
<div class="stats-strip" id="stats-strip">
<div class="stat-item">
<div class="stat-val" id="stat-total"></div>
<div class="stat-label">Total</div>
</div>
<div class="stat-item">
<div class="stat-val amber" id="stat-active"></div>
<div class="stat-label">Active</div>
</div>
<div class="stat-item">
<div class="stat-val green" id="stat-done"></div>
<div class="stat-label">Completed</div>
</div>
<div class="stat-item">
<div class="stat-val red" id="stat-failed"></div>
<div class="stat-label">Failed</div>
</div>
</div>
<!-- Filter bar -->
<div class="filter-bar">
<div class="tabs">
<button class="tab-btn active" data-filter="all" onclick="setFilter('all', this)">
All <span class="count" id="cnt-all">0</span>
</button>
<button class="tab-btn" data-filter="active" onclick="setFilter('active', this)">
Active <span class="count" id="cnt-active">0</span>
</button>
<button class="tab-btn" data-filter="completed" onclick="setFilter('completed', this)">
Completed <span class="count" id="cnt-completed">0</span>
</button>
<button class="tab-btn" data-filter="failed" onclick="setFilter('failed', this)">
Failed <span class="count" id="cnt-failed">0</span>
</button>
</div>
<div class="spacer"></div>
<select class="wd-select type-select" id="type-filter" onchange="renderJobs()">
<option value="">All types</option>
<option value="transcode">Transcode</option>
<option value="proxy">Proxy</option>
<option value="thumbnail">Thumbnail</option>
<option value="conform">Conform</option>
<option value="ingest">Ingest</option>
</select>
<div class="refresh-indicator">
<div class="refresh-dot" id="refresh-dot"></div>
<span id="refresh-label">Connecting…</span>
</div>
</div>
<!-- Jobs area -->
<div class="jobs-area" id="jobs-area">
<!-- populated by JS -->
</div>
</div><!-- /page-body -->
</div><!-- /main -->
</div><!-- /shell -->
<!-- ── Job detail slide panel ─────────────────────────────── -->
<div class="wd-slide-overlay" id="detail-overlay" onclick="closeDetail()"></div>
<div class="wd-slide-panel" id="detail-panel" role="dialog" aria-label="Job detail">
<div class="wd-slide-panel-header">
<span class="wd-slide-panel-title" id="detail-title">Job Detail</span>
<button class="wd-btn wd-btn--ghost wd-btn--sm" onclick="closeDetail()" aria-label="Close" 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="wd-slide-panel-body job-detail-panel" id="detail-body">
<!-- populated by openDetail() -->
</div>
</div>
<div id="toast-container" class="wd-toast-container"></div>
<script>
/* ────────────────────────────────────────────────────────
Config & state
──────────────────────────────────────────────────────── */
const API = '/api/v1';
let allJobs = [];
let currentFilter = 'all';
let sseSource = null;
let activeCount = 0;
/* ────────────────────────────────────────────────────────
API helpers
──────────────────────────────────────────────────────── */
async function api(path, opts = {}) {
const r = await fetch(API + path, {
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
...opts
});
if (!r.ok) {
const msg = await r.text().catch(() => r.statusText);
throw new Error(msg || r.statusText);
}
return r.json();
}
/* ────────────────────────────────────────────────────────
SSE live feed
──────────────────────────────────────────────────────── */
function startSSE() {
const dot = document.getElementById('refresh-dot');
const label = document.getElementById('refresh-label');
if (sseSource) { sseSource.close(); sseSource = null; }
sseSource = new EventSource('/api/v1/jobs/events');
sseSource.addEventListener('open', () => {
dot.classList.add('live');
label.textContent = 'Live';
});
sseSource.addEventListener('message', (ev) => {
try {
const payload = JSON.parse(ev.data);
if (payload.type !== 'jobs') return;
allJobs = payload.jobs;
updateStats();
updateCounts();
renderJobs();
} catch (_) {}
});
sseSource.addEventListener('error', () => {
dot.classList.remove('live');
label.textContent = 'Reconnecting…';
});
}
/* ────────────────────────────────────────────────────────
Stats + counts
──────────────────────────────────────────────────────── */
function updateStats() {
const total = allJobs.length;
const active = allJobs.filter(j => j.status === 'active' || j.status === 'waiting').length;
const done = allJobs.filter(j => j.status === 'completed').length;
const failed = allJobs.filter(j => j.status === 'failed').length;
activeCount = active;
document.getElementById('stat-total').textContent = total;
document.getElementById('stat-active').textContent = active;
document.getElementById('stat-done').textContent = done;
document.getElementById('stat-failed').textContent = failed;
}
function updateCounts() {
const typeFilter = document.getElementById('type-filter').value;
const base = typeFilter ? allJobs.filter(j => j.type === typeFilter) : allJobs;
const counts = {
all: base.length,
active: base.filter(j => j.status === 'active' || j.status === 'waiting').length,
completed: base.filter(j => j.status === 'completed').length,
failed: base.filter(j => j.status === 'failed').length
};
for (const [k, v] of Object.entries(counts)) {
const el = document.getElementById('cnt-' + k);
if (el) el.textContent = v;
}
}
/* ────────────────────────────────────────────────────────
Render
──────────────────────────────────────────────────────── */
function getFilteredJobs() {
const typeFilter = document.getElementById('type-filter').value;
let jobs = typeFilter ? allJobs.filter(j => j.type === typeFilter) : allJobs;
if (currentFilter === 'active') return jobs.filter(j => j.status === 'active' || j.status === 'waiting');
if (currentFilter === 'completed') return jobs.filter(j => j.status === 'completed');
if (currentFilter === 'failed') return jobs.filter(j => j.status === 'failed');
return jobs;
}
function renderJobs() {
const area = document.getElementById('jobs-area');
const jobs = getFilteredJobs();
if (jobs.length === 0) {
const labels = {
all: 'No jobs yet', active: 'No active jobs',
completed: 'No completed jobs', failed: 'No failed jobs'
};
area.innerHTML = `
<div class="wd-empty">
<div class="wd-empty-icon">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
<rect x="4" y="6" width="24" height="20" rx="2" stroke="var(--border-strong)" stroke-width="1.5"/>
<path d="M9 12h14M9 17h10M9 22h6" stroke="var(--border-strong)" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</div>
<div class="wd-empty-title">${labels[currentFilter] || 'No jobs'}</div>
<div class="wd-empty-body">Jobs appear here when assets are processed.</div>
</div>`;
return;
}
area.innerHTML = `
<table class="jobs-table">
<thead>
<tr>
<th>Type</th>
<th>Asset</th>
<th>Status</th>
<th class="progress-cell">Progress</th>
<th>Started</th>
<th>Duration</th>
<th></th>
</tr>
</thead>
<tbody id="jobs-tbody">
</tbody>
</table>`;
const tbody = document.getElementById('jobs-tbody');
for (const job of jobs) {
tbody.appendChild(renderRow(job));
}
}
function renderRow(job) {
const tr = document.createElement('tr');
tr.dataset.jobId = job.id;
const pct = typeof job.progress === 'number' ? job.progress : 0;
const isActive = job.status === 'active' || job.status === 'waiting';
const barClass = job.status === 'completed' ? 'complete' : job.status === 'failed' ? 'failed' : '';
const assetName = job.asset_name || (job.asset_id ? job.asset_id.slice(0, 16) + '…' : '—');
const assetId = job.asset_id ? job.asset_id.slice(0, 8) : '';
const started = job.created_at ? new Date(job.created_at) : null;
const startedStr = started ? started.toLocaleTimeString('en-US', { hour12: false }) : '—';
const relStr = started ? timeAgo(started) : '';
const dur = formatDuration(job.started_at, job.completed_at || job.failed_at);
const retryBtn = (job.status === 'failed' && job.asset_id)
? `<button class="wd-btn wd-btn--ghost" style="font-size:var(--text-xs);padding:4px 10px;color:var(--status-green)" onclick="retryJob('${escHtml(job.asset_id)}', event)" title="Re-queue asset processing">Retry</button>`
: '';
tr.innerHTML = `
<td><span class="type-chip ${escHtml(job.type || 'conform')}">${escHtml((job.type || 'conform').toUpperCase())}</span></td>
<td>
<span class="asset-name" title="${escHtml(assetName)}">${escHtml(assetName)}</span>
${assetId ? `<span class="asset-link">${escHtml(assetId)}</span>` : ''}
</td>
<td>${statusBadge(job.status)}</td>
<td class="progress-cell">
<div class="inline-progress">
<div class="bar-track"><div class="bar-fill ${barClass}" style="width:${pct}%"></div></div>
<span class="pct">${isActive ? pct + '%' : (job.status === 'completed' ? '100%' : '—')}</span>
</div>
</td>
<td class="time-cell">
<div>${startedStr}</div>
<div class="rel">${relStr}</div>
</td>
<td class="time-cell">${dur}</td>
<td>
<button class="wd-btn wd-btn--ghost" style="font-size:var(--text-xs);padding:4px 10px" onclick="openDetail('${escHtml(job.id)}')">Details</button>
${retryBtn}
<button class="wd-btn wd-btn--ghost" style="font-size:var(--text-xs);padding:4px 10px;color:var(--signal-bad)" onclick="killJob('${escHtml(job.id)}', event)" title="Remove this job from the queue">Kill</button>
</td>`;
return tr;
}
async function killJob(jobId, ev) {
ev.stopPropagation();
if (!confirm('Remove this job from the queue? If a worker is still processing it, the run is abandoned.')) return;
try {
const r = await fetch('/api/v1/jobs/' + encodeURIComponent(jobId), { method: 'DELETE', credentials: 'include' });
if (r.ok) { toast('Job removed', 'success'); }
else { const d = await r.json().catch(()=>({})); toast('Remove failed: ' + (d.error || r.statusText), 'error'); }
} catch (err) {
toast('Remove failed: ' + err.message, 'error');
}
}
async function retryJob(assetId, ev) {
ev.stopPropagation();
try {
await api('/assets/' + encodeURIComponent(assetId) + '/retry', { method: 'POST' });
toast('Job re-queued — processing will restart shortly.');
} catch (e) {
showError('Retry failed: ' + e.message);
}
}
function statusBadge(status) {
const map = {
active: '<span class="wd-badge wd-badge--bad">Active</span>',
waiting: '<span class="wd-badge wd-badge--idle">Waiting</span>',
completed: '<span class="wd-badge wd-badge--good">Done</span>',
failed: '<span class="wd-badge wd-badge--bad">Failed</span>',
delayed: '<span class="wd-badge wd-badge--warn">Delayed</span>'
};
return map[status] || `<span class="wd-badge wd-badge--idle">${escHtml(status || 'Unknown')}</span>`;
}
/* ────────────────────────────────────────────────────────
Filter tabs
──────────────────────────────────────────────────────── */
function setFilter(filter, btn) {
currentFilter = filter;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderJobs();
}
/* ────────────────────────────────────────────────────────
Detail panel
──────────────────────────────────────────────────────── */
function openDetail(jobId) {
const job = allJobs.find(j => String(j.id) === String(jobId));
if (!job) return;
const pct = typeof job.progress === 'number' ? job.progress : 0;
const barClass = job.status === 'completed' ? 'complete' : job.status === 'failed' ? 'failed' : '';
const dur = formatDuration(job.started_at, job.completed_at || job.failed_at);
document.getElementById('detail-title').textContent = (job.type || 'Job').toUpperCase() + ' — ' + (job.asset_name || job.id || '');
document.getElementById('detail-body').innerHTML = `
<div class="panel-section">
<div class="detail-label">Status</div>
<div class="detail-value">${statusBadge(job.status)}</div>
</div>
<div class="panel-section">
<div class="detail-label">Progress</div>
<div class="progress-large">
<div class="pct-label">${job.status === 'completed' ? '100' : pct}%</div>
<div class="bar-track">
<div class="bar-fill ${barClass}" style="width:${job.status === 'completed' ? 100 : pct}%"></div>
</div>
</div>
</div>
<div class="panel-section">
<div class="detail-label">Asset ID</div>
<div class="detail-value" style="font-family:monospace;font-size:var(--text-xs)">${escHtml(job.asset_id || '—')}</div>
</div>
<div class="panel-section" style="display:grid;grid-template-columns:1fr 1fr;gap:var(--sp-4)">
<div>
<div class="detail-label">Created</div>
<div class="detail-value">${job.created_at ? new Date(job.created_at).toLocaleString() : '—'}</div>
</div>
<div>
<div class="detail-label">Duration</div>
<div class="detail-value">${dur}</div>
</div>
</div>
${job.status === 'failed' && job.asset_id ? `
<div class="panel-section">
<button class="wd-btn wd-btn--secondary wd-btn--sm" onclick="retryJob('${escHtml(job.asset_id)}', event); closeDetail();">
Retry — re-queue processing
</button>
</div>` : ''}
${job.error ? `
<div class="panel-section">
<div class="detail-label">Error</div>
<div class="log-block" style="color:var(--status-red)">${escHtml(job.error)}</div>
</div>` : ''}
${job.logs ? `
<div class="panel-section">
<div class="detail-label">Logs</div>
<div class="log-block">${escHtml(job.logs)}</div>
</div>` : ''}
<div class="panel-section">
<div class="detail-label">Raw data</div>
<div class="log-block">${escHtml(JSON.stringify(job, null, 2))}</div>
</div>`;
document.getElementById('detail-panel').classList.add('is-open');
document.getElementById('detail-overlay').classList.add('is-open');
}
function closeDetail() {
document.getElementById('detail-panel').classList.remove('is-open');
document.getElementById('detail-overlay').classList.remove('is-open');
}
/* ────────────────────────────────────────────────────────
Clear completed
──────────────────────────────────────────────────────── */
async function clearCompleted() {
const completed = allJobs.filter(j => j.status === 'completed');
if (completed.length === 0) { toast('No completed jobs to clear.'); return; }
try {
await Promise.all(completed.map(j => api(`/jobs/${j.id}`, { method: 'DELETE' }).catch(() => {})));
toast(`Cleared ${completed.length} completed job${completed.length === 1 ? '' : 's'}.`);
} catch (e) {
showError('Failed to clear jobs: ' + e.message);
}
}
/* ────────────────────────────────────────────────────────
Utilities
──────────────────────────────────────────────────────── */
function escHtml(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function timeAgo(date) {
const s = Math.floor((Date.now() - date.getTime()) / 1000);
if (s < 60) return `${s}s ago`;
if (s < 3600) return `${Math.floor(s/60)}m ago`;
if (s < 86400)return `${Math.floor(s/3600)}h ago`;
return `${Math.floor(s/86400)}d ago`;
}
function formatDuration(start, end) {
if (!start) return '—';
const s = new Date(start);
const e = end ? new Date(end) : new Date();
const ms = e - s;
if (isNaN(ms) || ms < 0) return '';
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s`;
if (sec < 3600) return `${Math.floor(sec/60)}m ${sec % 60}s`;
return `${Math.floor(sec/3600)}h ${Math.floor((sec%3600)/60)}m`;
}
function toast(msg, type = 'success') {
const el = document.createElement('div');
el.className = `wd-toast ${type === 'error' ? 'wd-toast--error' : 'wd-toast--success'}`;
el.textContent = msg;
document.getElementById('toast-container').appendChild(el);
requestAnimationFrame(() => el.classList.add('show'));
setTimeout(() => { el.classList.remove('show'); setTimeout(() => el.remove(), 300); }, 3500);
}
function showError(msg) { toast(msg, 'error'); }
/* ────────────────────────────────────────────────────────
Init
──────────────────────────────────────────────────────── */
startSSE();
</script>
<script src="js/topbar-strip.js?v=1"></script>
<script src="js/auth-guard.js"></script>
</body>
</html>

View file

@ -263,7 +263,7 @@
try{
const res = await fetch(API + '/login', {method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',
body: JSON.stringify({username:$('username').value.trim(),password:$('password').value})});
if(res.ok){ showFlash('Signed in, redirecting...','success'); setTimeout(()=>{location.href='home.html'},600); }
if(res.ok){ showFlash('Signed in, redirecting...','success'); setTimeout(()=>{location.href='/'},600); }
else{ const d=await res.json().catch(()=>({})); showFlash(d.error||'Login failed','error'); }
} catch(err){ showFlash('Network error: '+err.message,'error'); }
finally{ btn.disabled=false; btn.textContent='Sign in'; }

View file

@ -1,546 +0,0 @@
<!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>Player — Dragonflight</title>
<link rel="stylesheet" href="/dist/app.css">
<style>
.player-container {
display: grid;
grid-template-columns: 1fr 300px;
gap: 16px;
height: calc(100vh - 110px);
overflow: hidden;
}
.player-main {
display: flex;
flex-direction: column;
gap: 16px;
}
.video-container {
flex: 1;
background-color: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.video-player {
width: 100%;
height: 100%;
background-color: var(--bg-base);
}
.metadata-panel {
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
}
.metadata-section {
padding: 12px;
background-color: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
}
.metadata-section-title {
font-size: 0.85rem;
font-weight: 700;
color: var(--text-secondary);
text-transform: uppercase;
margin-bottom: 12px;
letter-spacing: 0.3px;
}
.metadata-row {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.metadata-row:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.metadata-label {
font-size: 0.75rem;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.2px;
}
.metadata-value {
font-size: 0.9rem;
color: var(--text-primary);
font-weight: 500;
}
.metadata-editable {
display: flex;
gap: 8px;
flex-direction: column;
}
.metadata-textarea {
width: 100%;
padding: 8px;
background-color: var(--bg-base);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-family: 'Courier New', monospace;
font-size: 0.85rem;
resize: vertical;
min-height: 80px;
}
.metadata-textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(233, 69, 96, 0.1);
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.tag-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background-color: var(--bg-base);
border: 1px solid var(--border);
border-radius: 20px;
font-size: 0.8rem;
color: var(--text-secondary);
cursor: pointer;
transition: all 120ms ease;
}
.tag-badge:hover {
border-color: var(--accent);
color: var(--accent);
}
.tag-remove {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-weight: bold;
}
.edit-controls {
display: flex;
gap: 8px;
margin-top: 12px;
}
@media (max-width: 768px) {
.player-container {
grid-template-columns: 1fr;
height: auto;
}
.metadata-panel {
display: grid;
grid-template-columns: 1fr 1fr;
}
.video-container {
height: 400px;
}
}
</style>
</head>
<body>
<div class="wd-shell" style="display:flex;min-height:100vh;">
<nav class="wd-sidebar" aria-label="Main navigation">
<div class="wd-sidebar-header">
<img src="img/dragon-logo.png?v=1" alt="Dragonflight" style="width:18px;height:18px;">
<span class="wd-sidebar-brand">Dragonflight</span>
</div>
<div class="wd-sidebar-nav">
<a href="home.html" class="wd-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="wd-nav-item">
<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="wd-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="wd-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="wd-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="wd-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="wd-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="edit.html" class="wd-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="wd-sidebar-section">Admin</div>
<a href="users.html" class="wd-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="wd-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>
</div>
<div class="wd-sidebar-footer">
<div class="wd-sidebar-user">
<div class="wd-sidebar-user-avatar" id="userAvatar">?</div>
<div class="wd-sidebar-user-info">
<div class="wd-sidebar-user-name" id="userName"></div>
<div class="wd-sidebar-user-role" id="userRole"></div>
</div>
<button class="wd-btn wd-btn--ghost wd-btn--sm wd-btn--icon wd-sidebar-user-logout" id="logoutBtn" title="Sign out">
<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>
<div style="flex:1;display:flex;flex-direction:column;">
<header class="wd-topbar">
<div class="wd-topbar-left">
<span class="page-title">Player</span>
</div>
<div class="wd-topbar-right">
<button class="wd-btn wd-btn--ghost wd-btn--sm" onclick="goBack()">← Back to Library</button>
</div>
</header>
<div style="flex:1;overflow:auto;padding:16px;">
<div class="player-container">
<!-- Main Player -->
<div class="player-main">
<div class="video-container">
<video class="video-player" id="videoPlayer" controls></video>
</div>
</div>
<!-- Sidebar -->
<div class="metadata-panel">
<!-- File Info -->
<div class="metadata-section">
<div class="metadata-section-title">File Information</div>
<div class="metadata-row">
<div class="metadata-label">Filename</div>
<div class="metadata-value" id="metaFilename"></div>
</div>
<div class="metadata-row">
<div class="metadata-label">Format</div>
<div class="metadata-value" id="metaFormat"></div>
</div>
<div class="metadata-row">
<div class="metadata-label">Codec</div>
<div class="metadata-value" id="metaCodec"></div>
</div>
<div class="metadata-row">
<div class="metadata-label">Resolution</div>
<div class="metadata-value" id="metaResolution"></div>
</div>
<div class="metadata-row">
<div class="metadata-label">Framerate</div>
<div class="metadata-value" id="metaFps"></div>
</div>
<div class="metadata-row">
<div class="metadata-label">Duration</div>
<div class="metadata-value" id="metaDuration"></div>
</div>
<div class="metadata-row">
<div class="metadata-label">File Size</div>
<div class="metadata-value" id="metaFileSize"></div>
</div>
<div class="metadata-row">
<div class="metadata-label">Status</div>
<div class="metadata-value" id="metaStatus"></div>
</div>
<div class="metadata-row">
<div class="metadata-label">Captured</div>
<div class="metadata-value" id="metaCreated"></div>
</div>
</div>
<!-- Tags -->
<div class="metadata-section">
<div class="metadata-section-title">Tags</div>
<div class="tags-list" id="tagsList"></div>
<input
type="text"
id="newTagInput"
class="form-input"
placeholder="Add tag..."
style="font-size: 0.85rem;"
>
<button class="wd-btn wd-btn--secondary wd-btn--sm" style="width: 100%; margin-top: 8px;" onclick="addTag()">Add Tag</button>
</div>
<!-- Notes -->
<div class="metadata-section">
<div class="metadata-section-title">Notes</div>
<textarea id="notesInput" class="metadata-textarea" placeholder="Add notes about this asset..."></textarea>
<div class="edit-controls">
<button class="wd-btn wd-btn--primary wd-btn--sm" style="flex: 1;" onclick="saveMetadata()">Save</button>
<button class="wd-btn wd-btn--secondary wd-btn--sm" style="flex: 1;" onclick="resetMetadata()">Reset</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/js/api.js?v=6"></script>
<script src="/js/topbar-strip.js?v=1"></script>
<script src="js/auth-guard.js"></script>
<script>
// ============================================================
// STATE MANAGEMENT
// ============================================================
let playerState = {
assetId: null,
asset: null,
tags: [],
notes: '',
originalTags: [],
originalNotes: '',
};
// ============================================================
// HELPERS
// ============================================================
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')}`;
}
function formatFileSize(bytes) {
if (!bytes) return '—';
const units = ['B','KB','MB','GB','TB'];
let i = 0;
let val = bytes;
while (val >= 1024 && i < units.length - 1) { val /= 1024; i++; }
return `${val.toFixed(1)} ${units[i]}`;
}
function getStatusBadgeClass(status) {
switch (status) {
case 'recording': return 'wd-badge wd-badge--bad';
case 'ready': return 'wd-badge wd-badge--good';
case 'processing':return 'wd-badge wd-badge--warn';
case 'error': return 'wd-badge wd-badge--bad';
default: return 'wd-badge';
}
}
function getStatusLabel(status) {
switch (status) {
case 'recording': return 'Recording';
case 'ready': return 'Ready';
case 'processing': return 'Processing';
case 'error': return 'Error';
default: return status || '—';
}
}
// ============================================================
// INITIALIZATION
// ============================================================
document.addEventListener('DOMContentLoaded', () => {
setupEventListeners();
const params = new URLSearchParams(window.location.search);
playerState.assetId = params.get('id');
if (playerState.assetId) {
loadAsset();
}
});
function setupEventListeners() {
document.getElementById('newTagInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
addTag();
e.preventDefault();
}
});
}
async function loadAsset() {
try {
const result = await getAsset(playerState.assetId);
if (result.success) {
playerState.asset = result.data;
playerState.tags = result.data.tags || [];
playerState.notes = result.data.notes || '';
playerState.originalTags = [...playerState.tags];
playerState.originalNotes = playerState.notes;
renderAsset();
// Load video stream
const streamResult = await getAssetStreamUrl(playerState.assetId);
if (streamResult.success) {
document.getElementById('videoPlayer').src = streamResult.data.url;
}
}
} catch (error) {
console.error('Error loading asset:', error);
}
}
// ============================================================
// RENDERING
// ============================================================
function renderAsset() {
const asset = playerState.asset;
document.getElementById('metaFilename').textContent = asset.filename;
document.getElementById('metaFormat').textContent = asset.format || '—';
document.getElementById('metaCodec').textContent = asset.codec || '—';
document.getElementById('metaResolution').textContent = asset.resolution || '—';
document.getElementById('metaFps').textContent = asset.fps ? `${asset.fps} fps` : '—';
document.getElementById('metaDuration').textContent = formatDuration(asset.duration);
document.getElementById('metaFileSize').textContent = formatFileSize(asset.file_size || 0);
document.getElementById('metaStatus').innerHTML =
`<span class="${getStatusBadgeClass(asset.status)}">${getStatusLabel(asset.status)}</span>`;
document.getElementById('metaCreated').textContent = new Date(asset.created_at).toLocaleDateString();
renderTags();
document.getElementById('notesInput').value = playerState.notes;
document.title = `${asset.filename} — Dragonflight Player`;
}
function renderTags() {
const container = document.getElementById('tagsList');
container.innerHTML = '';
playerState.tags.forEach((tag, index) => {
const badge = document.createElement('div');
badge.className = 'tag-badge';
const tagSpan = document.createElement('span');
tagSpan.textContent = tag;
const removeSpan = document.createElement('span');
removeSpan.className = 'tag-remove';
removeSpan.textContent = '×';
removeSpan.onclick = () => removeTag(index);
badge.appendChild(tagSpan);
badge.appendChild(removeSpan);
container.appendChild(badge);
});
}
// ============================================================
// METADATA EDITING
// ============================================================
function addTag() {
const input = document.getElementById('newTagInput');
const tag = input.value.trim().toLowerCase();
if (tag && !playerState.tags.includes(tag)) {
playerState.tags.push(tag);
renderTags();
input.value = '';
}
}
function removeTag(index) {
playerState.tags.splice(index, 1);
renderTags();
}
async function saveMetadata() {
if (!playerState.assetId) return;
playerState.notes = document.getElementById('notesInput').value;
try {
const result = await updateAsset(playerState.assetId, {
tags: playerState.tags,
notes: playerState.notes,
});
if (result.success) {
playerState.originalTags = [...playerState.tags];
playerState.originalNotes = playerState.notes;
}
} catch (error) {
console.error('Error saving metadata:', error);
}
}
function resetMetadata() {
playerState.tags = [...playerState.originalTags];
playerState.notes = playerState.originalNotes;
renderTags();
document.getElementById('notesInput').value = playerState.notes;
}
// ============================================================
// NAVIGATION
// ============================================================
function navigateTo(page) {
if (page === 'assets') {
window.location.href = '/index.html';
} else if (page === 'capture') {
window.location.href = '/capture.html';
}
}
function goBack() {
window.location.href = '/index.html';
}
</script>
</body>
</html>

View file

@ -1,580 +0,0 @@
<!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>Projects — Dragonflight</title>
<link rel="stylesheet" href="/dist/app.css">
<style>
.proj-shell { display: flex; flex: 1; overflow: hidden; }
.proj-list-panel {
width: 340px;
flex-shrink: 0;
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
background: oklch(11% 0.018 250 / 0.6);
}
.proj-list-header {
padding: 16px 18px 12px;
display: flex; align-items: center; justify-content: space-between;
gap: 8px;
border-bottom: 1px solid var(--border);
}
.proj-list-title {
font-size: 11px; font-weight: 600;
letter-spacing: 0.16em; text-transform: uppercase;
color: var(--text-tertiary);
}
.proj-list-count {
font-size: 11px; font-family: var(--font-mono);
color: var(--text-tertiary);
}
.proj-list-search {
padding: 8px 14px 0;
}
.proj-list-search input {
width: 100%;
padding: 8px 12px;
background: oklch(15% 0.020 250);
border: 1px solid var(--border);
border-radius: var(--r-sm);
color: var(--text-primary);
font-size: 13px;
}
.proj-list { flex: 1; overflow: auto; padding: 8px; }
.proj-list-empty {
padding: 32px 18px;
text-align: center;
color: var(--text-tertiary);
font-size: 13px;
}
.proj-row {
display: flex; flex-direction: column; gap: 6px;
padding: 12px 14px;
border-radius: var(--r-sm);
border: 1px solid transparent;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
}
.proj-row:hover { background: oklch(15% 0.020 250 / 0.5); }
.proj-row.active {
background: oklch(18% 0.030 260 / 0.7);
border-color: oklch(45% 0.20 32 / 0.45);
}
.proj-row-name {
font-size: 14px; font-weight: 500;
color: var(--text-primary);
display: flex; align-items: center; gap: 8px;
}
.proj-row-meta {
display: flex; gap: 12px; align-items: center;
font-size: 11px; color: var(--text-tertiary);
font-family: var(--font-mono);
}
.proj-row-meta b { color: var(--text-secondary); font-weight: 600; }
.proj-detail {
flex: 1; display: flex; flex-direction: column;
overflow: hidden;
background: var(--bg-base);
}
.proj-detail-empty {
flex: 1; display: flex; align-items: center; justify-content: center;
color: var(--text-tertiary); font-size: 13px;
flex-direction: column; gap: 12px;
}
.proj-detail-header {
padding: 24px 32px 16px;
border-bottom: 1px solid var(--border);
}
.proj-detail-eyebrow {
font-size: 11px; font-weight: 600;
letter-spacing: 0.16em; text-transform: uppercase;
color: var(--text-tertiary);
margin-bottom: 8px;
}
.proj-detail-title {
display: flex; align-items: center; gap: 12px;
font-size: 22px; font-weight: 600;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.proj-detail-title input {
background: transparent;
border: 1px solid transparent;
border-radius: var(--r-sm);
padding: 4px 8px;
color: var(--text-primary);
font: inherit;
min-width: 240px;
}
.proj-detail-title input:focus,
.proj-detail-title input:hover {
border-color: var(--border);
background: oklch(13% 0.018 250);
}
.proj-detail-desc {
margin-top: 8px;
font-size: 13px; color: var(--text-secondary);
line-height: 1.55;
}
.proj-detail-desc textarea {
width: 100%; min-height: 56px; resize: vertical;
background: transparent;
border: 1px solid transparent;
border-radius: var(--r-sm);
padding: 6px 8px;
color: var(--text-secondary); font: inherit;
}
.proj-detail-desc textarea:focus,
.proj-detail-desc textarea:hover {
border-color: var(--border);
background: oklch(13% 0.018 250);
}
.proj-detail-stats {
display: flex; gap: 24px;
margin-top: 14px;
font-size: 12px; font-family: var(--font-mono);
color: var(--text-tertiary);
}
.proj-detail-stats b { color: var(--text-primary); font-weight: 600; }
.proj-detail-actions {
margin-top: 16px;
display: flex; gap: 8px;
}
.proj-bins {
flex: 1; overflow: auto;
padding: 24px 32px 40px;
}
.proj-bins-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 16px;
}
.proj-bins-title {
font-size: 11px; font-weight: 600;
letter-spacing: 0.16em; text-transform: uppercase;
color: var(--text-tertiary);
}
.proj-bin-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.proj-bin-card {
position: relative;
padding: 16px;
background: oklch(13% 0.018 250 / 0.6);
border: 1px solid oklch(28% 0.04 260 / 0.4);
border-radius: var(--r-sm);
display: flex; flex-direction: column; gap: 6px;
}
.proj-bin-card-name {
font-size: 14px; font-weight: 500;
color: var(--text-primary);
display: flex; align-items: center; gap: 8px;
}
.proj-bin-card-meta {
font-size: 11px; color: var(--text-tertiary);
font-family: var(--font-mono);
}
.proj-bin-card-actions {
position: absolute; top: 10px; right: 10px;
display: flex; gap: 4px;
opacity: 0; transition: opacity 120ms ease;
}
.proj-bin-card:hover .proj-bin-card-actions { opacity: 1; }
.proj-bin-empty {
grid-column: 1 / -1;
padding: 32px;
text-align: center;
color: var(--text-tertiary);
font-size: 13px;
border: 1px dashed var(--border);
border-radius: var(--r-sm);
}
/* Modal */
.modal-overlay {
position: fixed; inset: 0;
background: oklch(0% 0 0 / 0.6);
backdrop-filter: blur(4px);
display: none; align-items: center; justify-content: center;
z-index: 100;
}
.modal-overlay.open { display: flex; }
.modal {
width: min(420px, 90vw);
background: oklch(15% 0.025 250);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
box-shadow: 0 30px 60px -20px oklch(0% 0 0 / 0.5);
}
.modal h3 {
font-size: 16px; font-weight: 600;
margin-bottom: 12px;
color: var(--text-primary);
}
.modal label {
display: block;
font-size: 11px; font-weight: 600;
letter-spacing: 0.06em; text-transform: uppercase;
color: var(--text-tertiary);
margin-bottom: 6px; margin-top: 12px;
}
.modal input, .modal textarea {
width: 100%;
padding: 10px 12px;
background: oklch(11% 0.015 250);
border: 1px solid var(--border);
border-radius: var(--r-sm);
color: var(--text-primary);
font: inherit; font-size: 14px;
}
.modal input:focus, .modal textarea:focus {
outline: none; border-color: oklch(45% 0.20 32 / 0.6);
}
.modal-actions {
display: flex; gap: 8px; justify-content: flex-end;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="wd-shell" style="display:flex;min-height:100vh;">
<nav class="wd-sidebar" aria-label="Main navigation">
<div class="wd-sidebar-header">
<img src="img/dragon-logo.png?v=1" alt="Dragonflight" style="width:18px;height:18px;">
<span class="wd-sidebar-brand">Dragonflight</span>
</div>
<div class="wd-sidebar-nav">
<a href="home.html" class="wd-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="wd-nav-item">
<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="wd-nav-item is-active">
<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="wd-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="wd-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="wd-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="wd-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="wd-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="wd-sidebar-section">Admin</div>
<a href="users.html" class="wd-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="wd-nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
Tokens
</a>
<a href="containers.html" class="wd-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="wd-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>
<a href="settings.html" class="wd-nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M2.9 2.9l1.1 1.1M12 12l1.1 1.1M2.9 13.1L4 12M12 4l1.1-1.1"/></svg>
Settings
</a>
</div>
<div class="wd-sidebar-footer">
<div class="wd-sidebar-user">
<div class="wd-sidebar-user-avatar" id="userAvatar">?</div>
<div class="wd-sidebar-user-info">
<div class="wd-sidebar-user-name" id="userName"></div>
<div class="wd-sidebar-user-role" id="userRole"></div>
</div>
<button class="wd-btn wd-btn--ghost wd-btn--sm wd-btn--icon wd-sidebar-user-logout" id="logoutBtn" title="Sign out">
<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>
<div style="flex:1;display:flex;flex-direction:column;">
<header class="wd-topbar">
<div class="wd-topbar-left">
<span class="page-title">Projects</span>
</div>
<div class="wd-topbar-right">
<button class="wd-btn wd-btn--primary wd-btn--sm" onclick="openNewProject()">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"><path d="M8 3v10M3 8h10"/></svg>
New project
</button>
</div>
</header>
<div class="proj-shell">
<!-- Left: project list -->
<aside class="proj-list-panel">
<div class="proj-list-header">
<span class="proj-list-title">All Projects</span>
<span class="proj-list-count" id="projCount">--</span>
</div>
<div class="proj-list-search">
<input type="text" id="projSearch" placeholder="Search projects…" />
</div>
<div class="proj-list" id="projList">
<div class="proj-list-empty">Loading…</div>
</div>
</aside>
<!-- Right: detail / bins -->
<section class="proj-detail" id="projDetail">
<div class="proj-detail-empty">
<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1" width="48" height="48" style="opacity:0.4">
<path d="M3 9a1 1 0 0 1 1-1h9l3 3h12a1 1 0 0 1 1 1v15a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z"/>
</svg>
<div>Select a project on the left, or create a new one.</div>
</div>
</section>
</div>
</div>
</div>
<!-- New project modal -->
<div class="modal-overlay" id="newProjModal">
<div class="modal">
<h3>New project</h3>
<label>Name</label>
<input type="text" id="newProjName" placeholder="e.g. 2026 Sunday Service" autocomplete="off" />
<label>Description (optional)</label>
<textarea id="newProjDesc" rows="2" placeholder="A short note about the project"></textarea>
<div class="modal-actions">
<button class="wd-btn wd-btn--ghost wd-btn--sm" onclick="closeModal('newProjModal')">Cancel</button>
<button class="wd-btn wd-btn--primary wd-btn--sm" onclick="createProject()">Create</button>
</div>
</div>
</div>
<!-- New bin modal -->
<div class="modal-overlay" id="newBinModal">
<div class="modal">
<h3>New bin</h3>
<label>Name</label>
<input type="text" id="newBinName" placeholder="e.g. Cameras, B-Roll, Interviews" autocomplete="off" />
<div class="modal-actions">
<button class="wd-btn wd-btn--ghost wd-btn--sm" onclick="closeModal('newBinModal')">Cancel</button>
<button class="wd-btn wd-btn--primary wd-btn--sm" onclick="createBin()">Create</button>
</div>
</div>
</div>
<script src="js/api.js"></script>
<script src="js/topbar-strip.js"></script>
<script>
const state = { projects: [], filtered: [], selectedId: null, bins: [], assetsByProject: {} };
// ── Modal helpers ─────────────────────────
function openModal(id) { document.getElementById(id).classList.add('open'); setTimeout(() => { const i = document.querySelector('#' + id + ' input'); if (i) i.focus(); }, 50); }
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
function openNewProject() { document.getElementById('newProjName').value = ''; document.getElementById('newProjDesc').value = ''; openModal('newProjModal'); }
function openNewBin() { if (!state.selectedId) return; document.getElementById('newBinName').value = ''; openModal('newBinModal'); }
// ── API ────────────────────────────────────
async function api(path, opts = {}) {
const r = await fetch('/api/v1' + path, Object.assign({ credentials: 'include', headers: { 'Content-Type': 'application/json' } }, opts));
if (!r.ok) throw new Error('HTTP ' + r.status + ' on ' + path);
return r.json();
}
async function loadAll() {
try {
const projs = await api('/projects');
state.projects = Array.isArray(projs) ? projs : [];
const counts = await Promise.all(state.projects.map(async (p) => {
try {
const r = await api('/assets?project_id=' + encodeURIComponent(p.id) + '&limit=1');
return { id: p.id, count: r.total ?? 0 };
} catch { return { id: p.id, count: 0 }; }
}));
counts.forEach(c => { state.assetsByProject[c.id] = c.count; });
filterAndRender();
if (state.selectedId) await loadBins(state.selectedId);
} catch (e) {
document.getElementById('projList').innerHTML = '<div class="proj-list-empty" style="color:var(--status-red)">Failed to load: ' + e.message + '</div>';
}
}
function filterAndRender() {
const q = (document.getElementById('projSearch').value || '').trim().toLowerCase();
state.filtered = state.projects.filter(p => !q || p.name.toLowerCase().includes(q) || (p.description || '').toLowerCase().includes(q));
document.getElementById('projCount').textContent = state.filtered.length + (state.filtered.length === state.projects.length ? '' : ' / ' + state.projects.length);
const list = document.getElementById('projList');
if (state.filtered.length === 0) {
list.innerHTML = '<div class="proj-list-empty">' + (q ? 'No matches.' : 'No projects yet. Create one with the button above.') + '</div>';
return;
}
list.innerHTML = state.filtered.map(p => {
const cnt = state.assetsByProject[p.id] ?? 0;
const active = p.id === state.selectedId ? ' active' : '';
const created = new Date(p.created_at).toLocaleDateString();
return '<div class="proj-row' + active + '" onclick="selectProject(\'' + p.id + '\')"><div class="proj-row-name">' + esc(p.name) + '</div><div class="proj-row-meta"><span><b>' + cnt + '</b> assets</span><span>' + created + '</span></div></div>';
}).join('');
}
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
// ── Selection / detail ─────────────────────
async function selectProject(id) {
state.selectedId = id;
filterAndRender();
await loadBins(id);
}
async function loadBins(projectId) {
const p = state.projects.find(x => x.id === projectId);
if (!p) return;
try {
state.bins = await api('/bins?project_id=' + encodeURIComponent(projectId));
} catch { state.bins = []; }
renderDetail(p);
}
function renderDetail(p) {
const host = document.getElementById('projDetail');
const cnt = state.assetsByProject[p.id] ?? 0;
const created = new Date(p.created_at).toLocaleString();
host.innerHTML =
'<div class="proj-detail-header">' +
'<div class="proj-detail-eyebrow">Project</div>' +
'<div class="proj-detail-title"><input id="detailName" value="' + esc(p.name) + '" onblur="renameProject(\'' + p.id + '\', this.value)"></div>' +
'<div class="proj-detail-desc"><textarea id="detailDesc" placeholder="Description…" onblur="updateDesc(\'' + p.id + '\', this.value)">' + esc(p.description || '') + '</textarea></div>' +
'<div class="proj-detail-stats">' +
'<span><b>' + cnt + '</b> assets</span>' +
'<span><b>' + state.bins.length + '</b> bins</span>' +
'<span>Created ' + created + '</span>' +
'</div>' +
'<div class="proj-detail-actions">' +
'<button class="wd-btn wd-btn--primary wd-btn--sm" onclick="openNewBin()"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"><path d="M8 3v10M3 8h10"/></svg>New bin</button>' +
'<button class="wd-btn wd-btn--ghost wd-btn--sm" onclick="location.href=\'index.html?project=' + p.id + '\'">Open in Library</button>' +
'<button class="wd-btn wd-btn--danger wd-btn--sm" style="margin-left:auto" onclick="deleteProject(\'' + p.id + '\')">Delete project</button>' +
'</div>' +
'</div>' +
'<div class="proj-bins">' +
'<div class="proj-bins-header"><span class="proj-bins-title">Bins</span></div>' +
'<div class="proj-bin-grid">' +
(state.bins.length === 0
? '<div class="proj-bin-empty">No bins yet. Use <b>New bin</b> above to make your first one.</div>'
: state.bins.map(b => binCard(b)).join('')) +
'</div>' +
'</div>';
}
function binCard(b) {
const nameJs = esc(JSON.stringify(b.name));
return '<div class="proj-bin-card">' +
'<div class="proj-bin-card-name">' +
'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><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>' +
esc(b.name) +
'</div>' +
'<div class="proj-bin-card-meta">Created ' + new Date(b.created_at).toLocaleDateString() + '</div>' +
'<div class="proj-bin-card-actions">' +
'<button class="wd-btn wd-btn--ghost wd-btn--sm" style="padding:4px 8px" onclick="renameBinPrompt(\'' + b.id + '\', ' + nameJs + ')">Rename</button>' +
'<button class="wd-btn wd-btn--ghost wd-btn--sm" style="padding:4px 8px;color:var(--status-red)" onclick="deleteBin(\'' + b.id + '\')">Delete</button>' +
'</div>' +
'</div>';
}
// ── Mutations ──────────────────────────────
async function createProject() {
const name = document.getElementById('newProjName').value.trim();
const description = document.getElementById('newProjDesc').value.trim();
if (!name) return alert('Name is required');
try {
const p = await api('/projects', { method: 'POST', body: JSON.stringify({ name, description }) });
closeModal('newProjModal');
state.selectedId = p.id;
await loadAll();
} catch (e) { alert('Create failed: ' + e.message); }
}
async function renameProject(id, name) {
name = (name || '').trim();
if (!name) return loadAll();
const cur = state.projects.find(p => p.id === id);
if (cur && cur.name === name) return;
try { await api('/projects/' + id, { method: 'PATCH', body: JSON.stringify({ name }) }); await loadAll(); }
catch (e) { alert('Rename failed: ' + e.message); }
}
async function updateDesc(id, description) {
const cur = state.projects.find(p => p.id === id);
if (cur && (cur.description || '') === description) return;
try { await api('/projects/' + id, { method: 'PATCH', body: JSON.stringify({ description }) }); await loadAll(); }
catch (e) { alert('Save failed: ' + e.message); }
}
async function deleteProject(id) {
const p = state.projects.find(x => x.id === id);
const cnt = state.assetsByProject[id] ?? 0;
if (!confirm('Delete project "' + p.name + '"?\n\nThis will also delete its ' + cnt + ' asset(s) and any bins. This cannot be undone.')) return;
try {
await api('/projects/' + id, { method: 'DELETE' });
state.selectedId = null;
document.getElementById('projDetail').innerHTML = '<div class="proj-detail-empty"><svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1" width="48" height="48" style="opacity:0.4"><path d="M3 9a1 1 0 0 1 1-1h9l3 3h12a1 1 0 0 1 1 1v15a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z"/></svg><div>Select a project on the left, or create a new one.</div></div>';
await loadAll();
} catch (e) { alert('Delete failed: ' + e.message); }
}
async function createBin() {
const name = document.getElementById('newBinName').value.trim();
if (!name || !state.selectedId) return;
try {
await api('/bins', { method: 'POST', body: JSON.stringify({ project_id: state.selectedId, name }) });
closeModal('newBinModal');
await loadBins(state.selectedId);
} catch (e) { alert('Create bin failed: ' + e.message); }
}
async function renameBinPrompt(id, current) {
const name = prompt('Rename bin', current);
if (!name || name === current) return;
try { await api('/bins/' + id, { method: 'PATCH', body: JSON.stringify({ name }) }); await loadBins(state.selectedId); }
catch (e) { alert('Rename failed: ' + e.message); }
}
async function deleteBin(id) {
if (!confirm('Delete this bin? Assets inside the bin will become un-binned (still in the project).')) return;
try { await api('/bins/' + id, { method: 'DELETE' }); await loadBins(state.selectedId); }
catch (e) { alert('Delete failed: ' + e.message); }
}
// ── Init ────────────────────────────────────
document.getElementById('projSearch').addEventListener('input', filterAndRender);
document.querySelectorAll('.modal-overlay').forEach(el => el.addEventListener('click', e => { if (e.target === el) el.classList.remove('open'); }));
document.addEventListener('keydown', e => { if (e.key === 'Escape') document.querySelectorAll('.modal-overlay.open').forEach(m => m.classList.remove('open')); });
loadAll();
</script>
<script src="js/auth-guard.js"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -1,749 +0,0 @@
<!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>Settings — Dragonflight</title>
<link rel="stylesheet" href="/dist/app.css">
<style>
body { margin: 0; }
.settings-layout {
display: flex;
flex-direction: column;
gap: 32px;
max-width: 780px;
}
.settings-section {
background: oklch(13% 0.018 250 / 0.6);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.settings-section-header {
display: flex;
align-items: center;
gap: 12px;
padding: 18px 24px 16px;
border-bottom: 1px solid var(--border);
}
.settings-section-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.settings-section-icon.s3 { background: oklch(20% 0.08 195 / 0.5); color: oklch(68% 0.18 195); }
.settings-section-icon.gpu { background: oklch(20% 0.08 32 / 0.5); color: oklch(68% 0.18 32); }
.settings-section-icon.sdi { background: oklch(20% 0.08 25 / 0.5); color: oklch(72% 0.18 40); }
.settings-section-icon.ampp { background: oklch(20% 0.08 148 / 0.5); color: oklch(68% 0.18 148); }
.settings-section-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.005em;
}
.settings-section-desc {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 2px;
}
.settings-section-body {
padding: 20px 24px 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
/* Hardware table */
.hw-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.hw-table thead tr {
border-bottom: 1px solid var(--border);
}
.hw-table th {
padding: 6px 10px;
text-align: left;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.hw-table th:first-child { padding-left: 0; }
.hw-table tbody tr {
border-bottom: 1px solid oklch(28% 0.04 32 / 0.3);
}
.hw-table tbody tr:last-child { border-bottom: none; }
.hw-table td {
padding: 10px 10px;
vertical-align: middle;
color: var(--text-primary);
}
.hw-table td:first-child { padding-left: 0; }
.hw-node-name {
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
}
.hw-caps {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.hw-cap-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 4px;
font-family: var(--font-mono);
}
.hw-cap-badge.gpu { background: oklch(20% 0.08 32 / 0.4); color: oklch(68% 0.18 32); border: 1px solid oklch(62% 0.22 32 / 0.3); }
.hw-cap-badge.bmd { background: oklch(20% 0.08 25 / 0.4); color: oklch(72% 0.18 40); border: 1px solid oklch(62% 0.22 25 / 0.3); }
.hw-cap-badge.none { background: var(--bg-surface); color: var(--text-tertiary); border: 1px solid var(--border); }
/* Divider between subsections */
.settings-divider {
border: none;
border-top: 1px solid var(--border);
margin: 0 -24px;
}
/* Inline save feedback */
.save-feedback {
font-size: 12px;
font-weight: 500;
color: oklch(68% 0.18 148);
opacity: 0;
transition: opacity 400ms ease;
min-width: 80px;
}
.save-feedback.visible { opacity: 1; }
/* Test result inline */
.test-result {
font-size: 12px;
padding: 6px 12px;
border-radius: 6px;
display: none;
}
.test-result.ok { display: block; background: oklch(68% 0.18 148 / 0.1); border: 1px solid oklch(68% 0.18 148 / 0.3); color: oklch(68% 0.18 148); }
.test-result.error { display: block; background: oklch(62% 0.22 25 / 0.1); border: 1px solid oklch(62% 0.22 25 / 0.3); color: oklch(72% 0.18 40); }
.form-row-actions {
display: flex;
align-items: center;
gap: 10px;
margin-top: 4px;
}
/* Secret key row with show/hide toggle */
.input-with-action {
display: flex;
gap: 8px;
}
.input-with-action input {
flex: 1;
}
</style>
</head>
<body>
<div class="wd-shell" style="display:flex;min-height:100vh;">
<nav class="wd-sidebar" aria-label="Main navigation">
<div class="wd-sidebar-header">
<img src="img/dragon-logo.png?v=1" alt="Dragonflight" style="width:18px;height:18px;">
<span class="wd-sidebar-brand">Dragonflight</span>
</div>
<div class="wd-sidebar-nav">
<a href="home.html" class="wd-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="wd-nav-item">
<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="wd-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="wd-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="wd-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="wd-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="wd-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="wd-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="wd-sidebar-section">Admin</div>
<a href="users.html" class="wd-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="wd-nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
Tokens
</a>
<a href="containers.html" class="wd-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="wd-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>
<a href="settings.html" class="wd-nav-item is-active">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M2.9 2.9l1.1 1.1M12 12l1.1 1.1M2.9 13.1L4 12M12 4l1.1-1.1"/></svg>
Settings
</a>
</div>
<div class="wd-sidebar-footer">
<div class="wd-sidebar-user">
<div class="wd-sidebar-user-avatar" id="userAvatar">?</div>
<div class="wd-sidebar-user-info">
<div class="wd-sidebar-user-name" id="userName"></div>
<div class="wd-sidebar-user-role" id="userRole"></div>
</div>
<button class="wd-btn wd-btn--ghost wd-btn--sm wd-btn--icon wd-sidebar-user-logout" id="logoutBtn" title="Sign out">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"><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 style="flex:1;min-width:0;overflow:auto;padding:20px 24px 32px;">
<header style="display:flex;align-items:center;height:48px;border-bottom:1px solid var(--border-faint);margin:0 -24px 20px;padding:0 24px;">
<span style="font:600 14px/1 var(--font);color:var(--text-primary);letter-spacing:-0.005em;">Settings</span>
</header>
<div class="settings-layout">
<!-- ── S3 / Object Storage ───────────────────────────────────── -->
<div class="settings-section">
<div class="settings-section-header">
<div class="settings-section-icon s3">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16">
<ellipse cx="8" cy="4" rx="6" ry="2.5"/>
<path d="M2 4v4c0 1.4 2.7 2.5 6 2.5S14 9.4 14 8V4"/>
<path d="M2 8v4c0 1.4 2.7 2.5 6 2.5S14 13.4 14 12V8"/>
</svg>
</div>
<div>
<div class="settings-section-title">S3 / Object Storage</div>
<div class="settings-section-desc">S3-compatible bucket for media asset storage (Garage, MinIO, AWS S3, etc.)</div>
</div>
</div>
<div class="settings-section-body">
<div style="font-size:13px;color:var(--text-secondary);line-height:1.6;padding:12px 14px;background:oklch(11% 0.018 250 / 0.5);border:1px solid var(--border);border-radius:8px;">
<strong style="color:var(--text-primary);">Initial setup required:</strong>
Configure your S3-compatible object store below. Changes take effect immediately without restarting the API.
Use <strong>Test Connection</strong> to verify credentials before saving.
</div>
<div class="form-row">
<div class="form-group" style="flex:2;">
<label class="form-label" for="s3Endpoint">Endpoint URL</label>
<input type="url" id="s3Endpoint" placeholder="http://192.168.1.10:9000" autocomplete="off">
<div class="form-hint">Full URL including protocol and port. Leave blank to use AWS S3.</div>
</div>
<div class="form-group" style="flex:1;">
<label class="form-label" for="s3Region">Region</label>
<input type="text" id="s3Region" placeholder="us-east-1" autocomplete="off">
<div class="form-hint">Required for AWS; use any value for self-hosted.</div>
</div>
</div>
<div class="form-group">
<label class="form-label" for="s3Bucket">Bucket name</label>
<input type="text" id="s3Bucket" placeholder="mam" autocomplete="off">
</div>
<div class="form-group">
<label class="form-label" for="s3AccessKey">Access key ID</label>
<input type="text" id="s3AccessKey" placeholder="Access key" autocomplete="off" spellcheck="false">
</div>
<div class="form-group">
<label class="form-label" for="s3SecretKey">Secret access key</label>
<div class="input-with-action">
<input type="password" id="s3SecretKey" placeholder="Leave blank to keep current secret" autocomplete="off" spellcheck="false">
<button class="btn btn-ghost btn-sm" id="s3SecretToggle" onclick="toggleS3SecretVisibility()" style="flex-shrink:0;">Show</button>
</div>
<div class="form-hint" id="s3SecretHint" style="display:none;">
A secret key is currently saved. Enter a new value to replace it.
</div>
</div>
<div class="form-row-actions">
<button class="btn btn-primary btn-sm" onclick="saveS3()">Save &amp; Apply</button>
<button class="btn btn-ghost btn-sm" onclick="testS3()">Test connection</button>
<span class="save-feedback" id="s3Feedback">Saved</span>
</div>
<div class="test-result" id="s3TestResult"></div>
</div>
</div>
<!-- ── GPU / Transcoding ─────────────────────────────────────── -->
<div class="settings-section">
<div class="settings-section-header">
<div class="settings-section-icon gpu">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16">
<rect x="1" y="4" width="14" height="8" rx="1.5"/>
<path d="M4 4V2M8 4V2M12 4V2M4 12v2M8 12v2M12 12v2"/>
<path d="M4 8h2M7 8h2M10 8h2"/>
</svg>
</div>
<div>
<div class="settings-section-title">GPU / Transcoding</div>
<div class="settings-section-desc">NVIDIA NVENC acceleration for proxy generation and transcoding jobs</div>
</div>
</div>
<div class="settings-section-body">
<!-- Node hardware inventory -->
<div>
<div style="font-size:11px;font-weight:600;letter-spacing:0.12em;text-transform:uppercase;color:var(--text-tertiary);margin-bottom:10px;">
Node hardware inventory
<button class="btn btn-ghost btn-sm" onclick="loadHardware()" style="margin-left:8px;font-size:11px;padding:2px 8px;">Refresh</button>
</div>
<div id="hwTableWrap">
<div style="color:var(--text-tertiary);font-size:13px;">Loading…</div>
</div>
</div>
<hr class="settings-divider">
<!-- GPU transcoding toggle -->
<div class="form-group" style="margin:0;">
<label class="toggle">
<input type="checkbox" id="gpuEnabled" onchange="onGpuToggle()">
<div class="toggle-track"></div>
<span class="toggle-label">Enable GPU-accelerated transcoding</span>
</label>
<div class="form-hint">When enabled, proxy generation and transcode jobs use NVENC instead of CPU ffmpeg.</div>
</div>
<!-- GPU settings (shown when enabled) -->
<div id="gpuFields" style="display:none;">
<div class="form-row">
<div class="form-group">
<label class="form-label" for="gpuCodec">Encoder</label>
<select id="gpuCodec">
<option value="h264_nvenc">H.264 (h264_nvenc)</option>
<option value="hevc_nvenc">H.265 / HEVC (hevc_nvenc)</option>
<option value="av1_nvenc">AV1 (av1_nvenc) — Ada+ only</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="gpuPreset">Quality preset</label>
<select id="gpuPreset">
<option value="p1">p1 — fastest</option>
<option value="p2">p2 — fast</option>
<option value="p3">p3 — balanced</option>
<option value="p4" selected>p4 — medium (default)</option>
<option value="p5">p5 — slow</option>
<option value="p6">p6 — slower</option>
<option value="p7">p7 — slowest / highest quality</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="gpuBitrate">Proxy bitrate (Mbps)</label>
<input type="number" id="gpuBitrate" value="8" min="1" max="100" step="1">
</div>
</div>
<div class="form-group">
<label class="form-label" for="gpuNode">Transcoding node</label>
<select id="gpuNode">
<option value="">Auto (first online node with GPU)</option>
</select>
<div class="form-hint">Force all GPU jobs to a specific cluster node, or leave on Auto.</div>
</div>
</div>
<div class="form-row-actions">
<button class="btn btn-primary btn-sm" onclick="saveTranscoding()">Save GPU settings</button>
<span class="save-feedback" id="gpuFeedback">Saved</span>
</div>
</div>
</div>
<!-- ── SDI Capture Service ───────────────────────────────────── -->
<div class="settings-section">
<div class="settings-section-header">
<div class="settings-section-icon sdi">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16">
<rect x="1" y="4" width="10" height="8" rx="1"/>
<path d="M11 7l4-2v6l-4-2"/>
<circle cx="5.5" cy="8" r="1.5"/>
</svg>
</div>
<div>
<div class="settings-section-title">SDI Capture Service</div>
<div class="settings-section-desc">Route SDI capture to a remote node with a Blackmagic DeckLink card</div>
</div>
</div>
<div class="settings-section-body">
<div style="font-size:13px;color:var(--text-secondary);line-height:1.6;padding:12px 14px;background:oklch(11% 0.018 250 / 0.5);border:1px solid var(--border);border-radius:8px;">
<strong style="color:var(--text-primary);">Multi-node capture routing:</strong>
By default the Capture page talks to the local <code>capture</code> sidecar.
Set a remote URL here to forward all SDI capture API calls to a secondary MAM node
running <code>--profile capture</code> (e.g. a machine with a DeckLink card).
Leave blank to use the local capture service.
</div>
<div class="form-group">
<label class="form-label" for="captureUrl">Remote capture service URL</label>
<input type="url" id="captureUrl" placeholder="http://10.0.0.26:7437" autocomplete="off">
<div class="form-hint">Base URL of the capture service on the remote node. Leave blank for local sidecar.</div>
</div>
<div class="form-row-actions">
<button class="btn btn-primary btn-sm" onclick="saveCaptureService()">Save</button>
<button class="btn btn-ghost btn-sm" onclick="testCaptureService()">Test connection</button>
<span class="save-feedback" id="captureFeedback">Saved</span>
</div>
<div class="test-result" id="captureTestResult"></div>
</div>
</div>
<!-- ── AMPP Integration ──────────────────────────────────────── -->
<div class="settings-section">
<div class="settings-section-header">
<div class="settings-section-icon ampp">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16">
<path d="M8 1l2 5h5l-4 3 1.5 5L8 11l-4.5 3L5 9 1 6h5z"/>
</svg>
</div>
<div>
<div class="settings-section-title">AMPP Integration</div>
<div class="settings-section-desc">Grass Valley AMPP platform connectivity for asset sync</div>
</div>
</div>
<div class="settings-section-body">
<div class="form-group">
<label class="form-label" for="amppUrl">AMPP base URL</label>
<input type="url" id="amppUrl" placeholder="https://ampp.example.com" autocomplete="off">
</div>
<div class="form-group">
<label class="form-label" for="amppToken">API token</label>
<input type="password" id="amppToken" placeholder="Leave blank to keep current token" autocomplete="off">
<div class="form-hint" id="amppTokenHint" style="display:none;">
A token is currently saved. Enter a new value to replace it.
</div>
</div>
<div class="form-row-actions">
<button class="btn btn-primary btn-sm" onclick="saveAmpp()">Save</button>
<button class="btn btn-ghost btn-sm" onclick="testAmpp()">Test connection</button>
<span class="save-feedback" id="amppFeedback">Saved</span>
</div>
<div class="test-result" id="amppTestResult"></div>
</div>
</div>
</div><!-- /settings-layout -->
</main>
</div><!-- /wd-shell -->
<div class="toast-container" id="toastContainer" aria-live="polite"></div>
<script src="js/api.js?v=6"></script>
<script src="js/topbar-strip.js?v=1"></script>
<script>
const API = '/api/v1';
async function api(path, opts = {}) {
const r = await fetch(API + path, Object.assign({ credentials: 'include', headers: { 'Content-Type': 'application/json' } }, opts));
if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.error || `HTTP ${r.status}`); }
return r.json();
}
function esc(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── S3 / Object Storage ────────────────────────────────────────────────────
async function loadS3() {
try {
const d = await api('/settings/s3');
document.getElementById('s3Endpoint').value = d.s3_endpoint || '';
document.getElementById('s3Bucket').value = d.s3_bucket || '';
document.getElementById('s3AccessKey').value = d.s3_access_key || '';
document.getElementById('s3Region').value = d.s3_region || '';
if (d.s3_secret_key_exists) {
document.getElementById('s3SecretHint').style.display = '';
}
} catch (_) {}
}
async function saveS3() {
try {
const body = {
s3_endpoint: document.getElementById('s3Endpoint').value.trim(),
s3_bucket: document.getElementById('s3Bucket').value.trim(),
s3_access_key: document.getElementById('s3AccessKey').value.trim(),
s3_region: document.getElementById('s3Region').value.trim(),
};
const secret = document.getElementById('s3SecretKey').value.trim();
if (secret) body.s3_secret_key = secret;
await api('/settings/s3', { method: 'PUT', body: JSON.stringify(body) });
document.getElementById('s3SecretKey').value = '';
document.getElementById('s3SecretHint').style.display = '';
flashFeedback('s3Feedback');
hideTestResult('s3TestResult');
} catch (e) { toast('Save failed: ' + e.message, 'error'); }
}
async function testS3() {
const el = document.getElementById('s3TestResult');
try {
const body = {
s3_endpoint: document.getElementById('s3Endpoint').value.trim(),
s3_bucket: document.getElementById('s3Bucket').value.trim(),
s3_access_key: document.getElementById('s3AccessKey').value.trim(),
s3_region: document.getElementById('s3Region').value.trim(),
};
const secret = document.getElementById('s3SecretKey').value.trim();
if (secret) body.s3_secret_key = secret;
const d = await api('/settings/s3/test', { method: 'POST', body: JSON.stringify(body) });
showTestResult(el, true, d.message || 'Connection successful');
} catch (e) { showTestResult(el, false, e.message); }
}
function toggleS3SecretVisibility() {
const inp = document.getElementById('s3SecretKey');
const btn = document.getElementById('s3SecretToggle');
if (inp.type === 'password') {
inp.type = 'text';
btn.textContent = 'Hide';
} else {
inp.type = 'password';
btn.textContent = 'Show';
}
}
// ── Hardware inventory ─────────────────────────────────────────────────────
async function loadHardware() {
const wrap = document.getElementById('hwTableWrap');
try {
const { nodes } = await api('/settings/hardware');
if (!nodes.length) {
wrap.innerHTML = '<div style="color:var(--text-tertiary);font-size:13px;">No cluster nodes registered yet.</div>';
return;
}
wrap.innerHTML = `<table class="hw-table">
<thead><tr><th>Node</th><th>Role</th><th>Hardware</th><th>Status</th></tr></thead>
<tbody>${nodes.map(n => {
const gpus = (n.capabilities.gpus || []);
const bmds = (n.capabilities.blackmagic || []);
const badges = [
...gpus.map((g, i) => `<span class="hw-cap-badge gpu">GPU ${i}</span>`),
...bmds.map((b, i) => `<span class="hw-cap-badge bmd">DeckLink ${i}</span>`),
].join('') || '<span class="hw-cap-badge none">none</span>';
return `<tr>
<td><div class="hw-node-name">
<span class="status-dot ${n.online ? 'status-dot--recording' : 'status-dot--idle'}"></span>
${esc(n.hostname)}
</div><div style="font-size:11px;color:var(--text-tertiary);font-family:var(--font-mono);">${esc(n.ip_address || '')}</div></td>
<td><span class="badge badge-idle" style="font-size:11px">${esc(n.role)}</span></td>
<td><div class="hw-caps">${badges}</div></td>
<td><span class="badge ${n.online ? 'badge-ready' : 'badge-error'}" style="font-size:11px">${n.online ? 'Online' : 'Offline'}</span></td>
</tr>`;
}).join('')}</tbody>
</table>`;
// Populate GPU node selector
const sel = document.getElementById('gpuNode');
const cur = sel.value;
sel.innerHTML = '<option value="">Auto (first online node with GPU)</option>';
nodes.filter(n => n.online && (n.capabilities.gpus || []).length > 0).forEach(n => {
const gpuCount = n.capabilities.gpus.length;
sel.innerHTML += `<option value="${esc(n.id)}">${esc(n.hostname)} (${gpuCount} GPU${gpuCount > 1 ? 's' : ''})</option>`;
});
if (cur) sel.value = cur;
} catch (e) {
wrap.innerHTML = `<div style="color:var(--status-red);font-size:13px;">Failed to load hardware: ${esc(e.message)}</div>`;
}
}
// ── GPU / Transcoding ──────────────────────────────────────────────────────
async function loadTranscoding() {
try {
const d = await api('/settings/transcoding');
document.getElementById('gpuEnabled').checked = d.gpu_transcode_enabled === 'true';
document.getElementById('gpuCodec').value = d.gpu_codec || 'h264_nvenc';
document.getElementById('gpuPreset').value = d.gpu_preset || 'p4';
document.getElementById('gpuBitrate').value = d.gpu_bitrate_mbps || '8';
document.getElementById('gpuNode').value = d.gpu_node || '';
onGpuToggle();
} catch (_) {}
}
function onGpuToggle() {
document.getElementById('gpuFields').style.display =
document.getElementById('gpuEnabled').checked ? 'block' : 'none';
}
async function saveTranscoding() {
try {
await api('/settings/transcoding', {
method: 'PUT',
body: JSON.stringify({
gpu_transcode_enabled: String(document.getElementById('gpuEnabled').checked),
gpu_codec: document.getElementById('gpuCodec').value,
gpu_preset: document.getElementById('gpuPreset').value,
gpu_bitrate_mbps: document.getElementById('gpuBitrate').value,
gpu_node: document.getElementById('gpuNode').value,
}),
});
flashFeedback('gpuFeedback');
} catch (e) { toast('Save failed: ' + e.message, 'error'); }
}
// ── Capture service ────────────────────────────────────────────────────────
async function loadCaptureService() {
try {
const d = await api('/settings/capture-service');
document.getElementById('captureUrl').value = d.capture_service_url || '';
} catch (_) {}
}
async function saveCaptureService() {
try {
await api('/settings/capture-service', {
method: 'PUT',
body: JSON.stringify({ capture_service_url: document.getElementById('captureUrl').value.trim() }),
});
flashFeedback('captureFeedback');
hideTestResult('captureTestResult');
} catch (e) { toast('Save failed: ' + e.message, 'error'); }
}
async function testCaptureService() {
const url = document.getElementById('captureUrl').value.trim();
const el = document.getElementById('captureTestResult');
if (!url) { showTestResult(el, false, 'Enter a URL first'); return; }
try {
const r = await fetch(url + '/health', { signal: AbortSignal.timeout(5000) });
const body = await r.json().catch(() => ({}));
if (r.ok) showTestResult(el, true, `Connected — ${body.hostname || url} (${body.role || 'unknown'} node)`);
else showTestResult(el, false, `HTTP ${r.status}`);
} catch (e) { showTestResult(el, false, e.message); }
}
// ── AMPP ───────────────────────────────────────────────────────────────────
async function loadAmpp() {
try {
const d = await api('/settings/ampp');
document.getElementById('amppUrl').value = d.ampp_base_url || '';
if (d.ampp_token_exists) {
document.getElementById('amppTokenHint').style.display = '';
}
} catch (_) {}
}
async function saveAmpp() {
try {
const body = { ampp_base_url: document.getElementById('amppUrl').value.trim() };
const token = document.getElementById('amppToken').value.trim();
if (token) body.ampp_token = token;
await api('/settings/ampp', { method: 'PUT', body: JSON.stringify(body) });
document.getElementById('amppToken').value = '';
document.getElementById('amppTokenHint').style.display = '';
flashFeedback('amppFeedback');
hideTestResult('amppTestResult');
} catch (e) { toast('Save failed: ' + e.message, 'error'); }
}
async function testAmpp() {
const el = document.getElementById('amppTestResult');
try {
const d = await api('/settings/ampp/test', { method: 'POST' });
showTestResult(el, true, d.message || 'Connection successful');
} catch (e) { showTestResult(el, false, e.message); }
}
// ── Utilities ──────────────────────────────────────────────────────────────
function flashFeedback(id) {
const el = document.getElementById(id);
el.classList.add('visible');
setTimeout(() => el.classList.remove('visible'), 2500);
}
function showTestResult(el, ok, msg) {
el.className = `test-result ${ok ? 'ok' : 'error'}`;
el.textContent = msg;
}
function hideTestResult(id) {
const el = document.getElementById(id);
el.className = 'test-result';
}
function toast(msg, type = 'info') {
const el = document.createElement('div');
el.className = `toast toast--${type}`;
el.innerHTML = `<div class="toast-body"><div class="toast-title">${esc(msg)}</div></div>`;
document.getElementById('toastContainer').appendChild(el);
setTimeout(() => el.remove(), 4000);
}
// ── Init ───────────────────────────────────────────────────────────────────
Promise.all([loadS3(), loadHardware(), loadTranscoding(), loadCaptureService(), loadAmpp()]);
</script>
<script src="js/auth-guard.js"></script>
</body>
</html>

View file

@ -1,758 +0,0 @@
<!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>Token Pricing — Dragonflight</title>
<link rel="stylesheet" href="/dist/app.css">
<style>
body { margin: 0; }
/* GV-flavored teal-on-dark just to lean into the parody */
.tok-main {
flex: 1; overflow: auto;
background:
radial-gradient(ellipse 70% 50% at 50% 0%, oklch(38% 0.10 200 / 0.45), transparent 60%),
radial-gradient(ellipse 80% 60% at 20% 100%, oklch(35% 0.14 195 / 0.35), transparent 65%),
linear-gradient(135deg, oklch(20% 0.06 220), oklch(12% 0.025 230) 100%);
}
.tok-wrap { max-width: 1200px; margin: 0 auto; padding: 48px 32px 80px; }
.tok-banner {
text-align: center;
margin-bottom: 40px;
}
.tok-banner-eyebrow {
display: inline-flex; align-items: center; gap: 8px;
padding: 5px 14px;
background: oklch(15% 0.04 200);
border: 1px solid oklch(50% 0.12 200 / 0.5);
border-radius: 999px;
font-size: 10px; font-weight: 700; letter-spacing: 0.18em;
text-transform: uppercase; color: oklch(75% 0.12 200);
margin-bottom: 16px;
}
.tok-banner-eyebrow::before {
content: ''; width: 6px; height: 6px;
background: oklch(70% 0.18 200); border-radius: 50%;
box-shadow: 0 0 10px oklch(70% 0.18 200);
}
.tok-title {
font-size: 42px; font-weight: 700; letter-spacing: -0.02em;
line-height: 1.05; color: var(--text-primary);
margin-bottom: 10px;
}
.tok-title .strike { text-decoration: line-through; opacity: 0.4; }
.tok-title .pop { color: oklch(75% 0.14 200); }
.tok-sub {
max-width: 56ch; margin: 0 auto;
font-size: 14px; color: oklch(70% 0.05 215); line-height: 1.55;
}
.tok-sub b { color: oklch(80% 0.10 200); }
/* ─ Tier cards ─ */
.tok-tiers {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin: 32px 0 40px;
}
.tok-tier {
position: relative;
background: oklch(13% 0.025 220 / 0.7);
border: 1px solid oklch(35% 0.06 215 / 0.5);
border-radius: 12px;
padding: 22px 22px 18px;
backdrop-filter: blur(6px);
}
.tok-tier.featured {
border-color: oklch(60% 0.15 200 / 0.7);
box-shadow: 0 16px 50px -16px oklch(50% 0.18 200 / 0.5);
}
.tok-tier-flag {
position: absolute; top: -10px; right: 18px;
background: oklch(58% 0.16 200);
color: oklch(10% 0.02 220);
font-size: 10px; font-weight: 700; letter-spacing: 0.14em;
text-transform: uppercase;
padding: 4px 10px; border-radius: 999px;
}
.tok-tier-name {
font-size: 11px; font-weight: 700; letter-spacing: 0.18em;
text-transform: uppercase; color: oklch(70% 0.10 200);
margin-bottom: 8px;
}
.tok-tier-price {
font-size: 28px; font-weight: 700;
color: var(--text-primary); letter-spacing: -0.01em;
display: flex; align-items: baseline; gap: 4px;
}
.tok-tier-price small {
font-size: 13px; font-weight: 500;
color: var(--text-tertiary);
}
.tok-tier-tokens {
margin-top: 4px;
font-size: 12px; color: var(--text-secondary);
font-family: var(--font-mono); letter-spacing: 0.04em;
}
.tok-tier-list {
margin: 14px 0 16px; padding: 0; list-style: none;
font-size: 12px; line-height: 1.7;
color: var(--text-secondary);
}
.tok-tier-list li { padding-left: 16px; position: relative; }
.tok-tier-list li::before {
content: '+'; position: absolute; left: 0; top: 0;
color: oklch(70% 0.14 200); font-weight: 700;
}
.tok-tier-list li.minus::before { content: ''; color: oklch(62% 0.22 25 / 0.7); }
.tok-tier-cta {
display: block; text-align: center;
width: 100%; padding: 9px 12px;
background: transparent;
border: 1px solid oklch(50% 0.12 200 / 0.55);
border-radius: 8px;
color: oklch(75% 0.12 200);
font: inherit; font-size: 12px; font-weight: 600;
letter-spacing: 0.06em; text-transform: uppercase;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
}
.tok-tier-cta:hover { background: oklch(20% 0.08 200 / 0.4); }
.tok-tier.featured .tok-tier-cta {
background: oklch(55% 0.16 200);
border-color: oklch(55% 0.16 200);
color: oklch(10% 0.02 220);
}
.tok-tier.featured .tok-tier-cta:hover {
background: oklch(62% 0.18 200);
}
/* ─ Per-service table ─ */
.tok-section-head {
display: flex; align-items: baseline; justify-content: space-between;
margin: 40px 0 14px;
}
.tok-section-title {
font-size: 11px; font-weight: 700; letter-spacing: 0.20em;
text-transform: uppercase; color: oklch(70% 0.10 200);
}
.tok-section-hint {
font-size: 11px; color: var(--text-tertiary);
font-family: var(--font-mono);
}
.tok-table {
width: 100%;
background: oklch(11% 0.020 220 / 0.7);
border: 1px solid oklch(35% 0.06 215 / 0.4);
border-radius: 12px;
overflow: hidden;
backdrop-filter: blur(6px);
}
.tok-row {
display: grid;
grid-template-columns: 48px 1.4fr 1fr 0.9fr 0.9fr;
gap: 14px;
padding: 12px 18px;
border-top: 1px solid oklch(35% 0.06 215 / 0.25);
align-items: center;
font-size: 13px;
}
.tok-row:first-child {
border-top: 0;
font-size: 10px; font-weight: 700; letter-spacing: 0.16em;
text-transform: uppercase; color: oklch(65% 0.08 200);
padding: 14px 18px;
}
.tok-row-icon {
width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
background: oklch(15% 0.05 215);
border: 1px solid oklch(45% 0.12 200 / 0.4);
border-radius: 8px;
color: oklch(72% 0.12 200);
}
.tok-row-icon svg { width: 16px; height: 16px; }
.tok-row-name { font-weight: 500; color: var(--text-primary); }
.tok-row-name small {
display: block;
font-size: 11px; font-weight: 400;
color: var(--text-tertiary); margin-top: 2px;
}
.tok-row-meter, .tok-row-rate, .tok-row-mult {
font-family: var(--font-mono); font-size: 12px;
color: var(--text-secondary); letter-spacing: 0.04em;
}
.tok-row-rate b { color: oklch(78% 0.14 200); font-weight: 600; }
.tok-row-mult.hot { color: oklch(70% 0.18 25); }
.tok-row-mult.cold { color: oklch(70% 0.14 145); }
/* ─ Calculator ─ */
.tok-calc {
margin-top: 36px;
background: oklch(13% 0.025 220 / 0.7);
border: 1px solid oklch(35% 0.06 215 / 0.5);
border-radius: 12px;
padding: 22px 24px;
}
.tok-calc-head { margin-bottom: 14px; }
.tok-calc-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }
.tok-calc-sub { font-size: 12px; color: var(--text-tertiary); margin-top: 4px; }
.tok-calc-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin: 16px 0 18px;
}
.tok-calc-field {
display: flex; flex-direction: column; gap: 4px;
}
.tok-calc-label {
font-size: 10px; font-weight: 600;
letter-spacing: 0.14em; text-transform: uppercase;
color: var(--text-tertiary);
}
.tok-calc-field input {
padding: 8px 12px;
background: oklch(8% 0.015 220);
border: 1px solid oklch(35% 0.06 215 / 0.5);
border-radius: 6px;
color: var(--text-primary); font: inherit;
font-family: var(--font-mono); font-size: 14px;
}
.tok-calc-out {
padding: 16px 18px;
background: oklch(8% 0.015 220);
border: 1px solid oklch(50% 0.12 200 / 0.4);
border-radius: 8px;
display: flex; flex-wrap: wrap; gap: 24px; align-items: baseline;
}
.tok-calc-out-total {
display: flex; flex-direction: column;
}
.tok-calc-out-label {
font-size: 10px; font-weight: 600;
letter-spacing: 0.18em; text-transform: uppercase;
color: var(--text-tertiary);
}
.tok-calc-out-value {
font-size: 28px; font-weight: 700;
color: oklch(82% 0.12 200);
font-family: var(--font-mono); letter-spacing: -0.02em;
}
.tok-calc-out-aside {
font-size: 11px; color: var(--text-tertiary);
max-width: 36ch; line-height: 1.5;
}
/* ─ Footnote micro-print ─ */
.tok-footer {
margin-top: 36px;
padding: 18px 22px;
background: oklch(8% 0.015 220 / 0.6);
border: 1px solid oklch(30% 0.04 215 / 0.4);
border-radius: 10px;
font-size: 10px; line-height: 1.6;
color: oklch(55% 0.04 215);
letter-spacing: 0.01em;
}
.tok-footer b { color: oklch(70% 0.06 215); font-weight: 600; }
.tok-footer p { margin: 0 0 8px; }
.tok-footer p:last-child { margin: 0; }
@media (max-width: 700px) {
.tok-row { grid-template-columns: 36px 1fr 1fr; gap: 10px; padding: 10px 12px; font-size: 12px; }
.tok-row > :nth-child(4), .tok-row > :nth-child(5) { display: none; }
.tok-title { font-size: 30px; }
}
/* ─ Token burn chart ─ */
.tok-chart {
background: oklch(11% 0.020 220 / 0.7);
border: 1px solid oklch(35% 0.06 215 / 0.5);
border-radius: 12px;
padding: 20px 22px 22px;
backdrop-filter: blur(6px);
}
.tok-chart-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 18px;
}
.tok-stat {
display: flex; flex-direction: column; gap: 2px;
padding: 10px 12px;
background: oklch(8% 0.015 220 / 0.7);
border: 1px solid oklch(35% 0.06 215 / 0.3);
border-radius: 8px;
}
.tok-stat-label { font-size: 10px; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; color: var(--text-tertiary); }
.tok-stat-value { font-family: var(--font-mono); font-size: 20px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.01em; }
.tok-stat-delta { font-size: 10px; font-weight: 600; color: var(--text-tertiary); letter-spacing: 0.04em; }
.tok-stat-delta.hot { color: oklch(70% 0.18 25); }
.tok-stat-delta.cold { color: oklch(70% 0.14 145); }
.tok-chart-frame {
position: relative;
background: oklch(8% 0.015 220 / 0.7);
border: 1px solid oklch(35% 0.06 215 / 0.3);
border-radius: 8px;
padding: 16px;
}
.tok-chart-svg { width: 100%; height: 260px; display: block; }
.tok-chart-legend {
display: flex; flex-wrap: wrap; gap: 18px;
margin-top: 10px;
font-size: 11px; color: var(--text-secondary);
letter-spacing: 0.04em;
}
.tok-chart-legend span { display: inline-flex; align-items: center; gap: 6px; }
.tok-chart-legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; }
</style>
</head>
<body>
<div class="wd-shell" style="display:flex;min-height:100vh;">
<nav class="wd-sidebar" aria-label="Main navigation">
<div class="wd-sidebar-header">
<img src="img/dragon-logo.png?v=1" alt="Dragonflight" style="width:18px;height:18px;">
<span class="wd-sidebar-brand">Dragonflight</span>
</div>
<div class="wd-sidebar-nav">
<a href="home.html" class="wd-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="wd-nav-item">
<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="wd-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="wd-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="wd-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="wd-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="wd-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="wd-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="wd-sidebar-section">Admin</div>
<a href="users.html" class="wd-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="wd-nav-item is-active">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
Tokens
</a>
<a href="containers.html" class="wd-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="wd-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>
<a href="settings.html" class="wd-nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M2.9 2.9l1.1 1.1M12 12l1.1 1.1M2.9 13.1L4 12M12 4l1.1-1.1"/></svg>
Settings
</a>
</div>
<div class="wd-sidebar-footer">
<div class="wd-sidebar-user">
<div class="wd-sidebar-user-avatar" id="userAvatar">?</div>
<div class="wd-sidebar-user-info">
<div class="wd-sidebar-user-name" id="userName"></div>
<div class="wd-sidebar-user-role" id="userRole"></div>
</div>
<button class="wd-btn wd-btn--ghost wd-btn--sm wd-btn--icon wd-sidebar-user-logout" id="logoutBtn" title="Sign out">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"><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 class="tok-main" style="flex:1;min-width:0;overflow:auto;display:flex;flex-direction:column;">
<header style="display:flex;align-items:center;justify-content:space-between;height:48px;border-bottom:1px solid var(--border-faint);padding:0 24px;flex-shrink:0;">
<div style="display:flex;align-items:center;gap:10px;">
<span style="font:600 14px/1 var(--font);color:var(--text-primary);letter-spacing:-0.005em;">Token Pricing</span>
<span style="color:var(--text-tertiary);">/</span>
<span style="font:400 12px/1 var(--font);color:var(--text-tertiary);">Enterprise Compute Compliance Engine v4.7</span>
</div>
<button class="wd-btn wd-btn--ghost wd-btn--sm" onclick="alert('Your account executive will be in touch.\n\nEstimated response time: 6-12 business days.')">Talk to sales</button>
</header>
<div class="tok-wrap">
<!-- Banner -->
<div class="tok-banner">
<span class="tok-banner-eyebrow">Dragonflight Pricing</span>
<h1 class="tok-title"><span class="strike">Per-seat</span> · <span class="strike">Per-stream</span> · <span class="strike">Per-month</span><br><span class="pop">Per token.</span></h1>
<p class="tok-sub">Welcome to the future of broadcast media operations. Tokens are <b>fungible compute credits</b> that flexibly meter every action across the Platform™. Move faster. Pay precisely. Forecast nothing.</p>
</div>
<!-- Tiers -->
<div class="tok-tiers">
<div class="tok-tier">
<div class="tok-tier-name">Starter</div>
<div class="tok-tier-price">$499<small>&nbsp;/&nbsp;mo</small></div>
<div class="tok-tier-tokens">100,000 tokens · $4.99 / 1k</div>
<ul class="tok-tier-list">
<li>1 concurrent recorder</li>
<li>SD ingest (480p · 1.2× multiplier)</li>
<li>Standard support · email · 96h SLA</li>
<li class="minus">No HD codec access</li>
<li class="minus">No ProRes write</li>
</ul>
<button class="tok-tier-cta" onclick="addToCart('Starter')">Get started</button>
</div>
<div class="tok-tier featured">
<span class="tok-tier-flag">Most flexible</span>
<div class="tok-tier-name">Professional</div>
<div class="tok-tier-price">$2,499<small>&nbsp;/&nbsp;mo</small></div>
<div class="tok-tier-tokens">600,000 tokens · $4.17 / 1k</div>
<ul class="tok-tier-list">
<li>4 concurrent recorders</li>
<li>HD ingest (1080p · 3.2× multiplier)</li>
<li>ProRes HQ write (2.4× multiplier)</li>
<li>Priority queue · 24h SLA</li>
<li class="minus">4K surcharge applies</li>
</ul>
<button class="tok-tier-cta" onclick="addToCart('Professional')">Provision tier</button>
</div>
<div class="tok-tier">
<div class="tok-tier-name">Broadcast</div>
<div class="tok-tier-price">$8,999<small>&nbsp;/&nbsp;mo</small></div>
<div class="tok-tier-tokens">2,400,000 tokens · $3.75 / 1k</div>
<ul class="tok-tier-list">
<li>12 concurrent recorders</li>
<li>4K ingest (5.8× multiplier)</li>
<li>ProRes 4444 write (4.0× multiplier)</li>
<li>Named CSM · phone · 4h SLA</li>
<li>Token rollover (90 days, fees apply)</li>
</ul>
<button class="tok-tier-cta" onclick="addToCart('Broadcast')">Engage account team</button>
</div>
<div class="tok-tier">
<div class="tok-tier-name">Enterprise</div>
<div class="tok-tier-price">Contact us</div>
<div class="tok-tier-tokens">Custom token allocation</div>
<ul class="tok-tier-list">
<li>Unlimited* concurrent recorders</li>
<li>8K / IMF / DCP write tiers</li>
<li>Dedicated solutions architect</li>
<li>Quarterly token true-up audits</li>
<li class="minus">Implementation fee not included</li>
</ul>
<button class="tok-tier-cta" onclick="addToCart('Enterprise')">Request quotation</button>
</div>
</div>
<!-- Per-service table -->
<div class="tok-section-head">
<span class="tok-section-title">Per-Service Metering</span>
<span class="tok-section-hint">All rates exclusive of TVM · effective Q3 FY26</span>
</div>
<div class="tok-table">
<div class="tok-row">
<span></span>
<span>Service</span>
<span>Meter</span>
<span>Base rate</span>
<span>Multiplier</span>
</div>
<div class="tok-row">
<div class="tok-row-icon"><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></div>
<div class="tok-row-name">Library<small>Asset browse, search, thumbnail render</small></div>
<div class="tok-row-meter">Per asset · per hour</div>
<div class="tok-row-rate"><b>0.012</b> tokens</div>
<div class="tok-row-mult">1.00×</div>
</div>
<div class="tok-row">
<div class="tok-row-icon"><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></div>
<div class="tok-row-name">Ingest<small>Upload + transcode to managed proxy</small></div>
<div class="tok-row-meter">Per GB · per pass</div>
<div class="tok-row-rate"><b>14.4</b> tokens / GB</div>
<div class="tok-row-mult hot">2.4× during business hours</div>
</div>
<div class="tok-row">
<div class="tok-row-icon"><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></div>
<div class="tok-row-name">Recorder · SRT<small>Caller-mode network ingest, includes HLS preview*</small></div>
<div class="tok-row-meter">Per minute · per recorder</div>
<div class="tok-row-rate"><b>4.8</b> tokens / min</div>
<div class="tok-row-mult hot">+22% Reliability Adjustment</div>
</div>
<div class="tok-row">
<div class="tok-row-icon"><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></div>
<div class="tok-row-name">Recorder · RTMP<small>Generic ingest tier · legacy codec compatibility</small></div>
<div class="tok-row-meter">Per minute · per recorder</div>
<div class="tok-row-rate"><b>3.6</b> tokens / min</div>
<div class="tok-row-mult">1.00×</div>
</div>
<div class="tok-row">
<div class="tok-row-icon"><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></div>
<div class="tok-row-name">Capture · SDI<small>DeckLink baseband ingest · 12G-SDI add-on available</small></div>
<div class="tok-row-meter">Per minute · per SDI channel</div>
<div class="tok-row-rate"><b>9.2</b> tokens / min</div>
<div class="tok-row-mult hot">1.8× premium baseband multiplier</div>
</div>
<div class="tok-row">
<div class="tok-row-icon"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="13" cy="3" r="1"/></svg></div>
<div class="tok-row-name">Live HLS Preview<small>Real-time delivery acceleration (RTDA™)</small></div>
<div class="tok-row-meter">Per active viewer · per second</div>
<div class="tok-row-rate"><b>0.0008</b> tokens</div>
<div class="tok-row-mult hot">3.2× CDN egress premium</div>
</div>
<div class="tok-row">
<div class="tok-row-icon"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6"/><path d="M5 8h6M8 5v6"/></svg></div>
<div class="tok-row-name">ProRes HQ Write<small>Mastering-grade codec licensing</small></div>
<div class="tok-row-meter">Per minute of media</div>
<div class="tok-row-rate"><b>6.4</b> tokens / min</div>
<div class="tok-row-mult hot">2.4× codec entitlement fee</div>
</div>
<div class="tok-row">
<div class="tok-row-icon"><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></div>
<div class="tok-row-name">Editor render<small>Server-side concat / trim · brand-aligned codec ladder</small></div>
<div class="tok-row-meter">Per minute of output</div>
<div class="tok-row-rate"><b>11.8</b> tokens / min</div>
<div class="tok-row-mult hot">+18% Real-Time Render Surcharge</div>
</div>
<div class="tok-row">
<div class="tok-row-icon"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg></div>
<div class="tok-row-name">Background Jobs<small>Proxy gen, thumbnails, folder sync</small></div>
<div class="tok-row-meter">Per job · per CPU-second</div>
<div class="tok-row-rate"><b>0.45</b> tokens</div>
<div class="tok-row-mult cold">0.85× off-peak discount</div>
</div>
<div class="tok-row">
<div class="tok-row-icon"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 3h12v10H2z"/><path d="M5 6h6M5 9h4"/></svg></div>
<div class="tok-row-name">Premiere Pro Connector<small>CEP bridge · per-NLE seat compatibility license</small></div>
<div class="tok-row-meter">Per workstation · per month</div>
<div class="tok-row-rate"><b>22,000</b> tokens</div>
<div class="tok-row-mult hot">+ $99 NLE Compatibility Levy</div>
</div>
<div class="tok-row">
<div class="tok-row-icon"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6"/><path d="M8 4v4l3 2"/></svg></div>
<div class="tok-row-name">API call<small>GET /api/v1/* · includes 200-byte response budget</small></div>
<div class="tok-row-meter">Per request</div>
<div class="tok-row-rate"><b>0.0011</b> tokens</div>
<div class="tok-row-mult">1.00× (overage 3.4×)</div>
</div>
</div>
<!-- Usage chart -->
<div class="tok-section-head">
<span class="tok-section-title">Current Token Burn</span>
<span class="tok-section-hint" id="chartHint">Last 14 days · nightly true-up · TVM applied</span>
</div>
<div class="tok-chart">
<div class="tok-chart-stats">
<div class="tok-stat"><span class="tok-stat-label">MTD burn</span><span class="tok-stat-value" id="statMtd"></span><span class="tok-stat-delta hot" id="statMtdDelta">+0%</span></div>
<div class="tok-stat"><span class="tok-stat-label">Forecast EOM</span><span class="tok-stat-value" id="statEom"></span><span class="tok-stat-delta hot" id="statEomDelta">over plan</span></div>
<div class="tok-stat"><span class="tok-stat-label">TVM (live)</span><span class="tok-stat-value" id="statTvm"></span><span class="tok-stat-delta" id="statTvmTrend">stable</span></div>
<div class="tok-stat"><span class="tok-stat-label">Peak draw</span><span class="tok-stat-value" id="statPeak"></span><span class="tok-stat-delta cold" id="statPeakDay">Wed</span></div>
</div>
<div class="tok-chart-frame">
<svg class="tok-chart-svg" id="burnChart" viewBox="0 0 800 220" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg"></svg>
<div class="tok-chart-legend">
<span><i style="background:oklch(70% 0.18 200)"></i>Ingest</span>
<span><i style="background:oklch(62% 0.15 145)"></i>Recorders</span>
<span><i style="background:oklch(70% 0.18 80)"></i>Render</span>
<span><i style="background:oklch(62% 0.22 25)"></i>Overage</span>
</div>
</div>
</div>
<!-- Calculator -->
<div class="tok-calc">
<div class="tok-calc-head">
<div class="tok-calc-title">Monthly token estimator</div>
<div class="tok-calc-sub">Honest forecasts since 2019. Actual usage may vary by up to 340%.</div>
</div>
<div class="tok-calc-grid">
<div class="tok-calc-field"><label class="tok-calc-label">Ingest GB/mo</label><input id="iIngest" type="number" value="800" min="0"></div>
<div class="tok-calc-field"><label class="tok-calc-label">SRT recorder hours</label><input id="iSrt" type="number" value="120" min="0"></div>
<div class="tok-calc-field"><label class="tok-calc-label">SDI capture hours</label><input id="iSdi" type="number" value="40" min="0"></div>
<div class="tok-calc-field"><label class="tok-calc-label">Premiere seats</label><input id="iSeats" type="number" value="3" min="0"></div>
<div class="tok-calc-field"><label class="tok-calc-label">Editor render min/mo</label><input id="iRender" type="number" value="240" min="0"></div>
</div>
<div class="tok-calc-out">
<div class="tok-calc-out-total">
<span class="tok-calc-out-label">Estimated monthly tokens</span>
<span class="tok-calc-out-value" id="calcTokens"></span>
</div>
<div class="tok-calc-out-total">
<span class="tok-calc-out-label">At Professional tier</span>
<span class="tok-calc-out-value" id="calcDollars" style="color:oklch(82% 0.10 25)"></span>
</div>
<div class="tok-calc-out-aside" id="calcNote">Includes 2.4× business-hours Ingest multiplier. Excludes overage, peak-hour surcharge, codec entitlement, and the Token Velocity Modifier (TVM™), which fluctuates between 0.8× and 4.2× without notice.</div>
</div>
</div>
<!-- Footnote micro-print -->
<div class="tok-footer">
<p><b>Disclosures.</b> All rates quoted in Dragonflight-Tokens®, a non-transferable digital unit of account valid only within the Platform™. One (1) token is equivalent to 1.0 token at time of redemption, subject to the Token Velocity Modifier (TVM™), which is recalculated nightly and applied retroactively where contractually permitted. Tokens expire after 30 days unless rolled over with the Token Continuity Add-On (TCA, sold separately).</p>
<p><b>Multipliers.</b> "Reliability Adjustment", "Real-Time Render Surcharge", and "Premium Baseband Multiplier" are not surcharges; they are <i>positive entitlements</i> that grant continued access to services for which you have already paid. Refusal to pay constitutes voluntary entitlement waiver.</p>
<p><b>Token Compatibility Levy.</b> A 14% sustainability levy is automatically applied to all token consumption in support of the Platform's commitment to operational excellence. The levy is non-refundable, non-itemized, and not represented above.</p>
<p><b>Forward-looking statements.</b> Anything resembling a price on this page is illustrative and is not, has not been, and will never be, a price. Pricing is determined exclusively by your assigned Customer Success Outcome Architect during quarterly value-realization workshops.</p>
<p style="margin-top:14px;font-style:italic;opacity:0.7"><b>Dragonflight</b> is the broadcast asset management platform you actually own. No token has ever been minted, charged, or considered. This page exists for purely educational purposes. Any resemblance to a real metered-compute pricing model is entirely intentional and deeply affectionate.</p>
</div>
</div>
</main>
</div><!-- /wd-shell -->
<script src="js/api.js?v=6"></script>
<script src="js/topbar-strip.js?v=1"></script>
<script>
function addToCart(tier) {
const lines = [
'You have selected the ' + tier + ' tier.',
'',
'A Customer Success Outcome Architect will reach out',
'within 612 business days to schedule your initial',
'discovery + value-alignment workshop.',
'',
'Until then, please continue using Dragonflight for free.',
'Because it is free. Because we built it ourselves.',
];
alert(lines.join('\n'));
}
// Calculator
const tvm = 1.42; // current "Token Velocity Modifier"
function calc() {
const g = parseFloat(document.getElementById('iIngest').value || '0');
const srt = parseFloat(document.getElementById('iSrt').value || '0');
const sdi = parseFloat(document.getElementById('iSdi').value || '0');
const seats = parseFloat(document.getElementById('iSeats').value || '0');
const ren = parseFloat(document.getElementById('iRender').value || '0');
// Made-up math
const tokens = Math.round(
g * 14.4 * 2.4 +
srt * 60 * 4.8 * 1.22 +
sdi * 60 * 9.2 * 1.8 +
seats * 22000 +
ren * 11.8 * 1.18
);
const withLevy = Math.round(tokens * 1.14 * tvm);
document.getElementById('calcTokens').textContent = withLevy.toLocaleString();
const dollars = withLevy / 1000 * 4.17;
document.getElementById('calcDollars').textContent = '$' + Math.round(dollars).toLocaleString();
}
document.querySelectorAll('.tok-calc-field input').forEach(i => i.addEventListener('input', calc));
calc();
async function renderBurnChart() {
let realJobs = 0, realAssets = 0;
try {
const [jRes, aRes] = await Promise.all([
fetch('/api/v1/jobs', { credentials: 'include' }),
fetch('/api/v1/assets?limit=1', { credentials: 'include' }),
]);
if (jRes.ok) realJobs = (await jRes.json()).length;
if (aRes.ok) realAssets = (await aRes.json()).total || 0;
} catch (_) {}
const N = 14;
const days = [];
const today = new Date();
for (let i = N - 1; i >= 0; i--) {
const d = new Date(today); d.setDate(d.getDate() - i);
days.push(d);
}
function rng(seed) { let x = Math.sin(seed) * 10000; return x - Math.floor(x); }
const series = days.map((d, i) => {
const baseline = 8000 + realAssets * 18 + realJobs * 12;
const wk = (d.getDay() === 0 || d.getDay() === 6) ? 0.55 : 1.0;
const wave = 1 + 0.45 * Math.sin(i * 0.9) + 0.18 * Math.cos(i * 1.7);
const noise = 0.8 + 0.4 * rng(i * 13 + 7);
const total = Math.round(baseline * wk * wave * noise);
const ingest = Math.round(total * (0.35 + 0.05 * rng(i * 3 + 1)));
const recorders = Math.round(total * (0.30 + 0.04 * rng(i * 5 + 2)));
const render = Math.round(total * (0.20 + 0.04 * rng(i * 7 + 3)));
const overage = Math.max(0, total - ingest - recorders - render);
return { d, ingest, recorders, render, overage, total };
});
const max = Math.max(...series.map(s => s.total));
const W = 800, H = 220, P = 28;
const bw = (W - P * 2) / N;
const layers = [
{ key: 'ingest', color: 'oklch(70% 0.18 200)' },
{ key: 'recorders', color: 'oklch(62% 0.15 145)' },
{ key: 'render', color: 'oklch(70% 0.18 80)' },
{ key: 'overage', color: 'oklch(62% 0.22 25)' },
];
const bars = series.map((s, i) => {
const x = P + i * bw + 4;
let yAcc = H - P;
const stack = layers.map(l => {
const v = s[l.key] || 0;
const h = (v / max) * (H - P * 2);
yAcc -= h;
return '<rect x="' + x + '" y="' + yAcc + '" width="' + (bw - 8) + '" height="' + h + '" fill="' + l.color + '" opacity="0.92" rx="1"/>';
}).join('');
const label = s.d.toLocaleDateString('en', { month: 'short', day: 'numeric' });
const lbl = i % 2 === 0 ? '<text x="' + (x + (bw-8)/2) + '" y="' + (H - 8) + '" text-anchor="middle" font-size="10" fill="oklch(55% 0.04 215)" font-family="var(--font-mono)">' + label + '</text>' : '';
return stack + lbl;
}).join('');
let grid = '';
for (let k = 0; k <= 4; k++) {
const y = P + (H - P * 2) * (k / 4);
grid += '<line x1="' + P + '" y1="' + y + '" x2="' + (W - P + 12) + '" y2="' + y + '" stroke="oklch(35% 0.06 215 / 0.25)" stroke-width="0.5"/>';
const tick = Math.round(max * (1 - k / 4));
grid += '<text x="' + (W - P + 16) + '" y="' + (y + 3) + '" font-size="9" fill="oklch(50% 0.04 215)" font-family="var(--font-mono)">' + tick.toLocaleString() + '</text>';
}
document.getElementById('burnChart').innerHTML = grid + bars;
const mtd = series.reduce((a, s) => a + s.total, 0);
const eom = Math.round(mtd * 2.3);
const peakIdx = series.reduce((max, s, i, arr) => s.total > arr[max].total ? i : max, 0);
const tvm = (0.92 + 0.6 * rng(Date.now() / 60000 | 0)).toFixed(2);
document.getElementById('statMtd').textContent = mtd.toLocaleString();
document.getElementById('statMtdDelta').textContent = '+' + Math.round(rng(1) * 40 + 15) + '% vs prior period';
document.getElementById('statEom').textContent = eom.toLocaleString();
document.getElementById('statEomDelta').textContent = '+340% over plan';
document.getElementById('statTvm').textContent = tvm + 'x';
document.getElementById('statTvmTrend').textContent = parseFloat(tvm) > 1.2 ? 'spiking' : 'stable';
document.getElementById('statPeak').textContent = series[peakIdx].total.toLocaleString();
document.getElementById('statPeakDay').textContent = series[peakIdx].d.toLocaleDateString('en', { weekday: 'short' });
}
renderBurnChart();
</script>
<script src="js/auth-guard.js"></script>
</body>
</html>

View file

@ -1,501 +0,0 @@
<!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>Ingest — Dragonflight</title>
<link rel="stylesheet" href="/dist/app.css">
<style>
.ingest-content {
display: flex;
flex-direction: column;
gap: var(--sp-5);
max-width: 860px;
}
/* Context bar */
.context-bar {
display: flex;
align-items: flex-end;
gap: var(--sp-4);
}
.context-bar .wd-form-group { flex: 1; }
/* Drop zone */
.drop-zone {
border: 1px dashed var(--border);
border-radius: var(--r-md);
background: var(--bg-deep);
display: flex;
align-items: center;
justify-content: center;
padding: var(--sp-5) var(--sp-6);
gap: var(--sp-3);
text-align: left;
cursor: pointer;
transition: border-color var(--t-fast), background var(--t-fast);
min-height: 88px;
position: relative;
}
.drop-zone:hover,
.drop-zone.drag-over {
border-color: var(--accent);
background: var(--accent-subtle);
}
.drop-zone input[type="file"] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
width: 100%;
height: 100%;
}
.drop-zone-icon { color: var(--text-tertiary); }
.drop-zone-icon svg { width: 22px; height: 22px; }
.drop-zone-primary {
font-size: var(--text-md);
font-weight: 500;
color: var(--text-primary);
}
.drop-zone-secondary {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.drop-zone.drag-over .drop-zone-icon { color: var(--accent); }
.drop-zone.drag-over .drop-zone-primary { color: var(--accent); }
/* Upload queue */
.queue-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.queue-title {
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-secondary);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.queue-list {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.queue-item {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: var(--sp-3) var(--sp-4);
display: flex;
gap: var(--sp-4);
align-items: center;
}
.queue-item-icon { color: var(--text-tertiary); flex-shrink: 0; }
.queue-item-icon svg { width: 18px; height: 18px; }
.queue-item-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); }
.queue-item-name {
font-size: var(--text-sm);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.queue-item-meta {
display: flex;
align-items: center;
gap: var(--sp-3);
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.queue-item-progress { width: 100%; }
.queue-item-status { flex-shrink: 0; }
.queue-item.status-done { border-color: oklch(68% 0.18 148 / 0.25); }
.queue-item.status-error { border-color: oklch(62% 0.22 25 / 0.25); }
.queue-item.status-active { border-color: var(--accent-border); }
/* Overall progress */
.overall-progress {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: var(--sp-4);
display: flex;
align-items: center;
gap: var(--sp-4);
}
.overall-progress-label { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; }
.overall-progress-bar { flex: 1; }
.overall-progress-pct { font-size: var(--text-sm); font-variant-numeric: tabular-nums; color: var(--accent); font-weight: 500; white-space: nowrap; }
</style>
</head>
<body>
<div class="wd-shell" style="display:flex;min-height:100vh;">
<nav class="wd-sidebar" aria-label="Main navigation">
<div class="wd-sidebar-header">
<img src="img/dragon-logo.png?v=1" alt="Dragonflight" style="width:18px;height:18px;">
<span class="wd-sidebar-brand">Dragonflight</span>
</div>
<div class="wd-sidebar-nav">
<a href="home.html" class="wd-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="wd-nav-item">
<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="wd-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="wd-nav-item is-active">
<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="wd-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="wd-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="wd-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="wd-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="wd-sidebar-section">Admin</div>
<a href="users.html" class="wd-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="wd-nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
Tokens
</a>
<a href="containers.html" class="wd-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="wd-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>
<a href="settings.html" class="wd-nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M2.9 2.9l1.1 1.1M12 12l1.1 1.1M2.9 13.1L4 12M12 4l1.1-1.1"/></svg>
Settings
</a>
</div>
<div class="wd-sidebar-footer">
<div class="wd-sidebar-user">
<div class="wd-sidebar-user-avatar" id="userAvatar">?</div>
<div class="wd-sidebar-user-info">
<div class="wd-sidebar-user-name" id="userName"></div>
<div class="wd-sidebar-user-role" id="userRole"></div>
</div>
<button class="wd-btn wd-btn--ghost wd-btn--sm wd-btn--icon wd-sidebar-user-logout" id="logoutBtn" title="Sign out">
<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>
<div style="flex:1;display:flex;flex-direction:column;">
<header class="wd-topbar">
<div class="wd-topbar-left">
<span class="page-title">Ingest</span>
</div>
<div class="wd-topbar-right">
<button class="wd-btn wd-btn--ghost wd-btn--sm" id="clearDoneBtn" style="display:none;">Clear completed</button>
</div>
</header>
<div style="flex:1;overflow:auto;padding:24px;">
<div class="ingest-content">
<!-- Project + bin selectors -->
<div class="context-bar">
<div class="wd-form-group">
<label class="wd-label" for="projectSel">Project</label>
<select class="wd-select" id="projectSel">
<option value="">Select project…</option>
</select>
</div>
<div class="wd-form-group">
<label class="wd-label" for="binSel">Bin</label>
<select class="wd-select" id="binSel">
<option value="">No bin (project root)</option>
</select>
</div>
</div>
<!-- Drop zone -->
<div class="drop-zone" id="dropZone">
<input type="file" id="fileInput" multiple accept="video/*,audio/*,image/*" aria-label="Select files to upload">
<div class="drop-zone-icon">
<svg viewBox="0 0 36 36" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M18 26V10M11 17l7-7 7 7"/>
<path d="M4 28h28"/>
<rect x="2" y="4" width="32" height="26" rx="2" stroke-opacity="0.25"/>
</svg>
</div>
<div class="drop-zone-primary">Drop files here or click to browse</div>
<div class="drop-zone-secondary">Video, audio, and image files — up to 5 GB each</div>
</div>
<!-- Overall progress (hidden until uploading) -->
<div class="overall-progress" id="overallProgress" style="display:none;">
<span class="overall-progress-label" id="overallLabel">Uploading…</span>
<div class="overall-progress-bar progress-bar">
<div class="progress-fill" id="overallFill" style="width:0%"></div>
</div>
<span class="overall-progress-pct" id="overallPct">0%</span>
</div>
<!-- Queue -->
<div id="queueSection" style="display:none;">
<div class="queue-header" style="margin-bottom:var(--sp-3);">
<span class="queue-title">Queue</span>
<span class="text-xs text-tertiary" id="queueSummary"></span>
</div>
<div class="queue-list" id="queueList"></div>
</div>
</div>
</div>
</div>
</div>
<div class="wd-toast-container" id="toastContainer" aria-live="polite"></div>
<script src="js/api.js?v=6"></script>
<script src="js/topbar-strip.js?v=1"></script>
<script>
const CHUNK = 10 * 1024 * 1024; // 10 MB chunks
const state = { projects: [], bins: [], queue: [], uploading: false };
document.addEventListener('DOMContentLoaded', async () => {
await loadProjects();
const params = new URLSearchParams(location.search);
if (params.get('project')) {
document.getElementById('projectSel').value = params.get('project');
await loadBins();
}
setupDropZone();
document.getElementById('clearDoneBtn').onclick = clearDone;
});
async function loadProjects() {
const r = await getProjects();
if (!r.success) return;
state.projects = r.data;
const sel = document.getElementById('projectSel');
sel.innerHTML = '<option value="">Select project…</option>' +
r.data.map(p => `<option value="${p.id}">${esc(p.name)}</option>`).join('');
sel.onchange = loadBins;
}
async function loadBins() {
const projectId = document.getElementById('projectSel').value;
const sel = document.getElementById('binSel');
sel.innerHTML = '<option value="">No bin (project root)</option>';
if (!projectId) return;
const r = await getBins(projectId);
if (r.success && r.data.length) {
r.data.forEach(b => sel.innerHTML += `<option value="${b.id}">${esc(b.name)}</option>`);
}
}
function setupDropZone() {
const zone = document.getElementById('dropZone');
const input = document.getElementById('fileInput');
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag-over'); });
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('drag-over');
enqueue([...e.dataTransfer.files]);
});
input.addEventListener('change', () => { enqueue([...input.files]); input.value = ''; });
}
function enqueue(files) {
const projectId = document.getElementById('projectSel').value;
if (!projectId) { toast('Select a project first', '', 'warning'); return; }
files = files.filter(f => f.type.startsWith('video/') || f.type.startsWith('audio/') || f.type.startsWith('image/'));
if (!files.length) { toast('No supported files selected', '', 'warning'); return; }
files.forEach(file => {
state.queue.push({ id: Math.random().toString(36).slice(2), file, status: 'queued', progress: 0, error: null });
});
renderQueue();
if (!state.uploading) processQueue();
}
async function processQueue() {
state.uploading = true;
const pending = state.queue.filter(i => i.status === 'queued');
for (const item of pending) {
await uploadFile(item);
renderQueue();
}
state.uploading = false;
toast('All uploads complete', `${pending.length} file${pending.length > 1 ? 's' : ''} ingested`, 'success');
}
async function uploadFile(item) {
item.status = 'active';
renderQueue();
const projectId = document.getElementById('projectSel').value;
const binId = document.getElementById('binSel').value || null;
const file = item.file;
try {
if (file.size <= 50 * 1024 * 1024) {
// Simple upload
const fd = new FormData();
fd.append('file', file);
fd.append('filename', file.name);
fd.append('projectId', projectId);
if (binId) fd.append('binId', binId);
fd.append('contentType', file.type);
const r = await simpleUpload(fd);
if (!r.success) throw new Error(r.error || 'Upload failed');
item.progress = 100;
} else {
// Multipart
const init = await initUpload({ filename: file.name, fileSize: file.size, contentType: file.type, projectId, binId });
if (!init.success) throw new Error(init.error || 'Failed to init upload');
const { assetId, uploadId, key } = init.data;
const parts = [];
const totalChunks = Math.ceil(file.size / CHUNK);
for (let i = 0; i < totalChunks; i++) {
const chunk = file.slice(i * CHUNK, (i + 1) * CHUNK);
const fd = new FormData();
fd.append('file', chunk, file.name);
fd.append('uploadId', uploadId);
fd.append('key', key);
fd.append('partNumber', i + 1);
const pr = await uploadPart(fd);
if (!pr.success) throw new Error(pr.error || 'Part upload failed');
parts.push({ partNumber: i + 1, etag: pr.data.etag });
item.progress = Math.round(((i + 1) / totalChunks) * 95);
renderQueue();
}
const complete = await completeUpload({ uploadId, key, assetId, parts });
if (!complete.success) throw new Error(complete.error || 'Failed to finalize');
item.progress = 100;
}
item.status = 'done';
} catch (err) {
item.status = 'error';
item.error = err.message;
}
renderQueue();
}
function renderQueue() {
const list = document.getElementById('queueList');
const section = document.getElementById('queueSection');
const overallProgress = document.getElementById('overallProgress');
const clearBtn = document.getElementById('clearDoneBtn');
if (!state.queue.length) { section.style.display = 'none'; overallProgress.style.display = 'none'; return; }
section.style.display = 'block';
const done = state.queue.filter(i => i.status === 'done').length;
const total = state.queue.length;
const hasActive = state.queue.some(i => i.status === 'active' || i.status === 'queued');
document.getElementById('queueSummary').textContent = `${done} of ${total} complete`;
clearBtn.style.display = done > 0 ? '' : 'none';
// Overall bar
if (hasActive || done > 0) {
overallProgress.style.display = 'flex';
const totalPct = state.queue.reduce((s, i) => s + (i.status === 'done' ? 100 : i.progress), 0) / total;
document.getElementById('overallFill').style.width = totalPct + '%';
document.getElementById('overallPct').textContent = Math.round(totalPct) + '%';
document.getElementById('overallLabel').textContent = hasActive ? 'Uploading…' : 'Complete';
}
list.innerHTML = state.queue.map(item => {
const statusLabel = { queued:'Queued', active:'Uploading', done:'Done', error:'Error' }[item.status] || item.status;
const statusClass = { queued:'wd-badge wd-badge--idle', active:'wd-badge wd-badge--warn', done:'wd-badge wd-badge--good', error:'wd-badge wd-badge--bad' }[item.status];
const iconColor = { queued:'var(--text-tertiary)', active:'var(--accent)', done:'var(--status-green)', error:'var(--status-red)' }[item.status];
const icon = item.file.type.startsWith('video') ? videoIcon : item.file.type.startsWith('audio') ? audioIcon : docIcon;
return `<div class="queue-item status-${item.status}">
<div class="queue-item-icon" style="color:${iconColor}">${icon}</div>
<div class="queue-item-body">
<div class="queue-item-name">${esc(item.file.name)}</div>
<div class="queue-item-meta">
<span>${formatFileSize(item.file.size)}</span>
${item.error ? `<span style="color:var(--status-red)">${esc(item.error)}</span>` : ''}
</div>
${item.status === 'active' ? `<div class="queue-item-progress progress-bar" style="margin-top:var(--sp-1)"><div class="progress-fill" style="width:${item.progress}%"></div></div>` : ''}
</div>
<div class="queue-item-status">
<span class="${statusClass}">${statusLabel}${item.status === 'active' ? ' ' + item.progress + '%' : ''}</span>
</div>
</div>`;
}).join('');
}
const videoIcon = `<svg viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18"><rect x="1" y="4" width="11" height="10" rx="1"/><path d="M12 8l5-2v6l-5-2"/></svg>`;
const audioIcon = `<svg viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18"><path d="M9 2v14M6 5v8M3 7v4M12 5v8M15 7v4"/></svg>`;
const docIcon = `<svg viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18"><path d="M5 2h6l4 4v10H5V2z"/><path d="M11 2v4h4"/></svg>`;
function clearDone() {
state.queue = state.queue.filter(i => i.status !== 'done');
renderQueue();
}
function toast(title, msg, type = 'info') {
const el = document.createElement('div');
el.className = `wd-toast wd-toast--${type}`;
el.innerHTML = `<div class="toast-body"><div class="toast-title">${esc(title)}</div>${msg ? `<div class="toast-msg">${esc(msg)}</div>` : ''}</div>`;
document.getElementById('toastContainer').appendChild(el);
setTimeout(() => el.remove(), 4000);
}
function esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function formatFileSize(bytes) {
if (!bytes) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB';
if (bytes < 1024*1024*1024) return (bytes/1024/1024).toFixed(1) + ' MB';
return (bytes/1024/1024/1024).toFixed(2) + ' GB';
}
</script>
<script src="js/auth-guard.js"></script>
</body>
</html>

View file

@ -1,643 +0,0 @@
<!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>Users — Dragonflight</title>
<link rel="stylesheet" href="/dist/app.css">
<style>
body { margin: 0; }
/* Local-only tabs styling — JS toggles .tab.active and .tab-content.active,
so we keep those class names and just retheme. */
.users-tabs {
display: flex;
gap: 4px;
padding: 0 20px;
background: var(--bg-deep);
border-bottom: 1px solid var(--border-faint);
flex-shrink: 0;
}
.users-tabs .tab {
background: transparent;
border: none;
color: var(--text-tertiary);
font: 500 12px/1 var(--font);
letter-spacing: 0.04em;
padding: 10px 14px;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.users-tabs .tab:hover { color: var(--text-secondary); }
.users-tabs .tab.active {
color: var(--accent-bright);
border-bottom-color: var(--accent);
}
.tab-content { display: none; }
.tab-content.active { display: flex; flex-direction: column; flex: 1; overflow: hidden; }
.section-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.section-title {
font: 500 14px/1.2 var(--font);
color: var(--text-primary);
}
.wd-list-row.users-row {
display: grid;
grid-template-columns: minmax(140px, 1fr) minmax(140px, 1.2fr) minmax(80px, 0.6fr) minmax(80px, 0.6fr) minmax(80px, 0.6fr) auto;
gap: 14px;
align-items: center;
}
.wd-list-row.users-row.header {
font: 600 10px/1 var(--font);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.wd-list-row.groups-row {
display: grid;
grid-template-columns: minmax(140px, 1fr) minmax(180px, 1.5fr) minmax(80px, 0.6fr) minmax(80px, 0.6fr) auto;
gap: 14px;
align-items: center;
}
.wd-list-row.groups-row.header {
font: 600 10px/1 var(--font);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.empty-row {
padding: 48px 12px;
text-align: center;
color: var(--text-tertiary);
font-size: 13px;
}
.text-tertiary { color: var(--text-tertiary); }
.text-secondary { color: var(--text-secondary); }
.text-xs { font-size: 11px; }
.text-sm { font-size: 12px; }
.member-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.member-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px 2px 6px;
border-radius: 100px;
font-size: 11px;
background: var(--bg-surface);
border: 1px solid var(--border-faint);
color: var(--text-secondary);
}
.member-chip button {
background: none;
border: none;
padding: 0;
color: var(--text-tertiary);
cursor: pointer;
line-height: 1;
display: flex;
align-items: center;
}
.member-chip button:hover { color: var(--signal-bad); }
</style>
</head>
<body>
<div class="wd-shell" style="display:flex;min-height:100vh;">
<!-- Sidebar -->
<nav class="wd-sidebar" aria-label="Main navigation">
<div class="wd-sidebar-header">
<img src="img/dragon-logo.png?v=1" alt="Dragonflight" style="width:18px;height:18px;">
<span class="wd-sidebar-brand">Dragonflight</span>
</div>
<div class="wd-sidebar-nav">
<a href="home.html" class="wd-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="wd-nav-item">
<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="wd-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="wd-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="wd-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="wd-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="wd-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="wd-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="wd-sidebar-section">Admin</div>
<a href="users.html" class="wd-nav-item is-active">
<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="wd-nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
Tokens
</a>
<a href="containers.html" class="wd-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="wd-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>
<a href="settings.html" class="wd-nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M2.9 2.9l1.1 1.1M12 12l1.1 1.1M2.9 13.1L4 12M12 4l1.1-1.1"/></svg>
Settings
</a>
</div>
<div class="wd-sidebar-footer">
<div class="wd-sidebar-user">
<div class="wd-sidebar-user-avatar" id="userAvatar">?</div>
<div class="wd-sidebar-user-info">
<div class="wd-sidebar-user-name" id="userName"></div>
<div class="wd-sidebar-user-role" id="userRole"></div>
</div>
<button class="wd-btn wd-btn--ghost wd-btn--sm wd-btn--icon wd-sidebar-user-logout" id="logoutBtn" title="Sign out">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"><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 style="flex:1;display:flex;flex-direction:column;">
<header class="wd-topbar">
<div class="wd-topbar-left">
<nav class="wd-breadcrumb"><span class="wd-breadcrumb-crumb">Users &amp; Groups</span></nav>
</div>
<div class="wd-topbar-right">
<button class="wd-btn wd-btn--primary wd-btn--sm" onclick="openUserPanel()">New user</button>
</div>
</header>
<!-- Tabs -->
<div class="users-tabs">
<button class="tab active" onclick="switchTab('users',this)">Users</button>
<button class="tab" onclick="switchTab('groups',this)">Groups</button>
</div>
<!-- Users tab -->
<div class="tab-content active" id="tab-users">
<main style="flex:1;padding:20px 20px 32px;overflow:auto;">
<div class="section-toolbar">
<span class="section-title" id="userCount">Users</span>
<button class="wd-btn wd-btn--primary wd-btn--sm" onclick="openUserPanel()">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"><path d="M8 2v12M2 8h12"/></svg>
New user
</button>
</div>
<div class="wd-list" id="usersTable">
<div class="wd-list-row users-row header">
<span>Username</span>
<span>Display name</span>
<span>Role</span>
<span>Groups</span>
<span>Created</span>
<span style="text-align:right">Actions</span>
</div>
<div id="usersTbody">
<div class="empty-row">Loading&hellip;</div>
</div>
</div>
</main>
</div>
<!-- Groups tab -->
<div class="tab-content" id="tab-groups">
<main style="flex:1;padding:20px 20px 32px;overflow:auto;">
<div class="section-toolbar">
<span class="section-title" id="groupCount">Groups</span>
<button class="wd-btn wd-btn--primary wd-btn--sm" onclick="openGroupPanel()">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"><path d="M8 2v12M2 8h12"/></svg>
New group
</button>
</div>
<div class="wd-list" id="groupsTable">
<div class="wd-list-row groups-row header">
<span>Name</span>
<span>Description</span>
<span>Members</span>
<span>Created</span>
<span style="text-align:right">Actions</span>
</div>
<div id="groupsTbody">
<div class="empty-row">Loading&hellip;</div>
</div>
</div>
</main>
</div>
</div>
</div>
<!-- User slide panel -->
<div class="wd-slide-panel-overlay" id="userOverlay" onclick="closeUserPanel()"></div>
<div class="wd-slide-panel" id="userPanel">
<div class="wd-slide-panel-header">
<span class="wd-slide-panel-title" id="userPanelTitle">New user</span>
<button class="wd-btn wd-btn--ghost wd-btn--sm wd-btn--icon" onclick="closeUserPanel()" aria-label="Close">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"><path d="M3 3l10 10M13 3L3 13"/></svg>
</button>
</div>
<div class="wd-slide-panel-body">
<input type="hidden" id="editUserId">
<div class="wd-form-group">
<label class="wd-label" for="uUsername">Username</label>
<input class="wd-input" type="text" id="uUsername" placeholder="e.g. jsmith">
</div>
<div class="wd-form-group">
<label class="wd-label" for="uDisplayName">Display name</label>
<input class="wd-input" type="text" id="uDisplayName" placeholder="e.g. Jane Smith">
</div>
<div class="wd-form-group">
<label class="wd-label" for="uRole">Role</label>
<select class="wd-select" id="uRole">
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="wd-form-group">
<label class="wd-label" for="uPassword" id="uPasswordLabel">Password</label>
<input class="wd-input" type="password" id="uPassword" placeholder="Min 8 characters">
<div class="wd-hint" id="uPasswordHint"></div>
</div>
</div>
<div class="wd-slide-panel-footer">
<button class="wd-btn wd-btn--ghost wd-btn--md" onclick="closeUserPanel()">Cancel</button>
<button class="wd-btn wd-btn--primary wd-btn--md" id="saveUserBtn" onclick="saveUser()">Create user</button>
</div>
</div>
<!-- Group slide panel -->
<div class="wd-slide-panel-overlay" id="groupOverlay" onclick="closeGroupPanel()"></div>
<div class="wd-slide-panel" id="groupPanel">
<div class="wd-slide-panel-header">
<span class="wd-slide-panel-title" id="groupPanelTitle">New group</span>
<button class="wd-btn wd-btn--ghost wd-btn--sm wd-btn--icon" onclick="closeGroupPanel()" aria-label="Close">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"><path d="M3 3l10 10M13 3L3 13"/></svg>
</button>
</div>
<div class="wd-slide-panel-body">
<input type="hidden" id="editGroupId">
<div class="wd-form-group">
<label class="wd-label" for="gName">Group name</label>
<input class="wd-input" type="text" id="gName" placeholder="e.g. News Team">
</div>
<div class="wd-form-group">
<label class="wd-label" for="gDescription">Description</label>
<textarea class="wd-textarea" id="gDescription" rows="2" placeholder="Optional description"></textarea>
</div>
<div class="wd-form-group" id="gMembersSection" style="display:none;">
<label class="wd-label">Members</label>
<div class="member-chips" id="memberChips"></div>
<select class="wd-select" id="addMemberSelect" style="margin-top:8px;">
<option value="">Add member…</option>
</select>
</div>
</div>
<div class="wd-slide-panel-footer">
<button class="wd-btn wd-btn--ghost wd-btn--md" onclick="closeGroupPanel()">Cancel</button>
<button class="wd-btn wd-btn--primary wd-btn--md" id="saveGroupBtn" onclick="saveGroup()">Create group</button>
</div>
</div>
<div class="wd-toast-container" id="toastContainer" aria-live="polite"></div>
<script src="js/api.js"></script>
<script>
let allUsers = [], allGroups = [], currentGroupMembers = [];
document.addEventListener('DOMContentLoaded', () => {
loadUsers();
loadGroups();
});
// ── Tabs ──────────────────────────────────────────────────
function switchTab(name, btn) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-' + name).classList.add('active');
}
// ── Load users ────────────────────────────────────────────
async function loadUsers() {
const r = await getUsers();
if (!r.success) { toast('Failed to load users', r.error, 'error'); return; }
allUsers = r.data;
renderUsers();
}
function renderUsers() {
const tbody = document.getElementById('usersTbody');
document.getElementById('userCount').textContent = `${allUsers.length} user${allUsers.length !== 1 ? 's' : ''}`;
if (!allUsers.length) {
tbody.innerHTML = `<div class="empty-row">No users yet</div>`;
return;
}
const roleBadge = (role) => {
const mod = role === 'admin' ? 'wd-badge--good'
: role === 'editor' ? 'wd-badge--info'
: 'wd-badge--idle';
return `<span class="wd-badge ${mod}">${esc(role)}</span>`;
};
tbody.innerHTML = allUsers.map(u => `
<div class="wd-list-row users-row">
<div class="wd-list-cell wd-list-cell--name"><code style="font-size:11px;font-family:var(--font-mono);">${esc(u.username)}</code></div>
<div class="wd-list-cell">${esc(u.display_name || '—')}</div>
<div class="wd-list-cell">${roleBadge(u.role)}</div>
<div class="wd-list-cell"><span class="text-tertiary text-xs">${u.group_count || 0} group${u.group_count !== 1 ? 's' : ''}</span></div>
<div class="wd-list-cell text-xs text-tertiary">${u.created_at ? new Date(u.created_at).toLocaleDateString() : '—'}</div>
<div class="wd-list-cell wd-list-cell--actions" style="text-align:right">
<div style="display:flex;gap:4px;justify-content:flex-end;">
<button class="wd-btn wd-btn--ghost wd-btn--sm" onclick="editUser('${u.id}')">Edit</button>
<button class="wd-btn wd-btn--danger wd-btn--sm" onclick="confirmDeleteUser('${u.id}',${esc(JSON.stringify(u.username))})">Delete</button>
</div>
</div>
</div>`).join('');
}
// ── User panel ────────────────────────────────────────────
function openUserPanel(userId) {
document.getElementById('editUserId').value = '';
document.getElementById('uUsername').value = '';
document.getElementById('uDisplayName').value = '';
document.getElementById('uRole').value = 'editor';
document.getElementById('uPassword').value = '';
document.getElementById('uUsername').disabled = false;
document.getElementById('userPanelTitle').textContent = 'New user';
document.getElementById('saveUserBtn').textContent = 'Create user';
document.getElementById('uPasswordLabel').textContent = 'Password';
document.getElementById('uPasswordHint').textContent = '';
document.getElementById('userPanel').classList.add('open');
document.getElementById('userOverlay').classList.add('open');
}
function editUser(id) {
const u = allUsers.find(x => x.id === id);
if (!u) return;
document.getElementById('editUserId').value = u.id;
document.getElementById('uUsername').value = u.username;
document.getElementById('uUsername').disabled = true;
document.getElementById('uDisplayName').value = u.display_name || '';
document.getElementById('uRole').value = u.role;
document.getElementById('uPassword').value = '';
document.getElementById('userPanelTitle').textContent = 'Edit user';
document.getElementById('saveUserBtn').textContent = 'Save changes';
document.getElementById('uPasswordLabel').textContent = 'New password';
document.getElementById('uPasswordHint').textContent = 'Leave blank to keep existing password';
document.getElementById('userPanel').classList.add('open');
document.getElementById('userOverlay').classList.add('open');
}
function closeUserPanel() {
document.getElementById('userPanel').classList.remove('open');
document.getElementById('userOverlay').classList.remove('open');
}
async function saveUser() {
const id = document.getElementById('editUserId').value;
const username = document.getElementById('uUsername').value.trim();
const display_name = document.getElementById('uDisplayName').value.trim();
const role = document.getElementById('uRole').value;
const password = document.getElementById('uPassword').value;
if (!id && !username) { toast('Username required', '', 'warning'); return; }
if (!id && !password) { toast('Password required for new user', '', 'warning'); return; }
const btn = document.getElementById('saveUserBtn');
btn.disabled = true;
let r;
if (id) {
const payload = { display_name, role };
if (password) payload.password = password;
r = await updateUser(id, payload);
} else {
r = await createUser({ username, display_name, role, password });
}
btn.disabled = false;
if (r.success) {
toast(id ? 'User updated' : 'User created', '', 'success');
closeUserPanel();
loadUsers();
} else {
toast('Failed to save user', r.error, 'error');
}
}
async function confirmDeleteUser(id, name) {
if (!confirm(`Delete user "${name}"? This cannot be undone.`)) return;
const r = await deleteUser(id);
if (r.success) { toast('User deleted', '', 'success'); loadUsers(); }
else toast('Failed to delete user', r.error, 'error');
}
// ── Load groups ───────────────────────────────────────────
async function loadGroups() {
const r = await getGroups();
if (!r.success) { toast('Failed to load groups', r.error, 'error'); return; }
allGroups = r.data;
renderGroups();
}
function renderGroups() {
const tbody = document.getElementById('groupsTbody');
document.getElementById('groupCount').textContent = `${allGroups.length} group${allGroups.length !== 1 ? 's' : ''}`;
if (!allGroups.length) {
tbody.innerHTML = `<div class="empty-row">No groups yet</div>`;
return;
}
tbody.innerHTML = allGroups.map(g => `
<div class="wd-list-row groups-row">
<div class="wd-list-cell wd-list-cell--name" style="font-weight:500">${esc(g.name)}</div>
<div class="wd-list-cell text-secondary text-sm">${esc(g.description || '—')}</div>
<div class="wd-list-cell text-xs text-tertiary">${g.member_count || 0} member${g.member_count !== 1 ? 's' : ''}</div>
<div class="wd-list-cell text-xs text-tertiary">${g.created_at ? new Date(g.created_at).toLocaleDateString() : '—'}</div>
<div class="wd-list-cell wd-list-cell--actions" style="text-align:right">
<div style="display:flex;gap:4px;justify-content:flex-end;">
<button class="wd-btn wd-btn--ghost wd-btn--sm" onclick="editGroup('${g.id}')">Edit</button>
<button class="wd-btn wd-btn--danger wd-btn--sm" onclick="confirmDeleteGroup('${g.id}',${esc(JSON.stringify(g.name))})">Delete</button>
</div>
</div>
</div>`).join('');
}
// ── Group panel ───────────────────────────────────────────
function openGroupPanel() {
document.getElementById('editGroupId').value = '';
document.getElementById('gName').value = '';
document.getElementById('gDescription').value = '';
document.getElementById('gMembersSection').style.display = 'none';
document.getElementById('groupPanelTitle').textContent = 'New group';
document.getElementById('saveGroupBtn').textContent = 'Create group';
document.getElementById('groupPanel').classList.add('open');
document.getElementById('groupOverlay').classList.add('open');
}
async function editGroup(id) {
const g = allGroups.find(x => x.id === id);
if (!g) return;
document.getElementById('editGroupId').value = g.id;
document.getElementById('gName').value = g.name;
document.getElementById('gDescription').value = g.description || '';
document.getElementById('groupPanelTitle').textContent = 'Edit group';
document.getElementById('saveGroupBtn').textContent = 'Save changes';
document.getElementById('groupPanel').classList.add('open');
document.getElementById('groupOverlay').classList.add('open');
// Load members
document.getElementById('gMembersSection').style.display = 'block';
const mr = await getGroupMembers(id);
currentGroupMembers = mr.success ? mr.data : [];
renderMemberChips(id);
// Populate add-member dropdown
const sel = document.getElementById('addMemberSelect');
const memberIds = new Set(currentGroupMembers.map(m => m.id));
sel.innerHTML = '<option value="">Add member…</option>' +
allUsers.filter(u => !memberIds.has(u.id))
.map(u => `<option value="${u.id}">${esc(u.display_name || u.username)}</option>`)
.join('');
sel.onchange = async () => {
if (!sel.value) return;
await addGroupMember(id, sel.value);
const mr2 = await getGroupMembers(id);
currentGroupMembers = mr2.success ? mr2.data : [];
renderMemberChips(id);
// Update dropdown
const memberIds2 = new Set(currentGroupMembers.map(m => m.id));
sel.innerHTML = '<option value="">Add member…</option>' +
allUsers.filter(u => !memberIds2.has(u.id))
.map(u => `<option value="${u.id}">${esc(u.display_name || u.username)}</option>`)
.join('');
loadGroups();
};
}
function renderMemberChips(groupId) {
const container = document.getElementById('memberChips');
if (!currentGroupMembers.length) {
container.innerHTML = `<span class="text-xs text-tertiary">No members yet</span>`;
return;
}
container.innerHTML = currentGroupMembers.map(m => `
<span class="member-chip">
${esc(m.display_name || m.username)}
<button onclick="removeMember('${groupId}','${m.id}')" title="Remove">
<svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.5" width="10" height="10"><path d="M2 2l6 6M8 2L2 8"/></svg>
</button>
</span>`).join('');
}
async function removeMember(groupId, userId) {
await removeGroupMember(groupId, userId);
const mr = await getGroupMembers(groupId);
currentGroupMembers = mr.success ? mr.data : [];
renderMemberChips(groupId);
const memberIds = new Set(currentGroupMembers.map(m => m.id));
const sel = document.getElementById('addMemberSelect');
sel.innerHTML = '<option value="">Add member…</option>' +
allUsers.filter(u => !memberIds.has(u.id))
.map(u => `<option value="${u.id}">${esc(u.display_name || u.username)}</option>`)
.join('');
loadGroups();
}
function closeGroupPanel() {
document.getElementById('groupPanel').classList.remove('open');
document.getElementById('groupOverlay').classList.remove('open');
currentGroupMembers = [];
}
async function saveGroup() {
const id = document.getElementById('editGroupId').value;
const name = document.getElementById('gName').value.trim();
const description = document.getElementById('gDescription').value.trim();
if (!name) { toast('Group name required', '', 'warning'); return; }
const btn = document.getElementById('saveGroupBtn');
btn.disabled = true;
const r = id
? await updateGroup(id, { name, description })
: await createGroup({ name, description });
btn.disabled = false;
if (r.success) {
toast(id ? 'Group updated' : 'Group created', name, 'success');
closeGroupPanel();
loadGroups();
} else {
toast('Failed to save group', r.error, 'error');
}
}
async function confirmDeleteGroup(id, name) {
if (!confirm(`Delete group "${name}"?`)) return;
const r = await deleteGroup(id);
if (r.success) { toast('Group deleted', '', 'success'); loadGroups(); }
else toast('Failed to delete group', r.error, 'error');
}
// ── 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 = `wd-toast wd-toast--${type}`;
el.innerHTML = `<div class="wd-toast-icon">${icons[type]||icons.info}</div><div class="wd-toast-body"><div class="wd-toast-title">${esc(title)}</div>${msg?`<div class="wd-toast-msg">${esc(msg)}</div>`:''}</div>`;
document.getElementById('toastContainer').appendChild(el);
setTimeout(() => el.remove(), 4000);
}
function esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
<script src="js/auth-guard.js"></script>
</body>
</html>