// Upload functionality and UI management class GatewayUI { constructor() { this.currentUpload = null; this.recentUploads = JSON.parse(localStorage.getItem('recentUploads') || '[]'); this.serviceStatus = {}; this.initializeElements(); this.attachEventListeners(); this.initializeTheme(); this.loadRecentUploads(); this.checkServiceStatus(); this.loadServerFiles(); // Update service status every 30 seconds setInterval(() => this.checkServiceStatus(), 30000); } initializeElements() { // Upload elements this.uploadArea = document.getElementById('upload-area'); this.fileInput = document.getElementById('file-input'); this.uploadProgress = document.getElementById('upload-progress'); this.progressFill = document.getElementById('progress-fill'); this.progressPercent = document.getElementById('progress-percent'); this.progressSpeed = document.getElementById('progress-speed'); this.progressEta = document.getElementById('progress-eta'); this.uploadFilename = document.getElementById('upload-filename'); // Options this.announceDht = document.getElementById('announce-dht'); this.storeBlossom = document.getElementById('store-blossom'); // Lists this.uploadsList = document.getElementById('uploads-list'); // Toast container this.toastContainer = document.getElementById('toast-container'); } attachEventListeners() { // File upload - only add if not already attached if (!this.uploadArea.hasAttribute('data-events-attached')) { this.uploadArea.addEventListener('click', (e) => { // Prevent double clicks if (e.detail === 1) { this.fileInput.click(); } }); this.uploadArea.setAttribute('data-events-attached', 'true'); } this.fileInput.addEventListener('change', (e) => this.handleFileSelect(e.target.files)); // Drag and drop this.uploadArea.addEventListener('dragover', (e) => this.handleDragOver(e)); this.uploadArea.addEventListener('dragleave', (e) => this.handleDragLeave(e)); this.uploadArea.addEventListener('drop', (e) => this.handleDrop(e)); // Prevent default drag behaviors on document document.addEventListener('dragover', (e) => e.preventDefault()); document.addEventListener('drop', (e) => e.preventDefault()); } initializeTheme() { const savedTheme = localStorage.getItem('theme') || 'light'; document.documentElement.setAttribute('data-theme', savedTheme); } handleDragOver(e) { e.preventDefault(); e.stopPropagation(); this.uploadArea.classList.add('drag-over'); } handleDragLeave(e) { e.preventDefault(); e.stopPropagation(); this.uploadArea.classList.remove('drag-over'); } handleDrop(e) { e.preventDefault(); e.stopPropagation(); this.uploadArea.classList.remove('drag-over'); const files = Array.from(e.dataTransfer.files); this.handleFileSelect(files); } handleFileSelect(files) { if (files.length === 0) return; // For now, handle one file at a time const file = files[0]; // Validate file const validation = this.validateFile(file); if (!validation.valid) { this.showToast(validation.message, 'error'); this.fileInput.value = ''; // Clear the input return; } this.uploadFile(file); } validateFile(file) { // Check file existence if (!file) { return { valid: false, message: 'No file selected' }; } // Check file size (10GB default limit - server will enforce actual limit) const maxSize = 10 * 1024 * 1024 * 1024; // 10GB if (file.size > maxSize) { return { valid: false, message: `File too large. Maximum size is ${this.formatBytes(maxSize)} (selected: ${this.formatBytes(file.size)})` }; } if (file.size === 0) { return { valid: false, message: 'Cannot upload empty file' }; } // Check filename if (!file.name || file.name.trim() === '') { return { valid: false, message: 'File must have a valid name' }; } if (file.name.length > 255) { return { valid: false, message: 'Filename too long (max 255 characters)' }; } // Check for dangerous characters in filename const dangerousChars = ['..', '/', '\\', ':', '*', '?', '"', '<', '>', '|']; for (const char of dangerousChars) { if (file.name.includes(char)) { return { valid: false, message: `Filename cannot contain '${char}' character` }; } } // Check file type (basic validation) const allowedTypes = [ // Video 'video/mp4', 'video/avi', 'video/mkv', 'video/mov', 'video/webm', // Audio 'audio/mp3', 'audio/wav', 'audio/flac', 'audio/m4a', 'audio/ogg', // Images 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp', // Documents 'application/pdf', 'text/plain', 'application/zip', 'application/x-rar-compressed', // Archives 'application/x-7z-compressed', 'application/x-tar', 'application/gzip' ]; // If type is provided and not in allowed list, show warning but allow if (file.type && !allowedTypes.includes(file.type) && !file.type.startsWith('application/')) { console.warn(`Unusual file type: ${file.type}`); } return { valid: true }; } async uploadFile(file) { if (this.currentUpload) { this.showToast('Another upload is in progress', 'warning'); return; } // Create FormData const formData = new FormData(); formData.append('file', file); // Add options if (this.announceDht.checked) { formData.append('announce_dht', 'true'); } if (this.storeBlossom.checked) { formData.append('store_blossom', 'true'); } // Show progress this.showUploadProgress(file.name); try { this.currentUpload = { file: file, startTime: Date.now(), abort: new AbortController() }; const headers = {}; if (window.nostrAuth && window.nostrAuth.sessionToken) { headers['Authorization'] = `Bearer ${window.nostrAuth.sessionToken}`; console.log('Upload with auth token:', window.nostrAuth.sessionToken.substring(0, 20) + '...'); } else { console.log('Upload without auth - nostrAuth:', !!window.nostrAuth, 'sessionToken:', !!window.nostrAuth?.sessionToken); } const response = await fetch('/api/upload', { method: 'POST', headers: headers, body: formData, signal: this.currentUpload.abort.signal }); if (!response.ok) { throw new Error(`Upload failed: ${response.status} ${response.statusText}`); } const result = await response.json(); this.handleUploadSuccess(file, result); } catch (error) { if (error.name === 'AbortError') { this.showToast('Upload cancelled', 'warning'); } else { console.error('Upload error:', error); // Provide user-friendly error messages let message = 'Upload failed'; if (error.message.includes('413') || error.message.includes('too large')) { message = 'File too large. Please choose a smaller file.'; } else if (error.message.includes('415') || error.message.includes('unsupported')) { message = 'File type not supported. Please try a different file.'; } else if (error.message.includes('429') || error.message.includes('rate limit')) { message = 'Upload rate limit exceeded. Please wait and try again.'; } else if (error.message.includes('401') || error.message.includes('unauthorized')) { message = 'Please login to upload files.'; } else if (error.message.includes('403') || error.message.includes('forbidden')) { message = 'Upload not allowed. Check your permissions.'; } else if (error.message.includes('507') || error.message.includes('storage')) { message = 'Server storage full. Please try again later.'; } else if (error.message.includes('NetworkError') || error.message.includes('fetch')) { message = 'Network error. Please check your connection and try again.'; } else if (error.message.includes('timeout')) { message = 'Upload timed out. Please try again with a smaller file.'; } this.showToast(message, 'error'); } } finally { this.hideUploadProgress(); this.currentUpload = null; } } showUploadProgress(filename) { this.uploadFilename.textContent = filename; this.uploadProgress.classList.remove('hidden'); this.uploadArea.style.display = 'none'; // Start progress simulation (since we can't track real progress easily) this.simulateProgress(); } simulateProgress() { let progress = 0; const startTime = Date.now(); const updateProgress = () => { if (!this.currentUpload) return; // Simulate realistic progress curve progress += (100 - progress) * 0.05; const elapsed = (Date.now() - startTime) / 1000; const speed = (this.currentUpload.file.size * (progress / 100)) / elapsed; const remaining = (this.currentUpload.file.size - (this.currentUpload.file.size * (progress / 100))) / speed; this.progressFill.style.width = `${progress}%`; this.progressPercent.textContent = `${Math.round(progress)}%`; this.progressSpeed.textContent = this.formatBytes(speed) + '/s'; this.progressEta.textContent = this.formatTime(remaining); if (progress < 95 && this.currentUpload) { setTimeout(updateProgress, 100); } }; updateProgress(); } hideUploadProgress() { this.uploadProgress.classList.add('hidden'); this.uploadArea.style.display = 'block'; this.progressFill.style.width = '0%'; this.fileInput.value = ''; } handleUploadSuccess(file, result) { this.showToast('File uploaded successfully!', 'success'); // Add to recent uploads const uploadRecord = { id: result.file_hash || result.hash, name: file.name, size: file.size, hash: result.file_hash || result.hash, torrentHash: result.torrent_hash, magnetLink: result.magnet_link, timestamp: Date.now(), type: file.type, isVideo: file.type.startsWith('video/') }; this.recentUploads.unshift(uploadRecord); this.recentUploads = this.recentUploads.slice(0, 10); // Keep only last 10 localStorage.setItem('recentUploads', JSON.stringify(this.recentUploads)); this.loadServerFiles(); } async loadServerFiles() { // Show loading state if (this.uploadsList) { this.uploadsList.innerHTML = '
Loading files...
'; } try { const response = await fetch('/api/files'); if (response.ok) { const data = await response.json(); if (data.files && data.files.length > 0) { // Merge server files with local uploads, avoiding duplicates const allFiles = [...data.files]; // Add local uploads that might not be on server yet this.recentUploads.forEach(localFile => { if (!allFiles.find(f => f.file_hash === localFile.hash)) { allFiles.unshift({ file_hash: localFile.hash, name: localFile.name, size: localFile.size, is_video: localFile.isVideo, torrent_hash: localFile.torrentHash, magnet_link: localFile.magnetLink }); } }); this.displayFiles(allFiles); return; } } } catch (error) { console.log('Could not load server files, showing local only:', error); if (this.uploadsList) { this.uploadsList.innerHTML = '
Failed to load server files. Showing local uploads only.
'; setTimeout(() => this.loadRecentUploads(), 2000); return; } } // Fallback to local uploads only this.loadRecentUploads(); } loadRecentUploads() { if (this.recentUploads.length === 0) { this.uploadsList.innerHTML = '

