2026-04-05 20:05:34 -04:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" / >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" / >
2026-04-05 20:19:57 -04:00
< title > Dragon Wind — Wild Dragon< / title >
< link rel = "icon" href = "/dragon-icon.png" type = "image/png" / >
2026-04-05 20:05:34 -04:00
< 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}
2026-04-05 20:19:57 -04:00
2026-04-05 20:05:34 -04:00
:root{
--bg-primary:#06080e;--bg-secondary:#0c1019;--bg-card:#10141f;--bg-card-hover:#151a28;
--border:#1a2035;--border-bright:#2a3555;
2026-04-05 20:19:57 -04:00
--accent:#1a3fc7;--accent-bright:#2b5cff;--accent-glow:rgba(43,92,255,.15);
2026-04-05 20:05:34 -04:00
--dragon:#e05c1a;--dragon-bright:#ff7d3b;--dragon-glow:rgba(224,92,26,.15);
2026-04-05 20:19:57 -04:00
--blue:#1e4bd8;--blue-bright:#3060ff;
2026-04-05 20:05:34 -04:00
--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"]{
2026-04-05 20:19:57 -04:00
--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);
2026-04-05 20:05:34 -04:00
--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}
2026-04-05 20:19:57 -04:00
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)}
}
2026-04-05 20:05:34 -04:00
2026-04-05 20:19:57 -04:00
/* 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)}
}
2026-04-05 20:42:21 -04:00
/* Wild Dragon wordmark — not used, dragon icon only */
.splash-wordmark{ display:none; }
2026-04-05 20:19:57 -04:00
@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
============================================================ */
2026-04-05 20:05:34 -04:00
.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}
2026-04-05 20:19:57 -04:00
.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}
2026-04-06 20:26:51 -04:00
.login-dragon-icon{display:none}
2026-04-05 20:42:21 -04:00
.login-wordmark{display:none}
2026-04-06 20:26:51 -04:00
.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}
2026-04-05 20:19:57 -04:00
.login-product{font-size:.65rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.18em;margin-top:.25rem}
2026-04-05 20:05:34 -04:00
.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}
2026-04-05 20:19:57 -04:00
.login-field:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(30,75,216,.15)}
2026-04-05 20:05:34 -04:00
.login-field::placeholder{color:var(--text-dim)}
2026-04-05 20:19:57 -04:00
.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)}
2026-04-05 20:05:34 -04:00
.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}
2026-04-05 20:19:57 -04:00
/* ============================================================
APP SHELL
============================================================ */
2026-04-05 20:05:34 -04:00
.app{position:relative;z-index:1}
.app.hidden{display:none}
2026-04-05 20:19:57 -04:00
/* 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}
2026-04-06 20:26:51 -04:00
.header-dragon{display:none}
2026-04-05 20:42:21 -04:00
.header-wordmark{display:none}
2026-04-06 20:26:51 -04:00
.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}
2026-04-05 20:19:57 -04:00
[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}
2026-04-05 20:05:34 -04:00
.btn-logout:hover{border-color:var(--error);color:var(--error)}
2026-04-05 20:19:57 -04:00
.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)}
2026-04-05 20:05:34 -04:00
2026-04-06 19:41:07 -04:00
/* 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}
2026-04-05 20:05:34 -04:00
/* NAV TABS */
2026-04-05 20:19:57 -04:00
.nav-tabs{display:flex;padding:0 2rem;border-bottom:1px solid var(--border);background:var(--bg-secondary)}
2026-04-05 20:42:21 -04:00
.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}
2026-04-05 20:05:34 -04:00
.nav-tab:hover{color:var(--text-secondary)}
2026-04-05 20:19:57 -04:00
.nav-tab.active{color:var(--blue-bright);border-bottom-color:var(--blue-bright)}
2026-04-05 20:05:34 -04:00
.nav-tab.admin-tab.active{color:var(--warning);border-bottom-color:var(--warning)}
2026-04-05 20:19:57 -04:00
/* PAGES */
.page{display:none;max-width:760px;margin:0 auto;padding:2rem 2rem}
2026-04-05 20:05:34 -04:00
.page.active{display:block}
2026-04-05 20:19:57 -04:00
/* 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}
2026-04-05 20:05:34 -04:00
2026-04-05 20:19:57 -04:00
/* 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}
2026-04-05 20:05:34 -04:00
.mode-btn:hover{border-color:var(--border-bright);background:var(--bg-card-hover)}
2026-04-05 20:19:57 -04:00
.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}
2026-04-05 20:05:34 -04:00
2026-04-06 20:26:51 -04:00
/* 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}
2026-04-05 20:05:34 -04:00
.add-row{display:flex;gap:.4rem;align-items:center}
2026-04-05 20:19:57 -04:00
.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)}
2026-04-05 20:05:34 -04:00
.add-input::placeholder{color:var(--text-dim)}
2026-04-05 20:19:57 -04:00
.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}
2026-04-05 20:05:34 -04:00
.tree-toggle:hover{border-color:var(--border-bright);color:var(--text-primary)}
2026-04-05 20:19:57 -04:00
.tree-toggle .arrow{transition:transform .2s;display:inline-block;font-size:.55rem}
2026-04-05 20:05:34 -04:00
.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}
2026-04-05 20:19:57 -04:00
.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}
2026-04-05 20:05:34 -04:00
/* DROP ZONE */
2026-04-05 20:19:57 -04:00
.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)}
2026-04-05 20:05:34 -04:00
#file-input{display:none}
/* FILE LIST */
2026-04-05 20:19:57 -04:00
.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}
2026-04-05 20:05:34 -04:00
.file-item:hover{border-color:var(--border-bright)}
2026-04-05 20:19:57 -04:00
.file-icon{font-size:1.05rem;flex-shrink:0}
2026-04-05 20:05:34 -04:00
.file-info{flex:1;min-width:0}
2026-04-05 20:19:57 -04:00
.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}
2026-04-05 20:05:34 -04:00
.file-status.pending{color:var(--text-dim)}
2026-04-05 20:19:57 -04:00
.file-status.uploading{color:var(--blue-bright)}
2026-04-05 20:05:34 -04:00
.file-status.done{color:var(--success)}
.file-status.error{color:var(--error)}
2026-04-05 20:19:57 -04:00
.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}
2026-04-05 20:05:34 -04:00
.file-remove:hover{background:var(--error-bg);color:var(--error)}
2026-04-05 20:19:57 -04:00
.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}
2026-04-05 20:05:34 -04:00
.file-progress-bar.udp{background:linear-gradient(90deg,var(--dragon),var(--dragon-bright))}
/* UPLOAD BUTTON */
2026-04-05 20:19:57 -04:00
.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)}
2026-04-05 20:05:34 -04:00
.btn-upload:disabled{opacity:.4;cursor:not-allowed;transform:none;box-shadow:none}
2026-04-05 20:19:57 -04:00
.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)}
2026-04-05 20:05:34 -04:00
2026-04-05 20:19:57 -04:00
/* 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}
2026-04-05 20:05:34 -04:00
.stat-value{font-size:1.4rem;font-weight:700;color:var(--text-primary);font-family:'JetBrains Mono',monospace}
2026-04-05 20:19:57 -04:00
.stat-label{font-size:.65rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.09em;margin-top:.2rem}
2026-04-05 20:05:34 -04:00
/* AMPP MONITOR */
2026-04-05 20:19:57 -04:00
.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}
2026-04-05 20:05:34 -04:00
.job-item:hover{border-color:var(--border-bright)}
2026-04-05 20:19:57 -04:00
.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}
2026-04-05 20:05:34 -04:00
.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}
2026-04-05 20:19:57 -04:00
.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)}
2026-04-05 20:05:34 -04:00
.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)}
2026-04-05 20:19:57 -04:00
.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}
2026-04-05 20:05:34 -04:00
.refresh-btn:hover{border-color:var(--border-bright);color:var(--text-primary)}
2026-04-05 20:19:57 -04:00
/* ADMIN */
2026-04-05 20:42:21 -04:00
.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}
2026-04-05 20:05:34 -04:00
.admin-tab:hover{color:var(--text-secondary)}
2026-04-05 20:19:57 -04:00
.admin-tab.active{color:var(--blue-bright);border-bottom-color:var(--blue-bright)}
2026-04-05 20:05:34 -04:00
.admin-panel{display:none}
.admin-panel.active{display:block}
2026-04-05 20:33:56 -04:00
/* 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}
2026-04-05 20:05:34 -04:00
/* FORMS */
.form-group{margin-bottom:1rem}
2026-04-05 20:19:57 -04:00
.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)}
2026-04-05 20:05:34 -04:00
.form-input::placeholder{color:var(--text-dim)}
.form-input[type="password"]{letter-spacing:.1em}
2026-04-05 20:19:57 -04:00
.form-hint{font-size:.68rem;color:var(--text-dim);margin-top:.28rem}
2026-04-05 20:05:34 -04:00
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:.75rem}
2026-04-05 20:19:57 -04:00
.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)}
2026-04-05 20:05:34 -04:00
.btn-primary:disabled{opacity:.4;cursor:not-allowed;transform:none}
2026-04-05 20:19:57 -04:00
.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}
2026-04-05 20:05:34 -04:00
.btn-secondary:hover{border-color:var(--border-bright);color:var(--text-primary)}
2026-04-05 20:19:57 -04:00
.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}
2026-04-05 20:05:34 -04:00
.btn-danger:hover{background:var(--error-bg)}
2026-04-05 20:19:57 -04:00
/* STATUS */
.status-msg{padding:.65rem .95rem;border-radius:8px;font-size:.8rem;font-weight:600;margin-top:.75rem;display:none}
2026-04-05 20:05:34 -04:00
.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}
2026-04-05 20:19:57 -04:00
.status-msg.loading{background:var(--accent-glow);color:var(--blue-bright);border:1px solid rgba(30,75,216,.2);display:block}
2026-04-05 20:05:34 -04:00
/* USER TABLE */
.user-table{width:100%;border-collapse:collapse}
2026-04-05 20:19:57 -04:00
.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}
2026-04-05 20:05:34 -04:00
.user-table tr:hover td{background:var(--bg-card-hover)}
2026-04-05 20:19:57 -04:00
.role-badge{display:inline-flex;padding:.18rem .52rem;border-radius:5px;font-size:.66rem;font-weight:700;text-transform:uppercase}
2026-04-05 20:05:34 -04:00
.role-badge.admin{background:var(--dragon-glow);color:var(--dragon-bright)}
2026-04-05 20:19:57 -04:00
.role-badge.user{background:var(--accent-glow);color:var(--blue-bright)}
2026-04-05 20:05:34 -04:00
/* RELAY STATUS */
2026-04-05 20:19:57 -04:00
.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}
2026-04-05 20:05:34 -04:00
.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)}
2026-04-05 20:19:57 -04:00
/* 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}
2026-04-05 20:05:34 -04:00
.toast.show{opacity:1;transform:translateY(0)}
2026-04-05 20:19:57 -04:00
.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)}
2026-04-05 20:05:34 -04:00
< / style >
< / head >
< body >
2026-04-05 20:19:57 -04:00
<!-- ============================================================
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 · 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 · wilddragon.net< / div >
< / div >
<!-- ============================================================
LOGIN
============================================================ -->
< div class = "login-screen hidden" id = "login-screen" >
2026-04-05 20:05:34 -04:00
< div class = "login-box" >
2026-04-05 20:19:57 -04:00
< div class = "login-icon-wrap" >
2026-04-06 20:26:51 -04:00
< div class = "login-vpm-text" > VPM< / div >
< div class = "login-vpm-sub" > Broadcast Upload Portal< / div >
2026-04-05 20:19:57 -04:00
< / div >
2026-04-05 20:05:34 -04:00
< 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 >
2026-04-05 20:19:57 -04:00
<!-- ============================================================
APP
============================================================ -->
2026-04-05 20:05:34 -04:00
< div class = "app hidden" id = "app" >
<!-- HEADER -->
< div class = "header" >
< div class = "header-left" >
2026-04-06 20:26:51 -04:00
< span class = "header-vpm-text" > VPM< / span >
< span class = "header-product-tag" > Upload Portal< / span >
2026-04-05 20:05:34 -04:00
< / div >
< div class = "header-right" >
< span class = "header-user" id = "header-user" > < / span >
2026-04-05 20:19:57 -04:00
< button class = "theme-toggle" id = "theme-btn" onclick = "toggleTheme()" > 🌙< / button >
2026-04-05 20:05:34 -04:00
< button class = "btn-logout" onclick = "doLogout()" > Sign Out< / button >
< / div >
< / div >
2026-04-05 20:19:57 -04:00
<!-- NAV TABS -->
< div class = "nav-tabs" >
2026-04-05 20:05:34 -04:00
< 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 >
2026-04-05 20:19:57 -04:00
< div class = "nav-tab admin-tab admin-only hidden" data-page = "admin" onclick = "switchPage('admin')" > ⚙️ Admin< / div >
2026-04-05 20:05:34 -04:00
< / div >
<!-- UPLOAD PAGE -->
< div class = "page active" id = "page-upload" >
2026-04-06 20:32:25 -04:00
< div style = "margin-top:1.5rem;margin-bottom:1.25rem" >
< div class = "mode-desc" id = "mode-desc" style = "margin-bottom:0" >
< strong id = "mode-label" > HTTP Mode:< / strong > < span id = "mode-detail" > Direct S3 presigned upload. Best for LAN and stable connections.< / span >
< span id = "udp-ext-hint" style = "display:none;margin-left:.5rem;color:var(--dragon-bright);font-size:.75rem" > ⚡ UDP extension detected — < a href = "#" onclick = "setMode('udp');return false" style = "color:inherit" > switch to UDP< / a > < / span >
2026-04-05 20:05:34 -04:00
< / div >
< / div >
2026-04-05 20:19:57 -04:00
< div style = "margin-bottom:1.5rem" >
2026-04-05 20:05:34 -04:00
< div class = "section-title" > Destination Folder< / div >
2026-04-06 20:26:51 -04:00
< div class = "folder-tree-box" id = "folder-tree-box" > < / div >
2026-04-05 20:19:57 -04:00
< div class = "add-row" style = "margin-top:.55rem" id = "add-folder-row" >
2026-04-05 20:05:34 -04:00
< 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 >
2026-04-05 20:19:57 -04:00
< 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 >
2026-04-05 20:05:34 -04:00
< / 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 >
2026-04-05 20:19:57 -04:00
< button class = "btn-upload" id = "upload-btn" onclick = "startUpload()" disabled > Upload Files< / button >
2026-04-05 20:05:34 -04:00
2026-04-05 20:19:57 -04:00
< div class = "upload-stats" >
2026-04-05 20:05:34 -04:00
< 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 >
2026-04-05 20:19:57 -04:00
< / div >
2026-04-05 20:05:34 -04:00
<!-- 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" >
2026-04-05 20:19:57 -04:00
< div style = "color:var(--text-dim);font-size:.85rem;text-align:center;padding:2rem" > Loading…< / div >
2026-04-05 20:05:34 -04:00
< / div >
< / div >
<!-- ADMIN PAGE -->
< div class = "page" id = "page-admin" >
< div style = "margin-top:1.5rem" > < / div >
< div class = "admin-tabs" >
2026-04-05 20:42:21 -04:00
< 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 = "extension" onclick = "switchAdminTab('extension')" > 🧩 Extension< / div >
< div class = "admin-tab" data-tab = "users" onclick = "switchAdminTab('users')" > Users< / div >
2026-04-06 19:58:29 -04:00
< div class = "admin-tab" data-tab = "sharelinks" onclick = "switchAdminTab('sharelinks')" > 🔗 Share Links< / div >
2026-04-05 20:42:21 -04:00
< div class = "admin-tab" data-tab = "folders" onclick = "switchAdminTab('folders')" > Folders< / div >
2026-04-05 20:05:34 -04:00
< / div >
2026-04-05 20:19:57 -04:00
<!-- S3 -->
2026-04-05 20:05:34 -04:00
< div class = "admin-panel active" id = "admin-s3" >
< div class = "section-title" > S3 Storage Configuration< / div >
2026-04-05 20:42:21 -04:00
< 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 >
2026-04-05 20:05:34 -04:00
< div class = "form-row" >
2026-04-05 20:19:57 -04:00
< div class = "form-group" > < label class = "form-label" > Region< / label > < input class = "form-input" id = "s3-region" type = "text" placeholder = "us-east-1" / > < / div >
< div class = "form-group" > < label class = "form-label" > Bucket Name< / label > < input class = "form-input" id = "s3-bucket" type = "text" placeholder = "mybucket" / > < / div >
2026-04-05 20:05:34 -04:00
< / div >
2026-04-05 20:19:57 -04:00
< div class = "form-group" > < label class = "form-label" > Access Key ID< / label > < input class = "form-input" id = "s3-access-key" type = "text" autocomplete = "off" / > < / div >
2026-04-05 20:05:34 -04:00
< div class = "form-group" >
< label class = "form-label" > Secret Access Key< / label >
2026-04-05 20:19:57 -04:00
< input class = "form-input" id = "s3-secret-key" type = "password" placeholder = "Leave blank to keep existing" autocomplete = "new-password" / >
2026-04-05 20:05:34 -04:00
< 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 >
2026-04-05 20:19:57 -04:00
<!-- Relay -->
2026-04-05 20:05:34 -04:00
< div class = "admin-panel" id = "admin-relay" >
< div class = "section-title" > UDP Relay Configuration< / div >
2026-04-05 20:19:57 -04:00
< div class = "relay-status-indicator" > < div class = "relay-dot grey" id = "relay-dot" > < / div > < span id = "relay-status-text" > Not checked< / span > < / div >
< div class = "form-group" > < label class = "form-label" > Relay Server URL< / label > < input class = "form-input" id = "relay-url" type = "url" placeholder = "https://relay.yourdomain.com" / > < div class = "form-hint" > Base URL of your Dragon Wind UDP Relay container< / div > < / div >
< div class = "form-group" > < label class = "form-label" > UDP Data Port< / label > < input class = "form-input" id = "relay-udp-port" type = "number" placeholder = "5000" min = "1024" max = "65535" / > < / div >
2026-04-05 20:05:34 -04:00
< 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 >
2026-04-05 20:22:51 -04:00
<!-- AMPP -->
< div class = "admin-panel" id = "admin-ampp" >
< div class = "section-title" > AMPP Configuration< / div >
< div class = "form-group" >
< label class = "form-label" > Base URL< / label >
< input class = "form-input" id = "ampp-base-url" type = "url" placeholder = "https://us-east-1.gvampp.com" / >
< div class = "form-hint" > Your Grass Valley AMPP regional endpoint< / div >
< / div >
< div class = "form-group" >
< label class = "form-label" > API Key (Base64 credentials)< / label >
< input class = "form-input" id = "ampp-api-key" type = "password" placeholder = "Leave blank to keep existing" autocomplete = "new-password" / >
< div class = "form-hint" id = "ampp-key-hint" > < / div >
< / div >
< div class = "btn-row" >
< button class = "btn-secondary" onclick = "testAmpp()" > 🔍 Test Connection< / button >
< button class = "btn-primary" onclick = "saveAmpp()" > 💾 Save Configuration< / button >
< / div >
< div class = "status-msg" id = "ampp-status" > < / div >
< / div >
2026-04-05 20:33:56 -04:00
<!-- Extension -->
< div class = "admin-panel" id = "admin-extension" >
2026-04-06 20:32:25 -04:00
< 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 >
2026-04-05 20:33:56 -04:00
< 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 >
2026-04-06 20:32:25 -04:00
< p > Navigate to < code > chrome://extensions< / code > or open Chrome menu → More Tools → Extensions.< / p >
2026-04-05 20:33:56 -04:00
< / 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 >
2026-04-06 20:32:25 -04:00
< p > Click < strong > Load unpacked< / strong > and select the < code > dragon-wind-extension< / code > folder you unzipped in step 1.< / p >
2026-04-05 20:33:56 -04:00
< / div >
< / div >
< div class = "ext-step" >
< div class = "ext-step-num" > 5< / div >
< div class = "ext-step-body" >
2026-04-06 20:32:25 -04:00
< 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 >
2026-04-05 20:33:56 -04:00
< / div >
< / div >
< / div >
< / div >
2026-04-05 20:19:57 -04:00
<!-- Users -->
2026-04-05 20:05:34 -04:00
< 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 >
2026-04-05 20:19:57 -04:00
< 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 >
2026-04-05 20:05:34 -04:00
< button class = "btn-primary" onclick = "createUser()" > + Create User< / button >
< div class = "status-msg" id = "user-status" > < / div >
< / div >
2026-04-06 19:58:29 -04:00
< 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 >
< / 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 >
2026-04-05 20:05:34 -04:00
< / div >
2026-04-05 20:19:57 -04:00
<!-- Folders -->
2026-04-05 20:05:34 -04:00
< 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" >
2026-04-05 20:19:57 -04:00
< input class = "add-input" id = "admin-new-folder" placeholder = "New top-level folder…" onkeydown = "if(event.key==='Enter')adminAddFolder()" / >
2026-04-05 20:05:34 -04:00
< button class = "btn-small" onclick = "adminAddFolder()" > + Add< / button >
< / div >
< div class = "status-msg" id = "folder-status" > < / div >
< / div >
2026-04-05 20:19:57 -04:00
< / div >
< / div >
2026-04-05 20:05:34 -04:00
< div class = "toast-container" id = "toast-container" > < / div >
2026-04-06 19:41:07 -04:00
<!-- FOOTER -->
< footer class = "app-footer" >
< div class = "app-footer-text" >
Built by < strong > Zac Gaetano< / strong > & < strong > Wild Dragon LLC< / strong >
< span class = "app-footer-divider" > ·< / span >
In partnership with < strong > Broadcast Management Group< / strong >
< / div >
< / footer >
2026-04-05 20:05:34 -04:00
< script >
2026-04-05 20:19:57 -04:00
// ============================================================
// 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);
})();
2026-04-05 20:05:34 -04:00
2026-04-05 20:19:57 -04:00
// ============================================================
// THEME
// ============================================================
2026-04-06 20:26:51 -04:00
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const savedTheme = localStorage.getItem('dw_theme') || (systemDark ? 'dark' : 'light');
2026-04-05 20:05:34 -04:00
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);
2026-04-05 20:19:57 -04:00
document.getElementById('theme-btn').textContent = t === 'dark' ? '🌙' : '☀️';
2026-04-05 20:05:34 -04:00
}
2026-04-05 20:19:57 -04:00
document.getElementById('theme-btn').textContent = savedTheme === 'dark' ? '🌙' : '☀️';
2026-04-06 20:26:51 -04:00
// 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' ? '🌙' : '☀️';
}
});
2026-04-05 20:19:57 -04:00
// ============================================================
// 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 = [];
2026-04-05 20:05:34 -04:00
2026-04-05 20:19:57 -04:00
// ============================================================
// AUTH
// ============================================================
2026-04-05 20:05:34 -04:00
async function doLogin() {
const user = document.getElementById('login-user').value.trim();
const pass = document.getElementById('login-pass').value;
2026-04-05 20:19:57 -04:00
const btn = document.getElementById('login-btn');
const err = document.getElementById('login-error');
2026-04-05 20:05:34 -04:00
if (!user || !pass) { err.textContent = 'Enter username and password'; return; }
btn.disabled = true; btn.textContent = 'Signing in…'; err.textContent = '';
try {
2026-04-05 20:19:57 -04:00
const r = await fetch('/api/login', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({username:user,password:pass}) });
2026-04-05 20:05:34 -04:00
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);
2026-04-05 20:19:57 -04:00
localStorage.setItem('dw_user', currentUser);
localStorage.setItem('dw_role', currentRole);
2026-04-05 20:05:34 -04:00
showApp();
2026-04-05 20:19:57 -04:00
} catch(e) { err.textContent = e.message; }
finally { btn.disabled = false; btn.textContent = 'Sign In'; }
2026-04-05 20:05:34 -04:00
}
async function doLogout() {
2026-04-05 20:19:57 -04:00
try { await api('POST','/api/logout'); } catch(_) {}
authToken = currentUser = currentRole = null;
['dw_token','dw_user','dw_role'].forEach(k => localStorage.removeItem(k));
2026-04-05 20:05:34 -04:00
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'));
2026-04-06 19:58:29 -04:00
loadS3Config(); loadRelayConfig(); loadAmppConfig(); loadUsers(); loadShareLinks(); populateSlFolderSelect();
2026-04-05 20:05:34 -04:00
}
loadFolders();
loadAmppJobs();
}
document.getElementById('login-pass').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
if (authToken) {
2026-04-05 20:19:57 -04:00
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');
2026-04-05 20:05:34 -04:00
}
2026-04-05 20:19:57 -04:00
// ============================================================
// API
// ============================================================
2026-04-05 20:05:34 -04:00
async function api(method, url, body) {
2026-04-05 20:19:57 -04:00
const opts = { method, headers:{'Content-Type':'application/json','x-auth-token': authToken||''} };
2026-04-05 20:05:34 -04:00
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();
}
2026-04-05 20:19:57 -04:00
// ============================================================
// NAVIGATION
// ============================================================
2026-04-05 20:05:34 -04:00
function switchPage(name) {
document.querySelectorAll('.nav-tab').forEach(t => t.classList.toggle('active', t.dataset.page === name));
document.querySelectorAll('.page').forEach(p => p.classList.toggle('active', p.id === `page-${name}`));
if (name === 'monitor') loadAmppJobs();
2026-04-05 20:19:57 -04:00
if (name === 'admin') { loadUsers(); loadAdminFolders(); }
2026-04-05 20:05:34 -04:00
}
function switchAdminTab(name) {
2026-04-05 20:42:21 -04:00
document.querySelectorAll('.admin-tab').forEach(t => {
t.classList.toggle('active', t.dataset.tab === name);
2026-04-05 20:05:34 -04:00
});
document.querySelectorAll('.admin-panel').forEach(p => p.classList.toggle('active', p.id === `admin-${name}`));
2026-04-06 19:58:29 -04:00
if (name === 'users') loadUsers();
if (name === 'folders') loadAdminFolders();
if (name === 'ampp') loadAmppConfig();
if (name === 'sharelinks') { loadShareLinks(); populateSlFolderSelect(); }
2026-04-05 20:05:34 -04:00
}
2026-04-05 20:33:56 -04:00
async function downloadExtension() {
const btn = event.target;
btn.disabled = true; btn.textContent = '⏳ Preparing download…';
try {
2026-04-05 21:25:46 -04:00
const res = await fetch('/api/extension/download', { headers: { 'x-auth-token': authToken || '' } });
2026-04-05 20:33:56 -04:00
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');
}
}
2026-04-05 20:19:57 -04:00
// ============================================================
// UPLOAD MODE
// ============================================================
2026-04-05 20:05:34 -04:00
function setMode(mode) {
uploadMode = mode;
2026-04-06 20:32:25 -04:00
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');
2026-04-05 20:05:34 -04:00
if (mode === 'http') {
btn.className = 'btn-upload';
2026-04-06 20:32:25 -04:00
if (label) label.textContent = 'HTTP Mode:';
if (detail) detail.textContent = 'Direct S3 presigned upload. Best for LAN and stable connections.';
if (hint) hint.style.display = 'none';
2026-04-05 20:19:57 -04:00
} else {
btn.className = 'btn-upload udp';
2026-04-06 20:32:25 -04:00
if (label) label.textContent = 'UDP Mode:';
if (detail) detail.textContent = 'Fast relay-accelerated transfer via the Chrome extension.';
if (hint) hint.style.display = 'none';
2026-04-05 20:05:34 -04:00
}
2026-04-05 20:19:57 -04:00
updateUploadBtn();
2026-04-05 20:05:34 -04:00
}
2026-04-06 20:32:25 -04:00
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';
}
2026-04-05 20:05:34 -04:00
2026-04-05 20:19:57 -04:00
// ============================================================
// FOLDERS
// ============================================================
2026-04-05 20:05:34 -04:00
async function loadFolders() {
try {
2026-04-05 20:19:57 -04:00
const d = await api('GET','/api/folders');
2026-04-05 20:05:34 -04:00
folderTree = d.tree || [];
2026-04-06 20:26:51 -04:00
renderFolderTree();
2026-04-05 20:19:57 -04:00
} catch(e) { console.error('loadFolders:',e); }
2026-04-05 20:05:34 -04:00
}
2026-04-06 20:26:51 -04:00
function renderFolderTree() {
const box = document.getElementById('folder-tree-box');
if (!box) return;
box.innerHTML = '';
// Root row
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) {
2026-04-05 20:05:34 -04:00
nodes.forEach(n => {
const fullPath = [...pathArr, n.name];
const key = fullPath.join('/');
2026-04-06 20:26:51 -04:00
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 > `;
2026-04-05 20:05:34 -04:00
if (currentRole === 'admin') {
const rm = document.createElement('button');
2026-04-06 20:26:51 -04:00
rm.className = 'ftr-rm'; rm.textContent = '× ';
rm.title = 'Delete folder';
2026-04-05 20:19:57 -04:00
rm.onclick = e => { e.stopPropagation(); deleteFolder(fullPath); };
2026-04-06 20:26:51 -04:00
row.appendChild(rm);
2026-04-05 20:05:34 -04:00
}
2026-04-06 20:26:51 -04:00
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);
2026-04-05 20:05:34 -04:00
});
}
2026-04-06 20:26:51 -04:00
addRows(folderTree, [], box);
2026-04-05 20:05:34 -04:00
}
2026-04-06 20:26:51 -04:00
// Legacy aliases so other code still works
function renderPrefixChips() { renderFolderTree(); }
function renderTree() {}
function toggleTree() {}
2026-04-05 20:05:34 -04:00
2026-04-06 20:26:51 -04:00
function updatePrefixDisplay() { document.getElementById('prefix-display').textContent = selectedPrefix || '(root)'; }
2026-04-05 20:05:34 -04:00
async function addFolder() {
const input = document.getElementById('new-folder-input');
2026-04-05 20:19:57 -04:00
const name = input.value.trim();
2026-04-05 20:05:34 -04:00
if (!name) return;
try {
const pathArr = selectedPrefix ? selectedPrefix.split('/') : [];
2026-04-05 20:19:57 -04:00
await api('POST','/api/folders/add',{path:pathArr,name});
input.value = ''; await loadFolders(); showToast('Folder added','success');
} catch(e) { showToast(e.message,'error'); }
2026-04-05 20:05:34 -04:00
}
async function deleteFolder(pathArr) {
2026-04-05 20:19:57 -04:00
if (!confirm(`Delete folder "${pathArr.join('/')}"?`)) return;
2026-04-05 20:05:34 -04:00
try {
2026-04-05 20:19:57 -04:00
await api('POST','/api/folders/delete',{path:pathArr});
if (selectedPrefix.startsWith(pathArr.join('/'))) { selectedPrefix=''; updatePrefixDisplay(); }
await loadFolders(); showToast('Folder deleted','success');
} catch(e) { showToast(e.message,'error'); }
2026-04-05 20:05:34 -04:00
}
2026-04-05 20:19:57 -04:00
// ============================================================
// FILE HANDLING
// ============================================================
2026-04-05 20:05:34 -04:00
function onDragOver(e) { e.preventDefault(); document.getElementById('drop-zone').classList.add('drag-over'); }
function onDragLeave(e) { document.getElementById('drop-zone').classList.remove('drag-over'); }
2026-04-05 20:19:57 -04:00
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=''; }
2026-04-05 20:05:34 -04:00
function addFiles(files) {
2026-04-05 20:19:57 -04:00
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();
2026-04-05 20:05:34 -04:00
}
function renderFileList() {
const list = document.getElementById('file-list');
list.innerHTML = '';
2026-04-05 20:19:57 -04:00
selectedFiles.forEach((item,i) => {
2026-04-05 20:05:34 -04:00
const el = document.createElement('div');
2026-04-05 20:19:57 -04:00
el.className='file-item'; el.id=`file-item-${i}`;
el.innerHTML=`
2026-04-05 20:05:34 -04:00
< span class = "file-icon" > ${getFileIcon(item.name)}< / span >
< div class = "file-info" >
2026-04-05 20:19:57 -04:00
< div class = "file-name" > ${esc(item.name)}< / div >
2026-04-05 20:05:34 -04:00
< div class = "file-size" > ${fmtSize(item.size)}< / div >
2026-04-05 20:19:57 -04:00
< 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 >
2026-04-05 20:05:34 -04:00
< / div >
< span class = "file-status ${item.status}" id = "stat-${i}" > ${item.status}< / span >
2026-04-05 20:19:57 -04:00
< button class = "file-remove" onclick = "removeFile(${i})" > × < / button > `;
2026-04-05 20:05:34 -04:00
list.appendChild(el);
});
}
2026-04-05 20:19:57 -04:00
function removeFile(i) { selectedFiles.splice(i,1); renderFileList(); updateUploadBtn(); updateStats(); }
2026-04-05 20:05:34 -04:00
function updateUploadBtn() {
2026-04-05 20:19:57 -04:00
const n = selectedFiles.filter(f => f.status==='pending').length;
2026-04-05 20:05:34 -04:00
const btn = document.getElementById('upload-btn');
btn.disabled = n === 0;
2026-04-05 20:19:57 -04:00
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');
2026-04-05 20:05:34 -04:00
}
function updateStats() {
2026-04-05 20:19:57 -04:00
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;
2026-04-05 20:05:34 -04:00
}
2026-04-05 20:19:57 -04:00
// ============================================================
// UPLOAD
// ============================================================
2026-04-05 20:05:34 -04:00
async function startUpload() {
2026-04-05 20:19:57 -04:00
const pending = selectedFiles.filter(f => f.status==='pending');
2026-04-05 20:05:34 -04:00
if (!pending.length) return;
document.getElementById('upload-btn').disabled = true;
2026-04-05 20:19:57 -04:00
if (uploadMode==='http') await uploadHTTP(pending);
2026-04-05 20:05:34 -04:00
else await uploadUDP(pending);
2026-04-05 20:19:57 -04:00
updateUploadBtn(); updateStats();
2026-04-05 20:05:34 -04:00
}
async function uploadHTTP(files) {
2026-04-05 20:19:57 -04:00
for (const item of files) {
2026-04-05 20:05:34 -04:00
const idx = selectedFiles.indexOf(item);
2026-04-05 20:19:57 -04:00
setFileStatus(idx,'uploading','Uploading…');
document.getElementById(`prog-${idx}`).style.display='block';
2026-04-05 20:05:34 -04:00
try {
2026-04-05 20:19:57 -04:00
const presigned = await api('POST','/api/presigned',{filename:item.name,prefix:selectedPrefix,contentType:item.file.type||'application/octet-stream'});
if (!presigned.success) throw new Error(presigned.error||'Failed to get presigned URL');
await new Promise((resolve,reject) => {
const xhr=new XMLHttpRequest();
xhr.open('PUT',presigned.url);
xhr.setRequestHeader('Content-Type',item.file.type||'application/octet-stream');
xhr.upload.onprogress=e=>{ if(e.lengthComputable){ const p=Math.round(e.loaded/e.total*100); document.getElementById(`progbar-${idx}`).style.width=p+'%'; setFileStatus(idx,'uploading',p+'%'); } };
xhr.onload=()=>xhr.status< 300 ? resolve ( ) :reject ( new Error ( ` S3 $ { xhr . status } ` ) ) ;
xhr.onerror=()=>reject(new Error('Network error'));
2026-04-05 20:05:34 -04:00
xhr.send(item.file);
});
2026-04-05 20:19:57 -04:00
document.getElementById(`progbar-${idx}`).style.width='100%';
setFileStatus(idx,'done','✓ Done'); item.status='done';
showToast(`Uploaded: ${item.name}`,'success');
} catch(e) { setFileStatus(idx,'error','✗ Error'); item.status='error'; showToast(`Failed: ${item.name} — ${e.message}`,'error'); }
2026-04-05 20:05:34 -04:00
updateStats();
}
}
async function uploadUDP(files) {
for (const item of files) {
const idx = selectedFiles.indexOf(item);
2026-04-05 20:19:57 -04:00
setFileStatus(idx,'uploading','Requesting session…');
document.getElementById(`prog-${idx}`).style.display='block';
const pb = document.getElementById(`progbar-${idx}`); pb.classList.add('udp');
2026-04-05 20:05:34 -04:00
try {
2026-04-05 20:19:57 -04:00
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}%`);
2026-04-05 20:05:34 -04:00
}
2026-04-05 20:19:57 -04:00
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();
2026-04-05 20:05:34 -04:00
}
}
2026-04-05 20:19:57 -04:00
function setFileStatus(i,cls,text) { const el=document.getElementById(`stat-${i}`); if(el){el.className=`file-status ${cls}`;el.textContent=text;} }
2026-04-05 20:05:34 -04:00
2026-04-05 20:19:57 -04:00
// ============================================================
// AMPP
// ============================================================
2026-04-05 20:05:34 -04:00
async function loadAmppJobs() {
2026-04-05 20:19:57 -04:00
const list=document.getElementById('job-list'); const status=document.getElementById('ampp-status');
status.className='status-msg loading'; status.textContent='Loading jobs…';
2026-04-05 20:05:34 -04:00
try {
2026-04-05 20:19:57 -04:00
const d = await api('GET','/api/ampp/jobs?limit=50');
status.className='status-msg'; status.textContent='';
if (!d.success) { status.className='status-msg error'; status.textContent=d.error||'Failed'; return; }
const jobs=d.jobs?.items||d.jobs||[];
if (!jobs.length) { list.innerHTML='< div style = "color:var(--text-dim);text-align:center;padding:2rem;font-size:.85rem" > No jobs found< / div > '; return; }
list.innerHTML='';
2026-04-05 20:05:34 -04:00
jobs.forEach(job => {
2026-04-05 20:19:57 -04:00
const el=document.createElement('div'); el.className='job-item';
const st=(job.status||job.state||'unknown').toLowerCase();
const cls=st.includes('run')?'running':st.includes('complet')||st.includes('success')?'completed':st.includes('fail')||st.includes('error')?'failed':st.includes('queue')||st.includes('wait')?'queued':'unknown';
el.innerHTML=`< div class = "job-dot ${cls}" > < / div > < div class = "job-info" > < div class = "job-name" > ${esc(job.name||job.id||'Job')}< / div > < div class = "job-meta" > ${job.created?new Date(job.created).toLocaleString():''}< / div > < / div > < span class = "job-status ${cls}" > ${cls.charAt(0).toUpperCase()+cls.slice(1)}< / span > `;
2026-04-05 20:05:34 -04:00
list.appendChild(el);
});
2026-04-05 20:19:57 -04:00
} catch(e) { status.className='status-msg error'; status.textContent=e.message; list.innerHTML=''; }
2026-04-05 20:05:34 -04:00
}
2026-04-05 20:19:57 -04:00
// ============================================================
// S3 ADMIN
// ============================================================
2026-04-05 20:05:34 -04:00
async function loadS3Config() {
try {
2026-04-05 20:19:57 -04:00
const d=await api('GET','/api/s3/config'); if(!d.success)return;
document.getElementById('s3-endpoint').value=d.config.endpoint||'';
document.getElementById('s3-region').value=d.config.region||'us-east-1';
document.getElementById('s3-bucket').value=d.config.bucket||'';
document.getElementById('s3-access-key').value=d.config.accessKeyId||'';
document.getElementById('s3-secret-hint').textContent=d.config.secretKeyExists?'🔒 Secret saved — leave blank to keep.':'No secret saved.';
} catch(_) {}
2026-04-05 20:05:34 -04:00
}
async function testS3() {
2026-04-05 20:19:57 -04:00
const s=document.getElementById('s3-status');
s.className='status-msg loading'; s.textContent='🔍 Testing S3 connection…';
2026-04-05 20:05:34 -04:00
try {
2026-04-05 20:19:57 -04:00
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}`;}
2026-04-05 20:05:34 -04:00
}
async function saveS3() {
2026-04-05 20:19:57 -04:00
const s=document.getElementById('s3-status');
s.className='status-msg loading'; s.textContent='💾 Saving…';
2026-04-05 20:05:34 -04:00
try {
2026-04-05 20:19:57 -04:00
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}`;}
2026-04-05 20:05:34 -04:00
}
2026-04-05 20:19:57 -04:00
// ============================================================
// RELAY ADMIN
// ============================================================
2026-04-05 20:05:34 -04:00
async function loadRelayConfig() {
2026-04-05 20:19:57 -04:00
try { const d=await api('GET','/api/relay/config'); if(!d.success)return; document.getElementById('relay-url').value=d.config.relayUrl||''; document.getElementById('relay-udp-port').value=d.config.udpPort||5000; } catch(_){}
2026-04-05 20:05:34 -04:00
}
async function testRelay() {
2026-04-05 20:19:57 -04:00
const s=document.getElementById('relay-status');
s.className='status-msg loading'; s.textContent='🔍 Testing relay…';
2026-04-05 20:05:34 -04:00
try {
2026-04-05 20:19:57 -04:00
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');}
2026-04-05 20:05:34 -04:00
}
async function saveRelay() {
2026-04-05 20:19:57 -04:00
const s=document.getElementById('relay-status');
s.className='status-msg loading'; s.textContent='💾 Saving…';
2026-04-05 20:05:34 -04:00
try {
2026-04-05 20:19:57 -04:00
const d=await api('PUT','/api/relay/config',{relayUrl:document.getElementById('relay-url').value.trim(),udpPort:parseInt(document.getElementById('relay-udp-port').value)});
s.className=`status-msg ${d.success?'success':'error'}`; s.textContent=d.success?'✅ Relay configuration saved':`❌ ${d.error}`;
} catch(e){s.className='status-msg error';s.textContent=`❌ ${e.message}`;}
2026-04-05 20:05:34 -04:00
}
2026-04-05 20:19:57 -04:00
function setRelayDot(c,t){document.getElementById('relay-dot').className=`relay-dot ${c}`;document.getElementById('relay-status-text').textContent=t;}
2026-04-05 20:05:34 -04:00
2026-04-05 20:22:51 -04:00
// ============================================================
// AMPP CONFIG ADMIN
// ============================================================
async function loadAmppConfig() {
try {
const d=await api('GET','/api/ampp/config'); if(!d.success)return;
document.getElementById('ampp-base-url').value=d.config.baseUrl||'';
const hint=document.getElementById('ampp-key-hint');
hint.textContent=d.config.apiKeyExists?'API key is saved (leave blank to keep)':'No API key saved yet';
} catch(_){}
}
async function testAmpp() {
const s=document.getElementById('ampp-status');
s.className='status-msg loading'; s.textContent='🔍 Testing AMPP connection…';
try {
const body={baseUrl:document.getElementById('ampp-base-url').value.trim()};
const k=document.getElementById('ampp-api-key').value.trim();
if(k) body.apiKey=k;
const d=await api('POST','/api/ampp/test',body);
s.className=`status-msg ${d.success?'success':'error'}`; s.textContent=d.success?`✅ ${d.message}`:`❌ ${d.error}`;
} catch(e){s.className='status-msg error';s.textContent=`❌ ${e.message}`;}
}
async function saveAmpp() {
const s=document.getElementById('ampp-status');
s.className='status-msg loading'; s.textContent='💾 Saving…';
try {
const body={baseUrl:document.getElementById('ampp-base-url').value.trim()};
const k=document.getElementById('ampp-api-key').value.trim();
if(k) body.apiKey=k;
const d=await api('PUT','/api/ampp/config',body);
s.className=`status-msg ${d.success?'success':'error'}`; s.textContent=d.success?'✅ AMPP configuration saved':`❌ ${d.error}`;
if(d.success){document.getElementById('ampp-api-key').value='';loadAmppConfig();}
} catch(e){s.className='status-msg error';s.textContent=`❌ ${e.message}`;}
}
2026-04-05 20:19:57 -04:00
// ============================================================
// USERS ADMIN
// ============================================================
2026-04-05 20:05:34 -04:00
async function loadUsers() {
try {
2026-04-05 20:19:57 -04:00
const d=await api('GET','/api/users'); if(!d.success)return;
const tbody=document.getElementById('user-tbody'); tbody.innerHTML='';
d.users.forEach(u=>{
2026-04-06 19:58:29 -04:00
const quotaLabel = u.quotaMB
? `${fmtBytes(u.uploadedBytes||0)} / ${u.quotaMB} MB`
: '< span style = "color:var(--text-dim)" > unlimited< / span > ';
2026-04-05 20:19:57 -04:00
const tr=document.createElement('tr');
2026-04-06 19:58:29 -04:00
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 >
${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 > `;
2026-04-05 20:05:34 -04:00
tbody.appendChild(tr);
});
2026-04-05 20:19:57 -04:00
} catch(_){}
2026-04-05 20:05:34 -04:00
}
async function createUser() {
2026-04-05 20:19:57 -04:00
const s=document.getElementById('user-status');
s.className='status-msg loading'; s.textContent='Creating…';
2026-04-05 20:05:34 -04:00
try {
2026-04-05 20:19:57 -04:00
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}`;}
2026-04-05 20:05:34 -04:00
}
2026-04-05 20:19:57 -04:00
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');}
2026-04-05 20:05:34 -04:00
}
2026-04-06 19:58:29 -04:00
// ============================================================
// 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';
}
// ============================================================
// 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} · Uses: ${uses}${expired?' < 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(_){}
}
2026-04-05 20:19:57 -04:00
// ============================================================
// FOLDERS ADMIN
// ============================================================
2026-04-05 20:05:34 -04:00
async function loadAdminFolders() {
2026-04-05 20:19:57 -04:00
try { const d=await api('GET','/api/folders'); folderTree=d.tree||[]; renderAdminFolderTree(folderTree,document.getElementById('admin-folder-tree'),[]); } catch(_){}
2026-04-05 20:05:34 -04:00
}
2026-04-05 20:19:57 -04:00
function renderAdminFolderTree(nodes,container,pathArr) {
container.innerHTML='';
if(!nodes.length){container.innerHTML='< div style = "color:var(--text-dim);font-size:.8rem;padding:.5rem" > No folders. Add one below.< / div > ';return;}
nodes.forEach(n=>{
const fp=[...pathArr,n.name];
const div=document.createElement('div');div.style.paddingLeft=pathArr.length*20+'px';div.style.marginBottom='.3rem';
div.innerHTML=`< div style = "display:flex;align-items:center;gap:.5rem;padding:.3rem .5rem;border:1px solid var(--border);border-radius:7px;background:var(--bg-card)" > < span style = "font-size:.82rem" > ${n.children.length?'📁':'📄'}< / span > < span style = "font-family:'JetBrains Mono',monospace;font-size:.78rem;flex:1" > ${esc(n.name)}< / span > < button class = "btn-danger" style = "padding:.18rem .48rem;font-size:.66rem" onclick = "deleteFolder(${JSON.stringify(fp)})" > Delete< / button > < / div > `;
2026-04-05 20:05:34 -04:00
container.appendChild(div);
2026-04-05 20:19:57 -04:00
if(n.children.length){const sub=document.createElement('div');renderAdminFolderTree(n.children,sub,fp);container.appendChild(sub);}
2026-04-05 20:05:34 -04:00
});
}
async function adminAddFolder() {
2026-04-05 20:19:57 -04:00
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}`;}
2026-04-05 20:05:34 -04:00
}
2026-04-05 20:19:57 -04:00
// ============================================================
// 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);
2026-04-05 20:05:34 -04:00
}
2026-04-05 20:19:57 -04:00
// ============================================================
// HELPERS
// ============================================================
function esc(s){return String(s).replace(/&/g,'& ').replace(/< /g,'< ').replace(/>/g,'> ').replace(/"/g,'" ');}
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'📄';}
2026-04-05 20:05:34 -04:00
< / script >
< / body >
< / html >