diff --git a/public/app.js b/public/app.js
index 056b88c..1e66751 100644
--- a/public/app.js
+++ b/public/app.js
@@ -2,12 +2,34 @@
let currentUser = null;
let downloads = [];
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 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
+// 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
let historyDays = parseInt(localStorage.getItem('sofarr-history-days'), 10) || 7;
let historyRefreshHandle = null;
@@ -361,10 +383,10 @@ function renderDownloads() {
const downloadsList = document.getElementById('downloads-list');
const noDownloads = document.getElementById('no-downloads');
- // Filter downloads by selected client
+ // Filter downloads by selected clients
let filteredDownloads = downloads;
- if (selectedDownloadClient !== 'all') {
- filteredDownloads = downloads.filter(d => d.instanceId === selectedDownloadClient);
+ if (selectedDownloadClients.length > 0) {
+ filteredDownloads = downloads.filter(d => selectedDownloadClients.includes(d.instanceId));
}
// Sort downloads by client order (matching the order in downloadClients)
@@ -1051,43 +1073,141 @@ function initHistoryControls() {
// =============================================================================
function initDownloadClientFilter() {
- const filterSelect = document.getElementById('download-client-filter');
- if (filterSelect) {
- // Set initial value from localStorage
- filterSelect.value = selectedDownloadClient;
- filterSelect.addEventListener('change', () => {
- selectedDownloadClient = filterSelect.value;
- localStorage.setItem('sofarr-download-client', selectedDownloadClient);
+ const dropdownBtn = document.getElementById('download-client-dropdown-btn');
+ const dropdown = document.getElementById('download-client-dropdown');
+ const selectAllBtn = document.getElementById('download-client-select-all');
+ const deselectAllBtn = document.getElementById('download-client-deselect-all');
+
+ if (dropdownBtn && dropdown) {
+ // 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();
});
}
}
function updateDownloadClientFilter() {
- const filterSelect = document.getElementById('download-client-filter');
- if (!filterSelect || downloadClients.length === 0) return;
+ const optionsContainer = document.getElementById('download-client-options');
+ if (!optionsContainer) return;
- // Save current selection
- const currentValue = filterSelect.value;
+ // Clear existing options
+ optionsContainer.innerHTML = '';
- // Clear existing options (except "All clients")
- filterSelect.innerHTML = '';
+ if (downloadClients.length === 0) {
+ optionsContainer.innerHTML = '
No clients available
';
+ return;
+ }
- // Add options for each download client
+ // Add checkboxes for each download client
downloadClients.forEach(client => {
- const option = document.createElement('option');
- option.value = client.id;
- option.textContent = client.name;
- filterSelect.appendChild(option);
+ const option = document.createElement('div');
+ option.className = 'download-client-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'
- if (currentValue && (currentValue === 'all' || downloadClients.some(c => c.id === currentValue))) {
- filterSelect.value = currentValue;
+ // Update button text
+ updateSelectedCountDisplay();
+}
+
+function toggleClientSelection(clientId, isSelected) {
+ if (isSelected) {
+ if (!selectedDownloadClients.includes(clientId)) {
+ selectedDownloadClients.push(clientId);
+ }
} else {
- filterSelect.value = 'all';
- selectedDownloadClient = 'all';
- localStorage.setItem('sofarr-download-client', 'all');
+ selectedDownloadClients = selectedDownloadClients.filter(id => id !== clientId);
+ }
+ 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`;
}
}
diff --git a/public/index.html b/public/index.html
index 35c8453..c30ab5a 100644
--- a/public/index.html
+++ b/public/index.html
@@ -147,9 +147,21 @@
diff --git a/public/style.css b/public/style.css
index 20f7fcf..10f67b1 100644
--- a/public/style.css
+++ b/public/style.css
@@ -698,6 +698,150 @@ body {
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 {
display: flex;
align-items: center;