diff --git a/public/app.js b/public/app.js index 8c12ad8..e63e5e1 100644 --- a/public/app.js +++ b/public/app.js @@ -5,6 +5,11 @@ let showAll = false; let csrfToken = null; // double-submit CSRF token, sent as X-CSRF-Token on mutating requests const SPLASH_MIN_MS = 1200; // minimum splash display time +// History section state +let historyDays = parseInt(localStorage.getItem('sofarr-history-days'), 10) || 7; +let historyRefreshHandle = null; +const HISTORY_REFRESH_MS = 5 * 60 * 1000; // auto-refresh history every 5 min + // SSE stream state let sseSource = null; let sseReconnectTimer = null; @@ -20,6 +25,7 @@ const SSE_RECONNECT_MS = 3000; // browser already retries, but we add explicit b document.addEventListener('DOMContentLoaded', () => { checkAuthentication(); initThemeSwitcher(); + initHistoryControls(); document.getElementById('login-form').addEventListener('submit', handleLogin); document.getElementById('logout-btn').addEventListener('click', handleLogout); @@ -209,6 +215,7 @@ async function handleLogin(e) { async function handleLogout() { try { stopSSE(); + stopHistoryRefresh(); if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; } await fetch('/api/auth/logout', { method: 'POST', @@ -217,6 +224,7 @@ async function handleLogout() { currentUser = null; csrfToken = null; downloads = []; + clearHistory(); showLogin(); } catch (err) { console.error('Logout failed:', err); @@ -238,6 +246,11 @@ function showDashboard() { sp.style.display = 'none'; sp.innerHTML = ''; document.getElementById('admin-controls').style.display = isAdmin ? 'flex' : 'none'; + // Initialise days input from saved value, then load history + const daysInput = document.getElementById('history-days'); + if (daysInput) daysInput.value = historyDays; + loadHistory(); + startHistoryRefresh(); } function showLoginError(message) { @@ -784,3 +797,197 @@ function hideLoading() { const loading = document.getElementById('loading'); loading.style.display = 'none'; } + +// ============================================================================= +// History section +// ============================================================================= + +function initHistoryControls() { + const daysInput = document.getElementById('history-days'); + const refreshBtn = document.getElementById('history-refresh-btn'); + if (daysInput) { + daysInput.addEventListener('change', () => { + const v = parseInt(daysInput.value, 10); + if (v > 0 && v <= 90) { + historyDays = v; + localStorage.setItem('sofarr-history-days', v); + loadHistory(); + } + }); + } + if (refreshBtn) { + refreshBtn.addEventListener('click', () => loadHistory(true)); + } +} + +function startHistoryRefresh() { + stopHistoryRefresh(); + historyRefreshHandle = setInterval(() => loadHistory(), HISTORY_REFRESH_MS); +} + +function stopHistoryRefresh() { + if (historyRefreshHandle) { + clearInterval(historyRefreshHandle); + historyRefreshHandle = null; + } +} + +function clearHistory() { + document.getElementById('history-list').innerHTML = ''; + document.getElementById('no-history').style.display = 'none'; + document.getElementById('history-error').style.display = 'none'; +} + +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 params = new URLSearchParams({ days: historyDays }); + if (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(); + loadingEl.style.display = 'none'; + renderHistory(data.history || []); + } catch (err) { + loadingEl.style.display = 'none'; + errorEl.textContent = 'Failed to load history.'; + errorEl.style.display = 'block'; + console.error('[History] Load error:', err); + } +} + +function renderHistory(items) { + const listEl = document.getElementById('history-list'); + const noHistoryEl = document.getElementById('no-history'); + listEl.innerHTML = ''; + if (!items.length) { + noHistoryEl.style.display = 'block'; + return; + } + noHistoryEl.style.display = 'none'; + items.forEach(item => listEl.appendChild(createHistoryCard(item))); +} + +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.instanceName) { + const instBadge = document.createElement('span'); + instBadge.className = 'history-instance-badge'; + instBadge.textContent = item.instanceName; + header.appendChild(instBadge); + } + + if (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 (isAdmin && item.arrLink) { + p.innerHTML = 'Series: ' + escapeHtml(item.seriesName) + ''; + } else { + p.textContent = 'Series: ' + item.seriesName; + } + info.appendChild(p); + } + if (item.movieName) { + const p = document.createElement('p'); + p.className = 'history-media-name'; + if (isAdmin && item.arrLink) { + p.innerHTML = 'Movie: ' + escapeHtml(item.movieName) + ''; + } 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; +} diff --git a/public/index.html b/public/index.html index ae54cd0..ef5a0ab 100644 --- a/public/index.html +++ b/public/index.html @@ -83,6 +83,24 @@
+
+
+

