Migrate frontend from monolithic app.js to vanilla ES modules
- 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:
@@ -0,0 +1,244 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
import { state, HISTORY_REFRESH_MS } from '../state.js';
|
||||
import { loadHistory as apiLoadHistory } from '../api.js';
|
||||
import { saveHistoryDays, saveIgnoreAvailable } from '../utils/storage.js';
|
||||
import { formatDate, formatEpisodeInfo, escapeHtml } from '../utils/format.js';
|
||||
|
||||
export function initHistoryControls() {
|
||||
const daysInput = document.getElementById('history-days');
|
||||
const refreshBtn = document.getElementById('history-refresh-btn');
|
||||
const ignoreToggle = document.getElementById('ignore-available-toggle');
|
||||
if (daysInput) {
|
||||
daysInput.addEventListener('change', () => {
|
||||
const v = parseInt(daysInput.value, 10);
|
||||
if (v > 0 && v <= 90) {
|
||||
historyDays = v;
|
||||
saveHistoryDays(v);
|
||||
loadHistory(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => loadHistory(true));
|
||||
}
|
||||
if (ignoreToggle) {
|
||||
ignoreToggle.checked = ignoreAvailable;
|
||||
ignoreToggle.addEventListener('change', () => {
|
||||
ignoreAvailable = ignoreToggle.checked;
|
||||
saveIgnoreAvailable(ignoreAvailable);
|
||||
renderHistory(lastHistoryItems);
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for history reload events from other modules
|
||||
document.addEventListener('historyReload', () => {
|
||||
loadHistory(true);
|
||||
});
|
||||
}
|
||||
|
||||
export function startHistoryRefresh() {
|
||||
stopHistoryRefresh();
|
||||
state.historyRefreshHandle = setInterval(() => loadHistory(), HISTORY_REFRESH_MS);
|
||||
}
|
||||
|
||||
export function stopHistoryRefresh() {
|
||||
if (state.historyRefreshHandle) {
|
||||
clearInterval(state.historyRefreshHandle);
|
||||
state.historyRefreshHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearHistory() {
|
||||
state.lastHistoryItems = [];
|
||||
document.getElementById('history-list').innerHTML = '';
|
||||
document.getElementById('no-history').style.display = 'none';
|
||||
document.getElementById('history-error').style.display = 'none';
|
||||
}
|
||||
|
||||
export async function loadHistory(forceRefresh = false) {
|
||||
const listEl = document.getElementById('history-list');
|
||||
const loadingEl = document.getElementById('history-loading');
|
||||
const errorEl = document.getElementById('history-error');
|
||||
const noHistoryEl = document.getElementById('no-history');
|
||||
|
||||
loadingEl.style.display = 'block';
|
||||
errorEl.style.display = 'none';
|
||||
noHistoryEl.style.display = 'none';
|
||||
|
||||
try {
|
||||
const result = await apiLoadHistory(forceRefresh);
|
||||
loadingEl.style.display = 'none';
|
||||
if (result.success) {
|
||||
state.lastHistoryItems = result.history;
|
||||
renderHistory(state.lastHistoryItems);
|
||||
} else {
|
||||
errorEl.textContent = result.error || 'Failed to load history.';
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
loadingEl.style.display = 'none';
|
||||
errorEl.textContent = 'Failed to load history.';
|
||||
errorEl.style.display = 'block';
|
||||
console.error('[History] Load error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderHistory(items) {
|
||||
const listEl = document.getElementById('history-list');
|
||||
const noHistoryEl = document.getElementById('no-history');
|
||||
listEl.innerHTML = '';
|
||||
const visible = state.ignoreAvailable
|
||||
? items.filter(item => !(item.outcome === 'failed' && item.availableForUpgrade))
|
||||
: items;
|
||||
if (!visible.length) {
|
||||
noHistoryEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
noHistoryEl.style.display = 'none';
|
||||
visible.forEach(item => listEl.appendChild(createHistoryCard(item)));
|
||||
}
|
||||
|
||||
export function createHistoryCard(item) {
|
||||
const card = document.createElement('div');
|
||||
card.className = `history-card ${item.type} ${item.outcome}`;
|
||||
|
||||
if (item.coverArt) {
|
||||
const coverDiv = document.createElement('div');
|
||||
coverDiv.className = 'history-cover';
|
||||
const img = document.createElement('img');
|
||||
img.src = '/api/dashboard/cover-art?url=' + encodeURIComponent(item.coverArt);
|
||||
img.alt = item.movieName || item.seriesName || item.title;
|
||||
img.loading = 'lazy';
|
||||
coverDiv.appendChild(img);
|
||||
card.appendChild(coverDiv);
|
||||
}
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'history-info';
|
||||
|
||||
// Header row: type badge + outcome badge
|
||||
const header = document.createElement('div');
|
||||
header.className = 'history-card-header';
|
||||
|
||||
const typeBadge = document.createElement('span');
|
||||
typeBadge.className = `history-type-badge ${item.type}`;
|
||||
typeBadge.textContent = item.type === 'series' ? '📺 Series' : '🎬 Movie';
|
||||
header.appendChild(typeBadge);
|
||||
|
||||
const outcomeBadge = document.createElement('span');
|
||||
outcomeBadge.className = `history-outcome-badge ${item.outcome}`;
|
||||
outcomeBadge.textContent = item.outcome === 'imported' ? '✓ Imported' : '✗ Failed';
|
||||
header.appendChild(outcomeBadge);
|
||||
|
||||
if (item.availableForUpgrade) {
|
||||
const upgradeBadge = document.createElement('span');
|
||||
upgradeBadge.className = 'history-upgrade-badge';
|
||||
upgradeBadge.title = 'A previous version of this item is available. An upgrade download has failed.';
|
||||
upgradeBadge.textContent = '⬆ Available';
|
||||
header.appendChild(upgradeBadge);
|
||||
}
|
||||
|
||||
if (item.instanceName) {
|
||||
const instBadge = document.createElement('span');
|
||||
instBadge.className = 'history-instance-badge';
|
||||
instBadge.textContent = item.instanceName;
|
||||
header.appendChild(instBadge);
|
||||
}
|
||||
|
||||
if (state.showAll && item.tagBadges && item.tagBadges.length > 0) {
|
||||
const unmatched = item.tagBadges.filter(b => !b.matchedUser);
|
||||
const matched = item.tagBadges.filter(b => b.matchedUser);
|
||||
for (const b of unmatched) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'download-user-badge unmatched';
|
||||
badge.textContent = b.label;
|
||||
header.appendChild(badge);
|
||||
}
|
||||
for (const b of matched) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'download-user-badge';
|
||||
badge.textContent = b.matchedUser;
|
||||
header.appendChild(badge);
|
||||
}
|
||||
} else if (item.matchedUserTag) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'download-user-badge';
|
||||
badge.textContent = item.matchedUserTag;
|
||||
header.appendChild(badge);
|
||||
}
|
||||
|
||||
info.appendChild(header);
|
||||
|
||||
// Title
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'history-title';
|
||||
title.textContent = item.title;
|
||||
info.appendChild(title);
|
||||
|
||||
// Series/movie name with optional arr link
|
||||
if (item.seriesName) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'history-media-name';
|
||||
if (state.isAdmin && item.arrLink) {
|
||||
p.innerHTML = 'Series: <a href="' + escapeHtml(item.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(item.seriesName) + '</a>';
|
||||
} else {
|
||||
p.textContent = 'Series: ' + item.seriesName;
|
||||
}
|
||||
info.appendChild(p);
|
||||
const epEl = formatEpisodeInfo(item.episodes);
|
||||
if (epEl) info.appendChild(epEl);
|
||||
}
|
||||
if (item.movieName) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'history-media-name';
|
||||
if (state.isAdmin && item.arrLink) {
|
||||
p.innerHTML = 'Movie: <a href="' + escapeHtml(item.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(item.movieName) + '</a>';
|
||||
} else {
|
||||
p.textContent = 'Movie: ' + item.movieName;
|
||||
}
|
||||
info.appendChild(p);
|
||||
}
|
||||
|
||||
// Detail pills
|
||||
const details = document.createElement('div');
|
||||
details.className = 'history-details';
|
||||
|
||||
if (item.completedAt) {
|
||||
details.appendChild(createDetailItem('Completed', formatDate(item.completedAt)));
|
||||
}
|
||||
if (item.quality) {
|
||||
details.appendChild(createDetailItem('Quality', item.quality));
|
||||
}
|
||||
|
||||
// Failed imports: show failure message
|
||||
if (item.outcome === 'failed' && item.failureMessage) {
|
||||
const failItem = document.createElement('div');
|
||||
failItem.className = 'history-failure-message';
|
||||
failItem.textContent = item.failureMessage;
|
||||
details.appendChild(failItem);
|
||||
}
|
||||
|
||||
info.appendChild(details);
|
||||
card.appendChild(info);
|
||||
return card;
|
||||
}
|
||||
|
||||
function createDetailItem(label, value) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'detail-item';
|
||||
item.dataset.label = label;
|
||||
|
||||
const labelSpan = document.createElement('span');
|
||||
labelSpan.className = 'detail-label';
|
||||
labelSpan.textContent = label;
|
||||
|
||||
const valueSpan = document.createElement('span');
|
||||
valueSpan.className = 'detail-value';
|
||||
valueSpan.textContent = value;
|
||||
|
||||
item.appendChild(labelSpan);
|
||||
item.appendChild(valueSpan);
|
||||
|
||||
return item;
|
||||
}
|
||||
Reference in New Issue
Block a user