Files
decypharr/pkg/web/assets/js/dashboard.js
2025-08-01 15:27:24 +01:00

1094 lines
42 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Dashboard functionality for torrent and NZB management
class Dashboard {
constructor() {
this.state = {
mode: 'torrents', // 'torrents' or 'nzbs'
torrents: [],
nzbs: [],
selectedItems: new Set(),
categories: new Set(),
filteredItems: [],
selectedCategory: '',
selectedState: '',
sortBy: 'added_on',
itemsPerPage: 20,
currentPage: 1,
selectedItemContextMenu: null
};
this.refs = {
// Mode switching
torrentsMode: document.getElementById('torrentsMode'),
nzbsMode: document.getElementById('nzbsMode'),
// Table elements
dataList: document.getElementById('dataList'),
torrentsHeaders: document.getElementById('torrentsHeaders'),
nzbsHeaders: document.getElementById('nzbsHeaders'),
// Controls
categoryFilter: document.getElementById('categoryFilter'),
stateFilter: document.getElementById('stateFilter'),
sortSelector: document.getElementById('sortSelector'),
selectAll: document.getElementById('selectAll'),
selectAllNzb: document.getElementById('selectAllNzb'),
batchDeleteBtn: document.getElementById('batchDeleteBtn'),
batchDeleteDebridBtn: document.getElementById('batchDeleteDebridBtn'),
refreshBtn: document.getElementById('refreshBtn'),
// Context menus
torrentContextMenu: document.getElementById('torrentContextMenu'),
nzbContextMenu: document.getElementById('nzbContextMenu'),
// Pagination and empty state
paginationControls: document.getElementById('paginationControls'),
paginationInfo: document.getElementById('paginationInfo'),
emptyState: document.getElementById('emptyState'),
emptyStateTitle: document.getElementById('emptyStateTitle'),
emptyStateMessage: document.getElementById('emptyStateMessage')
};
this.init();
}
init() {
this.bindEvents();
this.loadModeFromURL();
this.loadData();
this.startAutoRefresh();
}
bindEvents() {
// Mode switching
this.refs.torrentsMode.addEventListener('click', () => this.switchMode('torrents'));
this.refs.nzbsMode.addEventListener('click', () => this.switchMode('nzbs'));
// Refresh button
this.refs.refreshBtn.addEventListener('click', () => this.loadData());
// Batch delete
this.refs.batchDeleteBtn.addEventListener('click', () => this.deleteSelectedItems());
this.refs.batchDeleteDebridBtn.addEventListener('click', () => this.deleteSelectedItems(true));
// Select all checkboxes
this.refs.selectAll.addEventListener('change', (e) => this.toggleSelectAll(e.target.checked));
this.refs.selectAllNzb.addEventListener('change', (e) => this.toggleSelectAll(e.target.checked));
// Filters
this.refs.categoryFilter.addEventListener('change', (e) => this.setFilter('category', e.target.value));
this.refs.stateFilter.addEventListener('change', (e) => this.setFilter('state', e.target.value));
this.refs.sortSelector.addEventListener('change', (e) => this.setSort(e.target.value));
// Context menu
this.bindContextMenu();
// Item selection
this.refs.dataList.addEventListener('change', (e) => {
if (e.target.classList.contains('item-select')) {
this.toggleItemSelection(e.target.dataset.id, e.target.checked);
}
});
}
switchMode(mode) {
if (this.state.mode === mode) return;
this.state.mode = mode;
this.state.selectedItems.clear();
// Update URL parameter
this.updateURL(mode);
// Update button states
if (mode === 'torrents') {
this.refs.torrentsMode.classList.remove('btn-outline');
this.refs.torrentsMode.classList.add('btn-primary');
this.refs.nzbsMode.classList.remove('btn-primary');
this.refs.nzbsMode.classList.add('btn-outline');
// Show torrent headers, hide NZB headers
this.refs.torrentsHeaders.classList.remove('hidden');
this.refs.nzbsHeaders.classList.add('hidden');
// Update empty state
this.refs.emptyStateTitle.textContent = 'No Torrents Found';
this.refs.emptyStateMessage.textContent = "You haven't added any torrents yet. Start by adding your first download!";
// Show debrid batch delete button
this.refs.batchDeleteDebridBtn.classList.remove('hidden');
} else {
this.refs.nzbsMode.classList.remove('btn-outline');
this.refs.nzbsMode.classList.add('btn-primary');
this.refs.torrentsMode.classList.remove('btn-primary');
this.refs.torrentsMode.classList.add('btn-outline');
// Show NZB headers, hide torrent headers
this.refs.nzbsHeaders.classList.remove('hidden');
this.refs.torrentsHeaders.classList.add('hidden');
// Update empty state
this.refs.emptyStateTitle.textContent = 'No NZBs Found';
this.refs.emptyStateMessage.textContent = "You haven't added any NZB downloads yet. Start by adding your first NZB!";
// Hide debrid batch delete button (not relevant for NZBs)
this.refs.batchDeleteDebridBtn.classList.add('hidden');
}
// Reset filters and reload data
this.state.selectedCategory = '';
this.state.selectedState = '';
this.state.currentPage = 1;
this.refs.categoryFilter.value = '';
this.refs.stateFilter.value = '';
this.loadData();
this.updateBatchActions();
}
updateBatchActions() {
const hasSelection = this.state.selectedItems.size > 0;
// Show/hide batch delete button
if (this.refs.batchDeleteBtn) {
this.refs.batchDeleteBtn.classList.toggle('hidden', !hasSelection);
}
// Show/hide debrid batch delete button (only for torrents)
if (this.refs.batchDeleteDebridBtn) {
const showDebridButton = hasSelection && this.state.mode === 'torrents';
this.refs.batchDeleteDebridBtn.classList.toggle('hidden', !showDebridButton);
}
// Update button text with count
if (hasSelection) {
const count = this.state.selectedItems.size;
const itemType = this.state.mode === 'torrents' ? 'Torrent' : 'NZB';
const itemTypePlural = this.state.mode === 'torrents' ? 'Torrents' : 'NZBs';
if (this.refs.batchDeleteBtn) {
const deleteText = count === 1 ? `Delete ${itemType}` : `Delete ${count} ${itemTypePlural}`;
const deleteSpan = this.refs.batchDeleteBtn.querySelector('span');
if (deleteSpan) {
deleteSpan.textContent = deleteText;
}
}
if (this.refs.batchDeleteDebridBtn && this.state.mode === 'torrents') {
const debridText = count === 1 ? 'Remove From Debrid' : `Remove ${count} From Debrid`;
const debridSpan = this.refs.batchDeleteDebridBtn.querySelector('span');
if (debridSpan) {
debridSpan.textContent = debridText;
}
}
} else {
// Reset button text when no selection
if (this.refs.batchDeleteBtn) {
const deleteSpan = this.refs.batchDeleteBtn.querySelector('span');
if (deleteSpan) {
deleteSpan.textContent = 'Delete Selected';
}
}
if (this.refs.batchDeleteDebridBtn) {
const debridSpan = this.refs.batchDeleteDebridBtn.querySelector('span');
if (debridSpan) {
debridSpan.textContent = 'Remove From Debrid';
}
}
}
}
loadData() {
if (this.state.mode === 'torrents') {
this.loadTorrents();
} else {
this.loadNZBs();
}
}
async loadNZBs() {
try {
const response = await window.decypharrUtils.fetcher('/api/nzbs');
if (!response.ok) {
throw new Error('Failed to fetch NZBs');
}
const data = await response.json();
this.state.nzbs = data.nzbs || [];
this.updateCategories();
this.applyFilters();
this.renderData();
} catch (error) {
console.error('Error loading NZBs:', error);
window.decypharrUtils.createToast('Error loading NZBs', 'error');
}
}
updateCategories() {
const items = this.state.mode === 'torrents' ? this.state.torrents : this.state.nzbs;
this.state.categories = new Set(items.map(item => item.category).filter(Boolean));
}
applyFilters() {
if (this.state.mode === 'torrents') {
this.filterTorrents();
} else {
this.filterNZBs();
}
}
filterNZBs() {
let filtered = [...this.state.nzbs];
if (this.state.selectedCategory) {
filtered = filtered.filter(n => n.category === this.state.selectedCategory);
}
if (this.state.selectedState) {
filtered = filtered.filter(n => n.status === this.state.selectedState);
}
// Apply sorting
filtered.sort((a, b) => {
switch (this.state.sortBy) {
case 'added_on':
return new Date(b.added_on) - new Date(a.added_on);
case 'added_on_asc':
return new Date(a.added_on) - new Date(b.added_on);
case 'name_asc':
return a.name.localeCompare(b.name);
case 'name_desc':
return b.name.localeCompare(a.name);
case 'size_desc':
return (b.total_size || 0) - (a.total_size || 0);
case 'size_asc':
return (a.total_size || 0) - (b.total_size || 0);
case 'progress_desc':
return (b.progress || 0) - (a.progress || 0);
case 'progress_asc':
return (a.progress || 0) - (b.progress || 0);
default:
return 0;
}
});
this.state.filteredItems = filtered;
}
renderData() {
if (this.state.mode === 'torrents') {
this.renderTorrents();
} else {
this.renderNZBs();
}
}
renderNZBs() {
const startIndex = (this.state.currentPage - 1) * this.state.itemsPerPage;
const endIndex = startIndex + this.state.itemsPerPage;
const pageItems = this.state.filteredItems.slice(startIndex, endIndex);
const tbody = this.refs.dataList;
tbody.innerHTML = '';
if (pageItems.length === 0) {
this.refs.emptyState.classList.remove('hidden');
} else {
this.refs.emptyState.classList.add('hidden');
pageItems.forEach(nzb => {
const row = document.createElement('tr');
row.className = 'hover cursor-pointer';
row.setAttribute('data-id', nzb.id);
row.setAttribute('data-name', nzb.name);
row.setAttribute('data-category', nzb.category || '');
const progressPercent = Math.round(nzb.progress || 0);
const sizeFormatted = this.formatBytes(nzb.total_size || 0);
const etaFormatted = this.formatETA(nzb.eta || 0);
const ageFormatted = this.formatAge(nzb.date_posted);
const statusBadge = this.getStatusBadge(nzb.status);
row.innerHTML = `
<td class="w-12">
<label class="cursor-pointer">
<input type="checkbox" class="checkbox checkbox-sm item-select" data-id="${nzb.id}">
</label>
</td>
<td class="font-medium max-w-xs">
<div class="truncate" title="${nzb.name}">${nzb.name}</div>
</td>
<td>${sizeFormatted}</td>
<td>
<div class="flex items-center gap-2">
<div class="w-16 bg-base-300 rounded-full h-2">
<div class="bg-primary h-2 rounded-full transition-all duration-300" style="width: ${progressPercent}%"></div>
</div>
<span class="text-sm font-medium">${progressPercent}%</span>
</div>
</td>
<td>${etaFormatted}</td>
<td>
<span class="badge badge-ghost badge-sm">${nzb.category || 'N/A'}</span>
</td>
<td>${statusBadge}</td>
<td>${ageFormatted}</td>
<td>
<div class="flex gap-1">
<button class="btn btn-ghost btn-xs" onclick="window.dashboard.deleteNZB('${nzb.id}');" title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
`;
tbody.appendChild(row);
});
}
this.updatePagination();
this.updateSelectionUI();
}
getStatusBadge(status) {
const statusMap = {
'downloading': '<span class="badge badge-info badge-sm">Downloading</span>',
'completed': '<span class="badge badge-success badge-sm">Completed</span>',
'paused': '<span class="badge badge-warning badge-sm">Paused</span>',
'failed': '<span class="badge badge-error badge-sm">Failed</span>',
'queued': '<span class="badge badge-ghost badge-sm">Queued</span>',
'processing': '<span class="badge badge-info badge-sm">Processing</span>',
'verifying': '<span class="badge badge-info badge-sm">Verifying</span>',
'repairing': '<span class="badge badge-warning badge-sm">Repairing</span>',
'extracting': '<span class="badge badge-info badge-sm">Extracting</span>'
};
return statusMap[status] || '<span class="badge badge-ghost badge-sm">Unknown</span>';
}
formatETA(seconds) {
if (!seconds || seconds <= 0) return 'N/A';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
} else {
return `${minutes}m`;
}
}
formatAge(datePosted) {
if (!datePosted) return 'N/A';
const now = new Date();
const posted = new Date(datePosted);
const diffMs = now - posted;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return 'Today';
} else if (diffDays === 1) {
return '1 day';
} else {
return `${diffDays} days`;
}
}
formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
bindContextMenu() {
// Show context menu
this.refs.dataList.addEventListener('contextmenu', (e) => {
const row = e.target.closest('tr[data-id]');
if (!row) return;
e.preventDefault();
this.showContextMenu(e, row);
});
// Hide context menu
document.addEventListener('click', (e) => {
const torrentMenu = this.refs.torrentContextMenu;
const nzbMenu = this.refs.nzbContextMenu;
if (!torrentMenu.contains(e.target) && !nzbMenu.contains(e.target)) {
this.hideContextMenu();
}
});
// Context menu actions for torrents
this.refs.torrentContextMenu.addEventListener('click', (e) => {
const action = e.target.closest('[data-action]')?.dataset.action;
if (action) {
this.handleContextAction(action);
this.hideContextMenu();
}
});
// Context menu actions for NZBs
this.refs.nzbContextMenu.addEventListener('click', (e) => {
const action = e.target.closest('[data-action]')?.dataset.action;
if (action) {
this.handleContextAction(action);
this.hideContextMenu();
}
});
}
showContextMenu(event, row) {
const { pageX, pageY } = event;
const { clientWidth, clientHeight } = document.documentElement;
if (this.state.mode === 'torrents') {
this.state.selectedItemContextMenu = {
id: row.dataset.hash,
name: row.dataset.name,
category: row.dataset.category || '',
type: 'torrent'
};
const menu = this.refs.torrentContextMenu;
menu.querySelector('.torrent-name').textContent = this.state.selectedItemContextMenu.name;
// Position the menu
menu.style.left = `${Math.min(pageX, clientWidth - 200)}px`;
menu.style.top = `${Math.min(pageY, clientHeight - 150)}px`;
menu.classList.remove('hidden');
} else {
this.state.selectedItemContextMenu = {
id: row.dataset.id,
name: row.dataset.name,
category: row.dataset.category || '',
type: 'nzb'
};
const menu = this.refs.nzbContextMenu;
menu.querySelector('.nzb-name').textContent = this.state.selectedItemContextMenu.name;
// Position the menu
menu.style.left = `${Math.min(pageX, clientWidth - 200)}px`;
menu.style.top = `${Math.min(pageY, clientHeight - 150)}px`;
menu.classList.remove('hidden');
}
}
hideContextMenu() {
this.refs.torrentContextMenu.classList.add('hidden');
this.refs.nzbContextMenu.classList.add('hidden');
this.state.selectedItemContextMenu = null;
}
async handleContextAction(action) {
const item = this.state.selectedItemContextMenu;
if (!item) return;
if (item.type === 'torrent') {
await this.handleTorrentAction(action, item);
} else {
await this.handleNZBAction(action, item);
}
}
async handleTorrentAction(action, torrent) {
const actions = {
'copy-magnet': async () => {
try {
await navigator.clipboard.writeText(`magnet:?xt=urn:btih:${torrent.hash}`);
window.decypharrUtils.createToast('Magnet link copied to clipboard');
} catch (error) {
window.decypharrUtils.createToast('Failed to copy magnet link', 'error');
}
},
'copy-name': async () => {
try {
await navigator.clipboard.writeText(torrent.name);
window.decypharrUtils.createToast('Torrent name copied to clipboard');
} catch (error) {
window.decypharrUtils.createToast('Failed to copy torrent name', 'error');
}
},
'delete': async () => {
await this.deleteTorrent(torrent.hash, torrent.category, false);
}
};
if (actions[action]) {
await actions[action]();
}
}
async handleNZBAction(action, nzb) {
const actions = {
'pause': async () => {
try {
const response = await window.decypharrUtils.fetcher(`/api/nzbs/${nzb.id}/pause`, {
method: 'POST'
});
if (response.ok) {
window.decypharrUtils.createToast('NZB paused successfully');
this.loadData();
} else {
throw new Error('Failed to pause NZB');
}
} catch (error) {
window.decypharrUtils.createToast('Failed to pause NZB', 'error');
}
},
'resume': async () => {
try {
const response = await window.decypharrUtils.fetcher(`/api/nzbs/${nzb.id}/resume`, {
method: 'POST'
});
if (response.ok) {
window.decypharrUtils.createToast('NZB resumed successfully');
this.loadData();
} else {
throw new Error('Failed to resume NZB');
}
} catch (error) {
window.decypharrUtils.createToast('Failed to resume NZB', 'error');
}
},
'retry': async () => {
try {
const response = await window.decypharrUtils.fetcher(`/api/nzbs/${nzb.id}/retry`, {
method: 'POST'
});
if (response.ok) {
window.decypharrUtils.createToast('NZB retry started successfully');
this.loadData();
} else {
throw new Error('Failed to retry NZB');
}
} catch (error) {
window.decypharrUtils.createToast('Failed to retry NZB', 'error');
}
},
'copy-name': async () => {
try {
await navigator.clipboard.writeText(nzb.name);
window.decypharrUtils.createToast('NZB name copied to clipboard');
} catch (error) {
window.decypharrUtils.createToast('Failed to copy NZB name', 'error');
}
},
'delete': async () => {
await this.deleteNZB(nzb.id);
}
};
if (actions[action]) {
await actions[action]();
}
}
async deleteNZB(nzbId) {
try {
const response = await window.decypharrUtils.fetcher(`/api/nzbs/${nzbId}`, {
method: 'DELETE'
});
if (response.ok) {
window.decypharrUtils.createToast('NZB deleted successfully');
this.loadData();
} else {
throw new Error('Failed to delete NZB');
}
} catch (error) {
window.decypharrUtils.createToast('Failed to delete NZB', 'error');
}
}
async loadTorrents() {
try {
// Show loading state
this.refs.refreshBtn.disabled = true;
this.refs.paginationInfo.textContent = 'Loading torrents...';
const response = await window.decypharrUtils.fetcher('/api/torrents');
if (!response.ok) throw new Error('Failed to fetch torrents');
const torrents = await response.json();
this.state.torrents = torrents;
this.state.categories = new Set(torrents.map(t => t.category).filter(Boolean));
this.updateUI();
} catch (error) {
console.error('Error loading torrents:', error);
window.decypharrUtils.createToast(`Error loading torrents: ${error.message}`, 'error');
} finally {
this.refs.refreshBtn.disabled = false;
}
}
updateUI() {
// Apply filters based on current mode
this.applyFilters();
// Update category dropdown
this.updateCategoryFilter();
// Render data table
this.renderData();
// Update pagination
this.updatePagination();
// Update selection state
this.updateSelectionUI();
// Show/hide empty state
this.toggleEmptyState();
}
filterTorrents() {
let filtered = [...this.state.torrents];
if (this.state.selectedCategory) {
filtered = filtered.filter(t => t.category === this.state.selectedCategory);
}
if (this.state.selectedState) {
filtered = filtered.filter(t => t.state?.toLowerCase() === this.state.selectedState.toLowerCase());
}
// Sort torrents
filtered = this.sortTorrents(filtered);
this.state.filteredItems = filtered;
}
sortTorrents(torrents) {
const [field, direction] = this.state.sortBy.includes('_asc') || this.state.sortBy.includes('_desc')
? [this.state.sortBy.split('_').slice(0, -1).join('_'), this.state.sortBy.endsWith('_asc') ? 'asc' : 'desc']
: [this.state.sortBy, 'desc'];
return torrents.sort((a, b) => {
let valueA, valueB;
switch (field) {
case 'name':
valueA = a.name?.toLowerCase() || '';
valueB = b.name?.toLowerCase() || '';
break;
case 'size':
valueA = a.size || 0;
valueB = b.size || 0;
break;
case 'progress':
valueA = a.progress || 0;
valueB = b.progress || 0;
break;
case 'added_on':
valueA = a.added_on || 0;
valueB = b.added_on || 0;
break;
default:
valueA = a[field] || 0;
valueB = b[field] || 0;
}
if (typeof valueA === 'string') {
return direction === 'asc'
? valueA.localeCompare(valueB)
: valueB.localeCompare(valueA);
} else {
return direction === 'asc'
? valueA - valueB
: valueB - valueA;
}
});
}
renderTorrents() {
const startIndex = (this.state.currentPage - 1) * this.state.itemsPerPage;
const endIndex = Math.min(startIndex + this.state.itemsPerPage, this.state.filteredItems.length);
const pageItems = this.state.filteredItems.slice(startIndex, endIndex);
this.refs.dataList.innerHTML = pageItems.map(torrent => this.torrentRowTemplate(torrent)).join('');
}
torrentRowTemplate(torrent) {
const progressPercent = (torrent.progress * 100).toFixed(1);
const isSelected = this.state.selectedItems.has(torrent.hash);
let addedOn = new Date(torrent.added_on).toLocaleString();
return `
<tr data-id="${torrent.hash}"
data-name="${this.escapeHtml(torrent.name)}"
data-category="${torrent.category || ''}"
class="hover:bg-base-200 transition-colors">
<td>
<label class="cursor-pointer">
<input type="checkbox"
class="checkbox checkbox-sm item-select"
data-id="${torrent.hash}"
${isSelected ? 'checked' : ''}>
</label>
</td>
<td class="max-w-xs">
<div class="truncate font-medium" title="${this.escapeHtml(torrent.name)}">
${this.escapeHtml(torrent.name)}
</div>
</td>
<td class="text-nowrap font-mono text-sm">
${window.decypharrUtils.formatBytes(torrent.size)}
</td>
<td class="min-w-36">
<div class="flex items-center gap-3">
<progress class="progress progress-primary w-20 h-2"
value="${progressPercent}"
max="100"></progress>
<span class="text-sm font-medium min-w-12">${progressPercent}%</span>
</div>
</td>
<td class="text-nowrap font-mono text-sm">
${window.decypharrUtils.formatSpeed(torrent.dlspeed)}
</td>
<td>
${torrent.category ?
`<div class="badge badge-secondary badge-sm">${this.escapeHtml(torrent.category)}</div>` :
'<span class="text-base-content/50">None</span>'
}
</td>
<td>
${torrent.debrid ?
`<div class="badge badge-accent badge-sm">${this.escapeHtml(torrent.debrid)}</div>` :
'<span class="text-base-content/50">None</span>'
}
</td>
<td class="text-nowrap font-mono text-sm">
${torrent.num_seeds || 0}
</td>
<td>
<div class="badge ${this.getStateColor(torrent.state)} badge-sm">
${this.escapeHtml(torrent.state)}
</div>
</td>
<td>
<div class="flex gap-1">
<button class="btn btn-error btn-outline btn-xs tooltip"
onclick="dashboard.deleteTorrent('${torrent.hash}', '${torrent.category || ''}', false);"
data-tip="Delete from local">
<i class="bi bi-trash"></i>
</button>
${torrent.debrid && torrent.id ? `
<button class="btn btn-error btn-outline btn-xs tooltip"
onclick="dashboard.deleteTorrent('${torrent.hash}', '${torrent.category || ''}', true);"
data-tip="Remove from ${torrent.debrid}">
<i class="bi bi-cloud-slash"></i>
</button>
` : ''}
</div>
</td>
</tr>
`;
}
getStateColor(state) {
const stateColors = {
'downloading': 'badge-primary',
'pausedup': 'badge-success',
'error': 'badge-error',
'completed': 'badge-success'
};
return stateColors[state?.toLowerCase()] || 'badge-ghost';
}
updateCategoryFilter() {
const currentCategories = Array.from(this.state.categories).sort();
const categoryOptions = ['<option value="">All Categories</option>']
.concat(currentCategories.map(cat =>
`<option value="${this.escapeHtml(cat)}" ${cat === this.state.selectedCategory ? 'selected' : ''}>
${this.escapeHtml(cat)}
</option>`
));
this.refs.categoryFilter.innerHTML = categoryOptions.join('');
}
updatePagination() {
const totalPages = Math.ceil(this.state.filteredItems.length / this.state.itemsPerPage);
const startIndex = (this.state.currentPage - 1) * this.state.itemsPerPage;
const endIndex = Math.min(startIndex + this.state.itemsPerPage, this.state.filteredItems.length);
// Update pagination info
this.refs.paginationInfo.textContent =
`Showing ${this.state.filteredItems.length > 0 ? startIndex + 1 : 0}-${endIndex} of ${this.state.filteredItems.length} torrents`;
// Clear pagination controls
this.refs.paginationControls.innerHTML = '';
if (totalPages <= 1) return;
// Previous button
const prevBtn = this.createPaginationButton('', this.state.currentPage - 1, this.state.currentPage === 1);
this.refs.paginationControls.appendChild(prevBtn);
// Page numbers
const maxPageButtons = 5;
let startPage = Math.max(1, this.state.currentPage - Math.floor(maxPageButtons / 2));
let endPage = Math.min(totalPages, startPage + maxPageButtons - 1);
if (endPage - startPage + 1 < maxPageButtons) {
startPage = Math.max(1, endPage - maxPageButtons + 1);
}
for (let i = startPage; i <= endPage; i++) {
const pageBtn = this.createPaginationButton(i, i, false, i === this.state.currentPage);
this.refs.paginationControls.appendChild(pageBtn);
}
// Next button
const nextBtn = this.createPaginationButton('', this.state.currentPage + 1, this.state.currentPage === totalPages);
this.refs.paginationControls.appendChild(nextBtn);
}
createPaginationButton(text, page, disabled = false, active = false) {
const button = document.createElement('button');
button.className = `join-item btn btn-sm ${active ? 'btn-active' : ''} ${disabled ? 'btn-disabled' : ''}`;
button.textContent = text;
button.disabled = disabled;
if (!disabled) {
button.addEventListener('click', () => {
this.state.currentPage = page;
this.updateUI();
});
}
return button;
}
updateSelectionUI() {
// Clean up selected torrents that no longer exist
const currentHashes = new Set(this.state.filteredItems.map(t => t.hash));
this.state.selectedItems.forEach(hash => {
if (!currentHashes.has(hash)) {
this.state.selectedItems.delete(hash);
}
});
// Update batch delete button
this.refs.batchDeleteBtn.classList.toggle('hidden', this.state.selectedItems.size === 0);
this.refs.batchDeleteDebridBtn.classList.toggle('hidden', this.state.selectedItems.size === 0);
// Update select all checkbox
const visibleTorrents = this.state.filteredItems.slice(
(this.state.currentPage - 1) * this.state.itemsPerPage,
this.state.currentPage * this.state.itemsPerPage
);
this.refs.selectAll.checked = visibleTorrents.length > 0 &&
visibleTorrents.every(torrent => this.state.selectedItems.has(torrent.hash));
this.refs.selectAll.indeterminate = visibleTorrents.some(torrent => this.state.selectedItems.has(torrent.hash)) &&
!visibleTorrents.every(torrent => this.state.selectedItems.has(torrent.hash));
}
toggleEmptyState() {
const items = this.state.mode === 'torrents' ? this.state.torrents : this.state.nzbs;
const isEmpty = items.length === 0;
if (this.refs.emptyState) {
this.refs.emptyState.classList.toggle('hidden', !isEmpty);
}
// Find the main data table card and toggle its visibility
const dataTableCard = document.querySelector('.card:has(#dataList)');
if (dataTableCard) {
dataTableCard.classList.toggle('hidden', isEmpty);
}
}
// Event handlers
setFilter(type, value) {
if (type === 'category') {
this.state.selectedCategory = value;
} else if (type === 'state') {
this.state.selectedState = value;
}
this.state.currentPage = 1;
this.updateUI();
}
setSort(sortBy) {
this.state.sortBy = sortBy;
this.state.currentPage = 1;
this.updateUI();
}
toggleSelectAll(checked) {
const visibleTorrents = this.state.filteredItems.slice(
(this.state.currentPage - 1) * this.state.itemsPerPage,
this.state.currentPage * this.state.itemsPerPage
);
visibleTorrents.forEach(torrent => {
if (checked) {
this.state.selectedItems.add(torrent.hash);
} else {
this.state.selectedItems.delete(torrent.hash);
}
});
this.updateUI();
}
toggleItemSelection(id, checked) {
if (checked) {
this.state.selectedItems.add(id);
} else {
this.state.selectedItems.delete(id);
}
this.updateSelectionUI();
this.updateBatchActions();
}
async deleteTorrent(hash, category, removeFromDebrid = false) {
if (!confirm(`Are you sure you want to delete this torrent${removeFromDebrid ? ' from ' + category : ''}?`)) {
return;
}
try {
const endpoint = `/api/torrents/${encodeURIComponent(category)}/${hash}?removeFromDebrid=${removeFromDebrid}`;
const response = await window.decypharrUtils.fetcher(endpoint, { method: 'DELETE' });
if (!response.ok) throw new Error(await response.text());
window.decypharrUtils.createToast('Torrent deleted successfully');
await this.loadTorrents();
} catch (error) {
console.error('Error deleting torrent:', error);
window.decypharrUtils.createToast(`Failed to delete torrent: ${error.message}`, 'error');
}
}
async deleteSelectedItems(removeFromDebrid = false) {
const count = this.state.selectedItems.size;
if (count === 0) {
const itemType = this.state.mode === 'torrents' ? 'torrents' : 'NZBs';
window.decypharrUtils.createToast(`No ${itemType} selected for deletion`, 'warning');
return;
}
const itemType = this.state.mode === 'torrents' ? 'torrent' : 'NZB';
const itemTypePlural = this.state.mode === 'torrents' ? 'torrents' : 'NZBs';
if (!confirm(`Are you sure you want to delete ${count} ${count > 1 ? itemTypePlural : itemType}${removeFromDebrid ? ' from debrid' : ''}?`)) {
return;
}
try {
if (this.state.mode === 'torrents') {
const hashes = Array.from(this.state.selectedItems).join(',');
const response = await window.decypharrUtils.fetcher(
`/api/torrents/?hashes=${encodeURIComponent(hashes)}&removeFromDebrid=${removeFromDebrid}`,
{ method: 'DELETE' }
);
if (!response.ok) throw new Error(await response.text());
} else {
// Delete NZBs one by one
const promises = Array.from(this.state.selectedItems).map(id =>
window.decypharrUtils.fetcher(`/api/nzbs/${id}`, { method: 'DELETE' })
);
const responses = await Promise.all(promises);
for (const response of responses) {
if (!response.ok) throw new Error(await response.text());
}
}
window.decypharrUtils.createToast(`${count} ${count > 1 ? itemTypePlural : itemType} deleted successfully`);
this.state.selectedItems.clear();
await this.loadData();
} catch (error) {
console.error(`Error deleting ${itemTypePlural}:`, error);
window.decypharrUtils.createToast(`Failed to delete some ${itemTypePlural}: ${error.message}`, 'error');
}
}
startAutoRefresh() {
this.refreshInterval = setInterval(() => {
this.loadData();
}, 5000);
// Clean up on page unload
window.addEventListener('beforeunload', () => {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
});
}
escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text ? text.replace(/[&<>"']/g, (m) => map[m]) : '';
}
loadModeFromURL() {
const urlParams = new URLSearchParams(window.location.search);
const mode = urlParams.get('mode');
if (mode === 'nzbs' || mode === 'torrents') {
this.state.mode = mode;
} else {
this.state.mode = 'torrents'; // Default mode
}
// Set the initial UI state without triggering reload
this.setModeUI(this.state.mode);
}
setModeUI(mode) {
if (mode === 'torrents') {
this.refs.torrentsMode.classList.remove('btn-outline');
this.refs.torrentsMode.classList.add('btn-primary');
this.refs.nzbsMode.classList.remove('btn-primary');
this.refs.nzbsMode.classList.add('btn-outline');
this.refs.torrentsHeaders.classList.remove('hidden');
this.refs.nzbsHeaders.classList.add('hidden');
this.refs.emptyStateTitle.textContent = 'No Torrents Found';
this.refs.emptyStateMessage.textContent = "You haven't added any torrents yet. Start by adding your first download!";
this.refs.batchDeleteDebridBtn.classList.remove('hidden');
} else {
this.refs.nzbsMode.classList.remove('btn-outline');
this.refs.nzbsMode.classList.add('btn-primary');
this.refs.torrentsMode.classList.remove('btn-primary');
this.refs.torrentsMode.classList.add('btn-outline');
this.refs.nzbsHeaders.classList.remove('hidden');
this.refs.torrentsHeaders.classList.add('hidden');
this.refs.emptyStateTitle.textContent = 'No NZBs Found';
this.refs.emptyStateMessage.textContent = "You haven't added any NZB downloads yet. Start by adding your first NZB!";
this.refs.batchDeleteDebridBtn.classList.add('hidden');
}
}
updateURL(mode) {
const url = new URL(window.location);
url.searchParams.set('mode', mode);
window.history.replaceState({}, '', url);
}
}