Recently Completed

+
+ + + days + +
+
+ + + +
+
+ diff --git a/public/style.css b/public/style.css index 88b0f77..cfa3332 100644 --- a/public/style.css +++ b/public/style.css @@ -552,6 +552,208 @@ body { word-break: break-word; } +/* ===== Recently Completed History ===== */ +.history-container { + max-width: 1200px; + margin: 24px auto 0; + padding: 0 16px; +} + +.history-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.history-header h2 { + margin: 0; + font-size: 1.2rem; + color: var(--text-primary); + flex: 1 1 auto; +} + +.history-controls { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: nowrap; +} + +.history-days-label { + font-size: 0.85rem; + color: var(--text-secondary); +} + +.history-days-input { + width: 52px; + padding: 3px 6px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--surface); + color: var(--text-primary); + font-size: 0.85rem; + text-align: center; +} + +.history-refresh-btn { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-secondary); + cursor: pointer; + font-size: 1rem; + padding: 2px 7px; + line-height: 1.4; + transition: background 0.15s, color 0.15s; +} + +.history-refresh-btn:hover { + background: var(--hover-bg); + color: var(--text-primary); +} + +.history-loading, +.history-error, +.no-history { + color: var(--text-secondary); + font-size: 0.9rem; + padding: 8px 0; +} + +.history-error { + color: var(--error, #e74c3c); +} + +.history-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.history-card { + display: flex; + gap: 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 12px; + transition: background 0.2s; + align-items: flex-start; +} + +.history-card.failed { + border-left: 3px solid var(--error, #e74c3c); +} + +.history-card.imported { + border-left: 3px solid var(--success, #27ae60); +} + +.history-cover { + flex: 0 0 48px; + width: 48px; +} + +.history-cover img { + width: 48px; + height: 68px; + object-fit: cover; + border-radius: 4px; + display: block; +} + +.history-info { + flex: 1 1 auto; + min-width: 0; +} + +.history-card-header { + display: flex; + flex-wrap: wrap; + gap: 5px; + align-items: center; + margin-bottom: 4px; +} + +.history-type-badge, +.history-outcome-badge, +.history-instance-badge { + font-size: 0.72rem; + font-weight: 600; + padding: 2px 7px; + border-radius: 10px; + white-space: nowrap; +} + +.history-type-badge.series { + background: var(--badge-series-bg, #2980b9); + color: #fff; +} + +.history-type-badge.movie { + background: var(--badge-movie-bg, #8e44ad); + color: #fff; +} + +.history-outcome-badge.imported { + background: var(--success, #27ae60); + color: #fff; +} + +.history-outcome-badge.failed { + background: var(--error, #e74c3c); + color: #fff; +} + +.history-instance-badge { + background: var(--tag-bg, #ecf0f1); + color: var(--text-secondary); + border: 1px solid var(--border); +} + +.history-title { + font-size: 0.9rem; + font-weight: 600; + margin: 0 0 2px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.history-media-name { + font-size: 0.82rem; + color: var(--text-secondary); + margin: 0 0 4px; +} + +.history-details { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 4px; +} + +.history-failure-message { + font-size: 0.78rem; + color: var(--error, #e74c3c); + background: var(--error-bg, rgba(231, 76, 60, 0.08)); + border-radius: 4px; + padding: 3px 7px; + flex-basis: 100%; +} + +@media (max-width: 480px) { + .history-cover { + display: none; + } + .history-title { + white-space: normal; + } +} + /* ===== Footer ===== */ .app-footer { margin-top: 12px;