1595 lines
79 KiB
HTML
1595 lines
79 KiB
HTML
|
|
<!DOCTYPE html>
|
|||
|
|
<html lang="en">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8" />
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|||
|
|
<title>FramelightX Uploader</title>
|
|||
|
|
<link rel="icon" href="/logo.png" type="image/jpeg" />
|
|||
|
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
|||
|
|
<style>
|
|||
|
|
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
|||
|
|
|
|||
|
|
:root {
|
|||
|
|
--bg-primary: #06080e;
|
|||
|
|
--bg-secondary: #0c1019;
|
|||
|
|
--bg-card: #10141f;
|
|||
|
|
--bg-card-hover: #151a28;
|
|||
|
|
--border: #1a2035;
|
|||
|
|
--border-bright: #2a3555;
|
|||
|
|
--accent: #1a3fc7;
|
|||
|
|
--accent-bright: #2b5cff;
|
|||
|
|
--accent-glow: rgba(43, 92, 255, 0.15);
|
|||
|
|
--accent-glow-strong: rgba(43, 92, 255, 0.3);
|
|||
|
|
--text-primary: #e4e8f1;
|
|||
|
|
--text-secondary: #7a85a0;
|
|||
|
|
--text-dim: #4a5470;
|
|||
|
|
--success: #22c55e;
|
|||
|
|
--success-bg: rgba(34, 197, 94, 0.1);
|
|||
|
|
--error: #ef4444;
|
|||
|
|
--error-bg: rgba(239, 68, 68, 0.1);
|
|||
|
|
--warning: #f59e0b;
|
|||
|
|
--warning-bg: rgba(245, 158, 11, 0.1);
|
|||
|
|
--warning-border: rgba(245, 158, 11, 0.25);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-theme="light"] {
|
|||
|
|
--bg-primary: #f4f6f9;
|
|||
|
|
--bg-secondary: #ffffff;
|
|||
|
|
--bg-card: #ffffff;
|
|||
|
|
--bg-card-hover: #f0f2f6;
|
|||
|
|
--border: #d8dce6;
|
|||
|
|
--border-bright: #b8c0d0;
|
|||
|
|
--accent: #1a3fc7;
|
|||
|
|
--accent-bright: #2b5cff;
|
|||
|
|
--accent-glow: rgba(43, 92, 255, 0.1);
|
|||
|
|
--accent-glow-strong: rgba(43, 92, 255, 0.18);
|
|||
|
|
--text-primary: #1a1e2e;
|
|||
|
|
--text-secondary: #4a5068;
|
|||
|
|
--text-dim: #8890a4;
|
|||
|
|
--success: #16a34a;
|
|||
|
|
--success-bg: rgba(22, 163, 74, 0.08);
|
|||
|
|
--error: #dc2626;
|
|||
|
|
--error-bg: rgba(220, 38, 38, 0.08);
|
|||
|
|
--warning: #d97706;
|
|||
|
|
--warning-bg: rgba(217, 119, 6, 0.08);
|
|||
|
|
--warning-border: rgba(217, 119, 6, 0.2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-theme="light"] body::before { background: radial-gradient(ellipse 80% 60% at 20% 10%, rgba(43, 92, 255, 0.04) 0%, transparent 60%), radial-gradient(ellipse 60% 50% at 80% 90%, rgba(43, 92, 255, 0.03) 0%, transparent 60%); }
|
|||
|
|
[data-theme="light"] .header { background: rgba(244, 246, 249, 0.9); }
|
|||
|
|
[data-theme="light"] .login-screen { background: var(--bg-primary); }
|
|||
|
|
[data-theme="light"] .login-logo { filter: brightness(0.95); }
|
|||
|
|
[data-theme="light"] .logo-mark { filter: brightness(0.95); }
|
|||
|
|
[data-theme="light"] .login-title { background: linear-gradient(135deg, #1a3fc7, #2b5cff); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
|||
|
|
[data-theme="light"] .toast { backdrop-filter: blur(12px); }
|
|||
|
|
[data-theme="light"] .toast.success { background: rgba(22, 163, 74, 0.08); border-color: rgba(22, 163, 74, 0.2); }
|
|||
|
|
[data-theme="light"] .toast.error { background: rgba(220, 38, 38, 0.08); border-color: rgba(220, 38, 38, 0.2); }
|
|||
|
|
[data-theme="light"] .btn-upload { color: #fff; }
|
|||
|
|
[data-theme="light"] .login-btn { color: #fff; }
|
|||
|
|
[data-theme="light"] .btn-small { color: #fff; }
|
|||
|
|
[data-theme="light"] .admin-select { background: var(--bg-secondary); color: var(--text-primary); }
|
|||
|
|
|
|||
|
|
/* Theme toggle button */
|
|||
|
|
.theme-toggle {
|
|||
|
|
width: 34px; height: 34px; border-radius: 8px; border: 1px solid var(--border);
|
|||
|
|
background: var(--bg-secondary); color: var(--text-secondary); cursor: pointer;
|
|||
|
|
display: flex; align-items: center; justify-content: center; font-size: 1rem;
|
|||
|
|
transition: all 0.2s; flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
.theme-toggle:hover { border-color: var(--accent); color: var(--accent-bright); }
|
|||
|
|
|
|||
|
|
html { font-size: 15px; }
|
|||
|
|
body { font-family: 'Outfit', sans-serif; background: var(--bg-primary); color: var(--text-primary); min-height: 100vh; overflow-x: hidden; }
|
|||
|
|
body::before { content: ''; position: fixed; inset: 0; background: radial-gradient(ellipse 80% 60% at 20% 10%, rgba(26, 63, 199, 0.06) 0%, transparent 60%), radial-gradient(ellipse 60% 50% at 80% 90%, rgba(43, 92, 255, 0.04) 0%, transparent 60%); pointer-events: none; z-index: 0; }
|
|||
|
|
|
|||
|
|
/* LOGIN */
|
|||
|
|
.login-screen { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; background: var(--bg-primary); }
|
|||
|
|
.login-screen.hidden { display: none; }
|
|||
|
|
.login-box { width: 380px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 16px; padding: 2.5rem 2rem; text-align: center; position: relative; z-index: 1; }
|
|||
|
|
.login-logo { width: 64px; height: 64px; border-radius: 14px; object-fit: contain; margin-bottom: 1rem; filter: brightness(1.1); }
|
|||
|
|
.login-title { font-size: 1.5rem; font-weight: 700; background: linear-gradient(135deg, var(--accent-bright), #6b8cff); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin-bottom: 0.25rem; }
|
|||
|
|
.login-sub { font-size: 0.75rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 2rem; }
|
|||
|
|
.login-field { width: 100%; padding: 0.75rem 1rem; font-family: 'Outfit', sans-serif; font-size: 0.9rem; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 10px; color: var(--text-primary); outline: none; transition: all 0.2s; margin-bottom: 0.75rem; }
|
|||
|
|
.login-field:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
|
|||
|
|
.login-field::placeholder { color: var(--text-dim); }
|
|||
|
|
.login-btn { width: 100%; padding: 0.8rem; margin-top: 0.5rem; font-family: 'Outfit', sans-serif; font-size: 0.9rem; font-weight: 600; color: #fff; background: linear-gradient(135deg, var(--accent), var(--accent-bright)); border: none; border-radius: 10px; cursor: pointer; transition: all 0.2s; }
|
|||
|
|
.login-btn:hover { transform: translateY(-1px); box-shadow: 0 6px 24px rgba(43, 92, 255, 0.35); }
|
|||
|
|
.login-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none; }
|
|||
|
|
.login-error { margin-top: 0.75rem; font-size: 0.8rem; color: var(--error); min-height: 1.2em; }
|
|||
|
|
.login-footer { margin-top: 2rem; font-size: 0.68rem; color: var(--text-dim); }
|
|||
|
|
|
|||
|
|
/* APP */
|
|||
|
|
.app { position: relative; z-index: 1; }
|
|||
|
|
.app.hidden { display: none; }
|
|||
|
|
.header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 2.5rem; border-bottom: 1px solid var(--border); background: rgba(6, 8, 14, 0.85); backdrop-filter: blur(20px); position: sticky; top: 0; z-index: 100; }
|
|||
|
|
.header-left { display: flex; align-items: center; gap: 1rem; }
|
|||
|
|
.logo-mark { width: 36px; height: 36px; object-fit: contain; filter: brightness(1.2); border-radius: 8px; }
|
|||
|
|
.logo-text { font-size: 1.2rem; font-weight: 700; letter-spacing: -0.02em; background: linear-gradient(135deg, var(--accent-bright), #6b8cff); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
|||
|
|
.logo-sub { font-size: 0.65rem; color: var(--text-dim); font-weight: 400; letter-spacing: 0.08em; text-transform: uppercase; margin-top: -2px; }
|
|||
|
|
.header-right { display: flex; align-items: center; gap: 1rem; }
|
|||
|
|
.header-user { font-size: 0.78rem; color: var(--text-secondary); font-family: 'JetBrains Mono', monospace; }
|
|||
|
|
.btn-logout { padding: 0.4rem 0.9rem; font-family: 'Outfit', sans-serif; font-size: 0.72rem; font-weight: 600; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-secondary); cursor: pointer; transition: all 0.15s; }
|
|||
|
|
.btn-logout:hover { border-color: var(--error); color: var(--error); }
|
|||
|
|
|
|||
|
|
.main { max-width: 720px; margin: 0 auto; padding: 2rem 2.5rem; }
|
|||
|
|
.section-title { font-size: 0.7rem; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-dim); margin-bottom: 1rem; display: flex; align-items: center; gap: 0.6rem; }
|
|||
|
|
.section-title::before { content: ''; width: 3px; height: 14px; background: var(--accent-bright); border-radius: 2px; }
|
|||
|
|
|
|||
|
|
/* FOLDER TREE */
|
|||
|
|
.folder-section { margin-bottom: 1.5rem; }
|
|||
|
|
|
|||
|
|
.tree-root-bar { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.6rem; }
|
|||
|
|
|
|||
|
|
.tree-chip {
|
|||
|
|
display: inline-flex; align-items: center; gap: 0.4rem;
|
|||
|
|
padding: 0.4rem 0.85rem; font-family: 'JetBrains Mono', monospace; font-size: 0.78rem;
|
|||
|
|
border-radius: 20px; cursor: pointer; transition: all 0.15s;
|
|||
|
|
border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-secondary); user-select: none;
|
|||
|
|
}
|
|||
|
|
.tree-chip:hover { border-color: var(--border-bright); background: var(--bg-card); }
|
|||
|
|
.tree-chip.active { border-color: var(--accent-bright); background: var(--accent-glow); color: var(--accent-bright); }
|
|||
|
|
.tree-chip .rm { font-size: 0.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: 0.5; transition: all 0.15s; }
|
|||
|
|
.tree-chip .rm:hover { opacity: 1; background: var(--error-bg); color: var(--error); }
|
|||
|
|
|
|||
|
|
.tree-chip-none {
|
|||
|
|
display: inline-flex; align-items: center; padding: 0.4rem 0.85rem;
|
|||
|
|
font-family: 'JetBrains Mono', monospace; font-size: 0.78rem; border-radius: 20px;
|
|||
|
|
cursor: pointer; transition: all 0.15s; border: 1px solid var(--border);
|
|||
|
|
background: var(--bg-secondary); color: var(--text-dim); user-select: none; font-style: italic;
|
|||
|
|
}
|
|||
|
|
.tree-chip-none:hover { border-color: var(--border-bright); }
|
|||
|
|
.tree-chip-none.active { border-color: var(--accent-bright); background: var(--accent-glow); color: var(--accent-bright); font-style: normal; }
|
|||
|
|
|
|||
|
|
.add-row { display: flex; gap: 0.4rem; align-items: center; }
|
|||
|
|
.add-input { flex: 1; padding: 0.4rem 0.75rem; font-family: 'JetBrains Mono', monospace; font-size: 0.76rem; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; color: var(--text-primary); outline: none; transition: all 0.2s; }
|
|||
|
|
.add-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
|
|||
|
|
.add-input::placeholder { color: var(--text-dim); }
|
|||
|
|
.btn-small { padding: 0.4rem 0.85rem; font-family: 'Outfit', sans-serif; font-size: 0.72rem; font-weight: 600; background: var(--accent); color: #fff; border: none; border-radius: 8px; cursor: pointer; transition: all 0.15s; white-space: nowrap; }
|
|||
|
|
.btn-small:hover { background: var(--accent-bright); }
|
|||
|
|
|
|||
|
|
/* Collapsible tree toggle */
|
|||
|
|
.tree-toggle { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.35rem 0.8rem; margin-top: 0.6rem; font-family: 'Outfit', sans-serif; font-size: 0.72rem; font-weight: 600; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-secondary); cursor: pointer; transition: all 0.15s; user-select: none; }
|
|||
|
|
.tree-toggle:hover { border-color: var(--border-bright); color: var(--text-primary); }
|
|||
|
|
.tree-toggle .arrow { transition: transform 0.2s; display: inline-block; font-size: 0.6rem; }
|
|||
|
|
.tree-toggle.open .arrow { transform: rotate(90deg); }
|
|||
|
|
|
|||
|
|
.tree-panel { max-height: 0; overflow: hidden; transition: max-height 0.35s ease, opacity 0.25s ease; opacity: 0; }
|
|||
|
|
.tree-panel.open { max-height: 1200px; opacity: 1; }
|
|||
|
|
|
|||
|
|
.tree-inner { margin-top: 0.7rem; padding: 1rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px; }
|
|||
|
|
|
|||
|
|
/* Tree level rendering */
|
|||
|
|
.tree-level { padding-left: 0; }
|
|||
|
|
.tree-level .tree-level { padding-left: 1.2rem; border-left: 1px solid var(--border); margin-left: 0.4rem; }
|
|||
|
|
|
|||
|
|
.tree-node { margin-bottom: 0.35rem; }
|
|||
|
|
|
|||
|
|
.tree-node-row {
|
|||
|
|
display: flex; align-items: center; gap: 0.35rem; padding: 0.3rem 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tree-node-expand {
|
|||
|
|
width: 20px; height: 20px; display: inline-flex; align-items: center; justify-content: center;
|
|||
|
|
font-size: 0.55rem; color: var(--text-dim); cursor: pointer; border-radius: 4px;
|
|||
|
|
background: transparent; border: none; transition: all 0.15s; flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
.tree-node-expand:hover { background: var(--bg-card-hover); color: var(--text-primary); }
|
|||
|
|
.tree-node-expand.expanded { transform: rotate(90deg); }
|
|||
|
|
.tree-node-expand.leaf { visibility: hidden; }
|
|||
|
|
|
|||
|
|
.tree-node-name {
|
|||
|
|
font-family: 'JetBrains Mono', monospace; font-size: 0.76rem; color: var(--text-secondary);
|
|||
|
|
padding: 0.2rem 0.6rem; border-radius: 6px; cursor: pointer; transition: all 0.15s;
|
|||
|
|
border: 1px solid transparent; flex: 1;
|
|||
|
|
}
|
|||
|
|
.tree-node-name:hover { background: var(--bg-card-hover); border-color: var(--border); }
|
|||
|
|
.tree-node-name.selected { background: var(--accent-glow); border-color: var(--accent-bright); color: var(--accent-bright); }
|
|||
|
|
|
|||
|
|
.tree-node-rm {
|
|||
|
|
width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center;
|
|||
|
|
font-size: 0.55rem; border-radius: 50%; background: transparent; border: none;
|
|||
|
|
color: var(--text-dim); cursor: pointer; opacity: 0; transition: all 0.15s; flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
.tree-node-row:hover .tree-node-rm { opacity: 0.6; }
|
|||
|
|
.tree-node-rm:hover { opacity: 1 !important; background: var(--error-bg); color: var(--error); }
|
|||
|
|
|
|||
|
|
.tree-node-add {
|
|||
|
|
display: flex; align-items: center; gap: 0.3rem; padding: 0.2rem 0; margin-top: 0.2rem;
|
|||
|
|
}
|
|||
|
|
.tree-node-add input {
|
|||
|
|
width: 120px; padding: 0.25rem 0.5rem; font-family: 'JetBrains Mono', monospace; font-size: 0.7rem;
|
|||
|
|
background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 5px;
|
|||
|
|
color: var(--text-primary); outline: none;
|
|||
|
|
}
|
|||
|
|
.tree-node-add input:focus { border-color: var(--accent); }
|
|||
|
|
.tree-node-add input::placeholder { color: var(--text-dim); }
|
|||
|
|
.tree-node-add button {
|
|||
|
|
padding: 0.25rem 0.5rem; font-family: 'Outfit', sans-serif; font-size: 0.65rem; font-weight: 600;
|
|||
|
|
background: var(--accent); color: #fff; border: none; border-radius: 5px; cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tree-children { overflow: hidden; }
|
|||
|
|
.tree-children.collapsed { display: none; }
|
|||
|
|
|
|||
|
|
.tree-breadcrumb {
|
|||
|
|
font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; color: var(--text-dim);
|
|||
|
|
margin-top: 0.5rem;
|
|||
|
|
}
|
|||
|
|
.tree-breadcrumb span { color: var(--accent-bright); }
|
|||
|
|
|
|||
|
|
.folder-hint { font-size: 0.7rem; color: var(--text-dim); margin-top: 0.5rem; font-family: 'JetBrains Mono', monospace; }
|
|||
|
|
|
|||
|
|
/* WARNING */
|
|||
|
|
.warning-banner { display: none; align-items: center; gap: 0.5rem; padding: 0.6rem 1rem; margin-bottom: 1rem; background: var(--warning-bg); border: 1px solid var(--warning-border); border-radius: 8px; font-size: 0.78rem; color: var(--warning); }
|
|||
|
|
.warning-banner.visible { display: flex; }
|
|||
|
|
.warning-banner .icon { font-size: 1rem; flex-shrink: 0; }
|
|||
|
|
|
|||
|
|
/* DROPZONE */
|
|||
|
|
.dropzone { border: 2px dashed var(--border-bright); border-radius: 12px; padding: 2.5rem 2rem; text-align: center; cursor: pointer; transition: all 0.3s; background: var(--bg-secondary); position: relative; overflow: hidden; margin-bottom: 1rem; }
|
|||
|
|
.dropzone::before { content: ''; position: absolute; inset: 0; background: linear-gradient(135deg, var(--accent-glow), transparent 60%); opacity: 0; transition: opacity 0.3s; }
|
|||
|
|
.dropzone:hover, .dropzone.dragover { border-color: var(--accent-bright); background: var(--bg-card); }
|
|||
|
|
.dropzone:hover::before, .dropzone.dragover::before { opacity: 1; }
|
|||
|
|
.dropzone-icon { font-size: 2.2rem; margin-bottom: 0.6rem; opacity: 0.5; position: relative; }
|
|||
|
|
.dropzone-text { font-size: 0.9rem; color: var(--text-secondary); position: relative; }
|
|||
|
|
.dropzone-text strong { color: var(--accent-bright); font-weight: 600; }
|
|||
|
|
.dropzone-sub { font-size: 0.72rem; color: var(--text-dim); margin-top: 0.3rem; position: relative; }
|
|||
|
|
|
|||
|
|
/* Queue */
|
|||
|
|
.queue { margin-bottom: 1rem; }
|
|||
|
|
.queue-item { display: flex; align-items: center; gap: 0.8rem; padding: 0.6rem 0.9rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 0.4rem; animation: slideIn 0.25s ease-out; }
|
|||
|
|
@keyframes slideIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
|
|||
|
|
.queue-icon { font-size: 1.1rem; flex-shrink: 0; }
|
|||
|
|
.queue-info { flex: 1; min-width: 0; }
|
|||
|
|
.queue-name { font-size: 0.8rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|||
|
|
.queue-name-warn { color: var(--warning); }
|
|||
|
|
.queue-meta { font-size: 0.66rem; color: var(--text-dim); font-family: 'JetBrains Mono', monospace; }
|
|||
|
|
.queue-remove { background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 0.9rem; padding: 0.2rem; border-radius: 4px; transition: all 0.15s; }
|
|||
|
|
.queue-remove:hover { color: var(--error); background: var(--error-bg); }
|
|||
|
|
.queue-status { font-size: 0.66rem; font-family: 'JetBrains Mono', monospace; padding: 0.15rem 0.5rem; border-radius: 4px; font-weight: 500; }
|
|||
|
|
.queue-status.pending { color: var(--text-dim); }
|
|||
|
|
.queue-status.uploading { color: var(--accent-bright); background: var(--accent-glow); }
|
|||
|
|
.queue-status.done { color: var(--success); background: var(--success-bg); }
|
|||
|
|
.queue-status.transferred { color: var(--accent-bright); background: var(--accent-glow); }
|
|||
|
|
.queue-status.error { color: var(--error); background: var(--error-bg); }
|
|||
|
|
|
|||
|
|
.btn-upload { width: 100%; padding: 0.8rem; font-family: 'Outfit', sans-serif; font-size: 0.88rem; font-weight: 600; letter-spacing: 0.03em; color: #fff; background: linear-gradient(135deg, var(--accent), var(--accent-bright)); border: none; border-radius: 10px; cursor: pointer; transition: all 0.2s; position: relative; overflow: hidden; }
|
|||
|
|
.btn-upload:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 6px 24px rgba(43, 92, 255, 0.35); }
|
|||
|
|
.btn-upload:disabled { opacity: 0.4; cursor: not-allowed; }
|
|||
|
|
.btn-progress { position: absolute; left: 0; bottom: 0; height: 3px; background: var(--accent-bright); transition: width 0.3s; border-radius: 0 0 10px 10px; }
|
|||
|
|
|
|||
|
|
.upload-progress { margin-top: 0.8rem; }
|
|||
|
|
.progress-bar-track { width: 100%; height: 6px; background: var(--bg-secondary); border-radius: 3px; overflow: hidden; border: 1px solid var(--border); }
|
|||
|
|
.progress-bar-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent-bright)); border-radius: 3px; transition: width 0.2s; }
|
|||
|
|
.progress-stats { display: flex; justify-content: space-between; margin-top: 0.4rem; font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; color: var(--text-secondary); }
|
|||
|
|
|
|||
|
|
/* History */
|
|||
|
|
.history-section { margin-top: 2rem; }
|
|||
|
|
.history-list { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: var(--bg-secondary); }
|
|||
|
|
.history-header { display: grid; grid-template-columns: 1fr 140px 100px; gap: 1rem; padding: 0.55rem 1rem; font-size: 0.63rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-dim); border-bottom: 1px solid var(--border); background: var(--bg-card); }
|
|||
|
|
.history-row { display: grid; grid-template-columns: 1fr 140px 100px; gap: 1rem; padding: 0.55rem 1rem; font-size: 0.78rem; border-bottom: 1px solid var(--border); align-items: center; }
|
|||
|
|
.history-row:last-child { border-bottom: none; }
|
|||
|
|
.history-name { font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-primary); }
|
|||
|
|
.history-folder { font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--accent-bright); }
|
|||
|
|
.history-time { font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; color: var(--text-dim); }
|
|||
|
|
.history-size { font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; color: var(--text-secondary); text-align: right; }
|
|||
|
|
.empty-history { padding: 2.5rem; text-align: center; color: var(--text-dim); font-size: 0.82rem; }
|
|||
|
|
.empty-history .icon { font-size: 1.8rem; margin-bottom: 0.5rem; opacity: 0.35; }
|
|||
|
|
.history-count { font-family: 'JetBrains Mono', monospace; font-size: 0.68rem; color: var(--text-dim); margin-top: 0.4rem; }
|
|||
|
|
|
|||
|
|
/* Admin Panel */
|
|||
|
|
.admin-section { margin-top: 2rem; }
|
|||
|
|
.admin-panel { background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px; padding: 1rem; }
|
|||
|
|
.admin-add-row { display: flex; gap: 0.4rem; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; }
|
|||
|
|
.admin-select { padding: 0.4rem 0.6rem; font-family: 'Outfit', sans-serif; font-size: 0.76rem; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; color: var(--text-primary); outline: none; cursor: pointer; }
|
|||
|
|
.admin-select:focus { border-color: var(--accent); }
|
|||
|
|
.user-list { }
|
|||
|
|
.user-row { display: grid; grid-template-columns: 1fr 80px 100px 70px; gap: 0.6rem; padding: 0.55rem 0.5rem; border-bottom: 1px solid var(--border); align-items: center; font-size: 0.78rem; }
|
|||
|
|
.user-row:last-child { border-bottom: none; }
|
|||
|
|
.user-row:hover { background: var(--bg-card-hover); border-radius: 6px; }
|
|||
|
|
.user-name { font-family: 'JetBrains Mono', monospace; font-size: 0.76rem; color: var(--text-primary); }
|
|||
|
|
.user-role { font-family: 'JetBrains Mono', monospace; font-size: 0.68rem; padding: 0.15rem 0.5rem; border-radius: 10px; text-align: center; }
|
|||
|
|
.user-role.admin { color: var(--accent-bright); background: var(--accent-glow); }
|
|||
|
|
.user-role.user { color: var(--text-secondary); background: var(--bg-secondary); }
|
|||
|
|
.user-date { font-family: 'JetBrains Mono', monospace; font-size: 0.66rem; color: var(--text-dim); }
|
|||
|
|
.user-actions { display: flex; gap: 0.3rem; justify-content: flex-end; }
|
|||
|
|
.btn-user-action { padding: 0.25rem 0.5rem; font-family: 'Outfit', sans-serif; font-size: 0.65rem; font-weight: 600; border: 1px solid var(--border); border-radius: 5px; cursor: pointer; background: var(--bg-secondary); color: var(--text-secondary); transition: all 0.15s; }
|
|||
|
|
.btn-user-action:hover { border-color: var(--border-bright); color: var(--text-primary); }
|
|||
|
|
.btn-user-action.danger:hover { border-color: var(--error); color: var(--error); background: var(--error-bg); }
|
|||
|
|
|
|||
|
|
.footer { text-align: center; padding: 1.2rem; border-top: 1px solid var(--border); font-size: 0.7rem; color: var(--text-dim); letter-spacing: 0.04em; margin-top: 2rem; }
|
|||
|
|
|
|||
|
|
.toast-container { position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 999; display: flex; flex-direction: column; gap: 0.5rem; }
|
|||
|
|
.toast { padding: 0.75rem 1.2rem; border-radius: 8px; font-size: 0.8rem; font-weight: 500; animation: toastIn 0.3s ease-out, toastOut 0.3s ease-in 3.7s forwards; display: flex; align-items: center; gap: 0.5rem; backdrop-filter: blur(12px); border: 1px solid; }
|
|||
|
|
.toast.success { background: rgba(34, 197, 94, 0.12); border-color: rgba(34, 197, 94, 0.25); color: var(--success); }
|
|||
|
|
.toast.error { background: rgba(239, 68, 68, 0.12); border-color: rgba(239, 68, 68, 0.25); color: var(--error); }
|
|||
|
|
@keyframes toastIn { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } }
|
|||
|
|
@keyframes toastOut { from { opacity: 1; } to { opacity: 0; transform: translateY(10px); } }
|
|||
|
|
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--accent-glow-strong); border-top-color: var(--accent-bright); border-radius: 50%; animation: spin 0.6s linear infinite; }
|
|||
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|||
|
|
@media (max-width: 600px) { .main { padding: 1.5rem 1rem; } .header { padding: 0.8rem 1rem; } .login-box { width: 90%; margin: 0 1rem; } }
|
|||
|
|
::-webkit-scrollbar { width: 6px; }
|
|||
|
|
::-webkit-scrollbar-track { background: transparent; }
|
|||
|
|
::-webkit-scrollbar-thumb { background: var(--border-bright); border-radius: 3px; }
|
|||
|
|
|
|||
|
|
/* ==================== AMPP JOB STATUS PANEL ==================== */
|
|||
|
|
/* ==================== DISCLAIMER MODAL ==================== */
|
|||
|
|
.modal-overlay {
|
|||
|
|
position: fixed; inset: 0; z-index: 2000; display: flex; align-items: center; justify-content: center;
|
|||
|
|
background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(6px); opacity: 0; transition: opacity 0.3s;
|
|||
|
|
pointer-events: none;
|
|||
|
|
}
|
|||
|
|
.modal-overlay.visible { opacity: 1; pointer-events: auto; }
|
|||
|
|
.modal-box {
|
|||
|
|
width: 480px; max-width: 92vw; background: var(--bg-card); border: 1px solid var(--border-bright);
|
|||
|
|
border-radius: 16px; padding: 2rem; position: relative; transform: translateY(12px);
|
|||
|
|
transition: transform 0.3s; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
|||
|
|
}
|
|||
|
|
.modal-overlay.visible .modal-box { transform: translateY(0); }
|
|||
|
|
.modal-icon { font-size: 2rem; text-align: center; margin-bottom: 0.8rem; }
|
|||
|
|
.modal-title {
|
|||
|
|
font-size: 1.1rem; font-weight: 700; text-align: center; margin-bottom: 1rem;
|
|||
|
|
background: linear-gradient(135deg, var(--accent-bright), #6b8cff);
|
|||
|
|
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
|||
|
|
}
|
|||
|
|
.modal-body {
|
|||
|
|
font-size: 0.82rem; color: var(--text-secondary); line-height: 1.7; margin-bottom: 1.5rem;
|
|||
|
|
}
|
|||
|
|
.modal-body strong { color: var(--text-primary); font-weight: 600; }
|
|||
|
|
.modal-body .highlight {
|
|||
|
|
display: block; margin: 0.8rem 0; padding: 0.7rem 1rem; background: var(--accent-glow);
|
|||
|
|
border: 1px solid rgba(43, 92, 255, 0.2); border-radius: 8px; color: var(--accent-bright);
|
|||
|
|
font-family: 'JetBrains Mono', monospace; font-size: 0.76rem;
|
|||
|
|
}
|
|||
|
|
.modal-body .file-types {
|
|||
|
|
display: block; margin: 0.6rem 0; padding: 0.6rem 1rem; background: var(--bg-secondary);
|
|||
|
|
border: 1px solid var(--border); border-radius: 8px; font-family: 'JetBrains Mono', monospace;
|
|||
|
|
font-size: 0.68rem; color: var(--text-dim); line-height: 1.8;
|
|||
|
|
}
|
|||
|
|
.modal-body .blocked {
|
|||
|
|
display: block; margin: 0.4rem 0; padding: 0.5rem 1rem; background: var(--error-bg);
|
|||
|
|
border: 1px solid rgba(239, 68, 68, 0.15); border-radius: 8px; font-family: 'JetBrains Mono', monospace;
|
|||
|
|
font-size: 0.7rem; color: var(--error);
|
|||
|
|
}
|
|||
|
|
.modal-btn {
|
|||
|
|
width: 100%; padding: 0.8rem; font-family: 'Outfit', sans-serif; font-size: 0.9rem; font-weight: 600;
|
|||
|
|
color: #fff; background: linear-gradient(135deg, var(--accent), var(--accent-bright)); border: none;
|
|||
|
|
border-radius: 10px; cursor: pointer; transition: all 0.2s;
|
|||
|
|
}
|
|||
|
|
.modal-btn:hover { transform: translateY(-1px); box-shadow: 0 6px 24px rgba(43, 92, 255, 0.35); }
|
|||
|
|
|
|||
|
|
.jobs-section { margin-top: 2rem; }
|
|||
|
|
.jobs-panel { background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
|||
|
|
|
|||
|
|
.jobs-header-bar {
|
|||
|
|
display: flex; align-items: center; justify-content: space-between; padding: 0.7rem 1rem;
|
|||
|
|
border-bottom: 1px solid var(--border); background: var(--bg-secondary);
|
|||
|
|
}
|
|||
|
|
.jobs-header-left { display: flex; align-items: center; gap: 0.6rem; }
|
|||
|
|
.jobs-live-dot {
|
|||
|
|
width: 8px; height: 8px; border-radius: 50%; background: var(--success);
|
|||
|
|
box-shadow: 0 0 6px var(--success); animation: livePulse 2s ease-in-out infinite;
|
|||
|
|
}
|
|||
|
|
.jobs-live-dot.offline { background: var(--text-dim); box-shadow: none; animation: none; }
|
|||
|
|
@keyframes livePulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|||
|
|
.jobs-header-label { font-size: 0.72rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-secondary); }
|
|||
|
|
.jobs-header-right { display: flex; align-items: center; gap: 0.5rem; }
|
|||
|
|
.jobs-filter-btn {
|
|||
|
|
padding: 0.25rem 0.6rem; font-family: 'JetBrains Mono', monospace; font-size: 0.66rem;
|
|||
|
|
border: 1px solid var(--border); border-radius: 12px; background: var(--bg-secondary);
|
|||
|
|
color: var(--text-dim); cursor: pointer; transition: all 0.15s;
|
|||
|
|
}
|
|||
|
|
.jobs-filter-btn:hover { border-color: var(--border-bright); color: var(--text-secondary); }
|
|||
|
|
.jobs-filter-btn.active { border-color: var(--accent-bright); background: var(--accent-glow); color: var(--accent-bright); }
|
|||
|
|
.jobs-refresh-btn {
|
|||
|
|
width: 26px; height: 26px; border-radius: 6px; border: 1px solid var(--border);
|
|||
|
|
background: var(--bg-secondary); color: var(--text-dim); cursor: pointer;
|
|||
|
|
display: flex; align-items: center; justify-content: center; font-size: 0.78rem;
|
|||
|
|
transition: all 0.15s;
|
|||
|
|
}
|
|||
|
|
.jobs-refresh-btn:hover { border-color: var(--accent); color: var(--accent-bright); }
|
|||
|
|
.jobs-refresh-btn.spinning { animation: spin 0.6s linear; }
|
|||
|
|
|
|||
|
|
.jobs-stats {
|
|||
|
|
display: flex; gap: 0; border-bottom: 1px solid var(--border);
|
|||
|
|
}
|
|||
|
|
.jobs-stat {
|
|||
|
|
flex: 1; padding: 0.6rem 0.8rem; text-align: center; border-right: 1px solid var(--border);
|
|||
|
|
}
|
|||
|
|
.jobs-stat:last-child { border-right: none; }
|
|||
|
|
.jobs-stat-val { font-family: 'JetBrains Mono', monospace; font-size: 1.1rem; font-weight: 700; color: var(--text-primary); }
|
|||
|
|
.jobs-stat-val.running { color: var(--accent-bright); }
|
|||
|
|
.jobs-stat-val.queued { color: var(--warning); }
|
|||
|
|
.jobs-stat-val.done { color: var(--success); }
|
|||
|
|
.jobs-stat-val.failed { color: var(--error); }
|
|||
|
|
.jobs-stat-label { font-size: 0.6rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-dim); margin-top: 0.15rem; }
|
|||
|
|
|
|||
|
|
.jobs-list { max-height: 400px; overflow-y: auto; }
|
|||
|
|
.job-row {
|
|||
|
|
display: grid; grid-template-columns: 32px 1fr 90px 80px; gap: 0.6rem;
|
|||
|
|
padding: 0.65rem 1rem; border-bottom: 1px solid var(--border); align-items: center;
|
|||
|
|
transition: background 0.15s;
|
|||
|
|
}
|
|||
|
|
.job-row:last-child { border-bottom: none; }
|
|||
|
|
.job-row:hover { background: var(--bg-card-hover); }
|
|||
|
|
|
|||
|
|
.job-state-badge {
|
|||
|
|
width: 28px; height: 28px; border-radius: 8px; display: flex; align-items: center;
|
|||
|
|
justify-content: center; font-size: 0.85rem; flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
.job-state-badge.inProgress { background: var(--accent-glow-strong); }
|
|||
|
|
.job-state-badge.queued, .job-state-badge.starting, .job-state-badge.claimed { background: var(--warning-bg); }
|
|||
|
|
.job-state-badge.complete { background: var(--success-bg); }
|
|||
|
|
.job-state-badge.failed, .job-state-badge.aborted { background: var(--error-bg); }
|
|||
|
|
|
|||
|
|
.job-info { min-width: 0; }
|
|||
|
|
.job-asset-name {
|
|||
|
|
font-size: 0.78rem; font-weight: 500; white-space: nowrap;
|
|||
|
|
overflow: hidden; text-overflow: ellipsis; color: var(--text-primary);
|
|||
|
|
}
|
|||
|
|
.job-meta {
|
|||
|
|
font-size: 0.66rem; color: var(--text-dim); font-family: 'JetBrains Mono', monospace;
|
|||
|
|
margin-top: 0.1rem; display: flex; gap: 0.6rem; align-items: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.job-progress-wrap { min-width: 0; }
|
|||
|
|
.job-progress-track {
|
|||
|
|
width: 100%; height: 4px; background: var(--bg-secondary); border-radius: 2px;
|
|||
|
|
overflow: hidden; border: 1px solid var(--border);
|
|||
|
|
}
|
|||
|
|
.job-progress-fill {
|
|||
|
|
height: 100%; border-radius: 2px; transition: width 0.4s ease;
|
|||
|
|
}
|
|||
|
|
.job-progress-fill.inProgress { background: linear-gradient(90deg, var(--accent), var(--accent-bright)); }
|
|||
|
|
.job-progress-fill.queued { background: var(--warning); }
|
|||
|
|
.job-progress-fill.complete { background: var(--success); }
|
|||
|
|
.job-progress-fill.failed { background: var(--error); }
|
|||
|
|
.job-progress-pct {
|
|||
|
|
font-family: 'JetBrains Mono', monospace; font-size: 0.63rem;
|
|||
|
|
color: var(--text-dim); text-align: center; margin-top: 0.15rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.job-time {
|
|||
|
|
font-family: 'JetBrains Mono', monospace; font-size: 0.66rem;
|
|||
|
|
color: var(--text-dim); text-align: right;
|
|||
|
|
}
|
|||
|
|
.job-duration { color: var(--text-secondary); }
|
|||
|
|
|
|||
|
|
.jobs-empty {
|
|||
|
|
padding: 2.5rem; text-align: center; color: var(--text-dim); font-size: 0.82rem;
|
|||
|
|
}
|
|||
|
|
.jobs-empty .icon { font-size: 1.8rem; margin-bottom: 0.5rem; opacity: 0.35; }
|
|||
|
|
.jobs-error {
|
|||
|
|
padding: 1rem; text-align: center; color: var(--error); font-size: 0.78rem;
|
|||
|
|
background: var(--error-bg); border-bottom: 1px solid rgba(239,68,68,0.15);
|
|||
|
|
}
|
|||
|
|
.jobs-last-updated {
|
|||
|
|
padding: 0.4rem 1rem; text-align: right; font-family: 'JetBrains Mono', monospace;
|
|||
|
|
font-size: 0.6rem; color: var(--text-dim); border-top: 1px solid var(--border);
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
|
|||
|
|
<!-- LOGIN -->
|
|||
|
|
<div class="login-screen" id="loginScreen">
|
|||
|
|
<div class="login-box">
|
|||
|
|
<img src="/logo.png" alt="Logo" class="login-logo" />
|
|||
|
|
<div class="login-title">FramelightX</div>
|
|||
|
|
<div class="login-sub">S3 Upload Manager</div>
|
|||
|
|
<input type="text" class="login-field" id="loginUser" placeholder="Username" autocomplete="username" />
|
|||
|
|
<input type="password" class="login-field" id="loginPass" placeholder="Password" autocomplete="current-password" />
|
|||
|
|
<button class="login-btn" id="loginBtn" onclick="doLogin()">Sign In</button>
|
|||
|
|
<div class="login-error" id="loginError"></div>
|
|||
|
|
<div class="login-footer" style="display:flex; align-items:center; justify-content:center; gap:0.6rem;">
|
|||
|
|
Made by Zac Gaetano
|
|||
|
|
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle light/dark mode" id="loginThemeBtn">☀</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- APP -->
|
|||
|
|
<div class="app hidden" id="appScreen">
|
|||
|
|
<header class="header">
|
|||
|
|
<div class="header-left">
|
|||
|
|
<img src="/logo.png" alt="Logo" class="logo-mark" />
|
|||
|
|
<div><div class="logo-text">FramelightX</div><div class="logo-sub">S3 Upload Manager</div></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="header-right">
|
|||
|
|
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle light/dark mode" id="appThemeBtn">☀</button>
|
|||
|
|
<span class="header-user" id="headerUser"></span>
|
|||
|
|
<button class="btn-logout" onclick="doLogout()">Sign Out</button>
|
|||
|
|
</div>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
<div class="main">
|
|||
|
|
<!-- Root Folder Chips -->
|
|||
|
|
<div class="folder-section">
|
|||
|
|
<div class="section-title">FramelightX Folder</div>
|
|||
|
|
<div class="tree-root-bar" id="rootBar"></div>
|
|||
|
|
<div class="add-row" id="rootAddRow">
|
|||
|
|
<input type="text" class="add-input" id="rootAddInput" placeholder="New root folder…" spellcheck="false" />
|
|||
|
|
<button class="btn-small" onclick="addRoot()">+ Add</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Tree toggle -->
|
|||
|
|
<button class="tree-toggle" id="treeToggle" onclick="toggleTree()">
|
|||
|
|
<span class="arrow">▶</span> Subfolders
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<div class="tree-panel" id="treePanel">
|
|||
|
|
<div class="tree-inner" id="treeInner">
|
|||
|
|
<div id="treeContainer"></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="folder-hint">Uploading as: <span id="folderPreview">filename.ext</span></div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Dropzone -->
|
|||
|
|
<div class="section-title">Upload Files</div>
|
|||
|
|
<div class="dropzone" id="dropzone" onclick="document.getElementById('fileInput').click()">
|
|||
|
|
<div class="dropzone-icon">⬆</div>
|
|||
|
|
<div class="dropzone-text">Drop files here or <strong>browse</strong></div>
|
|||
|
|
<div class="dropzone-sub">Media & sidecar files only — no size limit</div>
|
|||
|
|
</div>
|
|||
|
|
<input type="file" id="fileInput" multiple hidden accept="video/*,audio/*,image/*,.r3d,.braw,.ari,.mxf,.mts,.m2ts,.prores,.exr,.dpx,.dng,.cr2,.nef,.arw,.psd,.scc,.srt,.vtt,.stl,.edl,.xml,.aaf,.ale,.cdl,.cube,.lut" />
|
|||
|
|
|
|||
|
|
<div class="queue" id="queue"></div>
|
|||
|
|
|
|||
|
|
<button class="btn-upload" id="uploadBtn" disabled>
|
|||
|
|
Upload Files
|
|||
|
|
<div class="btn-progress" id="btnProgress" style="width:0"></div>
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<div class="upload-progress" id="uploadProgress" style="display:none">
|
|||
|
|
<div class="progress-bar-track"><div class="progress-bar-fill" id="progressBarFill" style="width:0%"></div></div>
|
|||
|
|
<div class="progress-stats"><span id="progressPct">0%</span><span id="progressSpeed"></span><span id="progressEta"></span></div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- AMPP Job Status -->
|
|||
|
|
<div class="jobs-section">
|
|||
|
|
<div class="section-title">FramelightX Job Status</div>
|
|||
|
|
<div class="jobs-panel" id="jobsPanel">
|
|||
|
|
<div class="jobs-header-bar">
|
|||
|
|
<div class="jobs-header-left">
|
|||
|
|
<div class="jobs-live-dot" id="jobsLiveDot"></div>
|
|||
|
|
<span class="jobs-header-label">Live Status</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="jobs-header-right">
|
|||
|
|
<button class="jobs-filter-btn active" data-filter="" onclick="setJobFilter(this, '')">All</button>
|
|||
|
|
<button class="jobs-filter-btn" data-filter="inProgress" onclick="setJobFilter(this, 'inProgress')">Running</button>
|
|||
|
|
<button class="jobs-filter-btn" data-filter="queued" onclick="setJobFilter(this, 'queued')">Queued</button>
|
|||
|
|
<button class="jobs-filter-btn" data-filter="complete" onclick="setJobFilter(this, 'complete')">Done</button>
|
|||
|
|
<button class="jobs-filter-btn" data-filter="failed" onclick="setJobFilter(this, 'failed')">Failed</button>
|
|||
|
|
<button class="jobs-refresh-btn" onclick="refreshJobs(true)" title="Refresh now">↻</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="jobs-stats" id="jobsStats">
|
|||
|
|
<div class="jobs-stat"><div class="jobs-stat-val running" id="statRunning">—</div><div class="jobs-stat-label">Running</div></div>
|
|||
|
|
<div class="jobs-stat"><div class="jobs-stat-val queued" id="statQueued">—</div><div class="jobs-stat-label">Queued</div></div>
|
|||
|
|
<div class="jobs-stat"><div class="jobs-stat-val done" id="statComplete">—</div><div class="jobs-stat-label">Complete</div></div>
|
|||
|
|
<div class="jobs-stat"><div class="jobs-stat-val failed" id="statFailed">—</div><div class="jobs-stat-label">Failed</div></div>
|
|||
|
|
</div>
|
|||
|
|
<div id="jobsError"></div>
|
|||
|
|
<div class="jobs-list" id="jobsList">
|
|||
|
|
<div class="jobs-empty" id="jobsEmpty"><div class="icon">⚡</div><div>Connecting to AMPP…</div></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="jobs-last-updated" id="jobsLastUpdated"></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Session History -->
|
|||
|
|
<div class="history-section">
|
|||
|
|
<div class="section-title">Session Uploads</div>
|
|||
|
|
<div class="history-list" id="historyList">
|
|||
|
|
<div class="history-header"><span>S3 Key</span><span>Time</span><span style="text-align:right">Size</span></div>
|
|||
|
|
<div class="empty-history" id="emptyHistory"><div class="icon">📋</div><div>No uploads yet this session</div></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="history-count" id="historyCount"></div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Admin Panel (admin only) -->
|
|||
|
|
<div class="admin-section" id="adminSection" style="display:none">
|
|||
|
|
<div class="section-title">User Management</div>
|
|||
|
|
<div class="admin-panel">
|
|||
|
|
<div class="admin-add-row">
|
|||
|
|
<input type="text" class="add-input" id="newUsername" placeholder="Username" spellcheck="false" />
|
|||
|
|
<input type="password" class="add-input" id="newPassword" placeholder="Password" />
|
|||
|
|
<select class="admin-select" id="newRole"><option value="user">User</option><option value="admin">Admin</option></select>
|
|||
|
|
<button class="btn-small" onclick="createUser()">+ Create</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="user-list" id="userList"></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<footer class="footer">Made by Zac Gaetano · Bucket: <strong>upload</strong> @ broadcastmgmt.cloud</footer>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- DISCLAIMER MODAL -->
|
|||
|
|
<div class="modal-overlay" id="disclaimerModal">
|
|||
|
|
<div class="modal-box">
|
|||
|
|
<div class="modal-icon">⚠</div>
|
|||
|
|
<div class="modal-title">Upload Information</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
This tool uses <strong>Multipart S3 uploads through HTTPS</strong>. Once a file shows as <strong>"Transferred"</strong> you are free to leave the page.
|
|||
|
|
<span class="highlight">FramelightX can take 10–15 minutes to pick up files from the S3 bucket.</span>
|
|||
|
|
<strong>Accepted file types:</strong>
|
|||
|
|
<span class="file-types">.mp4 .mov .mxf .mkv .avi .wmv .mpg .mpeg .m4v .ts .m2ts .webm .flv .3gp .f4v .vob .ogv .mp3 .wav .aac .flac .ogg .wma .aiff .m4a .ac3 .dts .opus .jpg .jpeg .png .tiff .tif .bmp .gif .exr .dpx .raw .cr2 .nef .arw .dng .psd .svg .webp .r3d .braw .ari .mts .prores .scc .srt .vtt .stl .edl .xml .aaf .ale .cdl .cube .lut</span>
|
|||
|
|
<strong>Blocked:</strong>
|
|||
|
|
<span class="blocked">Executable files (.exe, .sh, .bash, .bat, .cmd, .ps1, .msi, .dll, .com, .scr, .vbs, .js, .jar, .py, .rb, .pl, .php, .cgi, .wsf, .reg, .inf, .app, .dmg, .run, .bin, .elf, .apk, .deb, .rpm) are not permitted.</span>
|
|||
|
|
</div>
|
|||
|
|
<button class="modal-btn" onclick="dismissDisclaimer()">I Understand</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="toast-container" id="toasts"></div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
// ==================== THEME ====================
|
|||
|
|
function getTheme() { return localStorage.getItem("framelightx-theme") || "dark"; }
|
|||
|
|
function applyTheme(theme) {
|
|||
|
|
document.documentElement.setAttribute("data-theme", theme);
|
|||
|
|
const icon = theme === "light" ? "🌙" : "☀";
|
|||
|
|
const btns = document.querySelectorAll(".theme-toggle");
|
|||
|
|
btns.forEach((b) => (b.textContent = icon));
|
|||
|
|
}
|
|||
|
|
function toggleTheme() {
|
|||
|
|
const next = getTheme() === "dark" ? "light" : "dark";
|
|||
|
|
localStorage.setItem("framelightx-theme", next);
|
|||
|
|
applyTheme(next);
|
|||
|
|
}
|
|||
|
|
// Apply on load
|
|||
|
|
applyTheme(getTheme());
|
|||
|
|
|
|||
|
|
// ==================== STATE ====================
|
|||
|
|
let authToken = null;
|
|||
|
|
let fileQueue = [];
|
|||
|
|
let isUploading = false;
|
|||
|
|
let sessionHistory = [];
|
|||
|
|
let tree = []; // [{ name, children: [...] }]
|
|||
|
|
let selectedPath = []; // e.g. ["Media", "Rushes", "CamA"]
|
|||
|
|
let expandedPaths = new Set(); // track which nodes are expanded in tree view
|
|||
|
|
let treePanelOpen = false;
|
|||
|
|
let currentRole = null; // "admin" or "user"
|
|||
|
|
|
|||
|
|
// ==================== HELPERS ====================
|
|||
|
|
function authHeaders() { return { "x-auth-token": authToken }; }
|
|||
|
|
function pathKey(arr) { return arr.join("__"); }
|
|||
|
|
function esc(s) { return s.replace(/'/g, "\\'").replace(/"/g, """); }
|
|||
|
|
|
|||
|
|
// Path registry — avoids putting JSON arrays in HTML onclick attributes
|
|||
|
|
const pathReg = {};
|
|||
|
|
function regPath(arr) { const k = pathKey(arr); pathReg[k] = arr.slice(); return k; }
|
|||
|
|
function getRegPath(k) { return pathReg[k] || []; }
|
|||
|
|
|
|||
|
|
function getNodeAt(pathArr) {
|
|||
|
|
let nodes = tree;
|
|||
|
|
for (const seg of pathArr) {
|
|||
|
|
const n = nodes.find((x) => x.name === seg);
|
|||
|
|
if (!n) return null;
|
|||
|
|
nodes = n.children;
|
|||
|
|
}
|
|||
|
|
return nodes; // returns children array at that level
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildPrefix() {
|
|||
|
|
return selectedPath.join("--");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updatePreview() {
|
|||
|
|
const el = document.getElementById("folderPreview");
|
|||
|
|
const prefix = buildPrefix();
|
|||
|
|
el.textContent = prefix ? `${prefix}--filename.ext` : "filename.ext (no prefix)";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== DISCLAIMER ====================
|
|||
|
|
function showDisclaimer() {
|
|||
|
|
document.getElementById("disclaimerModal").classList.add("visible");
|
|||
|
|
}
|
|||
|
|
function dismissDisclaimer() {
|
|||
|
|
document.getElementById("disclaimerModal").classList.remove("visible");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== FILE TYPE VALIDATION ====================
|
|||
|
|
const BLOCKED_EXTENSIONS = new Set([
|
|||
|
|
'exe','sh','bash','bat','cmd','ps1','msi','dll','com','scr','vbs','js','jar',
|
|||
|
|
'py','rb','pl','php','cgi','wsf','reg','inf','app','dmg','run','bin','elf',
|
|||
|
|
'apk','deb','rpm','ssh','csh','ksh','zsh','fish','command','action','workflow'
|
|||
|
|
]);
|
|||
|
|
const ALLOWED_EXTENSIONS = new Set([
|
|||
|
|
// Video
|
|||
|
|
'mp4','mov','mxf','mkv','avi','wmv','mpg','mpeg','m4v','ts','m2ts','webm','flv',
|
|||
|
|
'3gp','f4v','vob','ogv',
|
|||
|
|
// Audio
|
|||
|
|
'mp3','wav','aac','flac','ogg','wma','aiff','m4a','ac3','dts','opus',
|
|||
|
|
// Image
|
|||
|
|
'jpg','jpeg','png','tiff','tif','bmp','gif','exr','dpx','raw','cr2','nef','arw',
|
|||
|
|
'dng','psd','svg','webp',
|
|||
|
|
// Camera RAW / Production
|
|||
|
|
'r3d','braw','ari','mts','prores',
|
|||
|
|
// Metadata / Sidecar
|
|||
|
|
'scc','srt','vtt','stl','edl','xml','aaf','ale','cdl','cube','lut'
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
function getExtension(filename) {
|
|||
|
|
const dot = filename.lastIndexOf('.');
|
|||
|
|
if (dot === -1) return '';
|
|||
|
|
return filename.substring(dot + 1).toLowerCase();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isFileAllowed(filename) {
|
|||
|
|
const ext = getExtension(filename);
|
|||
|
|
if (!ext) return true; // no extension — allow (server will also check)
|
|||
|
|
if (BLOCKED_EXTENSIONS.has(ext)) return false;
|
|||
|
|
if (ALLOWED_EXTENSIONS.has(ext)) return true;
|
|||
|
|
return false; // unknown extension — block by default
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== AUTH ====================
|
|||
|
|
// ==================== SESSION PERSISTENCE ====================
|
|||
|
|
function saveSession(token, user, role) {
|
|||
|
|
try {
|
|||
|
|
localStorage.setItem("flx-token", token);
|
|||
|
|
localStorage.setItem("flx-user", user);
|
|||
|
|
localStorage.setItem("flx-role", role);
|
|||
|
|
} catch (e) {}
|
|||
|
|
}
|
|||
|
|
function clearSession() {
|
|||
|
|
try {
|
|||
|
|
localStorage.removeItem("flx-token");
|
|||
|
|
localStorage.removeItem("flx-user");
|
|||
|
|
localStorage.removeItem("flx-role");
|
|||
|
|
} catch (e) {}
|
|||
|
|
}
|
|||
|
|
function getSavedSession() {
|
|||
|
|
try {
|
|||
|
|
const token = localStorage.getItem("flx-token");
|
|||
|
|
const user = localStorage.getItem("flx-user");
|
|||
|
|
const role = localStorage.getItem("flx-role");
|
|||
|
|
if (token && user && role) return { token, user, role };
|
|||
|
|
} catch (e) {}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function enterApp(token, user, role, showModal) {
|
|||
|
|
authToken = token;
|
|||
|
|
currentRole = role;
|
|||
|
|
document.getElementById("headerUser").textContent = user + (role === "admin" ? " (admin)" : "");
|
|||
|
|
document.getElementById("loginScreen").classList.add("hidden");
|
|||
|
|
document.getElementById("appScreen").classList.remove("hidden");
|
|||
|
|
if (showModal) showDisclaimer();
|
|||
|
|
const isAdmin = role === "admin";
|
|||
|
|
document.getElementById("adminSection").style.display = isAdmin ? "" : "none";
|
|||
|
|
document.getElementById("rootAddRow").style.display = isAdmin ? "" : "none";
|
|||
|
|
loadTree();
|
|||
|
|
if (isAdmin) loadUsers();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function doLogin() {
|
|||
|
|
const user = document.getElementById("loginUser").value.trim();
|
|||
|
|
const pass = document.getElementById("loginPass").value;
|
|||
|
|
const errEl = document.getElementById("loginError");
|
|||
|
|
const btn = document.getElementById("loginBtn");
|
|||
|
|
if (!user || !pass) { errEl.textContent = "Enter username and password"; return; }
|
|||
|
|
btn.disabled = true; btn.textContent = "Signing in…"; errEl.textContent = "";
|
|||
|
|
try {
|
|||
|
|
const res = await fetch("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: user, password: pass }) });
|
|||
|
|
const data = await res.json();
|
|||
|
|
if (data.success) {
|
|||
|
|
saveSession(data.token, data.user, data.role);
|
|||
|
|
enterApp(data.token, data.user, data.role, true);
|
|||
|
|
startJobPolling();
|
|||
|
|
} else { errEl.textContent = "Invalid username or password"; }
|
|||
|
|
} catch { errEl.textContent = "Connection error"; }
|
|||
|
|
btn.disabled = false; btn.textContent = "Sign In";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
document.getElementById("loginPass").addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); });
|
|||
|
|
document.getElementById("loginUser").addEventListener("keydown", (e) => { if (e.key === "Enter") document.getElementById("loginPass").focus(); });
|
|||
|
|
|
|||
|
|
async function doLogout() {
|
|||
|
|
stopJobPolling();
|
|||
|
|
jobsData = []; jobsConnected = false; renderJobs();
|
|||
|
|
try { await fetch("/api/logout", { method: "POST", headers: authHeaders() }); } catch {}
|
|||
|
|
clearSession();
|
|||
|
|
authToken = null; currentRole = null; sessionHistory = []; fileQueue = []; selectedPath = []; expandedPaths.clear();
|
|||
|
|
document.getElementById("appScreen").classList.add("hidden");
|
|||
|
|
document.getElementById("loginScreen").classList.remove("hidden");
|
|||
|
|
document.getElementById("loginUser").value = ""; document.getElementById("loginPass").value = "";
|
|||
|
|
document.getElementById("loginError").textContent = "";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Auto-restore session on page load
|
|||
|
|
(function restoreSession() {
|
|||
|
|
const saved = getSavedSession();
|
|||
|
|
if (!saved) return;
|
|||
|
|
// Verify the token is still valid
|
|||
|
|
fetch("/api/folders", { headers: { "x-auth-token": saved.token } })
|
|||
|
|
.then(r => r.json())
|
|||
|
|
.then(data => {
|
|||
|
|
if (data.success) {
|
|||
|
|
enterApp(saved.token, saved.user, saved.role, false);
|
|||
|
|
startJobPolling();
|
|||
|
|
} else {
|
|||
|
|
clearSession();
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
.catch(() => { clearSession(); });
|
|||
|
|
})();
|
|||
|
|
|
|||
|
|
// ==================== TREE API ====================
|
|||
|
|
async function loadTree() {
|
|||
|
|
try {
|
|||
|
|
const res = await fetch("/api/folders", { headers: authHeaders() });
|
|||
|
|
const data = await res.json();
|
|||
|
|
if (data.success) { tree = data.tree; renderRootBar(); renderTree(); updatePreview(); }
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function apiAddNode(parentPath, name) {
|
|||
|
|
try {
|
|||
|
|
const res = await fetch("/api/folders/add", {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { ...authHeaders(), "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({ path: parentPath, name }),
|
|||
|
|
});
|
|||
|
|
const data = await res.json();
|
|||
|
|
if (data.success) { tree = data.tree; renderRootBar(); renderTree(); updatePreview(); return true; }
|
|||
|
|
else { toast(data.error, "error"); }
|
|||
|
|
} catch { toast("Failed to add folder", "error"); }
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function apiDeleteNode(nodePath) {
|
|||
|
|
try {
|
|||
|
|
const res = await fetch("/api/folders/delete", {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { ...authHeaders(), "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({ path: nodePath }),
|
|||
|
|
});
|
|||
|
|
const data = await res.json();
|
|||
|
|
if (data.success) {
|
|||
|
|
tree = data.tree;
|
|||
|
|
// If selected path starts with deleted path, deselect
|
|||
|
|
const dp = nodePath.join("_");
|
|||
|
|
const sp = selectedPath.slice(0, nodePath.length).join("_");
|
|||
|
|
if (sp === dp) selectedPath = selectedPath.slice(0, nodePath.length - 1);
|
|||
|
|
renderRootBar(); renderTree(); updatePreview();
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
} catch { toast("Failed to delete", "error"); }
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== ROOT BAR (chips) ====================
|
|||
|
|
function renderRootBar() {
|
|||
|
|
const bar = document.getElementById("rootBar");
|
|||
|
|
const activeRoot = selectedPath.length > 0 ? selectedPath[0] : null;
|
|||
|
|
const isAdmin = currentRole === "admin";
|
|||
|
|
|
|||
|
|
let html = `<span class="tree-chip-none ${selectedPath.length === 0 ? "active" : ""}" onclick="selectRoot(null)">None</span>`;
|
|||
|
|
html += tree.map((f) =>
|
|||
|
|
`<span class="tree-chip ${activeRoot === f.name ? "active" : ""}" onclick="selectRoot('${esc(f.name)}')">
|
|||
|
|
${f.name}
|
|||
|
|
${isAdmin ? `<button class="rm" onclick="event.stopPropagation(); deleteRoot('${esc(f.name)}')">✕</button>` : ""}
|
|||
|
|
</span>`
|
|||
|
|
).join("");
|
|||
|
|
bar.innerHTML = html;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function selectRoot(name) {
|
|||
|
|
if (name === null) { selectedPath = []; }
|
|||
|
|
else { selectedPath = [name]; }
|
|||
|
|
renderRootBar(); renderTree(); updatePreview();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function addRoot() {
|
|||
|
|
const input = document.getElementById("rootAddInput");
|
|||
|
|
const name = input.value.trim();
|
|||
|
|
if (!name) return;
|
|||
|
|
const ok = await apiAddNode([], name);
|
|||
|
|
if (ok) {
|
|||
|
|
const cleaned = name.replace(/[^a-zA-Z0-9\-. ]/g, "");
|
|||
|
|
selectedPath = [cleaned];
|
|||
|
|
renderRootBar(); renderTree(); updatePreview();
|
|||
|
|
input.value = "";
|
|||
|
|
toast(`Root folder "${cleaned}" added`, "success");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
document.getElementById("rootAddInput").addEventListener("keydown", (e) => { if (e.key === "Enter") addRoot(); });
|
|||
|
|
|
|||
|
|
async function deleteRoot(name) {
|
|||
|
|
await apiDeleteNode([name]);
|
|||
|
|
toast(`"${name}" removed`, "success");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== TREE PANEL ====================
|
|||
|
|
function toggleTree() {
|
|||
|
|
treePanelOpen = !treePanelOpen;
|
|||
|
|
document.getElementById("treeToggle").classList.toggle("open", treePanelOpen);
|
|||
|
|
document.getElementById("treePanel").classList.toggle("open", treePanelOpen);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderTree() {
|
|||
|
|
const container = document.getElementById("treeContainer");
|
|||
|
|
|
|||
|
|
if (selectedPath.length === 0) {
|
|||
|
|
container.innerHTML = '<div style="font-size:0.78rem; color:var(--text-dim); font-style:italic; padding:0.3rem 0;">Select a root folder to manage subfolders</div>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Find the root node
|
|||
|
|
const rootNode = tree.find((n) => n.name === selectedPath[0]);
|
|||
|
|
if (!rootNode) { container.innerHTML = ''; return; }
|
|||
|
|
|
|||
|
|
// Render tree recursively starting from root's children
|
|||
|
|
container.innerHTML = `
|
|||
|
|
<div style="font-size:0.72rem; color:var(--text-dim); margin-bottom:0.5rem; font-weight:600; letter-spacing:0.08em; text-transform:uppercase;">
|
|||
|
|
${rootNode.name} subfolder tree
|
|||
|
|
</div>
|
|||
|
|
${renderTreeLevel(rootNode.children, [rootNode.name])}
|
|||
|
|
`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderTreeLevel(nodes, parentPath) {
|
|||
|
|
const pk = regPath(parentPath);
|
|||
|
|
const depth = parentPath.length;
|
|||
|
|
const isAdmin = currentRole === "admin";
|
|||
|
|
|
|||
|
|
// Check if any node at this level is selected or in the selected path
|
|||
|
|
let anySelectedAtThisLevel = false;
|
|||
|
|
|
|||
|
|
let html = `<div class="tree-level">`;
|
|||
|
|
|
|||
|
|
for (const node of nodes) {
|
|||
|
|
const nodePath = [...parentPath, node.name];
|
|||
|
|
const nk = regPath(nodePath);
|
|||
|
|
const isExpanded = expandedPaths.has(nk);
|
|||
|
|
const hasChildren = node.children.length > 0;
|
|||
|
|
const isSelected = pathKey(selectedPath) === nk;
|
|||
|
|
const isInSelectedPath = selectedPath.length >= nodePath.length && pathKey(selectedPath.slice(0, nodePath.length)) === nk;
|
|||
|
|
|
|||
|
|
if (isSelected || isInSelectedPath) anySelectedAtThisLevel = true;
|
|||
|
|
|
|||
|
|
html += `<div class="tree-node">`;
|
|||
|
|
html += `<div class="tree-node-row">`;
|
|||
|
|
|
|||
|
|
// Expand arrow
|
|||
|
|
html += `<button class="tree-node-expand ${isExpanded ? "expanded" : ""} ${!hasChildren ? "leaf" : ""}" onclick="toggleExpand('${nk}')">▶</button>`;
|
|||
|
|
|
|||
|
|
// Name (clickable to select)
|
|||
|
|
html += `<span class="tree-node-name ${isSelected ? "selected" : ""}" onclick="selectNodeByKey('${nk}')">${node.name}</span>`;
|
|||
|
|
|
|||
|
|
// Delete (admin only)
|
|||
|
|
if (isAdmin) {
|
|||
|
|
html += `<button class="tree-node-rm" onclick="deleteNodeByKey('${nk}')">✕</button>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
html += `</div>`; // end row
|
|||
|
|
|
|||
|
|
// Children (expanded)
|
|||
|
|
if (hasChildren) {
|
|||
|
|
html += `<div class="tree-children ${isExpanded ? "" : "collapsed"}">`;
|
|||
|
|
html += renderTreeLevel(node.children, nodePath);
|
|||
|
|
html += `</div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Add CHILD input — for going deeper into this node (admin only, when selected)
|
|||
|
|
if (isAdmin && isSelected) {
|
|||
|
|
html += `<div class="tree-node-add" style="padding-left:1.6rem;">`;
|
|||
|
|
html += `<input type="text" placeholder="+ sub of ${node.name}…" id="addChild_${nk}" onkeydown="if(event.key==='Enter')addChildByKey('${nk}')" />`;
|
|||
|
|
html += `<button onclick="addChildByKey('${nk}')">Add</button>`;
|
|||
|
|
html += `</div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
html += `</div>`; // end node
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Empty state
|
|||
|
|
if (nodes.length === 0) {
|
|||
|
|
html += `<div style="font-size:0.72rem; color:var(--text-dim); font-style:italic; padding:0.3rem 0 0.3rem 0.2rem;">No subfolders</div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Add SIBLING input — for adding more items at THIS level (admin only)
|
|||
|
|
// Show when the parent is selected (and has no children yet), OR when any node at this level is selected/in-path
|
|||
|
|
const isParentSelected = pathKey(selectedPath) === pk;
|
|||
|
|
if (isAdmin && (anySelectedAtThisLevel || (isParentSelected && nodes.length === 0))) {
|
|||
|
|
html += `<div class="tree-node-add" style="padding-left:0.2rem; margin-top:0.2rem;">`;
|
|||
|
|
html += `<input type="text" placeholder="+ folder here…" id="addSibling_${pk}" onkeydown="if(event.key==='Enter')addSiblingByKey('${pk}')" />`;
|
|||
|
|
html += `<button onclick="addSiblingByKey('${pk}')">Add</button>`;
|
|||
|
|
html += `</div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
html += `</div>`;
|
|||
|
|
return html;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function toggleExpand(nk) {
|
|||
|
|
if (expandedPaths.has(nk)) expandedPaths.delete(nk);
|
|||
|
|
else expandedPaths.add(nk);
|
|||
|
|
renderTree();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function selectNodeByKey(nk) {
|
|||
|
|
const nodePath = getRegPath(nk);
|
|||
|
|
selectedPath = nodePath;
|
|||
|
|
// Auto-expand parent paths
|
|||
|
|
for (let i = 1; i < nodePath.length; i++) {
|
|||
|
|
expandedPaths.add(pathKey(nodePath.slice(0, i)));
|
|||
|
|
}
|
|||
|
|
renderRootBar(); renderTree(); updatePreview();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function addChildByKey(pk) {
|
|||
|
|
const input = document.getElementById(`addChild_${pk}`);
|
|||
|
|
if (!input) return;
|
|||
|
|
const name = input.value.trim();
|
|||
|
|
if (!name) return;
|
|||
|
|
const cleaned = name.replace(/[^a-zA-Z0-9\-. ]/g, "");
|
|||
|
|
if (!cleaned) return;
|
|||
|
|
const parentPath = getRegPath(pk);
|
|||
|
|
const ok = await apiAddNode(parentPath, cleaned);
|
|||
|
|
if (ok) {
|
|||
|
|
expandedPaths.add(pk);
|
|||
|
|
renderRootBar(); renderTree(); updatePreview();
|
|||
|
|
toast(`"${cleaned}" added`, "success");
|
|||
|
|
const newInput = document.getElementById(`addChild_${pk}`);
|
|||
|
|
if (newInput) { newInput.value = ""; newInput.focus(); }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function addSiblingByKey(pk) {
|
|||
|
|
const input = document.getElementById(`addSibling_${pk}`);
|
|||
|
|
if (!input) return;
|
|||
|
|
const name = input.value.trim();
|
|||
|
|
if (!name) return;
|
|||
|
|
const cleaned = name.replace(/[^a-zA-Z0-9\-. ]/g, "");
|
|||
|
|
if (!cleaned) return;
|
|||
|
|
const parentPath = getRegPath(pk);
|
|||
|
|
const ok = await apiAddNode(parentPath, cleaned);
|
|||
|
|
if (ok) {
|
|||
|
|
renderRootBar(); renderTree(); updatePreview();
|
|||
|
|
toast(`"${cleaned}" added`, "success");
|
|||
|
|
const newInput = document.getElementById(`addSibling_${pk}`);
|
|||
|
|
if (newInput) { newInput.value = ""; newInput.focus(); }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function deleteNodeByKey(nk) {
|
|||
|
|
const nodePath = getRegPath(nk);
|
|||
|
|
const name = nodePath[nodePath.length - 1];
|
|||
|
|
await apiDeleteNode(nodePath);
|
|||
|
|
toast(`"${name}" removed`, "success");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== FILE QUEUE ====================
|
|||
|
|
const dropzone = document.getElementById("dropzone");
|
|||
|
|
const fileInput = document.getElementById("fileInput");
|
|||
|
|
const queueEl = document.getElementById("queue");
|
|||
|
|
const uploadBtn = document.getElementById("uploadBtn");
|
|||
|
|
const btnProgress = document.getElementById("btnProgress");
|
|||
|
|
|
|||
|
|
dropzone.addEventListener("dragover", (e) => { e.preventDefault(); dropzone.classList.add("dragover"); });
|
|||
|
|
dropzone.addEventListener("dragleave", () => { dropzone.classList.remove("dragover"); });
|
|||
|
|
dropzone.addEventListener("drop", (e) => { e.preventDefault(); dropzone.classList.remove("dragover"); addFiles(e.dataTransfer.files); });
|
|||
|
|
fileInput.addEventListener("change", () => { addFiles(fileInput.files); fileInput.value = ""; });
|
|||
|
|
|
|||
|
|
function addFiles(files) {
|
|||
|
|
let blocked = [];
|
|||
|
|
for (const f of files) {
|
|||
|
|
if (!isFileAllowed(f.name)) {
|
|||
|
|
blocked.push(f.name);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
if (!fileQueue.find((q) => q.file.name === f.name && q.file.size === f.size)) {
|
|||
|
|
fileQueue.push({ file: f, status: "pending" });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (blocked.length > 0) {
|
|||
|
|
toast(`Blocked ${blocked.length} file(s): ${blocked.slice(0, 3).join(", ")}${blocked.length > 3 ? "…" : ""} — only media files are accepted`, "error");
|
|||
|
|
}
|
|||
|
|
renderQueue();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function removeFile(index) { fileQueue.splice(index, 1); renderQueue(); }
|
|||
|
|
|
|||
|
|
function formatSize(bytes) {
|
|||
|
|
if (bytes === 0) return "0 B";
|
|||
|
|
const k = 1024; const sizes = ["B", "KB", "MB", "GB", "TB"];
|
|||
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|||
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderQueue() {
|
|||
|
|
const allTransferred = fileQueue.length > 0 && fileQueue.every((q) => q.status === "transferred" || q.status === "done");
|
|||
|
|
const allDone = fileQueue.length > 0 && fileQueue.every((q) => q.status === "done");
|
|||
|
|
|
|||
|
|
uploadBtn.disabled = fileQueue.length === 0 || isUploading;
|
|||
|
|
|
|||
|
|
if (allTransferred || allDone) {
|
|||
|
|
uploadBtn.textContent = "✓ All Transferred — Safe to Close";
|
|||
|
|
} else if (isUploading) {
|
|||
|
|
uploadBtn.textContent = "Uploading…";
|
|||
|
|
} else if (fileQueue.length > 0) {
|
|||
|
|
uploadBtn.textContent = `Upload ${fileQueue.length} File${fileQueue.length !== 1 ? "s" : ""}`;
|
|||
|
|
} else {
|
|||
|
|
uploadBtn.textContent = "Upload Files";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
queueEl.innerHTML = fileQueue.map((item, i) => {
|
|||
|
|
return `<div class="queue-item">
|
|||
|
|
<span class="queue-icon">📄</span>
|
|||
|
|
<div class="queue-info">
|
|||
|
|
<div class="queue-name">${item.file.name}</div>
|
|||
|
|
<div class="queue-meta">${formatSize(item.file.size)}</div>
|
|||
|
|
</div>
|
|||
|
|
<span class="queue-status ${item.status}">${item.status === "pending" ? "Queued" : item.status === "uploading" ? '<span class="spinner"></span> Uploading' : (item.status === "transferred" || item.status === "done") ? "✓ Transferred" : "✗ Failed"}</span>
|
|||
|
|
${item.status === "pending" ? `<button class="queue-remove" onclick="removeFile(${i})">✕</button>` : ""}
|
|||
|
|
</div>`;
|
|||
|
|
}).join("");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== UPLOAD ====================
|
|||
|
|
uploadBtn.addEventListener("click", async () => {
|
|||
|
|
if (fileQueue.length === 0 || isUploading) return;
|
|||
|
|
isUploading = true; renderQueue();
|
|||
|
|
|
|||
|
|
const prefix = buildPrefix();
|
|||
|
|
const formData = new FormData();
|
|||
|
|
formData.append("prefix", prefix);
|
|||
|
|
for (const item of fileQueue) { formData.append("files", item.file); item.status = "uploading"; }
|
|||
|
|
renderQueue();
|
|||
|
|
|
|||
|
|
const progressEl = document.getElementById("uploadProgress");
|
|||
|
|
const progressBarFill = document.getElementById("progressBarFill");
|
|||
|
|
const progressPct = document.getElementById("progressPct");
|
|||
|
|
const progressSpeed = document.getElementById("progressSpeed");
|
|||
|
|
const progressEta = document.getElementById("progressEta");
|
|||
|
|
|
|||
|
|
progressEl.style.display = "";
|
|||
|
|
progressBarFill.style.width = "0%"; progressPct.textContent = "0%"; progressSpeed.textContent = ""; progressEta.textContent = "";
|
|||
|
|
const startTime = Date.now();
|
|||
|
|
let markedTransferred = false;
|
|||
|
|
|
|||
|
|
const xhr = new XMLHttpRequest();
|
|||
|
|
xhr.open("POST", "/api/upload");
|
|||
|
|
xhr.setRequestHeader("x-auth-token", authToken);
|
|||
|
|
|
|||
|
|
xhr.upload.addEventListener("progress", (e) => {
|
|||
|
|
if (!e.lengthComputable) return;
|
|||
|
|
const pct = Math.round((e.loaded / e.total) * 100);
|
|||
|
|
const elapsed = (Date.now() - startTime) / 1000;
|
|||
|
|
const bps = e.loaded / elapsed;
|
|||
|
|
const remaining = (e.total - e.loaded) / bps;
|
|||
|
|
progressBarFill.style.width = pct + "%"; btnProgress.style.width = pct + "%";
|
|||
|
|
progressPct.textContent = pct + "%";
|
|||
|
|
progressSpeed.textContent = formatSize(bps) + "/s";
|
|||
|
|
progressEta.textContent = remaining < 60 ? Math.ceil(remaining) + "s remaining" : Math.ceil(remaining / 60) + "m remaining";
|
|||
|
|
|
|||
|
|
// Mark all files as "transferred" when browser finishes sending
|
|||
|
|
if (pct >= 100 && !markedTransferred) {
|
|||
|
|
markedTransferred = true;
|
|||
|
|
fileQueue.forEach((i) => { i.status = "transferred"; });
|
|||
|
|
renderQueue();
|
|||
|
|
progressEta.textContent = "Complete";
|
|||
|
|
progressSpeed.textContent = "";
|
|||
|
|
|
|||
|
|
// Build session history entries from queue (we know the prefix)
|
|||
|
|
for (const item of fileQueue) {
|
|||
|
|
const key = prefix ? `${prefix}--${item.file.name}` : item.file.name;
|
|||
|
|
sessionHistory.unshift({
|
|||
|
|
originalName: item.file.name,
|
|||
|
|
key,
|
|||
|
|
size: item.file.size,
|
|||
|
|
timestamp: new Date().toISOString(),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
renderHistory();
|
|||
|
|
toast(`${fileQueue.length} file(s) transferred`, "success");
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
xhr.addEventListener("load", () => {
|
|||
|
|
try {
|
|||
|
|
const r = JSON.parse(xhr.responseText);
|
|||
|
|
if (xhr.status < 300 && r.success) {
|
|||
|
|
fileQueue.forEach((i) => { i.status = "done"; });
|
|||
|
|
renderQueue();
|
|||
|
|
progressEta.textContent = "Complete";
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
setTimeout(() => { fileQueue = []; renderQueue(); progressEl.style.display = "none"; }, 2000);
|
|||
|
|
btnProgress.style.width = "0"; isUploading = false; renderQueue();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
xhr.addEventListener("error", () => {
|
|||
|
|
// Files were already transferred — don't mark as failed
|
|||
|
|
if (markedTransferred) {
|
|||
|
|
progressEta.textContent = "Transfer complete (server did not confirm)";
|
|||
|
|
} else {
|
|||
|
|
fileQueue.forEach((i) => { i.status = "error"; }); renderQueue();
|
|||
|
|
progressEta.textContent = "Network error";
|
|||
|
|
toast("Upload failed: network error", "error");
|
|||
|
|
}
|
|||
|
|
setTimeout(() => { fileQueue = []; renderQueue(); progressEl.style.display = "none"; }, 3000);
|
|||
|
|
btnProgress.style.width = "0"; isUploading = false; renderQueue();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
xhr.addEventListener("abort", () => {
|
|||
|
|
if (!markedTransferred) {
|
|||
|
|
fileQueue.forEach((i) => { i.status = "error"; }); renderQueue();
|
|||
|
|
toast("Upload aborted", "error");
|
|||
|
|
}
|
|||
|
|
btnProgress.style.width = "0"; isUploading = false; renderQueue();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Timeout — if server hasn't responded after 3 min, just clean up the UI
|
|||
|
|
setTimeout(() => {
|
|||
|
|
if (xhr.readyState !== 4) {
|
|||
|
|
if (markedTransferred) {
|
|||
|
|
fileQueue.forEach((i) => { i.status = "done"; });
|
|||
|
|
renderQueue();
|
|||
|
|
progressEta.textContent = "Complete";
|
|||
|
|
setTimeout(() => { fileQueue = []; renderQueue(); progressEl.style.display = "none"; }, 1500);
|
|||
|
|
btnProgress.style.width = "0"; isUploading = false; renderQueue();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}, 180000);
|
|||
|
|
|
|||
|
|
xhr.send(formData);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ==================== HISTORY ====================
|
|||
|
|
function renderHistory() {
|
|||
|
|
const list = document.getElementById("historyList"); const empty = document.getElementById("emptyHistory"); const count = document.getElementById("historyCount");
|
|||
|
|
list.querySelectorAll(".history-row").forEach((r) => r.remove());
|
|||
|
|
if (sessionHistory.length === 0) { empty.style.display = ""; count.textContent = ""; return; }
|
|||
|
|
empty.style.display = "none";
|
|||
|
|
count.textContent = `${sessionHistory.length} file${sessionHistory.length !== 1 ? "s" : ""} uploaded this session`;
|
|||
|
|
for (const item of sessionHistory) {
|
|||
|
|
const row = document.createElement("div"); row.className = "history-row";
|
|||
|
|
const time = item.timestamp ? new Date(item.timestamp).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "—";
|
|||
|
|
row.innerHTML = `<span class="history-name" title="${item.key}">${item.key}</span><span class="history-time">${time}</span><span class="history-size">${formatSize(item.size)}</span>`;
|
|||
|
|
list.appendChild(row);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== TOAST ====================
|
|||
|
|
function toast(msg, type = "success") {
|
|||
|
|
const el = document.createElement("div"); el.className = `toast ${type}`;
|
|||
|
|
el.innerHTML = `${type === "success" ? "✓" : "✗"} ${msg}`;
|
|||
|
|
document.getElementById("toasts").appendChild(el);
|
|||
|
|
setTimeout(() => el.remove(), 4000);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== USER MANAGEMENT (admin) ====================
|
|||
|
|
async function loadUsers() {
|
|||
|
|
try {
|
|||
|
|
const res = await fetch("/api/users", { headers: authHeaders() });
|
|||
|
|
const data = await res.json();
|
|||
|
|
if (data.success) renderUsers(data.users);
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderUsers(users) {
|
|||
|
|
const list = document.getElementById("userList");
|
|||
|
|
list.innerHTML = users.map((u) => {
|
|||
|
|
const created = u.created ? new Date(u.created).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) : "—";
|
|||
|
|
return `<div class="user-row">
|
|||
|
|
<span class="user-name">${u.username}</span>
|
|||
|
|
<span class="user-role ${u.role}">${u.role}</span>
|
|||
|
|
<span class="user-date">${created}</span>
|
|||
|
|
<span class="user-actions">
|
|||
|
|
<button class="btn-user-action" onclick="resetPassword('${esc(u.username)}')" title="Reset password">🔑</button>
|
|||
|
|
<button class="btn-user-action" onclick="toggleRole('${esc(u.username)}', '${u.role}')" title="Toggle role">👤</button>
|
|||
|
|
<button class="btn-user-action danger" onclick="deleteUser('${esc(u.username)}')" title="Delete">✕</button>
|
|||
|
|
</span>
|
|||
|
|
</div>`;
|
|||
|
|
}).join("");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function createUser() {
|
|||
|
|
const username = document.getElementById("newUsername").value.trim();
|
|||
|
|
const password = document.getElementById("newPassword").value;
|
|||
|
|
const role = document.getElementById("newRole").value;
|
|||
|
|
if (!username || !password) { toast("Username and password required", "error"); return; }
|
|||
|
|
try {
|
|||
|
|
const res = await fetch("/api/users", {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { ...authHeaders(), "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({ username, password, role }),
|
|||
|
|
});
|
|||
|
|
const data = await res.json();
|
|||
|
|
if (data.success) {
|
|||
|
|
toast(`User "${username}" created`, "success");
|
|||
|
|
document.getElementById("newUsername").value = "";
|
|||
|
|
document.getElementById("newPassword").value = "";
|
|||
|
|
loadUsers();
|
|||
|
|
} else { toast(data.error, "error"); }
|
|||
|
|
} catch { toast("Failed to create user", "error"); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function deleteUser(username) {
|
|||
|
|
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/users/${encodeURIComponent(username)}`, { method: "DELETE", headers: authHeaders() });
|
|||
|
|
const data = await res.json();
|
|||
|
|
if (data.success) { toast(`User "${username}" deleted`, "success"); loadUsers(); }
|
|||
|
|
else { toast(data.error, "error"); }
|
|||
|
|
} catch { toast("Failed to delete user", "error"); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function resetPassword(username) {
|
|||
|
|
const newPass = prompt(`Enter new password for "${username}":`);
|
|||
|
|
if (!newPass) return;
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/users/${encodeURIComponent(username)}/password`, {
|
|||
|
|
method: "PUT",
|
|||
|
|
headers: { ...authHeaders(), "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({ password: newPass }),
|
|||
|
|
});
|
|||
|
|
const data = await res.json();
|
|||
|
|
if (data.success) { toast(`Password reset for "${username}"`, "success"); }
|
|||
|
|
else { toast(data.error, "error"); }
|
|||
|
|
} catch { toast("Failed to reset password", "error"); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function toggleRole(username, currentUserRole) {
|
|||
|
|
const newRole = currentUserRole === "admin" ? "user" : "admin";
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/users/${encodeURIComponent(username)}/role`, {
|
|||
|
|
method: "PUT",
|
|||
|
|
headers: { ...authHeaders(), "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({ role: newRole }),
|
|||
|
|
});
|
|||
|
|
const data = await res.json();
|
|||
|
|
if (data.success) { toast(`${username} is now ${newRole}`, "success"); loadUsers(); }
|
|||
|
|
else { toast(data.error, "error"); }
|
|||
|
|
} catch { toast("Failed to change role", "error"); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== AMPP JOB MONITORING ====================
|
|||
|
|
let jobPollTimer = null;
|
|||
|
|
let jobFilter = ""; // "" = all, "inProgress", "queued", "complete", "failed"
|
|||
|
|
let jobsData = [];
|
|||
|
|
let jobsConnected = false;
|
|||
|
|
let jobsLastRefresh = null;
|
|||
|
|
const JOB_POLL_INTERVAL = 8000; // 8 seconds
|
|||
|
|
const JOB_POLL_ACTIVE = 4000; // 4 seconds when jobs are running
|
|||
|
|
|
|||
|
|
const stateIcons = {
|
|||
|
|
inProgress: "⚙", starting: "⚙", claimed: "⚙",
|
|||
|
|
queued: "⏳",
|
|||
|
|
complete: "✓",
|
|||
|
|
failed: "✗", aborted: "✗",
|
|||
|
|
unknown: "?"
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const stateLabels = {
|
|||
|
|
inProgress: "Running", starting: "Starting", claimed: "Claimed",
|
|||
|
|
queued: "Queued",
|
|||
|
|
complete: "Complete",
|
|||
|
|
failed: "Failed", aborted: "Aborted", aborting: "Aborting",
|
|||
|
|
unknown: "Unknown"
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function stateClass(state) {
|
|||
|
|
if (["inProgress", "starting", "claimed"].includes(state)) return "inProgress";
|
|||
|
|
if (["queued"].includes(state)) return "queued";
|
|||
|
|
if (["complete"].includes(state)) return "complete";
|
|||
|
|
if (["failed", "aborted", "aborting"].includes(state)) return "failed";
|
|||
|
|
return "queued";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setJobFilter(btn, filter) {
|
|||
|
|
jobFilter = filter;
|
|||
|
|
document.querySelectorAll(".jobs-filter-btn").forEach(b => b.classList.remove("active"));
|
|||
|
|
btn.classList.add("active");
|
|||
|
|
renderJobs();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getJobsLast24h() {
|
|||
|
|
const cutoff = Date.now() - (24 * 60 * 60 * 1000);
|
|||
|
|
return jobsData.filter(j => {
|
|||
|
|
const created = j["created:dateTime"];
|
|||
|
|
if (!created) return false;
|
|||
|
|
return new Date(created).getTime() >= cutoff;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getFilteredJobs() {
|
|||
|
|
const recent = getJobsLast24h();
|
|||
|
|
if (!jobFilter) return recent;
|
|||
|
|
return recent.filter(j => {
|
|||
|
|
const state = j["state:jobState"] || "";
|
|||
|
|
const sc = stateClass(state);
|
|||
|
|
if (jobFilter === "inProgress") return sc === "inProgress";
|
|||
|
|
if (jobFilter === "queued") return state === "queued";
|
|||
|
|
if (jobFilter === "complete") return state === "complete";
|
|||
|
|
if (jobFilter === "failed") return sc === "failed";
|
|||
|
|
return true;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function jobTimeAgo(dateStr) {
|
|||
|
|
if (!dateStr) return "—";
|
|||
|
|
const d = new Date(dateStr);
|
|||
|
|
const now = new Date();
|
|||
|
|
const diffMs = now - d;
|
|||
|
|
if (diffMs < 0) return "just now";
|
|||
|
|
const secs = Math.floor(diffMs / 1000);
|
|||
|
|
if (secs < 60) return `${secs}s ago`;
|
|||
|
|
const mins = Math.floor(secs / 60);
|
|||
|
|
if (mins < 60) return `${mins}m ago`;
|
|||
|
|
const hrs = Math.floor(mins / 60);
|
|||
|
|
if (hrs < 24) return `${hrs}h ago`;
|
|||
|
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function jobDuration(started, completed) {
|
|||
|
|
if (!started) return "";
|
|||
|
|
const s = new Date(started);
|
|||
|
|
const e = completed ? new Date(completed) : new Date();
|
|||
|
|
const diffMs = e - s;
|
|||
|
|
if (diffMs < 0) return "";
|
|||
|
|
const secs = Math.floor(diffMs / 1000);
|
|||
|
|
if (secs < 60) return `${secs}s`;
|
|||
|
|
const mins = Math.floor(secs / 60);
|
|||
|
|
const remSecs = secs % 60;
|
|||
|
|
if (mins < 60) return `${mins}m ${remSecs}s`;
|
|||
|
|
const hrs = Math.floor(mins / 60);
|
|||
|
|
return `${hrs}h ${mins % 60}m`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderJobStats() {
|
|||
|
|
let running = 0, queued = 0, complete = 0, failed = 0;
|
|||
|
|
for (const j of getJobsLast24h()) {
|
|||
|
|
const sc = stateClass(j["state:jobState"] || "");
|
|||
|
|
if (sc === "inProgress") running++;
|
|||
|
|
else if (j["state:jobState"] === "queued") queued++;
|
|||
|
|
else if (sc === "complete") complete++;
|
|||
|
|
else if (sc === "failed") failed++;
|
|||
|
|
}
|
|||
|
|
document.getElementById("statRunning").textContent = running;
|
|||
|
|
document.getElementById("statQueued").textContent = queued;
|
|||
|
|
document.getElementById("statComplete").textContent = complete;
|
|||
|
|
document.getElementById("statFailed").textContent = failed;
|
|||
|
|
|
|||
|
|
// Adaptive poll rate — faster when jobs are active
|
|||
|
|
return running > 0 || queued > 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderJobs() {
|
|||
|
|
const listEl = document.getElementById("jobsList");
|
|||
|
|
const emptyEl = document.getElementById("jobsEmpty");
|
|||
|
|
const dotEl = document.getElementById("jobsLiveDot");
|
|||
|
|
const updatedEl = document.getElementById("jobsLastUpdated");
|
|||
|
|
|
|||
|
|
dotEl.classList.toggle("offline", !jobsConnected);
|
|||
|
|
|
|||
|
|
const hasActive = renderJobStats();
|
|||
|
|
|
|||
|
|
const filtered = getFilteredJobs();
|
|||
|
|
|
|||
|
|
if (filtered.length === 0) {
|
|||
|
|
listEl.innerHTML = "";
|
|||
|
|
emptyEl.style.display = "";
|
|||
|
|
if (!jobsConnected) {
|
|||
|
|
emptyEl.innerHTML = '<div class="icon">⚡</div><div>Connecting to AMPP…</div>';
|
|||
|
|
} else if (jobFilter) {
|
|||
|
|
emptyEl.innerHTML = `<div class="icon">📋</div><div>No ${stateLabels[jobFilter] || jobFilter} jobs</div>`;
|
|||
|
|
} else {
|
|||
|
|
emptyEl.innerHTML = '<div class="icon">✓</div><div>No jobs in the last 24 hours</div>';
|
|||
|
|
}
|
|||
|
|
listEl.appendChild(emptyEl);
|
|||
|
|
} else {
|
|||
|
|
emptyEl.style.display = "none";
|
|||
|
|
listEl.innerHTML = filtered.map(j => {
|
|||
|
|
const state = j["state:jobState"] || "unknown";
|
|||
|
|
const sc = stateClass(state);
|
|||
|
|
const progress = j["progress:percent"] || 0;
|
|||
|
|
const assetName = j["assetName:text"] || j["asset:name"] || "Unknown asset";
|
|||
|
|
const created = j["created:dateTime"];
|
|||
|
|
const started = j["started:dateTime"];
|
|||
|
|
const completed = j["completed:dateTime"];
|
|||
|
|
const errorMsg = j["errorMessage:text"] || "";
|
|||
|
|
const jobId = j["job:id"] || "";
|
|||
|
|
|
|||
|
|
const timeDisplay = completed
|
|||
|
|
? jobTimeAgo(completed)
|
|||
|
|
: started
|
|||
|
|
? jobTimeAgo(started)
|
|||
|
|
: jobTimeAgo(created);
|
|||
|
|
|
|||
|
|
const dur = jobDuration(started, completed);
|
|||
|
|
|
|||
|
|
const jobName = j["name:text"] || "";
|
|||
|
|
|
|||
|
|
return `<div class="job-row" title="${errorMsg ? 'Error: ' + errorMsg : jobId}">
|
|||
|
|
<div class="job-state-badge ${sc}">${stateIcons[state] || "?"}</div>
|
|||
|
|
<div class="job-info">
|
|||
|
|
<div class="job-asset-name">${assetName}</div>
|
|||
|
|
<div class="job-meta">
|
|||
|
|
<span style="color:var(--accent-bright)">${jobName}</span>
|
|||
|
|
<span>${stateLabels[state] || state}</span>
|
|||
|
|
${dur ? `<span class="job-duration">${dur}</span>` : ""}
|
|||
|
|
${errorMsg ? `<span style="color:var(--error)">${errorMsg.substring(0, 60)}</span>` : ""}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="job-progress-wrap">
|
|||
|
|
<div class="job-progress-track"><div class="job-progress-fill ${sc}" style="width:${progress}%"></div></div>
|
|||
|
|
<div class="job-progress-pct">${progress}%</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="job-time">${timeDisplay}</div>
|
|||
|
|
</div>`;
|
|||
|
|
}).join("");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (jobsLastRefresh) {
|
|||
|
|
updatedEl.textContent = `Updated ${jobsLastRefresh.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return hasActive;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function refreshJobs(manual) {
|
|||
|
|
if (!authToken) return;
|
|||
|
|
|
|||
|
|
if (manual) {
|
|||
|
|
const btn = document.querySelector(".jobs-refresh-btn");
|
|||
|
|
if (btn) { btn.classList.add("spinning"); setTimeout(() => btn.classList.remove("spinning"), 600); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const url = `/api/ampp/jobs?limit=50`;
|
|||
|
|
const res = await fetch(url, { headers: authHeaders() });
|
|||
|
|
const data = await res.json();
|
|||
|
|
|
|||
|
|
if (data.success) {
|
|||
|
|
jobsConnected = true;
|
|||
|
|
// The API may return an array directly or wrapped
|
|||
|
|
jobsData = Array.isArray(data.jobs) ? data.jobs : (data.jobs?.items || data.jobs?.results || []);
|
|||
|
|
|
|||
|
|
// If the response is a flat object with job fields, it might be a single-item page
|
|||
|
|
if (!Array.isArray(jobsData) && typeof data.jobs === "object") {
|
|||
|
|
// Try to extract array from various response shapes
|
|||
|
|
const keys = Object.keys(data.jobs);
|
|||
|
|
const arrKey = keys.find(k => Array.isArray(data.jobs[k]));
|
|||
|
|
if (arrKey) jobsData = data.jobs[arrKey];
|
|||
|
|
else jobsData = [data.jobs];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
jobsLastRefresh = new Date();
|
|||
|
|
document.getElementById("jobsError").innerHTML = "";
|
|||
|
|
} else {
|
|||
|
|
jobsConnected = false;
|
|||
|
|
document.getElementById("jobsError").innerHTML = `<div class="jobs-error">${data.error || "Failed to connect to AMPP"}</div>`;
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
jobsConnected = false;
|
|||
|
|
document.getElementById("jobsError").innerHTML = `<div class="jobs-error">Connection error: ${err.message}</div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const hasActive = renderJobs();
|
|||
|
|
|
|||
|
|
// Schedule next poll — faster when jobs are active
|
|||
|
|
clearTimeout(jobPollTimer);
|
|||
|
|
jobPollTimer = setTimeout(() => refreshJobs(false), hasActive ? JOB_POLL_ACTIVE : JOB_POLL_INTERVAL);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function startJobPolling() {
|
|||
|
|
refreshJobs(false);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function stopJobPolling() {
|
|||
|
|
clearTimeout(jobPollTimer);
|
|||
|
|
jobPollTimer = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Job polling is started from doLogin and restoreSession directly — no monkey-patching needed.
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|