diff --git a/package.json b/package.json index f3ea4c9..ac59c3c 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dev": "ts-node src/cli/index.ts", "server": "ts-node src/server/index.ts", "server:build": "node dist/server/index.js", + "build:frontend": "tsc -p src/server/public/tsconfig.json", "test": "jest", "lint": "eslint src/**/*.ts", "prepublishOnly": "npm run build" diff --git a/src/server/public/app.js b/src/server/public/app.js index fc9a159..fabd637 100644 --- a/src/server/public/app.js +++ b/src/server/public/app.js @@ -1,268 +1,324 @@ +"use strict"; +// ── Types ──────────────────────────────────────────── +// ── State ──────────────────────────────────────────── let authToken = sessionStorage.getItem('authToken'); -let selectedFilePath = ''; -let currentConfig = {}; - +let selectedFilePath = null; +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() { - const h = { 'Content-Type': 'application/json' }; - if (authToken) h['Authorization'] = `Basic ${authToken}`; - return h; + const h = { 'Content-Type': 'application/json' }; + if (authToken) + h['Authorization'] = `Basic ${authToken}`; + return h; } - async function api(method, url, body) { - const res = await fetch(url, { - method, - headers: apiHeaders(), - body: body ? JSON.stringify(body) : undefined - }); - if (res.status === 401) { + 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(method, url, body) { + const res = await api(method, url, body); + const data = await res.json(); + if (!res.ok) + throw new Error(data.error || 'Request failed'); + return data; +} +// ── Screen switching ────────────────────────────────── +function showLoginScreen() { + el('login-screen').classList.remove('hidden'); + el('main-screen').classList.add('hidden'); +} +function showMainScreen() { + el('login-screen').classList.add('hidden'); + el('main-screen').classList.remove('hidden'); +} +// ── Tab navigation ──────────────────────────────────── +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(); + const username = el('login-username').value; + const password = el('login-password').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; - showLogin(); - throw new Error('Unauthorized'); - } - return res; + sseMap.forEach(s => s.close()); + sseMap.clear(); + if (pollTimer) + clearInterval(pollTimer); + showLoginScreen(); +}); +// ── Utils ───────────────────────────────────────────── +function escapeHtml(str) { + if (!str) + return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); } - -async function apiJson(method, url, body) { - const res = await api(method, url, body); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || 'Request failed'); - return data; -} - -// Login -function showLogin() { - document.getElementById('login-screen').classList.remove('hidden'); - document.getElementById('main-screen').classList.add('hidden'); - document.getElementById('login-error').classList.add('hidden'); -} - -function showMain() { - document.getElementById('login-screen').classList.add('hidden'); - document.getElementById('main-screen').classList.remove('hidden'); -} - -document.getElementById('login-form').addEventListener('submit', async (e) => { - e.preventDefault(); - const username = document.getElementById('login-username').value; - const password = document.getElementById('login-password').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; - sessionStorage.setItem('authToken', authToken); - showMain(); - initApp(); - } else { - document.getElementById('login-error').textContent = data.error; - document.getElementById('login-error').classList.remove('hidden'); +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++; } - } catch (err) { - document.getElementById('login-error').textContent = 'Connection failed'; - document.getElementById('login-error').classList.remove('hidden'); - } -}); - -document.getElementById('logout-btn').addEventListener('click', () => { - sessionStorage.removeItem('authToken'); - authToken = null; - showLogin(); -}); - -// Tab navigation -document.querySelectorAll('button.tab').forEach(btn => { - btn.addEventListener('click', () => { - document.querySelectorAll('button.tab').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); - document.getElementById(btn.dataset.tab).classList.add('active'); - if (btn.dataset.tab === 'dashboard') loadJobs(); - if (btn.dataset.tab === 'files') loadFilesList(); - }); -}); - -// Mini tabs (video source) -document.querySelectorAll('button.tab-mini').forEach(btn => { - btn.addEventListener('click', () => { - document.querySelectorAll('button.tab-mini').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - document.querySelectorAll('.src-panel').forEach(p => p.classList.remove('active')); - document.getElementById('src-' + btn.dataset.src).classList.add('active'); - }); -}); - -// 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); + return `${size.toFixed(1)} ${units[i]}`; +} +// ── Browse files (for New Job) ──────────────────────── async function loadBrowseFiles() { - try { - const data = await apiJson('GET', '/api/files'); - const sel = document.getElementById('video-select'); - sel.innerHTML = ''; - data.files.forEach(f => { - sel.innerHTML += ``; - }); - } catch (err) { - console.error(err); - } + try { + const data = await apiJson('GET', '/api/files'); + const sel = el('video-select'); + sel.innerHTML = ''; + 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); + } } - -document.getElementById('video-select').addEventListener('change', (e) => { - if (e.target.value) selectedFilePath = e.target.value; +el('refresh-files').addEventListener('click', loadBrowseFiles); +el('video-select').addEventListener('change', function () { + if (this.value) + selectedFilePath = this.value; }); - -// YouTube download -document.getElementById('download-url').addEventListener('click', async () => { - const url = document.getElementById('youtube-url').value; - if (!url) return; - const status = document.getElementById('download-status'); - status.textContent = 'Downloading...'; - status.className = 'status'; - try { - const data = await apiJson('POST', '/api/files/youtube', { url }); - status.textContent = `Downloaded: ${data.filename}`; - status.className = 'status success'; - selectedFilePath = data.filePath; - document.getElementById('video-select').innerHTML += ``; - } catch (err) { - status.textContent = `Error: ${err.message}`; - status.className = 'status error'; - } +// ── File upload ─────────────────────────────────────── +el('video-upload').addEventListener('change', function () { + if (this.files?.length) + selectedFilePath = null; // will upload on submit }); - -// New job form -document.getElementById('new-job-form').addEventListener('submit', async (e) => { - e.preventDefault(); - if (!selectedFilePath) { - const fileEl = document.getElementById('video-upload'); - if (fileEl.files.length > 0) { - const formData = new FormData(); - formData.append('video', fileEl.files[0]); - try { - const res = await fetch('/api/files/upload', { method: 'POST', headers: { Authorization: `Basic ${authToken}` }, body: formData }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || 'Upload failed'); - selectedFilePath = data.filePath; - } catch (err) { - alert('Upload error: ' + err.message); +// ── YouTube download ────────────────────────────────── +el('download-url').addEventListener('click', async () => { + const url = el('youtube-url').value; + if (!url) return; - } - } else { - alert('Please select a video file or source'); - return; + const status = el('download-status'); + status.textContent = 'Downloading...'; + status.className = 'status'; + try { + const data = await apiJson('POST', '/api/files/youtube', { url }); + status.textContent = `Downloaded: ${data.filename}`; + status.className = 'status success'; + selectedFilePath = data.filePath; + const sel = el('video-select'); + 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.className = 'status error'; } - } - - const fd = new FormData(e.target); - const config = {}; - 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) && val !== '') config[key] = parseFloat(val); - else config[key] = val; - } - - const outputOptions = { - audio: fd.get('output-audio') === 'on', - subtitles: fd.get('output-subtitles') === 'on', - muxed: fd.get('output-muxed') === 'on' - }; - - // Build config with vision/tts providers - if (config.visionProvider) { - config.visionProviders = {}; - config.visionProviders[config.visionProvider] = { - model: config.visionModel || 'gpt-4o', - maxTokens: config.visionMaxTokens ? parseInt(config.visionMaxTokens) : 300 - }; - } - if (config.ttsProvider) { - config.ttsProviders = {}; - config.ttsProviders[config.ttsProvider] = { - model: config.ttsModel || 'tts-1', - voice: config.ttsVoice || 'alloy' - }; - } - - 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('POST', '/api/jobs', { videoPath: selectedFilePath, config, outputOptions }); - await apiJson('POST', `/api/jobs/${data.job.id}/start`); - selectedFilePath = ''; - document.getElementById('video-upload').value = ''; - document.getElementById('new-job-form').reset(); - document.querySelector('.tab[data-tab="dashboard"]').click(); - loadJobs(); - } catch (err) { - alert('Error creating job: ' + err.message); - } }); - -// Load jobs +// ── New Job form ────────────────────────────────────── +el('new-job-form').addEventListener('submit', async (e) => { + e.preventDefault(); + if (!selectedFilePath) { + const fileEl = el('video-upload'); + if (fileEl.files?.length) { + const formData = new FormData(); + formData.append('video', fileEl.files[0]); + try { + 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(); + if (!res.ok) + throw new Error(data.error || 'Upload failed'); + selectedFilePath = data.filePath; + } + catch (err) { + alert('Upload error: ' + err.message); + return; + } + } + else { + alert('Please select a video file or source'); + return; + } + } + const fd = new FormData(e.target); + const config = {}; + 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) && val !== '') + config[key] = parseFloat(val); + 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 = {}; + vp[config.visionProvider] = { + model: config.visionModel || 'gpt-4o', + maxTokens: config.visionMaxTokens ? parseInt(config.visionMaxTokens) : 300, + }; + config.visionProviders = vp; + } + if (config.ttsProvider) { + const tp = {}; + tp[config.ttsProvider] = { + 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('POST', '/api/jobs', { + videoPath: selectedFilePath, + config, + outputOptions, + }); + await apiJson('POST', `/api/jobs/${data.job.id}/start`); + selectedFilePath = null; + el('video-upload').value = ''; + el('new-job-form').reset(); + switchTab('dashboard'); + } + catch (err) { + alert('Error creating job: ' + err.message); + } +}); +// ── Job list & rendering ────────────────────────────── async function loadJobs() { - try { - const data = await apiJson('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); - } + try { + const data = await apiJson('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) { - const container = document.getElementById('jobs-list'); - if (!jobs.length) { - container.innerHTML = '

