// 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();
});