// 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); // Refresh file list every 15 seconds to pick up completed transcoding jobs setInterval(() => this.loadServerFiles(false), 15000); } 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); // Reset progress this.updateProgress(0, 0, file.size); 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 this.uploadWithProgress('/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'; } async uploadWithProgress(url, options) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); // Track upload progress xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { const percentComplete = (e.loaded / e.total) * 100; this.updateProgress(percentComplete, e.loaded, e.total); } }); // Handle completion xhr.addEventListener('load', () => { // Create a Response-like object const responseObj = { ok: xhr.status >= 200 && xhr.status < 300, status: xhr.status, statusText: xhr.statusText, json: async () => { try { return JSON.parse(xhr.responseText); } catch (error) { throw new Error('Invalid response format'); } } }; resolve(responseObj); }); // Handle errors xhr.addEventListener('error', () => { reject(new Error('Network error occurred during upload')); }); // Handle timeout xhr.addEventListener('timeout', () => { reject(new Error('Upload timed out')); }); // Handle abort xhr.addEventListener('abort', () => { reject(new Error('AbortError')); }); // Set timeout (10 minutes for large files) xhr.timeout = 600000; // Open connection FIRST xhr.open(options.method || 'GET', url); // Set headers AFTER opening Object.entries(options.headers || {}).forEach(([key, value]) => { xhr.setRequestHeader(key, value); }); // Handle abort signal if (options.signal) { options.signal.addEventListener('abort', () => { xhr.abort(); }); } // Send request xhr.send(options.body); }); } updateProgress(percent, loaded, total) { if (!this.currentUpload) return; const elapsed = (Date.now() - this.currentUpload.startTime) / 1000; const speed = loaded / elapsed; const remaining = total > loaded ? (total - loaded) / speed : 0; this.progressFill.style.width = `${percent}%`; this.progressPercent.textContent = `${Math.round(percent)}%`; this.progressSpeed.textContent = this.formatBytes(speed) + '/s'; this.progressEta.textContent = this.formatTime(remaining); } simulateProgress() { // Fallback for browsers that don't support progress events 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(showLoading = true) { // Show loading state only if explicitly requested if (this.uploadsList && showLoading) { this.uploadsList.innerHTML = '
Loading files...
'; } try { const response = await fetch('/api/files'); if (response.ok) { const data = await response.json(); // Debug logging console.log('API /api/files response:', { fileCount: data.files ? data.files.length : 0, files: data.files ? data.files.map(f => ({ name: f.name, hash: f.file_hash?.substring(0, 8), is_video: f.is_video, storage_type: f.storage_type })) : [] }); 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 = `/api/stream/${hash}`; window.open(url, '_blank'); } async shareFile(hash, name) { console.log('shareFile called with:', { hash, name }); try { // Get file metadata to check for streaming info const response = await fetch(`/api/metadata/${hash}`); let fileData = null; if (response.ok) { fileData = await response.json(); } const baseUrl = window.location.origin; // Use torrent info hash for magnet link if available, otherwise use file hash let magnetHash = hash; if (fileData && fileData.torrent_hash) { magnetHash = fileData.torrent_hash; console.log('Using torrent InfoHash for magnet:', magnetHash); } else { console.log('No torrent InfoHash found, using file hash:', magnetHash); } const links = { direct: `${baseUrl}/api/download/${hash}`, torrent: `${baseUrl}/api/torrent/${hash}`, magnet: `magnet:?xt=urn:btih:${magnetHash}&dn=${encodeURIComponent(name)}` }; // Add streaming links if available if (fileData && fileData.streaming_info) { links.stream = `${baseUrl}/api/stream/${hash}`; links.hls = `${baseUrl}/api/stream/${hash}/playlist.m3u8`; } // Add NIP-71 Nostr link if available if (fileData && fileData.nip71_share_link) { links.nostr = fileData.nip71_share_link; } this.showShareModal(name, links); } catch (error) { console.error('Failed to get file metadata:', error); // Fallback to basic links (using file hash for magnet since we can't get torrent info) const baseUrl = window.location.origin; const links = { direct: `${baseUrl}/api/download/${hash}`, torrent: `${baseUrl}/api/torrent/${hash}`, magnet: `magnet:?xt=urn:btih:${hash}&dn=${encodeURIComponent(name)}` }; this.showShareModal(name, links); } } showShareModal(fileName, links) { console.log('showShareModal called with:', { fileName, links }); const modal = document.getElementById('share-modal'); const fileNameEl = document.getElementById('share-file-name'); const linksContainer = document.getElementById('share-links'); console.log('Modal elements found:', { modal: !!modal, fileNameEl: !!fileNameEl, linksContainer: !!linksContainer }); if (!modal || !fileNameEl || !linksContainer) { console.error('Share modal elements not found'); return; } fileNameEl.textContent = fileName; const linkTypes = [ { key: 'direct', label: 'Direct Download', icon: '⬇️' }, { key: 'torrent', label: 'Torrent File', icon: '🧲' }, { key: 'magnet', label: 'Magnet Link', icon: '🔗' }, { key: 'stream', label: 'Stream Video', icon: '▶️' }, { key: 'hls', label: 'HLS Playlist', icon: '📺' }, { key: 'nostr', label: 'Share on Nostr', icon: '⚡' } ]; linksContainer.innerHTML = linkTypes .filter(type => links[type.key]) .map(type => { if (type.key === 'nostr') { return ` `; } return ` `; }).join(''); modal.style.display = 'block'; } async deleteFile(hash, name) { if (!window.nostrAuth || !window.nostrAuth.isAuthenticated()) { alert('You must be logged in to delete files'); return; } if (!confirm(`Are you sure you want to delete "${name}"?`)) { return; } try { const result = await window.nostrAuth.deleteFile(hash); if (result.success) { // Remove from recent uploads this.recentUploads = this.recentUploads.filter(upload => upload.hash !== hash); this.saveRecentUploads(); // Refresh file list await this.loadServerFiles(); alert(`File "${name}" deleted successfully`); } } catch (error) { console.error('Delete failed:', error); alert(`Failed to delete file: ${error.message}`); } } 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 share modal async function copyToClipboard(text) { try { await navigator.clipboard.writeText(text); // Show a temporary success message const copyBtn = event.target; const originalText = copyBtn.textContent; copyBtn.textContent = 'Copied!'; copyBtn.style.backgroundColor = '#4CAF50'; setTimeout(() => { copyBtn.textContent = originalText; copyBtn.style.backgroundColor = ''; }, 2000); } catch (error) { console.error('Failed to copy to clipboard:', error); // Fallback for older browsers const textarea = document.createElement('textarea'); textarea.value = text; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); alert('Copied to clipboard!'); } } function closeShareModal() { const modal = document.getElementById('share-modal'); if (modal) { modal.style.display = 'none'; } } // 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 } }); // Mobile-specific enhancements document.addEventListener('DOMContentLoaded', () => { // Detect if this is a mobile device const isMobile = window.innerWidth <= 768 || /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); if (isMobile) { // Add mobile class to body for CSS targeting document.body.classList.add('mobile-device'); // Show mobile menu toggle only on mobile const mobileToggle = document.getElementById('mobile-menu-toggle'); if (mobileToggle && window.innerWidth <= 768) { mobileToggle.style.display = 'flex'; } // Close mobile menu when clicking nav links const navLinks = document.querySelectorAll('nav a'); navLinks.forEach(link => { link.addEventListener('click', () => { const nav = document.getElementById('main-nav'); const toggle = document.getElementById('mobile-menu-toggle'); nav.classList.remove('mobile-nav-open'); toggle.classList.remove('active'); }); }); // Close mobile menu when clicking outside document.addEventListener('click', (e) => { const nav = document.getElementById('main-nav'); const toggle = document.getElementById('mobile-menu-toggle'); if (nav && nav.classList.contains('mobile-nav-open')) { if (!nav.contains(e.target) && !toggle.contains(e.target)) { nav.classList.remove('mobile-nav-open'); toggle.classList.remove('active'); } } }); // Enhance drag and drop for mobile const uploadArea = document.getElementById('upload-area'); if (uploadArea) { // Add touch feedback uploadArea.addEventListener('touchstart', (e) => { uploadArea.classList.add('touched'); }); uploadArea.addEventListener('touchend', (e) => { uploadArea.classList.remove('touched'); }); // Improve file input handling on mobile const fileInput = document.getElementById('file-input'); if (fileInput) { fileInput.addEventListener('change', (e) => { if (e.target.files.length > 0) { // Visual feedback that files were selected uploadArea.style.borderColor = 'var(--accent-primary)'; setTimeout(() => { uploadArea.style.borderColor = ''; }, 1000); } }); } } // Improve modal scrolling on mobile const modals = document.querySelectorAll('.modal'); modals.forEach(modal => { modal.addEventListener('touchmove', (e) => { // Prevent body scroll when scrolling in modal e.stopPropagation(); }); }); // Add double-tap prevention for buttons let lastTouchEnd = 0; document.addEventListener('touchend', (e) => { const now = (new Date()).getTime(); if (now - lastTouchEnd <= 300) { e.preventDefault(); } lastTouchEnd = now; }, false); // Improve copy functionality for mobile const originalCopyToClipboard = window.copyToClipboard; window.copyToClipboard = async function(text) { try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); if (window.player && window.player.showToast) { window.player.showToast('Copied to clipboard!', 'success'); } else if (gatewayUI) { gatewayUI.showToast('Copied to clipboard!', 'success'); } } else { // Fallback for older mobile browsers const textArea = document.createElement('textarea'); textArea.value = text; textArea.style.position = 'fixed'; textArea.style.left = '-999999px'; textArea.style.top = '-999999px'; document.body.appendChild(textArea); textArea.focus(); textArea.select(); try { document.execCommand('copy'); if (window.player && window.player.showToast) { window.player.showToast('Copied to clipboard!', 'success'); } else if (gatewayUI) { gatewayUI.showToast('Copied to clipboard!', 'success'); } } catch (err) { console.error('Failed to copy:', err); if (window.player && window.player.showToast) { window.player.showToast('Failed to copy to clipboard', 'error'); } else if (gatewayUI) { gatewayUI.showToast('Failed to copy to clipboard', 'error'); } } finally { document.body.removeChild(textArea); } } } catch (err) { console.error('Copy failed:', err); if (window.player && window.player.showToast) { window.player.showToast('Failed to copy to clipboard', 'error'); } else if (gatewayUI) { gatewayUI.showToast('Failed to copy to clipboard', 'error'); } } }; } // Add viewport height fix for mobile browsers function setViewportHeight() { const vh = window.innerHeight * 0.01; document.documentElement.style.setProperty('--vh', `${vh}px`); } // Handle responsive menu switching function handleResize() { const mobileToggle = document.getElementById('mobile-menu-toggle'); const nav = document.getElementById('main-nav'); if (window.innerWidth > 768) { // Desktop mode if (mobileToggle) { mobileToggle.style.display = 'none'; mobileToggle.classList.remove('active'); } if (nav) { nav.classList.remove('mobile-nav-open'); } } else { // Mobile mode if (mobileToggle) { mobileToggle.style.display = 'flex'; } } setViewportHeight(); } setViewportHeight(); window.addEventListener('resize', handleResize); window.addEventListener('orientationchange', () => { setTimeout(handleResize, 100); }); });