No jobs yet. Create one from the "New Job" tab.

'; - return; - } - - container.innerHTML = jobs.map(j => { - const segs = JSON.parse(j.segments || '[]'); - const progressClass = j.status === 'completed' ? 'completed' : j.status === 'failed' ? 'failed' : ''; - const downloads = []; - if (j.status === 'completed') { - if (j.output_audio) downloads.push(`Audio`); - if (j.output_subtitles_srt) downloads.push(`SRT`); - if (j.output_subtitles_vtt) downloads.push(`VTT`); - if (j.output_muxed) downloads.push(`Muxed`); + const container = el('jobs-list'); + if (!jobs.length) { + container.innerHTML = '

No jobs yet. Create one from the "New Job" tab.

'; + return; } - - let actions = ''; - if (j.status === 'pending' || j.status === 'queued') { - actions += ``; - } - if (j.status === 'processing') { - actions += ``; - } - if (j.status === 'failed' || j.status === 'paused' || j.status === 'cancelled') { - actions += ``; - } - if (j.status !== 'processing') { - actions += ``; - } - - return ` + container.innerHTML = jobs.map(j => { + const segs = JSON.parse(j.segments || '[]'); + const progressClass = j.status === 'completed' ? 'completed' : j.status === 'failed' ? 'failed' : ''; + const downloads = []; + if (j.status === 'completed') { + if (j.output_audio) + downloads.push(`Audio`); + if (j.output_subtitles_srt) + downloads.push(`SRT`); + if (j.output_subtitles_vtt) + downloads.push(`VTT`); + if (j.output_muxed) + downloads.push(`Muxed`); + } + let actions = ''; + if (j.status === 'pending' || j.status === 'queued') { + actions += ``; + } + if (j.status === 'processing') { + actions += ``; + } + if (j.status === 'failed' || j.status === 'paused' || j.status === 'cancelled') { + actions += ``; + } + if (j.status !== 'processing') { + actions += ``; + } + return `

