Add multi-select download client filter with client type display
All checks were successful
Build and Push Docker Image / build (push) Successful in 24s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 45s
CI / Security audit (push) Successful in 1m23s
CI / Tests & coverage (push) Successful in 1m35s

This commit is contained in:
2026-05-19 23:41:43 +01:00
parent 7195a09562
commit be791ed044
3 changed files with 307 additions and 31 deletions

View File

@@ -2,12 +2,34 @@
let currentUser = null; let currentUser = null;
let downloads = []; let downloads = [];
let downloadClients = []; // List of download clients from server (for ordering/filtering) let downloadClients = []; // List of download clients from server (for ordering/filtering)
let selectedDownloadClient = localStorage.getItem('sofarr-download-client') || 'all'; // Selected client filter let selectedDownloadClients = []; // Array of selected client IDs for multi-select filter
let isAdmin = false; let isAdmin = false;
let showAll = false; let showAll = false;
let csrfToken = null; // double-submit CSRF token, sent as X-CSRF-Token on mutating requests let csrfToken = null; // double-submit CSRF token, sent as X-CSRF-Token on mutating requests
const SPLASH_MIN_MS = 1200; // minimum splash display time const SPLASH_MIN_MS = 1200; // minimum splash display time
// Migration from old single-select to new multi-select format
(function migrateDownloadClientFilter() {
const oldSelection = localStorage.getItem('sofarr-download-client');
if (oldSelection && oldSelection !== 'all') {
try {
selectedDownloadClients = [oldSelection];
localStorage.setItem('sofarr-download-clients', JSON.stringify(selectedDownloadClients));
localStorage.removeItem('sofarr-download-client');
} catch (e) {
console.error('[Migration] Failed to migrate download client filter:', e);
}
} else {
try {
const newSelection = localStorage.getItem('sofarr-download-clients');
selectedDownloadClients = newSelection ? JSON.parse(newSelection) : [];
} catch (e) {
console.error('[Migration] Failed to load download client filter:', e);
selectedDownloadClients = [];
}
}
})();
// History section state // History section state
let historyDays = parseInt(localStorage.getItem('sofarr-history-days'), 10) || 7; let historyDays = parseInt(localStorage.getItem('sofarr-history-days'), 10) || 7;
let historyRefreshHandle = null; let historyRefreshHandle = null;
@@ -361,10 +383,10 @@ function renderDownloads() {
const downloadsList = document.getElementById('downloads-list'); const downloadsList = document.getElementById('downloads-list');
const noDownloads = document.getElementById('no-downloads'); const noDownloads = document.getElementById('no-downloads');
// Filter downloads by selected client // Filter downloads by selected clients
let filteredDownloads = downloads; let filteredDownloads = downloads;
if (selectedDownloadClient !== 'all') { if (selectedDownloadClients.length > 0) {
filteredDownloads = downloads.filter(d => d.instanceId === selectedDownloadClient); filteredDownloads = downloads.filter(d => selectedDownloadClients.includes(d.instanceId));
} }
// Sort downloads by client order (matching the order in downloadClients) // Sort downloads by client order (matching the order in downloadClients)
@@ -1051,43 +1073,141 @@ function initHistoryControls() {
// ============================================================================= // =============================================================================
function initDownloadClientFilter() { function initDownloadClientFilter() {
const filterSelect = document.getElementById('download-client-filter'); const dropdownBtn = document.getElementById('download-client-dropdown-btn');
if (filterSelect) { const dropdown = document.getElementById('download-client-dropdown');
// Set initial value from localStorage const selectAllBtn = document.getElementById('download-client-select-all');
filterSelect.value = selectedDownloadClient; const deselectAllBtn = document.getElementById('download-client-deselect-all');
filterSelect.addEventListener('change', () => {
selectedDownloadClient = filterSelect.value; if (dropdownBtn && dropdown) {
localStorage.setItem('sofarr-download-client', selectedDownloadClient); // Toggle dropdown
dropdownBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = dropdown.classList.toggle('open');
dropdownBtn.classList.toggle('open', isOpen);
dropdownBtn.setAttribute('aria-expanded', isOpen);
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!dropdown.contains(e.target) && !dropdownBtn.contains(e.target)) {
dropdown.classList.remove('open');
dropdownBtn.classList.remove('open');
dropdownBtn.setAttribute('aria-expanded', 'false');
}
});
// Close dropdown on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
dropdown.classList.remove('open');
dropdownBtn.classList.remove('open');
dropdownBtn.setAttribute('aria-expanded', 'false');
}
});
}
if (selectAllBtn) {
selectAllBtn.addEventListener('click', (e) => {
e.stopPropagation();
selectedDownloadClients = downloadClients.map(c => c.id);
localStorage.setItem('sofarr-download-clients', JSON.stringify(selectedDownloadClients));
updateDownloadClientFilter();
renderDownloads();
});
}
if (deselectAllBtn) {
deselectAllBtn.addEventListener('click', (e) => {
e.stopPropagation();
selectedDownloadClients = [];
localStorage.setItem('sofarr-download-clients', JSON.stringify(selectedDownloadClients));
updateDownloadClientFilter();
renderDownloads(); renderDownloads();
}); });
} }
} }
function updateDownloadClientFilter() { function updateDownloadClientFilter() {
const filterSelect = document.getElementById('download-client-filter'); const optionsContainer = document.getElementById('download-client-options');
if (!filterSelect || downloadClients.length === 0) return; if (!optionsContainer) return;
// Save current selection // Clear existing options
const currentValue = filterSelect.value; optionsContainer.innerHTML = '';
// Clear existing options (except "All clients") if (downloadClients.length === 0) {
filterSelect.innerHTML = '<option value="all">All clients</option>'; optionsContainer.innerHTML = '<div class="download-client-empty">No clients available</div>';
return;
}
// Add options for each download client // Add checkboxes for each download client
downloadClients.forEach(client => { downloadClients.forEach(client => {
const option = document.createElement('option'); const option = document.createElement('div');
option.value = client.id; option.className = 'download-client-option';
option.textContent = client.name;
filterSelect.appendChild(option); const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'download-client-checkbox';
checkbox.value = client.id;
checkbox.checked = selectedDownloadClients.includes(client.id);
checkbox.id = `client-${client.id}`;
const label = document.createElement('label');
label.className = 'download-client-option-label';
label.htmlFor = `client-${client.id}`;
label.textContent = client.name;
const typeBadge = document.createElement('span');
typeBadge.className = 'download-client-type';
typeBadge.textContent = client.type;
option.appendChild(checkbox);
option.appendChild(label);
option.appendChild(typeBadge);
// Toggle selection when clicking the row
option.addEventListener('click', (e) => {
if (e.target !== checkbox) {
checkbox.checked = !checkbox.checked;
}
toggleClientSelection(client.id, checkbox.checked);
});
// Toggle selection when checkbox changes
checkbox.addEventListener('change', (e) => {
toggleClientSelection(client.id, e.target.checked);
});
optionsContainer.appendChild(option);
}); });
// Restore selection if still valid, otherwise default to 'all' // Update button text
if (currentValue && (currentValue === 'all' || downloadClients.some(c => c.id === currentValue))) { updateSelectedCountDisplay();
filterSelect.value = currentValue; }
function toggleClientSelection(clientId, isSelected) {
if (isSelected) {
if (!selectedDownloadClients.includes(clientId)) {
selectedDownloadClients.push(clientId);
}
} else { } else {
filterSelect.value = 'all'; selectedDownloadClients = selectedDownloadClients.filter(id => id !== clientId);
selectedDownloadClient = 'all'; }
localStorage.setItem('sofarr-download-client', 'all'); localStorage.setItem('sofarr-download-clients', JSON.stringify(selectedDownloadClients));
updateSelectedCountDisplay();
renderDownloads();
}
function updateSelectedCountDisplay() {
const selectedText = document.getElementById('download-client-selected-text');
if (!selectedText) return;
if (selectedDownloadClients.length === 0) {
selectedText.textContent = 'All clients';
} else if (selectedDownloadClients.length === 1) {
const client = downloadClients.find(c => c.id === selectedDownloadClients[0]);
selectedText.textContent = client ? client.name : '1 selected';
} else {
selectedText.textContent = `${selectedDownloadClients.length} selected`;
} }
} }

