feat: Add Ombi request filtering and search
Build and Push Docker Image / build (push) Successful in 1m29s
Docs Check / Markdown lint (push) Successful in 1m51s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 2m3s
CI / Security audit (push) Successful in 2m54s
CI / Swagger Validation & Coverage (push) Successful in 3m6s
Docs Check / Mermaid diagram parse check (push) Successful in 3m13s
CI / Tests & coverage (push) Successful in 3m31s
Build and Push Docker Image / build (push) Successful in 1m29s
Docs Check / Markdown lint (push) Successful in 1m51s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 2m3s
CI / Security audit (push) Successful in 2m54s
CI / Swagger Validation & Coverage (push) Successful in 3m6s
Docs Check / Mermaid diagram parse check (push) Successful in 3m13s
CI / Tests & coverage (push) Successful in 3m31s
- Add request filters UI (type, status, sort, search) - Implement dual-layer filtering (server + client) - Add ombiFilters utility for consistent filtering logic - Persist filter preferences in localStorage - Add SSE support for real-time Ombi request updates - Add webhook endpoints for Ombi integration - Update OpenAPI spec for new endpoints - Add unit tests for filter logic and UI - Add integration tests for Ombi routes
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
// Bootstrap - wire all event handlers on DOMContentLoaded
|
||||
import { checkAuthenticationAndInit, handleLogin, handleLogoutClick } from './ui/auth.js';
|
||||
import { initDownloadClientFilter } from './ui/filters.js';
|
||||
import { initRequestFilters } from './ui/requestFilters.js';
|
||||
import { initHistoryControls } from './ui/history.js';
|
||||
import { toggleStatusPanel } from './ui/statusPanel.js';
|
||||
import { initWebhooks } from './ui/webhooks.js';
|
||||
@@ -46,6 +47,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
initThemeSwitcher();
|
||||
initTabs();
|
||||
initDownloadClientFilter();
|
||||
initRequestFilters();
|
||||
initHistoryControls();
|
||||
initWebhooks();
|
||||
|
||||
|
||||
+7
-1
@@ -31,7 +31,13 @@ export const state = {
|
||||
sonarrWebhook: { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null },
|
||||
radarrWebhook: { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null },
|
||||
ombiWebhook: { enabled: false, triggers: { requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false }, stats: null },
|
||||
webhookMetrics: null
|
||||
webhookMetrics: null,
|
||||
|
||||
// Request filter state
|
||||
selectedRequestTypes: ['movie', 'tv'],
|
||||
selectedRequestStatuses: [],
|
||||
requestSortMode: 'requestedDate_desc',
|
||||
requestSearchQuery: ''
|
||||
};
|
||||
|
||||
// Constants
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
import { state } from '../state.js';
|
||||
import {
|
||||
saveRequestTypes,
|
||||
saveRequestStatuses,
|
||||
saveRequestSort,
|
||||
saveRequestSearch
|
||||
} from '../utils/storage.js';
|
||||
import { renderRequests } from './requests.js';
|
||||
|
||||
// ---- Type filter dropdown ----
|
||||
|
||||
function initTypeFilter() {
|
||||
const btn = document.getElementById('request-type-filter-btn');
|
||||
const dropdown = document.getElementById('request-type-filter-dropdown');
|
||||
const selectAll = document.getElementById('request-type-select-all');
|
||||
const deselectAll = document.getElementById('request-type-deselect-all');
|
||||
|
||||
if (!btn || !dropdown) return;
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.classList.toggle('open');
|
||||
});
|
||||
|
||||
selectAll?.addEventListener('click', () => setAllTypes(true));
|
||||
deselectAll?.addEventListener('click', () => setAllTypes(false));
|
||||
|
||||
// Wire up checkboxes
|
||||
const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.addEventListener('change', () => {
|
||||
const value = cb.closest('.request-filter-option').dataset.value;
|
||||
toggleType(value, cb.checked);
|
||||
});
|
||||
});
|
||||
|
||||
updateTypeFilterUI();
|
||||
}
|
||||
|
||||
function setAllTypes(checked) {
|
||||
const dropdown = document.getElementById('request-type-filter-dropdown');
|
||||
const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox');
|
||||
const newTypes = [];
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = checked;
|
||||
if (checked) newTypes.push(cb.closest('.request-filter-option').dataset.value);
|
||||
});
|
||||
state.selectedRequestTypes = checked ? newTypes : [];
|
||||
saveRequestTypes(state.selectedRequestTypes);
|
||||
updateTypeFilterUI();
|
||||
renderRequests();
|
||||
}
|
||||
|
||||
function toggleType(value, checked) {
|
||||
const idx = state.selectedRequestTypes.indexOf(value);
|
||||
if (checked && idx === -1) {
|
||||
state.selectedRequestTypes.push(value);
|
||||
} else if (!checked && idx > -1) {
|
||||
state.selectedRequestTypes.splice(idx, 1);
|
||||
}
|
||||
saveRequestTypes(state.selectedRequestTypes);
|
||||
updateTypeFilterUI();
|
||||
renderRequests();
|
||||
}
|
||||
|
||||
function updateTypeFilterUI() {
|
||||
const text = document.getElementById('request-type-selected-text');
|
||||
if (!text) return;
|
||||
|
||||
const dropdown = document.getElementById('request-type-filter-dropdown');
|
||||
const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
const value = cb.closest('.request-filter-option').dataset.value;
|
||||
cb.checked = state.selectedRequestTypes.includes(value);
|
||||
});
|
||||
|
||||
if (state.selectedRequestTypes.length === 0) {
|
||||
text.textContent = 'None';
|
||||
} else if (state.selectedRequestTypes.length === checkboxes.length) {
|
||||
text.textContent = 'All';
|
||||
} else {
|
||||
text.textContent = state.selectedRequestTypes.length;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Status filter dropdown ----
|
||||
|
||||
function initStatusFilter() {
|
||||
const btn = document.getElementById('request-status-filter-btn');
|
||||
const dropdown = document.getElementById('request-status-filter-dropdown');
|
||||
const selectAll = document.getElementById('request-status-select-all');
|
||||
const deselectAll = document.getElementById('request-status-deselect-all');
|
||||
|
||||
if (!btn || !dropdown) return;
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.classList.toggle('open');
|
||||
});
|
||||
|
||||
selectAll?.addEventListener('click', () => setAllStatuses(true));
|
||||
deselectAll?.addEventListener('click', () => setAllStatuses(false));
|
||||
|
||||
const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.addEventListener('change', () => {
|
||||
const value = cb.closest('.request-filter-option').dataset.value;
|
||||
toggleStatus(value, cb.checked);
|
||||
});
|
||||
});
|
||||
|
||||
updateStatusFilterUI();
|
||||
}
|
||||
|
||||
function setAllStatuses(checked) {
|
||||
const dropdown = document.getElementById('request-status-filter-dropdown');
|
||||
const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox');
|
||||
const newStatuses = [];
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = checked;
|
||||
if (checked) newStatuses.push(cb.closest('.request-filter-option').dataset.value);
|
||||
});
|
||||
state.selectedRequestStatuses = checked ? newStatuses : [];
|
||||
saveRequestStatuses(state.selectedRequestStatuses);
|
||||
updateStatusFilterUI();
|
||||
renderRequests();
|
||||
}
|
||||
|
||||
function toggleStatus(value, checked) {
|
||||
const idx = state.selectedRequestStatuses.indexOf(value);
|
||||
if (checked && idx === -1) {
|
||||
state.selectedRequestStatuses.push(value);
|
||||
} else if (!checked && idx > -1) {
|
||||
state.selectedRequestStatuses.splice(idx, 1);
|
||||
}
|
||||
saveRequestStatuses(state.selectedRequestStatuses);
|
||||
updateStatusFilterUI();
|
||||
renderRequests();
|
||||
}
|
||||
|
||||
function updateStatusFilterUI() {
|
||||
const text = document.getElementById('request-status-selected-text');
|
||||
if (!text) return;
|
||||
|
||||
const dropdown = document.getElementById('request-status-filter-dropdown');
|
||||
const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
const value = cb.closest('.request-filter-option').dataset.value;
|
||||
cb.checked = state.selectedRequestStatuses.includes(value);
|
||||
});
|
||||
|
||||
if (state.selectedRequestStatuses.length === 0) {
|
||||
text.textContent = 'All';
|
||||
} else if (state.selectedRequestStatuses.length === checkboxes.length) {
|
||||
text.textContent = 'All';
|
||||
} else {
|
||||
text.textContent = state.selectedRequestStatuses.length;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Sort select ----
|
||||
|
||||
function initSortSelect() {
|
||||
const select = document.getElementById('request-sort-select');
|
||||
if (!select) return;
|
||||
|
||||
select.value = state.requestSortMode;
|
||||
select.addEventListener('change', (e) => {
|
||||
state.requestSortMode = e.target.value;
|
||||
saveRequestSort(state.requestSortMode);
|
||||
renderRequests();
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Search input ----
|
||||
|
||||
function initSearchInput() {
|
||||
const input = document.getElementById('request-search-input');
|
||||
if (!input) return;
|
||||
|
||||
input.value = state.requestSearchQuery;
|
||||
|
||||
let debounceTimer;
|
||||
input.addEventListener('input', (e) => {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
state.requestSearchQuery = e.target.value;
|
||||
saveRequestSearch(state.requestSearchQuery);
|
||||
renderRequests();
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Global click-outside handler ----
|
||||
|
||||
function initClickOutside() {
|
||||
document.addEventListener('click', (e) => {
|
||||
const typeDropdown = document.getElementById('request-type-filter-dropdown');
|
||||
const typeBtn = document.getElementById('request-type-filter-btn');
|
||||
const statusDropdown = document.getElementById('request-status-filter-dropdown');
|
||||
const statusBtn = document.getElementById('request-status-filter-btn');
|
||||
|
||||
if (typeDropdown && !typeDropdown.contains(e.target) && e.target !== typeBtn && !typeBtn?.contains(e.target)) {
|
||||
typeDropdown.classList.remove('open');
|
||||
}
|
||||
if (statusDropdown && !statusDropdown.contains(e.target) && e.target !== statusBtn && !statusBtn?.contains(e.target)) {
|
||||
statusDropdown.classList.remove('open');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Public API ----
|
||||
|
||||
export function initRequestFilters() {
|
||||
initTypeFilter();
|
||||
initStatusFilter();
|
||||
initSortSelect();
|
||||
initSearchInput();
|
||||
initClickOutside();
|
||||
|
||||
// Listen for SSE updates (registered once on app bootstrap)
|
||||
document.addEventListener('ombiRequestsUpdated', () => {
|
||||
renderRequests();
|
||||
});
|
||||
}
|
||||
+35
-42
@@ -2,12 +2,15 @@
|
||||
|
||||
import { state } from '../state.js';
|
||||
import { escapeHtml } from '../utils/format.js';
|
||||
import { applyRequestFilters, getRequestStatus } from '../utils/ombiFilters.js';
|
||||
|
||||
/**
|
||||
* Helper function to extract the username from an Ombi request object.
|
||||
* The Ombi API returns requestedUser as an OmbiStore.Entities.OmbiUser object,
|
||||
* not a string, so we need to extract the username from the object.
|
||||
*
|
||||
*
|
||||
* Must stay in sync with server/utils/ombiHelpers.js
|
||||
*
|
||||
* @param {Object} request - The Ombi request object
|
||||
* @returns {string} The extracted username, or empty string if not found
|
||||
*/
|
||||
@@ -38,16 +41,34 @@ export function renderRequests() {
|
||||
...ombiRequests.tv.map(r => ({ ...r, mediaType: 'tv' }))
|
||||
];
|
||||
|
||||
// Apply client-side filters, sorting, and search
|
||||
const filtered = applyRequestFilters(allRequests, {
|
||||
types: state.selectedRequestTypes,
|
||||
statuses: state.selectedRequestStatuses,
|
||||
sort: state.requestSortMode,
|
||||
search: state.requestSearchQuery
|
||||
});
|
||||
|
||||
requestsList.innerHTML = '';
|
||||
|
||||
if (allRequests.length === 0) {
|
||||
if (noRequests) noRequests.style.display = 'block';
|
||||
if (filtered.length === 0) {
|
||||
if (noRequests) {
|
||||
noRequests.style.display = 'block';
|
||||
const p = noRequests.querySelector('p');
|
||||
if (p) {
|
||||
// Differentiate between no data from Ombi vs filters excluded everything
|
||||
const hasAnyData = allRequests.length > 0;
|
||||
p.textContent = hasAnyData
|
||||
? 'No requests match your filters.'
|
||||
: 'No requests found.';
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (noRequests) noRequests.style.display = 'none';
|
||||
|
||||
allRequests.forEach(request => {
|
||||
filtered.forEach(request => {
|
||||
const card = createRequestCard(request);
|
||||
requestsList.appendChild(card);
|
||||
});
|
||||
@@ -102,7 +123,7 @@ function createRequestCard(request) {
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'request-actions';
|
||||
|
||||
if (request.theMovieDbId) {
|
||||
if (state.ombiBaseUrl && request.theMovieDbId) {
|
||||
const ombiLink = document.createElement('a');
|
||||
ombiLink.className = 'request-link ombi-link';
|
||||
ombiLink.href = `${state.ombiBaseUrl}/details/${request.mediaType}/${request.theMovieDbId}`;
|
||||
@@ -129,46 +150,18 @@ function createStatusBadge(request) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'request-status-badge';
|
||||
|
||||
let status = 'unknown';
|
||||
let text = 'Unknown';
|
||||
|
||||
if (request.available) {
|
||||
status = 'available';
|
||||
text = 'Available';
|
||||
} else if (request.approved) {
|
||||
status = 'approved';
|
||||
text = 'Approved';
|
||||
} else if (request.denied) {
|
||||
status = 'denied';
|
||||
text = `Denied: ${request.deniedReason || 'No reason'}`;
|
||||
} else if (request.requested) {
|
||||
status = 'pending';
|
||||
text = 'Pending';
|
||||
}
|
||||
const status = getRequestStatus(request);
|
||||
const statusTexts = {
|
||||
available: 'Available',
|
||||
denied: `Denied: ${request.deniedReason || 'No reason'}`,
|
||||
approved: 'Approved',
|
||||
pending: 'Pending',
|
||||
unknown: 'Unknown'
|
||||
};
|
||||
|
||||
badge.classList.add(status);
|
||||
badge.textContent = text;
|
||||
badge.textContent = statusTexts[status] || 'Unknown';
|
||||
|
||||
return badge;
|
||||
}
|
||||
|
||||
export function setupRequestsTab() {
|
||||
// Listen for SSE updates
|
||||
if (state.sseSource) {
|
||||
state.sseSource.addEventListener('message', (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.ombiRequests) {
|
||||
state.ombiRequests = data.ombiRequests;
|
||||
renderRequests();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Also listen for custom event triggered from sse.js
|
||||
document.addEventListener('ombiRequestsUpdated', () => {
|
||||
renderRequests();
|
||||
});
|
||||
|
||||
// Initial render
|
||||
renderRequests();
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { getActiveTab, saveActiveTab } from '../utils/storage.js';
|
||||
import { loadHistory } from './history.js';
|
||||
import { setupRequestsTab } from './requests.js';
|
||||
|
||||
export function initTabs() {
|
||||
const downloadsTab = document.querySelector('[data-tab="downloads"]');
|
||||
@@ -55,7 +54,6 @@ export function activateTab(tab) {
|
||||
if (requestsSection) requestsSection.classList.remove('hidden');
|
||||
saveActiveTab('requests');
|
||||
setupRequestsTab();
|
||||
} else if (tab === 'history') {
|
||||
if (historyTab) historyTab.classList.add('active');
|
||||
if (historySection) historySection.classList.remove('hidden');
|
||||
saveActiveTab('history');
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Pure filter / sort / search utilities for Ombi requests.
|
||||
* Must stay in sync with server/utils/ombiFilters.js
|
||||
*/
|
||||
|
||||
/**
|
||||
* Derive a single status string from an Ombi request object.
|
||||
* Priority: available > denied > approved > pending > unknown
|
||||
*
|
||||
* @param {Object} request
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getRequestStatus(request) {
|
||||
if (!request) return 'unknown';
|
||||
if (request.available) return 'available';
|
||||
if (request.denied) return 'denied';
|
||||
if (request.approved) return 'approved';
|
||||
if (request.requested) return 'pending';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter requests by media type.
|
||||
*
|
||||
* @param {Array} requests
|
||||
* @param {string[]} types
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function filterByType(requests, types) {
|
||||
if (!types || types.length === 0) return requests;
|
||||
const normalized = types.map(t => t.toLowerCase());
|
||||
if (normalized.includes('all')) return requests;
|
||||
return requests.filter(r => normalized.includes(r.mediaType));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter requests by status.
|
||||
*
|
||||
* @param {Array} requests
|
||||
* @param {string[]} statuses
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function filterByStatus(requests, statuses) {
|
||||
if (!statuses || statuses.length === 0) return requests;
|
||||
const normalized = statuses.map(s => s.toLowerCase());
|
||||
return requests.filter(r => normalized.includes(getRequestStatus(r)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter requests by case-insensitive title substring.
|
||||
*
|
||||
* @param {Array} requests
|
||||
* @param {string} query
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function filterBySearch(requests, query) {
|
||||
if (!query || query.trim() === '') return requests;
|
||||
const q = query.trim().toLowerCase();
|
||||
return requests.filter(r => (r.title || '').toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort requests by the given sort mode.
|
||||
*
|
||||
* @param {Array} requests
|
||||
* @param {string} sortMode
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function sortRequests(requests, sortMode) {
|
||||
const sorted = [...requests];
|
||||
switch (sortMode) {
|
||||
case 'requestedDate_asc':
|
||||
return sorted.sort((a, b) => {
|
||||
const da = a.requestedDate ? new Date(a.requestedDate).getTime() : 0;
|
||||
const db = b.requestedDate ? new Date(b.requestedDate).getTime() : 0;
|
||||
return da - db;
|
||||
});
|
||||
case 'title_asc':
|
||||
return sorted.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
|
||||
case 'title_desc':
|
||||
return sorted.sort((a, b) => (b.title || '').localeCompare(a.title || ''));
|
||||
case 'requestedDate_desc':
|
||||
default:
|
||||
return sorted.sort((a, b) => {
|
||||
const da = a.requestedDate ? new Date(a.requestedDate).getTime() : 0;
|
||||
const db = b.requestedDate ? new Date(b.requestedDate).getTime() : 0;
|
||||
return db - da;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all filters and sorting in one call.
|
||||
*
|
||||
* @param {Array} requests
|
||||
* @param {Object} options
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function applyRequestFilters(requests, { types, statuses, sort, search } = {}) {
|
||||
let result = [...requests];
|
||||
result = filterByType(result, types);
|
||||
result = filterByStatus(result, statuses);
|
||||
result = filterBySearch(result, search);
|
||||
result = sortRequests(result, sort);
|
||||
return result;
|
||||
}
|
||||
@@ -46,6 +46,41 @@ import { state } from '../state.js';
|
||||
}
|
||||
})();
|
||||
|
||||
// Load request filter preferences from localStorage
|
||||
(function loadRequestFilters() {
|
||||
try {
|
||||
const savedTypes = localStorage.getItem('sofarr-request-types');
|
||||
if (savedTypes) state.selectedRequestTypes = JSON.parse(savedTypes);
|
||||
} catch (e) {
|
||||
console.error('[Storage] Failed to load request types:', e);
|
||||
state.selectedRequestTypes = ['movie', 'tv'];
|
||||
}
|
||||
|
||||
try {
|
||||
const savedStatuses = localStorage.getItem('sofarr-request-statuses');
|
||||
if (savedStatuses) state.selectedRequestStatuses = JSON.parse(savedStatuses);
|
||||
} catch (e) {
|
||||
console.error('[Storage] Failed to load request statuses:', e);
|
||||
state.selectedRequestStatuses = [];
|
||||
}
|
||||
|
||||
try {
|
||||
const savedSort = localStorage.getItem('sofarr-request-sort');
|
||||
if (savedSort) state.requestSortMode = savedSort;
|
||||
} catch (e) {
|
||||
console.error('[Storage] Failed to load request sort:', e);
|
||||
state.requestSortMode = 'requestedDate_desc';
|
||||
}
|
||||
|
||||
try {
|
||||
const savedSearch = localStorage.getItem('sofarr-request-search');
|
||||
if (savedSearch !== null) state.requestSearchQuery = savedSearch;
|
||||
} catch (e) {
|
||||
console.error('[Storage] Failed to load request search:', e);
|
||||
state.requestSearchQuery = '';
|
||||
}
|
||||
})();
|
||||
|
||||
// Export helper functions for localStorage operations
|
||||
export function saveHistoryDays(days) {
|
||||
localStorage.setItem('sofarr-history-days', days);
|
||||
@@ -74,3 +109,19 @@ export function getActiveTab() {
|
||||
export function saveActiveTab(tab) {
|
||||
localStorage.setItem('sofarr-active-tab', tab);
|
||||
}
|
||||
|
||||
export function saveRequestTypes(types) {
|
||||
localStorage.setItem('sofarr-request-types', JSON.stringify(types));
|
||||
}
|
||||
|
||||
export function saveRequestStatuses(statuses) {
|
||||
localStorage.setItem('sofarr-request-statuses', JSON.stringify(statuses));
|
||||
}
|
||||
|
||||
export function saveRequestSort(sort) {
|
||||
localStorage.setItem('sofarr-request-sort', sort);
|
||||
}
|
||||
|
||||
export function saveRequestSearch(query) {
|
||||
localStorage.setItem('sofarr-request-search', query);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user