mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-01 22:06:39 +01:00
This PR addresses an issue where the "download selected" functionality behaves unexpectedly. If a user makes a selection of books to download, performs a new search without clearing the previous selection, and selects new books, clicking "download selected" would still include the books from the prior selection. This causes the "download selected" button to fail, as the books from the previous selection are no longer available. A potential enhancement could allow users to select books across multiple searches and download them collectively. This could be achieved by creating a dedicated section in the UI to track and display the currently selected books. This feature might go well with a broader UI overhaul, as has been suggested in some issues - maybe after your CF bypass rewrite? Co-authored-by: CaliBrain <calibrain@l4n.xyz>
679 lines
24 KiB
JavaScript
679 lines
24 KiB
JavaScript
// Main application JavaScript
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// DOM Elements
|
|
const elements = {
|
|
search: {
|
|
searchInput: document.getElementById('search-input'),
|
|
searchButton: document.getElementById('search-button'),
|
|
advanced: {
|
|
advSearchButton: document.getElementById('adv-search-button'),
|
|
searchFiltersForm: document.getElementById('search-filters')
|
|
}
|
|
},
|
|
selectAllCheckbox: document.getElementById('select-all-checkbox'),
|
|
downloadSelectedButton: document.getElementById('download-selected-button'),
|
|
resultsSectionAccordion: document.getElementById('results-section-accordion'),
|
|
searchAccordion: document.getElementById('search-accordion'),
|
|
resultsHeading: document.getElementById('results-heading'),
|
|
resultsTable: document.getElementById('results-table'),
|
|
resultsTableBody: document.querySelector('#results-table tbody'),
|
|
searchLoading: document.getElementById('search-loading'),
|
|
statusLoading: document.getElementById('status-loading'),
|
|
statusTable: document.getElementById('status-table'),
|
|
statusTableBody: document.querySelector('#status-table tbody'),
|
|
modalOverlay: document.getElementById('modal-overlay'),
|
|
detailsContainer: document.getElementById('details-container')
|
|
};
|
|
|
|
// State
|
|
let modalDetails = null;
|
|
const selectedBooks = new Set();
|
|
const STATE = {
|
|
isSearching: false,
|
|
isLoadingDetails: false,
|
|
};
|
|
|
|
// Constants
|
|
const REFRESH_INTERVAL = 60000; // 60 seconds
|
|
const API_ENDPOINTS = {
|
|
search: '/request/api/search',
|
|
info: '/request/api/info',
|
|
download: '/request/api/download',
|
|
status: '/request/api/status'
|
|
};
|
|
const FILTERS = ['isbn', 'author', 'title', 'lang' , 'sort', "content"];
|
|
|
|
// Utility Functions
|
|
const utils = {
|
|
debounce(func, wait) {
|
|
let timeout;
|
|
return function executedFunction(...args) {
|
|
const later = () => {
|
|
clearTimeout(timeout);
|
|
func(...args);
|
|
};
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
};
|
|
},
|
|
|
|
showLoading(element) {
|
|
element.removeAttribute('hidden');
|
|
},
|
|
|
|
hideLoading(element) {
|
|
element.setAttribute('hidden', '');
|
|
},
|
|
|
|
showAccordion(element) {
|
|
UIkit.accordion(element).toggle(1, true);
|
|
},
|
|
|
|
async fetchJson(url, options = {}) {
|
|
try {
|
|
const response = await fetch(url, options);
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error('Fetch error:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
createElement(tag, attributes = {}, children = []) {
|
|
const element = document.createElement(tag);
|
|
Object.entries(attributes).forEach(([key, value]) => {
|
|
element[key] = value;
|
|
});
|
|
children.forEach(child => {
|
|
if (typeof child === 'string') {
|
|
element.appendChild(document.createTextNode(child));
|
|
} else {
|
|
element.appendChild(child);
|
|
}
|
|
});
|
|
return element;
|
|
},
|
|
|
|
sortResultsTable(column, order = 'asc') {
|
|
const rows = Array.from(elements.resultsTableBody.querySelectorAll('tr'));
|
|
const headers = document.querySelectorAll('#results-table thead th');
|
|
|
|
const parseFileSize = (size) => {
|
|
const match = size.match(/([\d.]+)([KMGT]B)/i);
|
|
if (!match) return 0;
|
|
const [_, value, unit] = match;
|
|
const multiplier = { KB: 1, MB: 1024, GB: 1024 * 1024, TB: 1024 * 1024 * 1024 };
|
|
return parseFloat(value) * (multiplier[unit.toUpperCase()] || 1);
|
|
};
|
|
|
|
const parseTitle = (title) => {
|
|
return title.replace(/^(The)\s+/i, '').trim();
|
|
}
|
|
|
|
const columnName = headers[column].textContent.trim().toLowerCase();
|
|
|
|
const getCellValue = (row, column) => {
|
|
const cell = row.querySelector(`td:nth-child(${column + 1})`);
|
|
const text = cell ? cell.textContent.trim() : '';
|
|
if (columnName === 'size') return parseFileSize(text);
|
|
if (columnName === 'title') return parseTitle(text);
|
|
return text;
|
|
};
|
|
|
|
rows.sort((a, b) => {
|
|
const valA = getCellValue(a, column);
|
|
const valB = getCellValue(b, column);
|
|
|
|
if (!isNaN(valA) && !isNaN(valB)) {
|
|
return order === 'asc' ? valA - valB : valB - valA;
|
|
}
|
|
return order === 'asc'
|
|
? valA.localeCompare(valB)
|
|
: valB.localeCompare(valA);
|
|
});
|
|
|
|
elements.resultsTableBody.innerHTML = '';
|
|
rows.forEach(row => elements.resultsTableBody.appendChild(row));
|
|
|
|
headers.forEach(header => {
|
|
const icon = header.querySelector('.sort-icon');
|
|
if (icon) {
|
|
icon.removeAttribute('uk-icon');
|
|
}
|
|
});
|
|
|
|
const currentHeader = headers[column];
|
|
const icon = currentHeader.querySelector('.sort-icon');
|
|
if (icon) {
|
|
icon.setAttribute('uk-icon', `icon: ${order === 'asc' ? 'triangle-up' : 'triangle-down'}`);
|
|
}
|
|
},
|
|
|
|
updateDownloadSelectedButton() {
|
|
elements.downloadSelectedButton.disabled = selectedBooks.size === 0;
|
|
},
|
|
|
|
handleCheckboxChange(event) {
|
|
const checkbox = event.target;
|
|
if (checkbox.checked) {
|
|
selectedBooks.add(checkbox.value);
|
|
} else {
|
|
selectedBooks.delete(checkbox.value);
|
|
}
|
|
utils.updateDownloadSelectedButton();
|
|
},
|
|
|
|
async handleDownloadSelected() {
|
|
if (selectedBooks.size === 0) return;
|
|
|
|
const bookIds = Array.from(selectedBooks);
|
|
const books = bookIds.map((bookId) => {
|
|
const row = document.querySelector(`#book-${bookId}`).closest('tr');
|
|
return {
|
|
title: row.querySelector('td:nth-child(4)').textContent,
|
|
author: row.querySelector('td:nth-child(5)').textContent
|
|
};
|
|
});
|
|
|
|
// Confirmation modal
|
|
const confirmationContent = `
|
|
<h2>Confirm Download</h2>
|
|
<p>Are you sure you want to download ${books.length} book${books.length > 1 ? 's' : ''}?</p>
|
|
<div class="uk-overflow-auto">
|
|
<table class="uk-table uk-table-divider uk-table-small">
|
|
<thead>
|
|
<tr>
|
|
<th>Title</th>
|
|
<th>Author</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${books.map((book) => `
|
|
<tr>
|
|
<td>${book.title}</td>
|
|
<td>${book.author}</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="uk-flex uk-flex-between uk-margin-top">
|
|
<button id="cancel-download" class="uk-button uk-button-default">Cancel</button>
|
|
<button id="confirm-download" class="uk-button uk-button-primary">Download</button>
|
|
</div>
|
|
`;
|
|
|
|
elements.detailsContainer.innerHTML = confirmationContent;
|
|
|
|
document.getElementById('cancel-download').addEventListener('click', modal.close);
|
|
document.getElementById('confirm-download').addEventListener('click', async () => {
|
|
await utils.confirmDownload(bookIds);
|
|
});
|
|
|
|
modal.open();
|
|
},
|
|
|
|
async confirmDownload(bookIds) {
|
|
bookIds.map((bookId) =>
|
|
utils.fetchJson(`${API_ENDPOINTS.download}?id=${encodeURIComponent(bookId)}`)
|
|
);
|
|
|
|
this.clearAllCheckboxes();
|
|
modal.close();
|
|
},
|
|
|
|
clearAllCheckboxes() {
|
|
selectedBooks.forEach((bookId) => {
|
|
const checkbox = document.getElementById(`book-${bookId}`);
|
|
if (checkbox) checkbox.checked = false;
|
|
});
|
|
|
|
const selectAllCheckbox = document.getElementById('select-all-checkbox');
|
|
if (selectAllCheckbox) selectAllCheckbox.checked = false;
|
|
|
|
selectedBooks.clear();
|
|
utils.updateDownloadSelectedButton();
|
|
}
|
|
};
|
|
|
|
// Search Functions
|
|
const search = {
|
|
async performSearch(query) {
|
|
utils.clearAllCheckboxes();
|
|
if (STATE.isSearching) return;
|
|
|
|
try {
|
|
STATE.isSearching = true;
|
|
utils.showLoading(elements.searchLoading);
|
|
|
|
if (!elements.searchAccordion.classList.contains('uk-open')) {
|
|
utils.showAccordion(elements.resultsSectionAccordion);
|
|
};
|
|
|
|
const data = await utils.fetchJson(
|
|
`${API_ENDPOINTS.search}?${query}`
|
|
);
|
|
|
|
this.displayResults(data);
|
|
} catch (error) {
|
|
this.handleSearchError(error);
|
|
} finally {
|
|
STATE.isSearching = false;
|
|
utils.hideLoading(elements.searchLoading);
|
|
}
|
|
},
|
|
|
|
buildQuery() {
|
|
let queryParams = [];
|
|
|
|
if(elements.search.searchInput.value.trim()) {
|
|
queryParams.push(`query=${encodeURIComponent(elements.search.searchInput.value.trim())}`);
|
|
}
|
|
|
|
if(elements.search.advanced.searchFiltersForm.hasAttribute('hidden')) {
|
|
//Not advanced search
|
|
return queryParams.join('&');
|
|
}
|
|
|
|
FILTERS.forEach(filterType => {
|
|
const inputs = document.querySelectorAll(`[id^="${filterType}-input"]`);
|
|
|
|
inputs.forEach(input => {
|
|
const value = input.value.trim();
|
|
if (value) {
|
|
queryParams.push(`${filterType}=${encodeURIComponent(value.trim())}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
return queryParams.join('&');
|
|
},
|
|
|
|
displayResults(books) {
|
|
elements.resultsTableBody.innerHTML = '';
|
|
|
|
if (!books.length) {
|
|
this.displayNoResults();
|
|
return;
|
|
}
|
|
|
|
books.forEach((book, index) => {
|
|
const row = this.createBookRow(book, index);
|
|
elements.resultsTableBody.appendChild(row);
|
|
});
|
|
},
|
|
|
|
displayNoResults() {
|
|
const row = utils.createElement('tr', {}, [
|
|
utils.createElement('td', {
|
|
colSpan: '10',
|
|
textContent: 'No results found.'
|
|
})
|
|
]);
|
|
elements.resultsTableBody.appendChild(row);
|
|
},
|
|
|
|
createBookRow(book, index) {
|
|
const checkboxCell = utils.createElement('td', {}, [
|
|
utils.createElement('input', {
|
|
type: 'checkbox',
|
|
className: 'uk-checkbox',
|
|
id: 'book-' + book.id,
|
|
name: 'book-' + book.id,
|
|
value: book.id,
|
|
onchange: utils.handleCheckboxChange
|
|
})
|
|
]);
|
|
|
|
return utils.createElement('tr', {}, [
|
|
checkboxCell,
|
|
utils.createElement('td', { textContent: index + 1 }),
|
|
this.createPreviewCell(book.preview),
|
|
utils.createElement('td', { textContent: book.title || 'N/A' }),
|
|
utils.createElement('td', { textContent: book.author || 'N/A' }),
|
|
utils.createElement('td', { textContent: book.publisher || 'N/A' }),
|
|
utils.createElement('td', { textContent: book.year || 'N/A' }),
|
|
utils.createElement('td', { textContent: book.language || 'N/A' }),
|
|
utils.createElement('td', { textContent: book.format || 'N/A' }),
|
|
utils.createElement('td', { textContent: book.size || 'N/A' }),
|
|
this.createActionCell(book)
|
|
]);
|
|
},
|
|
|
|
createPreviewCell(previewUrl) {
|
|
if (!previewUrl) {
|
|
return utils.createElement('td', { textContent: 'N/A' });
|
|
}
|
|
|
|
const img = utils.createElement('img', {
|
|
src: previewUrl,
|
|
alt: 'Book Preview',
|
|
style: 'max-width: 60px;'
|
|
});
|
|
|
|
return utils.createElement('td', {}, [img]);
|
|
},
|
|
|
|
createActionCell(book) {
|
|
const buttonDetails = utils.createElement('button', {
|
|
className: 'uk-button uk-button-default uk-align-center uk-margin-small uk-width-1-1',
|
|
onclick: () => bookDetails.show(book.id)
|
|
}, [utils.createElement('span', { textContent: 'Details' })]);
|
|
|
|
const downloadButton = utils.createElement('button', {
|
|
className: 'uk-button uk-button-primary uk-align-center uk-margin-small uk-width-1-1',
|
|
onclick: () => bookDetails.downloadBook(book)
|
|
}, [utils.createElement('span', { textContent: 'Download' })]);
|
|
|
|
return utils.createElement('td', {}, [buttonDetails, downloadButton]);
|
|
},
|
|
|
|
handleSearchError(error) {
|
|
console.error('Search error:', error);
|
|
elements.resultsTableBody.innerHTML = '';
|
|
const errorRow = utils.createElement('tr', {}, [
|
|
utils.createElement('td', {
|
|
colSpan: '10',
|
|
textContent: 'An error occurred while searching. Please try again.'
|
|
})
|
|
]);
|
|
elements.resultsTableBody.appendChild(errorRow);
|
|
}
|
|
};
|
|
|
|
// Book Details Functions
|
|
const bookDetails = {
|
|
async show(bookId) {
|
|
if (STATE.isLoadingDetails) return;
|
|
|
|
try {
|
|
STATE.isLoadingDetails = true;
|
|
modal.open();
|
|
elements.detailsContainer.innerHTML = '<p>Loading details...</p>';
|
|
|
|
const book = await utils.fetchJson(
|
|
`${API_ENDPOINTS.info}?id=${encodeURIComponent(bookId)}`
|
|
);
|
|
|
|
modalDetails = book;
|
|
this.displayDetails(book);
|
|
} catch (error) {
|
|
this.handleDetailsError(error);
|
|
} finally {
|
|
STATE.isLoadingDetails = false;
|
|
}
|
|
},
|
|
|
|
displayDetails(book) {
|
|
elements.detailsContainer.innerHTML = this.generateDetailsHTML(book);
|
|
|
|
// Add event listeners
|
|
document.getElementById('download-button')
|
|
.addEventListener('click', () => this.downloadBook(book));
|
|
document.getElementById('close-details')
|
|
.addEventListener('click', modal.close);
|
|
},
|
|
|
|
generateDetailsHTML(book) {
|
|
return `
|
|
|
|
<div class="uk-card uk-card-default uk-child-width-1-2" uk-grid>
|
|
<div class="uk-card-media-left uk-cover-container uk-padding">
|
|
<img class="uk-height-medium" src="${book.preview || ''}" alt="Book Preview" uk-cover>
|
|
<canvas width="299" height="461"></canvas>
|
|
</div>
|
|
<div class="uk-card-body">
|
|
<h3>${book.title || 'No title available'}</h3>
|
|
<p><strong>Author:</strong> ${book.author || 'N/A'}</p>
|
|
<p><strong>Publisher:</strong> ${book.publisher || 'N/A'}</p>
|
|
<p><strong>Year:</strong> ${book.year || 'N/A'}</p>
|
|
<p><strong>Language:</strong> ${book.language || 'N/A'}</p>
|
|
<p><strong>Format:</strong> ${book.format || 'N/A'}</p>
|
|
<p><strong>Size:</strong> ${book.size || 'N/A'}</p>
|
|
</div>
|
|
|
|
<button id="download-button" class="uk-button uk-button-primary" type="button">Download</button>
|
|
<button id="close-details" class="uk-button uk-button-default uk-modal-close" type="button">Close</button>
|
|
</div>
|
|
<ul uk-accordion>
|
|
<li>
|
|
<a class="uk-accordion-title" href>Further Information</a>
|
|
<div class="uk-accordion-content">
|
|
${this.generateInfoList(book.info)}
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
`;
|
|
},
|
|
|
|
generateInfoList(info) {
|
|
if (!info) return '';
|
|
|
|
const listItems = Object.entries(info)
|
|
.map(([key, values]) => `
|
|
<li><strong>${key}:</strong> ${values.join(', ')}</li>
|
|
`)
|
|
.join('');
|
|
|
|
return `<ul class="uk-list uk-list-bullet">${listItems}</ul>`;
|
|
},
|
|
|
|
async downloadBook(book) {
|
|
if (!book) return;
|
|
|
|
try {
|
|
utils.showLoading(elements.searchLoading);
|
|
await utils.fetchJson(
|
|
`${API_ENDPOINTS.download}?id=${encodeURIComponent(book.id)}`
|
|
);
|
|
|
|
modal.close();
|
|
status.fetch();
|
|
} catch (error) {
|
|
console.error('Download error:', error);
|
|
} finally {
|
|
utils.hideLoading(elements.searchLoading);
|
|
}
|
|
},
|
|
|
|
handleDetailsError(error) {
|
|
console.error('Details error:', error);
|
|
elements.detailsContainer.innerHTML = `
|
|
<p>Error loading details. Please try again.</p>
|
|
<div class="details-actions">
|
|
<button id="close-details" onclick="modal.close()">Close</button>
|
|
</div>
|
|
`;
|
|
document.getElementById('close-details')
|
|
.addEventListener('click', modal.close);
|
|
}
|
|
};
|
|
|
|
// Status Functions
|
|
const status = {
|
|
async fetch() {
|
|
try {
|
|
utils.showLoading(elements.statusLoading);
|
|
const data = await utils.fetchJson(API_ENDPOINTS.status);
|
|
this.display(data);
|
|
} catch (error) {
|
|
this.handleError(error);
|
|
} finally {
|
|
utils.hideLoading(elements.statusLoading);
|
|
}
|
|
},
|
|
|
|
display(data) {
|
|
elements.statusTableBody.innerHTML = '';
|
|
|
|
// Handle each status type
|
|
Object.entries(data).forEach(([status, booksInStatus]) => {
|
|
// If the status section has books
|
|
if (Object.keys(booksInStatus).length > 0) {
|
|
// For each book in this status
|
|
Object.entries(booksInStatus).forEach(([bookId, bookData]) => {
|
|
this.addStatusRow(status, bookData);
|
|
});
|
|
}
|
|
});
|
|
},
|
|
|
|
addStatusRow(status, book) {
|
|
if (!book.id || !book.title) return;
|
|
|
|
const statusCell = utils.createElement('td', {
|
|
className: `status-${status.toLowerCase()}`,
|
|
textContent: status
|
|
});
|
|
|
|
let titleElement;
|
|
if (status.toLowerCase().includes('available')) {
|
|
titleElement = utils.createElement('a', {
|
|
href: `/request/api/localdownload?id=${book.id}`,
|
|
target: '_blank',
|
|
textContent: book.title || 'N/A'
|
|
});
|
|
}
|
|
else {
|
|
titleElement = utils.createElement('td', { textContent: book.title || 'N/A' })
|
|
}
|
|
|
|
const row = utils.createElement('tr', {}, [
|
|
statusCell,
|
|
utils.createElement('td', { textContent: book.id }),
|
|
titleElement,
|
|
this.createPreviewCell(book.preview)
|
|
]);
|
|
|
|
elements.statusTableBody.appendChild(row);
|
|
},
|
|
|
|
createPreviewCell(previewUrl) {
|
|
const cell = utils.createElement('td');
|
|
|
|
if (previewUrl) {
|
|
const img = utils.createElement('img', {
|
|
src: previewUrl,
|
|
alt: 'Book Preview',
|
|
style: 'max-width: 60px; height: auto;'
|
|
});
|
|
cell.appendChild(img);
|
|
} else {
|
|
cell.textContent = 'N/A';
|
|
}
|
|
|
|
return cell;
|
|
},
|
|
|
|
handleError(error) {
|
|
console.error('Status error:', error);
|
|
elements.statusTableBody.innerHTML = '';
|
|
|
|
const errorRow = utils.createElement('tr', {}, [
|
|
utils.createElement('td', {
|
|
colSpan: '4',
|
|
className: 'error-message',
|
|
textContent: 'Error loading status. Will retry automatically.'
|
|
})
|
|
]);
|
|
|
|
elements.statusTableBody.appendChild(errorRow);
|
|
}
|
|
};
|
|
|
|
// Modal Functions
|
|
const modal = {
|
|
open() {
|
|
elements.modalOverlay.classList.add('active');
|
|
},
|
|
|
|
close() {
|
|
elements.modalOverlay.classList.remove('active');
|
|
modalDetails = null;
|
|
}
|
|
};
|
|
|
|
// Event Listeners
|
|
function setupEventListeners() {
|
|
// Search events
|
|
elements.search.searchButton.addEventListener('click', () => {
|
|
const query = search.buildQuery();
|
|
if(query) search.performSearch(query);
|
|
});
|
|
|
|
elements.search.advanced.advSearchButton.addEventListener('click', () => {
|
|
const query = search.buildQuery();
|
|
if(query) search.performSearch(query);
|
|
});
|
|
|
|
elements.search.searchInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
|
|
const query = search.buildQuery();
|
|
if(query) search.performSearch(query);
|
|
}
|
|
});
|
|
|
|
// Modal close on overlay click
|
|
elements.modalOverlay.addEventListener('click', (e) => {
|
|
if (e.target === elements.modalOverlay) {
|
|
modal.close();
|
|
}
|
|
});
|
|
|
|
// Keyboard accessibility
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && elements.modalOverlay.classList.contains('active')) {
|
|
modal.close();
|
|
}
|
|
});
|
|
// Download selected books
|
|
elements.downloadSelectedButton.addEventListener('click', utils.handleDownloadSelected);
|
|
|
|
// Check/uncheck all book checkboxes
|
|
elements.selectAllCheckbox.addEventListener('change', (event) => {
|
|
const isChecked = event.target.checked;
|
|
|
|
document.querySelectorAll('.uk-checkbox').forEach((checkbox) => {
|
|
if (checkbox !== elements.selectAllCheckbox) {
|
|
checkbox.checked = isChecked;
|
|
if (isChecked) {
|
|
selectedBooks.add(checkbox.value);
|
|
} else {
|
|
selectedBooks.delete(checkbox.value);
|
|
}
|
|
}
|
|
});
|
|
utils.updateDownloadSelectedButton();
|
|
});
|
|
|
|
function setupSorting() {
|
|
const headers = document.querySelectorAll('#results-table thead th[data-sort]');
|
|
headers.forEach((header) => {
|
|
let sortOrder = 'asc';
|
|
header.addEventListener('click', () => {
|
|
const allHeaders = Array.from(document.querySelectorAll('#results-table thead th'));
|
|
const columnIndex = allHeaders.indexOf(header);
|
|
utils.sortResultsTable(columnIndex, sortOrder);
|
|
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
|
|
});
|
|
});
|
|
}
|
|
|
|
setupSorting();
|
|
|
|
}
|
|
|
|
// Initialize
|
|
function init() {
|
|
setupEventListeners();
|
|
status.fetch();
|
|
setInterval(() => status.fetch(), REFRESH_INTERVAL);
|
|
}
|
|
|
|
init();
|
|
}); |