feat(history): add Recently Completed section to frontend dashboard
This commit is contained in:
207
public/app.js
207
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: <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;
|
||||
}
|
||||
|
||||
@@ -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">↻</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>
|
||||
|
||||
202
public/style.css
202
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;
|
||||
|
||||
Reference in New Issue
Block a user