Migrate frontend from monolithic app.js to vanilla ES modules
Build and Push Docker Image / build (push) Successful in 35s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 49s
CI / Security audit (push) Successful in 1m9s
CI / Tests & coverage (push) Successful in 1m23s

- Delete React files (App.jsx, main.jsx, App.css)
- Create modular vanilla JS structure in client/src/:
  - state.js (global state object)
  - api.js (all fetch calls)
  - sse.js (SSE connection management)
  - ui/auth.js (authentication UI)
  - ui/downloads.js (downloads rendering)
  - ui/history.js (history section)
  - ui/statusPanel.js (status panel)
  - ui/webhooks.js (webhook management)
  - ui/filters.js (download client filter)
  - ui/theme.js (theme switching)
  - ui/tabs.js (tab navigation)
  - utils/format.js (formatting utilities)
  - utils/storage.js (localStorage helpers)
  - main.js (DOMContentLoaded bootstrap)
- Update vite.config.js for vanilla build outputting to ../public/app.js
- Build succeeds: 14 modules, 43.88 kB output
This commit is contained in:
2026-05-20 23:30:24 +01:00
parent a38fc4a8ce
commit 8dc105ff3e
19 changed files with 2173 additions and 2854 deletions
+74
View File
@@ -0,0 +1,74 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
export function formatSize(size) {
if (!size) return 'N/A';
// If already a formatted string (e.g., "21.5 GB"), return as-is
if (typeof size === 'string') {
return size;
}
// If it's a number (bytes), format it
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(size) / Math.log(1024));
return Math.round(size / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}
export function formatSpeed(bytesPerSecond) {
if (!bytesPerSecond || bytesPerSecond === 0) return '0 B/s';
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
let value = bytesPerSecond;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
return `${value.toFixed(2)} ${units[unitIndex]}`;
}
export function formatDate(dateString) {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleString();
}
export function formatTimeAgo(timestamp) {
if (!timestamp) return 'Never';
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return seconds + 's ago';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return minutes + 'm ago';
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + 'h ago';
return Math.floor(hours / 24) + 'd ago';
}
export function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Build an episode-info element for series downloads/history.
// Single episode: "S01E05 — Episode Title"
// Multiple episodes: "Multiple episodes" with tooltip listing them all.
// Returns null if no episode data.
export function formatEpisodeInfo(episodes) {
if (!episodes || episodes.length === 0) return null;
const el = document.createElement('p');
el.className = 'episode-info';
if (episodes.length === 1) {
const ep = episodes[0];
const code = 'S' + String(ep.season).padStart(2, '0') + 'E' + String(ep.episode).padStart(2, '0');
el.textContent = ep.title ? code + ' \u2014 ' + ep.title : code;
} else {
el.textContent = 'Multiple episodes';
el.classList.add('multi-episode');
const lines = episodes.map(ep => {
const code = 'S' + String(ep.season).padStart(2, '0') + 'E' + String(ep.episode).padStart(2, '0');
return ep.title ? code + ' \u2014 ' + ep.title : code;
});
el.setAttribute('data-tooltip', lines.join('\n'));
}
return el;
}
+76
View File
@@ -0,0 +1,76 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { state } from '../state.js';
// Migration from old single-select to new multi-select format
(function migrateDownloadClientFilter() {
const oldSelection = localStorage.getItem('sofarr-download-client');
if (oldSelection && oldSelection !== 'all') {
try {
state.selectedDownloadClients = [oldSelection];
localStorage.setItem('sofarr-download-clients', JSON.stringify(state.selectedDownloadClients));
localStorage.removeItem('sofarr-download-client');
} catch (e) {
console.error('[Migration] Failed to migrate download client filter:', e);
}
} else {
try {
const newSelection = localStorage.getItem('sofarr-download-clients');
state.selectedDownloadClients = newSelection ? JSON.parse(newSelection) : [];
} catch (e) {
console.error('[Migration] Failed to load download client filter:', e);
state.selectedDownloadClients = [];
}
}
})();
// Load history days from localStorage
(function loadHistorySettings() {
try {
const savedDays = localStorage.getItem('sofarr-history-days');
if (savedDays) {
state.historyDays = parseInt(savedDays, 10) || 7;
}
} catch (e) {
console.error('[Storage] Failed to load history days:', e);
}
})();
// Load ignore available setting from localStorage
(function loadIgnoreAvailable() {
try {
const saved = localStorage.getItem('sofarr-ignore-available');
state.ignoreAvailable = saved === 'true';
} catch (e) {
console.error('[Storage] Failed to load ignore available:', e);
}
})();
// Export helper functions for localStorage operations
export function saveHistoryDays(days) {
localStorage.setItem('sofarr-history-days', days);
}
export function saveIgnoreAvailable(value) {
localStorage.setItem('sofarr-ignore-available', value);
}
export function saveDownloadClients(clients) {
localStorage.setItem('sofarr-download-clients', JSON.stringify(clients));
}
export function getTheme() {
return localStorage.getItem('sofarr-theme') || 'light';
}
export function saveTheme(theme) {
localStorage.setItem('sofarr-theme', theme);
}
export function getActiveTab() {
return localStorage.getItem('sofarr-active-tab') || 'downloads';
}
export function saveActiveTab(tab) {
localStorage.setItem('sofarr-active-tab', tab);
}