Claude 9e544456db Initial commit with UI fixes for dark mode
This repository contains Transmission RSS Manager with the following changes:
- Fixed dark mode navigation tab visibility issue
- Improved text contrast in dark mode throughout the app
- Created dedicated dark-mode.css for better organization
- Enhanced JavaScript for dynamic styling in dark mode
- Added complete installation scripts

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-13 17:16:41 +00:00

1573 lines
60 KiB
JavaScript

document.addEventListener('DOMContentLoaded', function() {
// Initialize navigation
initNavigation();
// Initialize event listeners
initEventListeners();
// Load initial dashboard data
loadDashboardData();
// Set up dark mode based on user preference
initDarkMode();
// Set up auto refresh if enabled
initAutoRefresh();
// Initialize Bootstrap tooltips
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltips.forEach(tooltip => new bootstrap.Tooltip(tooltip));
});
function initNavigation() {
const navLinks = document.querySelectorAll('.navbar-nav .nav-link');
navLinks.forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const page = this.getAttribute('data-page');
showPage(page);
});
});
// Set active page from URL hash or default to dashboard
const hash = window.location.hash.substring(1);
showPage(hash || 'dashboard');
}
function showPage(page) {
// Hide all pages
const pages = document.querySelectorAll('.page-content');
pages.forEach(p => p.classList.add('d-none'));
// Remove active class from all nav links
const navLinks = document.querySelectorAll('.navbar-nav .nav-link');
navLinks.forEach(link => link.classList.remove('active'));
// Show selected page
const selectedPage = document.getElementById(`page-${page}`);
if (selectedPage) {
selectedPage.classList.remove('d-none');
// Set active class on nav link
const activeNav = document.querySelector(`.nav-link[data-page="${page}"]`);
if (activeNav) {
activeNav.classList.add('active');
}
// Update URL hash
window.location.hash = page;
// Load page-specific data
loadPageData(page);
}
}
function loadPageData(page) {
switch (page) {
case 'dashboard':
loadDashboardData();
break;
case 'feeds':
loadFeeds();
loadAllItems();
loadMatchedItems();
break;
case 'torrents':
loadTorrents();
break;
case 'settings':
loadSettings();
break;
}
}
function initEventListeners() {
// RSS Feeds page
document.getElementById('btn-add-feed').addEventListener('click', showAddFeedModal);
document.getElementById('btn-refresh-feeds').addEventListener('click', refreshFeeds);
document.getElementById('save-feed-btn').addEventListener('click', saveFeed);
// Torrents page
document.getElementById('btn-add-torrent').addEventListener('click', showAddTorrentModal);
document.getElementById('btn-refresh-torrents').addEventListener('click', loadTorrents);
document.getElementById('save-torrent-btn').addEventListener('click', saveTorrent);
// Logs page
document.getElementById('btn-refresh-logs').addEventListener('click', refreshLogs);
document.getElementById('btn-clear-logs').addEventListener('click', clearLogs);
document.getElementById('btn-apply-log-filters').addEventListener('click', applyLogFilters);
document.getElementById('btn-reset-log-filters').addEventListener('click', resetLogFilters);
document.getElementById('btn-export-logs').addEventListener('click', exportLogs);
// Settings page
document.getElementById('settings-form').addEventListener('submit', saveSettings);
document.getElementById('dark-mode-toggle').addEventListener('click', toggleDarkMode);
document.getElementById('btn-reset-settings').addEventListener('click', resetSettings);
// Configuration operations
document.getElementById('btn-backup-config').addEventListener('click', backupConfig);
document.getElementById('btn-reset-config').addEventListener('click', resetConfig);
// Additional Transmission Instances
document.getElementById('add-transmission-instance').addEventListener('click', addTransmissionInstance);
}
// Dashboard
function loadDashboardData() {
// Fetch dashboard statistics
fetch('/api/dashboard/stats')
.then(response => response.json())
.then(stats => {
document.getElementById('active-downloads').textContent = stats.activeDownloads;
document.getElementById('seeding-torrents').textContent = stats.seedingTorrents;
document.getElementById('active-feeds').textContent = stats.activeFeeds;
document.getElementById('completed-today').textContent = stats.completedToday;
document.getElementById('added-today').textContent = stats.addedToday;
document.getElementById('feeds-count').textContent = stats.feedsCount;
document.getElementById('matched-count').textContent = stats.matchedCount;
// Format download/upload speeds
const downloadSpeed = formatBytes(stats.downloadSpeed) + '/s';
const uploadSpeed = formatBytes(stats.uploadSpeed) + '/s';
document.getElementById('download-speed').textContent = downloadSpeed;
document.getElementById('upload-speed').textContent = uploadSpeed;
document.getElementById('current-speed').textContent = `${downloadSpeed}${uploadSpeed}`;
// Set progress bars (max 100%)
const maxSpeed = Math.max(stats.downloadSpeed, stats.uploadSpeed, 1);
const dlPercent = Math.min(Math.round((stats.downloadSpeed / maxSpeed) * 100), 100);
const ulPercent = Math.min(Math.round((stats.uploadSpeed / maxSpeed) * 100), 100);
document.getElementById('download-speed-bar').style.width = `${dlPercent}%`;
document.getElementById('upload-speed-bar').style.width = `${ulPercent}%`;
})
.catch(error => {
console.error('Error loading dashboard stats:', error);
});
// Load chart data
loadDownloadHistoryChart();
// Load other dashboard components
loadActiveTorrents();
loadRecentMatches();
}
function loadSystemStatus() {
const statusElement = document.getElementById('system-status');
statusElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
fetch('/api/config')
.then(response => response.json())
.then(config => {
// Create system status HTML
let html = '<ul class="list-group">';
html += `<li class="list-group-item d-flex justify-content-between align-items-center">Auto Download <span class="badge ${config.autoDownloadEnabled ? 'bg-success' : 'bg-danger'}">${config.autoDownloadEnabled ? 'Enabled' : 'Disabled'}</span></li>`;
html += `<li class="list-group-item d-flex justify-content-between align-items-center">Check Interval <span class="badge bg-primary">${config.checkIntervalMinutes} minutes</span></li>`;
html += `<li class="list-group-item d-flex justify-content-between align-items-center">Transmission Connection <span class="badge ${config.transmission.host ? 'bg-success' : 'bg-warning'}">${config.transmission.host ? config.transmission.host + ':' + config.transmission.port : 'Not configured'}</span></li>`;
html += `<li class="list-group-item d-flex justify-content-between align-items-center">Post Processing <span class="badge ${config.postProcessing.enabled ? 'bg-success' : 'bg-danger'}">${config.postProcessing.enabled ? 'Enabled' : 'Disabled'}</span></li>`;
html += '</ul>';
statusElement.innerHTML = html;
})
.catch(error => {
console.error('Error loading system status:', error);
statusElement.innerHTML = '<div class="alert alert-danger">Error loading system status</div>';
});
}
function loadRecentMatches() {
const matchesElement = document.getElementById('recent-matches');
matchesElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
fetch('/api/feeds/matched')
.then(response => response.json())
.then(items => {
// Sort by publish date descending and take the first 5
const recentItems = items.sort((a, b) => new Date(b.publishDate) - new Date(a.publishDate)).slice(0, 5);
if (recentItems.length === 0) {
matchesElement.innerHTML = '<div class="alert alert-info">No matched items yet</div>';
return;
}
let html = '<div class="list-group">';
recentItems.forEach(item => {
const date = new Date(item.publishDate);
html += `<a href="${item.link}" target="_blank" class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">${item.title}</h6>
<small>${formatDate(date)}</small>
</div>
<small class="d-flex justify-content-between">
<span>Matched rule: ${item.matchedRule}</span>
<span class="badge ${item.isDownloaded ? 'bg-success' : 'bg-warning'}">${item.isDownloaded ? 'Downloaded' : 'Not Downloaded'}</span>
</small>
</a>`;
});
html += '</div>';
matchesElement.innerHTML = html;
})
.catch(error => {
console.error('Error loading recent matches:', error);
matchesElement.innerHTML = '<div class="alert alert-danger">Error loading recent matches</div>';
});
}
function loadActiveTorrents() {
const torrentsElement = document.getElementById('active-torrents');
torrentsElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
fetch('/api/torrents')
.then(response => response.json())
.then(torrents => {
console.log('Dashboard torrents:', torrents);
// Sort by progress ascending and filter for active torrents
const activeTorrents = torrents
.filter(t => t && t.status && (t.status === 'Downloading' || t.status === 'Seeding'))
.sort((a, b) => (a.percentDone || 0) - (b.percentDone || 0));
if (activeTorrents.length === 0) {
torrentsElement.innerHTML = '<div class="alert alert-info">No active torrents</div>';
return;
}
let html = '<div class="list-group">';
activeTorrents.forEach(torrent => {
// Handle potential null or undefined values
if (!torrent || !torrent.name) {
return;
}
// Safely calculate percentages and sizes with error handling
let progressPercent = 0;
try {
progressPercent = Math.round((torrent.percentDone || 0) * 100);
} catch (e) {
console.warn('Error calculating progress percent:', e);
}
let sizeInGB = '0.00';
try {
if (torrent.totalSize && torrent.totalSize > 0) {
sizeInGB = (torrent.totalSize / 1073741824).toFixed(2);
}
} catch (e) {
console.warn('Error calculating size in GB:', e);
}
const torrentStatus = torrent.status || 'Unknown';
const statusClass = torrentStatus.toLowerCase().replace(/\s+/g, '-');
html += `<div class="list-group-item">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">${torrent.name}</h6>
<span class="badge bg-${statusClass === 'seeding' ? 'success' : 'primary'}">${torrentStatus}</span>
</div>
<div class="progress mt-2">
<div class="progress-bar ${torrentStatus === 'Seeding' ? 'bg-success' : 'bg-primary'}" role="progressbar" style="width: ${progressPercent}%">${progressPercent}%</div>
</div>
<small class="d-block mt-1 text-muted">Size: ${sizeInGB} GB</small>
</div>`;
});
html += '</div>';
torrentsElement.innerHTML = html;
})
.catch(error => {
console.error('Error loading active torrents:', error);
torrentsElement.innerHTML = '<div class="alert alert-danger">Error loading active torrents</div>';
});
}
// RSS Feeds
function loadFeeds() {
const feedsElement = document.getElementById('feeds-list');
feedsElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
fetch('/api/feeds')
.then(response => response.json())
.then(feeds => {
if (feeds.length === 0) {
feedsElement.innerHTML = '<div class="alert alert-info">No feeds added yet</div>';
return;
}
let html = '<div class="list-group">';
feeds.forEach(feed => {
const lastChecked = feed.lastChecked ? new Date(feed.lastChecked) : null;
html += `<div class="list-group-item list-group-item-action" data-feed-id="${feed.id}">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">${feed.name}</h5>
<small>${lastChecked ? 'Last checked: ' + formatDate(lastChecked) : 'Never checked'}</small>
</div>
<p class="mb-1"><a href="${feed.url}" target="_blank">${feed.url}</a></p>
<div class="d-flex justify-content-between align-items-center">
<small>${feed.rules.length} rules</small>
<div>
<button class="btn btn-sm btn-outline-primary me-2 btn-edit-feed" data-feed-id="${feed.id}">Edit</button>
<button class="btn btn-sm btn-outline-danger btn-delete-feed" data-feed-id="${feed.id}">Delete</button>
</div>
</div>
</div>`;
});
html += '</div>';
feedsElement.innerHTML = html;
// Add event listeners
document.querySelectorAll('.btn-edit-feed').forEach(btn => {
btn.addEventListener('click', function() {
const feedId = this.getAttribute('data-feed-id');
editFeed(feedId);
});
});
document.querySelectorAll('.btn-delete-feed').forEach(btn => {
btn.addEventListener('click', function() {
const feedId = this.getAttribute('data-feed-id');
deleteFeed(feedId);
});
});
})
.catch(error => {
console.error('Error loading feeds:', error);
feedsElement.innerHTML = '<div class="alert alert-danger">Error loading feeds</div>';
});
}
function loadAllItems() {
const itemsElement = document.getElementById('all-items-list');
itemsElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
fetch('/api/feeds/items')
.then(response => response.json())
.then(items => {
if (items.length === 0) {
itemsElement.innerHTML = '<div class="alert alert-info">No feed items yet</div>';
return;
}
let html = '<div class="feed-items-container">';
items.forEach(item => {
const date = new Date(item.publishDate);
const classes = `feed-item ${item.isMatched ? 'matched' : ''} ${item.isDownloaded ? 'downloaded' : ''}`;
html += `<div class="${classes}" data-item-id="${item.id}">
<div class="feed-item-title"><a href="${item.link}" target="_blank">${item.title}</a></div>
<div class="feed-item-date">${formatDate(date)}</div>
${item.isMatched ? `<div class="text-success small">Matched rule: ${item.matchedRule}</div>` : ''}
${item.isDownloaded ? '<div class="text-muted small">Downloaded</div>' : ''}
${!item.isDownloaded && item.isMatched ?
`<div class="feed-item-buttons">
<button class="btn btn-sm btn-primary btn-download-item" data-item-id="${item.id}">Download</button>
</div>` : ''
}
</div>`;
});
html += '</div>';
itemsElement.innerHTML = html;
// Add event listeners
document.querySelectorAll('.btn-download-item').forEach(btn => {
btn.addEventListener('click', function() {
const itemId = this.getAttribute('data-item-id');
downloadItem(itemId);
});
});
})
.catch(error => {
console.error('Error loading feed items:', error);
itemsElement.innerHTML = '<div class="alert alert-danger">Error loading feed items</div>';
});
}
function loadMatchedItems() {
const matchedElement = document.getElementById('matched-items-list');
matchedElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
fetch('/api/feeds/matched')
.then(response => response.json())
.then(items => {
if (items.length === 0) {
matchedElement.innerHTML = '<div class="alert alert-info">No matched items yet</div>';
return;
}
let html = '<div class="feed-items-container">';
items.forEach(item => {
const date = new Date(item.publishDate);
const classes = `feed-item matched ${item.isDownloaded ? 'downloaded' : ''}`;
html += `<div class="${classes}" data-item-id="${item.id}">
<div class="feed-item-title"><a href="${item.link}" target="_blank">${item.title}</a></div>
<div class="feed-item-date">${formatDate(date)}</div>
<div class="text-success small">Matched rule: ${item.matchedRule}</div>
${item.isDownloaded ? '<div class="text-muted small">Downloaded</div>' : ''}
${!item.isDownloaded ?
`<div class="feed-item-buttons">
<button class="btn btn-sm btn-primary btn-download-matched-item" data-item-id="${item.id}">Download</button>
</div>` : ''
}
</div>`;
});
html += '</div>';
matchedElement.innerHTML = html;
// Add event listeners
document.querySelectorAll('.btn-download-matched-item').forEach(btn => {
btn.addEventListener('click', function() {
const itemId = this.getAttribute('data-item-id');
downloadItem(itemId);
});
});
})
.catch(error => {
console.error('Error loading matched items:', error);
matchedElement.innerHTML = '<div class="alert alert-danger">Error loading matched items</div>';
});
}
function showAddFeedModal() {
// Clear form
document.getElementById('feed-name').value = '';
document.getElementById('feed-url').value = '';
document.getElementById('feed-rules').value = '';
document.getElementById('feed-auto-download').checked = false;
// Update modal title and button text
document.querySelector('#add-feed-modal .modal-title').textContent = 'Add RSS Feed';
document.getElementById('save-feed-btn').textContent = 'Add Feed';
// Remove feed ID data attribute
document.getElementById('save-feed-btn').removeAttribute('data-feed-id');
// Show modal
const modal = new bootstrap.Modal(document.getElementById('add-feed-modal'));
modal.show();
}
function editFeed(feedId) {
// Fetch feed data
fetch(`/api/feeds`)
.then(response => response.json())
.then(feeds => {
const feed = feeds.find(f => f.id === feedId);
if (!feed) {
alert('Feed not found');
return;
}
// Populate form
document.getElementById('feed-name').value = feed.name;
document.getElementById('feed-url').value = feed.url;
document.getElementById('feed-rules').value = feed.rules.join('\n');
document.getElementById('feed-auto-download').checked = feed.autoDownload;
// Update modal title and button text
document.querySelector('#add-feed-modal .modal-title').textContent = 'Edit RSS Feed';
document.getElementById('save-feed-btn').textContent = 'Save Changes';
// Add feed ID data attribute
document.getElementById('save-feed-btn').setAttribute('data-feed-id', feedId);
// Show modal
const modal = new bootstrap.Modal(document.getElementById('add-feed-modal'));
modal.show();
})
.catch(error => {
console.error('Error fetching feed:', error);
alert('Error fetching feed');
});
}
function saveFeed() {
const name = document.getElementById('feed-name').value.trim();
const url = document.getElementById('feed-url').value.trim();
const rulesText = document.getElementById('feed-rules').value.trim();
const autoDownload = document.getElementById('feed-auto-download').checked;
if (!name || !url) {
alert('Please enter a name and URL');
return;
}
// Parse rules (split by new line and remove empty lines)
const rules = rulesText.split('\n').filter(rule => rule.trim() !== '');
const feedId = document.getElementById('save-feed-btn').getAttribute('data-feed-id');
const isEditing = !!feedId;
const feedData = {
name: name,
url: url,
rules: rules,
autoDownload: autoDownload
};
if (isEditing) {
feedData.id = feedId;
// Update existing feed
fetch(`/api/feeds/${feedId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(feedData)
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to update feed');
}
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('add-feed-modal'));
modal.hide();
// Refresh feeds
loadFeeds();
})
.catch(error => {
console.error('Error updating feed:', error);
alert('Error updating feed');
});
} else {
// Add new feed
fetch('/api/feeds', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(feedData)
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to add feed');
}
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('add-feed-modal'));
modal.hide();
// Refresh feeds
loadFeeds();
// Also refresh items since a new feed might have new items
loadAllItems();
loadMatchedItems();
})
.catch(error => {
console.error('Error adding feed:', error);
alert('Error adding feed');
});
}
}
function deleteFeed(feedId) {
if (!confirm('Are you sure you want to delete this feed?')) {
return;
}
fetch(`/api/feeds/${feedId}`, {
method: 'DELETE'
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to delete feed');
}
// Refresh feeds
loadFeeds();
// Also refresh items since items from this feed should be removed
loadAllItems();
loadMatchedItems();
})
.catch(error => {
console.error('Error deleting feed:', error);
alert('Error deleting feed');
});
}
function refreshFeeds() {
const btn = document.getElementById('btn-refresh-feeds');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Refreshing...';
fetch('/api/feeds/refresh', {
method: 'POST'
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to refresh feeds');
}
// Re-enable button
btn.disabled = false;
btn.textContent = 'Refresh Feeds';
// Refresh feed items
loadFeeds();
loadAllItems();
loadMatchedItems();
})
.catch(error => {
console.error('Error refreshing feeds:', error);
alert('Error refreshing feeds');
// Re-enable button
btn.disabled = false;
btn.textContent = 'Refresh Feeds';
});
}
function downloadItem(itemId) {
fetch(`/api/feeds/download/${itemId}`, {
method: 'POST'
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to download item');
}
// Refresh items
loadAllItems();
loadMatchedItems();
// Also refresh torrents since a new torrent should be added
loadTorrents();
})
.catch(error => {
console.error('Error downloading item:', error);
alert('Error downloading item');
});
}
// Torrents
function loadTorrents() {
const torrentsElement = document.getElementById('torrents-list');
torrentsElement.innerHTML = '<div class="d-flex justify-content-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div></div>';
fetch('/api/torrents')
.then(response => response.json())
.then(torrents => {
console.log('Loaded torrents:', torrents);
if (torrents.length === 0) {
torrentsElement.innerHTML = '<div class="alert alert-info">No torrents</div>';
return;
}
let html = '<div class="torrents-container">';
torrents.forEach(torrent => {
// Handle potential null or undefined values
if (!torrent || !torrent.name) {
console.warn('Invalid torrent data:', torrent);
return;
}
// Safely calculate percentages and sizes with error handling
let progressPercent = 0;
try {
progressPercent = Math.round((torrent.percentDone || 0) * 100);
} catch (e) {
console.warn('Error calculating progress percent:', e);
}
let sizeInGB = '0.00';
try {
if (torrent.totalSize && torrent.totalSize > 0) {
sizeInGB = (torrent.totalSize / 1073741824).toFixed(2);
}
} catch (e) {
console.warn('Error calculating size in GB:', e);
}
const torrentStatus = torrent.status || 'Unknown';
const statusClass = torrentStatus.toLowerCase().replace(/\s+/g, '-');
html += `<div class="torrent-item" data-torrent-id="${torrent.id || 0}">
<div class="torrent-item-header">
<div class="torrent-item-title">${torrent.name}</div>
<span class="badge bg-${statusClass === 'seeding' ? 'success' : statusClass === 'downloading' ? 'primary' : statusClass === 'stopped' ? 'secondary' : 'info'}">${torrentStatus}</span>
</div>
<div class="torrent-item-progress">
<div class="progress">
<div class="progress-bar ${torrentStatus === 'Seeding' ? 'bg-success' : 'bg-primary'}" role="progressbar" style="width: ${progressPercent}%">${progressPercent}%</div>
</div>
</div>
<div class="torrent-item-details">
<span>Size: ${sizeInGB} GB</span>
<span>Location: ${torrent.downloadDir || 'Unknown'}</span>
</div>
<div class="torrent-item-buttons">
${torrentStatus === 'Stopped' ?
`<button class="btn btn-sm btn-success me-2 btn-start-torrent" data-torrent-id="${torrent.id || 0}">Start</button>` :
`<button class="btn btn-sm btn-warning me-2 btn-stop-torrent" data-torrent-id="${torrent.id || 0}">Stop</button>`
}
<button class="btn btn-sm btn-danger me-2 btn-remove-torrent" data-torrent-id="${torrent.id || 0}">Remove</button>
${progressPercent >= 100 ?
`<button class="btn btn-sm btn-info btn-process-torrent" data-torrent-id="${torrent.id || 0}">Process</button>` : ''
}
</div>
</div>`;
});
html += '</div>';
torrentsElement.innerHTML = html;
// Add event listeners
document.querySelectorAll('.btn-start-torrent').forEach(btn => {
btn.addEventListener('click', function() {
const torrentId = parseInt(this.getAttribute('data-torrent-id'));
startTorrent(torrentId);
});
});
document.querySelectorAll('.btn-stop-torrent').forEach(btn => {
btn.addEventListener('click', function() {
const torrentId = parseInt(this.getAttribute('data-torrent-id'));
stopTorrent(torrentId);
});
});
document.querySelectorAll('.btn-remove-torrent').forEach(btn => {
btn.addEventListener('click', function() {
const torrentId = parseInt(this.getAttribute('data-torrent-id'));
removeTorrent(torrentId);
});
});
document.querySelectorAll('.btn-process-torrent').forEach(btn => {
btn.addEventListener('click', function() {
const torrentId = parseInt(this.getAttribute('data-torrent-id'));
processTorrent(torrentId);
});
});
})
.catch(error => {
console.error('Error loading torrents:', error);
torrentsElement.innerHTML = '<div class="alert alert-danger">Error loading torrents</div>';
});
}
function showAddTorrentModal() {
// Clear form
document.getElementById('torrent-url').value = '';
document.getElementById('torrent-download-dir').value = '';
// Show modal
const modal = new bootstrap.Modal(document.getElementById('add-torrent-modal'));
modal.show();
}
function saveTorrent() {
const url = document.getElementById('torrent-url').value.trim();
const downloadDir = document.getElementById('torrent-download-dir').value.trim();
if (!url) {
alert('Please enter a torrent URL or magnet link');
return;
}
const torrentData = {
url: url
};
if (downloadDir) {
torrentData.downloadDir = downloadDir;
}
fetch('/api/torrents', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(torrentData)
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to add torrent');
}
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('add-torrent-modal'));
modal.hide();
// Refresh torrents
loadTorrents();
})
.catch(error => {
console.error('Error adding torrent:', error);
alert('Error adding torrent');
});
}
function startTorrent(torrentId) {
fetch(`/api/torrents/${torrentId}/start`, {
method: 'POST'
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to start torrent');
}
// Refresh torrents
loadTorrents();
})
.catch(error => {
console.error('Error starting torrent:', error);
alert('Error starting torrent');
});
}
function stopTorrent(torrentId) {
fetch(`/api/torrents/${torrentId}/stop`, {
method: 'POST'
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to stop torrent');
}
// Refresh torrents
loadTorrents();
})
.catch(error => {
console.error('Error stopping torrent:', error);
alert('Error stopping torrent');
});
}
function removeTorrent(torrentId) {
if (!confirm('Are you sure you want to remove this torrent? The downloaded files will be kept.')) {
return;
}
fetch(`/api/torrents/${torrentId}`, {
method: 'DELETE'
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to remove torrent');
}
// Refresh torrents
loadTorrents();
})
.catch(error => {
console.error('Error removing torrent:', error);
alert('Error removing torrent');
});
}
function processTorrent(torrentId) {
fetch(`/api/torrents/${torrentId}/process`, {
method: 'POST'
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to process torrent');
}
alert('Torrent processing started');
})
.catch(error => {
console.error('Error processing torrent:', error);
alert('Error processing torrent');
});
}
// Settings
function loadSettings() {
const form = document.getElementById('settings-form');
fetch('/api/config')
.then(response => response.json())
.then(config => {
// Transmission settings
document.getElementById('transmission-host').value = config.transmission.host;
document.getElementById('transmission-port').value = config.transmission.port;
document.getElementById('transmission-use-https').checked = config.transmission.useHttps;
document.getElementById('transmission-username').value = '';
document.getElementById('transmission-password').value = '';
// RSS settings
document.getElementById('auto-download-enabled').checked = config.autoDownloadEnabled;
document.getElementById('check-interval').value = config.checkIntervalMinutes;
// Directory settings
document.getElementById('download-directory').value = config.downloadDirectory;
document.getElementById('media-library').value = config.mediaLibraryPath;
// Post processing settings
document.getElementById('post-processing-enabled').checked = config.postProcessing.enabled;
document.getElementById('extract-archives').checked = config.postProcessing.extractArchives;
document.getElementById('organize-media').checked = config.postProcessing.organizeMedia;
document.getElementById('minimum-seed-ratio').value = config.postProcessing.minimumSeedRatio;
document.getElementById('media-extensions').value = config.postProcessing.mediaExtensions.join(', ');
})
.catch(error => {
console.error('Error loading settings:', error);
alert('Error loading settings');
});
}
function saveSettings(e) {
e.preventDefault();
// Show saving indicator
const saveBtn = document.querySelector('#settings-form button[type="submit"]');
const originalBtnText = saveBtn.innerHTML;
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Saving...';
const config = {
transmission: {
host: document.getElementById('transmission-host').value.trim(),
port: parseInt(document.getElementById('transmission-port').value),
useHttps: document.getElementById('transmission-use-https').checked,
username: document.getElementById('transmission-username').value.trim(),
password: document.getElementById('transmission-password').value.trim()
},
autoDownloadEnabled: document.getElementById('auto-download-enabled').checked,
checkIntervalMinutes: parseInt(document.getElementById('check-interval').value),
downloadDirectory: document.getElementById('download-directory').value.trim(),
mediaLibraryPath: document.getElementById('media-library').value.trim(),
postProcessing: {
enabled: document.getElementById('post-processing-enabled').checked,
extractArchives: document.getElementById('extract-archives').checked,
organizeMedia: document.getElementById('organize-media').checked,
minimumSeedRatio: parseInt(document.getElementById('minimum-seed-ratio').value),
mediaExtensions: document.getElementById('media-extensions').value.split(',').map(ext => ext.trim())
},
userPreferences: {
enableDarkMode: document.body.classList.contains('dark-mode'),
autoRefreshUIEnabled: localStorage.getItem('autoRefresh') !== 'false',
autoRefreshIntervalSeconds: parseInt(localStorage.getItem('refreshInterval')) || 30,
notificationsEnabled: true,
notificationEvents: ["torrent-added", "torrent-completed", "torrent-error"],
defaultView: "dashboard",
confirmBeforeDelete: true,
maxItemsPerPage: 25,
dateTimeFormat: "yyyy-MM-dd HH:mm:ss",
showCompletedTorrents: true,
keepHistoryDays: 30
},
enableDetailedLogging: false
};
// Log what we're saving to help with debugging
console.log('Saving configuration:', config);
fetch('/api/config', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(config)
})
.then(response => {
if (!response.ok) {
return response.text().then(text => {
throw new Error(`Failed to save settings: ${text}`);
});
}
return response.json();
})
.then(result => {
console.log('Settings saved successfully:', result);
// Show success message
const settingsForm = document.getElementById('settings-form');
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-success alert-dismissible fade show mt-3';
alertDiv.role = 'alert';
alertDiv.innerHTML = `
<strong>Success!</strong> Your settings have been saved.
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
`;
settingsForm.appendChild(alertDiv);
// Auto dismiss after 3 seconds
setTimeout(() => {
const bsAlert = bootstrap.Alert.getOrCreateInstance(alertDiv);
bsAlert.close();
}, 3000);
// Re-enable save button
saveBtn.disabled = false;
saveBtn.innerHTML = originalBtnText;
})
.catch(error => {
console.error('Error saving settings:', error);
// Show error message
const settingsForm = document.getElementById('settings-form');
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-danger alert-dismissible fade show mt-3';
alertDiv.role = 'alert';
alertDiv.innerHTML = `
<strong>Error!</strong> ${error.message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
`;
settingsForm.appendChild(alertDiv);
// Re-enable save button
saveBtn.disabled = false;
saveBtn.innerHTML = originalBtnText;
});
}
// Helper functions
function formatDate(date) {
if (!date) return 'N/A';
// Format as "YYYY-MM-DD HH:MM"
return `${date.getFullYear()}-${padZero(date.getMonth() + 1)}-${padZero(date.getDate())} ${padZero(date.getHours())}:${padZero(date.getMinutes())}`;
}
function padZero(num) {
return num.toString().padStart(2, '0');
}
// Dark Mode Functions
function initDarkMode() {
// Check local storage preference or system preference
const darkModePreference = localStorage.getItem('darkMode');
if (darkModePreference === 'true' ||
(darkModePreference === null && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
enableDarkMode();
} else {
disableDarkMode();
}
}
function toggleDarkMode() {
if (document.body.classList.contains('dark-mode')) {
disableDarkMode();
} else {
enableDarkMode();
}
}
function enableDarkMode() {
document.body.classList.add('dark-mode');
document.getElementById('dark-mode-toggle').innerHTML = '<i class="bi bi-sun-fill"></i>';
localStorage.setItem('darkMode', 'true');
// Also update user preferences if on settings page
const darkModeCheckbox = document.getElementById('enable-dark-mode');
if (darkModeCheckbox) {
darkModeCheckbox.checked = true;
}
}
function disableDarkMode() {
document.body.classList.remove('dark-mode');
document.getElementById('dark-mode-toggle').innerHTML = '<i class="bi bi-moon-fill"></i>';
localStorage.setItem('darkMode', 'false');
// Also update user preferences if on settings page
const darkModeCheckbox = document.getElementById('enable-dark-mode');
if (darkModeCheckbox) {
darkModeCheckbox.checked = false;
}
}
// Auto-refresh
function initAutoRefresh() {
// Get auto-refresh settings from local storage or use defaults
const autoRefresh = localStorage.getItem('autoRefresh') !== 'false';
const refreshInterval = parseInt(localStorage.getItem('refreshInterval')) || 30;
if (autoRefresh) {
startAutoRefresh(refreshInterval);
}
}
function startAutoRefresh(intervalSeconds) {
// Clear any existing interval
if (window.refreshTimer) {
clearInterval(window.refreshTimer);
}
// Set new interval
window.refreshTimer = setInterval(() => {
const currentPage = window.location.hash.substring(1) || 'dashboard';
loadPageData(currentPage);
}, intervalSeconds * 1000);
localStorage.setItem('autoRefresh', 'true');
localStorage.setItem('refreshInterval', intervalSeconds.toString());
}
function stopAutoRefresh() {
if (window.refreshTimer) {
clearInterval(window.refreshTimer);
window.refreshTimer = null;
}
localStorage.setItem('autoRefresh', 'false');
}
// Chart functions
function loadDownloadHistoryChart() {
fetch('/api/dashboard/history')
.then(response => response.json())
.then(history => {
const ctx = document.getElementById('download-history-chart').getContext('2d');
// Extract dates and count values
const labels = history.map(point => {
const date = new Date(point.date);
return `${date.getMonth() + 1}/${date.getDate()}`;
});
const countData = history.map(point => point.count);
const sizeData = history.map(point => point.totalSize / (1024 * 1024 * 1024)); // Convert to GB
// Create or update chart
if (window.downloadHistoryChart) {
window.downloadHistoryChart.data.labels = labels;
window.downloadHistoryChart.data.datasets[0].data = countData;
window.downloadHistoryChart.data.datasets[1].data = sizeData;
window.downloadHistoryChart.update();
} else {
window.downloadHistoryChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [
{
label: 'Number of Downloads',
data: countData,
backgroundColor: 'rgba(13, 110, 253, 0.5)',
borderColor: 'rgba(13, 110, 253, 1)',
borderWidth: 1,
yAxisID: 'y'
},
{
label: 'Total Size (GB)',
data: sizeData,
type: 'line',
borderColor: 'rgba(25, 135, 84, 1)',
backgroundColor: 'rgba(25, 135, 84, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
yAxisID: 'y1'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
},
tooltip: {
callbacks: {
label: function(context) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.datasetIndex === 0) {
label += context.parsed.y;
} else {
label += context.parsed.y.toFixed(2) + ' GB';
}
return label;
}
}
}
},
scales: {
y: {
type: 'linear',
display: true,
position: 'left',
title: {
display: true,
text: 'Number of Downloads'
},
beginAtZero: true
},
y1: {
type: 'linear',
display: true,
position: 'right',
title: {
display: true,
text: 'Total Size (GB)'
},
beginAtZero: true,
grid: {
drawOnChartArea: false
}
}
}
}
});
}
})
.catch(error => {
console.error('Error loading download history chart:', error);
});
}
// Logs Management
function refreshLogs() {
const logFilters = getLogFilters();
loadLogs(logFilters);
}
function getLogFilters() {
return {
level: document.getElementById('log-level').value,
search: document.getElementById('log-search').value,
dateRange: document.getElementById('log-date-range').value,
skip: 0,
take: parseInt(document.getElementById('items-per-page')?.value || 25)
};
}
function loadLogs(filters) {
const tbody = document.getElementById('logs-table-body');
tbody.innerHTML = '<tr><td colspan="4" class="text-center py-4">Loading logs...</td></tr>';
// Build query string
const query = new URLSearchParams();
if (filters.level && filters.level !== 'All') {
query.append('Level', filters.level);
}
if (filters.search) {
query.append('Search', filters.search);
}
// Handle date range
const now = new Date();
let startDate = null;
switch (filters.dateRange) {
case 'today':
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
break;
case 'yesterday':
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0, 0);
break;
case 'week':
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7, 0, 0, 0);
break;
case 'month':
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30, 0, 0, 0);
break;
}
if (startDate) {
query.append('StartDate', startDate.toISOString());
}
query.append('Skip', filters.skip.toString());
query.append('Take', filters.take.toString());
fetch(`/api/logs?${query.toString()}`)
.then(response => response.json())
.then(logs => {
if (logs.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="text-center py-4">No logs found</td></tr>';
document.getElementById('log-count').textContent = '0 entries';
document.getElementById('logs-pagination-info').textContent = 'Showing 0 of 0 entries';
return;
}
let html = '';
logs.forEach(log => {
const timestamp = new Date(log.timestamp);
const levelClass = getLevelClass(log.level);
html += `<tr>
<td class="text-nowrap">${formatDate(timestamp)}</td>
<td><span class="badge ${levelClass}">${log.level}</span></td>
<td>${log.message}</td>
<td>${log.context || ''}</td>
</tr>`;
});
tbody.innerHTML = html;
document.getElementById('log-count').textContent = `${logs.length} entries`;
document.getElementById('logs-pagination-info').textContent = `Showing ${logs.length} entries`;
// Update pagination (simplified for now)
updateLogPagination(filters, logs.length);
})
.catch(error => {
console.error('Error loading logs:', error);
tbody.innerHTML = '<tr><td colspan="4" class="text-center py-4">Error loading logs</td></tr>';
});
}
function updateLogPagination(filters, count) {
const pagination = document.getElementById('logs-pagination');
// Simplified pagination - just first page for now
pagination.innerHTML = `
<li class="page-item active">
<a class="page-link" href="#">1</a>
</li>
`;
}
function getLevelClass(level) {
switch (level.toLowerCase()) {
case 'debug':
return 'bg-secondary';
case 'information':
return 'bg-info';
case 'warning':
return 'bg-warning';
case 'error':
return 'bg-danger';
case 'critical':
return 'bg-dark';
default:
return 'bg-secondary';
}
}
function applyLogFilters() {
const filters = getLogFilters();
loadLogs(filters);
}
function resetLogFilters() {
document.getElementById('log-level').value = 'All';
document.getElementById('log-search').value = '';
document.getElementById('log-date-range').value = 'week';
loadLogs(getLogFilters());
}
function clearLogs() {
if (!confirm('Are you sure you want to clear all logs? This action cannot be undone.')) {
return;
}
fetch('/api/logs/clear', {
method: 'POST'
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to clear logs');
}
// Refresh logs
loadLogs(getLogFilters());
})
.catch(error => {
console.error('Error clearing logs:', error);
alert('Error clearing logs');
});
}
function exportLogs() {
const filters = getLogFilters();
const query = new URLSearchParams();
if (filters.level && filters.level !== 'All') {
query.append('Level', filters.level);
}
if (filters.search) {
query.append('Search', filters.search);
}
// Create download link
const link = document.createElement('a');
link.href = `/api/logs/export?${query.toString()}`;
link.download = `transmission-rss-logs-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
// Helper function to format file sizes
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 B';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
// Configuration Operations
function backupConfig() {
if (!confirm('This will create a backup of your configuration files. Do you want to continue?')) {
return;
}
fetch('/api/config/backup', {
method: 'POST'
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to backup configuration');
}
return response.blob();
})
.then(blob => {
// Create download link for the backup file
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = `transmission-rss-config-backup-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
alert('Configuration backup created successfully.');
})
.catch(error => {
console.error('Error backing up configuration:', error);
alert('Error creating configuration backup.');
});
}
function resetConfig() {
if (!confirm('WARNING: This will reset your configuration to default settings. All your feeds, rules, and user preferences will be lost. This cannot be undone. Are you absolutely sure?')) {
return;
}
if (!confirm('FINAL WARNING: All feeds, rules, and settings will be permanently deleted. Type "RESET" to confirm.')) {
return;
}
const confirmation = prompt('Type "RESET" to confirm configuration reset:');
if (confirmation !== 'RESET') {
alert('Configuration reset cancelled.');
return;
}
fetch('/api/config/reset', {
method: 'POST'
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to reset configuration');
}
alert('Configuration has been reset to defaults. The application will now reload.');
window.location.reload();
})
.catch(error => {
console.error('Error resetting configuration:', error);
alert('Error resetting configuration.');
});
}
// Transmission Instance Management
function addTransmissionInstance() {
const instancesList = document.getElementById('transmission-instances-list');
const instanceCount = document.querySelectorAll('.transmission-instance').length;
const newInstanceIndex = instanceCount + 1;
const instanceHtml = `
<div class="transmission-instance card mb-3" id="transmission-instance-${newInstanceIndex}">
<div class="card-body">
<div class="d-flex justify-content-between mb-3">
<h5 class="card-title">Instance #${newInstanceIndex}</h5>
<button type="button" class="btn btn-sm btn-danger" onclick="removeTransmissionInstance(${newInstanceIndex})">
<i class="bi bi-trash me-1"></i>Remove
</button>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" name="transmissionInstances[${newInstanceIndex}].name" placeholder="Secondary Server">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Host</label>
<input type="text" class="form-control" name="transmissionInstances[${newInstanceIndex}].host">
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Port</label>
<input type="number" class="form-control" name="transmissionInstances[${newInstanceIndex}].port" value="9091">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" class="form-control" name="transmissionInstances[${newInstanceIndex}].username">
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" class="form-control" name="transmissionInstances[${newInstanceIndex}].password">
</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch mt-4">
<input class="form-check-input" type="checkbox" name="transmissionInstances[${newInstanceIndex}].useHttps">
<label class="form-check-label">Use HTTPS</label>
</div>
</div>
</div>
</div>
</div>`;
// If the "no instances" message is showing, remove it
if (instancesList.querySelector('.text-center.text-muted')) {
instancesList.innerHTML = '';
}
// Add the new instance
instancesList.insertAdjacentHTML('beforeend', instanceHtml);
}
function removeTransmissionInstance(index) {
const instance = document.getElementById(`transmission-instance-${index}`);
if (instance) {
instance.remove();
// If there are no instances left, show the "no instances" message
const instancesList = document.getElementById('transmission-instances-list');
if (instancesList.children.length === 0) {
instancesList.innerHTML = '<div class="text-center text-muted py-3">No additional instances configured</div>';
}
}
}
function resetSettings() {
if (!confirm('This will reset all settings to their default values. Are you sure?')) {
return;
}
// Load default settings
fetch('/api/config/defaults')
.then(response => response.json())
.then(defaults => {
// Apply defaults to form
loadSettingsIntoForm(defaults);
})
.catch(error => {
console.error('Error loading default settings:', error);
alert('Error loading default settings');
});
}