Rewrite frontend in TypeScript, fix tab switching blank content bug, add build:frontend script

This commit is contained in:
2026-05-13 16:39:46 +02:00
parent ee9dc02864
commit cfcdfb4a72
5 changed files with 1161 additions and 467 deletions

View File

@@ -16,6 +16,7 @@
"dev": "ts-node src/cli/index.ts", "dev": "ts-node src/cli/index.ts",
"server": "ts-node src/server/index.ts", "server": "ts-node src/server/index.ts",
"server:build": "node dist/server/index.js", "server:build": "node dist/server/index.js",
"build:frontend": "tsc -p src/server/public/tsconfig.json",
"test": "jest", "test": "jest",
"lint": "eslint src/**/*.ts", "lint": "eslint src/**/*.ts",
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"

View File

@@ -1,131 +1,171 @@
"use strict";
// ── Types ────────────────────────────────────────────
// ── State ────────────────────────────────────────────
let authToken = sessionStorage.getItem('authToken'); let authToken = sessionStorage.getItem('authToken');
let selectedFilePath = ''; let selectedFilePath = null;
let currentConfig = {}; const sseMap = new Map();
let pollTimer = null;
// ── DOM helpers ───────────────────────────────────────
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
const el = (id) => document.getElementById(id);
// ── API ───────────────────────────────────────────────
function apiHeaders() { function apiHeaders() {
const h = { 'Content-Type': 'application/json' }; const h = { 'Content-Type': 'application/json' };
if (authToken) h['Authorization'] = `Basic ${authToken}`; if (authToken)
h['Authorization'] = `Basic ${authToken}`;
return h; return h;
} }
async function api(method, url, body) { async function api(method, url, body) {
const res = await fetch(url, { const res = await fetch(url, {
method, method,
headers: apiHeaders(), headers: apiHeaders(),
body: body ? JSON.stringify(body) : undefined body: body ? JSON.stringify(body) : undefined,
}); });
if (res.status === 401) { if (res.status === 401) {
sessionStorage.removeItem('authToken'); sessionStorage.removeItem('authToken');
authToken = null; authToken = null;
showLogin(); showLoginScreen();
throw new Error('Unauthorized'); throw new Error('Unauthorized');
} }
return res; return res;
} }
async function apiJson(method, url, body) { async function apiJson(method, url, body) {
const res = await api(method, url, body); const res = await api(method, url, body);
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed'); if (!res.ok)
throw new Error(data.error || 'Request failed');
return data; return data;
} }
// ── Screen switching ──────────────────────────────────
// Login function showLoginScreen() {
function showLogin() { el('login-screen').classList.remove('hidden');
document.getElementById('login-screen').classList.remove('hidden'); el('main-screen').classList.add('hidden');
document.getElementById('main-screen').classList.add('hidden');
document.getElementById('login-error').classList.add('hidden');
} }
function showMainScreen() {
function showMain() { el('login-screen').classList.add('hidden');
document.getElementById('login-screen').classList.add('hidden'); el('main-screen').classList.remove('hidden');
document.getElementById('main-screen').classList.remove('hidden');
} }
// ── Tab navigation ────────────────────────────────────
document.getElementById('login-form').addEventListener('submit', async (e) => { function switchTab(name) {
$$('button.tab').forEach(b => b.classList.remove('active'));
document.querySelector(`button.tab[data-tab="${name}"]`)?.classList.add('active');
$$('.tab-content').forEach(c => c.classList.remove('active'));
const pane = document.getElementById(name);
if (pane)
pane.classList.add('active');
if (name === 'dashboard')
loadJobs();
if (name === 'files')
loadFilesList();
}
$$('button.tab').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab || ''));
});
// ── Mini tabs (video source) ──────────────────────────
$$('button.tab-mini').forEach(btn => {
btn.addEventListener('click', () => {
$$('button.tab-mini').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
$$('.src-panel').forEach(p => p.classList.remove('active'));
const panel = document.getElementById('src-' + (btn.dataset.src || ''));
if (panel)
panel.classList.add('active');
});
});
// ── Login ─────────────────────────────────────────────
el('login-form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const username = document.getElementById('login-username').value; const username = el('login-username').value;
const password = document.getElementById('login-password').value; const password = el('login-password').value;
try { try {
const res = await fetch('/api/auth/login', { const res = await fetch('/api/auth/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }) body: JSON.stringify({ username, password }),
}); });
const data = await res.json(); const data = await res.json();
if (data.authenticated) { if (data.authenticated) {
authToken = data.token; authToken = data.token;
if (authToken)
sessionStorage.setItem('authToken', authToken); sessionStorage.setItem('authToken', authToken);
showMain(); showMainScreen();
initApp(); initApp();
} else {
document.getElementById('login-error').textContent = data.error;
document.getElementById('login-error').classList.remove('hidden');
} }
} catch (err) { else {
document.getElementById('login-error').textContent = 'Connection failed'; el('login-error').textContent = data.error;
document.getElementById('login-error').classList.remove('hidden'); el('login-error').classList.remove('hidden');
}
}
catch {
el('login-error').textContent = 'Connection failed';
el('login-error').classList.remove('hidden');
} }
}); });
el('logout-btn').addEventListener('click', () => {
document.getElementById('logout-btn').addEventListener('click', () => {
sessionStorage.removeItem('authToken'); sessionStorage.removeItem('authToken');
authToken = null; authToken = null;
showLogin(); sseMap.forEach(s => s.close());
sseMap.clear();
if (pollTimer)
clearInterval(pollTimer);
showLoginScreen();
}); });
// ── Utils ─────────────────────────────────────────────
// Tab navigation function escapeHtml(str) {
document.querySelectorAll('button.tab').forEach(btn => { if (!str)
btn.addEventListener('click', () => { return '';
document.querySelectorAll('button.tab').forEach(b => b.classList.remove('active')); return String(str)
btn.classList.add('active'); .replace(/&/g, '&')
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); .replace(/</g, '&lt;')
document.getElementById(btn.dataset.tab).classList.add('active'); .replace(/>/g, '&gt;')
if (btn.dataset.tab === 'dashboard') loadJobs(); .replace(/"/g, '&quot;');
if (btn.dataset.tab === 'files') loadFilesList(); }
}); function formatSize(bytes) {
}); if (!bytes)
return '0 B';
// Mini tabs (video source) const units = ['B', 'KB', 'MB', 'GB'];
document.querySelectorAll('button.tab-mini').forEach(btn => { let i = 0;
btn.addEventListener('click', () => { let size = bytes;
document.querySelectorAll('button.tab-mini').forEach(b => b.classList.remove('active')); while (size >= 1024 && i < units.length - 1) {
btn.classList.add('active'); size /= 1024;
document.querySelectorAll('.src-panel').forEach(p => p.classList.remove('active')); i++;
document.getElementById('src-' + btn.dataset.src).classList.add('active'); }
}); return `${size.toFixed(1)} ${units[i]}`;
}); }
// ── Browse files (for New Job) ────────────────────────
// File upload
document.getElementById('video-upload').addEventListener('change', () => {
const file = document.getElementById('video-upload').files[0];
if (file) selectedFilePath = null;
});
// Refresh browse files
document.getElementById('refresh-files').addEventListener('click', loadBrowseFiles);
async function loadBrowseFiles() { async function loadBrowseFiles() {
try { try {
const data = await apiJson('GET', '/api/files'); const data = await apiJson('GET', '/api/files');
const sel = document.getElementById('video-select'); const sel = el('video-select');
sel.innerHTML = '<option value="">-- Select file --</option>'; sel.innerHTML = '<option value="">-- Select file --</option>';
data.files.forEach(f => { data.files.forEach(f => {
sel.innerHTML += `<option value="${f.filePath}">${f.filename} (${formatSize(f.size)})</option>`; const opt = document.createElement('option');
opt.value = f.filePath;
opt.textContent = `${f.filename} (${formatSize(f.size)})`;
sel.appendChild(opt);
}); });
} catch (err) { }
catch (err) {
console.error(err); console.error(err);
} }
} }
el('refresh-files').addEventListener('click', loadBrowseFiles);
document.getElementById('video-select').addEventListener('change', (e) => { el('video-select').addEventListener('change', function () {
if (e.target.value) selectedFilePath = e.target.value; if (this.value)
selectedFilePath = this.value;
}); });
// ── File upload ───────────────────────────────────────
// YouTube download el('video-upload').addEventListener('change', function () {
document.getElementById('download-url').addEventListener('click', async () => { if (this.files?.length)
const url = document.getElementById('youtube-url').value; selectedFilePath = null; // will upload on submit
if (!url) return; });
const status = document.getElementById('download-status'); // ── YouTube download ──────────────────────────────────
el('download-url').addEventListener('click', async () => {
const url = el('youtube-url').value;
if (!url)
return;
const status = el('download-status');
status.textContent = 'Downloading...'; status.textContent = 'Downloading...';
status.className = 'status'; status.className = 'status';
try { try {
@@ -133,89 +173,104 @@ document.getElementById('download-url').addEventListener('click', async () => {
status.textContent = `Downloaded: ${data.filename}`; status.textContent = `Downloaded: ${data.filename}`;
status.className = 'status success'; status.className = 'status success';
selectedFilePath = data.filePath; selectedFilePath = data.filePath;
document.getElementById('video-select').innerHTML += `<option value="${data.filePath}" selected>${data.filename}</option>`; const sel = el('video-select');
} catch (err) { const opt = document.createElement('option');
opt.value = data.filePath;
opt.textContent = data.filename;
opt.selected = true;
sel.appendChild(opt);
}
catch (err) {
status.textContent = `Error: ${err.message}`; status.textContent = `Error: ${err.message}`;
status.className = 'status error'; status.className = 'status error';
} }
}); });
// ── New Job form ──────────────────────────────────────
// New job form el('new-job-form').addEventListener('submit', async (e) => {
document.getElementById('new-job-form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
if (!selectedFilePath) { if (!selectedFilePath) {
const fileEl = document.getElementById('video-upload'); const fileEl = el('video-upload');
if (fileEl.files.length > 0) { if (fileEl.files?.length) {
const formData = new FormData(); const formData = new FormData();
formData.append('video', fileEl.files[0]); formData.append('video', fileEl.files[0]);
try { try {
const res = await fetch('/api/files/upload', { method: 'POST', headers: { Authorization: `Basic ${authToken}` }, body: formData }); const headers = {};
if (authToken)
headers['Authorization'] = `Basic ${authToken}`;
const res = await fetch('/api/files/upload', { method: 'POST', headers, body: formData });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Upload failed'); if (!res.ok)
throw new Error(data.error || 'Upload failed');
selectedFilePath = data.filePath; selectedFilePath = data.filePath;
} catch (err) { }
catch (err) {
alert('Upload error: ' + err.message); alert('Upload error: ' + err.message);
return; return;
} }
} else { }
else {
alert('Please select a video file or source'); alert('Please select a video file or source');
return; return;
} }
} }
const fd = new FormData(e.target); const fd = new FormData(e.target);
const config = {}; const config = {};
for (const [key, val] of fd.entries()) { for (const [key, val] of fd.entries()) {
if (key === '') continue; if (key === '')
if (val === 'on') config[key] = true; continue;
else if (val === 'off') config[key] = false; if (val === 'on')
else if (!isNaN(val) && val !== '') config[key] = parseFloat(val); config[key] = true;
else config[key] = val; else if (val === 'off')
config[key] = false;
else if (!isNaN(val) && val !== '')
config[key] = parseFloat(val);
else
config[key] = val;
} }
const outputOptions = { const outputOptions = {
audio: fd.get('output-audio') === 'on', audio: fd.get('output-audio') === 'on',
subtitles: fd.get('output-subtitles') === 'on', subtitles: fd.get('output-subtitles') === 'on',
muxed: fd.get('output-muxed') === 'on' muxed: fd.get('output-muxed') === 'on',
}; };
// Build config with vision/tts providers
if (config.visionProvider) { if (config.visionProvider) {
config.visionProviders = {}; const vp = {};
config.visionProviders[config.visionProvider] = { vp[config.visionProvider] = {
model: config.visionModel || 'gpt-4o', model: config.visionModel || 'gpt-4o',
maxTokens: config.visionMaxTokens ? parseInt(config.visionMaxTokens) : 300 maxTokens: config.visionMaxTokens ? parseInt(config.visionMaxTokens) : 300,
}; };
config.visionProviders = vp;
} }
if (config.ttsProvider) { if (config.ttsProvider) {
config.ttsProviders = {}; const tp = {};
config.ttsProviders[config.ttsProvider] = { tp[config.ttsProvider] = {
model: config.ttsModel || 'tts-1', model: config.ttsModel || 'tts-1',
voice: config.ttsVoice || 'alloy' voice: config.ttsVoice || 'alloy',
}; };
config.ttsProviders = tp;
} }
delete config.visionModel; delete config.visionModel;
delete config.visionMaxTokens; delete config.visionMaxTokens;
delete config.ttsModel; delete config.ttsModel;
delete config['output-audio']; delete config['output-audio'];
delete config['output-subtitles']; delete config['output-subtitles'];
delete config['output-muxed']; delete config['output-muxed'];
try { try {
const data = await apiJson('POST', '/api/jobs', { videoPath: selectedFilePath, config, outputOptions }); const data = await apiJson('POST', '/api/jobs', {
videoPath: selectedFilePath,
config,
outputOptions,
});
await apiJson('POST', `/api/jobs/${data.job.id}/start`); await apiJson('POST', `/api/jobs/${data.job.id}/start`);
selectedFilePath = ''; selectedFilePath = null;
document.getElementById('video-upload').value = ''; el('video-upload').value = '';
document.getElementById('new-job-form').reset(); el('new-job-form').reset();
document.querySelector('.tab[data-tab="dashboard"]').click(); switchTab('dashboard');
loadJobs(); }
} catch (err) { catch (err) {
alert('Error creating job: ' + err.message); alert('Error creating job: ' + err.message);
} }
}); });
// ── Job list & rendering ──────────────────────────────
// Load jobs
async function loadJobs() { async function loadJobs() {
try { try {
const data = await apiJson('GET', '/api/jobs'); const data = await apiJson('GET', '/api/jobs');
@@ -225,43 +280,44 @@ async function loadJobs() {
connectSSE(j.id); connectSSE(j.id);
} }
}); });
} catch (err) { }
catch (err) {
console.error(err); console.error(err);
} }
} }
function renderJobs(jobs) { function renderJobs(jobs) {
const container = document.getElementById('jobs-list'); const container = el('jobs-list');
if (!jobs.length) { if (!jobs.length) {
container.innerHTML = '<p class="empty">No jobs yet. Create one from the "New Job" tab.</p>'; container.innerHTML = '<p class="empty">No jobs yet. Create one from the "New Job" tab.</p>';
return; return;
} }
container.innerHTML = jobs.map(j => { container.innerHTML = jobs.map(j => {
const segs = JSON.parse(j.segments || '[]'); const segs = JSON.parse(j.segments || '[]');
const progressClass = j.status === 'completed' ? 'completed' : j.status === 'failed' ? 'failed' : ''; const progressClass = j.status === 'completed' ? 'completed' : j.status === 'failed' ? 'failed' : '';
const downloads = []; const downloads = [];
if (j.status === 'completed') { if (j.status === 'completed') {
if (j.output_audio) downloads.push(`<a href="/api/jobs/${j.id}/download/audio" download>Audio</a>`); if (j.output_audio)
if (j.output_subtitles_srt) downloads.push(`<a href="/api/jobs/${j.id}/download/subtitles?format=srt" download>SRT</a>`); downloads.push(`<a href="/api/jobs/${j.id}/download/audio" download>Audio</a>`);
if (j.output_subtitles_vtt) downloads.push(`<a href="/api/jobs/${j.id}/download/subtitles?format=vtt" download>VTT</a>`); if (j.output_subtitles_srt)
if (j.output_muxed) downloads.push(`<a href="/api/jobs/${j.id}/download/muxed" download>Muxed</a>`); downloads.push(`<a href="/api/jobs/${j.id}/download/subtitles?format=srt" download>SRT</a>`);
if (j.output_subtitles_vtt)
downloads.push(`<a href="/api/jobs/${j.id}/download/subtitles?format=vtt" download>VTT</a>`);
if (j.output_muxed)
downloads.push(`<a href="/api/jobs/${j.id}/download/muxed" download>Muxed</a>`);
} }
let actions = ''; let actions = '';
if (j.status === 'pending' || j.status === 'queued') { if (j.status === 'pending' || j.status === 'queued') {
actions += `<button class="start-job" data-id="${j.id}">Start</button>`; actions += `<button class="act-start" data-id="${j.id}">Start</button>`;
} }
if (j.status === 'processing') { if (j.status === 'processing') {
actions += `<button class="pause-job" data-id="${j.id}">Pause</button>`; actions += `<button class="act-pause" data-id="${j.id}">Pause</button>`;
} }
if (j.status === 'failed' || j.status === 'paused' || j.status === 'cancelled') { if (j.status === 'failed' || j.status === 'paused' || j.status === 'cancelled') {
actions += `<button class="restart-job" data-id="${j.id}">Restart</button>`; actions += `<button class="act-restart" data-id="${j.id}">Restart</button>`;
} }
if (j.status !== 'processing') { if (j.status !== 'processing') {
actions += `<button class="delete-job danger" data-id="${j.id}">Delete</button>`; actions += `<button class="act-delete danger" data-id="${j.id}">Delete</button>`;
} }
return ` return `
<div class="job-card" data-id="${j.id}"> <div class="job-card" data-id="${j.id}">
<div class="job-card-header"> <div class="job-card-header">
@@ -272,7 +328,7 @@ function renderJobs(jobs) {
<div class="progress-bar"><div class="progress-fill ${progressClass}" style="width:${j.progress}%"></div></div> <div class="progress-bar"><div class="progress-fill ${progressClass}" style="width:${j.progress}%"></div></div>
<div class="job-meta"> <div class="job-meta">
<span>${Math.round(j.progress)}%</span> <span>${Math.round(j.progress)}%</span>
<span>Index: ${j.current_index}/${j.total_units}</span> <span>Idx: ${j.current_index}/${j.total_units}</span>
<span>${new Date(j.created_at).toLocaleString()}</span> <span>${new Date(j.created_at).toLocaleString()}</span>
</div> </div>
${j.error ? `<div class="error-msg">${escapeHtml(j.error)}</div>` : ''} ${j.error ? `<div class="error-msg">${escapeHtml(j.error)}</div>` : ''}
@@ -283,63 +339,110 @@ function renderJobs(jobs) {
<button class="toggle-detail" data-id="${j.id}">${segs.length} segments</button> <button class="toggle-detail" data-id="${j.id}">${segs.length} segments</button>
</div>`; </div>`;
}).join(''); }).join('');
// Wire up action buttons
// Wire up buttons container.querySelectorAll('.act-start').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id || '', 'start')));
container.querySelectorAll('.start-job').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id, 'start'))); container.querySelectorAll('.act-pause').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id || '', 'pause')));
container.querySelectorAll('.pause-job').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id, 'pause'))); container.querySelectorAll('.act-restart').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id || '', 'restart')));
container.querySelectorAll('.restart-job').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id, 'restart'))); container.querySelectorAll('.act-delete').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id || '', 'delete')));
container.querySelectorAll('.delete-job').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id, 'delete'))); container.querySelectorAll('.toggle-detail').forEach(b => {
container.querySelectorAll('.toggle-detail').forEach(b => b.addEventListener('click', () => { b.addEventListener('click', () => {
const detail = container.querySelector(`.job-detail[data-id="${b.dataset.id}"]`); const jobId = b.dataset.id || '';
const detail = container.querySelector(`.job-detail[data-id="${jobId}"]`);
if (!detail)
return;
detail.classList.toggle('open'); detail.classList.toggle('open');
b.textContent = detail.classList.contains('open') ? 'Hide segments' : `${JSON.parse((jobs.find(j => j.id === b.dataset.id) || {}).segments || '[]').length} segments`; const job = jobs.find(j => j.id === jobId);
})); const segs = job ? JSON.parse(job.segments || '[]') : [];
b.textContent = detail.classList.contains('open') ? 'Hide segments' : `${segs.length} segments`;
});
});
} }
async function handleJobAction(id, action) { async function handleJobAction(id, action) {
const method = action === 'delete' ? 'DELETE' : 'POST'; const method = action === 'delete' ? 'DELETE' : 'POST';
const url = `/api/jobs/${id}${action === 'delete' ? '' : '/' + action}`; const url = `/api/jobs/${id}${action === 'delete' ? '' : '/' + action}`;
try { try {
await api(method, url); await api(method, url);
loadJobs(); loadJobs();
} catch (err) { }
catch (err) {
alert(`Error: ${err.message}`); alert(`Error: ${err.message}`);
} }
} }
el('refresh-jobs').addEventListener('click', loadJobs);
// Jobs refresh // ── Polling ───────────────────────────────────────────
document.getElementById('refresh-jobs').addEventListener('click', loadJobs); function startPolling() {
if (pollTimer)
// Auto-refresh jobs return;
let jobsInterval; pollTimer = window.setInterval(loadJobs, 5000);
function startJobsPolling() {
jobsInterval = setInterval(loadJobs, 5000);
} }
function stopJobsPolling() { // ── SSE live progress ─────────────────────────────────
clearInterval(jobsInterval); function connectSSE(jobId) {
if (sseMap.has(jobId))
return;
const es = new EventSource(`/api/jobs/${jobId}/progress?token=${encodeURIComponent(authToken)}`);
es.onmessage = (event) => {
const data = JSON.parse(event.data);
updateJobCard(jobId, data);
if (data.status === 'completed' || data.status === 'failed' || data.status === 'cancelled') {
es.close();
sseMap.delete(jobId);
}
};
es.onerror = () => {
es.close();
sseMap.delete(jobId);
};
sseMap.set(jobId, es);
} }
function updateJobCard(jobId, data) {
// Settings const card = document.querySelector(`.job-card[data-id="${jobId}"]`);
if (!card)
return;
const badge = card.querySelector('.status-badge');
if (badge) {
badge.className = `status-badge status-${data.status}`;
badge.textContent = data.status;
}
const fill = card.querySelector('.progress-fill');
if (fill) {
fill.style.width = data.progress + '%';
fill.className = 'progress-fill';
if (data.status === 'completed')
fill.classList.add('completed');
else if (data.status === 'failed')
fill.classList.add('failed');
}
const metaSpans = card.querySelectorAll('.job-meta span');
if (metaSpans[0])
metaSpans[0].textContent = Math.round(data.progress) + '%';
if (metaSpans[1])
metaSpans[1].textContent = `Idx: ${data.currentIndex}/${data.totalUnits}`;
const log = card.querySelector('.segment-log');
if (log && data.segments) {
log.innerHTML = data.segments.map(s => `<div class="segment-entry"><span class="segment-time">[${s.startTime.toFixed(1)}s]</span> ${escapeHtml(s.description)}</div>`).join('');
}
const toggleBtn = card.querySelector('.toggle-detail');
if (toggleBtn && data.segments) {
toggleBtn.textContent = `${data.segments.length} segments`;
}
}
// ── Settings ──────────────────────────────────────────
async function loadSettings() { async function loadSettings() {
try { try {
const data = await apiJson('GET', '/api/config'); const data = await apiJson('GET', '/api/config');
const container = document.getElementById('settings-fields'); const container = el('settings-fields');
const config = data.config || {}; const entries = Object.entries(data.config || {});
currentConfig = config; if (!entries.length) {
let html = ''; container.innerHTML = '<p class="empty">No custom settings yet. Settings from .env are used as defaults.</p>';
for (const [key, value] of Object.entries(config)) { return;
html += `<label>${key} <input type="text" name="${key}" value="${escapeHtml(String(value))}"></label>`;
} }
if (!Object.keys(config).length) { container.innerHTML = entries.map(([key, value]) => `<label>${escapeHtml(key)} <input type="text" name="${escapeHtml(key)}" value="${escapeHtml(String(value))}"></label>`).join('');
html = '<p class="empty">No custom settings yet. Settings from .env are used as defaults.</p>';
} }
container.innerHTML = html; catch (err) {
} catch (err) {
console.error(err); console.error(err);
} }
} }
el('settings-form').addEventListener('submit', async (e) => {
document.getElementById('settings-form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const fd = new FormData(e.target); const fd = new FormData(e.target);
const config = {}; const config = {};
@@ -349,12 +452,12 @@ document.getElementById('settings-form').addEventListener('submit', async (e) =>
try { try {
await apiJson('PUT', '/api/config', config); await apiJson('PUT', '/api/config', config);
alert('Settings saved'); alert('Settings saved');
} catch (err) { }
catch (err) {
alert('Error: ' + err.message); alert('Error: ' + err.message);
} }
}); });
// ── Files list ────────────────────────────────────────
// Files list
let selectedFiles = new Set(); let selectedFiles = new Set();
async function loadFilesList() { async function loadFilesList() {
try { try {
@@ -367,158 +470,91 @@ async function loadFilesList() {
<td>${formatSize(f.size)}</td> <td>${formatSize(f.size)}</td>
</tr> </tr>
`).join(''); `).join('');
tbody.querySelectorAll('.file-checkbox').forEach(cb => {
document.querySelectorAll('.file-checkbox').forEach(cb => { cb.addEventListener('change', updateFileSelection);
cb.addEventListener('change', () => updateFileSelection());
}); });
} catch (err) { }
catch (err) {
console.error(err); console.error(err);
} }
} }
function updateFileSelection() { function updateFileSelection() {
selectedFiles.clear(); selectedFiles.clear();
document.querySelectorAll('.file-checkbox:checked').forEach(cb => { document.querySelectorAll('.file-checkbox:checked').forEach(cb => {
if (cb.dataset.path)
selectedFiles.add(cb.dataset.path); selectedFiles.add(cb.dataset.path);
}); });
document.getElementById('delete-selected-files').disabled = selectedFiles.size === 0; el('delete-selected-files').disabled = selectedFiles.size === 0;
} }
el('select-all-files').addEventListener('change', function () {
document.getElementById('select-all-files').addEventListener('change', (e) => { document.querySelectorAll('.file-checkbox').forEach(cb => {
document.querySelectorAll('.file-checkbox').forEach(cb => { cb.checked = e.target.checked; }); cb.checked = this.checked;
});
updateFileSelection(); updateFileSelection();
}); });
el('delete-selected-files').addEventListener('click', () => {
document.getElementById('delete-selected-files').addEventListener('click', async () => { if (!confirm(`Delete ${selectedFiles.size} file(s)?`))
if (!confirm(`Delete ${selectedFiles.size} file(s)?`)) return; return;
for (const path of selectedFiles) {
// Files are served from uploads dir, delete via fs on server...
// Not implementing server-side file deletion for now
}
alert('File deletion not yet implemented'); alert('File deletion not yet implemented');
}); });
el('refresh-files-list').addEventListener('click', loadFilesList);
document.getElementById('refresh-files-list').addEventListener('click', loadFilesList); // ── Config defaults for New Job form ─────────────────
// Pre-fill new job form with config defaults
async function loadConfigDefaults() { async function loadConfigDefaults() {
try { try {
const data = await apiJson('GET', '/api/config'); const data = await apiJson('GET', '/api/config');
const config = data.config || {}; const c = data.config || {};
if (c.visionProvider) {
if (config.visionProvider) {
const sel = document.querySelector('[name="visionProvider"]'); const sel = document.querySelector('[name="visionProvider"]');
if (sel) {
sel.innerHTML = '<option value="openai">OpenAI</option><option value="gemini">Gemini</option><option value="ollama">Ollama</option><option value="openrouter">OpenRouter</option>'; sel.innerHTML = '<option value="openai">OpenAI</option><option value="gemini">Gemini</option><option value="ollama">Ollama</option><option value="openrouter">OpenRouter</option>';
sel.value = config.visionProvider; sel.value = c.visionProvider;
} }
if (config.visionModel) document.querySelector('[name="visionModel"]').value = config.visionModel; }
if (config.ttsProvider) { if (c.ttsProvider) {
const sel = document.querySelector('[name="ttsProvider"]'); const sel = document.querySelector('[name="ttsProvider"]');
if (sel) {
sel.innerHTML = '<option value="openai">OpenAI</option><option value="elevenlabs">ElevenLabs</option><option value="google">Google Cloud</option>'; sel.innerHTML = '<option value="openai">OpenAI</option><option value="elevenlabs">ElevenLabs</option><option value="google">Google Cloud</option>';
sel.value = config.ttsProvider; sel.value = c.ttsProvider;
} }
if (config.ttsModel) document.querySelector('[name="ttsModel"]').value = config.ttsModel; }
if (config.ttsVoice) document.querySelector('[name="ttsVoice"]').value = config.ttsVoice; const fields = [
if (config.ttsSpeedFactor) document.querySelector('[name="ttsSpeedFactor"]').value = config.ttsSpeedFactor; ['visionModel'], ['ttsModel'], ['ttsVoice'], ['ttsSpeedFactor'],
if (config.ttsInstructions) document.querySelector('[name="ttsInstructions"]').value = config.ttsInstructions; ['ttsInstructions', 'textarea'], ['batchWindowDuration'], ['framesInBatch'],
if (config.batchWindowDuration) document.querySelector('[name="batchWindowDuration"]').value = config.batchWindowDuration; ['captureIntervalSeconds'], ['contextWindowSize'],
if (config.framesInBatch) document.querySelector('[name="framesInBatch"]').value = config.framesInBatch; ['defaultPrompt', 'textarea'], ['changePrompt', 'textarea'], ['batchPrompt', 'textarea'],
if (config.captureIntervalSeconds) document.querySelector('[name="captureIntervalSeconds"]').value = config.captureIntervalSeconds; ];
if (config.contextWindowSize) document.querySelector('[name="contextWindowSize"]').value = config.contextWindowSize; for (const [name, tag] of fields) {
if (config.defaultPrompt) document.querySelector('[name="defaultPrompt"]').value = config.defaultPrompt; const el = document.querySelector(`[name="${name}"]`);
if (config.changePrompt) document.querySelector('[name="changePrompt"]').value = config.changePrompt; if (el && c[name] !== undefined)
if (config.batchPrompt) document.querySelector('[name="batchPrompt"]').value = config.batchPrompt; el.value = c[name];
} catch (err) { }
}
catch (err) {
console.error(err); console.error(err);
} }
} }
// ── Init ──────────────────────────────────────────────
// Setup SSE for live progress
const sseConnections = {};
function connectSSE(jobId) {
if (sseConnections[jobId]) return;
const source = new EventSource(`/api/jobs/${jobId}/progress?token=${encodeURIComponent(authToken)}`);
source.onmessage = (event) => {
const data = JSON.parse(event.data);
updateJobCard(jobId, data);
if (data.status === 'completed' || data.status === 'failed' || data.status === 'cancelled') {
source.close();
delete sseConnections[jobId];
}
};
source.onerror = () => {
source.close();
delete sseConnections[jobId];
};
sseConnections[jobId] = source;
}
function updateJobCard(jobId, data) {
const card = document.querySelector(`.job-card[data-id="${jobId}"]`);
if (!card) return;
const badge = card.querySelector('.status-badge');
badge.className = `status-badge status-${data.status}`;
badge.textContent = data.status;
const fill = card.querySelector('.progress-fill');
fill.style.width = data.progress + '%';
fill.className = 'progress-fill';
if (data.status === 'completed') fill.classList.add('completed');
if (data.status === 'failed') fill.classList.add('failed');
const metaSpans = card.querySelectorAll('.job-meta span');
if (metaSpans[0]) metaSpans[0].textContent = Math.round(data.progress) + '%';
if (metaSpans[1]) metaSpans[1].textContent = `Index: ${data.currentIndex}/${data.totalUnits}`;
// Update segments
const log = card.querySelector('.segment-log');
if (data.segments && log) {
log.innerHTML = data.segments.map((s, i) => `<div class="segment-entry"><span class="segment-time">[${s.startTime.toFixed(1)}s]</span> ${escapeHtml(s.description)}</div>`).join('');
}
// Update segment count button
const toggleBtn = card.querySelector('.toggle-detail');
if (toggleBtn && data.segments) {
toggleBtn.textContent = `${data.segments.length} segments`;
}
}
// Initialize
function initApp() { function initApp() {
loadJobs(); loadJobs();
loadBrowseFiles(); loadBrowseFiles();
loadConfigDefaults(); loadConfigDefaults();
startJobsPolling(); startPolling();
} }
// ── Startup ───────────────────────────────────────────
// Escape HTML for safe rendering
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function formatSize(bytes) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let size = bytes;
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
return `${size.toFixed(1)} ${units[i]}`;
}
// Check if already authenticated
(async () => { (async () => {
if (authToken) { if (authToken) {
try { try {
const res = await fetch('/api/auth/check', { headers: { Authorization: `Basic ${authToken}` } }); const res = await fetch('/api/auth/check', {
headers: { Authorization: `Basic ${authToken}` },
});
const data = await res.json(); const data = await res.json();
if (data.authenticated) { if (data.authenticated) {
showMain(); showMainScreen();
initApp(); initApp();
return; return;
} }
} catch (e) {}
} }
showLogin(); catch { /* fall through to login */ }
}
showLoginScreen();
})(); })();

