ame-job-manager/public/index.html
Claude 205ef3f50a Add high-res media folder fallback lookup for unmapped proxy files
- Added hiresMediaFolder setting to configuration UI
- Implement findHighResFileForGves() to search for missing high-res media files
- When .prproj has unlinked proxy media (FramelightX didn't populate paths), search configured folder for matches
- Modified remapPrproj() to accept options with hiresMediaFolder path
- Uses file Title metadata and video/audio extension matching as fallback
- Server passes hiresMediaFolder setting when calling remapper
2026-03-31 19:39:42 -04:00

1250 lines
44 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>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.jpg" alt="Grassvalley AMPP" title="AME Remote Job Manager">
<div class="header-title">
<h1><span>AME</span> Remote Job Manager</h1>
<div class="header-subtitle">Made By an Exhausted Zac Gaetano</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 class="settings-field" style="grid-column: 1 / -1">
<label>High-Res Media Folder (Optional)</label>
<input type="text" id="setting-hires-media-folder" placeholder="e.g. //172.18.210.5/bmg_video/media">
<div class="settings-help">UNC/SMB path where high-resolution source files are stored. When .prproj files reference proxy media that aren't linked in the project, the system will search this folder for matching high-res files. Leave blank to disable fallback lookup.</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-hires-media-folder').value = data.hiresMediaFolder || '';
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(),
hiresMediaFolder: document.getElementById('setting-hires-media-folder').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;
const fileToSubmit = pendingFile; // save ref before closeModal nulls it
closeModal();
const formData = new FormData();
formData.append('prproj', fileToSubmit);
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>