// Caddy-RS File Manager JavaScript Application class FileManager { constructor() { this.apiBase = this.getApiBase(); this.currentPath = '/'; this.selectedFiles = new Set(); this.websocket = null; this.isRealtimeEnabled = false; this.initializeElements(); this.bindEvents(); this.loadFileList(); this.updateStatus('connecting', 'Connecting to server...'); } // Get API base URL based on current location getApiBase() { const protocol = window.location.protocol; const host = window.location.host; return `${protocol}//${host}/api`; } // Initialize DOM element references initializeElements() { this.elements = { statusIndicator: document.getElementById('status-indicator'), statusText: document.getElementById('status-text'), refreshBtn: document.getElementById('refresh-btn'), currentPath: document.getElementById('current-path'), uploadBtn: document.getElementById('upload-btn'), fileList: document.getElementById('file-list'), uploadArea: document.getElementById('upload-area'), uploadZone: document.getElementById('upload-zone'), fileInput: document.getElementById('file-input'), uploadProgress: document.getElementById('upload-progress'), progressFill: document.getElementById('progress-fill'), progressText: document.getElementById('progress-text'), realtimeLog: document.getElementById('realtime-log'), toggleRealtime: document.getElementById('toggle-realtime'), contextMenu: document.getElementById('context-menu'), modal: document.getElementById('modal'), modalTitle: document.getElementById('modal-title'), modalBody: document.getElementById('modal-body'), modalCancel: document.getElementById('modal-cancel'), modalConfirm: document.getElementById('modal-confirm'), modalClose: document.querySelector('.modal-close') }; } // Bind event listeners bindEvents() { // Navigation this.elements.refreshBtn.addEventListener('click', () => this.loadFileList()); // Upload this.elements.uploadBtn.addEventListener('click', () => this.toggleUploadArea()); this.elements.uploadZone.addEventListener('click', () => this.elements.fileInput.click()); this.elements.fileInput.addEventListener('change', (e) => this.handleFileSelect(e)); // Drag and drop this.elements.uploadZone.addEventListener('dragover', (e) => this.handleDragOver(e)); this.elements.uploadZone.addEventListener('dragleave', (e) => this.handleDragLeave(e)); this.elements.uploadZone.addEventListener('drop', (e) => this.handleDrop(e)); // Real-time this.elements.toggleRealtime.addEventListener('click', () => this.toggleRealtime()); // Modal this.elements.modalClose.addEventListener('click', () => this.closeModal()); this.elements.modalCancel.addEventListener('click', () => this.closeModal()); // Context menu document.addEventListener('click', () => this.hideContextMenu()); this.elements.contextMenu.addEventListener('click', (e) => this.handleContextMenuClick(e)); // Keyboard shortcuts document.addEventListener('keydown', (e) => this.handleKeyboard(e)); } // Update connection status updateStatus(status, text) { this.elements.statusIndicator.className = `status-${status}`; this.elements.statusText.textContent = text; } // Load file list from server async loadFileList() { try { this.updateStatus('connecting', 'Loading files...'); const response = await fetch(`${this.apiBase}/list`); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const files = await response.json(); this.renderFileList(files); this.updateStatus('connected', 'Connected'); } catch (error) { console.error('Failed to load file list:', error); this.updateStatus('disconnected', 'Connection failed'); this.addLogEntry(`Error loading files: ${error.message}`, 'error'); } } // Render file list in the UI renderFileList(files) { const fileList = this.elements.fileList; fileList.innerHTML = ''; if (files.length === 0) { fileList.innerHTML = '
No files found
'; return; } files.forEach(file => { const fileItem = this.createFileItem(file); fileList.appendChild(fileItem); }); } // Create a file item element createFileItem(file) { const item = document.createElement('div'); item.className = 'file-item'; item.dataset.path = file.path; const icon = file.is_directory ? '📁' : this.getFileIcon(file.path); const size = file.is_directory ? '-' : this.formatFileSize(file.size); const modified = this.formatDate(file.modified); item.innerHTML = `
${icon} ${file.path}
${size}
${modified}
`; // Add event listeners item.addEventListener('click', (e) => this.selectFile(e, file)); item.addEventListener('contextmenu', (e) => this.showContextMenu(e, file)); return item; } // Get appropriate icon for file type getFileIcon(filename) { const ext = filename.split('.').pop().toLowerCase(); const iconMap = { 'txt': '📄', 'md': '📝', 'json': '📋', 'yml': '📋', 'yaml': '📋', 'js': '📜', 'ts': '📜', 'html': '🌐', 'css': '🎨', 'scss': '🎨', 'jpg': '🖼️', 'jpeg': '🖼️', 'png': '🖼️', 'gif': '🖼️', 'svg': '🖼️', 'pdf': '📕', 'doc': '📘', 'docx': '📘', 'xls': '📗', 'xlsx': '📗', 'zip': '🗜️', 'tar': '🗜️', 'gz': '🗜️', '7z': '🗜️', 'mp3': '🎵', 'wav': '🎵', 'mp4': '🎬', 'avi': '🎬', 'mov': '🎬' }; return iconMap[ext] || '📄'; } // Format file size for display formatFileSize(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } // Format date for display formatDate(dateString) { const date = new Date(dateString); return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); } // Toggle upload area visibility toggleUploadArea() { const uploadArea = this.elements.uploadArea; const isVisible = uploadArea.style.display !== 'none'; uploadArea.style.display = isVisible ? 'none' : 'block'; this.elements.uploadBtn.textContent = isVisible ? '📤 Upload Files' : '❌ Cancel Upload'; } // Handle file selection from input handleFileSelect(event) { const files = Array.from(event.target.files); this.uploadFiles(files); } // Handle drag over event handleDragOver(event) { event.preventDefault(); this.elements.uploadZone.classList.add('dragover'); } // Handle drag leave event handleDragLeave(event) { event.preventDefault(); this.elements.uploadZone.classList.remove('dragover'); } // Handle drop event handleDrop(event) { event.preventDefault(); this.elements.uploadZone.classList.remove('dragover'); const files = Array.from(event.dataTransfer.files); this.uploadFiles(files); } // Upload files to server async uploadFiles(files) { if (files.length === 0) return; this.elements.uploadProgress.style.display = 'block'; for (let i = 0; i < files.length; i++) { const file = files[i]; const progress = ((i + 1) / files.length) * 100; this.elements.progressText.textContent = `Uploading ${file.name} (${i + 1}/${files.length})`; this.elements.progressFill.style.width = `${progress}%`; try { await this.uploadSingleFile(file); this.addLogEntry(`Uploaded: ${file.name}`, 'success'); } catch (error) { this.addLogEntry(`Failed to upload ${file.name}: ${error.message}`, 'error'); } } this.elements.uploadProgress.style.display = 'none'; this.toggleUploadArea(); this.loadFileList(); // Refresh file list } // Upload a single file async uploadSingleFile(file) { const formData = new FormData(); formData.append('file', file); const response = await fetch(`${this.apiBase}/upload?path=${encodeURIComponent(file.name)}`, { method: 'POST', body: file }); if (!response.ok) { throw new Error(`Upload failed: ${response.status} ${response.statusText}`); } } // Download file from server async downloadFile(path) { try { const response = await fetch(`${this.apiBase}/download?path=${encodeURIComponent(path)}`); if (!response.ok) { throw new Error(`Download failed: ${response.status}`); } const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = path.split('/').pop(); document.body.appendChild(a); a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(url); this.addLogEntry(`Downloaded: ${path}`, 'success'); } catch (error) { this.addLogEntry(`Download failed: ${error.message}`, 'error'); } } // Delete file from server async deleteFile(path) { if (!confirm(`Are you sure you want to delete "${path}"?`)) { return; } try { // Note: This would need a DELETE endpoint on the server this.addLogEntry(`Delete functionality not yet implemented for: ${path}`, 'info'); } catch (error) { this.addLogEntry(`Delete failed: ${error.message}`, 'error'); } } // Select/deselect file selectFile(event, file) { event.stopPropagation(); const item = event.currentTarget; if (event.ctrlKey || event.metaKey) { // Multi-select item.classList.toggle('selected'); if (item.classList.contains('selected')) { this.selectedFiles.add(file.path); } else { this.selectedFiles.delete(file.path); } } else { // Single select document.querySelectorAll('.file-item').forEach(el => el.classList.remove('selected')); item.classList.add('selected'); this.selectedFiles.clear(); this.selectedFiles.add(file.path); } } // Show context menu showContextMenu(event, file) { event.preventDefault(); const menu = this.elements.contextMenu; menu.style.display = 'block'; menu.style.left = event.pageX + 'px'; menu.style.top = event.pageY + 'px'; menu.dataset.path = file.path; } // Hide context menu hideContextMenu() { this.elements.contextMenu.style.display = 'none'; } // Handle context menu clicks handleContextMenuClick(event) { event.stopPropagation(); const action = event.target.dataset.action; const path = this.elements.contextMenu.dataset.path; if (!action || !path) return; switch (action) { case 'download': this.downloadFile(path); break; case 'delete': this.deleteFile(path); break; case 'rename': this.renameFile(path); break; case 'info': this.showFileInfo(path); break; } this.hideContextMenu(); } // Rename file (placeholder) renameFile(path) { const newName = prompt(`Rename "${path}" to:`, path); if (newName && newName !== path) { this.addLogEntry(`Rename functionality not yet implemented: ${path} -> ${newName}`, 'info'); } } // Show file information async showFileInfo(path) { try { const response = await fetch(`${this.apiBase}/metadata?path=${encodeURIComponent(path)}`); const metadata = await response.json(); this.showModal('File Properties', `
Path: ${metadata.path}
Size: ${this.formatFileSize(metadata.size)}
Modified: ${this.formatDate(metadata.modified)}
Hash: ${metadata.hash}
Type: ${metadata.is_directory ? 'Directory' : 'File'}
`); } catch (error) { this.addLogEntry(`Failed to get file info: ${error.message}`, 'error'); } } // Show modal dialog showModal(title, content) { this.elements.modalTitle.textContent = title; this.elements.modalBody.innerHTML = content; this.elements.modal.style.display = 'flex'; } // Close modal dialog closeModal() { this.elements.modal.style.display = 'none'; } // Toggle real-time WebSocket connection toggleRealtime() { if (this.isRealtimeEnabled) { this.disconnectWebSocket(); } else { this.connectWebSocket(); } } // Connect to WebSocket for real-time updates connectWebSocket() { try { const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${wsProtocol}//${window.location.host}/ws`; this.websocket = new WebSocket(wsUrl); this.websocket.onopen = () => { this.isRealtimeEnabled = true; this.elements.toggleRealtime.textContent = 'Disable Real-time'; this.addLogEntry('WebSocket connected - real-time updates enabled', 'success'); // Subscribe to updates this.websocket.send(JSON.stringify({ type: 'Subscribe', client_id: 'web-ui-' + Date.now() })); }; this.websocket.onmessage = (event) => { try { const message = JSON.parse(event.data); this.handleWebSocketMessage(message); } catch (error) { this.addLogEntry(`WebSocket message error: ${error.message}`, 'error'); } }; this.websocket.onclose = () => { this.isRealtimeEnabled = false; this.elements.toggleRealtime.textContent = 'Enable Real-time'; this.addLogEntry('WebSocket disconnected', 'info'); }; this.websocket.onerror = (error) => { this.addLogEntry(`WebSocket error: ${error.message || 'Connection failed'}`, 'error'); }; } catch (error) { this.addLogEntry(`Failed to connect WebSocket: ${error.message}`, 'error'); } } // Disconnect WebSocket disconnectWebSocket() { if (this.websocket) { this.websocket.close(); this.websocket = null; } } // Handle WebSocket messages handleWebSocketMessage(message) { switch (message.type) { case 'FileOperation': this.handleFileOperation(message.operation); break; case 'Ack': this.addLogEntry(`Server acknowledged: ${message.operation_id}`, 'info'); break; case 'Error': this.addLogEntry(`Server error: ${message.message}`, 'error'); break; case 'Pong': // Handle heartbeat response break; default: this.addLogEntry(`Unknown message type: ${message.type}`, 'info'); } } // Handle file operation from WebSocket handleFileOperation(operation) { let message = ''; if (operation.Create) { message = `File created: ${operation.Create.metadata.path}`; } else if (operation.Update) { message = `File updated: ${operation.Update.metadata.path}`; } else if (operation.Delete) { message = `File deleted: ${operation.Delete.path}`; } else if (operation.Move) { message = `File moved: ${operation.Move.from} → ${operation.Move.to}`; } this.addLogEntry(message, 'info'); // Refresh file list to show changes this.loadFileList(); } // Add entry to real-time log addLogEntry(message, type = 'info') { const logEntry = document.createElement('div'); logEntry.className = `log-entry ${type}`; logEntry.textContent = `${new Date().toLocaleTimeString()} - ${message}`; this.elements.realtimeLog.appendChild(logEntry); this.elements.realtimeLog.scrollTop = this.elements.realtimeLog.scrollHeight; // Limit log entries const entries = this.elements.realtimeLog.children; if (entries.length > 100) { this.elements.realtimeLog.removeChild(entries[0]); } } // Handle keyboard shortcuts handleKeyboard(event) { if (event.ctrlKey || event.metaKey) { switch (event.key) { case 'r': event.preventDefault(); this.loadFileList(); break; case 'u': event.preventDefault(); this.toggleUploadArea(); break; } } if (event.key === 'Escape') { this.closeModal(); this.hideContextMenu(); } } } // Initialize the file manager when the page loads let fileManager; document.addEventListener('DOMContentLoaded', () => { fileManager = new FileManager(); });