Testing a new UI
This commit is contained in:
638
pkg/web/assets/js/common.js
Normal file
638
pkg/web/assets/js/common.js
Normal file
@@ -0,0 +1,638 @@
|
||||
// Common utilities and functions
|
||||
class DecypharrUtils {
|
||||
constructor() {
|
||||
this.urlBase = window.urlBase || '';
|
||||
this.toastContainer = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupToastSystem();
|
||||
this.setupThemeToggle();
|
||||
this.setupPasswordToggles();
|
||||
this.setupVersionInfo();
|
||||
this.setupGlobalEventListeners();
|
||||
this.createToastContainer();
|
||||
}
|
||||
|
||||
// Create toast container if it doesn't exist
|
||||
createToastContainer() {
|
||||
let container = document.querySelector('.toast-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'toast-container fixed bottom-4 right-4 z-50 space-y-2';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
this.toastContainer = container;
|
||||
}
|
||||
|
||||
// Setup toast system
|
||||
setupToastSystem() {
|
||||
// Add toast CSS styles
|
||||
this.addToastStyles();
|
||||
|
||||
// Global toast handler
|
||||
window.addEventListener('error', (e) => {
|
||||
console.error('Global error:', e.error);
|
||||
this.createToast(`Unexpected error: ${e.error?.message || 'Unknown error'}`, 'error');
|
||||
});
|
||||
|
||||
// Handle unhandled promise rejections
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
console.error('Unhandled promise rejection:', e.reason);
|
||||
this.createToast(`Promise rejected: ${e.reason?.message || 'Unknown error'}`, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Add toast styles to document
|
||||
addToastStyles() {
|
||||
if (document.getElementById('toast-styles')) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'toast-styles';
|
||||
style.textContent = `
|
||||
@keyframes toastSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toastSlideOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.toast-container .alert {
|
||||
animation: toastSlideIn 0.3s ease-out;
|
||||
max-width: 400px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.toast-container .alert.toast-closing {
|
||||
animation: toastSlideOut 0.3s ease-in forwards;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.toast-container {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
}
|
||||
|
||||
.toast-container .alert {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// URL joining utility
|
||||
joinURL(base, path) {
|
||||
if (!base.endsWith('/')) base += '/';
|
||||
if (path.startsWith('/')) path = path.substring(1);
|
||||
return base + path;
|
||||
}
|
||||
|
||||
// Enhanced fetch wrapper
|
||||
async fetcher(endpoint, options = {}) {
|
||||
const url = this.joinURL(this.urlBase, endpoint);
|
||||
|
||||
// Handle FormData - don't set Content-Type for FormData
|
||||
const defaultOptions = {
|
||||
headers: {},
|
||||
...options
|
||||
};
|
||||
|
||||
// Only set Content-Type if not FormData
|
||||
if (!(options.body instanceof FormData)) {
|
||||
defaultOptions.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
// Merge headers
|
||||
defaultOptions.headers = {
|
||||
...defaultOptions.headers,
|
||||
...options.headers
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, defaultOptions);
|
||||
|
||||
// Add loading state management
|
||||
if (options.loadingButton) {
|
||||
this.setButtonLoading(options.loadingButton, false);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (options.loadingButton) {
|
||||
this.setButtonLoading(options.loadingButton, false);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced toast system
|
||||
createToast(message, type = 'success', duration = null) {
|
||||
const toastTimeouts = {
|
||||
success: 5000,
|
||||
warning: 10000,
|
||||
error: 15000,
|
||||
info: 7000
|
||||
};
|
||||
|
||||
type = ['success', 'warning', 'error', 'info'].includes(type) ? type : 'success';
|
||||
duration = duration || toastTimeouts[type];
|
||||
|
||||
// Ensure toast container exists
|
||||
if (!this.toastContainer) {
|
||||
this.createToastContainer();
|
||||
}
|
||||
|
||||
const toastId = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const alertTypeClass = {
|
||||
success: 'alert-success',
|
||||
warning: 'alert-warning',
|
||||
error: 'alert-error',
|
||||
info: 'alert-info'
|
||||
};
|
||||
|
||||
const icons = {
|
||||
success: '<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>',
|
||||
error: '<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>',
|
||||
warning: '<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>',
|
||||
info: '<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>'
|
||||
};
|
||||
|
||||
const toastHtml = `
|
||||
<div id="${toastId}" class="alert ${alertTypeClass[type]} shadow-lg mb-2">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-6 h-6 shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
${icons[type]}
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<span class="text-sm">${message.replace(/\n/g, '<br>')}</span>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-ghost btn-circle" onclick="window.decypharrUtils.closeToast('${toastId}');">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.toastContainer.insertAdjacentHTML('beforeend', toastHtml);
|
||||
|
||||
// Auto-close toast
|
||||
const timeoutId = setTimeout(() => this.closeToast(toastId), duration);
|
||||
|
||||
// Store timeout ID for manual closing
|
||||
const toastElement = document.getElementById(toastId);
|
||||
if (toastElement) {
|
||||
toastElement.dataset.timeoutId = timeoutId;
|
||||
}
|
||||
|
||||
return toastId;
|
||||
}
|
||||
|
||||
closeToast(toastId) {
|
||||
const toastElement = document.getElementById(toastId);
|
||||
if (toastElement) {
|
||||
// Clear auto-close timeout
|
||||
if (toastElement.dataset.timeoutId) {
|
||||
clearTimeout(parseInt(toastElement.dataset.timeoutId));
|
||||
}
|
||||
|
||||
toastElement.classList.add('toast-closing');
|
||||
setTimeout(() => {
|
||||
if (toastElement.parentNode) {
|
||||
toastElement.remove();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
// Close all toasts
|
||||
closeAllToasts() {
|
||||
const toasts = this.toastContainer?.querySelectorAll('.alert');
|
||||
if (toasts) {
|
||||
toasts.forEach(toast => {
|
||||
if (toast.id) {
|
||||
this.closeToast(toast.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Button loading state management
|
||||
setButtonLoading(buttonElement, loading = true, originalText = null) {
|
||||
if (typeof buttonElement === 'string') {
|
||||
buttonElement = document.getElementById(buttonElement) || document.querySelector(buttonElement);
|
||||
}
|
||||
|
||||
if (!buttonElement) return;
|
||||
|
||||
if (loading) {
|
||||
buttonElement.disabled = true;
|
||||
if (!buttonElement.dataset.originalText) {
|
||||
buttonElement.dataset.originalText = originalText || buttonElement.innerHTML;
|
||||
}
|
||||
buttonElement.innerHTML = '<span class="loading loading-spinner loading-sm"></span>Processing...';
|
||||
buttonElement.classList.add('loading-state');
|
||||
} else {
|
||||
buttonElement.disabled = false;
|
||||
buttonElement.innerHTML = buttonElement.dataset.originalText || 'Submit';
|
||||
buttonElement.classList.remove('loading-state');
|
||||
delete buttonElement.dataset.originalText;
|
||||
}
|
||||
}
|
||||
|
||||
// Password field utilities
|
||||
setupPasswordToggles() {
|
||||
document.addEventListener('click', (e) => {
|
||||
const toggleBtn = e.target.closest('.password-toggle-btn');
|
||||
if (toggleBtn) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Find the associated input field
|
||||
const container = toggleBtn.closest('.password-toggle-container');
|
||||
if (container) {
|
||||
const input = container.querySelector('input, textarea');
|
||||
const icon = toggleBtn.querySelector('i');
|
||||
if (input && icon) {
|
||||
this.togglePasswordField(input, icon);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
togglePasswordField(field, icon) {
|
||||
if (!icon) return;
|
||||
|
||||
if (field.tagName.toLowerCase() === 'textarea') {
|
||||
this.togglePasswordTextarea(field, icon);
|
||||
} else {
|
||||
this.togglePasswordInput(field, icon);
|
||||
}
|
||||
}
|
||||
|
||||
togglePasswordInput(field, icon) {
|
||||
if (field.type === 'password') {
|
||||
field.type = 'text';
|
||||
icon.className = 'bi bi-eye-slash';
|
||||
} else {
|
||||
field.type = 'password';
|
||||
icon.className = 'bi bi-eye';
|
||||
}
|
||||
}
|
||||
|
||||
togglePasswordTextarea(field, icon) {
|
||||
const isHidden = field.style.webkitTextSecurity === 'disc' ||
|
||||
field.style.webkitTextSecurity === '' ||
|
||||
field.getAttribute('data-password-visible') !== 'true';
|
||||
|
||||
if (isHidden) {
|
||||
field.style.webkitTextSecurity = 'none';
|
||||
field.style.textSecurity = 'none';
|
||||
field.setAttribute('data-password-visible', 'true');
|
||||
icon.className = 'bi bi-eye-slash';
|
||||
} else {
|
||||
field.style.webkitTextSecurity = 'disc';
|
||||
field.style.textSecurity = 'disc';
|
||||
field.setAttribute('data-password-visible', 'false');
|
||||
icon.className = 'bi bi-eye';
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy methods for backward compatibility
|
||||
togglePassword(fieldId) {
|
||||
const field = document.getElementById(fieldId);
|
||||
const button = field?.closest('.password-toggle-container')?.querySelector('.password-toggle-btn');
|
||||
let icon = button.querySelector("i");
|
||||
if (field && icon) {
|
||||
this.togglePasswordField(field, icon);
|
||||
}
|
||||
}
|
||||
|
||||
// Theme management
|
||||
setupThemeToggle() {
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
const htmlElement = document.documentElement;
|
||||
|
||||
if (!themeToggle) return;
|
||||
|
||||
const setTheme = (theme) => {
|
||||
htmlElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
themeToggle.checked = theme === 'dark';
|
||||
|
||||
// Smooth theme transition
|
||||
document.body.style.transition = 'background-color 0.3s ease, color 0.3s ease';
|
||||
setTimeout(() => {
|
||||
document.body.style.transition = '';
|
||||
}, 300);
|
||||
|
||||
// Emit theme change event
|
||||
window.dispatchEvent(new CustomEvent('themechange', { detail: { theme } }));
|
||||
};
|
||||
|
||||
// Load saved theme
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme) {
|
||||
setTheme(savedTheme);
|
||||
} else if (window.matchMedia?.('(prefers-color-scheme: dark)').matches) {
|
||||
setTheme('dark');
|
||||
} else {
|
||||
setTheme('light');
|
||||
}
|
||||
|
||||
// Theme toggle event
|
||||
themeToggle.addEventListener('change', () => {
|
||||
const currentTheme = htmlElement.getAttribute('data-theme');
|
||||
setTheme(currentTheme === 'dark' ? 'light' : 'dark');
|
||||
});
|
||||
|
||||
// Listen for system theme changes
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
|
||||
if (!localStorage.getItem('theme')) {
|
||||
setTheme(e.matches ? 'dark' : 'light');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Version info
|
||||
async setupVersionInfo() {
|
||||
try {
|
||||
const response = await this.fetcher('/version');
|
||||
if (!response.ok) throw new Error('Failed to fetch version');
|
||||
|
||||
const data = await response.json();
|
||||
const versionBadge = document.getElementById('version-badge');
|
||||
|
||||
if (versionBadge) {
|
||||
versionBadge.innerHTML = `
|
||||
<a href="https://github.com/sirrobot01/decypharr/releases/tag/${data.version}"
|
||||
target="_blank"
|
||||
class="text-current hover:text-primary transition-colors">
|
||||
${data.channel}-${data.version}
|
||||
</a>
|
||||
`;
|
||||
|
||||
// Remove existing badge classes
|
||||
versionBadge.classList.remove('badge-warning', 'badge-error', 'badge-ghost');
|
||||
|
||||
if (data.channel === 'beta') {
|
||||
versionBadge.classList.add('badge-warning');
|
||||
} else if (data.channel === 'nightly') {
|
||||
versionBadge.classList.add('badge-error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching version:', error);
|
||||
const versionBadge = document.getElementById('version-badge');
|
||||
if (versionBadge) {
|
||||
versionBadge.textContent = 'Unknown';
|
||||
versionBadge.classList.add('badge-ghost');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global event listeners
|
||||
setupGlobalEventListeners() {
|
||||
// Smooth scroll for anchor links
|
||||
document.addEventListener('click', (e) => {
|
||||
const link = e.target.closest('a[href^="#"]');
|
||||
if (link && link.getAttribute('href') !== '#') {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(link.getAttribute('href'));
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Enhanced form validation
|
||||
document.addEventListener('invalid', (e) => {
|
||||
e.target.classList.add('input-error');
|
||||
setTimeout(() => e.target.classList.remove('input-error'), 3000);
|
||||
}, true);
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Escape key closes modals and dropdowns
|
||||
if (e.key === 'Escape') {
|
||||
// Close modals
|
||||
document.querySelectorAll('.modal[open]').forEach(modal => modal.close());
|
||||
|
||||
// Close dropdowns
|
||||
document.querySelectorAll('.dropdown-open').forEach(dropdown => {
|
||||
dropdown.classList.remove('dropdown-open');
|
||||
});
|
||||
|
||||
// Close context menus
|
||||
document.querySelectorAll('.context-menu:not(.hidden)').forEach(menu => {
|
||||
menu.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + / for help (if help system exists)
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
|
||||
e.preventDefault();
|
||||
this.showKeyboardShortcuts();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle page visibility changes
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
// Page is hidden - pause auto-refresh timers if any
|
||||
window.dispatchEvent(new CustomEvent('pageHidden'));
|
||||
} else {
|
||||
// Page is visible - resume auto-refresh timers if any
|
||||
window.dispatchEvent(new CustomEvent('pageVisible'));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle online/offline status
|
||||
window.addEventListener('online', () => {
|
||||
this.createToast('Connection restored', 'success');
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
this.createToast('Connection lost - working offline', 'warning');
|
||||
});
|
||||
}
|
||||
|
||||
// Show keyboard shortcuts modal
|
||||
showKeyboardShortcuts() {
|
||||
const shortcuts = [
|
||||
{ key: 'Esc', description: 'Close modals and dropdowns' },
|
||||
{ key: 'Ctrl + /', description: 'Show this help' },
|
||||
{ key: 'Ctrl + R', description: 'Refresh page' }
|
||||
];
|
||||
|
||||
const modal = document.createElement('dialog');
|
||||
modal.className = 'modal';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-box">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
</form>
|
||||
<h3 class="font-bold text-lg mb-4">Keyboard Shortcuts</h3>
|
||||
<div class="space-y-2">
|
||||
${shortcuts.map(shortcut => `
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="kbd kbd-sm">${shortcut.key}</span>
|
||||
<span class="text-sm">${shortcut.description}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
modal.showModal();
|
||||
|
||||
modal.addEventListener('close', () => {
|
||||
document.body.removeChild(modal);
|
||||
});
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
formatBytes(bytes) {
|
||||
if (!bytes || bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
formatSpeed(speed) {
|
||||
return `${this.formatBytes(speed)}/s`;
|
||||
}
|
||||
|
||||
formatDuration(seconds) {
|
||||
if (!seconds || seconds === 0) return '0s';
|
||||
|
||||
const units = [
|
||||
{ label: 'd', seconds: 86400 },
|
||||
{ label: 'h', seconds: 3600 },
|
||||
{ label: 'm', seconds: 60 },
|
||||
{ label: 's', seconds: 1 }
|
||||
];
|
||||
|
||||
const parts = [];
|
||||
let remaining = seconds;
|
||||
|
||||
for (const unit of units) {
|
||||
const count = Math.floor(remaining / unit.seconds);
|
||||
if (count > 0) {
|
||||
parts.push(`${count}${unit.label}`);
|
||||
remaining %= unit.seconds;
|
||||
}
|
||||
}
|
||||
|
||||
return parts.slice(0, 2).join(' ') || '0s';
|
||||
}
|
||||
|
||||
// Debounce function
|
||||
debounce(func, wait, immediate = false) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
timeout = null;
|
||||
if (!immediate) func(...args);
|
||||
};
|
||||
const callNow = immediate && !timeout;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
if (callNow) func(...args);
|
||||
};
|
||||
}
|
||||
|
||||
// Throttle function
|
||||
throttle(func, limit) {
|
||||
let inThrottle;
|
||||
return function(...args) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Copy to clipboard utility
|
||||
async copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
this.createToast('Copied to clipboard', 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error);
|
||||
this.createToast('Failed to copy to clipboard', 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate URL
|
||||
isValidUrl(string) {
|
||||
try {
|
||||
new URL(string);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Escape HTML
|
||||
escapeHtml(text) {
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
return text ? text.replace(/[&<>"']/g, (m) => map[m]) : '';
|
||||
}
|
||||
|
||||
// Get current theme
|
||||
getCurrentTheme() {
|
||||
return document.documentElement.getAttribute('data-theme') || 'light';
|
||||
}
|
||||
|
||||
// Network status
|
||||
isOnline() {
|
||||
return navigator.onLine;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize utilities
|
||||
window.decypharrUtils = new DecypharrUtils();
|
||||
|
||||
// Global functions for backward compatibility
|
||||
window.fetcher = (endpoint, options = {}) => window.decypharrUtils.fetcher(endpoint, options);
|
||||
window.createToast = (message, type, duration) => window.decypharrUtils.createToast(message, type, duration);
|
||||
|
||||
// Export for ES6 modules if needed
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = DecypharrUtils;
|
||||
}
|
||||
1214
pkg/web/assets/js/config.js
Normal file
1214
pkg/web/assets/js/config.js
Normal file
File diff suppressed because it is too large
Load Diff
553
pkg/web/assets/js/dashboard.js
Normal file
553
pkg/web/assets/js/dashboard.js
Normal file
@@ -0,0 +1,553 @@
|
||||
// Dashboard functionality for torrent management
|
||||
class TorrentDashboard {
|
||||
constructor() {
|
||||
this.state = {
|
||||
torrents: [],
|
||||
selectedTorrents: new Set(),
|
||||
categories: new Set(),
|
||||
filteredTorrents: [],
|
||||
selectedCategory: '',
|
||||
selectedState: '',
|
||||
sortBy: 'added_on',
|
||||
itemsPerPage: 20,
|
||||
currentPage: 1,
|
||||
selectedTorrentContextMenu: null
|
||||
};
|
||||
|
||||
this.refs = {
|
||||
torrentsList: document.getElementById('torrentsList'),
|
||||
categoryFilter: document.getElementById('categoryFilter'),
|
||||
stateFilter: document.getElementById('stateFilter'),
|
||||
sortSelector: document.getElementById('sortSelector'),
|
||||
selectAll: document.getElementById('selectAll'),
|
||||
batchDeleteBtn: document.getElementById('batchDeleteBtn'),
|
||||
refreshBtn: document.getElementById('refreshBtn'),
|
||||
torrentContextMenu: document.getElementById('torrentContextMenu'),
|
||||
paginationControls: document.getElementById('paginationControls'),
|
||||
paginationInfo: document.getElementById('paginationInfo'),
|
||||
emptyState: document.getElementById('emptyState')
|
||||
};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.bindEvents();
|
||||
this.loadTorrents();
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Refresh button
|
||||
this.refs.refreshBtn.addEventListener('click', () => this.loadTorrents());
|
||||
|
||||
// Batch delete
|
||||
this.refs.batchDeleteBtn.addEventListener('click', () => this.deleteSelectedTorrents());
|
||||
|
||||
// Select all checkbox
|
||||
this.refs.selectAll.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();
|
||||
|
||||
// Torrent selection
|
||||
this.refs.torrentsList.addEventListener('change', (e) => {
|
||||
if (e.target.classList.contains('torrent-select')) {
|
||||
this.toggleTorrentSelection(e.target.dataset.hash, e.target.checked);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bindContextMenu() {
|
||||
// Show context menu
|
||||
this.refs.torrentsList.addEventListener('contextmenu', (e) => {
|
||||
const row = e.target.closest('tr[data-hash]');
|
||||
if (!row) return;
|
||||
|
||||
e.preventDefault();
|
||||
this.showContextMenu(e, row);
|
||||
});
|
||||
|
||||
// Hide context menu
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!this.refs.torrentContextMenu.contains(e.target)) {
|
||||
this.hideContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Context menu actions
|
||||
this.refs.torrentContextMenu.addEventListener('click', (e) => {
|
||||
const action = e.target.closest('[data-action]')?.dataset.action;
|
||||
if (action) {
|
||||
this.handleContextAction(action);
|
||||
this.hideContextMenu();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
showContextMenu(event, row) {
|
||||
this.state.selectedTorrentContextMenu = {
|
||||
hash: row.dataset.hash,
|
||||
name: row.dataset.name,
|
||||
category: row.dataset.category || ''
|
||||
};
|
||||
|
||||
this.refs.torrentContextMenu.querySelector('.torrent-name').textContent =
|
||||
this.state.selectedTorrentContextMenu.name;
|
||||
|
||||
const { pageX, pageY } = event;
|
||||
const { clientWidth, clientHeight } = document.documentElement;
|
||||
const menu = this.refs.torrentContextMenu;
|
||||
|
||||
// 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.state.selectedTorrentContextMenu = null;
|
||||
}
|
||||
|
||||
async handleContextAction(action) {
|
||||
const torrent = this.state.selectedTorrentContextMenu;
|
||||
if (!torrent) return;
|
||||
|
||||
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 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() {
|
||||
// Filter torrents
|
||||
this.filterTorrents();
|
||||
|
||||
// Update category dropdown
|
||||
this.updateCategoryFilter();
|
||||
|
||||
// Render torrents table
|
||||
this.renderTorrents();
|
||||
|
||||
// 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.filteredTorrents = 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.filteredTorrents.length);
|
||||
const pageItems = this.state.filteredTorrents.slice(startIndex, endIndex);
|
||||
|
||||
this.refs.torrentsList.innerHTML = pageItems.map(torrent => this.torrentRowTemplate(torrent)).join('');
|
||||
}
|
||||
|
||||
torrentRowTemplate(torrent) {
|
||||
const progressPercent = (torrent.progress * 100).toFixed(1);
|
||||
const isSelected = this.state.selectedTorrents.has(torrent.hash);
|
||||
|
||||
return `
|
||||
<tr data-hash="${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 torrent-select"
|
||||
data-hash="${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>
|
||||
<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.filteredTorrents.length / this.state.itemsPerPage);
|
||||
const startIndex = (this.state.currentPage - 1) * this.state.itemsPerPage;
|
||||
const endIndex = Math.min(startIndex + this.state.itemsPerPage, this.state.filteredTorrents.length);
|
||||
|
||||
// Update pagination info
|
||||
this.refs.paginationInfo.textContent =
|
||||
`Showing ${this.state.filteredTorrents.length > 0 ? startIndex + 1 : 0}-${endIndex} of ${this.state.filteredTorrents.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.filteredTorrents.map(t => t.hash));
|
||||
this.state.selectedTorrents.forEach(hash => {
|
||||
if (!currentHashes.has(hash)) {
|
||||
this.state.selectedTorrents.delete(hash);
|
||||
}
|
||||
});
|
||||
|
||||
// Update batch delete button
|
||||
this.refs.batchDeleteBtn.classList.toggle('hidden', this.state.selectedTorrents.size === 0);
|
||||
|
||||
// Update select all checkbox
|
||||
const visibleTorrents = this.state.filteredTorrents.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.selectedTorrents.has(torrent.hash));
|
||||
this.refs.selectAll.indeterminate = visibleTorrents.some(torrent => this.state.selectedTorrents.has(torrent.hash)) &&
|
||||
!visibleTorrents.every(torrent => this.state.selectedTorrents.has(torrent.hash));
|
||||
}
|
||||
|
||||
toggleEmptyState() {
|
||||
const isEmpty = this.state.torrents.length === 0;
|
||||
this.refs.emptyState.classList.toggle('hidden', !isEmpty);
|
||||
document.querySelector('.card:has(#torrentsList)').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.filteredTorrents.slice(
|
||||
(this.state.currentPage - 1) * this.state.itemsPerPage,
|
||||
this.state.currentPage * this.state.itemsPerPage
|
||||
);
|
||||
|
||||
visibleTorrents.forEach(torrent => {
|
||||
if (checked) {
|
||||
this.state.selectedTorrents.add(torrent.hash);
|
||||
} else {
|
||||
this.state.selectedTorrents.delete(torrent.hash);
|
||||
}
|
||||
});
|
||||
|
||||
this.updateUI();
|
||||
}
|
||||
|
||||
toggleTorrentSelection(hash, checked) {
|
||||
if (checked) {
|
||||
this.state.selectedTorrents.add(hash);
|
||||
} else {
|
||||
this.state.selectedTorrents.delete(hash);
|
||||
}
|
||||
this.updateSelectionUI();
|
||||
}
|
||||
|
||||
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 deleteSelectedTorrents() {
|
||||
const count = this.state.selectedTorrents.size;
|
||||
if (!confirm(`Are you sure you want to delete ${count} selected torrent${count > 1 ? 's' : ''}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const hashes = Array.from(this.state.selectedTorrents).join(',');
|
||||
const response = await window.decypharrUtils.fetcher(
|
||||
`/api/torrents/?hashes=${encodeURIComponent(hashes)}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
|
||||
window.decypharrUtils.createToast(`${count} torrent${count > 1 ? 's' : ''} deleted successfully`);
|
||||
this.state.selectedTorrents.clear();
|
||||
await this.loadTorrents();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting torrents:', error);
|
||||
window.decypharrUtils.createToast(`Failed to delete some torrents: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
startAutoRefresh() {
|
||||
this.refreshInterval = setInterval(() => {
|
||||
this.loadTorrents();
|
||||
}, 5000);
|
||||
|
||||
// Clean up on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
return text ? text.replace(/[&<>"']/g, (m) => map[m]) : '';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize dashboard when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.dashboard = new TorrentDashboard();
|
||||
});
|
||||
250
pkg/web/assets/js/download.js
Normal file
250
pkg/web/assets/js/download.js
Normal file
@@ -0,0 +1,250 @@
|
||||
// Download page functionality
|
||||
class DownloadManager {
|
||||
constructor() {
|
||||
this.downloadFolder = '{{ .DownloadFolder }}' || '';
|
||||
this.refs = {
|
||||
downloadForm: document.getElementById('downloadForm'),
|
||||
magnetURI: document.getElementById('magnetURI'),
|
||||
torrentFiles: document.getElementById('torrentFiles'),
|
||||
arr: document.getElementById('arr'),
|
||||
downloadAction: document.getElementById('downloadAction'),
|
||||
downloadUncached: document.getElementById('downloadUncached'),
|
||||
downloadFolder: document.getElementById('downloadFolder'),
|
||||
debrid: document.getElementById('debrid'),
|
||||
submitBtn: document.getElementById('submitDownload'),
|
||||
activeCount: document.getElementById('activeCount'),
|
||||
completedCount: document.getElementById('completedCount'),
|
||||
totalSize: document.getElementById('totalSize')
|
||||
};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.loadSavedOptions();
|
||||
this.bindEvents();
|
||||
this.handleMagnetFromURL();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Form submission
|
||||
this.refs.downloadForm.addEventListener('submit', (e) => this.handleSubmit(e));
|
||||
|
||||
// Save options on change
|
||||
this.refs.arr.addEventListener('change', () => this.saveOptions());
|
||||
this.refs.downloadAction.addEventListener('change', () => this.saveOptions());
|
||||
this.refs.downloadUncached.addEventListener('change', () => this.saveOptions());
|
||||
this.refs.downloadFolder.addEventListener('change', () => this.saveOptions());
|
||||
|
||||
// File input enhancement
|
||||
this.refs.torrentFiles.addEventListener('change', (e) => this.handleFileSelection(e));
|
||||
|
||||
// Drag and drop
|
||||
this.setupDragAndDrop();
|
||||
}
|
||||
|
||||
loadSavedOptions() {
|
||||
const savedOptions = {
|
||||
category: localStorage.getItem('downloadCategory') || '',
|
||||
action: localStorage.getItem('downloadAction') || 'symlink',
|
||||
uncached: localStorage.getItem('downloadUncached') === 'true',
|
||||
folder: localStorage.getItem('downloadFolder') || this.downloadFolder
|
||||
};
|
||||
|
||||
this.refs.arr.value = savedOptions.category;
|
||||
this.refs.downloadAction.value = savedOptions.action;
|
||||
this.refs.downloadUncached.checked = savedOptions.uncached;
|
||||
this.refs.downloadFolder.value = savedOptions.folder;
|
||||
}
|
||||
|
||||
saveOptions() {
|
||||
localStorage.setItem('downloadCategory', this.refs.arr.value);
|
||||
localStorage.setItem('downloadAction', this.refs.downloadAction.value);
|
||||
localStorage.setItem('downloadUncached', this.refs.downloadUncached.checked.toString());
|
||||
localStorage.setItem('downloadFolder', this.refs.downloadFolder.value);
|
||||
}
|
||||
|
||||
handleMagnetFromURL() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const magnetURI = urlParams.get('magnet');
|
||||
|
||||
if (magnetURI) {
|
||||
this.refs.magnetURI.value = magnetURI;
|
||||
history.replaceState({}, document.title, window.location.pathname);
|
||||
|
||||
// Show notification
|
||||
window.decypharrUtils.createToast('Magnet link loaded from URL', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
async handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
// Get URLs
|
||||
const urls = this.refs.magnetURI.value
|
||||
.split('\n')
|
||||
.map(url => url.trim())
|
||||
.filter(url => url.length > 0);
|
||||
|
||||
if (urls.length > 0) {
|
||||
formData.append('urls', urls.join('\n'));
|
||||
}
|
||||
|
||||
// Get files
|
||||
for (let i = 0; i < this.refs.torrentFiles.files.length; i++) {
|
||||
formData.append('files', this.refs.torrentFiles.files[i]);
|
||||
}
|
||||
|
||||
// Validation
|
||||
const totalItems = urls.length + this.refs.torrentFiles.files.length;
|
||||
if (totalItems === 0) {
|
||||
window.decypharrUtils.createToast('Please provide at least one torrent', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (totalItems > 100) {
|
||||
window.decypharrUtils.createToast('Please submit up to 100 torrents at a time', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add other form data
|
||||
formData.append('arr', this.refs.arr.value);
|
||||
formData.append('downloadFolder', this.refs.downloadFolder.value);
|
||||
formData.append('action', this.refs.downloadAction.value);
|
||||
formData.append('downloadUncached', this.refs.downloadUncached.checked);
|
||||
|
||||
if (this.refs.debrid) {
|
||||
formData.append('debrid', this.refs.debrid.value);
|
||||
}
|
||||
|
||||
try {
|
||||
// Set loading state
|
||||
window.decypharrUtils.setButtonLoading(this.refs.submitBtn, true);
|
||||
|
||||
const response = await window.decypharrUtils.fetcher('/api/add', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {} // Remove Content-Type to let browser set it for FormData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Unknown error');
|
||||
}
|
||||
|
||||
// Handle partial success
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
if (result.results.length > 0) {
|
||||
window.decypharrUtils.createToast(
|
||||
`Added ${result.results.length} torrents with ${result.errors.length} errors`,
|
||||
'warning'
|
||||
);
|
||||
this.showErrorDetails(result.errors);
|
||||
} else {
|
||||
window.decypharrUtils.createToast('Failed to add torrents', 'error');
|
||||
this.showErrorDetails(result.errors);
|
||||
}
|
||||
} else {
|
||||
window.decypharrUtils.createToast(
|
||||
`Successfully added ${result.results.length} torrent${result.results.length > 1 ? 's' : ''}!`
|
||||
);
|
||||
this.clearForm();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error adding downloads:', error);
|
||||
window.decypharrUtils.createToast(`Error adding downloads: ${error.message}`, 'error');
|
||||
} finally {
|
||||
window.decypharrUtils.setButtonLoading(this.refs.submitBtn, false);
|
||||
}
|
||||
}
|
||||
|
||||
showErrorDetails(errors) {
|
||||
// Create a modal or detailed view for errors
|
||||
const errorList = errors.map(error => `• ${error}`).join('\n');
|
||||
console.error('Download errors:', errorList);
|
||||
|
||||
// You could also show this in a modal for better UX
|
||||
setTimeout(() => {
|
||||
if (confirm('Some torrents failed to add. Would you like to see the details?')) {
|
||||
alert(errorList);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
clearForm() {
|
||||
this.refs.magnetURI.value = '';
|
||||
this.refs.torrentFiles.value = '';
|
||||
}
|
||||
|
||||
handleFileSelection(e) {
|
||||
const files = e.target.files;
|
||||
if (files.length > 0) {
|
||||
const fileNames = Array.from(files).map(f => f.name).join(', ');
|
||||
window.decypharrUtils.createToast(
|
||||
`Selected ${files.length} file${files.length > 1 ? 's' : ''}: ${fileNames}`,
|
||||
'info'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setupDragAndDrop() {
|
||||
const dropZone = this.refs.downloadForm;
|
||||
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, this.preventDefaults, false);
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, () => this.highlight(dropZone), false);
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, () => this.unhighlight(dropZone), false);
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => this.handleDrop(e), false);
|
||||
}
|
||||
|
||||
preventDefaults(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
highlight(element) {
|
||||
element.classList.add('border-primary', 'border-2', 'border-dashed', 'bg-primary/5');
|
||||
}
|
||||
|
||||
unhighlight(element) {
|
||||
element.classList.remove('border-primary', 'border-2', 'border-dashed', 'bg-primary/5');
|
||||
}
|
||||
|
||||
handleDrop(e) {
|
||||
const dt = e.dataTransfer;
|
||||
const files = dt.files;
|
||||
|
||||
// Filter for .torrent files
|
||||
const torrentFiles = Array.from(files).filter(file =>
|
||||
file.name.toLowerCase().endsWith('.torrent')
|
||||
);
|
||||
|
||||
if (torrentFiles.length > 0) {
|
||||
// Create a new FileList-like object
|
||||
const dataTransfer = new DataTransfer();
|
||||
torrentFiles.forEach(file => dataTransfer.items.add(file));
|
||||
this.refs.torrentFiles.files = dataTransfer.files;
|
||||
|
||||
this.handleFileSelection({ target: { files: torrentFiles } });
|
||||
} else {
|
||||
window.decypharrUtils.createToast('Please drop .torrent files only', 'warning');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize download manager when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.downloadManager = new DownloadManager();
|
||||
});
|
||||
1119
pkg/web/assets/js/repair.js
Normal file
1119
pkg/web/assets/js/repair.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user