Fix the frontend
This commit is contained in:
@@ -59,9 +59,12 @@ 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)!;
|
||||
const el = (id: string): HTMLElement => {
|
||||
const e = document.getElementById(id);
|
||||
if (!e) throw new Error(`Missing element #${id}`);
|
||||
return e;
|
||||
};
|
||||
|
||||
// ── API ───────────────────────────────────────────────
|
||||
|
||||
@@ -96,51 +99,76 @@ async function apiJson<T>(method: string, url: string, body?: unknown): Promise<
|
||||
// ── Screen switching ──────────────────────────────────
|
||||
|
||||
function showLoginScreen(): void {
|
||||
el('login-screen').classList.remove('hidden');
|
||||
el('main-screen').classList.add('hidden');
|
||||
el('login-screen').hidden = false;
|
||||
el('main-screen').hidden = true;
|
||||
}
|
||||
|
||||
function showMainScreen(): void {
|
||||
el('login-screen').classList.add('hidden');
|
||||
el('main-screen').classList.remove('hidden');
|
||||
el('login-screen').hidden = true;
|
||||
el('main-screen').hidden = false;
|
||||
}
|
||||
|
||||
// ── Tab navigation ────────────────────────────────────
|
||||
// ── Tablist (WAI-ARIA) ────────────────────────────────
|
||||
|
||||
function switchTab(name: string): void {
|
||||
$$('button.tab').forEach(b => b.classList.remove('active'));
|
||||
document.querySelector<HTMLElement>(`button.tab[data-tab="${name}"]`)?.classList.add('active');
|
||||
function activateTab(tablistId: string, tabId: string): void {
|
||||
const tablist = el(tablistId);
|
||||
const tabs = Array.from(tablist.querySelectorAll<HTMLElement>('[role="tab"]'));
|
||||
tabs.forEach(t => {
|
||||
const selected = t.id === tabId;
|
||||
t.setAttribute('aria-selected', selected ? 'true' : 'false');
|
||||
t.setAttribute('tabindex', selected ? '0' : '-1');
|
||||
t.classList.toggle('active', selected);
|
||||
|
||||
$$('.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');
|
||||
const panelId = t.getAttribute('aria-controls');
|
||||
if (!panelId) return;
|
||||
const panel = document.getElementById(panelId);
|
||||
if (panel) panel.hidden = !selected;
|
||||
});
|
||||
});
|
||||
|
||||
const tab = tabs.find(t => t.id === tabId);
|
||||
const tabName = tab?.getAttribute('aria-controls') || '';
|
||||
onTabActivated(tablistId, tabName);
|
||||
}
|
||||
|
||||
function onTabActivated(tablistId: string, panelId: string): void {
|
||||
if (tablistId !== 'main-tablist') return;
|
||||
if (panelId === 'dashboard') loadJobs();
|
||||
if (panelId === 'files') loadFilesList();
|
||||
}
|
||||
|
||||
function wireTablist(tablistId: string): void {
|
||||
const tablist = el(tablistId);
|
||||
const tabs = Array.from(tablist.querySelectorAll<HTMLElement>('[role="tab"]'));
|
||||
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => activateTab(tablistId, tab.id));
|
||||
});
|
||||
|
||||
tablist.addEventListener('keydown', (e) => {
|
||||
const ke = e as KeyboardEvent;
|
||||
const current = document.activeElement as HTMLElement | null;
|
||||
if (!current || !tabs.includes(current)) return;
|
||||
let next: HTMLElement | undefined;
|
||||
const idx = tabs.indexOf(current);
|
||||
if (ke.key === 'ArrowRight') next = tabs[(idx + 1) % tabs.length];
|
||||
else if (ke.key === 'ArrowLeft') next = tabs[(idx - 1 + tabs.length) % tabs.length];
|
||||
else if (ke.key === 'Home') next = tabs[0];
|
||||
else if (ke.key === 'End') next = tabs[tabs.length - 1];
|
||||
if (next) {
|
||||
ke.preventDefault();
|
||||
activateTab(tablistId, next.id);
|
||||
next.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Login ─────────────────────────────────────────────
|
||||
|
||||
el('login-form').addEventListener('submit', async (e) => {
|
||||
(el('login-form') as HTMLFormElement).addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const username = (el('login-username') as HTMLInputElement).value;
|
||||
const password = (el('login-password') as HTMLInputElement).value;
|
||||
const errorEl = el('login-error');
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
@@ -151,15 +179,16 @@ el('login-form').addEventListener('submit', async (e) => {
|
||||
if (data.authenticated) {
|
||||
authToken = data.token;
|
||||
if (authToken) sessionStorage.setItem('authToken', authToken);
|
||||
errorEl.hidden = true;
|
||||
showMainScreen();
|
||||
initApp();
|
||||
} else {
|
||||
el('login-error').textContent = data.error;
|
||||
el('login-error').classList.remove('hidden');
|
||||
errorEl.textContent = data.error || 'Login failed';
|
||||
errorEl.hidden = false;
|
||||
}
|
||||
} catch {
|
||||
el('login-error').textContent = 'Connection failed';
|
||||
el('login-error').classList.remove('hidden');
|
||||
errorEl.textContent = 'Connection failed';
|
||||
errorEl.hidden = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -223,41 +252,91 @@ const uploadName = el('upload-name');
|
||||
|
||||
videoUpload.addEventListener('change', function () {
|
||||
if (this.files?.length) {
|
||||
selectedFilePath = null; // will upload on submit
|
||||
selectedFilePath = null;
|
||||
uploadName.textContent = `Selected: ${this.files[0].name} (${formatSize(this.files[0].size)})`;
|
||||
} else {
|
||||
uploadName.textContent = '';
|
||||
}
|
||||
});
|
||||
|
||||
// ── YouTube download ──────────────────────────────────
|
||||
// ── YouTube download (SSE) ────────────────────────────
|
||||
|
||||
el('download-url').addEventListener('click', async () => {
|
||||
const url = (el('youtube-url') as HTMLInputElement).value;
|
||||
let youtubeStream: EventSource | null = null;
|
||||
|
||||
el('download-url').addEventListener('click', () => {
|
||||
const url = (el('youtube-url') as HTMLInputElement).value.trim();
|
||||
if (!url) return;
|
||||
if (!authToken) return;
|
||||
|
||||
const status = el('download-status');
|
||||
status.textContent = 'Downloading...';
|
||||
const progressWrap = document.querySelector<HTMLElement>('.download-progress');
|
||||
const progressbar = el('download-progressbar');
|
||||
const fill = el('download-fill');
|
||||
|
||||
status.textContent = 'Starting download...';
|
||||
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}`;
|
||||
if (progressWrap) progressWrap.hidden = false;
|
||||
progressbar.setAttribute('aria-valuenow', '0');
|
||||
fill.style.width = '0%';
|
||||
|
||||
if (youtubeStream) youtubeStream.close();
|
||||
|
||||
const streamUrl = `/api/files/youtube/stream?url=${encodeURIComponent(url)}&token=${encodeURIComponent(authToken)}`;
|
||||
const es = new EventSource(streamUrl);
|
||||
youtubeStream = es;
|
||||
|
||||
es.onmessage = (event) => {
|
||||
let data: { type: string; percent?: number; filePath?: string; filename?: string; title?: string; message?: string };
|
||||
try { data = JSON.parse(event.data); } catch { return; }
|
||||
|
||||
if (data.type === 'progress' && typeof data.percent === 'number') {
|
||||
const pct = Math.max(0, Math.min(100, data.percent));
|
||||
progressbar.setAttribute('aria-valuenow', String(Math.round(pct)));
|
||||
fill.style.width = `${pct}%`;
|
||||
status.textContent = `Downloading ${pct.toFixed(1)}%`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'done' && data.filePath && data.filename) {
|
||||
progressbar.setAttribute('aria-valuenow', '100');
|
||||
fill.style.width = '100%';
|
||||
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);
|
||||
|
||||
es.close();
|
||||
youtubeStream = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'error') {
|
||||
status.textContent = `Error: ${data.message || 'Download failed'}`;
|
||||
status.className = 'status error';
|
||||
if (progressWrap) progressWrap.hidden = true;
|
||||
es.close();
|
||||
youtubeStream = null;
|
||||
}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
if (es.readyState === EventSource.CLOSED) return;
|
||||
status.textContent = 'Connection lost';
|
||||
status.className = 'status error';
|
||||
}
|
||||
es.close();
|
||||
youtubeStream = null;
|
||||
};
|
||||
});
|
||||
|
||||
// ── New Job form ──────────────────────────────────────
|
||||
|
||||
el('new-job-form').addEventListener('submit', async (e) => {
|
||||
(el('new-job-form') as HTMLFormElement).addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (!selectedFilePath) {
|
||||
if (videoUpload.files?.length) {
|
||||
@@ -289,6 +368,12 @@ el('new-job-form').addEventListener('submit', async (e) => {
|
||||
else if (!isNaN(val as any) && val !== '') config[key] = parseFloat(val as string);
|
||||
else config[key] = val;
|
||||
}
|
||||
// Empty strings would clobber server-side defaults during the spread-merge in
|
||||
// JobManager.createJob — drop them. (The server also filters defensively.)
|
||||
for (const k of Object.keys(config)) {
|
||||
const v = config[k];
|
||||
if (v === '' || v === undefined || v === null) delete config[k];
|
||||
}
|
||||
|
||||
const outputOptions = {
|
||||
audio: fd.get('output-audio') === 'on',
|
||||
@@ -331,7 +416,7 @@ el('new-job-form').addEventListener('submit', async (e) => {
|
||||
videoUpload.value = '';
|
||||
uploadName.textContent = '';
|
||||
(el('new-job-form') as HTMLFormElement).reset();
|
||||
switchTab('dashboard');
|
||||
activateTab('main-tablist', 'tab-dashboard');
|
||||
} catch (err: any) {
|
||||
alert('Error creating job: ' + err.message);
|
||||
}
|
||||
@@ -340,6 +425,8 @@ el('new-job-form').addEventListener('submit', async (e) => {
|
||||
// ── Job list & rendering ──────────────────────────────
|
||||
|
||||
async function loadJobs(): Promise<void> {
|
||||
const container = el('jobs-list');
|
||||
container.setAttribute('aria-busy', 'true');
|
||||
try {
|
||||
const data = await apiJson<{ jobs: Job[] }>('GET', '/api/jobs');
|
||||
renderJobs(data.jobs);
|
||||
@@ -350,13 +437,15 @@ async function loadJobs(): Promise<void> {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
container.setAttribute('aria-busy', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
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>';
|
||||
container.innerHTML = '<p class="empty">No jobs yet. Create one from the “New Job” tab.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -366,49 +455,56 @@ function renderJobs(jobs: Job[]): void {
|
||||
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>`);
|
||||
// Plain <a download> navigations don't send our Authorization header.
|
||||
// Pass the token via query string — middleware/auth.ts accepts ?token=.
|
||||
const tok = authToken ? `token=${encodeURIComponent(authToken)}` : '';
|
||||
const sep = (qs: string) => qs.includes('?') ? '&' : '?';
|
||||
const url = (path: string) => tok ? `${path}${sep(path)}${tok}` : path;
|
||||
if (j.output_audio) downloads.push(`<a href="${url(`/api/jobs/${j.id}/download/audio`)}" download>Audio</a>`);
|
||||
if (j.output_subtitles_srt) downloads.push(`<a href="${url(`/api/jobs/${j.id}/download/subtitles?format=srt`)}" download>SRT</a>`);
|
||||
if (j.output_subtitles_vtt) downloads.push(`<a href="${url(`/api/jobs/${j.id}/download/subtitles?format=vtt`)}" download>VTT</a>`);
|
||||
if (j.output_muxed) downloads.push(`<a href="${url(`/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>`;
|
||||
actions += `<button type="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>`;
|
||||
actions += `<button type="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>`;
|
||||
actions += `<button type="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>`;
|
||||
actions += `<button type="button" class="act-delete danger" data-id="${j.id}">Delete</button>`;
|
||||
}
|
||||
|
||||
const pct = Math.round(j.progress);
|
||||
return `
|
||||
<div class="job-card" data-id="${j.id}">
|
||||
<article class="job-card" data-id="${j.id}" aria-labelledby="job-${j.id}-title">
|
||||
<div class="job-card-header">
|
||||
<h3>${escapeHtml(j.video_filename)}</h3>
|
||||
<h3 id="job-${j.id}-title">${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 role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct}" aria-label="Job progress" class="progress-bar">
|
||||
<div class="progress-fill ${progressClass}" style="width:${pct}%"></div>
|
||||
</div>
|
||||
<div class="job-meta">
|
||||
<span>${Math.round(j.progress)}%</span>
|
||||
<span>${pct}%</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>` : ''}
|
||||
${j.error ? `<div class="error-msg" role="alert">${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>
|
||||
<button type="button" class="toggle-detail" data-id="${j.id}" aria-expanded="false" aria-controls="job-${j.id}-detail">${segs.length} segments</button>
|
||||
<div class="job-detail" id="job-${j.id}-detail" data-id="${j.id}" hidden>
|
||||
<div class="segment-log">${segs.map(s => `<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>`;
|
||||
</article>`;
|
||||
}).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 =>
|
||||
@@ -422,10 +518,12 @@ function renderJobs(jobs: Job[]): void {
|
||||
const jobId = b.dataset.id || '';
|
||||
const detail = container.querySelector<HTMLElement>(`.job-detail[data-id="${jobId}"]`);
|
||||
if (!detail) return;
|
||||
detail.classList.toggle('open');
|
||||
const willOpen = detail.hidden;
|
||||
detail.hidden = !willOpen;
|
||||
b.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
|
||||
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`;
|
||||
b.textContent = willOpen ? 'Hide segments' : `${segs.length} segments`;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -454,7 +552,8 @@ function startPolling(): void {
|
||||
|
||||
function connectSSE(jobId: string): void {
|
||||
if (sseMap.has(jobId)) return;
|
||||
const es = new EventSource(`/api/jobs/${jobId}/progress?token=${encodeURIComponent(authToken!)}`);
|
||||
if (!authToken) 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);
|
||||
@@ -480,16 +579,20 @@ function updateJobCard(jobId: string, data: ProgressData): void {
|
||||
badge.textContent = data.status;
|
||||
}
|
||||
|
||||
const pct = Math.round(data.progress);
|
||||
const bar = card.querySelector<HTMLElement>('[role="progressbar"]');
|
||||
if (bar) bar.setAttribute('aria-valuenow', String(pct));
|
||||
|
||||
const fill = card.querySelector<HTMLElement>('.progress-fill');
|
||||
if (fill) {
|
||||
fill.style.width = data.progress + '%';
|
||||
fill.style.width = pct + '%';
|
||||
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[0]) metaSpans[0].textContent = pct + '%';
|
||||
if (metaSpans[1]) metaSpans[1].textContent = `Idx: ${data.currentIndex}/${data.totalUnits}`;
|
||||
|
||||
const log = card.querySelector<HTMLElement>('.segment-log');
|
||||
@@ -501,7 +604,8 @@ function updateJobCard(jobId: string, data: ProgressData): void {
|
||||
|
||||
const toggleBtn = card.querySelector<HTMLElement>('.toggle-detail');
|
||||
if (toggleBtn && data.segments) {
|
||||
toggleBtn.textContent = `${data.segments.length} segments`;
|
||||
const expanded = toggleBtn.getAttribute('aria-expanded') === 'true';
|
||||
if (!expanded) toggleBtn.textContent = `${data.segments.length} segments`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,15 +620,16 @@ async function loadSettings(): Promise<void> {
|
||||
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('');
|
||||
container.innerHTML = entries.map(([key, value]) => {
|
||||
const safeKey = escapeHtml(key);
|
||||
return `<div class="field"><label for="setting-${safeKey}">${safeKey}</label><input type="text" id="setting-${safeKey}" name="${safeKey}" value="${escapeHtml(String(value))}"></div>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
el('settings-form').addEventListener('submit', async (e) => {
|
||||
(el('settings-form') as HTMLFormElement).addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target as HTMLFormElement);
|
||||
const config: Record<string, string> = {};
|
||||
@@ -549,7 +654,7 @@ async function loadFilesList(): Promise<void> {
|
||||
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><input type="checkbox" class="file-checkbox" data-filename="${escapeHtml(f.filename)}" aria-label="Select ${escapeHtml(f.filename)}"></td>
|
||||
<td>${escapeHtml(f.filename)}</td>
|
||||
<td>${formatSize(f.size)}</td>
|
||||
</tr>
|
||||
@@ -558,6 +663,9 @@ async function loadFilesList(): Promise<void> {
|
||||
tbody.querySelectorAll<HTMLInputElement>('.file-checkbox').forEach(cb => {
|
||||
cb.addEventListener('change', updateFileSelection);
|
||||
});
|
||||
(el('select-all-files') as HTMLInputElement).checked = false;
|
||||
selectedFiles.clear();
|
||||
updateFileSelection();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
@@ -566,7 +674,7 @@ async function loadFilesList(): Promise<void> {
|
||||
function updateFileSelection(): void {
|
||||
selectedFiles.clear();
|
||||
document.querySelectorAll<HTMLInputElement>('.file-checkbox:checked').forEach(cb => {
|
||||
if (cb.dataset.path) selectedFiles.add(cb.dataset.path);
|
||||
if (cb.dataset.filename) selectedFiles.add(cb.dataset.filename);
|
||||
});
|
||||
(el('delete-selected-files') as HTMLButtonElement).disabled = selectedFiles.size === 0;
|
||||
}
|
||||
@@ -578,9 +686,23 @@ function updateFileSelection(): void {
|
||||
updateFileSelection();
|
||||
});
|
||||
|
||||
el('delete-selected-files').addEventListener('click', () => {
|
||||
el('delete-selected-files').addEventListener('click', async () => {
|
||||
if (!selectedFiles.size) return;
|
||||
if (!confirm(`Delete ${selectedFiles.size} file(s)?`)) return;
|
||||
alert('File deletion not yet implemented');
|
||||
|
||||
const failures: string[] = [];
|
||||
for (const filename of selectedFiles) {
|
||||
try {
|
||||
await api('DELETE', `/api/files/${encodeURIComponent(filename)}`);
|
||||
} catch (err: any) {
|
||||
failures.push(`${filename}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
if (failures.length) {
|
||||
alert(`Some deletions failed:\n${failures.join('\n')}`);
|
||||
}
|
||||
await loadFilesList();
|
||||
await loadBrowseFiles();
|
||||
});
|
||||
|
||||
el('refresh-files-list').addEventListener('click', loadFilesList);
|
||||
@@ -606,15 +728,14 @@ async function loadConfigDefaults(): Promise<void> {
|
||||
sel.value = c.ttsProvider;
|
||||
}
|
||||
}
|
||||
const fields: [string, string?][] = [
|
||||
['visionModel'], ['ttsModel'], ['ttsVoice'], ['ttsSpeedFactor'],
|
||||
['ttsInstructions', 'textarea'], ['batchWindowDuration'], ['framesInBatch'],
|
||||
['captureIntervalSeconds'], ['contextWindowSize'],
|
||||
['defaultPrompt', 'textarea'], ['changePrompt', 'textarea'], ['batchPrompt', 'textarea'],
|
||||
const fields: string[] = [
|
||||
'visionModel', 'ttsModel', 'ttsVoice', 'ttsSpeedFactor', 'ttsInstructions',
|
||||
'batchWindowDuration', 'framesInBatch', 'captureIntervalSeconds', 'contextWindowSize',
|
||||
'defaultPrompt', 'changePrompt', 'batchPrompt',
|
||||
];
|
||||
for (const [name, tag] of fields) {
|
||||
const el = document.querySelector<HTMLInputElement | HTMLTextAreaElement>(`[name="${name}"]`);
|
||||
if (el && c[name] !== undefined) el.value = c[name];
|
||||
for (const name of fields) {
|
||||
const field = document.querySelector<HTMLInputElement | HTMLTextAreaElement>(`[name="${name}"]`);
|
||||
if (field && c[name] !== undefined) field.value = c[name];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -624,9 +745,12 @@ async function loadConfigDefaults(): Promise<void> {
|
||||
// ── Init ──────────────────────────────────────────────
|
||||
|
||||
function initApp(): void {
|
||||
wireTablist('main-tablist');
|
||||
wireTablist('source-tablist');
|
||||
loadJobs();
|
||||
loadBrowseFiles();
|
||||
loadConfigDefaults();
|
||||
loadSettings();
|
||||
startPolling();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user