No recent uploads

'; return; } const files = this.recentUploads.map(upload => ({ file_hash: upload.hash, name: upload.name, size: upload.size, is_video: upload.isVideo, torrent_hash: upload.torrentHash, magnet_link: upload.magnetLink })); this.displayFiles(files); } displayFiles(files) { if (files.length === 0) { this.uploadsList.innerHTML = '

No files uploaded

'; return; } this.uploadsList.innerHTML = files.map(file => `
${this.escapeHtml(file.name)}
${this.formatBytes(file.size)} • Hash: ${file.file_hash.substring(0, 8)}...
${file.is_video ? ` ` : ''}
`).join(''); } async checkServiceStatus() { try { // Use the stats API which provides comprehensive service information const response = await fetch('/api/stats'); if (response.ok) { const data = await response.json(); // Update service status based on stats data this.updateServiceStatus('gateway', data.gateway && data.gateway.status === 'healthy'); this.updateServiceStatus('blossom', data.blossom && data.blossom.status === 'healthy'); this.updateServiceStatus('dht', data.dht && data.dht.status === 'healthy'); } else { // If stats API fails, assume all services are down this.updateServiceStatus('gateway', false); this.updateServiceStatus('blossom', false); this.updateServiceStatus('dht', false); } } catch (error) { console.error('Service status check failed:', error); // If stats API fails, assume all services are down this.updateServiceStatus('gateway', false); this.updateServiceStatus('blossom', false); this.updateServiceStatus('dht', false); } this.updateSystemInfo(); } updateServiceStatus(serviceName, isOnline) { this.serviceStatus[serviceName] = isOnline; const statusElement = document.getElementById(`${serviceName}-status`); if (statusElement) { statusElement.textContent = isOnline ? '🟢' : '🔴'; statusElement.className = `status-indicator ${isOnline ? 'online' : 'offline'}`; } } updateSystemInfo() { // Update system information display const mode = Object.keys(this.serviceStatus).filter(s => this.serviceStatus[s]).length === 3 ? 'unified' : 'partial'; const systemMode = document.getElementById('system-mode'); if (systemMode) systemMode.textContent = mode; const totalStorage = document.getElementById('system-storage'); if (totalStorage) { const totalSize = this.recentUploads.reduce((sum, upload) => sum + upload.size, 0); totalStorage.textContent = this.formatBytes(totalSize); } const gatewayUploads = document.getElementById('gateway-uploads'); if (gatewayUploads) gatewayUploads.textContent = this.recentUploads.length; } cancelUpload() { if (this.currentUpload) { this.currentUpload.abort.abort(); } } downloadFile(hash) { const url = `/api/download/${hash}`; const a = document.createElement('a'); a.href = url; a.download = ''; document.body.appendChild(a); a.click(); document.body.removeChild(a); } getTorrent(hash) { const url = `/api/torrent/${hash}`; const a = document.createElement('a'); a.href = url; a.download = `${hash}.torrent`; document.body.appendChild(a); a.click(); document.body.removeChild(a); } playVideo(hash, name) { const url = `/player.html?hash=${hash}&name=${encodeURIComponent(name)}`; window.open(url, '_blank'); } shareFile(hash, name) { const baseUrl = window.location.origin; const shareText = `${name}\n\nDownload: ${baseUrl}/api/download/${hash}\nStream: ${baseUrl}/api/stream/${hash}/playlist.m3u8\nTorrent: ${baseUrl}/api/torrent/${hash}`; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(shareText).then(() => { this.showToast('Share links copied to clipboard!', 'success'); }); } else { // Fallback for older browsers const textarea = document.createElement('textarea'); textarea.value = shareText; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); this.showToast('Share links copied to clipboard!', 'success'); } } async deleteFile(hash, name) { if (!confirm(`Are you sure you want to delete "${name}"?\n\nThis action cannot be undone.`)) { return; } try { const headers = { 'Accept': 'application/json' }; if (window.nostrAuth && window.nostrAuth.sessionToken) { headers['Authorization'] = `Bearer ${window.nostrAuth.sessionToken}`; } const response = await fetch(`/api/delete/${hash}`, { method: 'DELETE', headers: headers }); if (response.ok) { const result = await response.json(); this.showToast(`File "${name}" deleted successfully!`, 'success'); // Remove from local storage if it exists this.recentUploads = this.recentUploads.filter(upload => upload.hash !== hash); localStorage.setItem('recentUploads', JSON.stringify(this.recentUploads)); // Refresh the file list this.loadServerFiles(); } else { const error = await response.json(); this.showToast(`Failed to delete file: ${error.error?.message || 'Unknown error'}`, 'error'); } } catch (error) { console.error('Delete error:', error); this.showToast(`Error deleting file: ${error.message}`, 'error'); } } showToast(message, type = 'info') { const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.textContent = message; this.toastContainer.appendChild(toast); // Remove toast after 3 seconds setTimeout(() => { toast.remove(); }, 3000); } // Utility functions formatBytes(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } formatTime(seconds) { if (!isFinite(seconds) || seconds < 0) return '--:--'; const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); if (hours > 0) { return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } return `${minutes}:${secs.toString().padStart(2, '0')}`; } formatDate(timestamp) { const date = new Date(timestamp); const now = new Date(); const diff = now - date; if (diff < 60000) return 'just now'; if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; return date.toLocaleDateString(); } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } } // Global functions for navigation and theme function showServices() { hideAllSections(); document.getElementById('services-section').classList.add('active'); gatewayUI.checkServiceStatus(); } function showAbout() { hideAllSections(); document.getElementById('about-section').classList.add('active'); } function hideAllSections() { document.querySelectorAll('.section').forEach(section => { section.classList.remove('active'); }); } function showUpload() { hideAllSections(); document.getElementById('upload-section').classList.add('active'); } function toggleTheme() { const currentTheme = document.documentElement.getAttribute('data-theme'); const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', newTheme); localStorage.setItem('theme', newTheme); } function refreshDHTStats() { gatewayUI.showToast('DHT stats refreshed', 'success'); // In a real implementation, this would fetch DHT statistics // from a dedicated endpoint } function cancelUpload() { gatewayUI.cancelUpload(); } function copyToClipboard(elementId) { const element = document.getElementById(elementId); element.select(); document.execCommand('copy'); gatewayUI.showToast('Copied to clipboard!', 'success'); } // Initialize the UI when the page loads let gatewayUI; document.addEventListener('DOMContentLoaded', () => { gatewayUI = new GatewayUI(); }); // Handle browser navigation window.addEventListener('hashchange', () => { const hash = window.location.hash.slice(1); switch (hash) { case 'services': showServices(); break; case 'about': showAbout(); break; case 'upload': showUpload(); break; case 'files': showFiles(); break; default: showAbout(); // Default to About page instead of Upload } });