${escapeHtml(j.video_filename)}

@@ -272,7 +328,7 @@ function renderJobs(jobs) {
${Math.round(j.progress)}% - Index: ${j.current_index}/${j.total_units} + Idx: ${j.current_index}/${j.total_units} ${new Date(j.created_at).toLocaleString()}
${j.error ? `
${escapeHtml(j.error)}
` : ''} @@ -282,243 +338,223 @@ function renderJobs(jobs) {
`; - }).join(''); - - // Wire up buttons - container.querySelectorAll('.start-job').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id, 'start'))); - container.querySelectorAll('.pause-job').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id, 'pause'))); - container.querySelectorAll('.restart-job').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id, 'restart'))); - container.querySelectorAll('.delete-job').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id, 'delete'))); - container.querySelectorAll('.toggle-detail').forEach(b => b.addEventListener('click', () => { - const detail = container.querySelector(`.job-detail[data-id="${b.dataset.id}"]`); - 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`; - })); + }).join(''); + // Wire up action buttons + container.querySelectorAll('.act-start').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('.act-restart').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('.toggle-detail').forEach(b => { + b.addEventListener('click', () => { + const jobId = b.dataset.id || ''; + const detail = container.querySelector(`.job-detail[data-id="${jobId}"]`); + if (!detail) + return; + detail.classList.toggle('open'); + 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) { - const method = action === 'delete' ? 'DELETE' : 'POST'; - const url = `/api/jobs/${id}${action === 'delete' ? '' : '/' + action}`; - try { - await api(method, url); - loadJobs(); - } catch (err) { - alert(`Error: ${err.message}`); - } + const method = action === 'delete' ? 'DELETE' : 'POST'; + const url = `/api/jobs/${id}${action === 'delete' ? '' : '/' + action}`; + try { + await api(method, url); + loadJobs(); + } + catch (err) { + alert(`Error: ${err.message}`); + } } - -// Jobs refresh -document.getElementById('refresh-jobs').addEventListener('click', loadJobs); - -// Auto-refresh jobs -let jobsInterval; -function startJobsPolling() { - jobsInterval = setInterval(loadJobs, 5000); +el('refresh-jobs').addEventListener('click', loadJobs); +// ── Polling ─────────────────────────────────────────── +function startPolling() { + if (pollTimer) + return; + pollTimer = window.setInterval(loadJobs, 5000); } -function stopJobsPolling() { - clearInterval(jobsInterval); +// ── SSE live progress ───────────────────────────────── +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); } - -// Settings +function updateJobCard(jobId, data) { + 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 => `
[${s.startTime.toFixed(1)}s] ${escapeHtml(s.description)}
`).join(''); + } + const toggleBtn = card.querySelector('.toggle-detail'); + if (toggleBtn && data.segments) { + toggleBtn.textContent = `${data.segments.length} segments`; + } +} +// ── Settings ────────────────────────────────────────── async function loadSettings() { - try { - const data = await apiJson('GET', '/api/config'); - const container = document.getElementById('settings-fields'); - const config = data.config || {}; - currentConfig = config; - let html = ''; - for (const [key, value] of Object.entries(config)) { - html += ``; + try { + const data = await apiJson('GET', '/api/config'); + const container = el('settings-fields'); + const entries = Object.entries(data.config || {}); + if (!entries.length) { + container.innerHTML = '

No custom settings yet. Settings from .env are used as defaults.

'; + return; + } + container.innerHTML = entries.map(([key, value]) => ``).join(''); } - if (!Object.keys(config).length) { - html = '

No custom settings yet. Settings from .env are used as defaults.

'; + catch (err) { + console.error(err); } - container.innerHTML = html; - } catch (err) { - console.error(err); - } } - -document.getElementById('settings-form').addEventListener('submit', async (e) => { - e.preventDefault(); - const fd = new FormData(e.target); - const config = {}; - for (const [key, val] of fd.entries()) { - config[key] = val; - } - try { - await apiJson('PUT', '/api/config', config); - alert('Settings saved'); - } catch (err) { - alert('Error: ' + err.message); - } +el('settings-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + const config = {}; + for (const [key, val] of fd.entries()) { + config[key] = val; + } + try { + await apiJson('PUT', '/api/config', config); + alert('Settings saved'); + } + catch (err) { + alert('Error: ' + err.message); + } }); - -// Files list +// ── Files list ──────────────────────────────────────── let selectedFiles = new Set(); async function loadFilesList() { - try { - const data = await apiJson('GET', '/api/files'); - const tbody = document.querySelector('#files-table tbody'); - tbody.innerHTML = data.files.map(f => ` + try { + const data = await apiJson('GET', '/api/files'); + const tbody = document.querySelector('#files-table tbody'); + tbody.innerHTML = data.files.map(f => ` ${escapeHtml(f.filename)} ${formatSize(f.size)} `).join(''); - - document.querySelectorAll('.file-checkbox').forEach(cb => { - cb.addEventListener('change', () => updateFileSelection()); - }); - } catch (err) { - console.error(err); - } + tbody.querySelectorAll('.file-checkbox').forEach(cb => { + cb.addEventListener('change', updateFileSelection); + }); + } + catch (err) { + console.error(err); + } } - function updateFileSelection() { - selectedFiles.clear(); - document.querySelectorAll('.file-checkbox:checked').forEach(cb => { - selectedFiles.add(cb.dataset.path); - }); - document.getElementById('delete-selected-files').disabled = selectedFiles.size === 0; + selectedFiles.clear(); + document.querySelectorAll('.file-checkbox:checked').forEach(cb => { + if (cb.dataset.path) + selectedFiles.add(cb.dataset.path); + }); + el('delete-selected-files').disabled = selectedFiles.size === 0; } - -document.getElementById('select-all-files').addEventListener('change', (e) => { - document.querySelectorAll('.file-checkbox').forEach(cb => { cb.checked = e.target.checked; }); - updateFileSelection(); +el('select-all-files').addEventListener('change', function () { + document.querySelectorAll('.file-checkbox').forEach(cb => { + cb.checked = this.checked; + }); + updateFileSelection(); }); - -document.getElementById('delete-selected-files').addEventListener('click', async () => { - if (!confirm(`Delete ${selectedFiles.size} file(s)?`)) 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'); -}); - -document.getElementById('refresh-files-list').addEventListener('click', loadFilesList); - -// Pre-fill new job form with config defaults -async function loadConfigDefaults() { - try { - const data = await apiJson('GET', '/api/config'); - const config = data.config || {}; - - if (config.visionProvider) { - const sel = document.querySelector('[name="visionProvider"]'); - sel.innerHTML = ''; - sel.value = config.visionProvider; - } - if (config.visionModel) document.querySelector('[name="visionModel"]').value = config.visionModel; - if (config.ttsProvider) { - const sel = document.querySelector('[name="ttsProvider"]'); - sel.innerHTML = ''; - sel.value = config.ttsProvider; - } - if (config.ttsModel) document.querySelector('[name="ttsModel"]').value = config.ttsModel; - if (config.ttsVoice) document.querySelector('[name="ttsVoice"]').value = config.ttsVoice; - if (config.ttsSpeedFactor) document.querySelector('[name="ttsSpeedFactor"]').value = config.ttsSpeedFactor; - if (config.ttsInstructions) document.querySelector('[name="ttsInstructions"]').value = config.ttsInstructions; - if (config.batchWindowDuration) document.querySelector('[name="batchWindowDuration"]').value = config.batchWindowDuration; - if (config.framesInBatch) document.querySelector('[name="framesInBatch"]').value = config.framesInBatch; - if (config.captureIntervalSeconds) document.querySelector('[name="captureIntervalSeconds"]').value = config.captureIntervalSeconds; - if (config.contextWindowSize) document.querySelector('[name="contextWindowSize"]').value = config.contextWindowSize; - if (config.defaultPrompt) document.querySelector('[name="defaultPrompt"]').value = config.defaultPrompt; - if (config.changePrompt) document.querySelector('[name="changePrompt"]').value = config.changePrompt; - if (config.batchPrompt) document.querySelector('[name="batchPrompt"]').value = config.batchPrompt; - } catch (err) { - console.error(err); - } -} - -// 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) => `
[${s.startTime.toFixed(1)}s] ${escapeHtml(s.description)}
`).join(''); - } - - // Update segment count button - const toggleBtn = card.querySelector('.toggle-detail'); - if (toggleBtn && data.segments) { - toggleBtn.textContent = `${data.segments.length} segments`; - } -} - -// Initialize -function initApp() { - loadJobs(); - loadBrowseFiles(); - loadConfigDefaults(); - startJobsPolling(); -} - -// Escape HTML for safe rendering -function escapeHtml(str) { - if (!str) return ''; - return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); -} - -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 () => { - if (authToken) { - try { - const res = await fetch('/api/auth/check', { headers: { Authorization: `Basic ${authToken}` } }); - const data = await res.json(); - if (data.authenticated) { - showMain(); - initApp(); +el('delete-selected-files').addEventListener('click', () => { + if (!confirm(`Delete ${selectedFiles.size} file(s)?`)) return; - } - } catch (e) {} - } - showLogin(); + alert('File deletion not yet implemented'); +}); +el('refresh-files-list').addEventListener('click', loadFilesList); +// ── Config defaults for New Job form ───────────────── +async function loadConfigDefaults() { + try { + const data = await apiJson('GET', '/api/config'); + const c = data.config || {}; + if (c.visionProvider) { + const sel = document.querySelector('[name="visionProvider"]'); + if (sel) { + sel.innerHTML = ''; + sel.value = c.visionProvider; + } + } + if (c.ttsProvider) { + const sel = document.querySelector('[name="ttsProvider"]'); + if (sel) { + sel.innerHTML = ''; + sel.value = c.ttsProvider; + } + } + const fields = [ + ['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(`[name="${name}"]`); + if (el && c[name] !== undefined) + el.value = c[name]; + } + } + catch (err) { + console.error(err); + } +} +// ── Init ────────────────────────────────────────────── +function initApp() { + 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(); })(); diff --git a/src/server/public/app.ts b/src/server/public/app.ts new file mode 100644 index 0000000..a8e69dd --- /dev/null +++ b/src/server/public/app.ts @@ -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(); +let pollTimer: number | null = null; + +// ── DOM helpers ─────────────────────────────────────── + +const $ = (sel: string): HTMLElement => document.querySelector(sel) as HTMLElement; +const $$ = (sel: string): NodeListOf => document.querySelectorAll(sel); +const el = (id: string): HTMLElement => document.getElementById(id)!; + +// ── API ─────────────────────────────────────────────── + +function apiHeaders(): Record { + const h: Record = { 'Content-Type': 'application/json' }; + if (authToken) h['Authorization'] = `Basic ${authToken}`; + return h; +} + +async function api(method: string, url: string, body?: unknown): Promise { + 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(method: string, url: string, body?: unknown): Promise { + 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(`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, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +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 { + try { + const data = await apiJson<{ files: FileInfo[] }>('GET', '/api/files'); + const sel = el('video-select') as HTMLSelectElement; + sel.innerHTML = ''; + 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 = {}; + 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 = {}; + 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 = {}; + 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 = {}; + 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 { + 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 = '

No jobs yet. Create one from the "New Job" tab.

'; + 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(`Audio`); + if (j.output_subtitles_srt) downloads.push(`SRT`); + if (j.output_subtitles_vtt) downloads.push(`VTT`); + if (j.output_muxed) downloads.push(`Muxed`); + } + + let actions = ''; + if (j.status === 'pending' || j.status === 'queued') { + actions += ``; + } + if (j.status === 'processing') { + actions += ``; + } + if (j.status === 'failed' || j.status === 'paused' || j.status === 'cancelled') { + actions += ``; + } + if (j.status !== 'processing') { + actions += ``; + } + + return ` +
+
+

${escapeHtml(j.video_filename)}

+
${actions}
+
+ ${j.status} +
+
+ ${Math.round(j.progress)}% + Idx: ${j.current_index}/${j.total_units} + ${new Date(j.created_at).toLocaleString()} +
+ ${j.error ? `
${escapeHtml(j.error)}
` : ''} + ${downloads.length ? `` : ''} +
+
${segs.map((s, i) => `
[${s.startTime.toFixed(1)}s] ${escapeHtml(s.description)}
`).join('')}
+
+ +
`; + }).join(''); + + // Wire up action buttons + container.querySelectorAll('.act-start').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('.act-restart').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('.toggle-detail').forEach(b => { + b.addEventListener('click', () => { + const jobId = b.dataset.id || ''; + const detail = container.querySelector(`.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 { + 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(`.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 => + `
[${s.startTime.toFixed(1)}s] ${escapeHtml(s.description)}
` + ).join(''); + } + + const toggleBtn = card.querySelector('.toggle-detail'); + if (toggleBtn && data.segments) { + toggleBtn.textContent = `${data.segments.length} segments`; + } +} + +// ── Settings ────────────────────────────────────────── + +async function loadSettings(): Promise { + try { + const data = await apiJson<{ config: Record }>('GET', '/api/config'); + const container = el('settings-fields'); + const entries = Object.entries(data.config || {}); + if (!entries.length) { + container.innerHTML = '

No custom settings yet. Settings from .env are used as defaults.

'; + return; + } + container.innerHTML = entries.map(([key, value]) => + `` + ).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 = {}; + 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(); + +async function loadFilesList(): Promise { + try { + const data = await apiJson<{ files: FileInfo[] }>('GET', '/api/files'); + const tbody = document.querySelector('#files-table tbody')!; + tbody.innerHTML = data.files.map(f => ` + + + ${escapeHtml(f.filename)} + ${formatSize(f.size)} + + `).join(''); + + tbody.querySelectorAll('.file-checkbox').forEach(cb => { + cb.addEventListener('change', updateFileSelection); + }); + } catch (err) { + console.error(err); + } +} + +function updateFileSelection(): void { + selectedFiles.clear(); + document.querySelectorAll('.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('.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 { + try { + const data = await apiJson<{ config: Record }>('GET', '/api/config'); + const c = data.config || {}; + + if (c.visionProvider) { + const sel = document.querySelector('[name="visionProvider"]'); + if (sel) { + sel.innerHTML = ''; + sel.value = c.visionProvider; + } + } + if (c.ttsProvider) { + const sel = document.querySelector('[name="ttsProvider"]'); + if (sel) { + sel.innerHTML = ''; + 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(`[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(); +})(); diff --git a/src/server/public/index.html b/src/server/public/index.html index f1a9421..8446d1b 100644 --- a/src/server/public/index.html +++ b/src/server/public/index.html @@ -43,7 +43,7 @@ -