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