View File

@@ -147,9 +147,21 @@
<div class="downloads-header"> <div class="downloads-header">
<div class="downloads-controls"> <div class="downloads-controls">
<label class="download-client-label" for="download-client-filter">Download client:</label> <label class="download-client-label" for="download-client-filter">Download client:</label>
<select id="download-client-filter" class="download-client-select"> <div class="download-client-filter" id="download-client-filter">
<option value="all">All clients</option> <button class="download-client-dropdown-btn" id="download-client-dropdown-btn" type="button" aria-expanded="false">
</select> <span id="download-client-selected-text">All clients</span>
<span class="dropdown-arrow"></span>
</button>
<div class="download-client-dropdown" id="download-client-dropdown">
<div class="download-client-dropdown-header">
<button class="download-client-dropdown-btn-small" id="download-client-select-all" type="button">Select All</button>
<button class="download-client-dropdown-btn-small" id="download-client-deselect-all" type="button">Deselect All</button>
</div>
<div class="download-client-options" id="download-client-options">
<!-- Options will be populated by JavaScript -->
</div>
</div>
</div>
</div> </div>
</div> </div>
<div id="no-downloads" class="no-downloads" style="display: none;"> <div id="no-downloads" class="no-downloads" style="display: none;">

View File

@@ -698,6 +698,150 @@ body {
border-color: var(--accent); border-color: var(--accent);
} }
/* Multi-select dropdown container */
.download-client-filter {
position: relative;
display: inline-block;
}
.download-client-dropdown-btn {
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
color: var(--text-primary);
font-size: 0.85rem;
cursor: pointer;
min-width: 140px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
transition: background 0.15s, border-color 0.15s;
}
.download-client-dropdown-btn:hover {
background: var(--hover-bg);
}
.download-client-dropdown-btn:focus {
outline: none;
border-color: var(--accent);
}
.download-client-dropdown-btn .dropdown-arrow {
font-size: 0.75rem;
transition: transform 0.2s;
}
.download-client-dropdown-btn.open .dropdown-arrow {
transform: rotate(180deg);
}
.download-client-count {
background: var(--accent);
color: white;
padding: 1px 6px;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 500;
}
/* Dropdown panel */
.download-client-dropdown {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 200px;
max-width: 300px;
max-height: 300px;
overflow-y: auto;
z-index: 1000;
display: none;
}
.download-client-dropdown.open {
display: block;
}
/* Dropdown header with Select All/Deselect All buttons */
.download-client-dropdown-header {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
display: flex;
gap: 8px;
position: sticky;
top: 0;
background: var(--surface);
z-index: 1;
}
.download-client-dropdown-btn-small {
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--surface-alt);
color: var(--text-secondary);
font-size: 0.75rem;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.download-client-dropdown-btn-small:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
/* Client option row */
.download-client-option {
padding: 8px 12px;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
transition: background 0.15s;
}
.download-client-option:hover {
background: var(--hover-bg);
}
.download-client-checkbox {
width: 16px;
height: 16px;
margin: 0;
cursor: pointer;
accent-color: var(--accent);
}
.download-client-option-label {
flex: 1;
font-size: 0.85rem;
color: var(--text-primary);
cursor: pointer;
}
.download-client-type {
font-size: 0.75rem;
color: var(--text-secondary);
background: var(--surface-alt);
padding: 1px 6px;
border-radius: 3px;
}
/* Empty state */
.download-client-empty {
padding: 12px;
text-align: center;
font-size: 0.85rem;
color: var(--text-secondary);
}
.history-header { .history-header {
display: flex; display: flex;
align-items: center; align-items: center;