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
+292
View File
@@ -0,0 +1,292 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { state } from './state.js';
export async function checkAuthentication() {
try {
// Fetch both auth state and a fresh CSRF token in parallel
const [meRes, csrfRes] = await Promise.all([
fetch('/api/auth/me'),
fetch('/api/auth/csrf')
]);
const data = await meRes.json();
const csrfData = await csrfRes.json();
if (csrfData.csrfToken) state.csrfToken = csrfData.csrfToken;
if (data.authenticated) {
state.currentUser = data.user;
state.isAdmin = !!data.user.isAdmin;
return { authenticated: true, user: data.user };
} else {
return { authenticated: false };
}
} catch (err) {
console.error('Authentication check failed:', err);
return { authenticated: false };
}
}
export async function handleLogin(username, password, rememberMe) {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password, rememberMe })
});
const data = await response.json();
if (data.success) {
state.currentUser = data.user;
state.isAdmin = !!data.user.isAdmin;
// Store CSRF token returned by login for use in subsequent requests
if (data.csrfToken) state.csrfToken = data.csrfToken;
return { success: true, user: data.user };
} else {
return { success: false, error: data.error || 'Login failed' };
}
} catch (err) {
console.error(err);
return { success: false, error: 'Login failed. Please try again.' };
}
}
export async function handleLogout() {
try {
await fetch('/api/auth/logout', {
method: 'POST',
headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : {}
});
currentUser = null;
csrfToken = null;
return { success: true };
} catch (err) {
console.error('Logout failed:', err);
return { success: false };
}
}
export async function loadHistory(forceRefresh = false) {
try {
const params = new URLSearchParams({ days: state.historyDays });
if (state.showAll) params.set('showAll', 'true');
if (forceRefresh) params.set('_t', Date.now());
const res = await fetch(`/api/history/recent?${params}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return { success: true, history: data.history || [] };
} catch (err) {
console.error('[History] Load error:', err);
return { success: false, error: 'Failed to load history.' };
}
}
export async function handleBlocklistSearch(download) {
try {
const res = await fetch('/api/dashboard/blocklist-search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({
arrQueueId: download.arrQueueId,
arrType: download.arrType,
arrInstanceUrl: download.arrInstanceUrl,
arrInstanceKey: download.arrInstanceKey,
arrContentId: download.arrContentId,
arrContentType: download.arrContentType
})
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${res.status}`);
}
return { success: true };
} catch (err) {
console.error('[Blocklist] Error:', err);
throw err;
}
}
export async function loadAppVersion() {
try {
const res = await fetch('/health');
const data = await res.json();
return data.version || null;
} catch (err) {
return null;
}
}
export async function fetchWebhookMetrics() {
try {
const res = await fetch('/api/dashboard/webhook-metrics');
if (!res.ok) return null;
return await res.json();
} catch (err) {
return null;
}
}
export async function fetchWebhookStatus() {
try {
// Fetch metrics in parallel
const metricsPromise = fetchWebhookMetrics();
// Fetch Sonarr notifications
let sonarrEnabled = false;
let sonarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
try {
const sonarrRes = await fetch('/api/sonarr/notifications');
if (sonarrRes.ok) {
const sonarrData = await sonarrRes.json();
const sonarrSofarr = sonarrData.find(n => n.name === 'Sofarr');
sonarrEnabled = !!sonarrSofarr;
if (sonarrSofarr) {
sonarrTriggers = {
onGrab: sonarrSofarr.onGrab,
onDownload: sonarrSofarr.onDownload,
onImport: sonarrSofarr.onImport,
onUpgrade: sonarrSofarr.onUpgrade
};
}
}
} catch (err) {
// Sonarr not configured
}
// Fetch Radarr notifications
let radarrEnabled = false;
let radarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
try {
const radarrRes = await fetch('/api/radarr/notifications');
if (radarrRes.ok) {
const radarrData = await radarrRes.json();
const radarrSofarr = radarrData.find(n => n.name === 'Sofarr');
radarrEnabled = !!radarrSofarr;
if (radarrSofarr) {
radarrTriggers = {
onGrab: radarrSofarr.onGrab,
onDownload: radarrSofarr.onDownload,
onImport: radarrSofarr.onImport,
onUpgrade: radarrSofarr.onUpgrade
};
}
}
} catch (err) {
// Radarr not configured
}
state.webhookMetrics = await metricsPromise;
// Find instance stats
const instanceEntries = state.webhookMetrics ? Object.entries(state.webhookMetrics.instances || {}) : [];
const sonarrStats = instanceEntries.find(([url]) => url.includes('sonarr'))?.[1] || null;
const radarrStats = instanceEntries.find(([url]) => url.includes('radarr'))?.[1] || null;
state.sonarrWebhook = { enabled: sonarrEnabled, triggers: sonarrTriggers, stats: sonarrStats };
state.radarrWebhook = { enabled: radarrEnabled, triggers: radarrTriggers, stats: radarrStats };
return { success: true };
} catch (err) {
console.error('Failed to fetch webhook status:', err);
return { success: false };
}
}
export async function enableSonarrWebhook() {
try {
const res = await fetch('/api/sonarr/notifications/sofarr-webhook', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken || '' }
});
if (!res.ok) throw new Error('Failed to enable');
await fetchWebhookStatus();
return { success: true };
} catch (err) {
console.error('Failed to enable Sonarr webhook:', err);
return { success: false, error: err.message };
}
}
export async function enableRadarrWebhook() {
try {
const res = await fetch('/api/radarr/notifications/sofarr-webhook', {
method: 'POST',
headers: { 'X-CSRF-Token': state.csrfToken || '' }
});
if (!res.ok) throw new Error('Failed to enable');
await fetchWebhookStatus();
return { success: true };
} catch (err) {
console.error('Failed to enable Radarr webhook:', err);
return { success: false, error: err.message };
}
}
export async function testSonarrWebhook() {
try {
const sonarrRes = await fetch('/api/sonarr/notifications');
if (!sonarrRes.ok) throw new Error('Failed to fetch notifications');
const sonarrData = await sonarrRes.json();
const sonarrSofarr = sonarrData.find(n => n.name === 'Sofarr');
if (!sonarrSofarr) throw new Error('Sofarr webhook not found');
const res = await fetch('/api/sonarr/notifications/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': state.csrfToken || ''
},
body: JSON.stringify(sonarrSofarr)
});
if (!res.ok) throw new Error('Test failed');
await fetchWebhookStatus();
return { success: true };
} catch (err) {
console.error('Failed to test Sonarr webhook:', err);
return { success: false, error: err.message };
}
}
export async function testRadarrWebhook() {
try {
const radarrRes = await fetch('/api/radarr/notifications');
if (!radarrRes.ok) throw new Error('Failed to fetch notifications');
const radarrData = await radarrRes.json();
const radarrSofarr = radarrData.find(n => n.name === 'Sofarr');
if (!radarrSofarr) throw new Error('Sofarr webhook not found');
const res = await fetch('/api/radarr/notifications/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': state.csrfToken || ''
},
body: JSON.stringify(radarrSofarr)
});
if (!res.ok) throw new Error('Test failed');
await fetchWebhookStatus();
return { success: true };
} catch (err) {
console.error('Failed to test Radarr webhook:', err);
return { success: false, error: err.message };
}
}
export async function refreshStatusPanel() {
try {
const res = await fetch('/api/dashboard/status');
if (!res.ok) throw new Error('Failed to fetch status: ' + res.status);
const data = await res.json();
return { success: true, data };
} catch (err) {
console.error('[Status] Error fetching status:', err);
return { success: false, error: err.message };
}
}