DragonWind/public/index.html
Zac Gaetano 155c821ef8 feat: AMPP configuration in admin settings
- GET/PUT /api/ampp/config — store base URL + API key in db.json (no restart needed)
- POST /api/ampp/test — authenticate against AMPP token endpoint and report result
- Refactored AMPP_BASE/AMPP_API_KEY constants to dynamic getAmppBase()/getAmppApiKey()
  functions so live config changes take effect immediately
- Admin → AMPP tab: base URL field, masked API key field, Test Connection + Save buttons
- Cached token invalidated automatically on config save

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

1167 lines
64 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 — Wild Dragon</title>
<link rel="icon" href="/dragon-icon.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);
--dragon:#e05c1a;--dragon-bright:#ff7d3b;--dragon-glow:rgba(224,92,26,.15);
--blue:#1e4bd8;--blue-bright:#3060ff;
--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:#f0f2f7;--bg-secondary:#fff;--bg-card:#fff;--bg-card-hover:#eaedf4;
--border:#d4d9e8;--border-bright:#b0b9cf;
--text-primary:#0f1220;--text-secondary:#3d4560;--text-dim:#7a84a0;
--accent-glow:rgba(43,92,255,.1);--dragon-glow:rgba(224,92,26,.08);
--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,.05) 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}
/* ============================================================
SPLASH SCREEN
============================================================ */
#splash{
position:fixed;inset:0;z-index:9999;background:#03040a;
display:flex;flex-direction:column;align-items:center;justify-content:center;
overflow:hidden;
}
#splash.fade-out{
animation:splashFadeOut .7s cubic-bezier(.4,0,1,1) forwards;
}
@keyframes splashFadeOut{
0%{opacity:1;transform:scale(1)}
100%{opacity:0;transform:scale(1.05);pointer-events:none}
}
/* Particle streaks */
.streak{
position:absolute;height:1px;width:200px;
background:linear-gradient(90deg,transparent,rgba(30,75,216,.4),transparent);
border-radius:1px;
animation:streakMove linear infinite;
pointer-events:none;
}
@keyframes streakMove{
from{transform:translateX(-300px)}
to{transform:translateX(110vw)}
}
/* Radial glow behind content */
.splash-bg-glow{
position:absolute;width:600px;height:600px;border-radius:50%;
background:radial-gradient(ellipse,rgba(30,75,216,.12) 0%,rgba(224,92,26,.06) 40%,transparent 70%);
animation:glowBreath 3s ease-in-out infinite;
pointer-events:none;
}
@keyframes glowBreath{
0%,100%{transform:scale(1);opacity:.7}
50%{transform:scale(1.12);opacity:1}
}
/* Ring around animated dragon */
.splash-ring{
position:absolute;width:300px;height:300px;border-radius:50%;
border:1px solid rgba(30,75,216,.2);
animation:ringPulse 2.5s ease-in-out infinite;
pointer-events:none;
}
.splash-ring-2{
width:380px;height:380px;border-color:rgba(224,92,26,.1);
animation-delay:.6s;
}
@keyframes ringPulse{
0%,100%{transform:scale(1);opacity:.5}
50%{transform:scale(1.05);opacity:1}
}
/* Main content */
.splash-content{
position:relative;z-index:2;
display:flex;flex-direction:column;align-items:center;gap:0;
animation:splashContentIn .9s cubic-bezier(.22,1,.36,1) both;
}
@keyframes splashContentIn{
from{opacity:0;transform:translateY(28px)}
to{opacity:1;transform:translateY(0)}
}
/* Animated dragon GIF */
.splash-anim{
width:220px;height:220px;object-fit:contain;
/* GIF is white — filter makes it show correctly on dark bg */
filter:drop-shadow(0 0 40px rgba(30,75,216,.6)) drop-shadow(0 0 80px rgba(224,92,26,.3));
animation:dragonFloat 4s ease-in-out infinite;
margin-bottom:-.5rem;
}
@keyframes dragonFloat{
0%,100%{transform:translateY(0) rotate(0deg)}
33%{transform:translateY(-8px) rotate(.5deg)}
66%{transform:translateY(-4px) rotate(-.3deg)}
}
/* Wild Dragon wordmark */
.splash-wordmark{
height:56px;max-width:360px;object-fit:contain;
/* Invert black logo to white, keep blue flame color */
filter:invert(1) drop-shadow(0 2px 12px rgba(0,0,0,.5));
animation:wordmarkIn .8s .25s cubic-bezier(.22,1,.36,1) both;
}
@keyframes wordmarkIn{
from{opacity:0;transform:translateY(10px) scale(.97)}
to{opacity:1;transform:translateY(0) scale(1)}
}
/* Dragon Wind subtitle */
.splash-product{
margin-top:.9rem;
font-family:'JetBrains Mono',monospace;
font-size:.72rem;letter-spacing:.35em;text-transform:uppercase;
background:linear-gradient(90deg,var(--dragon),var(--dragon-bright),#ffb07a,var(--dragon-bright),var(--dragon));
background-size:200% auto;
-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
animation:shimmer 3s linear infinite, wordmarkIn .8s .4s both;
}
@keyframes shimmer{
to{background-position:200% center}
}
/* Loading bar */
.splash-progress-wrap{
margin-top:2rem;
width:200px;height:2px;background:rgba(255,255,255,.06);border-radius:2px;overflow:hidden;
animation:wordmarkIn .6s .55s both;
}
.splash-progress-bar{
height:100%;width:0%;border-radius:2px;
background:linear-gradient(90deg,var(--blue),var(--dragon-bright));
transition:width .08s linear;
}
/* Version stamp */
.splash-version{
position:absolute;bottom:1.5rem;
font-family:'JetBrains Mono',monospace;font-size:.6rem;
color:rgba(255,255,255,.12);letter-spacing:.12em;
}
/* ============================================================
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:400px;background:var(--bg-card);border:1px solid var(--border);border-radius:18px;padding:2.5rem 2rem;text-align:center;position:relative;z-index:1;box-shadow:0 24px 80px rgba(0,0,0,.4)}
/* Dragon icon in login */
.login-icon-wrap{display:flex;flex-direction:column;align-items:center;gap:.5rem;margin-bottom:1.5rem}
.login-dragon-icon{width:72px;height:72px;object-fit:contain;filter:drop-shadow(0 4px 16px rgba(30,75,216,.4));animation:dragonFloat 4s ease-in-out infinite}
.login-wordmark{height:32px;max-width:220px;object-fit:contain}
/* Invert black logo in dark mode only */
body:not([data-theme="light"]) .login-wordmark{filter:invert(1)}
.login-product{font-size:.65rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.18em;margin-top:.25rem}
.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(--blue);box-shadow:0 0 0 3px rgba(30,75,216,.15)}
.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:700;color:#fff;background:linear-gradient(135deg,var(--blue),var(--blue-bright));border:none;border-radius:10px;cursor:pointer;transition:all .2s}
.login-btn:hover{transform:translateY(-1px);box-shadow:0 6px 24px rgba(30,75,216,.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 SHELL
============================================================ */
.app{position:relative;z-index:1}
.app.hidden{display:none}
/* HEADER */
.header{display:flex;align-items:center;justify-content:space-between;padding:.8rem 2rem;border-bottom:1px solid var(--border);background:rgba(6,8,14,.9);backdrop-filter:blur(20px);position:sticky;top:0;z-index:100}
.header-left{display:flex;align-items:center;gap:.85rem;min-width:0;flex:1}
.header-dragon{width:40px;height:40px;object-fit:contain;filter:drop-shadow(0 2px 8px rgba(30,75,216,.5));flex-shrink:0}
.header-wordmark{height:28px;max-width:180px;object-fit:contain;flex-shrink:0}
body:not([data-theme="light"]) .header-wordmark{filter:invert(1)}
[data-theme="light"] .header{background:rgba(240,242,247,.9)}
[data-theme="light"] .header-dragon{filter:none}
.header-product-tag{font-family:'JetBrains Mono',monospace;font-size:.58rem;color:var(--dragon-bright);text-transform:uppercase;letter-spacing:.15em;background:var(--dragon-glow);border:1px solid rgba(224,92,26,.25);border-radius:4px;padding:.15rem .45rem;flex-shrink:0}
.header-right{display:flex;align-items:center;gap:.85rem;flex-shrink:0}
.header-user{font-size:.75rem;color:var(--text-secondary);font-family:'JetBrains Mono',monospace}
.btn-logout{padding:.35rem .85rem;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:32px;height:32px;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:.95rem;transition:all .2s}
.theme-toggle:hover{border-color:var(--blue);color:var(--blue-bright)}
/* NAV TABS */
.nav-tabs{display:flex;padding:0 2rem;border-bottom:1px solid var(--border);background:var(--bg-secondary)}
.nav-tab{padding:.65rem 1.1rem;font-size:.78rem;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(--blue-bright);border-bottom-color:var(--blue-bright)}
.nav-tab.admin-tab.active{color:var(--warning);border-bottom-color:var(--warning)}
/* PAGES */
.page{display:none;max-width:760px;margin:0 auto;padding:2rem 2rem}
.page.active{display:block}
/* SECTION TITLE */
.section-title{font-size:.68rem;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--text-dim);margin-bottom:1rem;display:flex;align-items:center;gap:.55rem}
.section-title::before{content:'';width:3px;height:13px;background:linear-gradient(to bottom,var(--blue),var(--dragon));border-radius:2px}
/* UPLOAD MODE */
.mode-bar{display:flex;gap:.5rem;margin-bottom:1.25rem}
.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:.82rem;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(--blue-bright);background:var(--accent-glow);color:var(--blue-bright)}
.mode-btn.active-udp{border-color:var(--dragon-bright);background:var(--dragon-glow);color:var(--dragon-bright)}
.mode-badge{font-size:.62rem;padding:.12rem .4rem;border-radius:4px;font-weight:700;text-transform:uppercase;background:rgba(255,255,255,.06)}
.mode-desc{font-size:.78rem;color:var(--text-dim);margin-bottom:1.5rem;padding:.65rem .9rem;background:var(--bg-card);border:1px solid var(--border);border-radius:8px;line-height:1.5}
/* 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:.38rem .8rem;font-family:'JetBrains Mono',monospace;font-size:.76rem;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(--blue-bright);background:var(--accent-glow);color:var(--blue-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:.38rem .8rem;font-family:'JetBrains Mono',monospace;font-size:.76rem;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(--blue-bright);background:var(--accent-glow);color:var(--blue-bright);font-style:normal}
.add-row{display:flex;gap:.4rem;align-items:center}
.add-input{flex:1;padding:.38rem .7rem;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(--blue);box-shadow:0 0 0 3px rgba(30,75,216,.12)}
.add-input::placeholder{color:var(--text-dim)}
.btn-small{padding:.38rem .8rem;font-family:'Outfit',sans-serif;font-size:.72rem;font-weight:600;background:var(--blue);color:#fff;border:none;border-radius:8px;cursor:pointer;transition:all .15s;white-space:nowrap}
.btn-small:hover{background:var(--blue-bright)}
.tree-toggle{display:inline-flex;align-items:center;gap:.4rem;padding:.32rem .75rem;margin-top:.55rem;font-size:.7rem;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:.55rem}
.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:380px;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.25rem;position:relative}
.drop-zone:hover,.drop-zone.drag-over{border-color:var(--blue-bright);background:var(--accent-glow)}
.drop-zone-icon{font-size:2rem;margin-bottom:.65rem;display:block}
.drop-zone-label{font-size:.92rem;font-weight:600;color:var(--text-secondary);margin-bottom:.3rem}
.drop-zone-sub{font-size:.76rem;color:var(--text-dim)}
#file-input{display:none}
/* FILE LIST */
.file-list{margin-bottom:1.25rem}
.file-item{display:flex;align-items:center;gap:.75rem;padding:.6rem .85rem;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.05rem;flex-shrink:0}
.file-info{flex:1;min-width:0}
.file-name{font-size:.8rem;font-weight:600;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.file-size{font-size:.68rem;color:var(--text-dim);font-family:'JetBrains Mono',monospace}
.file-status{font-size:.72rem;font-weight:600;flex-shrink:0}
.file-status.pending{color:var(--text-dim)}
.file-status.uploading{color:var(--blue-bright)}
.file-status.done{color:var(--success)}
.file-status.error{color:var(--error)}
.file-remove{width:22px;height:22px;border-radius:50%;border:none;background:transparent;color:var(--text-dim);cursor:pointer;font-size:.68rem;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:.3rem;overflow:hidden}
.file-progress-bar{height:100%;background:linear-gradient(90deg,var(--blue),var(--blue-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:.9rem;font-family:'Outfit',sans-serif;font-size:.98rem;font-weight:700;color:#fff;border:none;border-radius:12px;cursor:pointer;transition:all .2s;letter-spacing:.02em;background:linear-gradient(135deg,var(--blue),var(--blue-bright))}
.btn-upload:hover:not(:disabled){transform:translateY(-2px);box-shadow:0 8px 28px rgba(30,75,216,.4)}
.btn-upload:disabled{opacity:.4;cursor:not-allowed;transform:none;box-shadow:none}
.btn-upload.udp{background:linear-gradient(135deg,var(--dragon),var(--dragon-bright))}
.btn-upload.udp:hover:not(:disabled){box-shadow:0 8px 28px rgba(224,92,26,.4)}
/* STATS */
.upload-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:.75rem;margin-top:1.5rem}
.stat-card{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:.85rem;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:.65rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.09em;margin-top:.2rem}
/* AMPP MONITOR */
.job-list{display:flex;flex-direction:column;gap:.45rem}
.job-item{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:.75rem .95rem;display:flex;align-items:center;gap:.75rem;transition:border-color .15s}
.job-item:hover{border-color:var(--border-bright)}
.job-dot{width:9px;height:9px;border-radius:50%;flex-shrink:0}
.job-dot.running{background:var(--blue-bright);box-shadow:0 0 6px var(--blue-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:.8rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.job-meta{font-size:.68rem;color:var(--text-dim);font-family:'JetBrains Mono',monospace;margin-top:.12rem}
.job-status{font-size:.7rem;font-weight:600;flex-shrink:0;padding:.18rem .55rem;border-radius:5px}
.job-status.running{background:var(--accent-glow);color:var(--blue-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:.32rem .75rem;font-size:.7rem;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 */
.admin-tabs{display:flex;border-bottom:1px solid var(--border);margin-bottom:1.5rem}
.admin-tab{padding:.5rem 1rem;font-size:.76rem;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(--blue-bright);border-bottom-color:var(--blue-bright)}
.admin-panel{display:none}
.admin-panel.active{display:block}
/* FORMS */
.form-group{margin-bottom:1rem}
.form-label{display:block;font-size:.72rem;font-weight:700;color:var(--text-secondary);margin-bottom:.38rem;text-transform:uppercase;letter-spacing:.06em}
.form-input{width:100%;padding:.62rem .88rem;font-family:'Outfit',sans-serif;font-size:.86rem;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(--blue);box-shadow:0 0 0 3px rgba(30,75,216,.12)}
.form-input::placeholder{color:var(--text-dim)}
.form-input[type="password"]{letter-spacing:.1em}
.form-hint{font-size:.68rem;color:var(--text-dim);margin-top:.28rem}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:.75rem}
.btn-row{display:flex;gap:.75rem;margin-top:1.2rem;flex-wrap:wrap}
.btn-primary{padding:.62rem 1.4rem;font-family:'Outfit',sans-serif;font-size:.84rem;font-weight:700;color:#fff;background:linear-gradient(135deg,var(--blue),var(--blue-bright));border:none;border-radius:9px;cursor:pointer;transition:all .2s}
.btn-primary:hover{transform:translateY(-1px);box-shadow:0 4px 16px rgba(30,75,216,.35)}
.btn-primary:disabled{opacity:.4;cursor:not-allowed;transform:none}
.btn-secondary{padding:.62rem 1.4rem;font-family:'Outfit',sans-serif;font-size:.84rem;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:.62rem 1.4rem;font-family:'Outfit',sans-serif;font-size:.84rem;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 */
.status-msg{padding:.65rem .95rem;border-radius:8px;font-size:.8rem;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(--blue-bright);border:1px solid rgba(30,75,216,.2);display:block}
/* USER TABLE */
.user-table{width:100%;border-collapse:collapse}
.user-table th{text-align:left;font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.09em;color:var(--text-dim);padding:.48rem .72rem;border-bottom:1px solid var(--border)}
.user-table td{padding:.62rem .72rem;font-size:.8rem;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:.18rem .52rem;border-radius:5px;font-size:.66rem;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(--blue-bright)}
/* RELAY STATUS */
.relay-status-indicator{display:flex;align-items:center;gap:.5rem;padding:.48rem .85rem;border-radius:8px;border:1px solid var(--border);background:var(--bg-card);font-size:.78rem;font-weight:600;margin-bottom:1rem}
.relay-dot{width:9px;height:9px;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)}
/* TOASTS */
.toast-container{position:fixed;bottom:1.5rem;right:1.5rem;z-index:9999;display:flex;flex-direction:column;gap:.45rem;pointer-events:none}
.toast{padding:.7rem 1.15rem;border-radius:10px;font-size:.8rem;font-weight:600;border:1px solid;backdrop-filter:blur(12px);opacity:0;transform:translateY(8px);transition:all .25s;pointer-events:none;max-width:300px}
.toast.show{opacity:1;transform:translateY(0)}
.toast.success{background:rgba(34,197,94,.1);border-color:rgba(34,197,94,.25);color:var(--success)}
.toast.error{background:rgba(239,68,68,.1);border-color:rgba(239,68,68,.25);color:var(--error)}
.toast.info{background:rgba(30,75,216,.1);border-color:rgba(30,75,216,.25);color:var(--blue-bright)}
</style>
</head>
<body>
<!-- ============================================================
SPLASH SCREEN
============================================================ -->
<div id="splash">
<!-- Animated background rings -->
<div class="splash-bg-glow"></div>
<div class="splash-ring"></div>
<div class="splash-ring splash-ring-2"></div>
<!-- Wind streaks (injected by JS) -->
<!-- Main content -->
<div class="splash-content">
<!-- Animated dragon GIF -->
<img class="splash-anim" src="/dragon-anim.gif" alt="" draggable="false"/>
<!-- Wild Dragon wordmark -->
<img class="splash-wordmark" src="/wilddragon-logo.png" alt="Wild Dragon" draggable="false"/>
<!-- Product subtitle -->
<div class="splash-product">Dragon Wind &nbsp;·&nbsp; Broadcast Upload Platform</div>
<!-- Loading bar -->
<div class="splash-progress-wrap">
<div class="splash-progress-bar" id="splash-bar"></div>
</div>
</div>
<div class="splash-version">v1.0 &nbsp;·&nbsp; wilddragon.net</div>
</div>
<!-- ============================================================
LOGIN
============================================================ -->
<div class="login-screen hidden" id="login-screen">
<div class="login-box">
<div class="login-icon-wrap">
<img class="login-dragon-icon" src="/dragon-icon.png" alt="Wild Dragon"/>
<img class="login-wordmark" src="/wilddragon-logo.png" alt="Wild Dragon"/>
<div class="login-product">Dragon Wind · Broadcast Platform</div>
</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="header-dragon" src="/dragon-icon.png" alt="Wild Dragon"/>
<img class="header-wordmark" src="/wilddragon-logo.png" alt="Wild Dragon"/>
<span class="header-product-tag">Dragon Wind</span>
</div>
<div class="header-right">
<span class="header-user" id="header-user"></span>
<button class="theme-toggle" id="theme-btn" onclick="toggleTheme()">🌙</button>
<button class="btn-logout" onclick="doLogout()">Sign Out</button>
</div>
</div>
<!-- NAV TABS -->
<div class="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-tab admin-only hidden" data-page="admin" onclick="switchPage('admin')">⚙️ Admin</div>
</div>
<!-- UPLOAD PAGE -->
<div class="page active" id="page-upload">
<div style="margin-top:1.5rem">
<div class="section-title">Upload Mode</div>
<div class="mode-bar">
<button class="mode-btn active-http" id="mode-http" onclick="setMode('http')">
🔗 HTTP <span class="mode-badge">Reliable</span>
</button>
<button class="mode-btn" 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 presigned upload. Best for LAN and stable connections. Speeds: 50200 MB/s.
</div>
</div>
<div 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:.55rem" 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:.45rem;font-size:.73rem;color:var(--text-dim)">
Prefix: <code id="prefix-display" style="color:var(--blue-bright);font-family:'JetBrains Mono',monospace">(root)</code>
</div>
</div>
<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)"/>
<div class="file-list" id="file-list"></div>
<button class="btn-upload" id="upload-btn" onclick="startUpload()" disabled>Upload Files</button>
<div class="upload-stats">
<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>
<!-- 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…</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('ampp')">AMPP</div>
<div class="admin-tab" onclick="switchAdminTab('users')">Users</div>
<div class="admin-tab" onclick="switchAdminTab('folders')">Folders</div>
</div>
<!-- S3 -->
<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"/></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" 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" 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 -->
<div class="admin-panel" id="admin-relay">
<div class="section-title">UDP Relay Configuration</div>
<div class="relay-status-indicator"><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</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>
<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>
<!-- AMPP -->
<div class="admin-panel" id="admin-ampp">
<div class="section-title">AMPP Configuration</div>
<div class="form-group">
<label class="form-label">Base URL</label>
<input class="form-input" id="ampp-base-url" type="url" placeholder="https://us-east-1.gvampp.com"/>
<div class="form-hint">Your Grass Valley AMPP regional endpoint</div>
</div>
<div class="form-group">
<label class="form-label">API Key (Base64 credentials)</label>
<input class="form-input" id="ampp-api-key" type="password" placeholder="Leave blank to keep existing" autocomplete="new-password"/>
<div class="form-hint" id="ampp-key-hint"></div>
</div>
<div class="btn-row">
<button class="btn-secondary" onclick="testAmpp()">🔍 Test Connection</button>
<button class="btn-primary" onclick="saveAmpp()">💾 Save Configuration</button>
</div>
<div class="status-msg" id="ampp-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"><thead><tr><th>Username</th><th>Role</th><th>Created</th><th>Actions</th></tr></thead><tbody id="user-tbody"></tbody></table>
</div>
<!-- Folders -->
<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…" 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>
</div>
<div class="toast-container" id="toast-container"></div>
<script>
// ============================================================
// SPLASH ANIMATION
// ============================================================
(function() {
const splash = document.getElementById('splash');
const bar = document.getElementById('splash-bar');
// Generate wind streak lines
for (let i = 0; i < 18; i++) {
const s = document.createElement('div');
s.className = 'streak';
const top = Math.random() * 100;
const dur = 1.4 + Math.random() * 2.2;
const delay = Math.random() * 3;
const width = 80 + Math.random() * 220;
const opacity = 0.15 + Math.random() * 0.35;
s.style.cssText = `top:${top}%;width:${width}px;opacity:${opacity};animation-duration:${dur}s;animation-delay:-${delay}s`;
splash.appendChild(s);
}
// Animate progress bar 0→100 over ~2.2s
let pct = 0;
const step = () => {
// Accelerates: slow start, fast finish
const increment = pct < 60 ? 1.2 : pct < 85 ? 2.5 : 3.8;
pct = Math.min(100, pct + increment);
bar.style.width = pct + '%';
if (pct < 100) requestAnimationFrame(step);
else setTimeout(dismissSplash, 280);
};
setTimeout(() => requestAnimationFrame(step), 600);
function dismissSplash() {
splash.classList.add('fade-out');
setTimeout(() => { splash.style.display = 'none'; }, 750);
}
// Safety: always dismiss after 4s even if something stalls
setTimeout(dismissSplash, 4000);
})();
// ============================================================
// 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.getElementById('theme-btn').textContent = t === 'dark' ? '🌙' : '☀️';
}
document.getElementById('theme-btn').textContent = savedTheme === 'dark' ? '🌙' : '☀️';
// ============================================================
// 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 = [];
// ============================================================
// 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 = currentUser = currentRole = null;
['dw_token','dw_user','dw_role'].forEach(k => localStorage.removeItem(k));
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(); loadAmppConfig(); loadUsers();
}
loadFolders();
loadAmppJobs();
}
document.getElementById('login-pass').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
if (authToken) {
api('GET','/api/health').then(() => showApp()).catch(() => {
authToken = null; localStorage.removeItem('dw_token');
document.getElementById('login-screen').classList.remove('hidden');
});
} else {
document.getElementById('login-screen').classList.remove('hidden');
}
// ============================================================
// API
// ============================================================
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) => {
t.classList.toggle('active', ['s3','relay','ampp','users','folders'][i] === name);
});
document.querySelectorAll('.admin-panel').forEach(p => p.classList.toggle('active', p.id === `admin-${name}`));
if (name === 'users') loadUsers();
if (name === 'folders') loadAdminFolders();
if (name === 'ampp') loadAmppConfig();
}
// ============================================================
// UPLOAD MODE
// ============================================================
function setMode(mode) {
uploadMode = mode;
document.getElementById('mode-http').className = `mode-btn${mode==='http'?' active-http':''}`;
document.getElementById('mode-udp').className = `mode-btn${mode==='udp' ?' active-udp':''}`;
const btn = document.getElementById('upload-btn');
const desc = document.getElementById('mode-desc');
if (mode === 'http') {
btn.className = 'btn-upload';
desc.innerHTML = '<strong>HTTP Mode:</strong> Direct S3 presigned upload. Best for LAN and stable connections. Speeds: 50200 MB/s.';
} else {
btn.className = 'btn-upload udp';
desc.innerHTML = '<strong>UDP Mode:</strong> Relay-accelerated transfer. 48× faster on WAN/lossy networks. Requires relay server.';
}
updateUploadBtn();
}
// ============================================================
// 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() {
document.getElementById('tree-toggle-btn').classList.toggle('open');
document.getElementById('tree-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:.72rem;color:var(--text-dim)">${n.children.length?'📁':'📄'}</span><span style="font-family:'JetBrains Mono',monospace;font-size:.76rem;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('/')}"?`)) 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">${esc(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${uploadMode==='udp'?' udp':''}" 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 n = selectedFiles.filter(f => f.status==='pending').length;
const btn = document.getElementById('upload-btn');
btn.disabled = n === 0;
btn.textContent = uploadMode==='udp'
? (n>0 ? `⚡ UDP Upload ${n} File${n>1?'s':''}` : '⚡ UDP Upload')
: (n>0 ? `Upload ${n} File${n>1?'s':''}` : 'Upload Files');
}
function updateStats() {
document.getElementById('stat-total').textContent = selectedFiles.length;
document.getElementById('stat-done').textContent = selectedFiles.filter(f=>f.status==='done').length;
document.getElementById('stat-failed').textContent = selectedFiles.filter(f=>f.status==='error').length;
}
// ============================================================
// 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 (const item of files) {
const idx = selectedFiles.indexOf(item);
setFileStatus(idx,'uploading','Uploading…');
document.getElementById(`prog-${idx}`).style.display='block';
try {
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');
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 p=Math.round(e.loaded/e.total*100); document.getElementById(`progbar-${idx}`).style.width=p+'%'; setFileStatus(idx,'uploading',p+'%'); } };
xhr.onload=()=>xhr.status<300?resolve():reject(new Error(`S3 ${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 session…');
document.getElementById(`prog-${idx}`).style.display='block';
const pb = document.getElementById(`progbar-${idx}`); pb.classList.add('udp');
try {
const sess = await api('POST','/api/udp/session',{filename:item.name,size:item.size,prefix:selectedPrefix});
if (!sess.success) throw new Error(sess.error);
const {sessionId,relayUrl} = sess;
const CHUNK=64*1024; const total=Math.ceil(item.size/CHUNK);
for (let i=0;i<total;i++) {
const chunk=item.file.slice(i*CHUNK,(i+1)*CHUNK);
await fetch(`${relayUrl}/session/${sessionId}/chunk/${i}`,{method:'POST',body:chunk,headers:{'Content-Type':'application/octet-stream'}});
const p=Math.round((i+1)/total*100); pb.style.width=p+'%'; setFileStatus(idx,'uploading',`${p}%`);
}
await api('POST',`/api/udp/session/${sessionId}/complete`,{success:true});
pb.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();
}
}
function setFileStatus(i,cls,text) { const el=document.getElementById(`stat-${i}`); if(el){el.className=`file-status ${cls}`;el.textContent=text;} }
// ============================================================
// AMPP
// ============================================================
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 cls=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';
el.innerHTML=`<div class="job-dot ${cls}"></div><div class="job-info"><div class="job-name">${esc(job.name||job.id||'Job')}</div><div class="job-meta">${job.created?new Date(job.created).toLocaleString():''}</div></div><span class="job-status ${cls}">${cls.charAt(0).toUpperCase()+cls.slice(1)}</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;
document.getElementById('s3-endpoint').value=d.config.endpoint||'';
document.getElementById('s3-region').value=d.config.region||'us-east-1';
document.getElementById('s3-bucket').value=d.config.bucket||'';
document.getElementById('s3-access-key').value=d.config.accessKeyId||'';
document.getElementById('s3-secret-hint').textContent=d.config.secretKeyExists?'🔒 Secret saved — leave blank to keep.':'No secret saved.';
} catch(_) {}
}
async function testS3() {
const s=document.getElementById('s3-status');
s.className='status-msg loading'; s.textContent='🔍 Testing S3 connection…';
try {
const d=await api('POST','/api/s3/test',{endpoint:document.getElementById('s3-endpoint').value.trim(),region:document.getElementById('s3-region').value.trim(),bucket:document.getElementById('s3-bucket').value.trim(),accessKeyId:document.getElementById('s3-access-key').value.trim(),secretAccessKey:document.getElementById('s3-secret-key').value||undefined});
s.className=`status-msg ${d.success?'success':'error'}`; s.textContent=d.success?`${d.message}`:`${d.error}`;
} catch(e){s.className='status-msg error';s.textContent=`${e.message}`;}
}
async function saveS3() {
const s=document.getElementById('s3-status');
s.className='status-msg loading'; s.textContent='💾 Saving…';
try {
const d=await api('PUT','/api/s3/config',{endpoint:document.getElementById('s3-endpoint').value.trim(),region:document.getElementById('s3-region').value.trim(),bucket:document.getElementById('s3-bucket').value.trim(),accessKeyId:document.getElementById('s3-access-key').value.trim(),secretAccessKey:document.getElementById('s3-secret-key').value||undefined});
s.className=`status-msg ${d.success?'success':'error'}`; s.textContent=d.success?'✅ S3 configuration saved':`${d.error}`; if(d.success)loadS3Config();
} catch(e){s.className='status-msg error';s.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 s=document.getElementById('relay-status');
s.className='status-msg loading'; s.textContent='🔍 Testing relay…';
try {
const d=await api('GET','/api/udp/relay/health');
if(d.healthy){s.className='status-msg success';s.textContent=`✅ Relay online — UDP port ${d.udpPort||'?'}`;setRelayDot('green','Relay online');}
else{s.className='status-msg error';s.textContent=`${d.error||'Not responding'}`;setRelayDot('red','Offline');}
} catch(e){s.className='status-msg error';s.textContent=`${e.message}`;setRelayDot('red','Error');}
}
async function saveRelay() {
const s=document.getElementById('relay-status');
s.className='status-msg loading'; s.textContent='💾 Saving…';
try {
const d=await api('PUT','/api/relay/config',{relayUrl:document.getElementById('relay-url').value.trim(),udpPort:parseInt(document.getElementById('relay-udp-port').value)});
s.className=`status-msg ${d.success?'success':'error'}`; s.textContent=d.success?'✅ Relay configuration saved':`${d.error}`;
} catch(e){s.className='status-msg error';s.textContent=`${e.message}`;}
}
function setRelayDot(c,t){document.getElementById('relay-dot').className=`relay-dot ${c}`;document.getElementById('relay-status-text').textContent=t;}
// ============================================================
// AMPP CONFIG ADMIN
// ============================================================
async function loadAmppConfig() {
try {
const d=await api('GET','/api/ampp/config'); if(!d.success)return;
document.getElementById('ampp-base-url').value=d.config.baseUrl||'';
const hint=document.getElementById('ampp-key-hint');
hint.textContent=d.config.apiKeyExists?'API key is saved (leave blank to keep)':'No API key saved yet';
} catch(_){}
}
async function testAmpp() {
const s=document.getElementById('ampp-status');
s.className='status-msg loading'; s.textContent='🔍 Testing AMPP connection…';
try {
const body={baseUrl:document.getElementById('ampp-base-url').value.trim()};
const k=document.getElementById('ampp-api-key').value.trim();
if(k) body.apiKey=k;
const d=await api('POST','/api/ampp/test',body);
s.className=`status-msg ${d.success?'success':'error'}`; s.textContent=d.success?`${d.message}`:`${d.error}`;
} catch(e){s.className='status-msg error';s.textContent=`${e.message}`;}
}
async function saveAmpp() {
const s=document.getElementById('ampp-status');
s.className='status-msg loading'; s.textContent='💾 Saving…';
try {
const body={baseUrl:document.getElementById('ampp-base-url').value.trim()};
const k=document.getElementById('ampp-api-key').value.trim();
if(k) body.apiKey=k;
const d=await api('PUT','/api/ampp/config',body);
s.className=`status-msg ${d.success?'success':'error'}`; s.textContent=d.success?'✅ AMPP configuration saved':`${d.error}`;
if(d.success){document.getElementById('ampp-api-key').value='';loadAmppConfig();}
} catch(e){s.className='status-msg error';s.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>${esc(u.username)}</strong></td><td><span class="role-badge ${u.role}">${u.role}</span></td><td style="color:var(--text-dim);font-size:.73rem;font-family:'JetBrains Mono',monospace">${u.created?new Date(u.created).toLocaleDateString():''}</td><td>${u.username!==currentUser?`<button class="btn-danger" style="padding:.22rem .55rem;font-size:.68rem" onclick="deleteUser('${esc(u.username)}')">Delete</button>`:'<span style="color:var(--text-dim);font-size:.73rem">(you)</span>'}</td>`;
tbody.appendChild(tr);
});
} catch(_){}
}
async function createUser() {
const s=document.getElementById('user-status');
s.className='status-msg loading'; s.textContent='Creating…';
try {
const d=await api('POST','/api/users',{username:document.getElementById('new-username').value.trim(),password:document.getElementById('new-user-pass').value,role:document.getElementById('new-user-role').value});
s.className=`status-msg ${d.success?'success':'error'}`; s.textContent=d.success?`✅ User created`:`${d.error}`;
if(d.success){document.getElementById('new-username').value='';document.getElementById('new-user-pass').value='';loadUsers();}
} catch(e){s.className='status-msg error';s.textContent=`${e.message}`;}
}
async function deleteUser(u) {
if(!confirm(`Delete user "${u}"?`))return;
try{await api('DELETE',`/api/users/${encodeURIComponent(u)}`);showToast(`User "${u}" 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 fp=[...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:.82rem">${n.children.length?'📁':'📄'}</span><span style="font-family:'JetBrains Mono',monospace;font-size:.78rem;flex:1">${esc(n.name)}</span><button class="btn-danger" style="padding:.18rem .48rem;font-size:.66rem" onclick="deleteFolder(${JSON.stringify(fp)})">Delete</button></div>`;
container.appendChild(div);
if(n.children.length){const sub=document.createElement('div');renderAdminFolderTree(n.children,sub,fp);container.appendChild(sub);}
});
}
async function adminAddFolder() {
const input=document.getElementById('admin-new-folder'); const name=input.value.trim();
if(!name)return; const s=document.getElementById('folder-status');
try{await api('POST','/api/folders/add',{path:[],name});input.value='';s.className='status-msg success';s.textContent=`✅ Folder "${name}" added`;await loadAdminFolders();await loadFolders();}
catch(e){s.className='status-msg error';s.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 esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function fmtSize(b){if(b<1024)return`${b} B`;if(b<1048576)return`${(b/1024).toFixed(1)} KB`;if(b<1073741824)return`${(b/1048576).toFixed(1)} MB`;return`${(b/1073741824).toFixed(2)} GB`;}
function getFileIcon(name){const ext=name.split('.').pop().toLowerCase();if(['mp4','mov','mxf','mkv','avi','r3d','braw','mts','m2ts','prores'].includes(ext))return'🎬';if(['mp3','wav','aac','flac','aiff','m4a'].includes(ext))return'🎵';if(['jpg','jpeg','png','tiff','exr','dpx','arw','cr2','dng','psd'].includes(ext))return'🖼️';return'📄';}
</script>
</body>
</html>