DragonWind/public/index.html
Zac Gaetano 641701edf8 feat: Dragon Wind v1.0 — dual-mode broadcast uploader
- Full VPM Uploader feature set (auth, users, folders, AMPP monitor)
- HTTP upload via presigned S3 URLs with XHR progress tracking
- UDP upload mode with relay server (WebRTC DataChannel + HTTP fallback)
- S3 Admin settings with live Test Connection (upload+delete verify)
- UDP Relay Admin settings with health check
- Standalone UDP relay server (Node.js + Docker) with multipart S3 assembly
- Chrome Extension (Manifest v3): popup, background, content script
- Dynamic S3 client — reconfigures on save without restart
- Dark/light theme, full AMPP job monitor
- docker-compose.yml with dragon-wind + udp-relay services

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 20:05:34 -04:00

1186 lines
60 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Dragon Wind</title>
<link rel="icon" href="/logo.png" type="image/png"/>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"/>
<style>
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
:root{
--bg-primary:#06080e;--bg-secondary:#0c1019;--bg-card:#10141f;--bg-card-hover:#151a28;
--border:#1a2035;--border-bright:#2a3555;
--accent:#1a3fc7;--accent-bright:#2b5cff;--accent-glow:rgba(43,92,255,.15);--accent-glow-strong:rgba(43,92,255,.3);
--dragon:#e05c1a;--dragon-bright:#ff7d3b;--dragon-glow:rgba(224,92,26,.15);
--text-primary:#e4e8f1;--text-secondary:#7a85a0;--text-dim:#4a5470;
--success:#22c55e;--success-bg:rgba(34,197,94,.1);
--error:#ef4444;--error-bg:rgba(239,68,68,.1);
--warning:#f59e0b;--warning-bg:rgba(245,158,11,.1);
}
[data-theme="light"]{
--bg-primary:#f4f6f9;--bg-secondary:#fff;--bg-card:#fff;--bg-card-hover:#f0f2f6;
--border:#d8dce6;--border-bright:#b8c0d0;
--text-primary:#1a1e2e;--text-secondary:#4a5068;--text-dim:#8890a4;
--accent-glow:rgba(43,92,255,.1);--dragon-glow:rgba(224,92,26,.1);
--success:#16a34a;--success-bg:rgba(22,163,74,.08);
--error:#dc2626;--error-bg:rgba(220,38,38,.08);
}
html{font-size:15px}
body{font-family:'Outfit',sans-serif;background:var(--bg-primary);color:var(--text-primary);min-height:100vh;overflow-x:hidden}
body::before{content:'';position:fixed;inset:0;background:radial-gradient(ellipse 80% 60% at 20% 10%,rgba(26,63,199,.06) 0%,transparent 60%),radial-gradient(ellipse 60% 50% at 80% 90%,rgba(224,92,26,.04) 0%,transparent 60%);pointer-events:none;z-index:0}
/* LOGIN */
.login-screen{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;background:var(--bg-primary)}
.login-screen.hidden{display:none}
.login-box{width:380px;background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:2.5rem 2rem;text-align:center;position:relative;z-index:1}
.login-logo{width:200px;height:100px;object-fit:contain;margin-bottom:1rem}
.login-title{font-size:1.8rem;font-weight:800;background:linear-gradient(135deg,var(--dragon),var(--dragon-bright));-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:.25rem}
.login-sub{font-size:.75rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.1em;margin-bottom:2rem}
.login-field{width:100%;padding:.75rem 1rem;font-family:'Outfit',sans-serif;font-size:.9rem;background:var(--bg-secondary);border:1px solid var(--border);border-radius:10px;color:var(--text-primary);outline:none;transition:all .2s;margin-bottom:.75rem}
.login-field:focus{border-color:var(--dragon);box-shadow:0 0 0 3px var(--dragon-glow)}
.login-field::placeholder{color:var(--text-dim)}
.login-btn{width:100%;padding:.8rem;margin-top:.5rem;font-family:'Outfit',sans-serif;font-size:.9rem;font-weight:600;color:#fff;background:linear-gradient(135deg,var(--dragon),var(--dragon-bright));border:none;border-radius:10px;cursor:pointer;transition:all .2s}
.login-btn:hover{transform:translateY(-1px);box-shadow:0 6px 24px rgba(224,92,26,.35)}
.login-btn:disabled{opacity:.4;cursor:not-allowed;transform:none;box-shadow:none}
.login-error{margin-top:.75rem;font-size:.8rem;color:var(--error);min-height:1.2em}
/* APP */
.app{position:relative;z-index:1}
.app.hidden{display:none}
.header{display:flex;align-items:center;justify-content:space-between;padding:1rem 2.5rem;border-bottom:1px solid var(--border);background:rgba(6,8,14,.85);backdrop-filter:blur(20px);position:sticky;top:0;z-index:100}
.header-left{display:flex;align-items:center;gap:1rem;min-width:0;flex:1}
.logo-mark{height:44px;object-fit:contain}
.brand-name{font-size:1.3rem;font-weight:800;background:linear-gradient(135deg,var(--dragon),var(--dragon-bright));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.brand-sub{font-size:.6rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.1em}
.header-right{display:flex;align-items:center;gap:1rem;flex-shrink:0}
.header-user{font-size:.78rem;color:var(--text-secondary);font-family:'JetBrains Mono',monospace}
.btn-logout{padding:.4rem .9rem;font-family:'Outfit',sans-serif;font-size:.72rem;font-weight:600;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;color:var(--text-secondary);cursor:pointer;transition:all .15s}
.btn-logout:hover{border-color:var(--error);color:var(--error)}
.theme-toggle{width:34px;height:34px;border-radius:8px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-secondary);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:1rem;transition:all .2s}
.theme-toggle:hover{border-color:var(--dragon);color:var(--dragon-bright)}
/* NAV TABS */
.nav-tabs{display:flex;gap:0;padding:0 2.5rem;border-bottom:1px solid var(--border);background:var(--bg-secondary)}
.nav-tab{padding:.65rem 1.25rem;font-size:.8rem;font-weight:600;color:var(--text-dim);cursor:pointer;border-bottom:2px solid transparent;transition:all .15s;white-space:nowrap}
.nav-tab:hover{color:var(--text-secondary)}
.nav-tab.active{color:var(--dragon-bright);border-bottom-color:var(--dragon-bright)}
.nav-tab.admin-tab{color:var(--warning)}
.nav-tab.admin-tab.active{color:var(--warning);border-bottom-color:var(--warning)}
/* PAGE PANELS */
.page{display:none;max-width:760px;margin:0 auto;padding:2rem 2.5rem}
.page.active{display:block}
/* SECTION */
.section-title{font-size:.7rem;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--text-dim);margin-bottom:1rem;display:flex;align-items:center;gap:.6rem}
.section-title::before{content:'';width:3px;height:14px;background:var(--dragon-bright);border-radius:2px}
/* UPLOAD MODE SELECTOR */
.mode-bar{display:flex;gap:.5rem;margin-bottom:1.5rem}
.mode-btn{flex:1;padding:.65rem;border:1px solid var(--border);border-radius:10px;background:var(--bg-card);color:var(--text-secondary);cursor:pointer;font-family:'Outfit',sans-serif;font-size:.85rem;font-weight:600;transition:all .2s;display:flex;align-items:center;justify-content:center;gap:.5rem}
.mode-btn:hover{border-color:var(--border-bright);background:var(--bg-card-hover)}
.mode-btn.active.http{border-color:var(--accent-bright);background:var(--accent-glow);color:var(--accent-bright)}
.mode-btn.active.udp{border-color:var(--dragon-bright);background:var(--dragon-glow);color:var(--dragon-bright)}
.mode-badge{font-size:.65rem;padding:.15rem .45rem;border-radius:4px;font-weight:700;text-transform:uppercase}
.mode-btn.active.http .mode-badge{background:var(--accent-glow);color:var(--accent-bright)}
.mode-btn.active.udp .mode-badge{background:var(--dragon-glow);color:var(--dragon-bright)}
.mode-desc{font-size:.75rem;color:var(--text-dim);margin-bottom:1.5rem;padding:.6rem .9rem;background:var(--bg-card);border:1px solid var(--border);border-radius:8px}
/* FOLDER CHIPS */
.tree-root-bar{display:flex;flex-wrap:wrap;gap:.4rem;margin-bottom:.6rem}
.tree-chip{display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .85rem;font-family:'JetBrains Mono',monospace;font-size:.78rem;border-radius:20px;cursor:pointer;transition:all .15s;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-secondary);user-select:none}
.tree-chip:hover{border-color:var(--border-bright);background:var(--bg-card)}
.tree-chip.active{border-color:var(--accent-bright);background:var(--accent-glow);color:var(--accent-bright)}
.tree-chip .rm{font-size:.6rem;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:transparent;border:none;color:inherit;cursor:pointer;opacity:.5;transition:all .15s}
.tree-chip .rm:hover{opacity:1;background:var(--error-bg);color:var(--error)}
.tree-chip-none{display:inline-flex;align-items:center;padding:.4rem .85rem;font-family:'JetBrains Mono',monospace;font-size:.78rem;border-radius:20px;cursor:pointer;transition:all .15s;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-dim);user-select:none;font-style:italic}
.tree-chip-none.active{border-color:var(--accent-bright);background:var(--accent-glow);color:var(--accent-bright);font-style:normal}
.add-row{display:flex;gap:.4rem;align-items:center}
.add-input{flex:1;padding:.4rem .75rem;font-family:'JetBrains Mono',monospace;font-size:.76rem;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);outline:none;transition:all .2s}
.add-input:focus{border-color:var(--dragon);box-shadow:0 0 0 3px var(--dragon-glow)}
.add-input::placeholder{color:var(--text-dim)}
.btn-small{padding:.4rem .85rem;font-family:'Outfit',sans-serif;font-size:.72rem;font-weight:600;background:var(--dragon);color:#fff;border:none;border-radius:8px;cursor:pointer;transition:all .15s;white-space:nowrap}
.btn-small:hover{background:var(--dragon-bright)}
.tree-toggle{display:inline-flex;align-items:center;gap:.4rem;padding:.35rem .8rem;margin-top:.6rem;font-size:.72rem;font-weight:600;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;color:var(--text-secondary);cursor:pointer;transition:all .15s;user-select:none}
.tree-toggle:hover{border-color:var(--border-bright);color:var(--text-primary)}
.tree-toggle .arrow{transition:transform .2s;display:inline-block;font-size:.6rem}
.tree-toggle.open .arrow{transform:rotate(90deg)}
.tree-panel{max-height:0;overflow:hidden;transition:max-height .35s ease,opacity .25s ease;opacity:0}
.tree-panel.open{max-height:2000px;opacity:1}
.tree-inner{margin-top:.7rem;padding:1rem;background:var(--bg-card);border:1px solid var(--border);border-radius:10px;max-height:400px;overflow-y:auto}
/* DROP ZONE */
.drop-zone{border:2px dashed var(--border);border-radius:14px;padding:2.5rem;text-align:center;cursor:pointer;transition:all .25s;background:var(--bg-card);margin-bottom:1.5rem;position:relative}
.drop-zone:hover,.drop-zone.drag-over{border-color:var(--dragon-bright);background:var(--dragon-glow)}
.drop-zone-icon{font-size:2rem;margin-bottom:.75rem;display:block}
.drop-zone-label{font-size:.95rem;font-weight:600;color:var(--text-secondary);margin-bottom:.35rem}
.drop-zone-sub{font-size:.78rem;color:var(--text-dim)}
#file-input{display:none}
/* FILE LIST */
.file-list{margin-bottom:1.5rem}
.file-item{display:flex;align-items:center;gap:.75rem;padding:.65rem .9rem;background:var(--bg-card);border:1px solid var(--border);border-radius:10px;margin-bottom:.4rem;transition:all .2s}
.file-item:hover{border-color:var(--border-bright)}
.file-icon{font-size:1.1rem;flex-shrink:0}
.file-info{flex:1;min-width:0}
.file-name{font-size:.82rem;font-weight:600;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.file-size{font-size:.7rem;color:var(--text-dim);font-family:'JetBrains Mono',monospace}
.file-status{font-size:.75rem;font-weight:600;flex-shrink:0}
.file-status.pending{color:var(--text-dim)}
.file-status.uploading{color:var(--accent-bright)}
.file-status.done{color:var(--success)}
.file-status.error{color:var(--error)}
.file-remove{width:24px;height:24px;border-radius:50%;border:none;background:transparent;color:var(--text-dim);cursor:pointer;font-size:.7rem;display:flex;align-items:center;justify-content:center;transition:all .15s;flex-shrink:0}
.file-remove:hover{background:var(--error-bg);color:var(--error)}
.file-progress{height:3px;background:var(--border);border-radius:2px;margin-top:.35rem;overflow:hidden}
.file-progress-bar{height:100%;background:linear-gradient(90deg,var(--accent),var(--accent-bright));border-radius:2px;transition:width .3s}
.file-progress-bar.udp{background:linear-gradient(90deg,var(--dragon),var(--dragon-bright))}
/* UPLOAD BUTTON */
.btn-upload{width:100%;padding:.95rem;font-family:'Outfit',sans-serif;font-size:1rem;font-weight:700;color:#fff;background:linear-gradient(135deg,var(--dragon),var(--dragon-bright));border:none;border-radius:12px;cursor:pointer;transition:all .2s;letter-spacing:.02em}
.btn-upload:hover:not(:disabled){transform:translateY(-2px);box-shadow:0 8px 28px rgba(224,92,26,.4)}
.btn-upload:disabled{opacity:.4;cursor:not-allowed;transform:none;box-shadow:none}
.btn-upload.http{background:linear-gradient(135deg,var(--accent),var(--accent-bright))}
.btn-upload.http:hover:not(:disabled){box-shadow:0 8px 28px rgba(43,92,255,.4)}
/* UPLOAD STATS */
.upload-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:.75rem;margin-bottom:1.5rem}
.stat-card{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:.9rem;text-align:center}
.stat-value{font-size:1.4rem;font-weight:700;color:var(--text-primary);font-family:'JetBrains Mono',monospace}
.stat-label{font-size:.68rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.08em;margin-top:.2rem}
/* AMPP MONITOR */
.job-list{display:flex;flex-direction:column;gap:.5rem}
.job-item{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:.8rem 1rem;display:flex;align-items:center;gap:.75rem;transition:border-color .15s}
.job-item:hover{border-color:var(--border-bright)}
.job-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.job-dot.running{background:var(--accent-bright);box-shadow:0 0 6px var(--accent-bright);animation:pulse 1.5s infinite}
.job-dot.completed{background:var(--success)}
.job-dot.failed{background:var(--error)}
.job-dot.queued{background:var(--warning)}
.job-dot.unknown{background:var(--text-dim)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.job-info{flex:1;min-width:0}
.job-name{font-size:.82rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.job-meta{font-size:.7rem;color:var(--text-dim);font-family:'JetBrains Mono',monospace;margin-top:.15rem}
.job-status{font-size:.72rem;font-weight:600;flex-shrink:0;padding:.2rem .6rem;border-radius:5px}
.job-status.running{background:var(--accent-glow);color:var(--accent-bright)}
.job-status.completed{background:var(--success-bg);color:var(--success)}
.job-status.failed{background:var(--error-bg);color:var(--error)}
.job-status.queued{background:var(--warning-bg);color:var(--warning)}
.refresh-btn{padding:.35rem .8rem;font-size:.72rem;font-weight:600;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;color:var(--text-secondary);cursor:pointer;transition:all .15s}
.refresh-btn:hover{border-color:var(--border-bright);color:var(--text-primary)}
/* ADMIN PANELS */
.admin-tabs{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:1.5rem}
.admin-tab{padding:.5rem 1rem;font-size:.78rem;font-weight:600;color:var(--text-dim);cursor:pointer;border-bottom:2px solid transparent;transition:all .15s}
.admin-tab:hover{color:var(--text-secondary)}
.admin-tab.active{color:var(--dragon-bright);border-bottom-color:var(--dragon-bright)}
.admin-panel{display:none}
.admin-panel.active{display:block}
/* FORMS */
.form-group{margin-bottom:1rem}
.form-label{display:block;font-size:.75rem;font-weight:600;color:var(--text-secondary);margin-bottom:.4rem;text-transform:uppercase;letter-spacing:.05em}
.form-input{width:100%;padding:.65rem .9rem;font-family:'Outfit',sans-serif;font-size:.88rem;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);outline:none;transition:all .2s}
.form-input:focus{border-color:var(--dragon);box-shadow:0 0 0 3px var(--dragon-glow)}
.form-input::placeholder{color:var(--text-dim)}
.form-input[type="password"]{letter-spacing:.1em}
.form-hint{font-size:.7rem;color:var(--text-dim);margin-top:.3rem}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:.75rem}
.btn-row{display:flex;gap:.75rem;margin-top:1.25rem;flex-wrap:wrap}
.btn-primary{padding:.65rem 1.5rem;font-family:'Outfit',sans-serif;font-size:.85rem;font-weight:700;color:#fff;background:linear-gradient(135deg,var(--dragon),var(--dragon-bright));border:none;border-radius:9px;cursor:pointer;transition:all .2s}
.btn-primary:hover{transform:translateY(-1px);box-shadow:0 4px 16px rgba(224,92,26,.35)}
.btn-primary:disabled{opacity:.4;cursor:not-allowed;transform:none}
.btn-secondary{padding:.65rem 1.5rem;font-family:'Outfit',sans-serif;font-size:.85rem;font-weight:600;background:var(--bg-secondary);border:1px solid var(--border);border-radius:9px;color:var(--text-secondary);cursor:pointer;transition:all .2s}
.btn-secondary:hover{border-color:var(--border-bright);color:var(--text-primary)}
.btn-danger{padding:.65rem 1.5rem;font-family:'Outfit',sans-serif;font-size:.85rem;font-weight:600;background:transparent;border:1px solid var(--error);border-radius:9px;color:var(--error);cursor:pointer;transition:all .2s}
.btn-danger:hover{background:var(--error-bg)}
/* STATUS MESSAGES */
.status-msg{padding:.7rem 1rem;border-radius:8px;font-size:.82rem;font-weight:600;margin-top:.75rem;display:none}
.status-msg.success{background:var(--success-bg);color:var(--success);border:1px solid rgba(34,197,94,.2);display:block}
.status-msg.error{background:var(--error-bg);color:var(--error);border:1px solid rgba(239,68,68,.2);display:block}
.status-msg.loading{background:var(--accent-glow);color:var(--accent-bright);border:1px solid rgba(43,92,255,.2);display:block}
/* USER TABLE */
.user-table{width:100%;border-collapse:collapse}
.user-table th{text-align:left;font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);padding:.5rem .75rem;border-bottom:1px solid var(--border)}
.user-table td{padding:.65rem .75rem;font-size:.82rem;border-bottom:1px solid var(--border);vertical-align:middle}
.user-table tr:hover td{background:var(--bg-card-hover)}
.role-badge{display:inline-flex;padding:.2rem .55rem;border-radius:5px;font-size:.68rem;font-weight:700;text-transform:uppercase}
.role-badge.admin{background:var(--dragon-glow);color:var(--dragon-bright)}
.role-badge.user{background:var(--accent-glow);color:var(--accent-bright)}
/* RELAY STATUS */
.relay-status-indicator{display:flex;align-items:center;gap:.5rem;padding:.5rem .9rem;border-radius:8px;border:1px solid var(--border);background:var(--bg-card);font-size:.8rem;font-weight:600;margin-bottom:1rem}
.relay-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.relay-dot.green{background:var(--success);box-shadow:0 0 6px var(--success)}
.relay-dot.red{background:var(--error)}
.relay-dot.grey{background:var(--text-dim)}
/* TOAST */
.toast-container{position:fixed;bottom:1.5rem;right:1.5rem;z-index:9999;display:flex;flex-direction:column;gap:.5rem;pointer-events:none}
.toast{padding:.75rem 1.25rem;border-radius:10px;font-size:.82rem;font-weight:600;border:1px solid;backdrop-filter:blur(12px);opacity:0;transform:translateY(10px);transition:all .25s;pointer-events:none;max-width:320px}
.toast.show{opacity:1;transform:translateY(0)}
.toast.success{background:rgba(34,197,94,.12);border-color:rgba(34,197,94,.3);color:var(--success)}
.toast.error{background:rgba(239,68,68,.12);border-color:rgba(239,68,68,.3);color:var(--error)}
.toast.info{background:rgba(43,92,255,.12);border-color:rgba(43,92,255,.3);color:var(--accent-bright)}
</style>
</head>
<body>
<!-- LOGIN -->
<div class="login-screen" id="login-screen">
<div class="login-box">
<img class="login-logo" src="/logo.png" alt="Dragon Wind" onerror="this.style.display='none'"/>
<div class="login-title">Dragon Wind</div>
<div class="login-sub">Broadcast Upload Platform</div>
<input class="login-field" id="login-user" type="text" placeholder="Username" autocomplete="username"/>
<input class="login-field" id="login-pass" type="password" placeholder="Password" autocomplete="current-password"/>
<button class="login-btn" id="login-btn" onclick="doLogin()">Sign In</button>
<div class="login-error" id="login-error"></div>
</div>
</div>
<!-- APP -->
<div class="app hidden" id="app">
<!-- HEADER -->
<div class="header">
<div class="header-left">
<img class="logo-mark" src="/logo.png" alt="Dragon Wind" onerror="this.style.display='none'"/>
<div><div class="brand-name">Dragon Wind</div><div class="brand-sub">Broadcast Upload Platform</div></div>
</div>
<div class="header-right">
<span class="header-user" id="header-user"></span>
<button class="theme-toggle" onclick="toggleTheme()">🌙</button>
<button class="btn-logout" onclick="doLogout()">Sign Out</button>
</div>
</div>
<!-- NAV -->
<div class="nav-tabs" id="nav-tabs">
<div class="nav-tab active" data-page="upload" onclick="switchPage('upload')">🌪️ Upload</div>
<div class="nav-tab" data-page="monitor" onclick="switchPage('monitor')">📡 AMPP Monitor</div>
<div class="nav-tab admin-only hidden" data-page="admin" onclick="switchPage('admin')">⚙️ Admin</div>
</div>
<!-- UPLOAD PAGE -->
<div class="page active" id="page-upload">
<!-- Mode Selector -->
<div style="margin-bottom:1rem;margin-top:1.5rem">
<div class="section-title">Upload Mode</div>
<div class="mode-bar">
<button class="mode-btn http active" id="mode-http" onclick="setMode('http')">
🔗 HTTP <span class="mode-badge">Reliable</span>
</button>
<button class="mode-btn udp" id="mode-udp" onclick="setMode('udp')">
⚡ UDP <span class="mode-badge">Fast WAN</span>
</button>
</div>
<div class="mode-desc" id="mode-desc">
<strong>HTTP Mode:</strong> Direct S3 upload via presigned URL. Best for LAN and stable connections. Speeds: 50200 MB/s.
</div>
</div>
<!-- Folder Prefix -->
<div class="folder-section" style="margin-bottom:1.5rem">
<div class="section-title">Destination Folder</div>
<div class="tree-root-bar" id="prefix-chips"></div>
<button class="tree-toggle" id="tree-toggle-btn" onclick="toggleTree()">
<span class="arrow"></span> Browse Subfolders
</button>
<div class="tree-panel" id="tree-panel">
<div class="tree-inner" id="tree-inner"></div>
</div>
<div class="add-row" style="margin-top:.6rem" id="add-folder-row">
<input class="add-input" id="new-folder-input" placeholder="New folder name…" onkeydown="if(event.key==='Enter')addFolder()"/>
<button class="btn-small" onclick="addFolder()">+ Add</button>
</div>
<div style="margin-top:.5rem;font-size:.75rem;color:var(--text-dim)">
Selected: <code id="prefix-display" style="color:var(--accent-bright);font-family:'JetBrains Mono',monospace">(root)</code>
</div>
</div>
<!-- Drop Zone -->
<div class="section-title">Files</div>
<div class="drop-zone" id="drop-zone" onclick="document.getElementById('file-input').click()" ondragover="onDragOver(event)" ondragleave="onDragLeave(event)" ondrop="onDrop(event)">
<span class="drop-zone-icon">📂</span>
<div class="drop-zone-label">Drop files here or click to browse</div>
<div class="drop-zone-sub">Video, audio, image, metadata — up to 50 GB per file</div>
</div>
<input id="file-input" type="file" multiple onchange="onFileInputChange(event)"/>
<!-- File List -->
<div class="file-list" id="file-list"></div>
<!-- Upload Button -->
<button class="btn-upload http" id="upload-btn" onclick="startUpload()" disabled>Upload Files</button>
<!-- Stats -->
<div class="upload-stats" style="margin-top:1.5rem">
<div class="stat-card"><div class="stat-value" id="stat-total">0</div><div class="stat-label">Total Files</div></div>
<div class="stat-card"><div class="stat-value" id="stat-done">0</div><div class="stat-label">Completed</div></div>
<div class="stat-card"><div class="stat-value" id="stat-failed">0</div><div class="stat-label">Failed</div></div>
</div>
</div><!-- /page-upload -->
<!-- AMPP MONITOR PAGE -->
<div class="page" id="page-monitor">
<div style="margin-top:1.5rem;display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem">
<div class="section-title" style="margin-bottom:0">AMPP Job Queue</div>
<button class="refresh-btn" onclick="loadAmppJobs()">🔄 Refresh</button>
</div>
<div id="ampp-status" class="status-msg"></div>
<div class="job-list" id="job-list">
<div style="color:var(--text-dim);font-size:.85rem;text-align:center;padding:2rem">Loading jobs…</div>
</div>
</div>
<!-- ADMIN PAGE -->
<div class="page" id="page-admin">
<div style="margin-top:1.5rem"></div>
<div class="admin-tabs">
<div class="admin-tab active" onclick="switchAdminTab('s3')">S3 Storage</div>
<div class="admin-tab" onclick="switchAdminTab('relay')">UDP Relay</div>
<div class="admin-tab" onclick="switchAdminTab('users')">Users</div>
<div class="admin-tab" onclick="switchAdminTab('folders')">Folders</div>
</div>
<!-- S3 SETTINGS -->
<div class="admin-panel active" id="admin-s3">
<div class="section-title">S3 Storage Configuration</div>
<div class="form-group">
<label class="form-label">Endpoint URL</label>
<input class="form-input" id="s3-endpoint" type="url" placeholder="https://s3.example.com or https://minio.yourhost.com:9000"/>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Region</label>
<input class="form-input" id="s3-region" type="text" placeholder="us-east-1"/>
</div>
<div class="form-group">
<label class="form-label">Bucket Name</label>
<input class="form-input" id="s3-bucket" type="text" placeholder="mybucket"/>
</div>
</div>
<div class="form-group">
<label class="form-label">Access Key ID</label>
<input class="form-input" id="s3-access-key" type="text" placeholder="AKIAIOSFODNN7EXAMPLE" autocomplete="off"/>
</div>
<div class="form-group">
<label class="form-label">Secret Access Key</label>
<input class="form-input" id="s3-secret-key" type="password" placeholder="Leave blank to keep existing secret key" autocomplete="new-password"/>
<div class="form-hint" id="s3-secret-hint"></div>
</div>
<div class="btn-row">
<button class="btn-secondary" onclick="testS3()">🔍 Test Connection</button>
<button class="btn-primary" onclick="saveS3()">💾 Save Configuration</button>
</div>
<div class="status-msg" id="s3-status"></div>
</div>
<!-- RELAY SETTINGS -->
<div class="admin-panel" id="admin-relay">
<div class="section-title">UDP Relay Configuration</div>
<div class="relay-status-indicator" id="relay-status-bar">
<div class="relay-dot grey" id="relay-dot"></div>
<span id="relay-status-text">Not checked</span>
</div>
<div class="form-group">
<label class="form-label">Relay Server URL</label>
<input class="form-input" id="relay-url" type="url" placeholder="https://relay.yourdomain.com"/>
<div class="form-hint">Base URL of your Dragon Wind UDP Relay container (TCP control port)</div>
</div>
<div class="form-group">
<label class="form-label">UDP Data Port</label>
<input class="form-input" id="relay-udp-port" type="number" placeholder="5000" min="1024" max="65535"/>
<div class="form-hint">UDP port exposed by the relay container (must be port-forwarded)</div>
</div>
<div class="btn-row">
<button class="btn-secondary" onclick="testRelay()">🔍 Test Relay</button>
<button class="btn-primary" onclick="saveRelay()">💾 Save Configuration</button>
</div>
<div class="status-msg" id="relay-status"></div>
</div>
<!-- USERS -->
<div class="admin-panel" id="admin-users">
<div class="section-title">User Management</div>
<div style="margin-bottom:1.5rem">
<div class="form-row">
<div class="form-group"><label class="form-label">Username</label><input class="form-input" id="new-username" placeholder="newuser"/></div>
<div class="form-group"><label class="form-label">Password</label><input class="form-input" id="new-user-pass" type="password" placeholder="min 4 chars"/></div>
</div>
<div class="form-group">
<label class="form-label">Role</label>
<select class="form-input" id="new-user-role" style="cursor:pointer">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<button class="btn-primary" onclick="createUser()">+ Create User</button>
<div class="status-msg" id="user-status"></div>
</div>
<table class="user-table" id="user-table">
<thead><tr><th>Username</th><th>Role</th><th>Created</th><th>Actions</th></tr></thead>
<tbody id="user-tbody"></tbody>
</table>
</div>
<!-- FOLDERS ADMIN -->
<div class="admin-panel" id="admin-folders">
<div class="section-title">Folder Structure</div>
<div id="admin-folder-tree"></div>
<div class="add-row" style="margin-top:1rem">
<input class="add-input" id="admin-new-folder" placeholder="New top-level folder name…" onkeydown="if(event.key==='Enter')adminAddFolder()"/>
<button class="btn-small" onclick="adminAddFolder()">+ Add</button>
</div>
<div class="status-msg" id="folder-status"></div>
</div>
</div><!-- /page-admin -->
</div><!-- /app -->
<!-- TOASTS -->
<div class="toast-container" id="toast-container"></div>
<script>
// ==================== STATE ====================
let authToken = localStorage.getItem('dw_token') || null;
let currentUser = localStorage.getItem('dw_user') || null;
let currentRole = localStorage.getItem('dw_role') || null;
let uploadMode = 'http';
let selectedPrefix = '';
let folderTree = [];
let selectedFiles = [];
let uploadStats = { total: 0, done: 0, failed: 0 };
// ==================== THEME ====================
const savedTheme = localStorage.getItem('dw_theme') || 'dark';
document.documentElement.setAttribute('data-theme', savedTheme);
function toggleTheme() {
const t = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', t);
localStorage.setItem('dw_theme', t);
document.querySelector('.theme-toggle').textContent = t === 'dark' ? '🌙' : '☀️';
}
document.querySelector('.theme-toggle').textContent = savedTheme === 'dark' ? '🌙' : '☀️';
// ==================== AUTH ====================
async function doLogin() {
const user = document.getElementById('login-user').value.trim();
const pass = document.getElementById('login-pass').value;
const btn = document.getElementById('login-btn');
const err = document.getElementById('login-error');
if (!user || !pass) { err.textContent = 'Enter username and password'; return; }
btn.disabled = true; btn.textContent = 'Signing in…'; err.textContent = '';
try {
const r = await fetch('/api/login', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({username:user,password:pass}) });
const d = await r.json();
if (!d.success) throw new Error(d.error || 'Login failed');
authToken = d.token; currentUser = d.user; currentRole = d.role;
localStorage.setItem('dw_token', authToken);
localStorage.setItem('dw_user', currentUser);
localStorage.setItem('dw_role', currentRole);
showApp();
} catch (e) {
err.textContent = e.message;
} finally {
btn.disabled = false; btn.textContent = 'Sign In';
}
}
async function doLogout() {
try { await api('POST', '/api/logout'); } catch (_) {}
authToken = null; currentUser = null; currentRole = null;
localStorage.removeItem('dw_token'); localStorage.removeItem('dw_user'); localStorage.removeItem('dw_role');
document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('app').classList.add('hidden');
document.getElementById('login-user').value = '';
document.getElementById('login-pass').value = '';
}
function showApp() {
document.getElementById('login-screen').classList.add('hidden');
document.getElementById('app').classList.remove('hidden');
document.getElementById('header-user').textContent = currentUser;
if (currentRole === 'admin') {
document.querySelectorAll('.admin-only').forEach(e => e.classList.remove('hidden'));
loadS3Config();
loadRelayConfig();
loadUsers();
}
loadFolders();
loadAmppJobs();
}
document.getElementById('login-pass').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
// Auto-login if token exists
if (authToken) {
api('GET', '/api/health').then(() => showApp()).catch(() => { authToken = null; localStorage.removeItem('dw_token'); });
}
// ==================== API HELPER ====================
async function api(method, url, body) {
const opts = { method, headers: { 'Content-Type': 'application/json', 'x-auth-token': authToken || '' } };
if (body) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
if (r.status === 401) { doLogout(); throw new Error('Session expired'); }
return r.json();
}
// ==================== NAVIGATION ====================
function switchPage(name) {
document.querySelectorAll('.nav-tab').forEach(t => t.classList.toggle('active', t.dataset.page === name));
document.querySelectorAll('.page').forEach(p => p.classList.toggle('active', p.id === `page-${name}`));
if (name === 'monitor') loadAmppJobs();
if (name === 'admin') { loadUsers(); loadAdminFolders(); }
}
function switchAdminTab(name) {
document.querySelectorAll('.admin-tab').forEach((t,i) => {
const names = ['s3','relay','users','folders'];
t.classList.toggle('active', names[i] === name);
});
document.querySelectorAll('.admin-panel').forEach(p => p.classList.toggle('active', p.id === `admin-${name}`));
if (name === 'users') loadUsers();
if (name === 'folders') loadAdminFolders();
}
// ==================== UPLOAD MODE ====================
function setMode(mode) {
uploadMode = mode;
document.getElementById('mode-http').classList.toggle('active', mode === 'http');
document.getElementById('mode-http').classList.toggle('http', true);
document.getElementById('mode-udp').classList.toggle('active', mode === 'udp');
document.getElementById('mode-udp').classList.toggle('udp', true);
const desc = document.getElementById('mode-desc');
const btn = document.getElementById('upload-btn');
if (mode === 'http') {
desc.innerHTML = '<strong>HTTP Mode:</strong> Direct S3 upload via presigned URL. Best for LAN and stable connections. Speeds: 50200 MB/s.';
btn.className = 'btn-upload http';
btn.textContent = selectedFiles.length > 0 ? `Upload ${selectedFiles.length} File${selectedFiles.length>1?'s':''}` : 'Upload Files';
} else {
desc.innerHTML = '<strong>UDP Mode:</strong> Relay-accelerated transfer via Dragon Wind Relay. 48× faster on WAN and lossy networks. Requires relay server.';
btn.className = 'btn-upload';
btn.textContent = selectedFiles.length > 0 ? `⚡ UDP Upload ${selectedFiles.length} File${selectedFiles.length>1?'s':''}` : '⚡ UDP Upload';
}
}
// ==================== FOLDERS ====================
async function loadFolders() {
try {
const d = await api('GET', '/api/folders');
folderTree = d.tree || [];
renderPrefixChips();
renderTree(folderTree, document.getElementById('tree-inner'), []);
} catch (e) { console.error('loadFolders:', e); }
}
function renderPrefixChips() {
const bar = document.getElementById('prefix-chips');
bar.innerHTML = '';
const none = document.createElement('div');
none.className = 'tree-chip-none' + (selectedPrefix === '' ? ' active' : '');
none.textContent = 'No Prefix (root)';
none.onclick = () => { selectedPrefix = ''; updatePrefixDisplay(); renderPrefixChips(); };
bar.appendChild(none);
function addChips(nodes, pathArr) {
nodes.forEach(n => {
const fullPath = [...pathArr, n.name];
const key = fullPath.join('/');
const chip = document.createElement('div');
chip.className = 'tree-chip' + (selectedPrefix === key ? ' active' : '');
chip.innerHTML = `${n.children.length ? '📁' : '📄'} ${n.name}`;
if (currentRole === 'admin') {
const rm = document.createElement('button');
rm.className = 'rm'; rm.textContent = '×';
rm.onclick = (e) => { e.stopPropagation(); deleteFolder(fullPath); };
chip.appendChild(rm);
}
chip.onclick = (e) => {
if (e.target.classList.contains('rm')) return;
selectedPrefix = key; updatePrefixDisplay(); renderPrefixChips();
};
bar.appendChild(chip);
if (n.children.length) addChips(n.children, fullPath);
});
}
addChips(folderTree, []);
}
function updatePrefixDisplay() {
document.getElementById('prefix-display').textContent = selectedPrefix || '(root)';
}
function toggleTree() {
const btn = document.getElementById('tree-toggle-btn');
const panel = document.getElementById('tree-panel');
btn.classList.toggle('open');
panel.classList.toggle('open');
}
function renderTree(nodes, container, pathArr) {
container.innerHTML = '';
if (!nodes.length) { container.innerHTML = '<div style="color:var(--text-dim);font-size:.8rem;padding:.5rem">No subfolders</div>'; return; }
nodes.forEach(n => {
const fullPath = [...pathArr, n.name];
const key = fullPath.join('/');
const div = document.createElement('div');
div.style.paddingLeft = pathArr.length ? '1.2rem' : '0';
div.style.marginBottom = '.3rem';
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:.4rem;cursor:pointer;padding:.25rem .4rem;border-radius:6px;transition:background .15s';
row.onmouseenter = () => row.style.background = 'var(--bg-card-hover)';
row.onmouseleave = () => row.style.background = '';
row.innerHTML = `<span style="font-size:.75rem;color:var(--text-dim)">${n.children.length?'📁':'📄'}</span><span style="font-family:'JetBrains Mono',monospace;font-size:.77rem;color:var(--text-secondary)">${n.name}</span>`;
row.onclick = () => { selectedPrefix = key; updatePrefixDisplay(); renderPrefixChips(); };
div.appendChild(row);
if (n.children.length) renderTree(n.children, div, fullPath);
container.appendChild(div);
});
}
async function addFolder() {
const input = document.getElementById('new-folder-input');
const name = input.value.trim();
if (!name) return;
try {
const pathArr = selectedPrefix ? selectedPrefix.split('/') : [];
await api('POST', '/api/folders/add', { path: pathArr, name });
input.value = '';
await loadFolders();
showToast('Folder added', 'success');
} catch (e) { showToast(e.message, 'error'); }
}
async function deleteFolder(pathArr) {
if (!confirm(`Delete folder "${pathArr.join('/')}" and all subfolders?`)) return;
try {
await api('POST', '/api/folders/delete', { path: pathArr });
if (selectedPrefix.startsWith(pathArr.join('/'))) { selectedPrefix = ''; updatePrefixDisplay(); }
await loadFolders();
showToast('Folder deleted', 'success');
} catch (e) { showToast(e.message, 'error'); }
}
// ==================== FILE HANDLING ====================
function onDragOver(e) { e.preventDefault(); document.getElementById('drop-zone').classList.add('drag-over'); }
function onDragLeave(e) { document.getElementById('drop-zone').classList.remove('drag-over'); }
function onDrop(e) {
e.preventDefault();
document.getElementById('drop-zone').classList.remove('drag-over');
addFiles(Array.from(e.dataTransfer.files));
}
function onFileInputChange(e) { addFiles(Array.from(e.target.files)); e.target.value = ''; }
function addFiles(files) {
files.forEach(f => {
if (!selectedFiles.find(x => x.name === f.name && x.size === f.size)) {
selectedFiles.push({ file: f, name: f.name, size: f.size, status: 'pending' });
}
});
renderFileList();
updateUploadBtn();
updateStats();
}
function renderFileList() {
const list = document.getElementById('file-list');
list.innerHTML = '';
selectedFiles.forEach((item, i) => {
const el = document.createElement('div');
el.className = 'file-item';
el.id = `file-item-${i}`;
el.innerHTML = `
<span class="file-icon">${getFileIcon(item.name)}</span>
<div class="file-info">
<div class="file-name">${escHtml(item.name)}</div>
<div class="file-size">${fmtSize(item.size)}</div>
<div class="file-progress" id="prog-${i}" style="display:none"><div class="file-progress-bar" id="progbar-${i}" style="width:0%"></div></div>
</div>
<span class="file-status ${item.status}" id="stat-${i}">${item.status}</span>
<button class="file-remove" onclick="removeFile(${i})">×</button>
`;
list.appendChild(el);
});
}
function removeFile(i) {
selectedFiles.splice(i, 1);
renderFileList();
updateUploadBtn();
updateStats();
}
function updateUploadBtn() {
const btn = document.getElementById('upload-btn');
const n = selectedFiles.filter(f => f.status === 'pending').length;
btn.disabled = n === 0;
const prefix = uploadMode === 'udp' ? '⚡ UDP Upload ' : 'Upload ';
btn.textContent = n > 0 ? `${prefix}${n} File${n>1?'s':''}` : (uploadMode === 'udp' ? '⚡ UDP Upload' : 'Upload Files');
}
function updateStats() {
uploadStats.total = selectedFiles.length;
uploadStats.done = selectedFiles.filter(f => f.status === 'done').length;
uploadStats.failed = selectedFiles.filter(f => f.status === 'error').length;
document.getElementById('stat-total').textContent = uploadStats.total;
document.getElementById('stat-done').textContent = uploadStats.done;
document.getElementById('stat-failed').textContent = uploadStats.failed;
}
// ==================== UPLOAD ====================
async function startUpload() {
const pending = selectedFiles.filter(f => f.status === 'pending');
if (!pending.length) return;
document.getElementById('upload-btn').disabled = true;
if (uploadMode === 'http') await uploadHTTP(pending);
else await uploadUDP(pending);
updateUploadBtn();
updateStats();
}
async function uploadHTTP(files) {
for (let i = 0; i < files.length; i++) {
const item = files[i];
const idx = selectedFiles.indexOf(item);
setFileStatus(idx, 'uploading', 'Uploading…');
document.getElementById(`prog-${idx}`).style.display = 'block';
try {
// Get presigned URL
const presigned = await api('POST', '/api/presigned', {
filename: item.name, prefix: selectedPrefix,
contentType: item.file.type || 'application/octet-stream'
});
if (!presigned.success) throw new Error(presigned.error || 'Failed to get presigned URL');
// Upload directly to S3
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', presigned.url);
xhr.setRequestHeader('Content-Type', item.file.type || 'application/octet-stream');
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
document.getElementById(`progbar-${idx}`).style.width = `${pct}%`;
setFileStatus(idx, 'uploading', `${pct}%`);
}
};
xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) resolve(); else reject(new Error(`S3 error ${xhr.status}`)); };
xhr.onerror = () => reject(new Error('Network error'));
xhr.send(item.file);
});
document.getElementById(`progbar-${idx}`).style.width = '100%';
setFileStatus(idx, 'done', '✓ Done');
item.status = 'done';
showToast(`Uploaded: ${item.name}`, 'success');
} catch (e) {
setFileStatus(idx, 'error', '✗ Error');
item.status = 'error';
showToast(`Failed: ${item.name}${e.message}`, 'error');
}
updateStats();
}
}
async function uploadUDP(files) {
for (const item of files) {
const idx = selectedFiles.indexOf(item);
setFileStatus(idx, 'uploading', 'Requesting UDP session…');
document.getElementById(`prog-${idx}`).style.display = 'block';
const progBar = document.getElementById(`progbar-${idx}`);
progBar.classList.add('udp');
try {
// 1. Create UDP session on main server
const sessResp = await api('POST', '/api/udp/session', {
filename: item.name, size: item.size, prefix: selectedPrefix
});
if (!sessResp.success) throw new Error(sessResp.error || 'Failed to create UDP session');
const { sessionId, relayUrl, udpPort, key } = sessResp;
// 2. Register session on relay control API
const s3Cfg = await api('GET', '/api/s3/config');
const relayInit = await fetch(`${relayUrl}/session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId, filename: item.name, size: item.size, key, bucket: s3Cfg.config?.bucket,
s3Config: {
endpoint: s3Cfg.config?.endpoint,
region: s3Cfg.config?.region || 'us-east-1',
accessKeyId: s3Cfg.config?.accessKeyId,
// Note: secretKey fetched server-side via relay token
secretAccessKey: '__relay_token__'
}
})
});
if (!relayInit.ok) throw new Error('Relay session init failed');
// 3. Send file over UDP
await sendFileUDP(item.file, sessionId, relayUrl, udpPort, (pct) => {
progBar.style.width = `${pct}%`;
setFileStatus(idx, 'uploading', `${pct}%`);
});
// 4. Mark complete
await api('POST', `/api/udp/session/${sessionId}/complete`, { success: true });
progBar.style.width = '100%';
setFileStatus(idx, 'done', '⚡ Done');
item.status = 'done';
showToast(`UDP Uploaded: ${item.name}`, 'success');
} catch (e) {
setFileStatus(idx, 'error', '✗ Error');
item.status = 'error';
showToast(`UDP Failed: ${item.name}${e.message}`, 'error');
}
updateStats();
}
}
async function sendFileUDP(file, sessionId, relayUrl, udpPort, onProgress) {
/**
* UDP transfer via WebRTC DataChannel (browser-compatible UDP-like transport).
* True UDP requires native socket access; in browsers we use WebRTC data channels
* which provide similar low-overhead semantics with optional reliability.
*
* For production: install the Dragon Wind native helper or use the Chrome extension
* which has socket access via chrome.sockets API.
*/
const CHUNK = 64 * 1024;
const totalChunks = Math.ceil(file.size / CHUNK);
let sent = 0;
// Use WebRTC DataChannel for UDP-like delivery
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
const dc = pc.createDataChannel('upload', { ordered: false, maxRetransmits: 3 });
await new Promise((resolve, reject) => {
dc.onopen = resolve;
dc.onerror = reject;
setTimeout(() => reject(new Error('WebRTC connection timeout')), 10000);
pc.createOffer().then(offer => pc.setLocalDescription(offer));
pc.onicecandidate = async (e) => {
if (e.candidate) return;
// Signal to relay via HTTP
const sigResp = await fetch(`${relayUrl}/signal/${sessionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sdp: pc.localDescription })
}).catch(() => null);
if (sigResp?.ok) {
const answer = await sigResp.json();
await pc.setRemoteDescription(answer.sdp);
} else {
// Fallback: chunked HTTP upload if relay WebRTC not available
reject(new Error('FALLBACK_HTTP'));
}
};
}).catch(async (err) => {
if (err.message === 'FALLBACK_HTTP') {
// Graceful fallback to chunked POST
await uploadHTTPChunked(file, sessionId, relayUrl, onProgress);
return;
}
throw err;
});
// Send chunks
for (let i = 0; i < totalChunks; i++) {
const chunk = await file.slice(i * CHUNK, (i + 1) * CHUNK).arrayBuffer();
// Packet: [16B sessionId][4B chunkIndex][data]
const packet = new ArrayBuffer(20 + chunk.byteLength);
const view = new DataView(packet);
// Encode sessionId as bytes
for (let b = 0; b < 16; b++) view.setUint8(b, parseInt(sessionId.slice(b*2, b*2+2), 16));
view.setUint32(16, i, false); // big-endian
new Uint8Array(packet).set(new Uint8Array(chunk), 20);
dc.send(packet);
sent++;
onProgress(Math.round((sent / totalChunks) * 100));
// Small backpressure
if (dc.bufferedAmount > 1024 * 1024) await new Promise(r => setTimeout(r, 50));
}
pc.close();
}
async function uploadHTTPChunked(file, sessionId, relayUrl, onProgress) {
const CHUNK = 512 * 1024; // 512KB HTTP chunks as fallback
const totalChunks = Math.ceil(file.size / CHUNK);
for (let i = 0; i < totalChunks; i++) {
const chunk = file.slice(i * CHUNK, (i + 1) * CHUNK);
await fetch(`${relayUrl}/session/${sessionId}/chunk/${i}`, {
method: 'POST', body: chunk, headers: { 'Content-Type': 'application/octet-stream' }
});
onProgress(Math.round(((i + 1) / totalChunks) * 100));
}
}
function setFileStatus(i, cls, text) {
const el = document.getElementById(`stat-${i}`);
if (el) { el.className = `file-status ${cls}`; el.textContent = text; }
}
// ==================== AMPP MONITOR ====================
async function loadAmppJobs() {
const list = document.getElementById('job-list');
const status = document.getElementById('ampp-status');
status.className = 'status-msg loading'; status.textContent = 'Loading jobs…';
try {
const d = await api('GET', '/api/ampp/jobs?limit=50');
status.className = 'status-msg'; status.textContent = '';
if (!d.success) { status.className = 'status-msg error'; status.textContent = d.error || 'Failed'; return; }
const jobs = d.jobs?.items || d.jobs || [];
if (!jobs.length) { list.innerHTML = '<div style="color:var(--text-dim);text-align:center;padding:2rem;font-size:.85rem">No jobs found</div>'; return; }
list.innerHTML = '';
jobs.forEach(job => {
const el = document.createElement('div');
el.className = 'job-item';
const st = (job.status || job.state || 'unknown').toLowerCase();
const dotCls = st.includes('run') ? 'running' : st.includes('complet') || st.includes('success') ? 'completed' : st.includes('fail') || st.includes('error') ? 'failed' : st.includes('queue') || st.includes('wait') ? 'queued' : 'unknown';
const label = st.charAt(0).toUpperCase() + st.slice(1);
const name = job.name || job.displayName || job.id || 'Unnamed Job';
const created = job.created ? new Date(job.created).toLocaleString() : '';
el.innerHTML = `
<div class="job-dot ${dotCls}"></div>
<div class="job-info"><div class="job-name">${escHtml(name)}</div><div class="job-meta">${created}</div></div>
<span class="job-status ${dotCls}">${label}</span>
`;
list.appendChild(el);
});
} catch (e) {
status.className = 'status-msg error'; status.textContent = e.message;
list.innerHTML = '';
}
}
// ==================== S3 ADMIN ====================
async function loadS3Config() {
try {
const d = await api('GET', '/api/s3/config');
if (!d.success) return;
const c = d.config;
document.getElementById('s3-endpoint').value = c.endpoint || '';
document.getElementById('s3-region').value = c.region || 'us-east-1';
document.getElementById('s3-bucket').value = c.bucket || '';
document.getElementById('s3-access-key').value = c.accessKeyId || '';
document.getElementById('s3-secret-hint').textContent = c.secretKeyExists ? '🔒 Secret key is saved. Leave blank to keep it.' : 'No secret key saved.';
} catch (_) {}
}
async function testS3() {
const endpoint = document.getElementById('s3-endpoint').value.trim();
const region = document.getElementById('s3-region').value.trim();
const bucket = document.getElementById('s3-bucket').value.trim();
const accessKeyId = document.getElementById('s3-access-key').value.trim();
const secretAccessKey = document.getElementById('s3-secret-key').value;
const status = document.getElementById('s3-status');
status.className = 'status-msg loading'; status.textContent = '🔍 Testing S3 connection…';
try {
const d = await api('POST', '/api/s3/test', { endpoint, region, bucket, accessKeyId, secretAccessKey: secretAccessKey || undefined });
if (d.success) { status.className = 'status-msg success'; status.textContent = `${d.message}`; }
else { status.className = 'status-msg error'; status.textContent = `${d.error}`; }
} catch (e) { status.className = 'status-msg error'; status.textContent = `${e.message}`; }
}
async function saveS3() {
const endpoint = document.getElementById('s3-endpoint').value.trim();
const region = document.getElementById('s3-region').value.trim();
const bucket = document.getElementById('s3-bucket').value.trim();
const accessKeyId = document.getElementById('s3-access-key').value.trim();
const secretAccessKey = document.getElementById('s3-secret-key').value;
const status = document.getElementById('s3-status');
status.className = 'status-msg loading'; status.textContent = '💾 Saving…';
try {
const d = await api('PUT', '/api/s3/config', { endpoint, region, bucket, accessKeyId, secretAccessKey: secretAccessKey || undefined });
if (d.success) { status.className = 'status-msg success'; status.textContent = '✅ S3 configuration saved'; loadS3Config(); }
else { status.className = 'status-msg error'; status.textContent = `${d.error}`; }
} catch (e) { status.className = 'status-msg error'; status.textContent = `${e.message}`; }
}
// ==================== RELAY ADMIN ====================
async function loadRelayConfig() {
try {
const d = await api('GET', '/api/relay/config');
if (!d.success) return;
document.getElementById('relay-url').value = d.config.relayUrl || '';
document.getElementById('relay-udp-port').value = d.config.udpPort || 5000;
} catch (_) {}
}
async function testRelay() {
const status = document.getElementById('relay-status');
status.className = 'status-msg loading'; status.textContent = '🔍 Testing relay…';
try {
const d = await api('GET', '/api/udp/relay/health');
if (d.healthy) {
status.className = 'status-msg success';
status.textContent = `✅ Relay online — UDP port ${d.udpPort || '?'}, sessions: ${d.activeSessions ?? '?'}`;
setRelayDot('green', 'Relay online');
} else {
status.className = 'status-msg error';
status.textContent = `${d.error || 'Relay not responding'}`;
setRelayDot('red', 'Relay offline');
}
} catch (e) { status.className = 'status-msg error'; status.textContent = `${e.message}`; setRelayDot('red', 'Error'); }
}
function setRelayDot(color, text) {
const dot = document.getElementById('relay-dot');
dot.className = `relay-dot ${color}`;
document.getElementById('relay-status-text').textContent = text;
}
async function saveRelay() {
const relayUrl = document.getElementById('relay-url').value.trim();
const udpPort = document.getElementById('relay-udp-port').value;
const status = document.getElementById('relay-status');
status.className = 'status-msg loading'; status.textContent = '💾 Saving…';
try {
const d = await api('PUT', '/api/relay/config', { relayUrl, udpPort: parseInt(udpPort) });
if (d.success) { status.className = 'status-msg success'; status.textContent = '✅ Relay configuration saved'; }
else { status.className = 'status-msg error'; status.textContent = `${d.error}`; }
} catch (e) { status.className = 'status-msg error'; status.textContent = `${e.message}`; }
}
// ==================== USERS ADMIN ====================
async function loadUsers() {
try {
const d = await api('GET', '/api/users');
if (!d.success) return;
const tbody = document.getElementById('user-tbody');
tbody.innerHTML = '';
d.users.forEach(u => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><strong>${escHtml(u.username)}</strong></td>
<td><span class="role-badge ${u.role}">${u.role}</span></td>
<td style="color:var(--text-dim);font-size:.75rem;font-family:'JetBrains Mono',monospace">${u.created ? new Date(u.created).toLocaleDateString() : ''}</td>
<td>
${u.username !== currentUser ? `<button class="btn-danger" style="padding:.25rem .6rem;font-size:.7rem" onclick="deleteUser('${escHtml(u.username)}')">Delete</button>` : '<span style="color:var(--text-dim);font-size:.75rem">(you)</span>'}
</td>
`;
tbody.appendChild(tr);
});
} catch (_) {}
}
async function createUser() {
const username = document.getElementById('new-username').value.trim();
const password = document.getElementById('new-user-pass').value;
const role = document.getElementById('new-user-role').value;
const status = document.getElementById('user-status');
status.className = 'status-msg loading'; status.textContent = 'Creating user…';
try {
const d = await api('POST', '/api/users', { username, password, role });
if (d.success) {
status.className = 'status-msg success'; status.textContent = `✅ User "${username}" created`;
document.getElementById('new-username').value = '';
document.getElementById('new-user-pass').value = '';
loadUsers();
} else { status.className = 'status-msg error'; status.textContent = `${d.error}`; }
} catch (e) { status.className = 'status-msg error'; status.textContent = `${e.message}`; }
}
async function deleteUser(username) {
if (!confirm(`Delete user "${username}"?`)) return;
try {
await api('DELETE', `/api/users/${encodeURIComponent(username)}`);
showToast(`User "${username}" deleted`, 'success');
loadUsers();
} catch (e) { showToast(e.message, 'error'); }
}
// ==================== FOLDERS ADMIN ====================
async function loadAdminFolders() {
try {
const d = await api('GET', '/api/folders');
folderTree = d.tree || [];
renderAdminFolderTree(folderTree, document.getElementById('admin-folder-tree'), []);
} catch (_) {}
}
function renderAdminFolderTree(nodes, container, pathArr) {
container.innerHTML = '';
if (!nodes.length) { container.innerHTML = '<div style="color:var(--text-dim);font-size:.8rem;padding:.5rem">No folders. Add one below.</div>'; return; }
nodes.forEach(n => {
const fullPath = [...pathArr, n.name];
const div = document.createElement('div');
div.style.paddingLeft = pathArr.length * 20 + 'px';
div.style.marginBottom = '.3rem';
div.innerHTML = `
<div style="display:flex;align-items:center;gap:.5rem;padding:.3rem .5rem;border:1px solid var(--border);border-radius:7px;background:var(--bg-card)">
<span style="font-size:.85rem">${n.children.length ? '📁' : '📄'}</span>
<span style="font-family:'JetBrains Mono',monospace;font-size:.8rem;flex:1">${escHtml(n.name)}</span>
<button class="btn-danger" style="padding:.2rem .5rem;font-size:.68rem" onclick="deleteFolder(${JSON.stringify(fullPath)})">Delete</button>
</div>
`;
container.appendChild(div);
if (n.children.length) {
const sub = document.createElement('div');
renderAdminFolderTree(n.children, sub, fullPath);
container.appendChild(sub);
}
});
}
async function adminAddFolder() {
const input = document.getElementById('admin-new-folder');
const name = input.value.trim();
if (!name) return;
const status = document.getElementById('folder-status');
try {
await api('POST', '/api/folders/add', { path: [], name });
input.value = '';
status.className = 'status-msg success'; status.textContent = `✅ Folder "${name}" added`;
await loadAdminFolders();
await loadFolders();
} catch (e) { status.className = 'status-msg error'; status.textContent = `${e.message}`; }
}
// ==================== TOASTS ====================
function showToast(msg, type = 'info') {
const c = document.getElementById('toast-container');
const t = document.createElement('div');
t.className = `toast ${type}`;
t.textContent = msg;
c.appendChild(t);
requestAnimationFrame(() => { t.classList.add('show'); });
setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 300); }, 3500);
}
// ==================== HELPERS ====================
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function fmtSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024*1024) return `${(bytes/1024).toFixed(1)} KB`;
if (bytes < 1024*1024*1024) return `${(bytes/1024/1024).toFixed(1)} MB`;
return `${(bytes/1024/1024/1024).toFixed(2)} GB`;
}
function getFileIcon(name) {
const ext = name.split('.').pop().toLowerCase();
const video = ['mp4','mov','mxf','mkv','avi','wmv','m4v','mts','m2ts','prores','r3d','braw'];
const audio = ['mp3','wav','aac','flac','ogg','aiff','m4a'];
const img = ['jpg','jpeg','png','tiff','exr','dpx','arw','cr2','dng','psd'];
if (video.includes(ext)) return '🎬';
if (audio.includes(ext)) return '🎵';
if (img.includes(ext)) return '🖼️';
return '📄';
}
</script>
</body>
</html>