DragonWind/public/index.html
2026-04-05 21:25:46 -04:00

1244 lines
69 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 — not used, dragon icon only */
.splash-wordmark{ display:none; }
@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{display:none}
.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{display:none}
[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;user-select:none;-webkit-user-select:none}
.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;flex-wrap:wrap}
.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;user-select:none;-webkit-user-select:none}
.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}
/* EXTENSION INSTALL GUIDE */
.ext-steps{display:flex;flex-direction:column;gap:.88rem;margin-bottom:1.5rem}
.ext-step{display:flex;gap:1rem;align-items:flex-start}
.ext-step-num{flex-shrink:0;width:28px;height:28px;border-radius:50%;background:var(--blue);color:#fff;font-size:.78rem;font-weight:700;display:flex;align-items:center;justify-content:center;margin-top:.1rem}
.ext-step-body{flex:1}
.ext-step-body strong{display:block;font-size:.88rem;color:var(--text-primary);margin-bottom:.25rem}
.ext-step-body p{font-size:.82rem;color:var(--text-secondary);margin:0;line-height:1.55}
.ext-step-body code{background:var(--bg-secondary);border:1px solid var(--border);border-radius:4px;padding:.1rem .4rem;font-family:'Courier New',monospace;font-size:.8rem;color:var(--blue-bright)}
.ext-note{background:var(--bg-secondary);border:1px solid var(--border);border-left:3px solid var(--dragon);border-radius:8px;padding:1rem 1.1rem;font-size:.82rem;color:var(--text-secondary);line-height:1.6}
/* 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" data-tab="s3" onclick="switchAdminTab('s3')">S3 Storage</div>
<div class="admin-tab" data-tab="relay" onclick="switchAdminTab('relay')">UDP Relay</div>
<div class="admin-tab" data-tab="ampp" onclick="switchAdminTab('ampp')">AMPP</div>
<div class="admin-tab" data-tab="extension" onclick="switchAdminTab('extension')">🧩 Extension</div>
<div class="admin-tab" data-tab="users" onclick="switchAdminTab('users')">Users</div>
<div class="admin-tab" data-tab="folders" 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 <span style="font-weight:400;text-transform:none;letter-spacing:0;color:var(--text-dim)">(optional — leave blank for AWS S3)</span></label>
<input class="form-input" id="s3-endpoint" type="url" placeholder="https://s3.example.com — or blank for AWS"/>
<div class="form-hint">For MinIO, Backblaze, Cloudflare R2, Wasabi, etc. enter the full endpoint URL. Leave blank to use standard AWS S3.</div>
</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>
<!-- Extension -->
<div class="admin-panel" id="admin-extension">
<div class="section-title">Chrome Extension — UDP Upload</div>
<p style="color:var(--text-secondary);margin-bottom:1.5rem;line-height:1.6">The Dragon Wind Chrome extension enables UDP-accelerated uploads directly from your browser. Download the extension package below, then follow the steps to install it.</p>
<button class="btn-primary" style="margin-bottom:2rem" onclick="downloadExtension()">⬇️ Download Extension (.zip)</button>
<div class="section-title" style="font-size:.8rem;margin-bottom:1rem">Installation Steps</div>
<div class="ext-steps">
<div class="ext-step">
<div class="ext-step-num">1</div>
<div class="ext-step-body">
<strong>Unzip the downloaded file</strong>
<p>Extract <code>dragon-wind-extension.zip</code> to a permanent folder on your computer — Chrome needs this folder to stay in place.</p>
</div>
</div>
<div class="ext-step">
<div class="ext-step-num">2</div>
<div class="ext-step-body">
<strong>Open Chrome Extensions</strong>
<p>In Chrome, navigate to <code>chrome://extensions</code> or open the menu → More Tools → Extensions.</p>
</div>
</div>
<div class="ext-step">
<div class="ext-step-num">3</div>
<div class="ext-step-body">
<strong>Enable Developer Mode</strong>
<p>Toggle the <strong>Developer mode</strong> switch in the top-right corner of the Extensions page.</p>
</div>
</div>
<div class="ext-step">
<div class="ext-step-num">4</div>
<div class="ext-step-body">
<strong>Load the extension</strong>
<p>Click <strong>Load unpacked</strong>, then navigate to and select the <code>dragon-wind-extension</code> folder you unzipped in step 1.</p>
</div>
</div>
<div class="ext-step">
<div class="ext-step-num">5</div>
<div class="ext-step-body">
<strong>Configure the extension</strong>
<p>Click the 🐉 Dragon Wind icon in Chrome's toolbar. Enter this server's URL and log in with your credentials. UDP uploads will now be available from any page.</p>
</div>
</div>
</div>
<div class="ext-note">
<strong>📡 UDP Port Forwarding</strong><br>
For uploads from outside your local network, make sure your UDP relay port is forwarded in your router. You can find your assigned UDP port in the <strong>.env</strong> file (<code>RELAY_UDP_PORT</code>) or check the UDP Relay tab above.
</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 => {
t.classList.toggle('active', t.dataset.tab === 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();
}
async function downloadExtension() {
const btn = event.target;
btn.disabled = true; btn.textContent = '⏳ Preparing download…';
try {
const res = await fetch('/api/extension/download', { headers: { 'x-auth-token': authToken || '' } });
if (!res.ok) throw new Error(`Server error ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'dragon-wind-extension.zip'; a.click();
URL.revokeObjectURL(url);
btn.textContent = '✅ Downloaded!';
setTimeout(() => { btn.disabled = false; btn.textContent = '⬇️ Download Extension (.zip)'; }, 3000);
} catch(e) {
btn.disabled = false; btn.textContent = '⬇️ Download Extension (.zip)';
showToast('Download failed: ' + e.message, 'error');
}
}
// ============================================================
// 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>