feat(history): add Recently Completed section to frontend dashboard

This commit is contained in:
2026-05-17 12:05:39 +01:00
parent ddcfbda0c2
commit eb321312dc
3 changed files with 427 additions and 0 deletions

View File

@@ -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: <a href="' + escapeHtml(item.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(item.seriesName) + '</a>';
} 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: <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;
}

View File

@@ -83,6 +83,24 @@
<div id="downloads-list" class="downloads-list"></div>
</div>
<div class="history-container" id="history-container">
<div class="history-header">
<h2>Recently Completed</h2>
<div class="history-controls">
<label class="history-days-label" for="history-days">Last</label>
<input type="number" id="history-days" class="history-days-input" value="7" min="1" max="90">
<span class="history-days-label">days</span>
<button id="history-refresh-btn" class="history-refresh-btn" title="Refresh history">&#8635;</button>
</div>
</div>
<div id="history-loading" class="history-loading" style="display: none;">Loading history...</div>
<div id="history-error" class="history-error" style="display: none;"></div>
<div id="no-history" class="no-history" style="display: none;">
<p>No completed downloads found in this period.</p>
</div>
<div id="history-list" class="history-list"></div>
</div>
<footer class="app-footer">
<p>Ensure your media is tagged with your username in Sonarr/Radarr to match downloads to users.</p>
</footer>

View File

@@ -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;