2026-03-31 15:29:50 -04:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > AME Remote Job Manager< / title >
< style >
:root {
--bg: #0f1117;
--bg-card: #1a1d27;
--bg-card-hover: #22263a;
--bg-input: #141720;
--border: #2a2e3d;
--text: #e4e6ef;
--text-muted: #8b8fa3;
--accent: #5b7fff;
--accent-hover: #7094ff;
--success: #34d399;
--warning: #fbbf24;
--error: #f87171;
--encoding: #a78bfa;
--radius: 8px;
}
.light {
--bg: #f3f4f6;
--bg-card: #ffffff;
--bg-card-hover: #f9fafb;
--bg-input: #ffffff;
--border: #d1d5db;
--text: #111827;
--text-muted: #6b7280;
--accent: #4f6ae6;
--accent-hover: #3b55d0;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
min-height: 100vh;
}
/* ─── Layout ─────────────────────────────── */
.app { max-width: 1000px; margin: 0 auto; padding: 24px 20px; }
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border);
}
header h1 { font-size: 20px; font-weight: 600; }
header h1 span { color: var(--accent); }
.header-actions { display: flex; align-items: center; gap: 12px; }
/* ─── Buttons ────────────────────────────── */
button, .btn {
padding: 8px 16px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text);
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.15s;
}
button:hover { background: var(--bg-card-hover); border-color: var(--accent); }
.btn-primary {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.btn-primary:hover { background: var(--accent-hover); }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.btn-danger { color: var(--error); }
.btn-danger:hover { border-color: var(--error); }
/* ─── Login ──────────────────────────────── */
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.login-box {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 40px;
width: 360px;
}
.login-box h2 { margin-bottom: 24px; font-size: 18px; text-align: center; }
.form-group { margin-bottom: 16px; }
.form-group label {
display: block;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
input[type="text"], input[type="password"] {
width: 100%;
padding: 10px 12px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-input);
color: var(--text);
font-size: 14px;
outline: none;
}
input:focus { border-color: var(--accent); }
.login-box button { width: 100%; margin-top: 8px; padding: 10px; }
.error-msg { color: var(--error); font-size: 13px; margin-top: 8px; text-align: center; }
/* ─── Status Cards ───────────────────────── */
.status-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 28px;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
text-align: center;
}
.stat-card .stat-value {
font-size: 28px;
font-weight: 700;
line-height: 1.2;
}
.stat-card .stat-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin-top: 4px;
}
.stat-queued .stat-value { color: var(--warning); }
.stat-encoding .stat-value { color: var(--encoding); }
.stat-complete .stat-value { color: var(--success); }
.stat-error .stat-value { color: var(--error); }
/* ─── Upload Area ────────────────────────── */
.upload-section {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
margin-bottom: 28px;
}
.upload-section h2 { font-size: 15px; font-weight: 600; margin-bottom: 16px; }
.drop-zone {
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.drop-zone:hover, .drop-zone.dragover {
border-color: var(--accent);
background: rgba(91, 127, 255, 0.05);
}
.drop-zone p { color: var(--text-muted); font-size: 14px; }
.drop-zone p strong { color: var(--text); }
.upload-progress {
margin-top: 16px;
display: none;
}
.upload-progress.active { display: block; }
.progress-bar-container {
height: 6px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
margin-top: 8px;
}
.progress-bar {
height: 100%;
background: var(--accent);
border-radius: 3px;
width: 0%;
transition: width 0.3s;
}
.upload-status { font-size: 13px; color: var(--text-muted); margin-top: 8px; }
/* ─── Analysis Modal ─────────────────────── */
.modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 100;
justify-content: center;
align-items: center;
}
.modal-overlay.active { display: flex; }
.modal {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 28px;
width: 560px;
max-height: 80vh;
overflow-y: auto;
}
.modal h3 { font-size: 16px; margin-bottom: 16px; }
.mapping-list { list-style: none; margin: 12px 0; }
.mapping-list li {
padding: 10px 12px;
background: var(--bg);
border-radius: var(--radius);
margin-bottom: 6px;
font-size: 12px;
font-family: monospace;
}
.mapping-arrow { color: var(--accent); margin: 0 8px; }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
/* ─── Jobs Table ─────────────────────────── */
.jobs-section h2 { font-size: 15px; font-weight: 600; margin-bottom: 16px; }
.job-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 20px;
margin-bottom: 8px;
display: grid;
grid-template-columns: 1fr auto auto auto;
align-items: center;
gap: 16px;
transition: background 0.15s;
}
.job-card:hover { background: var(--bg-card-hover); }
.job-name { font-weight: 500; font-size: 14px; }
.job-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.status-badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.status-queued { background: rgba(251, 191, 36, 0.15); color: var(--warning); }
.status-encoding { background: rgba(167, 139, 250, 0.15); color: var(--encoding); }
.status-complete { background: rgba(52, 211, 153, 0.15); color: var(--success); }
.status-error { background: rgba(248, 113, 113, 0.15); color: var(--error); }
.job-time { font-size: 12px; color: var(--text-muted); white-space: nowrap; }
.job-actions { display: flex; gap: 6px; }
.no-jobs {
text-align: center;
padding: 48px 20px;
color: var(--text-muted);
font-size: 14px;
}
/* ─── System Status ──────────────────────── */
.system-status {
margin-top: 28px;
padding: 16px 20px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 12px;
color: var(--text-muted);
display: flex;
gap: 24px;
}
.system-status .indicator {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
margin-right: 6px;
}
.indicator.online { background: var(--success); }
.indicator.offline { background: var(--error); }
/* ─── AME Stats ──────────────────────────── */
.ame-stats {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 28px;
}
.ame-stats h2 {
font-size: 15px;
font-weight: 600;
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.ame-stats h2 .ame-badge {
font-size: 10px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.ame-badge.connected { background: rgba(52, 211, 153, 0.15); color: var(--success); }
.ame-badge.disconnected { background: rgba(248, 113, 113, 0.15); color: var(--error); }
.ame-stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.ame-stat {
text-align: center;
padding: 12px 8px;
background: var(--bg);
border-radius: var(--radius);
}
.ame-stat .val { font-size: 20px; font-weight: 700; }
.ame-stat .lbl {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin-top: 2px;
}
.ame-recent { margin-top: 12px; }
.ame-recent h3 { font-size: 13px; font-weight: 600; margin-bottom: 8px; }
.ame-entry {
display: grid;
grid-template-columns: auto 1fr auto auto;
gap: 10px;
align-items: center;
padding: 8px 12px;
background: var(--bg);
border-radius: var(--radius);
margin-bottom: 4px;
font-size: 12px;
}
.ame-entry-icon { font-size: 14px; }
.ame-entry-file {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: monospace;
font-size: 11px;
}
.ame-entry-time { color: var(--text-muted); white-space: nowrap; }
.ame-entry-duration { color: var(--accent); white-space: nowrap; font-weight: 500; }
.ame-unavailable {
text-align: center;
padding: 20px;
color: var(--text-muted);
font-size: 13px;
}
/* ─── Logo ───────────────────────────────── */
.header-logo {
display: flex;
align-items: center;
gap: 12px;
}
.header-logo img {
height: 36px;
width: auto;
display: block;
}
.header-title {
display: flex;
flex-direction: column;
gap: 1px;
}
.header-title h1 { font-size: 18px; font-weight: 600; }
.header-title h1 span { color: var(--accent); }
.header-title .header-subtitle {
font-size: 11px;
color: var(--text-muted);
letter-spacing: 0.3px;
text-transform: uppercase;
}
/* ─── Settings Panel ─────────────────────── */
.settings-panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px 24px;
margin-bottom: 28px;
display: none;
}
.settings-panel.open { display: block; }
.settings-panel h2 {
font-size: 15px;
font-weight: 600;
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
@media (max-width: 640px) {
.settings-grid { grid-template-columns: 1fr; }
}
.settings-field label {
display: block;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.settings-field input[type="text"] {
width: 100%;
padding: 8px 12px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-input);
color: var(--text);
font-size: 13px;
font-family: monospace;
}
.settings-field input:focus { border-color: var(--accent); outline: none; }
.settings-help {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
}
.settings-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 4px;
}
.settings-status {
font-size: 12px;
color: var(--success);
align-self: center;
margin-right: auto;
display: none;
}
.settings-status.visible { display: block; }
/* ─── Responsive ─────────────────────────── */
@media (max-width: 640px) {
.status-row { grid-template-columns: repeat(2, 1fr); }
.job-card { grid-template-columns: 1fr; gap: 8px; }
}
< / style >
< / head >
< body >
< div class = "app" id = "app" >
<!-- Login Screen -->
< div id = "login-screen" class = "login-container" >
< div class = "login-box" >
< h2 > AME Remote Job Manager< / h2 >
< div class = "form-group" >
< label > Username< / label >
< input type = "text" id = "login-user" placeholder = "Username" autocomplete = "username" >
< / div >
< div class = "form-group" >
< label > Password< / label >
< input type = "password" id = "login-pass" placeholder = "Password" autocomplete = "current-password" >
< / div >
< button class = "btn-primary" onclick = "login()" > Sign In< / button >
< div id = "login-error" class = "error-msg" > < / div >
< / div >
< / div >
<!-- Main Dashboard -->
< div id = "dashboard" style = "display:none" >
< header >
< div class = "header-logo" >
< img src = "/logo.png" alt = "Wild Dragon" title = "Wild Dragon" >
< div class = "header-title" >
< h1 > < span > AME< / span > Remote Job Manager< / h1 >
< div class = "header-subtitle" > Broadcast Management Group< / div >
< / div >
< / div >
< div class = "header-actions" >
< span id = "user-label" style = "font-size:13px;color:var(--text-muted)" > < / span >
< button class = "btn-sm" onclick = "toggleSettings()" title = "Settings" id = "settings-btn" > ⚙ Settings< / button >
< button class = "btn-sm" onclick = "toggleTheme()" title = "Toggle theme" > ◑< / button >
< button class = "btn-sm btn-danger" onclick = "logout()" > Sign Out< / button >
< / div >
< / header >
<!-- Settings Panel -->
< div class = "settings-panel" id = "settings-panel" >
< h2 >
Configuration
< button class = "btn-sm" onclick = "toggleSettings()" style = "font-size:18px;padding:0 6px;border:none;background:none;cursor:pointer;color:var(--text-muted)" > × < / button >
< / h2 >
<!-- Section: Folder Paths -->
< div style = "font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:10px;" > Folder Paths< / div >
< div class = "settings-grid" >
< div class = "settings-field" >
< label > AME Watch Folder Path< / label >
< input type = "text" id = "setting-watch-folder" placeholder = "e.g. /watch or //172.18.x.x/AMEWatch" >
< div class = "settings-help" > Path where .prproj files are delivered for AME to pick up. Use a UNC/SMB path to point directly to a network share — e.g. < code > //172.18.210.5/AMEWatch< / code > < / div >
< / div >
< div class = "settings-field" >
< label > Output / Render Folder Path< / label >
< input type = "text" id = "setting-output-folder" placeholder = "e.g. /output or //172.18.x.x/Renders" >
< div class = "settings-help" > Path where AME writes rendered files. Used to detect job completion.< / div >
< / div >
< div class = "settings-field" style = "grid-column: 1 / -1" >
< label > AME Log Directory< / label >
< input type = "text" id = "setting-ame-log-dir" placeholder = "e.g. /ame-logs or /Users/username/Documents/Adobe/Adobe Media Encoder/25.0" >
< div class = "settings-help" > Path to the folder containing < code > AMEEncodingLog.txt< / code > . On macOS: < code > /Users/< user> /Documents/Adobe/Adobe Media Encoder/< version> < / code > < / div >
< / div >
< / div >
<!-- Section: SMB Credentials -->
< div style = "font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin:16px 0 10px;" > SMB / Network Share Credentials< / div >
< div class = "settings-help" style = "margin-bottom:12px;" >
Used when the watch or output folder is an SMB share that requires authentication. Credentials are stored server-side and never exposed in the browser.
< / div >
< div class = "settings-grid" >
< div class = "settings-field" >
< label > SMB Username< / label >
< input type = "text" id = "setting-smb-username" placeholder = "e.g. smbuser or DOMAIN\user" autocomplete = "off" >
< / div >
< div class = "settings-field" >
< label > SMB Password < span id = "smb-password-set-indicator" style = "color:var(--success);display:none" > ✓ saved< / span > < / label >
< input type = "password" id = "setting-smb-password" placeholder = "Leave blank to keep existing" autocomplete = "new-password" >
< / div >
< div class = "settings-field" >
< label > SMB Domain / Workgroup< / label >
< input type = "text" id = "setting-smb-domain" placeholder = "e.g. WORKGROUP or BMG" autocomplete = "off" >
< div class = "settings-help" > Optional. Leave blank if not required.< / div >
< / div >
< div class = "settings-field" >
< label > Notes< / label >
< input type = "text" id = "setting-smb-notes" placeholder = "e.g. \\172.18.210.5\AMEWatch" >
< div class = "settings-help" > Optional reference note. Not used by the system.< / div >
< / div >
< / div >
< div class = "settings-actions" >
< span class = "settings-status" id = "settings-status" > ✓ Settings saved< / span >
< button onclick = "loadSettings()" > Reset< / button >
< button class = "btn-primary" onclick = "saveSettings()" > Save Settings< / button >
< / div >
< / div >
<!-- Status Cards -->
< div class = "status-row" >
< div class = "stat-card stat-queued" >
< div class = "stat-value" id = "stat-queued" > 0< / div >
< div class = "stat-label" > Queued< / div >
< / div >
< div class = "stat-card stat-encoding" >
< div class = "stat-value" id = "stat-encoding" > 0< / div >
< div class = "stat-label" > Encoding< / div >
< / div >
< div class = "stat-card stat-complete" >
< div class = "stat-value" id = "stat-complete" > 0< / div >
< div class = "stat-label" > Complete< / div >
< / div >
< div class = "stat-card stat-error" >
< div class = "stat-value" id = "stat-error" > 0< / div >
< div class = "stat-label" > Errors< / div >
< / div >
< / div >
<!-- AME Stats Panel -->
< div class = "ame-stats" id = "ame-stats-panel" >
< h2 >
Adobe Media Encoder
< span class = "ame-badge disconnected" id = "ame-connection-badge" > No Logs< / span >
< / h2 >
< div id = "ame-content" >
< div class = "ame-unavailable" >
AME logs not connected. Mount the AME log directory to see encoding stats.
< / div >
< / div >
< / div >
<!-- Upload Section -->
< div class = "upload-section" >
< h2 > Submit Project for Encoding< / h2 >
< div class = "drop-zone" id = "drop-zone" onclick = "document.getElementById('file-input').click()" >
< p > < strong > Drop .prproj file here< / strong > or click to browse< / p >
< p style = "margin-top:6px;font-size:12px" > Files will be analyzed, remapped to high-res paths, and delivered to the AME watch folder< / p >
< / div >
< input type = "file" id = "file-input" accept = ".prproj" style = "display:none" >
< div class = "upload-progress" id = "upload-progress" >
< div class = "progress-bar-container" >
< div class = "progress-bar" id = "progress-bar" > < / div >
< / div >
< div class = "upload-status" id = "upload-status" > Uploading...< / div >
< / div >
< / div >
<!-- Jobs List -->
< div class = "jobs-section" >
< h2 > Job Queue< / h2 >
< div id = "jobs-list" >
< div class = "no-jobs" > No jobs yet. Upload a .prproj to get started.< / div >
< / div >
< / div >
<!-- System Status -->
< div class = "system-status" id = "system-status" >
< div > < span class = "indicator offline" id = "watch-indicator" > < / span > Watch Folder: < span id = "watch-status" > checking...< / span > < / div >
< div > < span class = "indicator offline" id = "output-indicator" > < / span > Output Folder: < span id = "output-status" > checking...< / span > < / div >
< / div >
< / div >
<!-- Analysis Modal -->
< div class = "modal-overlay" id = "analysis-modal" >
< div class = "modal" >
< h3 > Project Analysis< / h3 >
< div id = "analysis-content" > < / div >
< div class = "modal-actions" >
< button onclick = "closeModal()" > Cancel< / button >
< button class = "btn-primary" id = "confirm-submit" onclick = "confirmSubmit()" > Submit for Encoding< / button >
< / div >
< / div >
< / div >
<!-- Job Detail Modal -->
< div class = "modal-overlay" id = "detail-modal" >
< div class = "modal" >
< h3 id = "detail-title" > Job Details< / h3 >
< div id = "detail-content" > < / div >
< div class = "modal-actions" >
< button onclick = "closeDetailModal()" > Close< / button >
< / div >
< / div >
< / div >
< / div >
< script >
// ─── State ──────────────────────────────────
let sessionId = localStorage.getItem('ame_session');
let pendingFile = null;
let pollTimer = null;
// ─── API Helpers ────────────────────────────
async function api(method, path, body, isFormData) {
const opts = {
method,
headers: {}
};
if (sessionId) opts.headers['x-session-id'] = sessionId;
if (body & & !isFormData) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
if (isFormData) {
opts.body = body;
}
const res = await fetch('/api' + path, opts);
const data = await res.json();
if (res.status === 401) {
sessionId = null;
localStorage.removeItem('ame_session');
showLogin();
}
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
// ─── Auth ───────────────────────────────────
async function login() {
const user = document.getElementById('login-user').value;
const pass = document.getElementById('login-pass').value;
const errEl = document.getElementById('login-error');
errEl.textContent = '';
try {
const data = await api('POST', '/login', { username: user, password: pass });
sessionId = data.sessionId;
localStorage.setItem('ame_session', sessionId);
showDashboard(data.username);
} catch (e) {
errEl.textContent = e.message;
}
}
function logout() {
api('POST', '/logout').catch(() => {});
sessionId = null;
localStorage.removeItem('ame_session');
showLogin();
}
function showLogin() {
document.getElementById('login-screen').style.display = 'flex';
document.getElementById('dashboard').style.display = 'none';
if (pollTimer) clearInterval(pollTimer);
}
function showDashboard(username) {
document.getElementById('login-screen').style.display = 'none';
document.getElementById('dashboard').style.display = 'block';
document.getElementById('user-label').textContent = username || '';
loadSettings();
refreshAll();
pollTimer = setInterval(refreshAll, 5000);
}
// ─── Settings ───────────────────────────────
function toggleSettings() {
const panel = document.getElementById('settings-panel');
panel.classList.toggle('open');
}
async function loadSettings() {
try {
const data = await api('GET', '/settings');
document.getElementById('setting-watch-folder').value = data.watchFolder || '';
document.getElementById('setting-output-folder').value = data.outputFolder || '';
document.getElementById('setting-ame-log-dir').value = data.ameLogDir || '';
document.getElementById('setting-smb-username').value = data.smbUsername || '';
document.getElementById('setting-smb-password').value = ''; // never pre-fill password
document.getElementById('setting-smb-domain').value = data.smbDomain || '';
document.getElementById('setting-smb-notes').value = data.smbNotes || '';
// Show indicator if a password is already saved
const pwIndicator = document.getElementById('smb-password-set-indicator');
pwIndicator.style.display = data.smbPasswordSet ? 'inline' : 'none';
} catch (e) {
console.error('Failed to load settings:', e);
}
}
async function saveSettings() {
const payload = {
watchFolder: document.getElementById('setting-watch-folder').value.trim(),
outputFolder: document.getElementById('setting-output-folder').value.trim(),
ameLogDir: document.getElementById('setting-ame-log-dir').value.trim(),
smbUsername: document.getElementById('setting-smb-username').value.trim(),
smbPassword: document.getElementById('setting-smb-password').value, // empty = keep existing
smbDomain: document.getElementById('setting-smb-domain').value.trim(),
smbNotes: document.getElementById('setting-smb-notes').value.trim()
};
try {
const result = await api('POST', '/settings', payload);
// Update password indicator
const pwIndicator = document.getElementById('smb-password-set-indicator');
pwIndicator.style.display = result.settings.smbPasswordSet ? 'inline' : 'none';
document.getElementById('setting-smb-password').value = '';
const statusEl = document.getElementById('settings-status');
statusEl.classList.add('visible');
setTimeout(() => statusEl.classList.remove('visible'), 3000);
refreshAll();
} catch (e) {
alert('Failed to save settings: ' + e.message);
}
}
// ─── Theme ──────────────────────────────────
function toggleTheme() {
document.body.classList.toggle('light');
localStorage.setItem('ame_theme', document.body.classList.contains('light') ? 'light' : 'dark');
}
if (localStorage.getItem('ame_theme') === 'light') document.body.classList.add('light');
// ─── Dashboard Data ─────────────────────────
async function refreshAll() {
try {
const [statusData, jobsData] = await Promise.all([
api('GET', '/status'),
api('GET', '/jobs')
]);
updateStats(statusData.counts);
updateSystemStatus(statusData);
updateJobs(jobsData.jobs);
} catch (e) {
console.error('Refresh error:', e);
}
}
function updateStats(counts) {
document.getElementById('stat-queued').textContent = counts.queued;
document.getElementById('stat-encoding').textContent = counts.encoding;
document.getElementById('stat-complete').textContent = counts.complete;
document.getElementById('stat-error').textContent = counts.error;
}
function updateSystemStatus(data) {
const wi = document.getElementById('watch-indicator');
const oi = document.getElementById('output-indicator');
wi.className = 'indicator ' + (data.watchFolder.exists ? 'online' : 'offline');
oi.className = 'indicator ' + (data.outputFolder.exists ? 'online' : 'offline');
document.getElementById('watch-status').textContent =
data.watchFolder.exists ? `${data.watchFolder.path} (${data.watchFolder.files.length} files)` : 'not found';
document.getElementById('output-status').textContent =
data.outputFolder.exists ? `${data.outputFolder.path} (${data.outputFolder.files.length} files)` : 'not found';
// Update AME stats panel
updateAMEStats(data.ame);
}
function updateAMEStats(ame) {
const badge = document.getElementById('ame-connection-badge');
const content = document.getElementById('ame-content');
if (!ame || !ame.logDirExists) {
badge.className = 'ame-badge disconnected';
badge.textContent = 'No Logs';
content.innerHTML = '< div class = "ame-unavailable" > AME logs not connected. Mount the AME log directory to see encoding stats.< / div > ';
return;
}
const hasLogs = ame.encodingLog.exists || ame.errorLog.exists;
badge.className = 'ame-badge ' + (hasLogs ? 'connected' : 'disconnected');
badge.textContent = hasLogs ? 'Connected' : 'No Logs Found';
if (!hasLogs) {
content.innerHTML = '< div class = "ame-unavailable" > Log directory found but no AME log files yet. Encode something in AME to generate logs.< / div > ';
return;
}
const stats = ame.stats;
const avgTime = stats.averageEncodingTimeMs ? formatMs(stats.averageEncodingTimeMs) : '– ';
const totalTime = stats.totalEncodingTimeMs ? formatMs(stats.totalEncodingTimeMs) : '– ';
const lastFile = stats.lastEncoded ? (stats.lastEncoded.outputFile || stats.lastEncoded.sourceFile || '– ').split('/').pop().split('\\').pop() : '– ';
let html = `
< div class = "ame-stats-grid" >
< div class = "ame-stat" >
< div class = "val" style = "color:var(--success)" > ${stats.totalEncoded}< / div >
< div class = "lbl" > Encoded< / div >
< / div >
< div class = "ame-stat" >
< div class = "val" style = "color:var(--error)" > ${stats.totalErrors}< / div >
< div class = "lbl" > Errors< / div >
< / div >
< div class = "ame-stat" >
< div class = "val" style = "color:var(--accent)" > ${avgTime}< / div >
< div class = "lbl" > Avg Time< / div >
< / div >
< div class = "ame-stat" >
< div class = "val" style = "color:var(--text-muted);font-size:14px;word-break:break-all" > ${esc(lastFile)}< / div >
< div class = "lbl" > Last Output< / div >
< / div >
< / div >
`;
// Recent entries
if (stats.recentEntries & & stats.recentEntries.length > 0) {
html += '< div class = "ame-recent" > < h3 > Recent AME Activity< / h3 > ';
const showing = stats.recentEntries.slice(0, 8);
for (const entry of showing) {
const icon = entry.type === 'error' ? '✕' : '✓';
const iconColor = entry.type === 'error' ? 'var(--error)' : 'var(--success)';
const file = (entry.outputFile || entry.sourceFile || 'unknown').split('/').pop().split('\\').pop();
const time = entry.timestamp || entry.date || '';
const dur = entry.encodingTime || '';
html += `
< div class = "ame-entry" >
< span class = "ame-entry-icon" style = "color:${iconColor}" > ${icon}< / span >
< span class = "ame-entry-file" title = "${esc(entry.outputFile || entry.sourceFile || '')}" > ${esc(file)}< / span >
< span class = "ame-entry-time" > ${esc(time)}< / span >
< span class = "ame-entry-duration" > ${esc(dur)}< / span >
< / div >
`;
}
html += '< / div > ';
}
content.innerHTML = html;
}
function formatMs(ms) {
const secs = Math.floor(ms / 1000);
const hrs = Math.floor(secs / 3600);
const mins = Math.floor((secs % 3600) / 60);
const s = secs % 60;
if (hrs > 0) return hrs + 'h ' + mins + 'm';
if (mins > 0) return mins + 'm ' + s + 's';
return s + 's';
}
function updateJobs(jobs) {
const container = document.getElementById('jobs-list');
if (!jobs.length) {
container.innerHTML = '< div class = "no-jobs" > No jobs yet. Upload a .prproj to get started.< / div > ';
return;
}
container.innerHTML = jobs.map(job => `
< div class = "job-card" onclick = "showJobDetail('${job.id}')" >
< div >
< div class = "job-name" > ${esc(job.originalFilename)}< / div >
< div class = "job-meta" >
${job.remapReport ? job.remapReport.swapsPerformed + ' clips remapped' : ''}
· by ${esc(job.submittedBy)}
< / div >
< / div >
< span class = "status-badge status-${job.status}" > ${job.status}< / span >
< div class = "job-time" > ${timeAgo(job.submittedAt)}< / div >
< div class = "job-actions" >
< button class = "btn-sm btn-danger" onclick = "event.stopPropagation(); deleteJob('${job.id}')" title = "Delete" > ✕< / button >
< / div >
< / div >
`).join('');
}
// ─── File Upload ────────────────────────────
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
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');
const file = e.dataTransfer.files[0];
if (file & & file.name.endsWith('.prproj')) handleFile(file);
});
fileInput.addEventListener('change', () => {
if (fileInput.files[0]) handleFile(fileInput.files[0]);
fileInput.value = '';
});
async function handleFile(file) {
pendingFile = file;
// Analyze first
const formData = new FormData();
formData.append('prproj', file);
const progressEl = document.getElementById('upload-progress');
const statusEl = document.getElementById('upload-status');
progressEl.classList.add('active');
statusEl.textContent = 'Analyzing project file...';
document.getElementById('progress-bar').style.width = '30%';
try {
const data = await api('POST', '/jobs/analyze', formData, true);
progressEl.classList.remove('active');
showAnalysisModal(data.analysis, file.name);
} catch (e) {
statusEl.textContent = 'Analysis failed: ' + e.message;
document.getElementById('progress-bar').style.width = '100%';
document.getElementById('progress-bar').style.background = 'var(--error)';
setTimeout(() => {
progressEl.classList.remove('active');
document.getElementById('progress-bar').style.background = '';
}, 3000);
}
}
function showAnalysisModal(analysis, filename) {
const content = document.getElementById('analysis-content');
let html = `
< p style = "margin-bottom:12px;font-size:13px" >
< strong > ${esc(filename)}< / strong > — ${analysis.totalMediaBlocks} media blocks found
< / p >
< p style = "font-size:13px;margin-bottom:8px" >
< strong > ${analysis.gvesReferences}< / strong > .gves references →
< strong > ${analysis.mappedPairs}< / strong > mapped to high-res paths
< / p >
`;
if (analysis.unmappedGves > 0) {
html += `< p style = "color:var(--warning);font-size:13px;margin-bottom:8px" >
⚠ ${analysis.unmappedGves} .gves reference(s) could not be mapped to high-res
< / p > `;
}
if (analysis.hiresFormats.length) {
html += `< p style = "font-size:13px;margin-bottom:12px" >
Output formats: ${analysis.hiresFormats.map(f => '< code > ' + f.toUpperCase() + '< / code > ').join(', ')}
< / p > `;
}
if (analysis.mappings.length) {
html += '< ul class = "mapping-list" > ';
analysis.mappings.forEach(m => {
const gvesShort = m.gvesPath.split('\\').pop();
const hiresShort = m.hiresPath.split('\\').pop();
html += `< li > ${esc(gvesShort)} < span class = "mapping-arrow" > →< / span > ${esc(hiresShort)}< / li > `;
});
html += '< / ul > ';
}
content.innerHTML = html;
document.getElementById('analysis-modal').classList.add('active');
}
function closeModal() {
document.getElementById('analysis-modal').classList.remove('active');
pendingFile = null;
}
async function confirmSubmit() {
if (!pendingFile) return;
2026-03-31 16:42:25 -04:00
const fileToSubmit = pendingFile; // save ref before closeModal nulls it
2026-03-31 15:29:50 -04:00
closeModal();
const formData = new FormData();
2026-03-31 16:42:25 -04:00
formData.append('prproj', fileToSubmit);
2026-03-31 15:29:50 -04:00
const progressEl = document.getElementById('upload-progress');
const statusEl = document.getElementById('upload-status');
const barEl = document.getElementById('progress-bar');
progressEl.classList.add('active');
statusEl.textContent = 'Remapping and submitting to watch folder...';
barEl.style.width = '60%';
try {
const data = await api('POST', '/jobs', formData, true);
barEl.style.width = '100%';
statusEl.textContent = `✓ Job submitted — ${data.job.remapReport.swapsPerformed} clips remapped`;
barEl.style.background = 'var(--success)';
setTimeout(() => {
progressEl.classList.remove('active');
barEl.style.background = '';
barEl.style.width = '0%';
}, 3000);
refreshAll();
} catch (e) {
barEl.style.width = '100%';
barEl.style.background = 'var(--error)';
statusEl.textContent = 'Submit failed: ' + e.message;
setTimeout(() => {
progressEl.classList.remove('active');
barEl.style.background = '';
barEl.style.width = '0%';
}, 5000);
}
pendingFile = null;
}
// ─── Job Details ────────────────────────────
async function showJobDetail(jobId) {
try {
const data = await api('GET', '/jobs/' + jobId);
const job = data.job;
document.getElementById('detail-title').textContent = job.originalFilename;
let html = `
< p style = "font-size:13px;margin-bottom:8px" >
< strong > Status:< / strong > < span class = "status-badge status-${job.status}" > ${job.status}< / span >
< / p >
< p style = "font-size:13px;margin-bottom:4px" >
< strong > Submitted:< / strong > ${new Date(job.submittedAt).toLocaleString()} by ${esc(job.submittedBy)}
< / p >
< p style = "font-size:13px;margin-bottom:4px" >
< strong > Watch folder file:< / strong > < code > ${esc(job.remappedFilename)}< / code >
< / p >
`;
if (job.error) {
html += `< p style = "color:var(--error);font-size:13px;margin-top:8px" >
< strong > Error:< / strong > ${esc(job.error)}
< / p > `;
}
if (job.outputFiles & & job.outputFiles.length) {
html += `< p style = "font-size:13px;margin-top:8px" > < strong > Output files:< / strong > < / p >
< ul style = "font-size:12px;margin:4px 0 0 20px" > ${job.outputFiles.map(f => '< li > ' + esc(f) + '< / li > ').join('')}< / ul > `;
}
if (job.remapReport) {
const r = job.remapReport;
html += `< p style = "font-size:13px;margin-top:12px" > < strong > Remap Report:< / strong > < / p >
< p style = "font-size:12px;color:var(--text-muted)" >
${r.totalMediaBlocks} media blocks · ${r.gvesBlocks} .gves refs · ${r.swapsPerformed} swaps
< / p > `;
if (r.swaps & & r.swaps.length) {
html += '< ul class = "mapping-list" > ';
r.swaps.forEach(s => {
const oldShort = s.oldPath.split('\\').pop();
const newShort = s.newPath.split('\\').pop();
html += `< li > ${esc(oldShort)} < span class = "mapping-arrow" > →< / span > ${esc(newShort)}< / li > `;
});
html += '< / ul > ';
}
if (r.unmappedGves & & r.unmappedGves.length) {
html += `< p style = "color:var(--warning);font-size:12px;margin-top:8px" >
⚠ ${r.unmappedGves.length} unmapped .gves reference(s)
< / p > `;
}
}
document.getElementById('detail-content').innerHTML = html;
document.getElementById('detail-modal').classList.add('active');
} catch (e) {
console.error(e);
}
}
function closeDetailModal() {
document.getElementById('detail-modal').classList.remove('active');
}
async function deleteJob(jobId) {
if (!confirm('Delete this job record?')) return;
try {
await api('DELETE', '/jobs/' + jobId);
refreshAll();
} catch (e) {
console.error(e);
}
}
// ─── Utilities ──────────────────────────────
function esc(str) {
const el = document.createElement('span');
el.textContent = str;
return el.innerHTML;
}
function timeAgo(iso) {
const ms = Date.now() - new Date(iso).getTime();
const mins = Math.floor(ms / 60000);
if (mins < 1 ) return ' just now ' ;
if (mins < 60 ) return mins + ' m ago ' ;
const hrs = Math.floor(mins / 60);
if (hrs < 24 ) return hrs + ' h ago ' ;
return Math.floor(hrs / 24) + 'd ago';
}
// Close modals on overlay click
document.getElementById('analysis-modal').addEventListener('click', e => {
if (e.target === e.currentTarget) closeModal();
});
document.getElementById('detail-modal').addEventListener('click', e => {
if (e.target === e.currentTarget) closeDetailModal();
});
// Enter key for login
document.getElementById('login-pass').addEventListener('keydown', e => {
if (e.key === 'Enter') login();
});
// ─── Init ───────────────────────────────────
if (sessionId) {
// Validate existing session — fall back to login if it's expired
api('GET', '/status').then(() => showDashboard()).catch(() => showLogin());
} else {
// No session at all — show login screen
showLogin();
}
< / script >
< / body >
< / html >