DragonWind/public/index.html
Zac Gaetano 6947e25230 fix: restore uploader + add chunked multipart for large files
Reverts accidental landing page overwrite. Files >32MB now use S3
multipart upload with 32MB chunks and 6 parallel streams per file.
Files <=32MB still use fast direct presigned PUT.
2026-04-09 21:32:06 -04:00

1800 lines
100 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{display:none}
.login-wordmark{display:none}
.login-vpm-text{font-family:'Outfit',sans-serif;font-size:2.4rem;font-weight:800;letter-spacing:-.04em;color:var(--text-primary);line-height:1}
.login-vpm-sub{font-size:.62rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.18em;margin-top:.3rem}
.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{display:none}
.header-wordmark{display:none}
.header-vpm-text{font-family:'Outfit',sans-serif;font-size:1.25rem;font-weight:800;letter-spacing:-.03em;color:var(--text-primary);flex-shrink:0}
[data-theme="light"] .header{background:rgba(240,242,247,.9)}
.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)}
/* FOOTER */
.app-footer{text-align:center;padding:2rem 2rem 1.5rem;border-top:1px solid var(--border);margin-top:3rem}
.app-footer-text{font-size:.68rem;color:var(--text-dim);line-height:1.8;letter-spacing:.02em}
.app-footer-text strong{color:var(--text-secondary);font-weight:600}
.app-footer-divider{display:inline-block;margin:0 .5rem;opacity:.35}
/* 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 TREE SELECTOR */
.folder-tree-box{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;overflow:hidden;margin-bottom:.55rem}
.folder-tree-row{display:flex;align-items:center;gap:.5rem;padding:.42rem .75rem;cursor:pointer;transition:background .12s;user-select:none;border-bottom:1px solid var(--border)}
.folder-tree-row:last-child{border-bottom:none}
.folder-tree-row:hover{background:var(--bg-card-hover)}
.folder-tree-row.active{background:var(--accent-glow);border-left:3px solid var(--blue-bright)}
.folder-tree-row.active .ftr-name{color:var(--blue-bright)}
.folder-tree-row .ftr-icon{font-size:.78rem;flex-shrink:0;width:16px;text-align:center}
.folder-tree-row .ftr-name{font-family:'JetBrains Mono',monospace;font-size:.76rem;color:var(--text-secondary);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.folder-tree-row .ftr-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:.4;transition:all .15s;flex-shrink:0}
.folder-tree-row .ftr-rm:hover{opacity:1;background:var(--error-bg);color:var(--error)}
.folder-tree-children{padding-left:1.2rem}
/* keep add-row / chip styles for admin panel reuse */
.tree-chip{display:none}.tree-chip-none{display:none}.tree-root-bar{display:none}
.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)}
.dl-item{display:flex;align-items:center;gap:8px;padding:8px 10px;color:var(--text-primary);text-decoration:none;border-radius:6px;font-size:.85rem;font-weight:600;white-space:nowrap}.dl-item:hover{background:var(--bg-hover)}.dl-item span{margin-left:auto;color:var(--text-dim);font-size:.75rem}
</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">
<div class="login-vpm-text">VPM</div>
<div class="login-vpm-sub">Broadcast Upload Portal</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">
<span class="header-vpm-text">VPM</span>
<span class="header-product-tag">Upload Portal</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;margin-bottom:1.25rem">
<div style="display:flex;gap:.5rem;margin-bottom:.75rem">
<button class="mode-btn active-http" id="btn-http" onclick="setMode('http')">🌐 HTTP Mode</button>
<div style="position:relative;display:inline-block"><button class="mode-btn" id="btn-udp" onclick="toggleDlMenu(event)">🖥️ Electron App &#9660;</button><div id="dl-menu" style="display:none;position:absolute;top:110%;left:0;background:var(--bg-card);border:1px solid var(--border-bright);border-radius:8px;padding:6px;z-index:100;min-width:210px;box-shadow:0 8px 24px rgba(0,0,0,.4)"><a class="dl-item" href="https://github.com/Acsriot94/dragon-wind-desktop/releases/download/v0.1.0/Dragon.Wind.Setup.0.1.0.exe">&#x1FA9F; Windows <span>.exe</span></a><a class="dl-item" href="https://github.com/Acsriot94/dragon-wind-desktop/releases/download/v0.1.0/Dragon.Wind-0.1.0-arm64.dmg">&#x1F34E; macOS <span>.dmg</span></a></div></div>
</div>
<div class="mode-desc" id="mode-desc" style="margin-bottom:0">
<strong id="mode-label">HTTP Mode:</strong> <span id="mode-detail">Direct-to-S3 upload via presigned URLs. Browser uploads straight to storage, bypassing the server.</span>
</div>
</div>
<div style="margin-bottom:1.5rem">
<div class="section-title">Destination Folder</div>
<input class="form-input" id="folder-search" placeholder="🔍 Search folders…" oninput="renderFolderTree()" style="margin-bottom:.4rem;font-size:.78rem;padding:.4rem .65rem"/>
<div class="folder-tree-box" id="folder-tree-box" style="max-height:320px;overflow-y:auto"></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="ampp" onclick="switchAdminTab('ampp')">AMPP</div>
<div class="admin-tab" data-tab="users" onclick="switchAdminTab('users')">Users</div>
<div class="admin-tab" data-tab="sharelinks" onclick="switchAdminTab('sharelinks')">🔗 Share Links</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" value="us-east-1" autocomplete="off"/></div>
<div class="form-group"><label class="form-label">Bucket Name</label><input class="form-input" id="s3-bucket" type="text" placeholder="mybucket" autocomplete="off"/></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" spellcheck="false"/></div>
<div class="form-group">
<label class="form-label">Secret Access Key</label>
<input class="form-input" id="s3-secret-key" type="password" placeholder="Enter new secret — leave blank to keep existing" autocomplete="off" readonly onfocus="this.removeAttribute('readonly')"/>
<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" style="display:none!important">
<div class="section-title">UDP Relay Configuration</div>
<div style="background:linear-gradient(135deg,rgba(255,180,0,.08),rgba(255,120,0,.08));border:1px solid rgba(255,180,0,.3);border-radius:10px;padding:1.2rem 1.4rem;margin-bottom:1.5rem">
<div style="font-size:1rem;font-weight:700;color:var(--dragon-bright);margin-bottom:.4rem">⚡ Coming Soon — Electron Desktop Uploader</div>
<p style="color:var(--text-secondary);margin:0;line-height:1.6;font-size:.88rem">UDP relay mode is being replaced by a native <strong style="color:var(--text-primary)">Electron desktop app</strong> that delivers Aspera-class transfer speeds over a direct connection — no browser limitations, no port forwarding, and significantly higher throughput. Configuration below is reserved for the upcoming release.</p>
</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">Internal Relay URL</label><input class="form-input" id="relay-url" type="url" placeholder="http://dragon-wind-relay:3001"/><div class="form-hint">Internal URL the server uses to reach the relay container (Docker service name or localhost)</div></div>
<div class="form-group"><label class="form-label">Public Relay URL <span style="font-weight:400;text-transform:none;letter-spacing:0;color:var(--text-dim)">(what browsers connect to)</span></label><input class="form-input" id="relay-public-url" type="url" placeholder="http://vpm.broadcastmgmt.cloud:3001"/><div class="form-hint">The externally reachable URL for the relay — sent to uploaders' browsers. Must be reachable on port 3001 from the internet.</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="Enter API key — leave blank to keep existing" autocomplete="off" readonly onfocus="this.removeAttribute('readonly')"/>
<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-cfg-status"></div>
</div>
<!-- Extension (hidden) -->
<div class="admin-panel" id="admin-extension" style="display:none!important">
<div class="section-title">Chrome Extension</div>
<p style="color:var(--text-secondary);margin-bottom:1.5rem;line-height:1.6">The Chrome extension is required to use fast UDP uploads. Install it once in Chrome — no other setup needed on your end.</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>Navigate to <code>chrome://extensions</code> or open Chrome 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> 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>Sign in</strong>
<p>Click the extension icon in Chrome's toolbar, enter this portal's URL, and log in. UDP mode will now be available on the upload page.</p>
</div>
</div>
</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>Quota</th><th>Created</th><th>Actions</th></tr></thead><tbody id="user-tbody"></tbody></table>
<!-- Permissions modal -->
<div id="perm-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:500;align-items:center;justify-content:center">
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:2rem;width:480px;max-width:95vw;max-height:90vh;overflow-y:auto">
<div class="section-title" id="perm-modal-title">Permissions</div>
<div class="form-group" style="margin-top:1rem">
<label class="form-label">Upload Quota (MB) <span style="font-weight:400;text-transform:none;letter-spacing:0;color:var(--text-dim)">— 0 = unlimited</span></label>
<input class="form-input" id="perm-quota" type="number" min="0" placeholder="0"/>
<div class="form-hint" id="perm-quota-used"></div>
</div>
<div class="form-group">
<label class="form-label">Allowed Folders <span style="font-weight:400;text-transform:none;letter-spacing:0;color:var(--text-dim)">— none checked = all folders allowed</span></label>
<div id="perm-folder-list" style="margin-top:.5rem;display:flex;flex-direction:column;gap:.35rem;max-height:200px;overflow-y:auto;padding:.5rem;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px"></div>
</div>
<div class="btn-row">
<button class="btn-primary" onclick="savePermissions()">💾 Save</button>
<button class="btn-secondary" onclick="document.getElementById('perm-modal').style.display='none'">Cancel</button>
<button class="btn-danger" onclick="resetQuota()" id="perm-reset-btn">Reset Usage</button>
</div>
<div class="status-msg" id="perm-status"></div>
</div>
</div>
<!-- User Audit modal -->
<div id="audit-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:500;align-items:center;justify-content:center">
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:2rem;width:520px;max-width:95vw;max-height:90vh;overflow-y:auto">
<div class="section-title" id="audit-modal-title">User Audit</div>
<div id="audit-content" style="margin-top:1rem"></div>
<div class="btn-row" style="margin-top:1.5rem">
<button class="btn-secondary" onclick="document.getElementById('audit-modal').style.display='none'">Close</button>
</div>
</div>
</div>
</div>
<!-- Share Links -->
<div class="admin-panel" id="admin-sharelinks">
<div class="section-title">Share Links</div>
<p style="color:var(--text-secondary);font-size:.82rem;margin-bottom:1.5rem;line-height:1.6">Generate a shareable upload link for external users. No account needed — just send the link and they can upload directly to a specified folder.</p>
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:12px;padding:1.25rem;margin-bottom:1.5rem">
<div class="section-title" style="font-size:.75rem;margin-bottom:1rem">Create New Link</div>
<div class="form-group"><label class="form-label">Label</label><input class="form-input" id="sl-label" placeholder="e.g. Client Delivery — Episode 4"/></div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Destination Folder</label>
<select class="form-input" id="sl-folder" style="cursor:pointer"><option value="">— Root (no folder) —</option></select>
</div>
<div class="form-group">
<label class="form-label">Expires In</label>
<select class="form-input" id="sl-expiry" style="cursor:pointer">
<option value="">Never</option>
<option value="1">1 hour</option>
<option value="24">24 hours</option>
<option value="72">3 days</option>
<option value="168">7 days</option>
<option value="720">30 days</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Max Uses <span style="font-weight:400;text-transform:none;letter-spacing:0;color:var(--text-dim)">— 0 = unlimited</span></label>
<input class="form-input" id="sl-maxuses" type="number" min="0" placeholder="0"/>
</div>
<button class="btn-primary" onclick="createShareLink()">🔗 Generate Link</button>
<div class="status-msg" id="sl-create-status"></div>
</div>
<div class="section-title" style="font-size:.75rem;margin-bottom:.75rem">Active Links</div>
<div id="sl-list"><div style="color:var(--text-dim);font-size:.82rem">Loading…</div></div>
</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>
<!-- FOOTER -->
<footer class="app-footer">
<div class="app-footer-text">
Built by <strong>Zac Gaetano</strong>
<span class="app-footer-divider">·</span>
In partnership with <strong>Broadcast Management Group</strong>
</div>
</footer>
<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 systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const savedTheme = localStorage.getItem('dw_theme') || (systemDark ? 'dark' : 'light');
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' ? '🌙' : '☀️';
// Track system changes if user hasn't manually set a preference
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
if (!localStorage.getItem('dw_theme')) {
const t = e.matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', t);
document.getElementById('theme-btn').textContent = t === '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(); loadShareLinks(); populateSlFolderSelect();
}
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
// ============================================================
let amppRefreshTimer = null;
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}`));
// Clear AMPP auto-refresh when leaving monitor page
if (amppRefreshTimer) { clearInterval(amppRefreshTimer); amppRefreshTimer = null; }
if (name === 'monitor') {
loadAmppJobs();
amppRefreshTimer = setInterval(loadAmppJobs, 30000);
}
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();
if (name === 'sharelinks') { loadShareLinks(); populateSlFolderSelect(); }
}
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;
const btn = document.getElementById('upload-btn');
const label = document.getElementById('mode-label');
const detail = document.getElementById('mode-detail');
const hint = document.getElementById('udp-ext-hint');
const btnHttp = document.getElementById('btn-http');
const btnUdp = document.getElementById('btn-udp');
if (mode === 'http') {
btn.className = 'btn-upload';
if (label) label.textContent = 'HTTP Mode:';
if (detail) detail.textContent = 'Direct-to-S3 upload via presigned URLs. Browser uploads straight to storage, bypassing the server.';
if (hint) hint.style.display = 'none';
if (btnHttp) { btnHttp.className = 'mode-btn active-http'; }
if (btnUdp) { btnUdp.className = 'mode-btn'; }
} else {
btn.className = 'btn-upload udp';
if (label) label.textContent = 'UDP Mode:';
if (detail) detail.textContent = 'Fast relay-accelerated transfer via the Chrome extension.';
if (hint) hint.style.display = 'none';
if (btnHttp) { btnHttp.className = 'mode-btn'; }
if (btnUdp) { btnUdp.className = 'mode-btn active-udp'; }
}
updateUploadBtn();
}
function checkUdpExtension() {
// Extension injects a flag on the page when active
const extAvailable = !!(window.__dragonWindExtension || window.__dwExt);
const hint = document.getElementById('udp-ext-hint');
if (hint && extAvailable && uploadMode === 'http') hint.style.display = 'inline';
}
// ============================================================
// FOLDERS
// ============================================================
async function loadFolders() {
try {
const d = await api('GET','/api/folders');
folderTree = d.tree || [];
// Auto-select VPM as default folder if nothing is selected yet
if (!selectedPrefix && folderTree.some(n => n.name === 'VPM')) {
selectedPrefix = 'VPM';
updatePrefixDisplay();
}
renderFolderTree();
} catch(e) { console.error('loadFolders:',e); }
}
function renderFolderTree() {
const box = document.getElementById('folder-tree-box');
if (!box) return;
box.innerHTML = '';
const searchEl = document.getElementById('folder-search');
const filter = (searchEl ? searchEl.value.trim().toLowerCase() : '');
// Helper: does a node (or any descendant) match the filter?
function matchesFilter(node, pathArr) {
const key = [...pathArr, node.name].join('/');
if (key.toLowerCase().includes(filter)) return true;
if (node.children) return node.children.some(c => matchesFilter(c, [...pathArr, node.name]));
return false;
}
// Root row (always shown unless filtering)
if (!filter) {
const rootRow = document.createElement('div');
rootRow.className = 'folder-tree-row' + (selectedPrefix==='' ? ' active' : '');
rootRow.innerHTML = `<span class="ftr-icon">🏠</span><span class="ftr-name" style="font-style:${selectedPrefix===''?'normal':'italic'};color:${selectedPrefix===''?'':'var(--text-dim)'}">Root (no folder)</span>`;
rootRow.onclick = () => { selectedPrefix=''; updatePrefixDisplay(); renderFolderTree(); };
box.appendChild(rootRow);
}
function addRows(nodes, pathArr, container) {
const sorted = [...nodes].sort((a, b) => a.name.localeCompare(b.name, undefined, {sensitivity: 'base'}));
sorted.forEach(n => {
if (filter && !matchesFilter(n, pathArr)) return;
const fullPath = [...pathArr, n.name];
const key = fullPath.join('/');
const indent = pathArr.length;
const row = document.createElement('div');
row.className = 'folder-tree-row' + (selectedPrefix===key ? ' active' : '');
row.style.paddingLeft = (0.75 + indent * 1.2) + 'rem';
const icon = n.children && n.children.length ? '📁' : '📄';
row.innerHTML = `<span class="ftr-icon">${icon}</span><span class="ftr-name">${esc(n.name)}</span>`;
if (currentRole === 'admin') {
const rm = document.createElement('button');
rm.className = 'ftr-rm'; rm.textContent = '×';
rm.title = 'Delete folder';
rm.onclick = e => { e.stopPropagation(); deleteFolder(fullPath); };
row.appendChild(rm);
}
row.onclick = e => { if (e.target.classList.contains('ftr-rm')) return; selectedPrefix=key; updatePrefixDisplay(); renderFolderTree(); };
container.appendChild(row);
if (n.children && n.children.length) addRows(n.children, fullPath, container);
});
}
addRows(folderTree, [], box);
if (!box.children.length) {
box.innerHTML = '<div style="color:var(--text-dim);font-size:.8rem;padding:.75rem;text-align:center">No folders match your search</div>';
}
}
// Legacy aliases so other code still works
function renderPrefixChips() { renderFolderTree(); }
function renderTree() {}
function toggleTree() {}
function updatePrefixDisplay() { document.getElementById('prefix-display').textContent = selectedPrefix || '(root)'; }
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(); await loadAdminFolders(); 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();
}
// ============================================================
// PRESIGNED DIRECT-TO-S3 UPLOAD
// Browser uploads directly to RustFS/S3 via presigned PUT URLs.
// Server only generates signed URLs — file data never touches Node.
// Falls back to server-proxied upload if presigned fails.
// ============================================================
const UPLOAD_CONCURRENCY = 6; // concurrent file uploads
const CHUNK_SIZE = 32 * 1024 * 1024; // 32 MB per chunk
const CHUNKS_PARALLEL = 6; // concurrent chunks per file
async function uploadFilePresigned(item, idx) {
const pb = document.getElementById(`progbar-${idx}`);
const mime = item.file.type || 'application/octet-stream';
const totalParts = Math.max(1, Math.ceil(item.file.size / CHUNK_SIZE));
// Small files (<= 32 MB): use direct presigned PUT (fastest path)
if (totalParts === 1) {
setFileStatus(idx, 'uploading', 'Getting URL\u2026');
const pre = await api('POST', '/api/presigned', {
filename: item.name, prefix: selectedPrefix, contentType: mime, size: item.file.size,
});
if (!pre.success) throw new Error(pre.error || 'Failed to get presigned URL');
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const pct = Math.round(e.loaded / e.total * 100);
if (pb) pb.style.width = pct + '%';
setFileStatus(idx, 'uploading', pct + '%');
}
});
xhr.addEventListener('load', async () => {
if (xhr.status >= 200 && xhr.status < 300) {
try { await api('POST', '/api/presigned/complete', { key: pre.key, size: item.file.size }); } catch(_) {}
resolve({ key: pre.key });
} else {
reject(new Error(`S3 returned ${xhr.status}: ${xhr.statusText}`));
}
});
xhr.addEventListener('error', () => reject(new Error('Direct upload failed \u2014 network error')));
xhr.addEventListener('abort', () => reject(new Error('Upload aborted')));
xhr.open('PUT', pre.url);
xhr.setRequestHeader('Content-Type', mime);
xhr.send(item.file);
});
}
// Large files (> 32 MB): chunked multipart upload
setFileStatus(idx, 'uploading', 'Initiating\u2026');
const init = await api('POST', '/api/upload/initiate', {
filename: item.name, prefix: selectedPrefix, contentType: mime, totalParts,
});
if (!init.success) throw new Error(init.error || 'Failed to initiate upload');
const { uploadId, key } = init;
let uploaded = 0;
const chunkQueue = [];
for (let i = 1; i <= totalParts; i++) chunkQueue.push(i);
let chunkError = null;
async function chunkWorker() {
while (chunkQueue.length > 0 && !chunkError) {
const partNum = chunkQueue.shift();
if (!partNum) break;
const start = (partNum - 1) * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, item.file.size);
const blob = item.file.slice(start, end);
const fd = new FormData();
fd.append('uploadId', uploadId);
fd.append('partNumber', String(partNum));
fd.append('chunk', blob);
try {
const resp = await fetch('/api/upload/chunk', {
method: 'POST', headers: { 'x-auth-token': authToken }, body: fd,
}).then(r => r.json());
if (!resp.success) throw new Error(resp.error || 'Chunk ' + partNum + ' failed');
uploaded += (end - start);
const pct = Math.round(uploaded / item.file.size * 100);
if (pb) pb.style.width = pct + '%';
setFileStatus(idx, 'uploading', pct + '%');
} catch (e) { chunkError = e; }
}
}
const workers = [];
for (let w = 0; w < Math.min(CHUNKS_PARALLEL, totalParts); w++) workers.push(chunkWorker());
await Promise.all(workers);
if (chunkError) {
try { await api('POST', '/api/upload/abort', { uploadId }); } catch(_) {}
throw chunkError;
}
setFileStatus(idx, 'uploading', 'Finalizing\u2026');
const complete = await api('POST', '/api/upload/complete', { uploadId });
if (!complete.success) throw new Error(complete.error || 'Failed to complete upload');
return { key };
}
// Fallback: upload through Node server (for when presigned fails)
async function uploadFileViaServer(item, idx) {
return new Promise((resolve, reject) => {
const fd = new FormData();
fd.append('prefix', selectedPrefix);
fd.append('files', item.file, item.name);
const xhr = new XMLHttpRequest();
const pb = document.getElementById(`progbar-${idx}`);
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const pct = Math.round(e.loaded / e.total * 100);
if (pb) pb.style.width = pct + '%';
setFileStatus(idx, 'uploading', `${pct}% (server)`);
}
});
xhr.addEventListener('load', () => {
try {
const data = JSON.parse(xhr.responseText);
if (xhr.status >= 200 && xhr.status < 300 && data.success) resolve(data);
else reject(new Error(data.error || `Server returned ${xhr.status}`));
} catch (e) { reject(new Error(`Bad response: ${xhr.status}`)); }
});
xhr.addEventListener('error', () => reject(new Error('Network error')));
xhr.addEventListener('abort', () => reject(new Error('Upload aborted')));
xhr.open('POST', '/api/upload');
xhr.setRequestHeader('x-auth-token', authToken);
xhr.send(fd);
});
}
async function uploadHTTP(files) {
const fileQueue = [...files];
async function fileWorker() {
while (fileQueue.length) {
const item = fileQueue.shift();
if (!item) break;
const idx = selectedFiles.indexOf(item);
setFileStatus(idx, 'uploading', 'Starting…');
document.getElementById(`prog-${idx}`).style.display = 'block';
try {
// Try presigned direct-to-S3 first
await uploadFilePresigned(item, idx);
document.getElementById(`progbar-${idx}`).style.width = '100%';
setFileStatus(idx, 'done', '✓ Done'); item.status = 'done';
showToast(`Uploaded: ${item.name}`, 'success');
} catch(presignedErr) {
// Fallback to server-proxied upload
console.warn(`[upload] Presigned failed for ${item.name}: ${presignedErr.message}, falling back to server upload`);
try {
setFileStatus(idx, 'uploading', 'Retrying via server…');
if (document.getElementById(`progbar-${idx}`)) document.getElementById(`progbar-${idx}`).style.width = '0%';
await uploadFileViaServer(item, idx);
document.getElementById(`progbar-${idx}`).style.width = '100%';
setFileStatus(idx, 'done', '✓ Done (server)'); item.status = 'done';
showToast(`Uploaded: ${item.name}`, 'success');
} catch(serverErr) {
setFileStatus(idx, 'error', '✗ Error'); item.status = 'error';
showToast(`Failed: ${item.name}${serverErr.message}`, 'error');
}
}
updateStats();
}
}
await Promise.all(Array.from({length: Math.min(UPLOAD_CONCURRENCY, files.length)}, fileWorker));
}
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…';
list.innerHTML='';
try {
const d = await api('GET','/api/ampp/jobs?limit=50');
status.className=''; status.textContent='';
if (!d.success) {
// Check for "not configured" specifically — show a helpful prompt
if (d.error && d.error.toLowerCase().includes('not configured')) {
list.innerHTML=`<div style="text-align:center;padding:2rem 1rem">
<div style="font-size:2rem;margin-bottom:.75rem">📡</div>
<div style="font-weight:600;color:var(--text-primary);margin-bottom:.4rem">AMPP not configured</div>
<div style="font-size:.82rem;color:var(--text-dim);margin-bottom:1rem">Enter your AMPP base URL and API key in Admin settings to enable job monitoring.</div>
<button class="btn-secondary" style="font-size:.8rem" onclick="switchPage('admin');switchAdminTab('ampp')">⚙️ Go to Admin → AMPP</button>
</div>`;
} else {
status.className='status-msg error'; status.textContent=`${d.error||'Failed to load jobs'}`;
}
return;
}
// AMPP returns { items: [...], total: N } or an array directly
const jobs=Array.isArray(d.jobs)?d.jobs:(d.jobs?.items||d.jobs?.results||[]);
if (!jobs.length) {
list.innerHTML='<div style="color:var(--text-dim);text-align:center;padding:2rem;font-size:.85rem">No jobs in queue</div>';
return;
}
// Log first job's keys for debugging field names
if (jobs.length) console.log('[AMPP] Sample job keys:', Object.keys(jobs[0]), 'Full:', JSON.stringify(jobs[0]).substring(0, 500));
jobs.forEach(job => {
const el=document.createElement('div'); el.className='job-item';
// AMPP API uses colon-namespaced keys e.g. "state:jobState", "name:text", "job:id"
const st=(job['state:jobState']||job.status||job.state||job.jobStatus||'unknown').toLowerCase();
const cls=st.includes('run')||st.includes('active')?'running':st.includes('complet')||st.includes('success')||st.includes('done')?'completed':st.includes('fail')||st.includes('error')||st.includes('abort')?'failed':st.includes('queue')||st.includes('wait')||st.includes('pend')?'queued':'unknown';
// Try many possible asset name fields — AMPP responses vary by job type
const name=job['name:text']||job['assetName:text']||job['source:text']||job['sourceFile:text']||job['inputFile:text']||job['input:text']||job.name||job.displayName||job.assetName||job.sourceName||job.sourceFile||job.inputFile||job['job:id']||job.id||'Job';
const jobType=(job['type:jobType']||job['subtype:jobSubtype']||job.type||job.jobType||'').replace(/([A-Z])/g,' $1').trim();
const creator=job['creator:id']||job.creator||'';
const created=job['created:dateTime']||job.created||'';
const meta=[created?new Date(created).toLocaleString():'', jobType, creator].filter(Boolean).join(' · ');
el.innerHTML=`<div class="job-dot ${cls}"></div><div class="job-info"><div class="job-name">${esc(name)}</div><div class="job-meta">${esc(meta)}</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||'';
// Clear secret field and show status — never pre-fill passwords
const secretEl=document.getElementById('s3-secret-key');
secretEl.value='';
secretEl.placeholder=d.config.secretKeyExists?'Leave blank to keep existing secret':'Enter secret access key';
document.getElementById('s3-secret-hint').textContent=d.config.secretKeyExists?'🔒 Secret key is saved.':'⚠️ No secret saved yet.';
} 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-public-url').value=d.config.publicRelayUrl||'';
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(),publicRelayUrl:document.getElementById('relay-public-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-cfg-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-cfg-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 quotaLabel = u.quotaMB
? `${fmtBytes(u.uploadedBytes||0)} / ${u.quotaMB} MB`
: '<span style="color:var(--text-dim)">unlimited</span>';
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="font-size:.73rem;font-family:'JetBrains Mono',monospace">${quotaLabel}</td>
<td style="color:var(--text-dim);font-size:.73rem;font-family:'JetBrains Mono',monospace">${u.created?new Date(u.created).toLocaleDateString():''}</td>
<td style="display:flex;gap:.35rem;flex-wrap:wrap">
<button class="btn-secondary" style="padding:.22rem .55rem;font-size:.68rem" onclick="openPermissions('${esc(u.username)}')">⚙ Perms</button>
<button class="btn-secondary" style="padding:.22rem .55rem;font-size:.68rem" onclick="openUserAudit('${esc(u.username)}')">👁 Audit</button>
${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');}
}
// ============================================================
// PERMISSIONS MODAL
// ============================================================
let permCurrentUser = null;
async function openPermissions(username) {
permCurrentUser = username;
const modal = document.getElementById('perm-modal');
modal.style.display = 'flex';
document.getElementById('perm-modal-title').textContent = `Permissions — ${username}`;
document.getElementById('perm-status').className = 'status-msg';
document.getElementById('perm-status').textContent = '';
document.getElementById('perm-quota-used').textContent = 'Loading…';
document.getElementById('perm-folder-list').innerHTML = '<div style="color:var(--text-dim);font-size:.78rem">Loading…</div>';
try {
const [pd, fd] = await Promise.all([
api('GET', `/api/users/${encodeURIComponent(username)}/permissions`),
api('GET', '/api/folders')
]);
if (!pd.success) throw new Error(pd.error);
document.getElementById('perm-quota').value = pd.quotaMB || 0;
const usedMB = pd.uploadedBytes ? (pd.uploadedBytes / 1048576).toFixed(1) : '0';
const usedLabel = pd.quotaMB
? `Used: ${fmtBytes(pd.uploadedBytes||0)} of ${pd.quotaMB} MB`
: `Used: ${fmtBytes(pd.uploadedBytes||0)} (no limit)`;
document.getElementById('perm-quota-used').textContent = usedLabel;
const allFolders = flattenFolders(fd.tree || []);
const allowed = pd.allowedFolders || [];
const list = document.getElementById('perm-folder-list');
if (!allFolders.length) {
list.innerHTML = '<div style="color:var(--text-dim);font-size:.78rem">No folders configured yet.</div>';
} else {
list.innerHTML = allFolders.map(f => `
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:.8rem;padding:.15rem 0">
<input type="checkbox" value="${esc(f)}" ${allowed.includes(f)?'checked':''} style="accent-color:var(--accent-bright);width:14px;height:14px"/>
<span style="font-family:'JetBrains Mono',monospace">${esc(f)}</span>
</label>`).join('');
}
} catch(e) {
document.getElementById('perm-folder-list').innerHTML = `<div style="color:var(--error);font-size:.78rem">Error: ${e.message}</div>`;
}
}
function flattenFolders(nodes, prefix='') {
const out = [];
for (const n of nodes) {
const full = prefix ? `${prefix}--${n.name}` : n.name;
out.push(full);
if (n.children && n.children.length) out.push(...flattenFolders(n.children, full));
}
return out;
}
async function savePermissions() {
const s = document.getElementById('perm-status');
s.className = 'status-msg loading'; s.textContent = 'Saving…';
const quota = parseInt(document.getElementById('perm-quota').value) || 0;
const checked = [...document.querySelectorAll('#perm-folder-list input[type=checkbox]:checked')].map(c => c.value);
try {
const d = await api('PUT', `/api/users/${encodeURIComponent(permCurrentUser)}/permissions`, { quotaMB: quota, allowedFolders: checked });
s.className = `status-msg ${d.success?'success':'error'}`;
s.textContent = d.success ? '✅ Saved' : `${d.error}`;
if (d.success) loadUsers();
} catch(e) { s.className='status-msg error'; s.textContent=`${e.message}`; }
}
async function resetQuota() {
if (!confirm(`Reset upload usage counter for "${permCurrentUser}"?`)) return;
const s = document.getElementById('perm-status');
s.className = 'status-msg loading'; s.textContent = 'Resetting…';
try {
const d = await api('POST', `/api/users/${encodeURIComponent(permCurrentUser)}/quota/reset`);
s.className = `status-msg ${d.success?'success':'error'}`;
s.textContent = d.success ? '✅ Usage reset to 0' : `${d.error}`;
if (d.success) { document.getElementById('perm-quota-used').textContent = 'Used: 0 B'; loadUsers(); }
} catch(e) { s.className='status-msg error'; s.textContent=`${e.message}`; }
}
function fmtBytes(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';
}
// ============================================================
// USER AUDIT
// ============================================================
async function openUserAudit(username) {
const modal = document.getElementById('audit-modal');
modal.style.display = 'flex';
document.getElementById('audit-modal-title').textContent = `Audit — ${username}`;
const content = document.getElementById('audit-content');
content.innerHTML = '<div style="color:var(--text-dim);font-size:.82rem">Loading…</div>';
try {
const [pd, fd] = await Promise.all([
api('GET', `/api/users/${encodeURIComponent(username)}/permissions`),
api('GET', '/api/folders')
]);
if (!pd.success) throw new Error(pd.error);
const allFolders = flattenFolders(fd.tree || []);
const allowed = pd.allowedFolders || [];
const hasRestrictions = allowed.length > 0;
const visibleFolders = hasRestrictions ? allFolders.filter(f => allowed.some(a => f === a || f.startsWith(a + '--'))) : allFolders;
let html = '';
// Role & Quota
html += `<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem;margin-bottom:1.25rem">`;
html += `<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:.75rem">
<div style="font-size:.68rem;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:.25rem">Role</div>
<div style="font-size:.88rem;font-weight:600"><span class="role-badge ${pd.role||'user'}">${pd.role||'user'}</span></div>
</div>`;
html += `<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:.75rem">
<div style="font-size:.68rem;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:.25rem">Upload Quota</div>
<div style="font-size:.88rem;font-weight:600">${pd.quotaMB ? `${fmtBytes(pd.uploadedBytes||0)} / ${pd.quotaMB} MB` : 'Unlimited'}</div>
</div>`;
html += `</div>`;
// Visible pages
const pages = ['Upload'];
if (pd.role === 'admin') pages.push('AMPP Monitor', 'Admin Panel');
else pages.push('AMPP Monitor');
html += `<div style="margin-bottom:1.25rem">
<div style="font-size:.72rem;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:.5rem">Visible Pages</div>
<div style="display:flex;gap:.4rem;flex-wrap:wrap">${pages.map(p => `<span style="background:var(--accent-glow);color:var(--blue-bright);padding:.2rem .6rem;border-radius:6px;font-size:.75rem;font-weight:500">${p}</span>`).join('')}</div>
</div>`;
// Admin features
if (pd.role === 'admin') {
html += `<div style="margin-bottom:1.25rem">
<div style="font-size:.72rem;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:.5rem">Admin Capabilities</div>
<div style="font-size:.8rem;color:var(--text-secondary);line-height:1.6">
S3 Storage settings, AMPP config, User management, Share Links, Folder management, Add/delete folders on upload page
</div>
</div>`;
}
// Folder access
html += `<div>
<div style="font-size:.72rem;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:.5rem">Folder Access ${hasRestrictions ? '<span style="color:var(--warning)">(restricted)</span>' : '<span style="color:var(--success)">(all folders)</span>'}</div>
<div style="max-height:200px;overflow-y:auto;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:.5rem">`;
if (!visibleFolders.length) {
html += '<div style="color:var(--text-dim);font-size:.78rem;padding:.25rem">No folders configured</div>';
} else {
visibleFolders.forEach(f => {
const depth = (f.match(/--/g) || []).length;
const name = f.includes('--') ? f.split('--').pop() : f;
const isAllowed = !hasRestrictions || allowed.includes(f);
html += `<div style="padding:.2rem .4rem .2rem ${.4 + depth * 1}rem;font-family:'JetBrains Mono',monospace;font-size:.75rem;color:${isAllowed ? 'var(--text-secondary)' : 'var(--text-dim)'};display:flex;align-items:center;gap:.4rem">
<span>${isAllowed ? '✅' : '🚫'}</span> ${esc(name)}
</div>`;
});
}
html += `</div></div>`;
content.innerHTML = html;
} catch(e) {
content.innerHTML = `<div style="color:var(--error);font-size:.82rem">Error: ${e.message}</div>`;
}
}
// ============================================================
// SHARE LINKS
// ============================================================
async function loadShareLinks() {
const list = document.getElementById('sl-list');
if (!list) return;
try {
const d = await api('GET', '/api/sharelinks');
if (!d.success) { list.innerHTML=`<div style="color:var(--error);font-size:.82rem">❌ ${d.error}</div>`; return; }
if (!d.links.length) { list.innerHTML='<div style="color:var(--text-dim);font-size:.82rem">No share links yet.</div>'; return; }
list.innerHTML = d.links.map(l => {
const url = `${location.origin}/share/${l.token}`;
const exp = l.expiresAt ? new Date(l.expiresAt).toLocaleString() : 'Never';
const uses = l.maxUses ? `${l.uses}/${l.maxUses}` : `${l.uses} uses`;
const expired = l.expiresAt && new Date(l.expiresAt) < new Date();
return `<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:10px;padding:.9rem 1rem;margin-bottom:.6rem${expired?';opacity:.5':''}">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:.5rem;flex-wrap:wrap">
<div>
<div style="font-weight:600;font-size:.85rem">${esc(l.label||'(no label)')}</div>
${l.folder?`<div style="font-size:.72rem;color:var(--accent-bright);font-family:'JetBrains Mono',monospace;margin-top:.1rem">📁 ${esc(l.folder)}</div>`:''}
<div style="font-size:.7rem;color:var(--text-dim);margin-top:.25rem">Expires: ${exp} &nbsp;·&nbsp; Uses: ${uses}${expired?' &nbsp;<span style="color:var(--error)">EXPIRED</span>':''}</div>
</div>
<div style="display:flex;gap:.35rem;flex-shrink:0">
<button class="btn-secondary" style="padding:.22rem .55rem;font-size:.68rem" onclick="copyShareLink('${l.token}')">📋 Copy</button>
<button class="btn-danger" style="padding:.22rem .55rem;font-size:.68rem" onclick="deleteShareLink('${l.token}')">Delete</button>
</div>
</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:.67rem;color:var(--text-dim);margin-top:.5rem;word-break:break-all">${url}</div>
</div>`;
}).join('');
} catch(e) { list.innerHTML=`<div style="color:var(--error);font-size:.82rem">❌ ${e.message}</div>`; }
}
async function createShareLink() {
const s = document.getElementById('sl-create-status');
s.className = 'status-msg loading'; s.textContent = 'Creating…';
const body = {
label: document.getElementById('sl-label').value.trim(),
folder: document.getElementById('sl-folder').value,
expiresInHours: document.getElementById('sl-expiry').value || null,
maxUses: parseInt(document.getElementById('sl-maxuses').value) || 0
};
try {
const d = await api('POST', '/api/sharelinks', body);
if (!d.success) { s.className='status-msg error'; s.textContent=`${d.error}`; return; }
const url = `${location.origin}/share/${d.link.token}`;
s.className = 'status-msg success';
s.innerHTML = `✅ Link created! <br><span style="font-family:'JetBrains Mono',monospace;font-size:.75rem;word-break:break-all">${url}</span> <button class="btn-secondary" style="padding:.18rem .45rem;font-size:.68rem;margin-left:.5rem" onclick="navigator.clipboard.writeText('${url}').then(()=>showToast('Copied!','success'))">📋 Copy</button>`;
document.getElementById('sl-label').value = '';
document.getElementById('sl-folder').value = '';
document.getElementById('sl-expiry').value = '';
document.getElementById('sl-maxuses').value = '';
loadShareLinks();
} catch(e) { s.className='status-msg error'; s.textContent=`${e.message}`; }
}
async function deleteShareLink(token) {
if (!confirm('Delete this share link? It will immediately stop working.')) return;
try {
const d = await api('DELETE', `/api/sharelinks/${token}`);
if (d.success) { showToast('Share link deleted','success'); loadShareLinks(); }
else showToast(d.error,'error');
} catch(e) { showToast(e.message,'error'); }
}
function copyShareLink(token) {
const url = `${location.origin}/share/${token}`;
navigator.clipboard.writeText(url).then(()=>showToast('Link copied to clipboard!','success')).catch(()=>showToast('Could not copy','error'));
}
async function populateSlFolderSelect() {
const sel = document.getElementById('sl-folder');
if (!sel) return;
try {
const d = await api('GET', '/api/folders');
const folders = flattenFolders(d.tree || []);
sel.innerHTML = '<option value="">— Root (no folder) —</option>' + folders.map(f=>`<option value="${esc(f)}">${esc(f)}</option>`).join('');
} catch(_){}
}
// ============================================================
// 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;}
const sorted=[...nodes].sort((a,b)=>a.name.localeCompare(b.name,undefined,{sensitivity:'base'}));
sorted.forEach(n=>{
const fp=[...pathArr,n.name];
const div=document.createElement('div');div.style.paddingLeft=pathArr.length*20+'px';div.style.marginBottom='.3rem';
const row=document.createElement('div');
row.style.cssText='display:flex;align-items:center;gap:.5rem;padding:.3rem .5rem;border:1px solid var(--border);border-radius:7px;background:var(--bg-card)';
row.innerHTML=`<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>`;
const delBtn=document.createElement('button');
delBtn.className='btn-danger';delBtn.style.cssText='padding:.18rem .48rem;font-size:.66rem';delBtn.textContent='Delete';
delBtn.onclick=()=>{ deleteFolder(fp).then(()=>{loadAdminFolders();}); };
row.appendChild(delBtn);
div.appendChild(row);
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'📄';}
function toggleDlMenu(e){e.stopPropagation();var m=document.getElementById("dl-menu");m.style.display=m.style.display==="none"?"block":"none";}document.addEventListener("click",function(){var m=document.getElementById("dl-menu");if(m)m.style.display="none";});
</script>
</body>
</html>