Updates galore. Improved folder structure, componentized, and notifications upon completion.

This commit is contained in:
Brian McGonagill 2026-03-17 14:01:35 -05:00
parent b48784e2ad
commit 7e0502ca40
33 changed files with 3565 additions and 728 deletions

View file

@ -1202,7 +1202,107 @@ body {
color: rgba(245,245,242,0.65);
}
/* ── Animations ─────────────────────────────────────────────── */
/* ── Notification opt-in ────────────────────────────────────── */
.notify-group {
display: flex;
flex-direction: column;
gap: var(--space-sm);
flex: 1;
}
.notify-checkbox-row {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.notify-checkbox {
width: 18px;
height: 18px;
min-width: 18px;
min-height: 18px;
cursor: pointer;
accent-color: var(--accent);
flex-shrink: 0;
}
.notify-label {
font-size: 0.9rem;
color: var(--text-primary);
cursor: pointer;
font-weight: 500;
line-height: 1.3;
}
.notify-email-row {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding-left: 26px; /* indent under checkbox */
animation: slide-down 180ms ease;
}
.notify-email-row[hidden] {
display: none !important;
}
@keyframes slide-down {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.notify-email-label {
margin-bottom: 0;
}
.notify-email-input {
max-width: 340px;
}
.notify-divider {
width: 1px;
background: var(--border-base);
align-self: stretch;
margin: 0 var(--space-sm);
flex-shrink: 0;
}
/* Notification send status shown in progress footer */
.notify-status {
font-size: 0.85rem;
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-md);
border-radius: var(--radius-pill);
font-weight: 600;
}
.notify-status[hidden] {
display: none !important;
}
.notify-status.ok {
background: rgba(22, 101, 52, 0.10);
color: var(--text-success);
border: 1px solid rgba(22, 101, 52, 0.25);
}
.notify-status.fail {
background: rgba(185, 28, 28, 0.10);
color: var(--text-danger);
border: 1px solid rgba(185, 28, 28, 0.25);
}
[data-theme="dark"] .notify-status.ok {
background: rgba(134, 239, 172, 0.10);
border-color: rgba(134, 239, 172, 0.25);
}
[data-theme="dark"] .notify-status.fail {
background: rgba(252, 165, 165, 0.10);
border-color: rgba(252, 165, 165, 0.25);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
@ -1212,6 +1312,60 @@ body {
animation: pulse 1.8s ease infinite;
}
/* ── Stream-lost banner ─────────────────────────────────────── */
.stream-lost-banner {
display: flex;
align-items: center;
gap: var(--space-md);
flex-wrap: wrap;
background: rgba(180, 100, 0, 0.10);
border: 1.5px solid rgba(180, 100, 0, 0.35);
border-radius: var(--radius-md);
padding: var(--space-md) var(--space-lg);
margin-bottom: var(--space-lg);
color: #7a4500;
}
[data-theme="dark"] .stream-lost-banner {
background: rgba(251, 191, 36, 0.10);
border-color: rgba(251, 191, 36, 0.30);
color: #fbbf24;
}
.stream-lost-banner[hidden] {
display: none !important;
}
.banner-icon {
font-size: 1.2rem;
flex-shrink: 0;
}
.banner-text {
flex: 1;
font-size: 0.88rem;
font-weight: 500;
line-height: 1.4;
}
/* Reconnect button sits in the card title row */
.card-title .reconnect-btn {
margin-left: auto;
font-size: 0.78rem;
padding: 5px 12px;
min-height: 32px;
animation: pulse-reconnect 1.8s ease infinite;
}
.reconnect-btn[hidden] {
display: none !important;
}
@keyframes pulse-reconnect {
0%, 100% { border-color: var(--btn-outline-border); }
50% { border-color: var(--accent); color: var(--accent); }
}
/* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 768px) {
.app-main {
@ -1258,3 +1412,123 @@ body {
[data-theme="dark"] .file-table th {
color: var(--text-primary);
}
/* ── Settings modal ─────────────────────────────────────────── */
.settings-panel {
max-width: 560px;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.settings-body {
flex: 1;
overflow-y: auto;
padding: var(--space-lg) var(--space-xl);
}
.settings-body::-webkit-scrollbar { width: 6px; }
.settings-body::-webkit-scrollbar-track { background: var(--bg-card2); }
.settings-body::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
.settings-intro {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: var(--space-lg);
line-height: 1.5;
}
.settings-grid {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.settings-row-2 {
display: grid;
grid-template-columns: 100px 1fr;
gap: var(--space-md);
}
.settings-divider-above {
border-top: 1px solid var(--border-base);
padding-top: var(--space-lg);
margin-top: var(--space-sm);
}
.settings-save-status {
font-size: 0.82rem;
text-align: center;
min-height: 1.4em;
padding: var(--space-xs) var(--space-xl) var(--space-md);
color: var(--text-muted);
}
.settings-test-result {
min-height: 1.4em;
}
/* Password row with toggle */
.password-row {
display: flex;
gap: var(--space-sm);
align-items: center;
}
.password-row .text-input { flex: 1; }
.btn-icon-inline {
width: 40px;
height: 40px;
min-width: 40px;
min-height: 40px;
border: 1.5px solid var(--border-input);
border-radius: var(--radius-md);
background: var(--bg-input);
color: var(--text-muted);
font-size: 1rem;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background var(--transition-fast), border-color var(--transition-fast);
flex-shrink: 0;
}
.btn-icon-inline:hover {
background: var(--bg-row-alt);
border-color: var(--border-strong);
}
/* Select input to match text-input style */
.select-input {
cursor: pointer;
}
/* Inline button link (used in hint text) */
.btn-link {
background: none;
border: none;
padding: 0;
color: var(--text-link);
font: inherit;
font-size: inherit;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.btn-link:hover {
color: var(--accent);
}
/* Settings save status colours */
.settings-save-status.ok { color: var(--text-success); }
.settings-save-status.fail { color: var(--text-danger); }
/* SMTP not configured warning on the notify row */
.smtp-warn {
color: var(--accent);
font-size: 0.78rem;
font-weight: 600;
}

View file

@ -1,705 +1,38 @@
/**
* VideoPress Frontend Application
* Handles all UI interactions, API calls, and SSE progress streaming.
* WCAG 2.2 compliant, fully functional, no stubs.
* app.js VideoPress entry point
* --------------------------------
* Imports every feature module and calls its init function.
* No application logic lives here this file is intentionally thin.
*
* Module layout
* -------------
* utils.js Pure helpers: esc(), fmtTime(), pad()
* state.js Shared state object, DOM refs (els), announce()
* theme.js Dark / light mode toggle
* browser.js Server-side directory browser modal
* scan.js /api/scan, file selection table, select-all controls
* progress.js Progress bars, results card, stream-lost banner
* stream.js SSE stream, reconnect, snapshot restore (applySnapshot)
* compress.js Start / cancel / restart compression, notification opt-in
* session.js Page-load restore via /api/compress/active
* settings.js SMTP email settings modal
*/
'use strict';
// ─── State ──────────────────────────────────────────────────────────────────
const state = {
scannedFiles: [], // enriched file objects from API
selectedPaths: new Set(), // paths of selected files
currentJobId: null,
eventSource: null,
compressionResults: [],
browserPath: '/',
};
import { initTheme } from './modules/theme.js';
import { initBrowser } from './modules/browser.js';
import { initScan } from './modules/scan.js';
import { initStreamControls } from './modules/stream.js';
import { initCompress } from './modules/compress.js';
import { tryRestoreSession } from './modules/session.js';
import { initSettings } from './modules/settings.js';
// ─── DOM References ──────────────────────────────────────────────────────────
const $ = (id) => document.getElementById(id);
const els = {
// Config section
dirInput: $('dir-input'),
browseBtn: $('browse-btn'),
minSizeInput: $('min-size-input'),
suffixInput: $('suffix-input'),
scanBtn: $('scan-btn'),
scanStatus: $('scan-status'),
// Browser modal
browserModal: $('browser-modal'),
browserList: $('browser-list'),
browserPath: $('browser-current-path'),
closeBrowser: $('close-browser'),
browserCancel: $('browser-cancel'),
browserSelect: $('browser-select'),
// Files section
sectionFiles: $('section-files'),
selectAllBtn: $('select-all-btn'),
deselectAllBtn: $('deselect-all-btn'),
selectionSummary: $('selection-summary'),
fileTbody: $('file-tbody'),
compressBtn: $('compress-btn'),
// Progress section
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'),
// Results
sectionResults: $('section-results'),
resultsContent: $('results-content'),
restartBtn: $('restart-btn'),
// Theme
themeToggle: $('theme-toggle'),
themeIcon: $('theme-icon'),
// Screen reader announce
srAnnounce: $('sr-announce'),
};
// ─── Accessibility Helper ─────────────────────────────────────────────────────
function announce(msg) {
els.srAnnounce.textContent = '';
requestAnimationFrame(() => {
els.srAnnounce.textContent = msg;
});
}
// ─── Theme Management ─────────────────────────────────────────────────────────
function initTheme() {
const saved = localStorage.getItem('vp-theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = saved || (prefersDark ? 'dark' : 'light');
applyTheme(theme);
}
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);
}
els.themeToggle.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme') || 'light';
applyTheme(current === 'dark' ? 'light' : 'dark');
});
// ─── Directory Browser ────────────────────────────────────────────────────────
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 = '';
// Parent directory link
if (data.parent !== null) {
html += `
<button class="browser-item parent-dir" data-path="${escHtml(data.parent)}" data-is-dir="true">
<span class="item-icon" aria-hidden="true"></span>
<span>.. (parent directory)</span>
</button>`;
}
if (data.entries.length === 0 && !data.parent) {
html += '<p class="browser-loading">No accessible directories found.</p>';
}
for (const entry of data.entries) {
if (!entry.is_dir) continue;
html += `
<button class="browser-item" data-path="${escHtml(entry.path)}" data-is-dir="true"
role="option" aria-label="Directory: ${escHtml(entry.name)}">
<span class="item-icon" aria-hidden="true">📁</span>
<span>${escHtml(entry.name)}</span>
</button>`;
}
if (html === '') {
html = '<p class="browser-loading">No subdirectories found.</p>';
}
els.browserList.innerHTML = html;
// Attach click events
els.browserList.querySelectorAll('.browser-item').forEach(item => {
item.addEventListener('click', () => loadBrowserPath(item.dataset.path));
item.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
loadBrowserPath(item.dataset.path);
}
});
});
} catch (err) {
els.browserList.innerHTML = `<p class="browser-error" role="alert">Error: ${escHtml(err.message)}</p>`;
}
}
function openBrowserModal() {
els.browserModal.hidden = false;
document.body.style.overflow = 'hidden';
loadBrowserPath(els.dirInput.value || '/');
// Focus trap
els.closeBrowser.focus();
announce('Directory browser opened');
}
function closeBrowserModal() {
els.browserModal.hidden = true;
document.body.style.overflow = '';
els.browseBtn.focus();
announce('Directory browser closed');
}
els.browseBtn.addEventListener('click', openBrowserModal);
els.closeBrowser.addEventListener('click', closeBrowserModal);
els.browserCancel.addEventListener('click', closeBrowserModal);
els.browserSelect.addEventListener('click', () => {
els.dirInput.value = state.browserPath;
closeBrowserModal();
announce(`Directory selected: ${state.browserPath}`);
});
// Close modal on backdrop click
els.browserModal.addEventListener('click', (e) => {
if (e.target === els.browserModal) closeBrowserModal();
});
// Keyboard: close on Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !els.browserModal.hidden) {
closeBrowserModal();
}
});
// ─── Scan for Files ───────────────────────────────────────────────────────────
els.scanBtn.addEventListener('click', async () => {
const directory = els.dirInput.value.trim();
const minSize = parseFloat(els.minSizeInput.value);
if (!directory) {
showScanStatus('Please enter a directory path.', 'error');
els.dirInput.focus();
return;
}
if (isNaN(minSize) || minSize <= 0) {
showScanStatus('Please enter a valid minimum size greater than 0.', 'error');
els.minSizeInput.focus();
return;
}
els.scanBtn.disabled = true;
els.scanBtn.textContent = '⟳ Scanning…';
showScanStatus('Scanning directory, please wait…', 'info');
announce('Scanning directory for video files, please wait.');
// Hide previous results
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) {
showScanStatus(`Error: ${data.error}`, 'error');
announce(`Scan failed: ${data.error}`);
return;
}
state.scannedFiles = data.files;
state.selectedPaths.clear();
if (data.files.length === 0) {
showScanStatus(
`No video files larger than ${minSize} GB found in that directory.`,
'warn'
);
announce('No video files found matching your criteria.');
return;
}
showScanStatus(`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) {
showScanStatus(`Network error: ${err.message}`, 'error');
announce(`Scan error: ${err.message}`);
} finally {
els.scanBtn.disabled = false;
els.scanBtn.innerHTML = '<span class="btn-icon-prefix" aria-hidden="true">⊙</span> Scan for Files';
}
});
function showScanStatus(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 Rendering ─────────────────────────────────────────────────────
function renderFileTable(files) {
let html = '';
files.forEach((f, idx) => {
const sizeFmt = f.size_gb.toFixed(3);
const curBitrate = f.bit_rate_mbps ? `${f.bit_rate_mbps} Mbps` : 'Unknown';
const tgtBitrate = f.target_bit_rate_mbps ? `${f.target_bit_rate_mbps} Mbps` : '—';
const codec = (f.codec || 'unknown').toLowerCase();
const pathDir = f.path.replace(f.name, '');
// Normalise codec label and pick a CSS modifier for the badge colour
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' : '';
html += `
<tr id="row-${idx}" data-path="${escHtml(f.path)}">
<td class="col-check">
<input
type="checkbox"
class="file-checkbox"
id="chk-${idx}"
data-path="${escHtml(f.path)}"
aria-label="Select ${escHtml(f.name)} for compression"
/>
</td>
<td class="col-name">
<label for="chk-${idx}" class="file-name-cell" style="cursor:pointer">
<span class="file-name">${escHtml(f.name)}</span>
<span class="file-path" title="${escHtml(f.path)}">${escHtml(pathDir)}</span>
</label>
</td>
<td class="col-size">
<strong>${sizeFmt}</strong> GB
</td>
<td class="col-bitrate">
<span class="bitrate-badge">${escHtml(curBitrate)}</span>
</td>
<td class="col-target">
<span class="bitrate-badge target">${escHtml(tgtBitrate)}</span>
</td>
<td class="col-codec">
<span class="codec-tag ${codecMod}" title="Encoder: ${isHevc ? 'libx265' : 'libx264'}">${escHtml(codecLabel)}</span>
</td>
</tr>`;
});
els.fileTbody.innerHTML = html;
// Attach change events
els.fileTbody.querySelectorAll('.file-checkbox').forEach(chk => {
chk.addEventListener('change', () => {
const path = chk.dataset.path;
const row = chk.closest('tr');
if (chk.checked) {
state.selectedPaths.add(path);
row.classList.add('selected');
} else {
state.selectedPaths.delete(path);
row.classList.remove('selected');
}
updateSelectionUI();
});
});
updateSelectionUI();
}
function updateSelectionUI() {
const total = state.scannedFiles.length;
const sel = state.selectedPaths.size;
els.selectionSummary.textContent = `${sel} of ${total} selected`;
els.compressBtn.disabled = sel === 0;
}
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.`);
});
els.deselectAllBtn.addEventListener('click', () => {
els.fileTbody.querySelectorAll('.file-checkbox').forEach(chk => {
chk.checked = false;
chk.closest('tr').classList.remove('selected');
});
state.selectedPaths.clear();
updateSelectionUI();
announce('All files deselected.');
});
// ─── Start Compression ────────────────────────────────────────────────────────
els.compressBtn.addEventListener('click', async () => {
const selectedFiles = state.scannedFiles.filter(f => state.selectedPaths.has(f.path));
if (selectedFiles.length === 0) return;
const suffix = els.suffixInput.value.trim() || '_new';
const payload = {
files: selectedFiles.map(f => ({
path: f.path,
size_bytes: f.size_bytes,
target_bit_rate_bps: f.target_bit_rate_bps || 1000000,
codec: f.codec || 'unknown',
})),
suffix,
};
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(payload),
});
const data = await resp.json();
if (!resp.ok) {
alert(`Failed to start compression: ${data.error}`);
els.compressBtn.disabled = false;
els.compressBtn.innerHTML = '<span class="btn-icon-prefix" aria-hidden="true">⚡</span> Compress Selected Files';
return;
}
state.currentJobId = data.job_id;
state.compressionResults = [];
// Show progress section
setupProgressSection(selectedFiles);
els.sectionProgress.hidden = false;
els.sectionProgress.scrollIntoView({ behavior: 'smooth', block: 'start' });
announce(`Compression started for ${selectedFiles.length} file(s).`);
// Start SSE stream
startProgressStream(data.job_id, selectedFiles);
} catch (err) {
alert(`Error: ${err.message}`);
els.compressBtn.disabled = false;
els.compressBtn.innerHTML = '<span class="btn-icon-prefix" aria-hidden="true">⚡</span> Compress Selected Files';
}
});
// ─── Progress Setup ───────────────────────────────────────────────────────────
function setupProgressSection(files) {
els.progTotal.textContent = files.length;
els.progDone.textContent = '0';
els.progStatus.textContent = 'Running';
setOverallProgress(0);
// Create per-file items
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}: ${escHtml(f.name)}">
<div class="fp-header">
<span class="fp-name">${escHtml(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 ${escHtml(f.name)}">
<div class="fp-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"
id="fpbar-${idx}" aria-label="${escHtml(f.name)} progress">
<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;
}
function setOverallProgress(pct) {
const p = Math.round(pct);
els.overallBarFill.style.width = `${p}%`;
els.overallBar.setAttribute('aria-valuenow', p);
els.overallPct.textContent = `${p}%`;
}
function updateFileProgress(idx, pct, statusClass, statusText, detail, detailClass) {
const fill = $(`fpfill-${idx}`);
const bar = $(`fpbar-${idx}`);
const pctEl = $(`fppct-${idx}`);
const status = $(`fps-${idx}`);
const item = $(`fpi-${idx}`);
const det = $(`fpdetail-${idx}`);
if (!fill) return;
const p = 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}`;
// Toggle animation on bar fill
fill.classList.toggle('active', statusClass === 'running');
if (detail !== undefined) {
det.textContent = detail;
det.className = `fp-detail ${detailClass || ''}`;
}
}
// ─── SSE Stream Handling ──────────────────────────────────────────────────────
function startProgressStream(jobId, files) {
if (state.eventSource) {
state.eventSource.close();
}
state.eventSource = new EventSource(`/api/compress/progress/${jobId}`);
let doneCount = 0;
state.eventSource.onmessage = (evt) => {
let data;
try { data = JSON.parse(evt.data); }
catch { return; }
switch (data.type) {
case 'start':
els.progStatus.textContent = 'Running';
break;
case 'file_start':
updateFileProgress(data.index, 0, 'running', 'Compressing…', '', '');
// Scroll to active item
const activeItem = $(`fpi-${data.index}`);
if (activeItem) {
activeItem.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;
let detail = '';
if (data.elapsed_secs > 0 && data.duration_secs > 0) {
detail = `${fmtTime(data.elapsed_secs)} / ${fmtTime(data.duration_secs)}`;
}
updateFileProgress(data.index, pct, 'running', 'Compressing…', detail, '');
// Update overall progress
const overallPct = ((doneCount + (pct / 100)) / files.length) * 100;
setOverallProgress(overallPct);
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);
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');
state.compressionResults.push({ ...data, status: 'error' });
announce(`Error compressing file ${files[data.index]?.name}: ${data.message}`);
break;
}
case 'done':
state.eventSource.close();
els.progStatus.textContent = 'Complete';
setOverallProgress(100);
els.cancelBtn.disabled = true;
announce('All compression operations complete.');
showResults('done');
break;
case 'cancelled':
state.eventSource.close();
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 = () => {
if (state.eventSource.readyState === EventSource.CLOSED) return;
console.error('SSE connection error');
};
}
// ─── Cancel ───────────────────────────────────────────────────────────────────
els.cancelBtn.addEventListener('click', async () => {
if (!state.currentJobId) return;
const confirmed = window.confirm(
'Cancel all compression operations? Any files currently being processed will be deleted.'
);
if (!confirmed) 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);
}
});
// ─── Results ──────────────────────────────────────────────────────────────────
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. Any completed files are listed below.
</p>`;
}
if (results.length === 0 && 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">${escHtml(r.filename)}</div>
<div class="result-meta"> ${escHtml(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">${escHtml(r.filename)}</div>
<div class="result-meta" style="color:var(--text-danger)">${escHtml(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' });
}
// ─── Restart ──────────────────────────────────────────────────────────────────
els.restartBtn.addEventListener('click', () => {
// Reset state
state.scannedFiles = [];
state.selectedPaths.clear();
state.currentJobId = null;
state.compressionResults = [];
if (state.eventSource) { state.eventSource.close(); state.eventSource = null; }
// Reset UI
els.sectionFiles.hidden = true;
els.sectionProgress.hidden = true;
els.sectionResults.hidden = true;
els.fileTbody.innerHTML = '';
els.fileProgressList.innerHTML = '';
els.scanStatus.textContent = '';
els.compressBtn.innerHTML = '<span class="btn-icon-prefix" aria-hidden="true">⚡</span> Compress Selected Files';
els.compressBtn.disabled = true;
els.cancelBtn.disabled = false;
els.cancelBtn.textContent = '✕ Cancel Compression';
// Scroll to top
document.getElementById('section-config').scrollIntoView({ behavior: 'smooth' });
els.dirInput.focus();
announce('Session reset. Ready to scan again.');
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
function escHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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;
if (h > 0) return `${h}:${pad(m)}:${pad(sec)}`;
return `${m}:${pad(sec)}`;
}
function pad(n) {
return String(n).padStart(2, '0');
}
// ─── Init ─────────────────────────────────────────────────────────────────────
initTheme();
initBrowser();
initScan();
initStreamControls();
initCompress();
initSettings();
tryRestoreSession();

View 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();
});
}

View 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.');
});
}

View 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 0100
*/
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 0100
* @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
View 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.');
});
}

View 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
}
}

View 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
View 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
View 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);
});
}

View 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');
});
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* 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');
}