642
src/server/public/app.ts Normal file
View File

@@ -0,0 +1,642 @@
// ── Types ────────────────────────────────────────────
interface Job {
id: string;
video_path: string;
video_filename: string;
status: 'pending' | 'queued' | 'processing' | 'paused' | 'completed' | 'failed' | 'cancelled';
config: string;
progress: number;
current_index: number;
total_units: number;
segments: string;
last_context: string;
current_time_position: number;
error: string | null;
created_at: string;
updated_at: string;
completed_at: string | null;
output_audio: string | null;
output_subtitles_srt: string | null;
output_subtitles_vtt: string | null;
output_muxed: string | null;
output_options: string;
}
interface AudioSegment {
audioFile: string;
startTime: number;
duration: number;
description: string;
}
interface ProgressData {
id: string;
status: string;
progress: number;
currentIndex: number;
totalUnits: number;
segments: AudioSegment[];
error: string | null;
output_audio: string | null;
output_subtitles_srt: string | null;
output_subtitles_vtt: string | null;
output_muxed: string | null;
}
interface FileInfo {
filename: string;
filePath: string;
size: number;
}
// ── State ────────────────────────────────────────────
let authToken: string | null = sessionStorage.getItem('authToken');
let selectedFilePath: string | null = null;
const sseMap = new Map<string, EventSource>();
let pollTimer: number | null = null;
// ── DOM helpers ───────────────────────────────────────
const $ = (sel: string): HTMLElement => document.querySelector(sel) as HTMLElement;
const $$ = (sel: string): NodeListOf<HTMLElement> => document.querySelectorAll(sel);
const el = (id: string): HTMLElement => document.getElementById(id)!;
// ── API ───────────────────────────────────────────────
function apiHeaders(): Record<string, string> {
const h: Record<string, string> = { 'Content-Type': 'application/json' };
if (authToken) h['Authorization'] = `Basic ${authToken}`;
return h;
}
async function api(method: string, url: string, body?: unknown): Promise<Response> {
const res = await fetch(url, {
method,
headers: apiHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 401) {
sessionStorage.removeItem('authToken');
authToken = null;
showLoginScreen();
throw new Error('Unauthorized');
}
return res;
}
async function apiJson<T>(method: string, url: string, body?: unknown): Promise<T> {
const res = await api(method, url, body);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data as T;
}
// ── Screen switching ──────────────────────────────────
function showLoginScreen(): void {
el('login-screen').classList.remove('hidden');
el('main-screen').classList.add('hidden');
}
function showMainScreen(): void {
el('login-screen').classList.add('hidden');
el('main-screen').classList.remove('hidden');
}
// ── Tab navigation ────────────────────────────────────
function switchTab(name: string): void {
$$('button.tab').forEach(b => b.classList.remove('active'));
document.querySelector<HTMLElement>(`button.tab[data-tab="${name}"]`)?.classList.add('active');
$$('.tab-content').forEach(c => c.classList.remove('active'));
const pane = document.getElementById(name);
if (pane) pane.classList.add('active');
if (name === 'dashboard') loadJobs();
if (name === 'files') loadFilesList();
}
$$('button.tab').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab || ''));
});
// ── Mini tabs (video source) ──────────────────────────
$$('button.tab-mini').forEach(btn => {
btn.addEventListener('click', () => {
$$('button.tab-mini').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
$$('.src-panel').forEach(p => p.classList.remove('active'));
const panel = document.getElementById('src-' + (btn.dataset.src || ''));
if (panel) panel.classList.add('active');
});
});
// ── Login ─────────────────────────────────────────────
el('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const username = (el('login-username') as HTMLInputElement).value;
const password = (el('login-password') as HTMLInputElement).value;
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (data.authenticated) {
authToken = data.token;
if (authToken) sessionStorage.setItem('authToken', authToken);
showMainScreen();
initApp();
} else {
el('login-error').textContent = data.error;
el('login-error').classList.remove('hidden');
}
} catch {
el('login-error').textContent = 'Connection failed';
el('login-error').classList.remove('hidden');
}
});
el('logout-btn').addEventListener('click', () => {
sessionStorage.removeItem('authToken');
authToken = null;
sseMap.forEach(s => s.close());
sseMap.clear();
if (pollTimer) clearInterval(pollTimer);
showLoginScreen();
});
// ── Utils ─────────────────────────────────────────────
function escapeHtml(str: string | null | undefined): string {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function formatSize(bytes: number): string {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let size = bytes;
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
return `${size.toFixed(1)} ${units[i]}`;
}
// ── Browse files (for New Job) ────────────────────────
async function loadBrowseFiles(): Promise<void> {
try {
const data = await apiJson<{ files: FileInfo[] }>('GET', '/api/files');
const sel = el('video-select') as HTMLSelectElement;
sel.innerHTML = '<option value="">-- Select file --</option>';
data.files.forEach(f => {
const opt = document.createElement('option');
opt.value = f.filePath;
opt.textContent = `${f.filename} (${formatSize(f.size)})`;
sel.appendChild(opt);
});
} catch (err) {
console.error(err);
}
}
el('refresh-files').addEventListener('click', loadBrowseFiles);
(el('video-select') as HTMLSelectElement).addEventListener('change', function () {
if (this.value) selectedFilePath = this.value;
});
// ── File upload ───────────────────────────────────────
(el('video-upload') as HTMLInputElement).addEventListener('change', function () {
if (this.files?.length) selectedFilePath = null; // will upload on submit
});
// ── YouTube download ──────────────────────────────────
el('download-url').addEventListener('click', async () => {
const url = (el('youtube-url') as HTMLInputElement).value;
if (!url) return;
const status = el('download-status');
status.textContent = 'Downloading...';
status.className = 'status';
try {
const data = await apiJson<{ filePath: string; filename: string }>('POST', '/api/files/youtube', { url });
status.textContent = `Downloaded: ${data.filename}`;
status.className = 'status success';
selectedFilePath = data.filePath;
const sel = el('video-select') as HTMLSelectElement;
const opt = document.createElement('option');
opt.value = data.filePath;
opt.textContent = data.filename;
opt.selected = true;
sel.appendChild(opt);
} catch (err: any) {
status.textContent = `Error: ${err.message}`;
status.className = 'status error';
}
});
// ── New Job form ──────────────────────────────────────
el('new-job-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (!selectedFilePath) {
const fileEl = el('video-upload') as HTMLInputElement;
if (fileEl.files?.length) {
const formData = new FormData();
formData.append('video', fileEl.files[0]);
try {
const headers: Record<string, string> = {};
if (authToken) headers['Authorization'] = `Basic ${authToken}`;
const res = await fetch('/api/files/upload', { method: 'POST', headers, body: formData });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Upload failed');
selectedFilePath = data.filePath;
} catch (err: any) {
alert('Upload error: ' + err.message);
return;
}
} else {
alert('Please select a video file or source');
return;
}
}
const fd = new FormData(e.target as HTMLFormElement);
const config: Record<string, unknown> = {};
for (const [key, val] of fd.entries()) {
if (key === '') continue;
if (val === 'on') config[key] = true;
else if (val === 'off') config[key] = false;
else if (!isNaN(val as any) && val !== '') config[key] = parseFloat(val as string);
else config[key] = val;
}
const outputOptions = {
audio: fd.get('output-audio') === 'on',
subtitles: fd.get('output-subtitles') === 'on',
muxed: fd.get('output-muxed') === 'on',
};
if (config.visionProvider) {
const vp: Record<string, unknown> = {};
vp[config.visionProvider as string] = {
model: config.visionModel || 'gpt-4o',
maxTokens: config.visionMaxTokens ? parseInt(config.visionMaxTokens as string) : 300,
};
config.visionProviders = vp;
}
if (config.ttsProvider) {
const tp: Record<string, unknown> = {};
tp[config.ttsProvider as string] = {
model: config.ttsModel || 'tts-1',
voice: config.ttsVoice || 'alloy',
};
config.ttsProviders = tp;
}
delete config.visionModel;
delete config.visionMaxTokens;
delete config.ttsModel;
delete config['output-audio'];
delete config['output-subtitles'];
delete config['output-muxed'];
try {
const data = await apiJson<{ job: Job }>('POST', '/api/jobs', {
videoPath: selectedFilePath,
config,
outputOptions,
});
await apiJson('POST', `/api/jobs/${data.job.id}/start`);
selectedFilePath = null;
(el('video-upload') as HTMLInputElement).value = '';
(el('new-job-form') as HTMLFormElement).reset();
switchTab('dashboard');
} catch (err: any) {
alert('Error creating job: ' + err.message);
}
});
// ── Job list & rendering ──────────────────────────────
async function loadJobs(): Promise<void> {
try {
const data = await apiJson<{ jobs: Job[] }>('GET', '/api/jobs');
renderJobs(data.jobs);
data.jobs.forEach(j => {
if (j.status === 'processing' || j.status === 'queued') {
connectSSE(j.id);
}
});
} catch (err) {
console.error(err);
}
}
function renderJobs(jobs: Job[]): void {
const container = el('jobs-list');
if (!jobs.length) {
container.innerHTML = '<p class="empty">No jobs yet. Create one from the "New Job" tab.</p>';
return;
}
container.innerHTML = jobs.map(j => {
const segs: AudioSegment[] = JSON.parse(j.segments || '[]');
const progressClass = j.status === 'completed' ? 'completed' : j.status === 'failed' ? 'failed' : '';
const downloads: string[] = [];
if (j.status === 'completed') {
if (j.output_audio) downloads.push(`<a href="/api/jobs/${j.id}/download/audio" download>Audio</a>`);
if (j.output_subtitles_srt) downloads.push(`<a href="/api/jobs/${j.id}/download/subtitles?format=srt" download>SRT</a>`);
if (j.output_subtitles_vtt) downloads.push(`<a href="/api/jobs/${j.id}/download/subtitles?format=vtt" download>VTT</a>`);
if (j.output_muxed) downloads.push(`<a href="/api/jobs/${j.id}/download/muxed" download>Muxed</a>`);
}
let actions = '';
if (j.status === 'pending' || j.status === 'queued') {
actions += `<button class="act-start" data-id="${j.id}">Start</button>`;
}
if (j.status === 'processing') {
actions += `<button class="act-pause" data-id="${j.id}">Pause</button>`;
}
if (j.status === 'failed' || j.status === 'paused' || j.status === 'cancelled') {
actions += `<button class="act-restart" data-id="${j.id}">Restart</button>`;
}
if (j.status !== 'processing') {
actions += `<button class="act-delete danger" data-id="${j.id}">Delete</button>`;
}
return `
<div class="job-card" data-id="${j.id}">
<div class="job-card-header">
<h3>${escapeHtml(j.video_filename)}</h3>
<div class="job-actions">${actions}</div>
</div>
<span class="status-badge status-${j.status}">${j.status}</span>
<div class="progress-bar"><div class="progress-fill ${progressClass}" style="width:${j.progress}%"></div></div>
<div class="job-meta">
<span>${Math.round(j.progress)}%</span>
<span>Idx: ${j.current_index}/${j.total_units}</span>
<span>${new Date(j.created_at).toLocaleString()}</span>
</div>
${j.error ? `<div class="error-msg">${escapeHtml(j.error)}</div>` : ''}
${downloads.length ? `<div class="download-links">${downloads.join('')}</div>` : ''}
<div class="job-detail" data-id="${j.id}">
<div class="segment-log">${segs.map((s, i) => `<div class="segment-entry"><span class="segment-time">[${s.startTime.toFixed(1)}s]</span> ${escapeHtml(s.description)}</div>`).join('')}</div>
</div>
<button class="toggle-detail" data-id="${j.id}">${segs.length} segments</button>
</div>`;
}).join('');
// Wire up action buttons
container.querySelectorAll<HTMLElement>('.act-start').forEach(b =>
b.addEventListener('click', () => handleJobAction(b.dataset.id || '', 'start')));
container.querySelectorAll<HTMLElement>('.act-pause').forEach(b =>
b.addEventListener('click', () => handleJobAction(b.dataset.id || '', 'pause')));
container.querySelectorAll<HTMLElement>('.act-restart').forEach(b =>
b.addEventListener('click', () => handleJobAction(b.dataset.id || '', 'restart')));
container.querySelectorAll<HTMLElement>('.act-delete').forEach(b =>
b.addEventListener('click', () => handleJobAction(b.dataset.id || '', 'delete')));
container.querySelectorAll<HTMLElement>('.toggle-detail').forEach(b => {
b.addEventListener('click', () => {
const jobId = b.dataset.id || '';
const detail = container.querySelector<HTMLElement>(`.job-detail[data-id="${jobId}"]`);
if (!detail) return;
detail.classList.toggle('open');
const job = jobs.find(j => j.id === jobId);
const segs: AudioSegment[] = job ? JSON.parse(job.segments || '[]') : [];
b.textContent = detail.classList.contains('open') ? 'Hide segments' : `${segs.length} segments`;
});
});
}
async function handleJobAction(id: string, action: string): Promise<void> {
const method = action === 'delete' ? 'DELETE' : 'POST';
const url = `/api/jobs/${id}${action === 'delete' ? '' : '/' + action}`;
try {
await api(method, url);
loadJobs();
} catch (err: any) {
alert(`Error: ${err.message}`);
}
}
el('refresh-jobs').addEventListener('click', loadJobs);
// ── Polling ───────────────────────────────────────────
function startPolling(): void {
if (pollTimer) return;
pollTimer = window.setInterval(loadJobs, 5000);
}
// ── SSE live progress ─────────────────────────────────
function connectSSE(jobId: string): void {
if (sseMap.has(jobId)) return;
const es = new EventSource(`/api/jobs/${jobId}/progress?token=${encodeURIComponent(authToken!)}`);
es.onmessage = (event: MessageEvent) => {
const data: ProgressData = JSON.parse(event.data);
updateJobCard(jobId, data);
if (data.status === 'completed' || data.status === 'failed' || data.status === 'cancelled') {
es.close();
sseMap.delete(jobId);
}
};
es.onerror = () => {
es.close();
sseMap.delete(jobId);
};
sseMap.set(jobId, es);
}
function updateJobCard(jobId: string, data: ProgressData): void {
const card = document.querySelector<HTMLElement>(`.job-card[data-id="${jobId}"]`);
if (!card) return;
const badge = card.querySelector('.status-badge');
if (badge) {
badge.className = `status-badge status-${data.status}`;
badge.textContent = data.status;
}
const fill = card.querySelector<HTMLElement>('.progress-fill');
if (fill) {
fill.style.width = data.progress + '%';
fill.className = 'progress-fill';
if (data.status === 'completed') fill.classList.add('completed');
else if (data.status === 'failed') fill.classList.add('failed');
}
const metaSpans = card.querySelectorAll<HTMLElement>('.job-meta span');
if (metaSpans[0]) metaSpans[0].textContent = Math.round(data.progress) + '%';
if (metaSpans[1]) metaSpans[1].textContent = `Idx: ${data.currentIndex}/${data.totalUnits}`;
const log = card.querySelector<HTMLElement>('.segment-log');
if (log && data.segments) {
log.innerHTML = data.segments.map(s =>
`<div class="segment-entry"><span class="segment-time">[${s.startTime.toFixed(1)}s]</span> ${escapeHtml(s.description)}</div>`
).join('');
}
const toggleBtn = card.querySelector<HTMLElement>('.toggle-detail');
if (toggleBtn && data.segments) {
toggleBtn.textContent = `${data.segments.length} segments`;
}
}
// ── Settings ──────────────────────────────────────────
async function loadSettings(): Promise<void> {
try {
const data = await apiJson<{ config: Record<string, string> }>('GET', '/api/config');
const container = el('settings-fields');
const entries = Object.entries(data.config || {});
if (!entries.length) {
container.innerHTML = '<p class="empty">No custom settings yet. Settings from .env are used as defaults.</p>';
return;
}
container.innerHTML = entries.map(([key, value]) =>
`<label>${escapeHtml(key)} <input type="text" name="${escapeHtml(key)}" value="${escapeHtml(String(value))}"></label>`
).join('');
} catch (err) {
console.error(err);
}
}
el('settings-form').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target as HTMLFormElement);
const config: Record<string, string> = {};
for (const [key, val] of fd.entries()) {
config[key] = val as string;
}
try {
await apiJson('PUT', '/api/config', config);
alert('Settings saved');
} catch (err: any) {
alert('Error: ' + err.message);
}
});
// ── Files list ────────────────────────────────────────
let selectedFiles = new Set<string>();
async function loadFilesList(): Promise<void> {
try {
const data = await apiJson<{ files: FileInfo[] }>('GET', '/api/files');
const tbody = document.querySelector('#files-table tbody')!;
tbody.innerHTML = data.files.map(f => `
<tr>
<td><input type="checkbox" class="file-checkbox" data-path="${escapeHtml(f.filePath)}"></td>
<td>${escapeHtml(f.filename)}</td>
<td>${formatSize(f.size)}</td>
</tr>
`).join('');
tbody.querySelectorAll<HTMLInputElement>('.file-checkbox').forEach(cb => {
cb.addEventListener('change', updateFileSelection);
});
} catch (err) {
console.error(err);
}
}
function updateFileSelection(): void {
selectedFiles.clear();
document.querySelectorAll<HTMLInputElement>('.file-checkbox:checked').forEach(cb => {
if (cb.dataset.path) selectedFiles.add(cb.dataset.path);
});
(el('delete-selected-files') as HTMLButtonElement).disabled = selectedFiles.size === 0;
}
(el('select-all-files') as HTMLInputElement).addEventListener('change', function () {
document.querySelectorAll<HTMLInputElement>('.file-checkbox').forEach(cb => {
cb.checked = this.checked;
});
updateFileSelection();
});
el('delete-selected-files').addEventListener('click', () => {
if (!confirm(`Delete ${selectedFiles.size} file(s)?`)) return;
alert('File deletion not yet implemented');
});
el('refresh-files-list').addEventListener('click', loadFilesList);
// ── Config defaults for New Job form ─────────────────
async function loadConfigDefaults(): Promise<void> {
try {
const data = await apiJson<{ config: Record<string, string> }>('GET', '/api/config');
const c = data.config || {};
if (c.visionProvider) {
const sel = document.querySelector<HTMLSelectElement>('[name="visionProvider"]');
if (sel) {
sel.innerHTML = '<option value="openai">OpenAI</option><option value="gemini">Gemini</option><option value="ollama">Ollama</option><option value="openrouter">OpenRouter</option>';
sel.value = c.visionProvider;
}
}
if (c.ttsProvider) {
const sel = document.querySelector<HTMLSelectElement>('[name="ttsProvider"]');
if (sel) {
sel.innerHTML = '<option value="openai">OpenAI</option><option value="elevenlabs">ElevenLabs</option><option value="google">Google Cloud</option>';
sel.value = c.ttsProvider;
}
}
const fields: [string, string?][] = [
['visionModel'], ['ttsModel'], ['ttsVoice'], ['ttsSpeedFactor'],
['ttsInstructions', 'textarea'], ['batchWindowDuration'], ['framesInBatch'],
['captureIntervalSeconds'], ['contextWindowSize'],
['defaultPrompt', 'textarea'], ['changePrompt', 'textarea'], ['batchPrompt', 'textarea'],
];
for (const [name, tag] of fields) {
const el = document.querySelector<HTMLInputElement | HTMLTextAreaElement>(`[name="${name}"]`);
if (el && c[name] !== undefined) el.value = c[name];
}
} catch (err) {
console.error(err);
}
}
// ── Init ──────────────────────────────────────────────
function initApp(): void {
loadJobs();
loadBrowseFiles();
loadConfigDefaults();
startPolling();
}
// ── Startup ───────────────────────────────────────────
(async () => {
if (authToken) {
try {
const res = await fetch('/api/auth/check', {
headers: { Authorization: `Basic ${authToken}` },
});
const data = await res.json();
if (data.authenticated) {
showMainScreen();
initApp();
return;
}
} catch { /* fall through to login */ }
}
showLoginScreen();
})();

View File

@@ -43,7 +43,7 @@
</div> </div>
</div> </div>
<div id="new-job" class="tab-content hidden"> <div id="new-job" class="tab-content">
<h2>Create New Job</h2> <h2>Create New Job</h2>
<form id="new-job-form"> <form id="new-job-form">
<fieldset> <fieldset>
@@ -118,7 +118,7 @@
</form> </form>
</div> </div>
<div id="settings" class="tab-content hidden"> <div id="settings" class="tab-content">
<h2>Server Configuration</h2> <h2>Server Configuration</h2>
<p class="hint">These settings are stored on the server and used as defaults for new jobs.</p> <p class="hint">These settings are stored on the server and used as defaults for new jobs.</p>
<form id="settings-form"> <form id="settings-form">
@@ -127,7 +127,7 @@
</form> </form>
</div> </div>
<div id="files" class="tab-content hidden"> <div id="files" class="tab-content">
<h2>Uploaded Files</h2> <h2>Uploaded Files</h2>
<div class="toolbar"> <div class="toolbar">
<button id="refresh-files-list">Refresh</button> <button id="refresh-files-list">Refresh</button>
@@ -140,6 +140,6 @@
</div> </div>
</div> </div>
<script src="/app.js"></script> <script type="module" src="/app.js"></script>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"outDir": ".",
"rootDir": ".",
"sourceMap": false,
"declaration": false,
"lib": ["ES2020", "DOM", "DOM.Iterable"]
},
"files": ["app.ts"]
}