chore(web-ui): delete legacy standalone HTML pages; SPA is the only entry #27
18 changed files with 3 additions and 9234 deletions
|
|
@ -95,9 +95,9 @@ server {
|
|||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
# SPA fallback - try to serve file, else route to index.html
|
||||
# SPA fallback - try to serve file, else route to the React shell.
|
||||
location / {
|
||||
try_files $uri $uri/ /home.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
expires -1;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 <token></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>
|
||||
· Created ${created}
|
||||
· Last used: ${lastUsed}
|
||||
· 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
</script>
|
||||
<script src="js/auth-guard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
</script>
|
||||
<script src="js/auth-guard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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">—</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…</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…</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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>
|
||||
|
|
@ -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">—</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…</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…</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) : '—'}</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadContainers();
|
||||
setInterval(loadContainers, 10000);
|
||||
document.getElementById('refreshBtn').onclick = loadContainers;
|
||||
});
|
||||
</script>
|
||||
<script src="js/auth-guard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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> 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> 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> 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> 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 ↗</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> 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> 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> 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> 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>
|
||||
|
|
@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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>
|
||||
|
|
@ -263,7 +263,7 @@
|
|||
try{
|
||||
const res = await fetch(API + '/login', {method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',
|
||||
body: JSON.stringify({username:$('username').value.trim(),password:$('password').value})});
|
||||
if(res.ok){ showFlash('Signed in, redirecting...','success'); setTimeout(()=>{location.href='home.html'},600); }
|
||||
if(res.ok){ showFlash('Signed in, redirecting...','success'); setTimeout(()=>{location.href='/'},600); }
|
||||
else{ const d=await res.json().catch(()=>({})); showFlash(d.error||'Login failed','error'); }
|
||||
} catch(err){ showFlash('Network error: '+err.message,'error'); }
|
||||
finally{ btn.disabled=false; btn.textContent='Sign in'; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
|
@ -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 & 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ── 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>
|
||||
|
|
@ -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> / 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> / 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> / 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 6–12 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>
|
||||
|
|
@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1024*1024*1024) return (bytes/1024/1024).toFixed(1) + ' MB';
|
||||
return (bytes/1024/1024/1024).toFixed(2) + ' GB';
|
||||
}
|
||||
</script>
|
||||
<script src="js/auth-guard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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 & 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…</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…</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
</script>
|
||||
<script src="js/auth-guard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in a new issue