Restore TypeScript frontend, fix src-panel class mismatch, add onclick fallbacks for mini-tabs and file label
This commit is contained in:
567
src/server/public/app.js
Normal file
567
src/server/public/app.js
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
"use strict";
|
||||||
|
// ── Types ────────────────────────────────────────────
|
||||||
|
// ── State ────────────────────────────────────────────
|
||||||
|
let authToken = sessionStorage.getItem('authToken');
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
async function api(method, url, body) {
|
||||||
|
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;
|
||||||
|
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, '>')
|
||||||
|
.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]}`;
|
||||||
|
}
|
||||||
|
// ── Browse files (for New Job) ────────────────────────
|
||||||
|
async function loadBrowseFiles() {
|
||||||
|
try {
|
||||||
|
const data = await apiJson('GET', '/api/files');
|
||||||
|
const sel = el('video-select');
|
||||||
|
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').addEventListener('change', function () {
|
||||||
|
if (this.value)
|
||||||
|
selectedFilePath = this.value;
|
||||||
|
});
|
||||||
|
// ── File upload ───────────────────────────────────────
|
||||||
|
const videoUpload = el('video-upload');
|
||||||
|
const uploadName = el('upload-name');
|
||||||
|
videoUpload.addEventListener('change', function () {
|
||||||
|
if (this.files?.length) {
|
||||||
|
selectedFilePath = null; // will upload on submit
|
||||||
|
uploadName.textContent = `Selected: ${this.files[0].name} (${formatSize(this.files[0].size)})`;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
uploadName.textContent = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// ── 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.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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// ── New Job form ──────────────────────────────────────
|
||||||
|
el('new-job-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!selectedFilePath) {
|
||||||
|
if (videoUpload.files?.length) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('video', videoUpload.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;
|
||||||
|
videoUpload.value = '';
|
||||||
|
uploadName.textContent = '';
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderJobs(jobs) {
|
||||||
|
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 = 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(`<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('.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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el('refresh-jobs').addEventListener('click', loadJobs);
|
||||||
|
// ── Polling ───────────────────────────────────────────
|
||||||
|
function startPolling() {
|
||||||
|
if (pollTimer)
|
||||||
|
return;
|
||||||
|
pollTimer = window.setInterval(loadJobs, 5000);
|
||||||
|
}
|
||||||
|
// ── 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);
|
||||||
|
}
|
||||||
|
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 => `<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() {
|
||||||
|
try {
|
||||||
|
const data = await apiJson('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);
|
||||||
|
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 ────────────────────────────────────────
|
||||||
|
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 => `
|
||||||
|
<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('.file-checkbox').forEach(cb => {
|
||||||
|
cb.addEventListener('change', updateFileSelection);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function updateFileSelection() {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
el('select-all-files').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() {
|
||||||
|
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 = '<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('[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 = [
|
||||||
|
['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();
|
||||||
|
})();
|
||||||
650
src/server/public/app.ts
Normal file
650
src/server/public/app.ts
Normal file
@@ -0,0 +1,650 @@
|
|||||||
|
// ── 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, '&')
|
||||||
|
.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<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 ───────────────────────────────────────
|
||||||
|
|
||||||
|
const videoUpload = el('video-upload') as HTMLInputElement;
|
||||||
|
const uploadName = el('upload-name');
|
||||||
|
|
||||||
|
videoUpload.addEventListener('change', function () {
|
||||||
|
if (this.files?.length) {
|
||||||
|
selectedFilePath = null; // will upload on submit
|
||||||
|
uploadName.textContent = `Selected: ${this.files[0].name} (${formatSize(this.files[0].size)})`;
|
||||||
|
} else {
|
||||||
|
uploadName.textContent = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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) {
|
||||||
|
if (videoUpload.files?.length) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('video', videoUpload.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;
|
||||||
|
videoUpload.value = '';
|
||||||
|
uploadName.textContent = '';
|
||||||
|
(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();
|
||||||
|
})();
|
||||||
@@ -4,453 +4,154 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Audio Description Server</title>
|
<title>Audio Description Server</title>
|
||||||
<style>
|
<link rel="stylesheet" href="/style.css">
|
||||||
*,*::before,*::after{box-sizing:border-box}
|
|
||||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;margin:0;background:#0d1117;color:#c9d1d9}
|
|
||||||
.screen{display:none}
|
|
||||||
.screen.active{display:flex;align-items:center;justify-content:center;min-height:100vh}
|
|
||||||
#login-screen{flex-direction:column}
|
|
||||||
.login-card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:32px;width:360px;text-align:center}
|
|
||||||
.login-card h1{margin:0 0 8px;font-size:1.4rem}
|
|
||||||
.login-card p{margin:0 0 20px;color:#8b949e}
|
|
||||||
.login-card label{display:block;text-align:left;font-size:.85rem;margin-bottom:12px;color:#8b949e}
|
|
||||||
.login-card input{width:100%;margin-top:4px;padding:8px 12px;background:#0d1117;border:1px solid #30363d;border-radius:6px;color:#c9d1d9;font-size:1rem}
|
|
||||||
.login-card button{width:100%;padding:10px;background:#238636;color:#fff;border:none;border-radius:6px;font-size:1rem;cursor:pointer;margin-top:8px}
|
|
||||||
.login-card button:hover{background:#2ea043}
|
|
||||||
.login-error{color:#f85149;margin-top:12px;display:none}
|
|
||||||
.login-error.show{display:block}
|
|
||||||
#main-screen.active{display:block;min-height:100vh}
|
|
||||||
header{display:flex;align-items:center;justify-content:space-between;padding:12px 24px;background:#161b22;border-bottom:1px solid #30363d}
|
|
||||||
header h1{font-size:1.1rem;margin:0}
|
|
||||||
nav{display:flex;gap:4px}
|
|
||||||
nav button{background:transparent;color:#8b949e;border:none;padding:8px 16px;cursor:pointer;border-radius:6px;font-size:.9rem}
|
|
||||||
nav button:hover{background:#21262d;color:#c9d1d9}
|
|
||||||
nav button.active{background:#1f6feb;color:#fff}
|
|
||||||
nav button.logout:hover{background:#da3633;color:#fff}
|
|
||||||
.page{display:none;padding:24px}
|
|
||||||
.page.active{display:block}
|
|
||||||
.toolbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}
|
|
||||||
.toolbar h2{margin:0;font-size:1.2rem}
|
|
||||||
button{padding:8px 16px;background:#21262d;color:#c9d1d9;border:1px solid #30363d;border-radius:6px;cursor:pointer;font-size:.9rem}
|
|
||||||
button:hover{background:#30363d}
|
|
||||||
button.primary{background:#238636;border-color:#238636;color:#fff}
|
|
||||||
button.primary:hover{background:#2ea043}
|
|
||||||
button.danger:hover{background:#da3633;color:#fff;border-color:#da3633}
|
|
||||||
fieldset{border:1px solid #30363d;border-radius:8px;padding:16px;margin-bottom:16px}
|
|
||||||
legend{font-weight:600;padding:0 8px}
|
|
||||||
label{font-size:.85rem;color:#8b949e}
|
|
||||||
input,select,textarea{padding:8px 12px;background:#0d1117;border:1px solid #30363d;border-radius:6px;color:#c9d1d9;font-size:.9rem}
|
|
||||||
textarea{resize:vertical;min-height:60px}
|
|
||||||
select{min-width:200px}
|
|
||||||
.file-upload-btn{display:inline-block;padding:10px 20px;background:#1f6feb;color:#fff;border-radius:6px;cursor:pointer;font-size:.95rem;margin:8px 0}
|
|
||||||
.file-upload-btn:hover{background:#388bfd}
|
|
||||||
.file-name{margin:8px 0;font-size:.85rem;color:#8b949e}
|
|
||||||
.src-option{display:none;margin-top:8px}
|
|
||||||
.src-option.active{display:block}
|
|
||||||
.yt-status{font-size:.85rem;margin:8px 0;color:#8b949e}
|
|
||||||
.yt-status.ok{color:#3fb950}
|
|
||||||
.yt-status.err{color:#f85149}
|
|
||||||
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
|
||||||
.form-grid .full{grid-column:1/-1}
|
|
||||||
.form-grid label{display:flex;flex-direction:column;gap:4px}
|
|
||||||
details{margin-bottom:12px;border:1px solid #30363d;border-radius:8px;padding:12px 16px}
|
|
||||||
details summary{cursor:pointer;font-weight:600;padding:4px 0}
|
|
||||||
details .form-grid{margin-top:12px}
|
|
||||||
.msg{text-align:center;padding:40px;color:#8b949e;font-style:italic}
|
|
||||||
.status-badge{display:inline-block;padding:2px 10px;border-radius:12px;font-size:.75rem;font-weight:600;text-transform:uppercase;margin:4px 0}
|
|
||||||
.stat-pending{background:#21262d;color:#8b949e}
|
|
||||||
.stat-queued{background:#1a2332;color:#58a6ff}
|
|
||||||
.stat-processing{background:#1a2332;color:#58a6ff}
|
|
||||||
.stat-completed{background:#172f1e;color:#3fb950}
|
|
||||||
.stat-failed{background:#2d1518;color:#f85149}
|
|
||||||
.stat-paused{background:#2d2400;color:#d29922}
|
|
||||||
.stat-cancelled{background:#21262d;color:#8b949e}
|
|
||||||
.job-card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;margin-bottom:8px}
|
|
||||||
.job-card h3{margin:0 0 4px;font-size:1rem;word-break:break-all}
|
|
||||||
.job-top{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:4px}
|
|
||||||
.job-actions{display:flex;gap:4px;flex-wrap:wrap}
|
|
||||||
.job-actions button{font-size:.8rem;padding:4px 10px}
|
|
||||||
.progress-outer{height:6px;background:#21262d;border-radius:3px;margin:8px 0;overflow:hidden}
|
|
||||||
.progress-inner{height:100%;background:#1f6feb;border-radius:3px;transition:width .3s}
|
|
||||||
.progress-inner.done{background:#3fb950}
|
|
||||||
.progress-inner.fail{background:#f85149}
|
|
||||||
.job-meta{display:flex;gap:16px;font-size:.8rem;color:#8b949e;margin-bottom:4px}
|
|
||||||
.job-error{color:#f85149;font-size:.85rem;background:#2d1518;padding:8px;border-radius:4px;margin:8px 0}
|
|
||||||
.dl-links{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
|
|
||||||
.dl-links a{padding:6px 12px;background:#21262d;color:#58a6ff;text-decoration:none;border-radius:4px;font-size:.85rem;border:1px solid #30363d}
|
|
||||||
.seg-log{max-height:150px;overflow-y:auto;font-size:.8rem;color:#8b949e;background:#0d1117;padding:8px;border-radius:4px;margin:8px 0;display:none}
|
|
||||||
.seg-log.open{display:block}
|
|
||||||
.seg-entry{padding:4px 0;border-bottom:1px solid #1c2128}
|
|
||||||
.seg-time{color:#58a6ff}
|
|
||||||
#files-table{width:100%;border-collapse:collapse;margin-top:12px}
|
|
||||||
#files-table th,#files-table td{text-align:left;padding:8px 12px;border-bottom:1px solid #30363d}
|
|
||||||
#files-table th{font-size:.85rem;color:#8b949e}
|
|
||||||
.diag{position:fixed;top:0;right:0;background:#238636;color:#fff;padding:2px 8px;font-size:11px;z-index:9999;border-radius:0 0 0 4px}
|
|
||||||
input[type=checkbox]{accent-color:#1f6feb}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="app">
|
||||||
<!-- LOGIN SCREEN -->
|
<div id="login-screen" class="screen">
|
||||||
<div id="login-screen" class="screen active">
|
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<h1>Audio Description Server</h1>
|
<h1>Audio Description Server</h1>
|
||||||
<p>Please log in to continue</p>
|
<p>Please log in to continue</p>
|
||||||
<label>Username <input type="text" id="luser" autocomplete="username"></label>
|
<form id="login-form">
|
||||||
<label>Password <input type="password" id="lpass" autocomplete="current-password"></label>
|
<label>Username <input type="text" id="login-username" required autocomplete="username"></label>
|
||||||
<button id="lbtn">Login</button>
|
<label>Password <input type="password" id="login-password" required autocomplete="current-password"></label>
|
||||||
<div class="login-error" id="lerr"></div>
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
<p id="login-error" class="error hidden"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- MAIN SCREEN -->
|
<div id="main-screen" class="screen hidden">
|
||||||
<div id="main-screen" class="screen">
|
|
||||||
<header>
|
<header>
|
||||||
<h1>Audio Description Server</h1>
|
<h1>Audio Description Server</h1>
|
||||||
<nav>
|
<nav>
|
||||||
<button class="navtab active" data-page="dashboard">Dashboard</button>
|
<button class="tab active" data-tab="dashboard">Dashboard</button>
|
||||||
<button class="navtab" data-page="newjob">New Job</button>
|
<button class="tab" data-tab="new-job">New Job</button>
|
||||||
<button class="navtab" data-page="settings">Settings</button>
|
<button class="tab" data-tab="settings">Settings</button>
|
||||||
<button class="navtab" data-page="files">Files</button>
|
<button class="tab" data-tab="files">Files</button>
|
||||||
<button id="logout-btn" class="logout">Logout</button>
|
<button id="logout-btn" class="tab danger">Logout</button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Dashboard -->
|
<div id="dashboard" class="tab-content active">
|
||||||
<div id="page-dashboard" class="page active">
|
<div class="toolbar">
|
||||||
<div class="toolbar"><h2>Jobs</h2><button id="refresh-jobs-btn">Refresh</button></div>
|
<h2>Jobs</h2>
|
||||||
<div id="jobs-container"></div>
|
<button id="refresh-jobs">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="jobs-list" class="jobs-list">
|
||||||
|
<p class="empty">No jobs yet. Create one from the "New Job" tab.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- New Job -->
|
<div id="new-job" class="tab-content">
|
||||||
<div id="page-newjob" class="page">
|
|
||||||
<h2>Create New Job</h2>
|
<h2>Create New Job</h2>
|
||||||
|
<form id="new-job-form">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Video Source</legend>
|
<legend>Video Source</legend>
|
||||||
<div style="display:flex;gap:8px;margin-bottom:12px">
|
<div class="tabs-mini">
|
||||||
<button class="srctab active" data-src="upload">Upload File</button>
|
<button type="button" class="tab-mini active" data-src="upload" onclick="(function(b){document.querySelectorAll('button.tab-mini').forEach(function(x){x.classList.remove('active')});b.classList.add('active');document.querySelectorAll('.src-panel').forEach(function(p){p.classList.remove('active')});var panel=document.getElementById('src-'+b.dataset.src);if(panel)panel.classList.add('active')})(this)">Upload</button>
|
||||||
<button class="srctab" data-src="browse">Browse Server Files</button>
|
<button type="button" class="tab-mini" data-src="browse" onclick="(function(b){document.querySelectorAll('button.tab-mini').forEach(function(x){x.classList.remove('active')});b.classList.add('active');document.querySelectorAll('.src-panel').forEach(function(p){p.classList.remove('active')});var panel=document.getElementById('src-'+b.dataset.src);if(panel)panel.classList.add('active')})(this)">Browse Files</button>
|
||||||
<button class="srctab" data-src="youtube">YouTube / URL</button>
|
<button type="button" class="tab-mini" data-src="youtube" onclick="(function(b){document.querySelectorAll('button.tab-mini').forEach(function(x){x.classList.remove('active')});b.classList.add('active');document.querySelectorAll('.src-panel').forEach(function(p){p.classList.remove('active')});var panel=document.getElementById('src-'+b.dataset.src);if(panel)panel.classList.add('active')})(this)">YouTube / URL</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="src-upload" class="src-option active">
|
<div id="src-upload" class="src-panel active">
|
||||||
<label class="file-upload-btn" for="video-file-input">Choose a video file...</label>
|
<label for="video-upload" class="file-label" onclick="document.getElementById('video-upload').click()">Choose a video file...</label>
|
||||||
<input type="file" id="video-file-input" accept="video/*" style="display:none">
|
<input type="file" id="video-upload" accept="video/*" style="display:none">
|
||||||
<div class="file-name" id="file-name-display"></div>
|
<p class="file-name" id="upload-name"></p>
|
||||||
</div>
|
</div>
|
||||||
<div id="src-browse" class="src-option">
|
<div id="src-browse" class="src-panel">
|
||||||
<select id="file-select"><option value="">-- Select a file --</option></select>
|
<select id="video-select"><option value="">-- Select file --</option></select>
|
||||||
<button id="refresh-browse-btn" style="margin-left:8px">Refresh</button>
|
<button type="button" id="refresh-files">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="src-youtube" class="src-option">
|
<div id="src-youtube" class="src-panel">
|
||||||
<input type="url" id="yt-url" placeholder="https://www.youtube.com/watch?v=..." style="width:400px">
|
<input type="url" id="youtube-url" placeholder="https://www.youtube.com/watch?v=...">
|
||||||
<button id="yt-dl-btn" style="margin-left:8px">Download</button>
|
<button type="button" id="download-url">Download</button>
|
||||||
<div class="yt-status" id="yt-status"></div>
|
<p id="download-status" class="status"></p>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Output</legend>
|
<legend>Output Options</legend>
|
||||||
<label style="margin-right:16px"><input type="checkbox" id="chk-audio" checked> Audio Track</label>
|
<label><input type="checkbox" name="output-audio" checked> Audio Description Track</label>
|
||||||
<label style="margin-right:16px"><input type="checkbox" id="chk-subs" checked> Subtitles</label>
|
<label><input type="checkbox" name="output-subtitles" checked> Subtitles (SRT + VTT)</label>
|
||||||
<label><input type="checkbox" id="chk-mux"> Muxed MKV</label>
|
<label><input type="checkbox" name="output-muxed"> Muxed Video (MKV with 2nd audio track)</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Vision Settings</summary>
|
<summary>Vision Settings</summary>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<label>Provider <select id="cfg-vision-provider"><option value="openai">OpenAI</option><option value="gemini">Gemini</option><option value="ollama">Ollama</option><option value="openrouter">OpenRouter</option></select></label>
|
<label>Provider <select name="visionProvider"></select></label>
|
||||||
<label>Model <input type="text" id="cfg-vision-model" value="gpt-4o"></label>
|
<label>Model <input type="text" name="visionModel"></label>
|
||||||
<label>Max Tokens <input type="number" id="cfg-vision-max-tokens" value="300" min="10" max="10000"></label>
|
<label>Max Tokens <input type="number" name="visionMaxTokens" min="10" max="10000"></label>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>TTS Settings</summary>
|
<summary>TTS Settings</summary>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<label>Provider <select id="cfg-tts-provider"><option value="openai">OpenAI</option><option value="elevenlabs">ElevenLabs</option><option value="google">Google Cloud</option></select></label>
|
<label>Provider <select name="ttsProvider"></select></label>
|
||||||
<label>Model <input type="text" id="cfg-tts-model" value="tts-1"></label>
|
<label>Model <input type="text" name="ttsModel"></label>
|
||||||
<label>Voice <input type="text" id="cfg-tts-voice" value="alloy"></label>
|
<label>Voice <input type="text" name="ttsVoice"></label>
|
||||||
<label>Speed <input type="number" id="cfg-tts-speed" value="1.5" min="0.5" max="3" step="0.1"></label>
|
<label>Speed Factor <input type="number" name="ttsSpeedFactor" min="0.5" max="3" step="0.1"></label>
|
||||||
<label class="full">Instructions <textarea id="cfg-tts-instructions" rows="2">Speak in a calm, narrating tone.</textarea></label>
|
<label class="full">Instructions <textarea name="ttsInstructions" rows="2"></textarea></label>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Processing</summary>
|
<summary>Processing Settings</summary>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<label><input type="checkbox" id="cfg-batch-mode" checked> Batch Mode</label>
|
<label>Batch Mode <input type="checkbox" name="batchTimeMode" checked></label>
|
||||||
<label>Batch Window (s) <input type="number" id="cfg-batch-window" value="15" min="1" max="120"></label>
|
<label>Batch Window (sec) <input type="number" name="batchWindowDuration" min="1" max="120"></label>
|
||||||
<label>Frames/Batch <input type="number" id="cfg-frames-per-batch" value="10" min="1" max="60"></label>
|
<label>Frames Per Batch <input type="number" name="framesInBatch" min="1" max="60"></label>
|
||||||
<label>Interval (s) <input type="number" id="cfg-interval" value="10" min="1" max="120"></label>
|
<label>Capture Interval (sec) <input type="number" name="captureIntervalSeconds" min="1" max="120"></label>
|
||||||
<label>Context Size <input type="number" id="cfg-context" value="5" min="1" max="20"></label>
|
<label>Context Window Size <input type="number" name="contextWindowSize" min="1" max="20"></label>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Prompts</summary>
|
<summary>Prompts</summary>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<label class="full">Default <textarea id="cfg-prompt-default" rows="3">Describe this frame from a video in 1-2 sentences for someone who cannot see it.</textarea></label>
|
<label class="full">Default Prompt <textarea name="defaultPrompt" rows="3"></textarea></label>
|
||||||
<label class="full">Change <textarea id="cfg-prompt-change" rows="3">Describe what has changed between these frames in 1-2 sentences.</textarea></label>
|
<label class="full">Change Prompt <textarea name="changePrompt" rows="3"></textarea></label>
|
||||||
<label class="full">Batch <textarea id="cfg-prompt-batch" rows="3">Describe the sequence of frames in this batch over time.</textarea></label>
|
<label class="full">Batch Prompt <textarea name="batchPrompt" rows="3"></textarea></label>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<button class="primary" id="create-job-btn" style="font-size:1.1rem;padding:12px 32px">Create & Start Job</button>
|
<button type="submit" class="btn-primary">Create & Start Job</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings -->
|
<div id="settings" class="tab-content">
|
||||||
<div id="page-settings" class="page">
|
<h2>Server Configuration</h2>
|
||||||
<h2>Server Config</h2>
|
<p class="hint">These settings are stored on the server and used as defaults for new jobs.</p>
|
||||||
<p style="color:#8b949e;font-size:.85rem">Stored on server, used as defaults for new jobs.</p>
|
<form id="settings-form">
|
||||||
<div id="settings-fields" class="form-grid"></div>
|
<div id="settings-fields" class="form-grid"></div>
|
||||||
<button class="primary" id="save-settings-btn" style="margin-top:16px">Save Settings</button>
|
<button type="submit" class="btn-primary">Save Settings</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Files -->
|
<div id="files" class="tab-content">
|
||||||
<div id="page-files" class="page">
|
|
||||||
<h2>Uploaded Files</h2>
|
<h2>Uploaded Files</h2>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button id="refresh-fileslist-btn">Refresh</button>
|
<button id="refresh-files-list">Refresh</button>
|
||||||
|
<button id="delete-selected-files" class="danger" disabled>Delete Selected</button>
|
||||||
|
</div>
|
||||||
|
<div id="files-table-container">
|
||||||
|
<table id="files-table"><thead><tr><th><input type="checkbox" id="select-all-files"></th><th>Filename</th><th>Size</th></tr></thead><tbody></tbody></table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table id="files-table"><thead><tr><th>Filename</th><th>Size</th></tr></thead><tbody></tbody></table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="diag" id="diag-badge">JS ✓</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
console.log('INLINE: page loaded, about to load app.js');
|
||||||
var AUTH = sessionStorage.getItem('adtoken') || '';
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
var SELFILE = '';
|
var diag = document.createElement('div');
|
||||||
var POLL = null;
|
diag.id = 'js-diag';
|
||||||
var SSE = {};
|
diag.style.cssText = 'position:fixed;top:0;right:0;background:#238636;color:#fff;padding:2px 8px;font-size:11px;z-index:9999;border-radius:0 0 0 4px';
|
||||||
|
diag.textContent = 'JS ✓';
|
||||||
function $(s){return document.querySelector(s)}
|
document.body.appendChild(diag);
|
||||||
function $$(s){return document.querySelectorAll(s)}
|
|
||||||
function byId(s){return document.getElementById(s)}
|
|
||||||
function esc(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
|
||||||
function fmtBytes(b){if(!b)return'0 B';var u=['B','KB','MB','GB'],i=0;while(b>=1024&&i<u.length-1){b/=1024;i++}return b.toFixed(1)+' '+u[i]}
|
|
||||||
|
|
||||||
// api
|
|
||||||
function hdrs(){var h={'Content-Type':'application/json'};if(AUTH)h['Authorization']='Basic '+AUTH;return h}
|
|
||||||
async function api(m,u,b){var r=await fetch(u,{method:m,headers:hdrs(),body:b?JSON.stringify(b):undefined});if(r.status===401){AUTH='';sessionStorage.removeItem('adtoken');show('login-screen');throw new Error('Unauth')}return r}
|
|
||||||
async function apij(m,u,b){var r=await api(m,u,b);var d=await r.json();if(!r.ok)throw new Error(d.error||'Fail');return d}
|
|
||||||
|
|
||||||
// screen/page switching
|
|
||||||
function show(id){
|
|
||||||
$$('.screen').forEach(function(e){e.classList.remove('active')});
|
|
||||||
var el=byId(id);if(el)el.classList.add('active');
|
|
||||||
}
|
|
||||||
function showPage(name){
|
|
||||||
$$('.page').forEach(function(e){e.classList.remove('active')});
|
|
||||||
$$('.navtab').forEach(function(e){e.classList.remove('active')});
|
|
||||||
var p=byId('page-'+name);if(p)p.classList.add('active');
|
|
||||||
var n=$('.navtab[data-page="'+name+'"]');if(n)n.classList.add('active');
|
|
||||||
if(name==='dashboard')loadJobs();
|
|
||||||
if(name==='files')loadFilesTab();
|
|
||||||
if(name==='settings')loadSettingsTab();
|
|
||||||
if(name==='newjob')loadBrowseSelect();
|
|
||||||
}
|
|
||||||
|
|
||||||
// nav
|
|
||||||
$$('.navtab').forEach(function(b){
|
|
||||||
b.addEventListener('click',function(){showPage(this.dataset.page)});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// login
|
|
||||||
byId('lbtn').addEventListener('click',async function(){
|
|
||||||
var u=byId('luser').value,p=byId('lpass').value;
|
|
||||||
try{var r=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})});
|
|
||||||
var d=await r.json();
|
|
||||||
if(d.authenticated){AUTH=d.token;sessionStorage.setItem('adtoken',AUTH);show('main-screen');showPage('dashboard');loadJobs()}
|
|
||||||
else{var e=byId('lerr');e.textContent=d.error;e.classList.add('show')}
|
|
||||||
}catch(ex){var e=byId('lerr');e.textContent='Connection failed';e.classList.add('show')}
|
|
||||||
});
|
|
||||||
byId('logout-btn').addEventListener('click',function(){
|
|
||||||
AUTH='';sessionStorage.removeItem('adtoken');
|
|
||||||
Object.keys(SSE).forEach(function(k){SSE[k].close()});SSE={};
|
|
||||||
if(POLL)clearInterval(POLL);
|
|
||||||
show('login-screen');
|
|
||||||
});
|
|
||||||
|
|
||||||
// source tabs
|
|
||||||
$$('.srctab').forEach(function(b){
|
|
||||||
b.addEventListener('click',function(){
|
|
||||||
$$('.srctab').forEach(function(x){x.classList.remove('active')});
|
|
||||||
b.classList.add('active');
|
|
||||||
$$('.src-option').forEach(function(x){x.classList.remove('active')});
|
|
||||||
var p=byId('src-'+b.dataset.src);if(p)p.classList.add('active');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// file upload
|
|
||||||
var fileInput=byId('video-file-input');
|
|
||||||
var fileNameDisplay=byId('file-name-display');
|
|
||||||
fileInput.addEventListener('change',function(){
|
|
||||||
if(this.files&&this.files.length){
|
|
||||||
SELFILE='';
|
|
||||||
fileNameDisplay.textContent='Selected: '+this.files[0].name+' ('+fmtBytes(this.files[0].size)+')';
|
|
||||||
}else{fileNameDisplay.textContent=''}
|
|
||||||
});
|
|
||||||
|
|
||||||
// browse files dropdown
|
|
||||||
async function loadBrowseSelect(){
|
|
||||||
try{var d=await apij('GET','/api/files');var s=byId('file-select');
|
|
||||||
s.innerHTML='<option value="">-- Select a file --</option>';
|
|
||||||
d.files.forEach(function(f){
|
|
||||||
var o=document.createElement('option');o.value=f.filePath;o.textContent=f.filename+' ('+fmtBytes(f.size)+')';s.appendChild(o);
|
|
||||||
})}catch(ex){console.error(ex)}
|
|
||||||
}
|
|
||||||
byId('refresh-browse-btn').addEventListener('click',loadBrowseSelect);
|
|
||||||
byId('file-select').addEventListener('change',function(){if(this.value)SELFILE=this.value});
|
|
||||||
|
|
||||||
// youtube
|
|
||||||
byId('yt-dl-btn').addEventListener('click',async function(){
|
|
||||||
var url=byId('yt-url').value;if(!url)return;
|
|
||||||
var st=byId('yt-status');st.textContent='Downloading...';st.className='yt-status';
|
|
||||||
try{var d=await apij('POST','/api/files/youtube',{url:url});
|
|
||||||
st.textContent='OK: '+d.filename;st.className='yt-status ok';
|
|
||||||
SELFILE=d.filePath;
|
|
||||||
var s=byId('file-select');var o=document.createElement('option');o.value=d.filePath;o.textContent=d.filename;o.selected=true;s.appendChild(o);
|
|
||||||
}catch(ex){st.textContent='Error: '+ex.message;st.className='yt-status err'}
|
|
||||||
});
|
|
||||||
|
|
||||||
// create job
|
|
||||||
byId('create-job-btn').addEventListener('click',async function(){
|
|
||||||
if(!SELFILE&&fileInput.files&&fileInput.files.length){
|
|
||||||
var fd=new FormData();fd.append('video',fileInput.files[0]);
|
|
||||||
try{var h={};if(AUTH)h['Authorization']='Basic '+AUTH;
|
|
||||||
var r=await fetch('/api/files/upload',{method:'POST',headers:h,body:fd});
|
|
||||||
var d=await r.json();if(!r.ok)throw new Error(d.error||'Upload fail');SELFILE=d.filePath}catch(ex){alert('Upload error: '+ex.message);return}
|
|
||||||
}
|
|
||||||
if(!SELFILE){alert('Please select a video file');return}
|
|
||||||
|
|
||||||
var cfg={};
|
|
||||||
cfg.visionProvider=byId('cfg-vision-provider').value;
|
|
||||||
cfg.visionModel=byId('cfg-vision-model').value;
|
|
||||||
cfg.visionProviders={};cfg.visionProviders[cfg.visionProvider]={model:cfg.visionModel,maxTokens:parseInt(byId('cfg-vision-max-tokens').value)||300};
|
|
||||||
cfg.ttsProvider=byId('cfg-tts-provider').value;
|
|
||||||
cfg.ttsModel=byId('cfg-tts-model').value;
|
|
||||||
cfg.ttsVoice=byId('cfg-tts-voice').value;
|
|
||||||
cfg.ttsSpeedFactor=parseFloat(byId('cfg-tts-speed').value)||1.5;
|
|
||||||
cfg.ttsInstructions=byId('cfg-tts-instructions').value;
|
|
||||||
cfg.ttsProviders={};cfg.ttsProviders[cfg.ttsProvider]={model:cfg.ttsModel,voice:cfg.ttsVoice};
|
|
||||||
cfg.batchTimeMode=byId('cfg-batch-mode').checked;
|
|
||||||
cfg.batchWindowDuration=parseInt(byId('cfg-batch-window').value)||15;
|
|
||||||
cfg.framesInBatch=parseInt(byId('cfg-frames-per-batch').value)||10;
|
|
||||||
cfg.captureIntervalSeconds=parseInt(byId('cfg-interval').value)||10;
|
|
||||||
cfg.contextWindowSize=parseInt(byId('cfg-context').value)||5;
|
|
||||||
cfg.defaultPrompt=byId('cfg-prompt-default').value;
|
|
||||||
cfg.changePrompt=byId('cfg-prompt-change').value;
|
|
||||||
cfg.batchPrompt=byId('cfg-prompt-batch').value;
|
|
||||||
|
|
||||||
var opts={audio:byId('chk-audio').checked,subtitles:byId('chk-subs').checked,muxed:byId('chk-mux').checked};
|
|
||||||
|
|
||||||
try{var jd=await apij('POST','/api/jobs',{videoPath:SELFILE,config:cfg,outputOptions:opts});
|
|
||||||
await apij('POST','/api/jobs/'+jd.job.id+'/start');
|
|
||||||
SELFILE='';fileInput.value='';fileNameDisplay.textContent='';byId('yt-url').value='';byId('yt-status').textContent='';
|
|
||||||
showPage('dashboard')}catch(ex){alert('Error: '+ex.message)}
|
|
||||||
});
|
|
||||||
|
|
||||||
// jobs
|
|
||||||
async function loadJobs(){
|
|
||||||
try{var d=await apij('GET','/api/jobs');var c=byId('jobs-container');
|
|
||||||
if(!d.jobs.length){c.innerHTML='<div class="msg">No jobs yet. Create one from the New Job tab.</div>';return}
|
|
||||||
c.innerHTML=d.jobs.map(function(j){
|
|
||||||
var segs=JSON.parse(j.segments||'[]');
|
|
||||||
var acts='';
|
|
||||||
if(j.status==='pending'||j.status==='queued')acts+='<button class="act-start" data-id="'+j.id+'">Start</button>';
|
|
||||||
if(j.status==='processing')acts+='<button class="act-pause" data-id="'+j.id+'">Pause</button>';
|
|
||||||
if(j.status==='failed'||j.status==='paused'||j.status==='cancelled')acts+='<button class="act-restart" data-id="'+j.id+'">Restart</button>';
|
|
||||||
if(j.status!=='processing')acts+='<button class="act-delete danger" data-id="'+j.id+'">Delete</button>';
|
|
||||||
var dls='';
|
|
||||||
if(j.status==='completed'){
|
|
||||||
if(j.output_audio)dls+='<a href="/api/jobs/'+j.id+'/download/audio" download>Audio</a>';
|
|
||||||
if(j.output_subtitles_srt)dls+='<a href="/api/jobs/'+j.id+'/download/subtitles?format=srt" download>SRT</a>';
|
|
||||||
if(j.output_subtitles_vtt)dls+='<a href="/api/jobs/'+j.id+'/download/subtitles?format=vtt" download>VTT</a>';
|
|
||||||
if(j.output_muxed)dls+='<a href="/api/jobs/'+j.id+'/download/muxed" download>Muxed</a>';
|
|
||||||
}
|
|
||||||
var pc=j.status==='completed'?'done':(j.status==='failed'?'fail':'');
|
|
||||||
return '<div class="job-card" data-id="'+j.id+'">'+
|
|
||||||
'<div class="job-top"><h3>'+esc(j.video_filename)+'</h3><div class="job-actions">'+acts+'</div></div>'+
|
|
||||||
'<span class="status-badge stat-'+j.status+'">'+j.status+'</span>'+
|
|
||||||
'<div class="progress-outer"><div class="progress-inner '+pc+'" 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="job-error">'+esc(j.error)+'</div>':'')+
|
|
||||||
(dls?'<div class="dl-links">'+dls+'</div>':'')+
|
|
||||||
'<div class="seg-log" data-id="'+j.id+'">'+segs.map(function(s,i){return '<div class="seg-entry"><span class="seg-time">['+s.startTime.toFixed(1)+'s]</span> '+esc(s.description)+'</div>'}).join('')+'</div>'+
|
|
||||||
'<button class="seg-toggle" data-id="'+j.id+'" style="margin-top:4px;font-size:.8rem">'+segs.length+' segments</button>'+
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
// wire buttons
|
|
||||||
c.querySelectorAll('.act-start').forEach(function(b){b.addEventListener('click',function(){act(b.dataset.id,'start')})});
|
|
||||||
c.querySelectorAll('.act-pause').forEach(function(b){b.addEventListener('click',function(){act(b.dataset.id,'pause')})});
|
|
||||||
c.querySelectorAll('.act-restart').forEach(function(b){b.addEventListener('click',function(){act(b.dataset.id,'restart')})});
|
|
||||||
c.querySelectorAll('.act-delete').forEach(function(b){b.addEventListener('click',function(){act(b.dataset.id,'delete')})});
|
|
||||||
c.querySelectorAll('.seg-toggle').forEach(function(b){b.addEventListener('click',function(){
|
|
||||||
var l=c.querySelector('.seg-log[data-id="'+b.dataset.id+'"]');if(l)l.classList.toggle('open');
|
|
||||||
var j=d.jobs.find(function(x){return x.id===b.dataset.id});var segs=j?JSON.parse(j.segments||'[]'):[];
|
|
||||||
b.textContent=l&&l.classList.contains('open')?'Hide':(segs.length+' segments');
|
|
||||||
})});
|
|
||||||
// SSE
|
|
||||||
d.jobs.forEach(function(j){if(j.status==='processing'||j.status==='queued')sseConnect(j.id)});
|
|
||||||
}catch(ex){console.error(ex)}
|
|
||||||
}
|
|
||||||
async function act(id,action){
|
|
||||||
try{await api(action==='delete'?'DELETE':'POST','/api/jobs/'+id+(action==='delete'?'':'/'+action));loadJobs()}catch(ex){alert(ex.message)}
|
|
||||||
}
|
|
||||||
byId('refresh-jobs-btn').addEventListener('click',loadJobs);
|
|
||||||
|
|
||||||
// SSE
|
|
||||||
function sseConnect(jid){
|
|
||||||
if(SSE[jid])return;
|
|
||||||
var es=new EventSource('/api/jobs/'+jid+'/progress?token='+encodeURIComponent(AUTH));
|
|
||||||
es.onmessage=function(ev){
|
|
||||||
var d=JSON.parse(ev.data);updateCard(jid,d);
|
|
||||||
if(d.status==='completed'||d.status==='failed'||d.status==='cancelled'){es.close();delete SSE[jid]}
|
|
||||||
};
|
|
||||||
es.onerror=function(){es.close();delete SSE[jid]};
|
|
||||||
SSE[jid]=es;
|
|
||||||
}
|
|
||||||
function updateCard(jid,data){
|
|
||||||
var c=$('.job-card[data-id="'+jid+'"]');if(!c)return;
|
|
||||||
var b=c.querySelector('.status-badge');if(b){b.className='status-badge stat-'+data.status;b.textContent=data.status}
|
|
||||||
var f=c.querySelector('.progress-inner');if(f){f.style.width=data.progress+'%';f.className='progress-inner';if(data.status==='completed')f.classList.add('done');else if(data.status==='failed')f.classList.add('fail')}
|
|
||||||
var ms=c.querySelectorAll('.job-meta span');if(ms[0])ms[0].textContent=Math.round(data.progress)+'%';if(ms[1])ms[1].textContent='Idx: '+data.currentIndex+'/'+data.totalUnits;
|
|
||||||
var l=c.querySelector('.seg-log');if(l&&data.segments)l.innerHTML=data.segments.map(function(s){return '<div class="seg-entry"><span class="seg-time">['+s.startTime.toFixed(1)+'s]</span> '+esc(s.description)+'</div>'}).join('');
|
|
||||||
var t=c.querySelector('.seg-toggle');if(t&&data.segments)t.textContent=data.segments.length+' segments';
|
|
||||||
}
|
|
||||||
|
|
||||||
// settings
|
|
||||||
async function loadSettingsTab(){
|
|
||||||
try{var d=await apij('GET','/api/config');var c=byId('settings-fields');
|
|
||||||
var e=Object.entries(d.config||{});
|
|
||||||
if(!e.length){c.innerHTML='<div class="msg">No custom settings yet.</div>';return}
|
|
||||||
c.innerHTML=e.map(function(p){return '<label>'+esc(p[0])+' <input type="text" name="'+esc(p[0])+'" value="'+esc(String(p[1]))+'"></label>'}).join('');
|
|
||||||
}catch(ex){console.error(ex)}
|
|
||||||
}
|
|
||||||
byId('save-settings-btn').addEventListener('click',async function(){
|
|
||||||
var cfg={};$$('#settings-fields input').forEach(function(i){cfg[i.name]=i.value});
|
|
||||||
try{await apij('PUT','/api/config',cfg);alert('Saved')}catch(ex){alert('Error: '+ex.message)}
|
|
||||||
});
|
|
||||||
|
|
||||||
// files tab
|
|
||||||
async function loadFilesTab(){
|
|
||||||
try{var d=await apij('GET','/api/files');var tb=$('#files-table tbody');
|
|
||||||
tb.innerHTML=d.files.map(function(f){return '<tr><td>'+esc(f.filename)+'</td><td>'+fmtBytes(f.size)+'</td></tr>'}).join('');
|
|
||||||
}catch(ex){console.error(ex)}
|
|
||||||
}
|
|
||||||
byId('refresh-fileslist-btn').addEventListener('click',loadFilesTab);
|
|
||||||
|
|
||||||
// polling
|
|
||||||
function startPoll(){if(!POLL)POLL=setInterval(loadJobs,5000)}
|
|
||||||
|
|
||||||
// init
|
|
||||||
(async function(){
|
|
||||||
if(AUTH){try{var r=await fetch('/api/auth/check',{headers:{Authorization:'Basic '+AUTH}});var d=await r.json();if(d.authenticated){show('main-screen');showPage('dashboard');loadJobs();startPoll();return}}catch(ex){}}
|
|
||||||
show('login-screen');
|
|
||||||
})();
|
|
||||||
})();
|
|
||||||
</script>
|
</script>
|
||||||
|
<script defer src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
114
src/server/public/style.css
Normal file
114
src/server/public/style.css
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; background: #0d1117; color: #c9d1d9; }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
.error { color: #f85149; }
|
||||||
|
.success { color: #3fb950; }
|
||||||
|
.status { font-size: 0.85rem; margin: 4px 0; }
|
||||||
|
|
||||||
|
.screen { min-height: 100vh; }
|
||||||
|
#login-screen { display: flex; align-items: center; justify-content: center; }
|
||||||
|
.login-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 32px; width: 360px; text-align: center; }
|
||||||
|
.login-card h1 { margin: 0 0 8px; font-size: 1.4rem; }
|
||||||
|
.login-card p { margin: 0 0 20px; color: #8b949e; }
|
||||||
|
.login-card label { display: block; text-align: left; font-size: 0.85rem; margin-bottom: 12px; color: #8b949e; }
|
||||||
|
.login-card input { width: 100%; margin-top: 4px; padding: 8px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 1rem; }
|
||||||
|
.login-card button { width: 100%; padding: 10px; background: #238636; color: #fff; border: none; border-radius: 6px; font-size: 1rem; cursor: pointer; margin-top: 8px; }
|
||||||
|
.login-card button:hover { background: #2ea043; }
|
||||||
|
|
||||||
|
header { display: flex; align-items: center; justify-content: space-between; padding: 12px 24px; background: #161b22; border-bottom: 1px solid #30363d; }
|
||||||
|
header h1 { font-size: 1.1rem; margin: 0; }
|
||||||
|
nav { display: flex; gap: 4px; }
|
||||||
|
|
||||||
|
button.tab { background: transparent; color: #8b949e; border: none; padding: 8px 16px; cursor: pointer; border-radius: 6px; font-size: 0.9rem; }
|
||||||
|
button.tab:hover { background: #21262d; color: #c9d1d9; }
|
||||||
|
button.tab.active { background: #1f6feb; color: #fff; }
|
||||||
|
button.tab.danger:hover { background: #da3633; color: #fff; }
|
||||||
|
|
||||||
|
.tab-content { padding: 24px; display: none; }
|
||||||
|
.tab-content.active { display: block; }
|
||||||
|
|
||||||
|
.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||||
|
.toolbar h2 { margin: 0; font-size: 1.2rem; }
|
||||||
|
|
||||||
|
button { padding: 8px 16px; background: #21262d; color: #c9d1d9; border: 1px solid #30363d; border-radius: 6px; cursor: pointer; font-size: 0.9rem; }
|
||||||
|
button:hover { background: #30363d; }
|
||||||
|
button.btn-primary { background: #238636; border-color: #238636; color: #fff; }
|
||||||
|
button.btn-primary:hover { background: #2ea043; }
|
||||||
|
button.danger { background: transparent; color: #f85149; }
|
||||||
|
button.danger:hover { background: #da3633; color: #fff; border-color: #da3633; }
|
||||||
|
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.empty { color: #8b949e; font-style: italic; text-align: center; padding: 40px; }
|
||||||
|
|
||||||
|
fieldset { border: 1px solid #30363d; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
|
||||||
|
legend { font-weight: 600; padding: 0 8px; }
|
||||||
|
|
||||||
|
.tabs-mini { display: flex; gap: 4px; margin-bottom: 12px; }
|
||||||
|
button.tab-mini { background: transparent; color: #8b949e; border: 1px solid #30363d; padding: 6px 12px; cursor: pointer; border-radius: 4px; font-size: 0.85rem; }
|
||||||
|
button.tab-mini.active { background: #1f6feb; color: #fff; border-color: #1f6feb; }
|
||||||
|
.src-panel { display: none; }
|
||||||
|
.src-panel.active { display: block; }
|
||||||
|
|
||||||
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.form-grid label.full { grid-column: 1 / -1; }
|
||||||
|
.form-grid label { display: flex; flex-direction: column; font-size: 0.85rem; color: #8b949e; gap: 4px; }
|
||||||
|
.form-grid input, .form-grid select, .form-grid textarea { padding: 8px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 0.9rem; }
|
||||||
|
.form-grid textarea { resize: vertical; min-height: 60px; }
|
||||||
|
.form-grid input[type="checkbox"] { width: auto; }
|
||||||
|
|
||||||
|
details { margin-bottom: 12px; border: 1px solid #30363d; border-radius: 8px; padding: 12px 16px; }
|
||||||
|
details summary { cursor: pointer; font-weight: 600; padding: 4px 0; }
|
||||||
|
details .form-grid { margin-top: 12px; }
|
||||||
|
|
||||||
|
.hint { color: #8b949e; font-size: 0.85rem; margin-top: -12px; margin-bottom: 16px; }
|
||||||
|
|
||||||
|
select, input[type="file"], input[type="url"] { padding: 8px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 0.9rem; }
|
||||||
|
.file-label { display: inline-block; padding: 10px 20px; background: #238636; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 0.95rem; }
|
||||||
|
.file-label:hover { background: #2ea043; }
|
||||||
|
.file-name { margin: 8px 0 0; font-size: 0.85rem; color: #8b949e; }
|
||||||
|
|
||||||
|
/* Job cards */
|
||||||
|
.jobs-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.job-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
|
||||||
|
.job-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||||
|
.job-card-header h3 { margin: 0; font-size: 1rem; word-break: break-all; }
|
||||||
|
.job-actions { display: flex; gap: 4px; }
|
||||||
|
.job-actions button { font-size: 0.8rem; padding: 4px 10px; }
|
||||||
|
|
||||||
|
.status-badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; }
|
||||||
|
.status-pending { background: #21262d; color: #8b949e; }
|
||||||
|
.status-queued { background: #1a2332; color: #58a6ff; }
|
||||||
|
.status-processing { background: #1a2332; color: #58a6ff; }
|
||||||
|
.status-completed { background: #172f1e; color: #3fb950; }
|
||||||
|
.status-failed { background: #2d1518; color: #f85149; }
|
||||||
|
.status-paused { background: #2d2400; color: #d29922; }
|
||||||
|
.status-cancelled { background: #21262d; color: #8b949e; }
|
||||||
|
|
||||||
|
.progress-bar { height: 6px; background: #21262d; border-radius: 3px; margin: 8px 0; overflow: hidden; }
|
||||||
|
.progress-fill { height: 100%; background: #1f6feb; border-radius: 3px; transition: width 0.5s ease; }
|
||||||
|
.progress-fill.completed { background: #3fb950; }
|
||||||
|
.progress-fill.failed { background: #f85149; }
|
||||||
|
|
||||||
|
.job-meta { display: flex; gap: 16px; font-size: 0.8rem; color: #8b949e; margin-bottom: 8px; }
|
||||||
|
|
||||||
|
.job-detail { margin-top: 12px; padding-top: 12px; border-top: 1px solid #30363d; display: none; }
|
||||||
|
.job-detail.open { display: block; }
|
||||||
|
.segment-log { max-height: 200px; overflow-y: auto; font-size: 0.8rem; color: #8b949e; background: #0d1117; padding: 8px; border-radius: 4px; margin-bottom: 8px; }
|
||||||
|
.segment-entry { padding: 4px 0; border-bottom: 1px solid #1c2128; }
|
||||||
|
.segment-entry:last-child { border-bottom: none; }
|
||||||
|
.segment-time { color: #58a6ff; }
|
||||||
|
|
||||||
|
.download-links { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.download-links a { padding: 6px 12px; background: #21262d; color: #58a6ff; text-decoration: none; border-radius: 4px; font-size: 0.85rem; border: 1px solid #30363d; }
|
||||||
|
.download-links a:hover { background: #30363d; }
|
||||||
|
|
||||||
|
.error-msg { color: #f85149; font-size: 0.85rem; background: #2d1518; padding: 8px; border-radius: 4px; margin: 8px 0; }
|
||||||
|
|
||||||
|
/* Files table */
|
||||||
|
#files-table { width: 100%; border-collapse: collapse; }
|
||||||
|
#files-table th, #files-table td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #30363d; }
|
||||||
|
#files-table th { font-size: 0.85rem; color: #8b949e; }
|
||||||
|
#files-table tbody tr:hover { background: #161b22; }
|
||||||
|
|
||||||
|
/* Messages */
|
||||||
|
#login-error { margin-top: 12px; }
|
||||||
15
src/server/public/tsconfig.json
Normal file
15
src/server/public/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user