Updates galore. Improved folder structure, componentized, and notifications upon completion.
This commit is contained in:
parent
b48784e2ad
commit
7e0502ca40
33 changed files with 3565 additions and 728 deletions
111
static/js/modules/browser.js
Normal file
111
static/js/modules/browser.js
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* browser.js
|
||||
* ----------
|
||||
* Server-side directory browser modal.
|
||||
*
|
||||
* Fetches directory listings from /api/browse and renders them inside the
|
||||
* modal panel. The user navigates the server filesystem and selects a
|
||||
* directory to populate the scan path input.
|
||||
*
|
||||
* Exports
|
||||
* -------
|
||||
* initBrowser() — attach all event listeners; call once at startup
|
||||
*/
|
||||
|
||||
import { state, els, announce } from './state.js';
|
||||
import { esc } from './utils.js';
|
||||
|
||||
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
async function loadBrowserPath(path) {
|
||||
els.browserList.innerHTML =
|
||||
'<p class="browser-loading" aria-live="polite">Loading…</p>';
|
||||
els.browserPath.textContent = path;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/browse?path=${encodeURIComponent(path)}`);
|
||||
if (!resp.ok) throw new Error((await resp.json()).error || 'Error loading directory');
|
||||
const data = await resp.json();
|
||||
|
||||
state.browserPath = data.current;
|
||||
els.browserPath.textContent = data.current;
|
||||
|
||||
let html = '';
|
||||
|
||||
if (data.parent !== null) {
|
||||
html += `
|
||||
<button class="browser-item parent-dir" data-path="${esc(data.parent)}">
|
||||
<span class="item-icon" aria-hidden="true">↑</span>
|
||||
<span>.. (parent directory)</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
for (const entry of data.entries) {
|
||||
if (!entry.is_dir) continue;
|
||||
html += `
|
||||
<button class="browser-item" data-path="${esc(entry.path)}"
|
||||
role="option" aria-label="Directory: ${esc(entry.name)}">
|
||||
<span class="item-icon" aria-hidden="true">📁</span>
|
||||
<span>${esc(entry.name)}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
if (!html) html = '<p class="browser-loading">No subdirectories found.</p>';
|
||||
els.browserList.innerHTML = html;
|
||||
|
||||
els.browserList.querySelectorAll('.browser-item').forEach(btn => {
|
||||
btn.addEventListener('click', () => loadBrowserPath(btn.dataset.path));
|
||||
btn.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
loadBrowserPath(btn.dataset.path);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
els.browserList.innerHTML =
|
||||
`<p class="browser-error" role="alert">Error: ${esc(err.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function openBrowser() {
|
||||
els.browserModal.hidden = false;
|
||||
document.body.style.overflow = 'hidden';
|
||||
loadBrowserPath(els.dirInput.value || '/');
|
||||
els.closeBrowser.focus();
|
||||
announce('Directory browser opened');
|
||||
}
|
||||
|
||||
function closeBrowser() {
|
||||
els.browserModal.hidden = true;
|
||||
document.body.style.overflow = '';
|
||||
els.browseBtn.focus();
|
||||
announce('Directory browser closed');
|
||||
}
|
||||
|
||||
// ─── Public init ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Attach all event listeners for the directory browser modal.
|
||||
* Call once during app initialisation.
|
||||
*/
|
||||
export function initBrowser() {
|
||||
els.browseBtn.addEventListener('click', openBrowser);
|
||||
els.closeBrowser.addEventListener('click', closeBrowser);
|
||||
els.browserCancel.addEventListener('click', closeBrowser);
|
||||
|
||||
els.browserModal.addEventListener('click', e => {
|
||||
if (e.target === els.browserModal) closeBrowser();
|
||||
});
|
||||
|
||||
els.browserSelect.addEventListener('click', () => {
|
||||
els.dirInput.value = state.browserPath;
|
||||
closeBrowser();
|
||||
announce(`Directory selected: ${state.browserPath}`);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' && !els.browserModal.hidden) closeBrowser();
|
||||
});
|
||||
}
|
||||
195
static/js/modules/compress.js
Normal file
195
static/js/modules/compress.js
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
/**
|
||||
* compress.js
|
||||
* -----------
|
||||
* Compression job lifecycle: start, notification opt-in, cancel, and restart.
|
||||
*
|
||||
* Exports
|
||||
* -------
|
||||
* initCompress() — attach all event listeners; call once at startup
|
||||
*/
|
||||
|
||||
import { state, els, announce } from './state.js';
|
||||
import { setupProgressSection, showResults } from './progress.js';
|
||||
import { startProgressStream } from './stream.js';
|
||||
import { smtpIsConfigured } from './settings.js';
|
||||
|
||||
// ─── Public init ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Attach event listeners for:
|
||||
* - Notification checkbox toggle
|
||||
* - "Compress Selected Files" button
|
||||
* - "Cancel Compression" button
|
||||
* - "Start New Session" (restart) button
|
||||
*
|
||||
* Call once during app initialisation.
|
||||
*/
|
||||
export function initCompress() {
|
||||
_initNotifyToggle();
|
||||
_initCompressButton();
|
||||
_initCancelButton();
|
||||
_initRestartButton();
|
||||
}
|
||||
|
||||
// ─── Notification opt-in ─────────────────────────────────────────────────────
|
||||
|
||||
function _initNotifyToggle() {
|
||||
els.notifyChk.addEventListener('change', () => {
|
||||
const show = els.notifyChk.checked;
|
||||
els.notifyEmailRow.hidden = !show;
|
||||
els.notifyEmail.setAttribute('aria-required', show ? 'true' : 'false');
|
||||
const warn = document.getElementById('smtp-not-configured-warn');
|
||||
if (show) {
|
||||
els.notifyEmail.focus();
|
||||
if (warn) warn.hidden = smtpIsConfigured();
|
||||
} else {
|
||||
els.notifyEmail.value = '';
|
||||
if (warn) warn.hidden = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Start compression ────────────────────────────────────────────────────────
|
||||
|
||||
function _initCompressButton() {
|
||||
els.compressBtn.addEventListener('click', async () => {
|
||||
const selectedFiles = state.scannedFiles.filter(
|
||||
f => state.selectedPaths.has(f.path),
|
||||
);
|
||||
if (!selectedFiles.length) return;
|
||||
|
||||
const suffix = els.suffixInput.value.trim() || '_new';
|
||||
const notifyEmail = els.notifyChk.checked ? els.notifyEmail.value.trim() : '';
|
||||
|
||||
// Client-side email validation
|
||||
if (els.notifyChk.checked) {
|
||||
if (!notifyEmail) {
|
||||
els.notifyEmail.setCustomValidity('Please enter your email address.');
|
||||
els.notifyEmail.reportValidity();
|
||||
return;
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(notifyEmail)) {
|
||||
els.notifyEmail.setCustomValidity('Please enter a valid email address.');
|
||||
els.notifyEmail.reportValidity();
|
||||
return;
|
||||
}
|
||||
els.notifyEmail.setCustomValidity('');
|
||||
}
|
||||
|
||||
els.compressBtn.disabled = true;
|
||||
els.compressBtn.textContent = 'Starting…';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/compress/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
files: selectedFiles.map(f => ({
|
||||
path: f.path,
|
||||
size_bytes: f.size_bytes,
|
||||
target_bit_rate_bps: f.target_bit_rate_bps || 1_000_000,
|
||||
codec: f.codec || 'unknown',
|
||||
})),
|
||||
suffix,
|
||||
notify_email: notifyEmail,
|
||||
}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
if (!resp.ok) {
|
||||
alert(`Failed to start compression: ${data.error}`);
|
||||
_resetCompressBtn();
|
||||
return;
|
||||
}
|
||||
|
||||
state.currentJobId = data.job_id;
|
||||
state.seenEventCount = 0;
|
||||
state.compressionResults = [];
|
||||
sessionStorage.setItem('vp-job-id', data.job_id);
|
||||
|
||||
setupProgressSection(selectedFiles);
|
||||
els.sectionProgress.hidden = false;
|
||||
els.sectionProgress.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
announce(`Compression started for ${selectedFiles.length} file(s).`);
|
||||
startProgressStream(data.job_id, selectedFiles);
|
||||
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
_resetCompressBtn();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _resetCompressBtn() {
|
||||
els.compressBtn.disabled = false;
|
||||
els.compressBtn.innerHTML =
|
||||
'<span class="btn-icon-prefix" aria-hidden="true">⚡</span> Compress Selected Files';
|
||||
}
|
||||
|
||||
// ─── Cancel ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function _initCancelButton() {
|
||||
els.cancelBtn.addEventListener('click', async () => {
|
||||
if (!state.currentJobId) return;
|
||||
if (!confirm(
|
||||
'Cancel all compression operations? ' +
|
||||
'Any file currently being processed will be deleted.',
|
||||
)) return;
|
||||
|
||||
els.cancelBtn.disabled = true;
|
||||
els.cancelBtn.textContent = 'Cancelling…';
|
||||
|
||||
try {
|
||||
await fetch(`/api/compress/cancel/${state.currentJobId}`, { method: 'POST' });
|
||||
announce('Cancellation requested.');
|
||||
} catch (err) {
|
||||
console.error('Cancel error:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Restart (new session) ────────────────────────────────────────────────────
|
||||
|
||||
function _initRestartButton() {
|
||||
els.restartBtn.addEventListener('click', () => {
|
||||
// Clear state
|
||||
state.scannedFiles = [];
|
||||
state.selectedPaths.clear();
|
||||
state.currentJobId = null;
|
||||
state.compressionResults = [];
|
||||
state.seenEventCount = 0;
|
||||
|
||||
if (state.eventSource) {
|
||||
state.eventSource.close();
|
||||
state.eventSource = null;
|
||||
}
|
||||
if (state.reconnectTimer) {
|
||||
clearTimeout(state.reconnectTimer);
|
||||
state.reconnectTimer = null;
|
||||
}
|
||||
sessionStorage.removeItem('vp-job-id');
|
||||
|
||||
// Reset UI
|
||||
els.sectionFiles.hidden = true;
|
||||
els.sectionProgress.hidden = true;
|
||||
els.sectionResults.hidden = true;
|
||||
els.fileTbody.innerHTML = '';
|
||||
els.fileProgressList.innerHTML = '';
|
||||
els.scanStatus.textContent = '';
|
||||
els.notifyChk.checked = false;
|
||||
els.notifyEmailRow.hidden = true;
|
||||
els.notifyEmail.value = '';
|
||||
els.notifyStatus.hidden = true;
|
||||
els.notifyStatus.textContent = '';
|
||||
els.streamLostBanner.hidden = true;
|
||||
els.reconnectBtn.hidden = true;
|
||||
els.cancelBtn.disabled = false;
|
||||
els.cancelBtn.textContent = '✕ Cancel Compression';
|
||||
_resetCompressBtn();
|
||||
|
||||
document.getElementById('section-config')
|
||||
.scrollIntoView({ behavior: 'smooth' });
|
||||
els.dirInput.focus();
|
||||
announce('Session reset. Ready to scan again.');
|
||||
});
|
||||
}
|
||||
172
static/js/modules/progress.js
Normal file
172
static/js/modules/progress.js
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* progress.js
|
||||
* -----------
|
||||
* Progress section DOM management: per-file bars, overall bar, and
|
||||
* the final results summary.
|
||||
*
|
||||
* These functions are called by both stream.js (live SSE updates) and
|
||||
* session.js (snapshot restore on reconnect / page reload).
|
||||
*
|
||||
* Exports
|
||||
* -------
|
||||
* setupProgressSection(files)
|
||||
* setOverallProgress(pct)
|
||||
* updateFileProgress(idx, pct, statusClass, statusText, detail, detailClass)
|
||||
* showStreamLost()
|
||||
* hideStreamLost()
|
||||
* showResults(finalStatus)
|
||||
*/
|
||||
|
||||
import { state, els, announce } from './state.js';
|
||||
import { esc } from './utils.js';
|
||||
|
||||
// ─── Progress section setup ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render the initial per-file progress items and reset counters.
|
||||
* Called when a new compression job starts or when the DOM needs to be
|
||||
* rebuilt after a full page reload.
|
||||
* @param {Array} files — file objects from the job (name, path, …)
|
||||
*/
|
||||
export function setupProgressSection(files) {
|
||||
els.progTotal.textContent = files.length;
|
||||
els.progDone.textContent = '0';
|
||||
els.progStatus.textContent = 'Running';
|
||||
setOverallProgress(0);
|
||||
|
||||
let html = '';
|
||||
files.forEach((f, idx) => {
|
||||
html += `
|
||||
<div class="file-progress-item" id="fpi-${idx}" role="listitem"
|
||||
aria-label="File ${idx + 1} of ${files.length}: ${esc(f.name)}">
|
||||
<div class="fp-header">
|
||||
<span class="fp-name">${esc(f.name)}</span>
|
||||
<span class="fp-status waiting" id="fps-${idx}"
|
||||
aria-live="polite">Waiting</span>
|
||||
</div>
|
||||
<div class="fp-bar-wrap" aria-label="Progress for ${esc(f.name)}">
|
||||
<div class="fp-bar" role="progressbar"
|
||||
aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"
|
||||
id="fpbar-${idx}">
|
||||
<div class="fp-bar-fill" id="fpfill-${idx}" style="width:0%"></div>
|
||||
</div>
|
||||
<span class="fp-pct" id="fppct-${idx}" aria-hidden="true">0%</span>
|
||||
</div>
|
||||
<div class="fp-detail" id="fpdetail-${idx}"></div>
|
||||
</div>`;
|
||||
});
|
||||
els.fileProgressList.innerHTML = html;
|
||||
}
|
||||
|
||||
// ─── Bar helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Update the overall progress bar.
|
||||
* @param {number} pct 0–100
|
||||
*/
|
||||
export function setOverallProgress(pct) {
|
||||
const p = Math.min(100, Math.round(pct));
|
||||
els.overallBarFill.style.width = `${p}%`;
|
||||
els.overallBar.setAttribute('aria-valuenow', p);
|
||||
els.overallPct.textContent = `${p}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single file's progress bar, status badge, and detail text.
|
||||
*
|
||||
* @param {number} idx — file index (0-based)
|
||||
* @param {number} pct — 0–100
|
||||
* @param {string} statusClass — 'waiting' | 'running' | 'done' | 'error'
|
||||
* @param {string} statusText — visible badge text
|
||||
* @param {string} [detail] — optional sub-text (elapsed time, size saved…)
|
||||
* @param {string} [detailClass]— optional class applied to the detail element
|
||||
*/
|
||||
export function updateFileProgress(idx, pct, statusClass, statusText, detail, detailClass) {
|
||||
const fill = document.getElementById(`fpfill-${idx}`);
|
||||
const bar = document.getElementById(`fpbar-${idx}`);
|
||||
const pctEl = document.getElementById(`fppct-${idx}`);
|
||||
const status = document.getElementById(`fps-${idx}`);
|
||||
const item = document.getElementById(`fpi-${idx}`);
|
||||
const det = document.getElementById(`fpdetail-${idx}`);
|
||||
if (!fill) return;
|
||||
|
||||
const p = Math.min(100, Math.round(pct));
|
||||
fill.style.width = `${p}%`;
|
||||
bar.setAttribute('aria-valuenow', p);
|
||||
pctEl.textContent = `${p}%`;
|
||||
status.className = `fp-status ${statusClass}`;
|
||||
status.textContent = statusText;
|
||||
item.className = `file-progress-item ${statusClass}`;
|
||||
fill.classList.toggle('active', statusClass === 'running');
|
||||
|
||||
if (detail !== undefined) {
|
||||
det.textContent = detail;
|
||||
det.className = `fp-detail ${detailClass || ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Stream-lost banner ───────────────────────────────────────────────────────
|
||||
|
||||
/** Show the disconnection warning banner and reveal the Reconnect button. */
|
||||
export function showStreamLost() {
|
||||
els.streamLostBanner.hidden = false;
|
||||
els.reconnectBtn.hidden = false;
|
||||
els.progStatus.textContent = 'Disconnected';
|
||||
announce('Live progress stream disconnected. Use Reconnect to resume.');
|
||||
}
|
||||
|
||||
/** Hide the disconnection warning banner and Reconnect button. */
|
||||
export function hideStreamLost() {
|
||||
els.streamLostBanner.hidden = true;
|
||||
els.reconnectBtn.hidden = true;
|
||||
}
|
||||
|
||||
// ─── Results summary ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render the Step 4 results card and scroll it into view.
|
||||
* @param {'done'|'cancelled'} finalStatus
|
||||
*/
|
||||
export function showResults(finalStatus) {
|
||||
const results = state.compressionResults;
|
||||
let html = '';
|
||||
|
||||
if (finalStatus === 'cancelled') {
|
||||
html += `<p style="color:var(--text-muted);margin-bottom:var(--space-md)">
|
||||
Compression was cancelled. Completed files are listed below.</p>`;
|
||||
}
|
||||
|
||||
if (!results.length && finalStatus === 'cancelled') {
|
||||
html += '<p style="color:var(--text-muted)">No files were completed before cancellation.</p>';
|
||||
}
|
||||
|
||||
results.forEach(r => {
|
||||
if (r.status === 'done') {
|
||||
html += `
|
||||
<div class="result-row">
|
||||
<span class="result-icon">✅</span>
|
||||
<div class="result-info">
|
||||
<div class="result-name">${esc(r.filename)}</div>
|
||||
<div class="result-meta">→ ${esc(r.output || '')}</div>
|
||||
</div>
|
||||
<span class="result-reduction">-${r.reduction_pct}%</span>
|
||||
</div>`;
|
||||
} else if (r.status === 'error') {
|
||||
html += `
|
||||
<div class="result-row">
|
||||
<span class="result-icon">❌</span>
|
||||
<div class="result-info">
|
||||
<div class="result-name">${esc(r.filename)}</div>
|
||||
<div class="result-meta" style="color:var(--text-danger)">
|
||||
${esc(r.message)}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
});
|
||||
|
||||
if (!html) html = '<p style="color:var(--text-muted)">No results to display.</p>';
|
||||
els.resultsContent.innerHTML = html;
|
||||
els.sectionResults.hidden = false;
|
||||
els.sectionResults.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
194
static/js/modules/scan.js
Normal file
194
static/js/modules/scan.js
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
/**
|
||||
* scan.js
|
||||
* -------
|
||||
* Directory scan and file selection table.
|
||||
*
|
||||
* Handles the "Scan for Files" button, the /api/scan fetch, rendering the
|
||||
* results table, and the select-all / deselect-all controls.
|
||||
*
|
||||
* Exports
|
||||
* -------
|
||||
* initScan() — attach all event listeners; call once at startup
|
||||
*/
|
||||
|
||||
import { state, els, announce } from './state.js';
|
||||
import { esc } from './utils.js';
|
||||
|
||||
// ─── Status helper ────────────────────────────────────────────────────────────
|
||||
|
||||
function setScanStatus(msg, type) {
|
||||
els.scanStatus.textContent = msg;
|
||||
els.scanStatus.style.color =
|
||||
type === 'error' ? 'var(--text-danger)'
|
||||
: type === 'success' ? 'var(--text-success)'
|
||||
: type === 'warn' ? 'var(--accent)'
|
||||
: 'var(--text-muted)';
|
||||
}
|
||||
|
||||
// ─── File table ───────────────────────────────────────────────────────────────
|
||||
|
||||
function updateSelectionUI() {
|
||||
els.selectionSummary.textContent =
|
||||
`${state.selectedPaths.size} of ${state.scannedFiles.length} selected`;
|
||||
els.compressBtn.disabled = state.selectedPaths.size === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and inject the file selection table from the scan results.
|
||||
* Attaches checkbox change handlers for each row.
|
||||
* @param {Array} files — enriched file objects from /api/scan
|
||||
*/
|
||||
export function renderFileTable(files) {
|
||||
let html = '';
|
||||
|
||||
files.forEach((f, idx) => {
|
||||
const codec = (f.codec || 'unknown').toLowerCase();
|
||||
const isHevc = ['hevc', 'h265', 'x265'].includes(codec);
|
||||
const isH264 = ['h264', 'avc', 'x264'].includes(codec);
|
||||
const codecLabel = isHevc ? 'H.265 / HEVC'
|
||||
: isH264 ? 'H.264 / AVC'
|
||||
: codec.toUpperCase();
|
||||
const codecMod = isHevc ? 'hevc' : isH264 ? 'h264' : '';
|
||||
const pathDir = f.path.replace(f.name, '');
|
||||
|
||||
html += `
|
||||
<tr id="row-${idx}" data-path="${esc(f.path)}">
|
||||
<td class="col-check">
|
||||
<input type="checkbox" class="file-checkbox" id="chk-${idx}"
|
||||
data-path="${esc(f.path)}"
|
||||
aria-label="Select ${esc(f.name)} for compression" />
|
||||
</td>
|
||||
<td class="col-name">
|
||||
<label for="chk-${idx}" class="file-name-cell" style="cursor:pointer">
|
||||
<span class="file-name">${esc(f.name)}</span>
|
||||
<span class="file-path" title="${esc(f.path)}">${esc(pathDir)}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td class="col-size"><strong>${f.size_gb.toFixed(3)}</strong> GB</td>
|
||||
<td class="col-bitrate">
|
||||
<span class="bitrate-badge">
|
||||
${esc(f.bit_rate_mbps ? f.bit_rate_mbps + ' Mbps' : 'Unknown')}
|
||||
</span>
|
||||
</td>
|
||||
<td class="col-target">
|
||||
<span class="bitrate-badge target">
|
||||
${esc(f.target_bit_rate_mbps ? f.target_bit_rate_mbps + ' Mbps' : '—')}
|
||||
</span>
|
||||
</td>
|
||||
<td class="col-codec">
|
||||
<span class="codec-tag ${codecMod}"
|
||||
title="Encoder: ${isHevc ? 'libx265' : 'libx264'}">
|
||||
${esc(codecLabel)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
els.fileTbody.innerHTML = html;
|
||||
|
||||
els.fileTbody.querySelectorAll('.file-checkbox').forEach(chk => {
|
||||
chk.addEventListener('change', () => {
|
||||
const row = chk.closest('tr');
|
||||
if (chk.checked) {
|
||||
state.selectedPaths.add(chk.dataset.path);
|
||||
row.classList.add('selected');
|
||||
} else {
|
||||
state.selectedPaths.delete(chk.dataset.path);
|
||||
row.classList.remove('selected');
|
||||
}
|
||||
updateSelectionUI();
|
||||
});
|
||||
});
|
||||
|
||||
updateSelectionUI();
|
||||
}
|
||||
|
||||
// ─── Public init ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Attach event listeners for the scan button and file selection controls.
|
||||
* Call once during app initialisation.
|
||||
*/
|
||||
export function initScan() {
|
||||
// ── Scan button ──────────────────────────────────────────────────────────
|
||||
els.scanBtn.addEventListener('click', async () => {
|
||||
const directory = els.dirInput.value.trim();
|
||||
const minSize = parseFloat(els.minSizeInput.value);
|
||||
|
||||
if (!directory) {
|
||||
setScanStatus('Please enter a directory path.', 'error');
|
||||
els.dirInput.focus();
|
||||
return;
|
||||
}
|
||||
if (isNaN(minSize) || minSize <= 0) {
|
||||
setScanStatus('Please enter a valid minimum size > 0.', 'error');
|
||||
els.minSizeInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
els.scanBtn.disabled = true;
|
||||
els.scanBtn.textContent = '⟳ Scanning…';
|
||||
setScanStatus('Scanning directory, please wait…', 'info');
|
||||
announce('Scanning directory for video files.');
|
||||
els.sectionFiles.hidden = true;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/scan', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ directory, min_size_gb: minSize }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
if (!resp.ok) {
|
||||
setScanStatus(`Error: ${data.error}`, 'error');
|
||||
announce(`Scan failed: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
state.scannedFiles = data.files;
|
||||
state.selectedPaths.clear();
|
||||
|
||||
if (!data.files.length) {
|
||||
setScanStatus(`No video files larger than ${minSize} GB found.`, 'warn');
|
||||
announce('No video files found matching your criteria.');
|
||||
return;
|
||||
}
|
||||
|
||||
setScanStatus(`Found ${data.files.length} file(s).`, 'success');
|
||||
announce(`Scan complete. Found ${data.files.length} video files.`);
|
||||
renderFileTable(data.files);
|
||||
els.sectionFiles.hidden = false;
|
||||
els.sectionFiles.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
|
||||
} catch (err) {
|
||||
setScanStatus(`Network error: ${err.message}`, 'error');
|
||||
} finally {
|
||||
els.scanBtn.disabled = false;
|
||||
els.scanBtn.innerHTML =
|
||||
'<span class="btn-icon-prefix" aria-hidden="true">⊙</span> Scan for Files';
|
||||
}
|
||||
});
|
||||
|
||||
// ── Select all ───────────────────────────────────────────────────────────
|
||||
els.selectAllBtn.addEventListener('click', () => {
|
||||
els.fileTbody.querySelectorAll('.file-checkbox').forEach(chk => {
|
||||
chk.checked = true;
|
||||
state.selectedPaths.add(chk.dataset.path);
|
||||
chk.closest('tr').classList.add('selected');
|
||||
});
|
||||
updateSelectionUI();
|
||||
announce(`All ${state.scannedFiles.length} files selected.`);
|
||||
});
|
||||
|
||||
// ── Deselect all ─────────────────────────────────────────────────────────
|
||||
els.deselectAllBtn.addEventListener('click', () => {
|
||||
els.fileTbody.querySelectorAll('.file-checkbox').forEach(chk => {
|
||||
chk.checked = false;
|
||||
state.selectedPaths.delete(chk.dataset.path);
|
||||
chk.closest('tr').classList.remove('selected');
|
||||
});
|
||||
updateSelectionUI();
|
||||
announce('All files deselected.');
|
||||
});
|
||||
}
|
||||
59
static/js/modules/session.js
Normal file
59
static/js/modules/session.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* session.js
|
||||
* ----------
|
||||
* Page-load session restore.
|
||||
*
|
||||
* On every page load — including hard browser reloads (Ctrl+Shift+R) and
|
||||
* opening the app in a new tab — asks the server whether a job is active,
|
||||
* fetches its full snapshot, and reconnects the live SSE stream if needed.
|
||||
*
|
||||
* This does NOT depend on sessionStorage surviving the reload (though
|
||||
* sessionStorage is still written as a fast secondary hint).
|
||||
*
|
||||
* Exports
|
||||
* -------
|
||||
* tryRestoreSession() — call once at startup
|
||||
*/
|
||||
|
||||
import { announce } from './state.js';
|
||||
import { applySnapshot, startProgressStream } from './stream.js';
|
||||
import { showResults } from './progress.js';
|
||||
|
||||
/**
|
||||
* Query the server for active/recent jobs and restore the UI if one is found.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. GET /api/compress/active — find the most recent running job (or any job)
|
||||
* 2. GET /api/compress/status/<id> — fetch the full snapshot
|
||||
* 3. applySnapshot() to rebuild all progress bars
|
||||
* 4. If still running: re-attach the SSE stream
|
||||
* 5. If done/cancelled: show the results card
|
||||
*/
|
||||
export async function tryRestoreSession() {
|
||||
try {
|
||||
const activeResp = await fetch('/api/compress/active');
|
||||
if (!activeResp.ok) return;
|
||||
|
||||
const { jobs } = await activeResp.json();
|
||||
if (!jobs.length) return;
|
||||
|
||||
// Prefer the most recent running job; fall back to any job
|
||||
const candidate = jobs.find(j => j.status === 'running') || jobs[0];
|
||||
|
||||
const snapResp = await fetch(`/api/compress/status/${candidate.job_id}`);
|
||||
if (!snapResp.ok) return;
|
||||
|
||||
const snap = await snapResp.json();
|
||||
applySnapshot(snap);
|
||||
announce('Active compression job restored.');
|
||||
|
||||
if (snap.status === 'running') {
|
||||
startProgressStream(snap.job_id, snap.files);
|
||||
} else if (snap.status === 'done' || snap.status === 'cancelled') {
|
||||
showResults(snap.status);
|
||||
sessionStorage.removeItem('vp-job-id');
|
||||
}
|
||||
} catch {
|
||||
// Server unreachable or no jobs — start fresh, no action needed
|
||||
}
|
||||
}
|
||||
260
static/js/modules/settings.js
Normal file
260
static/js/modules/settings.js
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
/**
|
||||
* settings.js
|
||||
* -----------
|
||||
* SMTP email settings modal.
|
||||
*
|
||||
* Loads saved settings from the server on open, lets the user edit and
|
||||
* save them, and sends a test email to verify the configuration works.
|
||||
*
|
||||
* Exports
|
||||
* -------
|
||||
* initSettings() — wire up all listeners; call once at startup
|
||||
* smtpIsConfigured() — returns true if the server has smtp_host saved
|
||||
*/
|
||||
|
||||
import { announce } from './state.js';
|
||||
|
||||
// ─── DOM refs (local to this module) ─────────────────────────────────────────
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
const modal = $('settings-modal');
|
||||
const openBtn = $('settings-btn');
|
||||
const openFromHint = $('open-settings-from-hint');
|
||||
const closeBtn = $('close-settings');
|
||||
const cancelBtn = $('settings-cancel');
|
||||
const saveBtn = $('settings-save');
|
||||
const saveStatus = $('settings-save-status');
|
||||
|
||||
const hostInput = $('smtp-host');
|
||||
const portInput = $('smtp-port');
|
||||
const securitySel = $('smtp-security');
|
||||
const fromInput = $('smtp-from');
|
||||
const userInput = $('smtp-user');
|
||||
const passwordInput = $('smtp-password');
|
||||
const passwordHint = $('smtp-password-hint');
|
||||
const togglePwBtn = $('toggle-password');
|
||||
|
||||
const testToInput = $('smtp-test-to');
|
||||
const testBtn = $('smtp-test-btn');
|
||||
const testResult = $('smtp-test-result');
|
||||
|
||||
// ─── Module-level state ───────────────────────────────────────────────────────
|
||||
|
||||
let _configured = false; // whether smtp_host is set on the server
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns true if the server has an SMTP host configured.
|
||||
* Used by compress.js to warn the user before they start a job with
|
||||
* notifications enabled but no SMTP server set up.
|
||||
*/
|
||||
export function smtpIsConfigured() {
|
||||
return _configured;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach all event listeners for the settings modal.
|
||||
* Call once during app initialisation.
|
||||
*/
|
||||
export function initSettings() {
|
||||
openBtn.addEventListener('click', openSettings);
|
||||
if (openFromHint) openFromHint.addEventListener('click', openSettings);
|
||||
const openFromWarn = document.getElementById('open-settings-from-warn');
|
||||
if (openFromWarn) openFromWarn.addEventListener('click', openSettings);
|
||||
|
||||
closeBtn.addEventListener('click', closeSettings);
|
||||
cancelBtn.addEventListener('click', closeSettings);
|
||||
modal.addEventListener('click', e => { if (e.target === modal) closeSettings(); });
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' && !modal.hidden) closeSettings();
|
||||
});
|
||||
|
||||
saveBtn.addEventListener('click', saveSettings);
|
||||
testBtn.addEventListener('click', sendTestEmail);
|
||||
|
||||
// Password show/hide toggle
|
||||
togglePwBtn.addEventListener('click', () => {
|
||||
const isHidden = passwordInput.type === 'password';
|
||||
passwordInput.type = isHidden ? 'text' : 'password';
|
||||
togglePwBtn.setAttribute('aria-label', isHidden ? 'Hide password' : 'Show password');
|
||||
});
|
||||
|
||||
// Auto-fill port when security mode changes
|
||||
securitySel.addEventListener('change', () => {
|
||||
const presets = { tls: '587', ssl: '465', none: '25' };
|
||||
portInput.value = presets[securitySel.value] || portInput.value;
|
||||
});
|
||||
|
||||
// Load current config silently at startup so smtpIsConfigured() works
|
||||
_fetchConfig(false);
|
||||
}
|
||||
|
||||
// ─── Open / close ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function openSettings() {
|
||||
modal.hidden = false;
|
||||
document.body.style.overflow = 'hidden';
|
||||
clearStatus();
|
||||
await _fetchConfig(true);
|
||||
closeBtn.focus();
|
||||
announce('SMTP settings panel opened');
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
modal.hidden = true;
|
||||
document.body.style.overflow = '';
|
||||
openBtn.focus();
|
||||
announce('SMTP settings panel closed');
|
||||
}
|
||||
|
||||
// ─── Load settings from server ────────────────────────────────────────────────
|
||||
|
||||
async function _fetchConfig(populateForm) {
|
||||
try {
|
||||
const resp = await fetch('/api/settings/smtp');
|
||||
if (!resp.ok) return;
|
||||
const cfg = await resp.json();
|
||||
|
||||
_configured = Boolean(cfg.host);
|
||||
|
||||
if (!populateForm) return;
|
||||
|
||||
hostInput.value = cfg.host || '';
|
||||
portInput.value = cfg.port || '587';
|
||||
fromInput.value = cfg.from_addr || '';
|
||||
userInput.value = cfg.user || '';
|
||||
passwordInput.value = ''; // never pre-fill passwords
|
||||
|
||||
// Select the right security option
|
||||
const opt = securitySel.querySelector(`option[value="${cfg.security || 'tls'}"]`);
|
||||
if (opt) opt.selected = true;
|
||||
|
||||
passwordHint.textContent = cfg.password_set
|
||||
? 'A password is saved. Enter a new value to replace it, or leave blank to keep it.'
|
||||
: '';
|
||||
|
||||
} catch {
|
||||
// Silently ignore — server may not be reachable during init
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Save ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function saveSettings() {
|
||||
const host = hostInput.value.trim();
|
||||
const port = portInput.value.trim();
|
||||
const security = securitySel.value;
|
||||
const from = fromInput.value.trim();
|
||||
const user = userInput.value.trim();
|
||||
const password = passwordInput.value; // not trimmed — passwords may have spaces
|
||||
|
||||
if (!host) {
|
||||
showStatus('SMTP server host is required.', 'fail');
|
||||
hostInput.focus();
|
||||
return;
|
||||
}
|
||||
if (!port || isNaN(Number(port))) {
|
||||
showStatus('A valid port number is required.', 'fail');
|
||||
portInput.focus();
|
||||
return;
|
||||
}
|
||||
if (!from || !from.includes('@')) {
|
||||
showStatus('A valid From address is required.', 'fail');
|
||||
fromInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = 'Saving…';
|
||||
clearStatus();
|
||||
|
||||
try {
|
||||
const body = { host, port, security, from_addr: from, user };
|
||||
if (password) body.password = password;
|
||||
|
||||
const resp = await fetch('/api/settings/smtp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
if (!resp.ok) {
|
||||
showStatus(`Error: ${data.error}`, 'fail');
|
||||
return;
|
||||
}
|
||||
|
||||
_configured = Boolean(data.config?.host);
|
||||
passwordInput.value = '';
|
||||
passwordHint.textContent =
|
||||
'Password saved. Enter a new value to replace it, or leave blank to keep it.';
|
||||
showStatus('Settings saved successfully.', 'ok');
|
||||
announce('SMTP settings saved.');
|
||||
|
||||
} catch (err) {
|
||||
showStatus(`Network error: ${err.message}`, 'fail');
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = 'Save Settings';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test email ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function sendTestEmail() {
|
||||
const to = testToInput.value.trim();
|
||||
if (!to || !to.includes('@')) {
|
||||
setTestResult('Please enter a valid recipient address.', 'fail');
|
||||
testToInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
testBtn.disabled = true;
|
||||
testBtn.textContent = 'Sending…';
|
||||
setTestResult('Sending test email…', '');
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/settings/smtp/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ to }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.ok) {
|
||||
setTestResult(`✓ ${data.message}`, 'ok');
|
||||
announce(`Test email sent to ${to}.`);
|
||||
} else {
|
||||
setTestResult(`✗ ${data.message}`, 'fail');
|
||||
announce(`Test email failed: ${data.message}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setTestResult(`Network error: ${err.message}`, 'fail');
|
||||
} finally {
|
||||
testBtn.disabled = false;
|
||||
testBtn.textContent = 'Send Test';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function showStatus(msg, type) {
|
||||
saveStatus.textContent = msg;
|
||||
saveStatus.className = `settings-save-status ${type}`;
|
||||
}
|
||||
|
||||
function clearStatus() {
|
||||
saveStatus.textContent = '';
|
||||
saveStatus.className = 'settings-save-status';
|
||||
setTestResult('', '');
|
||||
}
|
||||
|
||||
function setTestResult(msg, type) {
|
||||
testResult.textContent = msg;
|
||||
testResult.style.color =
|
||||
type === 'ok' ? 'var(--text-success)'
|
||||
: type === 'fail' ? 'var(--text-danger)'
|
||||
: 'var(--text-muted)';
|
||||
}
|
||||
119
static/js/modules/state.js
Normal file
119
static/js/modules/state.js
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* state.js
|
||||
* --------
|
||||
* Single shared application state object and all DOM element references.
|
||||
*
|
||||
* Centralising these here means every module imports the same live object —
|
||||
* mutations made in one module are immediately visible to all others without
|
||||
* any event bus or pub/sub layer.
|
||||
*
|
||||
* Also exports announce(), which every module uses to push messages to the
|
||||
* ARIA live region for screen-reader users.
|
||||
*/
|
||||
|
||||
// ─── Shared mutable state ────────────────────────────────────────────────────
|
||||
export const state = {
|
||||
/** Files returned by the last /api/scan call. */
|
||||
scannedFiles: [],
|
||||
|
||||
/** Set of file paths the user has checked for compression. */
|
||||
selectedPaths: new Set(),
|
||||
|
||||
/** job_id of the currently active or most-recently-seen compression job. */
|
||||
currentJobId: null,
|
||||
|
||||
/** Active EventSource for the SSE progress stream. */
|
||||
eventSource: null,
|
||||
|
||||
/** Per-file result objects accumulated during a compression run. */
|
||||
compressionResults: [],
|
||||
|
||||
/** Current path shown in the server-side directory browser modal. */
|
||||
browserPath: '/',
|
||||
|
||||
/**
|
||||
* Index of the last SSE event we have processed.
|
||||
* Passed as ?from=N when reconnecting so the server skips events
|
||||
* we already applied to the UI.
|
||||
*/
|
||||
seenEventCount: 0,
|
||||
|
||||
/** Handle returned by setTimeout for the auto-reconnect retry. */
|
||||
reconnectTimer: null,
|
||||
};
|
||||
|
||||
// ─── DOM element references ───────────────────────────────────────────────────
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
export const els = {
|
||||
// Step 1 — Configure source
|
||||
dirInput: $('dir-input'),
|
||||
browseBtn: $('browse-btn'),
|
||||
minSizeInput: $('min-size-input'),
|
||||
suffixInput: $('suffix-input'),
|
||||
scanBtn: $('scan-btn'),
|
||||
scanStatus: $('scan-status'),
|
||||
|
||||
// Directory browser modal
|
||||
browserModal: $('browser-modal'),
|
||||
browserList: $('browser-list'),
|
||||
browserPath: $('browser-current-path'),
|
||||
closeBrowser: $('close-browser'),
|
||||
browserCancel: $('browser-cancel'),
|
||||
browserSelect: $('browser-select'),
|
||||
|
||||
// Step 2 — File selection
|
||||
sectionFiles: $('section-files'),
|
||||
selectAllBtn: $('select-all-btn'),
|
||||
deselectAllBtn: $('deselect-all-btn'),
|
||||
selectionSummary: $('selection-summary'),
|
||||
fileTbody: $('file-tbody'),
|
||||
compressBtn: $('compress-btn'),
|
||||
|
||||
// Email notification opt-in
|
||||
notifyChk: $('notify-chk'),
|
||||
notifyEmailRow: $('notify-email-row'),
|
||||
notifyEmail: $('notify-email'),
|
||||
|
||||
// Step 3 — Compression progress
|
||||
sectionProgress: $('section-progress'),
|
||||
progTotal: $('prog-total'),
|
||||
progDone: $('prog-done'),
|
||||
progStatus: $('prog-status'),
|
||||
overallBar: $('overall-bar'),
|
||||
overallBarFill: $('overall-bar-fill'),
|
||||
overallPct: $('overall-pct'),
|
||||
fileProgressList: $('file-progress-list'),
|
||||
cancelBtn: $('cancel-btn'),
|
||||
notifyStatus: $('notify-status'),
|
||||
reconnectBtn: $('reconnect-btn'),
|
||||
reconnectBtnBanner: $('reconnect-btn-banner'),
|
||||
streamLostBanner: $('stream-lost-banner'),
|
||||
|
||||
// Step 4 — Results
|
||||
sectionResults: $('section-results'),
|
||||
resultsContent: $('results-content'),
|
||||
restartBtn: $('restart-btn'),
|
||||
|
||||
// Header
|
||||
themeToggle: $('theme-toggle'),
|
||||
themeIcon: $('theme-icon'),
|
||||
settingsBtn: $('settings-btn'),
|
||||
|
||||
// Accessibility live region
|
||||
srAnnounce: $('sr-announce'),
|
||||
};
|
||||
|
||||
// ─── Screen-reader announcements ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Push a message to the ARIA assertive live region.
|
||||
* Clears first so repeated identical messages are still announced.
|
||||
* @param {string} msg
|
||||
*/
|
||||
export function announce(msg) {
|
||||
els.srAnnounce.textContent = '';
|
||||
requestAnimationFrame(() => {
|
||||
els.srAnnounce.textContent = msg;
|
||||
});
|
||||
}
|
||||
276
static/js/modules/stream.js
Normal file
276
static/js/modules/stream.js
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
/**
|
||||
* stream.js
|
||||
* ---------
|
||||
* SSE progress stream management and reconnect / snapshot-restore logic.
|
||||
*
|
||||
* Exports
|
||||
* -------
|
||||
* startProgressStream(jobId, files) — open (or re-open) the SSE connection
|
||||
* reconnectToJob(jobId) — fetch snapshot then re-open stream
|
||||
* applySnapshot(snap) — paint a server snapshot onto the UI
|
||||
* initStreamControls() — wire up Reconnect buttons; call once
|
||||
*/
|
||||
|
||||
import { state, els, announce } from './state.js';
|
||||
import { fmtTime } from './utils.js';
|
||||
import {
|
||||
setupProgressSection,
|
||||
setOverallProgress,
|
||||
updateFileProgress,
|
||||
showStreamLost,
|
||||
hideStreamLost,
|
||||
showResults,
|
||||
} from './progress.js';
|
||||
|
||||
// ─── SSE stream ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open a Server-Sent Events connection for *jobId*.
|
||||
*
|
||||
* Resumes from state.seenEventCount so no events are replayed or skipped
|
||||
* after a reconnect. doneCount is seeded from already-known results so
|
||||
* the overall progress bar is correct on the first incoming event.
|
||||
*
|
||||
* @param {string} jobId
|
||||
* @param {Array} files — file objects (need .name for announcements)
|
||||
*/
|
||||
export function startProgressStream(jobId, files) {
|
||||
// Cancel any pending auto-reconnect timer
|
||||
if (state.reconnectTimer) {
|
||||
clearTimeout(state.reconnectTimer);
|
||||
state.reconnectTimer = null;
|
||||
}
|
||||
// Close any stale connection
|
||||
if (state.eventSource) {
|
||||
state.eventSource.close();
|
||||
state.eventSource = null;
|
||||
}
|
||||
hideStreamLost();
|
||||
|
||||
state.eventSource = new EventSource(
|
||||
`/api/compress/progress/${jobId}?from=${state.seenEventCount}`,
|
||||
);
|
||||
|
||||
// Seed from results already recorded by applySnapshot (reconnect path)
|
||||
let doneCount = state.compressionResults.filter(
|
||||
r => r.status === 'done' || r.status === 'error',
|
||||
).length;
|
||||
|
||||
state.eventSource.onmessage = evt => {
|
||||
let data;
|
||||
try { data = JSON.parse(evt.data); } catch { return; }
|
||||
state.seenEventCount++;
|
||||
|
||||
switch (data.type) {
|
||||
|
||||
case 'start':
|
||||
els.progStatus.textContent = 'Running';
|
||||
break;
|
||||
|
||||
case 'file_start':
|
||||
updateFileProgress(data.index, 0, 'running', 'Compressing…', '', '');
|
||||
document.getElementById(`fpi-${data.index}`)
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
announce(
|
||||
`Compressing file ${data.index + 1} of ${data.total}: ` +
|
||||
`${files[data.index]?.name || ''}`,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'progress': {
|
||||
const pct = data.percent || 0;
|
||||
const detail = (data.elapsed_secs > 0 && data.duration_secs > 0)
|
||||
? `${fmtTime(data.elapsed_secs)} / ${fmtTime(data.duration_secs)}` : '';
|
||||
updateFileProgress(data.index, pct, 'running', 'Compressing…', detail, '');
|
||||
setOverallProgress(((doneCount + pct / 100) / files.length) * 100);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'file_done': {
|
||||
doneCount++;
|
||||
els.progDone.textContent = doneCount;
|
||||
const detail = data.reduction_pct
|
||||
? `Saved ${data.reduction_pct}% → ${data.output_size_gb} GB` : 'Complete';
|
||||
updateFileProgress(data.index, 100, 'done', '✓ Done', detail, 'success');
|
||||
setOverallProgress((doneCount / files.length) * 100);
|
||||
// Guard against replay on reconnect
|
||||
if (!state.compressionResults.find(
|
||||
r => r.index === data.index && r.status === 'done',
|
||||
)) {
|
||||
state.compressionResults.push({ ...data, status: 'done' });
|
||||
}
|
||||
announce(
|
||||
`File complete: ${files[data.index]?.name}. Saved ${data.reduction_pct}%.`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'file_error': {
|
||||
doneCount++;
|
||||
els.progDone.textContent = doneCount;
|
||||
updateFileProgress(data.index, 0, 'error', '✗ Error', data.message, 'error');
|
||||
if (!state.compressionResults.find(
|
||||
r => r.index === data.index && r.status === 'error',
|
||||
)) {
|
||||
state.compressionResults.push({ ...data, status: 'error' });
|
||||
}
|
||||
announce(`Error: ${files[data.index]?.name}: ${data.message}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'notify':
|
||||
els.notifyStatus.hidden = false;
|
||||
els.notifyStatus.className = `notify-status ${data.success ? 'ok' : 'fail'}`;
|
||||
els.notifyStatus.textContent = `✉ ${data.message}`;
|
||||
announce(data.message);
|
||||
break;
|
||||
|
||||
case 'done':
|
||||
state.eventSource.close();
|
||||
sessionStorage.removeItem('vp-job-id');
|
||||
els.progStatus.textContent = 'Complete';
|
||||
setOverallProgress(100);
|
||||
els.cancelBtn.disabled = true;
|
||||
announce('All compression operations complete.');
|
||||
showResults('done');
|
||||
break;
|
||||
|
||||
case 'cancelled':
|
||||
state.eventSource.close();
|
||||
sessionStorage.removeItem('vp-job-id');
|
||||
els.progStatus.textContent = 'Cancelled';
|
||||
announce('Compression cancelled.');
|
||||
showResults('cancelled');
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
state.eventSource.close();
|
||||
els.progStatus.textContent = 'Error';
|
||||
announce(`Compression error: ${data.message}`);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
state.eventSource.onerror = () => {
|
||||
// CLOSED means the stream ended cleanly (done/cancelled) — ignore.
|
||||
if (!state.eventSource || state.eventSource.readyState === EventSource.CLOSED) return;
|
||||
state.eventSource.close();
|
||||
state.eventSource = null;
|
||||
showStreamLost();
|
||||
// Auto-retry after 5 s
|
||||
state.reconnectTimer = setTimeout(() => {
|
||||
if (state.currentJobId) reconnectToJob(state.currentJobId);
|
||||
}, 5_000);
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Reconnect ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch a fresh status snapshot from the server, rebuild the progress UI to
|
||||
* reflect everything that happened while disconnected, then re-open the SSE
|
||||
* stream starting from the last event already processed.
|
||||
*
|
||||
* @param {string} jobId
|
||||
*/
|
||||
export async function reconnectToJob(jobId) {
|
||||
if (state.reconnectTimer) {
|
||||
clearTimeout(state.reconnectTimer);
|
||||
state.reconnectTimer = null;
|
||||
}
|
||||
hideStreamLost();
|
||||
els.progStatus.textContent = 'Reconnecting…';
|
||||
announce('Reconnecting to compression job…');
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/compress/status/${jobId}`);
|
||||
if (!resp.ok) throw new Error('Job no longer available on server.');
|
||||
const snap = await resp.json();
|
||||
|
||||
applySnapshot(snap);
|
||||
|
||||
if (snap.status === 'done' || snap.status === 'cancelled') {
|
||||
showResults(snap.status);
|
||||
sessionStorage.removeItem('vp-job-id');
|
||||
} else {
|
||||
startProgressStream(jobId, snap.files);
|
||||
announce('Reconnected. Progress restored.');
|
||||
}
|
||||
} catch (err) {
|
||||
els.progStatus.textContent = 'Reconnect failed';
|
||||
showStreamLost();
|
||||
els.streamLostBanner.querySelector('.banner-text').textContent =
|
||||
`Could not reconnect: ${err.message}`;
|
||||
announce(`Reconnect failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Snapshot restore ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Paint a server-supplied status snapshot onto the progress UI.
|
||||
*
|
||||
* Called by:
|
||||
* - reconnectToJob() after a mid-session SSE drop
|
||||
* - tryRestoreSession() on every page load to recover an active job
|
||||
*
|
||||
* @param {object} snap — response from GET /api/compress/status/<id>
|
||||
*/
|
||||
export function applySnapshot(snap) {
|
||||
// Rebuild the per-file DOM if the page was reloaded and lost it
|
||||
if (!document.getElementById('fpi-0')) {
|
||||
setupProgressSection(snap.files);
|
||||
}
|
||||
|
||||
state.currentJobId = snap.job_id;
|
||||
state.seenEventCount = snap.event_count;
|
||||
sessionStorage.setItem('vp-job-id', snap.job_id);
|
||||
|
||||
els.sectionProgress.hidden = false;
|
||||
els.progTotal.textContent = snap.total;
|
||||
els.progDone.textContent = snap.done_count;
|
||||
els.progStatus.textContent =
|
||||
snap.status === 'running' ? 'Running'
|
||||
: snap.status === 'done' ? 'Complete'
|
||||
: snap.status === 'cancelled' ? 'Cancelled'
|
||||
: snap.status;
|
||||
|
||||
// Restore each file bar from the snapshot's computed file_states
|
||||
snap.file_states.forEach((fs, idx) => {
|
||||
const statusClass = { done: 'done', error: 'error', running: 'running' }[fs.status] || 'waiting';
|
||||
const statusText = { done: '✓ Done', error: '✗ Error', running: 'Compressing…' }[fs.status] || 'Waiting';
|
||||
const detailClass = { done: 'success', error: 'error' }[fs.status] || '';
|
||||
updateFileProgress(idx, fs.percent || 0, statusClass, statusText, fs.detail || '', detailClass);
|
||||
});
|
||||
|
||||
// Restore overall bar
|
||||
const runningPct = snap.file_states.find(f => f.status === 'running')?.percent || 0;
|
||||
const overall = snap.total > 0
|
||||
? ((snap.done_count + runningPct / 100) / snap.total) * 100 : 0;
|
||||
setOverallProgress(Math.min(overall, 100));
|
||||
|
||||
// Seed compressionResults so showResults() has data if job is already done
|
||||
state.compressionResults = snap.file_states
|
||||
.filter(fs => fs.status === 'done' || fs.status === 'error')
|
||||
.map((fs, idx) => ({ ...fs, index: idx }));
|
||||
|
||||
if (snap.status === 'done') {
|
||||
els.cancelBtn.disabled = true;
|
||||
setOverallProgress(100);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Button wiring ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Attach click handlers to both Reconnect buttons (title-bar and banner).
|
||||
* Call once during app initialisation.
|
||||
*/
|
||||
export function initStreamControls() {
|
||||
els.reconnectBtn.addEventListener('click', () => {
|
||||
if (state.currentJobId) reconnectToJob(state.currentJobId);
|
||||
});
|
||||
els.reconnectBtnBanner.addEventListener('click', () => {
|
||||
if (state.currentJobId) reconnectToJob(state.currentJobId);
|
||||
});
|
||||
}
|
||||
46
static/js/modules/theme.js
Normal file
46
static/js/modules/theme.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* theme.js
|
||||
* --------
|
||||
* Dark / light theme management.
|
||||
*
|
||||
* Reads the user's saved preference from localStorage and falls back to the
|
||||
* OS-level prefers-color-scheme media query. Writes back on every change
|
||||
* so the choice persists across page loads.
|
||||
*
|
||||
* Exports
|
||||
* -------
|
||||
* initTheme() — call once at startup; reads saved pref and applies it
|
||||
* applyTheme() — apply a specific theme string ('dark' | 'light')
|
||||
*/
|
||||
|
||||
import { els } from './state.js';
|
||||
|
||||
/**
|
||||
* Apply *theme* to the document and persist the choice.
|
||||
* @param {'dark'|'light'} theme
|
||||
*/
|
||||
export function applyTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
els.themeIcon.textContent = theme === 'dark' ? '☀' : '◑';
|
||||
els.themeToggle.setAttribute(
|
||||
'aria-label',
|
||||
`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`,
|
||||
);
|
||||
localStorage.setItem('vp-theme', theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the saved theme preference (or detect from OS) and apply it.
|
||||
* Attaches the toggle button's click listener.
|
||||
* Call once during app initialisation.
|
||||
*/
|
||||
export function initTheme() {
|
||||
const saved = localStorage.getItem('vp-theme');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
applyTheme(saved || (prefersDark ? 'dark' : 'light'));
|
||||
|
||||
els.themeToggle.addEventListener('click', () => {
|
||||
const current = document.documentElement.getAttribute('data-theme');
|
||||
applyTheme(current === 'dark' ? 'light' : 'dark');
|
||||
});
|
||||
}
|
||||
45
static/js/modules/utils.js
Normal file
45
static/js/modules/utils.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* utils.js
|
||||
* --------
|
||||
* Pure utility functions with no DOM or state dependencies.
|
||||
* Safe to import anywhere without side-effects.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Escape a string for safe insertion into HTML.
|
||||
* @param {*} str
|
||||
* @returns {string}
|
||||
*/
|
||||
export function esc(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a duration in seconds as M:SS or H:MM:SS.
|
||||
* @param {number} seconds
|
||||
* @returns {string}
|
||||
*/
|
||||
export function fmtTime(seconds) {
|
||||
const s = Math.floor(seconds);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
return h > 0
|
||||
? `${h}:${pad(m)}:${pad(sec)}`
|
||||
: `${m}:${pad(sec)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero-pad a number to at least 2 digits.
|
||||
* @param {number} n
|
||||
* @returns {string}
|
||||
*/
|
||||
export function pad(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue