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

Before this commit /public had two parallel UIs: the React SPA (index.html
+ screens-*.jsx) and a stack of pre-SPA standalone pages (home.html,
recorders.html, jobs.html, ...). The SPA replaces every standalone page,
nothing in the .jsx tree links to them, and the only outside references
were login.html redirecting to home.html and the nginx fallback pointing
at home.html.

Delete 16 standalone pages (~9.2k lines of dead markup, ~430KB on disk):
  _primitives-smoke.html  api-tokens.html  capture.html  cluster.html
  containers.html         edit.html        editor.html   home.html
  jobs.html               player.html      projects.html recorders.html
  settings.html           tokens.html      upload.html   users.html

Keep:
  index.html  — the React SPA shell
  login.html  — the sign-in / setup screen

Wire the redirects to the SPA:
- login.html post-signin: home.html -> /
- nginx try_files fallback: /home.html -> /index.html

After this, sign-in lands the operator on the real React app instead of
the stale 2025-era home page. The Editor screen continues to embed the
separate editor service via the /editor/ nginx proxy (unaffected).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-05-23 16:48:38 -04:00
parent 53049d1c4d
commit ff2865b5d8
18 changed files with 3 additions and 9234 deletions

View file

@ -95,9 +95,9 @@ server {
proxy_request_buffering off; 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 / { location / {
try_files $uri $uri/ /home.html; try_files $uri $uri/ /index.html;
expires -1; expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate"; 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{ try{
const res = await fetch(API + '/login', {method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin', 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})}); 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'); } else{ const d=await res.json().catch(()=>({})); showFlash(d.error||'Login failed','error'); }
} catch(err){ showFlash('Network error: '+err.message,'error'); } } catch(err){ showFlash('Network error: '+err.message,'error'); }
finally{ btn.disabled=false; btn.textContent='Sign in'; } 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>