ame-job-manager/public/index.html

1243 lines
43 KiB
HTML
Raw Normal View History

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/&lt;user&gt;/Documents/Adobe/Adobe Media Encoder/&lt;version&gt;</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;
closeModal();
const formData = new FormData();
formData.append('prproj', pendingFile);
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>