diff --git a/app/images/download.svg b/app/images/download.svg new file mode 100644 index 000000000..53ce315ef --- /dev/null +++ b/app/images/download.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + diff --git a/app/images/upload.svg b/app/images/upload.svg new file mode 100644 index 000000000..1d22b95f8 --- /dev/null +++ b/app/images/upload.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + diff --git a/app/ui.js b/app/ui.js index 320a7353d..06fd687ac 100644 --- a/app/ui.js +++ b/app/ui.js @@ -87,6 +87,8 @@ const UI = { monitorStartX: 0, monitorStartY: 0, + currentDownloadPath: '', // Track current folder path for downloads + supportsBroadcastChannel: (typeof BroadcastChannel !== "undefined"), codecDetector: null, forcedCodecs: [], @@ -166,6 +168,8 @@ const UI = { UI.addMachineHandlers(); UI.addConnectionControlHandlers(); UI.addClipboardHandlers(); + UI.addUploadHandlers(); + UI.addDownloadHandlers(); UI.addSettingsHandlers(); UI.addDisplaysHandler(); // UI.addMultiMonitorAddHandler(); @@ -586,6 +590,28 @@ const UI = { .addEventListener('click', UI.clipboardClear); }, + addUploadHandlers() { + if (document.getElementById('noVNC_upload_button')) { + UI.addClickHandle('noVNC_upload_button', UI.toggleUploadPanel); + } + + const fileInput = document.getElementById("noVNC_file_input"); + if (fileInput) { + fileInput.addEventListener('change', UI.handleFileSelect); + } + }, + + addDownloadHandlers() { + if (document.getElementById('noVNC_download_button')) { + UI.addClickHandle('noVNC_download_button', UI.toggleDownloadPanel); + } + + const refreshButton = document.getElementById("noVNC_refresh_downloads_button"); + if (refreshButton) { + refreshButton.addEventListener('click', UI.refreshDownloadsList); + } + }, + // Add a call to save settings when the element changes, // unless the optional parameter changeFunc is used instead. addSettingChangeHandler(name, changeFunc) { @@ -1492,6 +1518,8 @@ const UI = { UI.closeSettingsPanel(); UI.closePowerPanel(); UI.closeClipboardPanel(); + UI.closeUploadPanel(); + UI.closeDownloadPanel(); UI.closeExtraKeys(); }, @@ -1644,6 +1672,416 @@ const UI = { } }, + openUploadPanel() { + UI.closeAllPanels(); + UI.openControlbar(); + + document.getElementById('noVNC_upload_panel') + .classList.add("noVNC_open"); + document.getElementById('noVNC_upload_button') + .classList.add("noVNC_selected"); + }, + + closeUploadPanel() { + document.getElementById('noVNC_upload_panel') + .classList.remove("noVNC_open"); + document.getElementById('noVNC_upload_button') + .classList.remove("noVNC_selected"); + }, + + toggleUploadPanel(e) { + if (!UI.isControlPanelItemClick(e)) { + return false; + } + + if (document.getElementById('noVNC_upload_panel') + .classList.contains("noVNC_open")) { + UI.closeUploadPanel(); + } else { + UI.openUploadPanel(); + } + }, + + handleFileSelect(e) { + const files = e.target.files; + if (!files || files.length === 0) return; + + for (let i = 0; i < files.length; i++) { + UI.uploadFile(files[i]); + } + + // Clear the input so the same file can be selected again + e.target.value = ''; + }, + + formatFileSize(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; + }, + + uploadFile(file) { + const uploadsList = document.getElementById('noVNC_upload_files_list'); + + // Create progress item + const progressItem = document.createElement('div'); + progressItem.className = 'noVNC_upload_item'; + progressItem.style.marginBottom = '10px'; + progressItem.style.padding = '8px'; + progressItem.style.border = '1px solid #ccc'; + progressItem.style.borderRadius = '4px'; + + const fileName = document.createElement('div'); + fileName.textContent = file.name + ' (' + UI.formatFileSize(file.size) + ')'; + fileName.style.fontSize = '13px'; + fileName.style.fontWeight = 'bold'; + fileName.style.marginBottom = '8px'; + fileName.style.wordBreak = 'break-all'; + fileName.style.color = '#ffffff'; + + const progressBarContainer = document.createElement('div'); + progressBarContainer.style.width = '100%'; + progressBarContainer.style.height = '20px'; + progressBarContainer.style.backgroundColor = '#f0f0f0'; + progressBarContainer.style.borderRadius = '10px'; + progressBarContainer.style.overflow = 'hidden'; + + const progressBar = document.createElement('div'); + progressBar.style.height = '100%'; + progressBar.style.width = '0%'; + progressBar.style.backgroundColor = '#4CAF50'; + progressBar.style.transition = 'width 0.3s'; + + const progressText = document.createElement('div'); + progressText.textContent = '0%'; + progressText.style.fontSize = '11px'; + progressText.style.marginTop = '3px'; + progressText.style.textAlign = 'center'; + + progressBarContainer.appendChild(progressBar); + progressItem.appendChild(fileName); + progressItem.appendChild(progressBarContainer); + progressItem.appendChild(progressText); + uploadsList.appendChild(progressItem); + + // Prepare FormData + const formData = new FormData(); + formData.append('file', file); + + // Create XMLHttpRequest + const xhr = new XMLHttpRequest(); + + // Progress handler + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const percentComplete = (e.loaded / e.total) * 100; + progressBar.style.width = percentComplete + '%'; + progressText.textContent = Math.round(percentComplete) + '%'; + } + }); + + // Completion handler + xhr.addEventListener('load', () => { + if (xhr.status === 200) { + progressBar.style.backgroundColor = '#4CAF50'; + progressText.textContent = 'Complete!'; + + // Remove after 5 seconds + setTimeout(() => { + progressItem.style.transition = 'opacity 0.5s'; + progressItem.style.opacity = '0'; + setTimeout(() => { + progressItem.remove(); + }, 500); + }, 5000); + } else { + progressBar.style.backgroundColor = '#f44336'; + progressText.textContent = 'Failed!'; + + // Remove after 5 seconds + setTimeout(() => { + progressItem.style.transition = 'opacity 0.5s'; + progressItem.style.opacity = '0'; + setTimeout(() => { + progressItem.remove(); + }, 500); + }, 5000); + } + }); + + // Error handler + xhr.addEventListener('error', () => { + progressBar.style.backgroundColor = '#f44336'; + progressText.textContent = 'Error!'; + + // Remove after 5 seconds + setTimeout(() => { + progressItem.style.transition = 'opacity 0.5s'; + progressItem.style.opacity = '0'; + setTimeout(() => { + progressItem.remove(); + }, 500); + }, 5000); + }); + + // Send request + xhr.open('POST', '/upload', true); + xhr.send(formData); + }, + + openDownloadPanel() { + UI.closeAllPanels(); + UI.openControlbar(); + + const panel = document.getElementById('noVNC_download_panel'); + const button = document.getElementById('noVNC_download_button'); + + if (panel) { + panel.classList.add("noVNC_open"); + } + if (button) { + button.classList.add("noVNC_selected"); + } + + // Reset to root folder when opening + UI.currentDownloadPath = ''; + + // Refresh file list when opening + UI.refreshDownloadsList(); + }, + + closeDownloadPanel() { + const panel = document.getElementById('noVNC_download_panel'); + const button = document.getElementById('noVNC_download_button'); + + if (panel) { + panel.classList.remove("noVNC_open"); + } + if (button) { + button.classList.remove("noVNC_selected"); + } + }, + + toggleDownloadPanel(e) { + if (!UI.isControlPanelItemClick(e)) { + return false; + } + + const panel = document.getElementById('noVNC_download_panel'); + if (panel && panel.classList.contains("noVNC_open")) { + UI.closeDownloadPanel(); + } else { + UI.openDownloadPanel(); + } + }, + + refreshDownloadsList() { + const downloadsList = document.getElementById('noVNC_download_files_list'); + + if (!downloadsList) { + console.log('Download files list element not found'); + return; + } + + // Show loading message + downloadsList.innerHTML = '
Loading files...
'; + + // Build API URL with path parameter if we're in a subfolder + let apiUrl = '/api/downloads'; + if (UI.currentDownloadPath) { + // Server expects path to start with / + apiUrl += '?path=' + encodeURIComponent('/' + UI.currentDownloadPath); + } + + console.log('Fetching downloads from:', apiUrl, 'currentPath:', UI.currentDownloadPath); + + // Fetch file list from API + const xhr = new XMLHttpRequest(); + xhr.open('GET', apiUrl, true); + + xhr.addEventListener('load', () => { + if (xhr.status === 200) { + try { + const response = JSON.parse(xhr.responseText); + + // Handle both old format (success/downloads) and new format (files) + const files = response.files || response.downloads || []; + + // Clear loading message + downloadsList.innerHTML = ''; + + // Add breadcrumb navigation + const breadcrumb = document.createElement('div'); + breadcrumb.style.padding = '8px'; + breadcrumb.style.marginBottom = '8px'; + breadcrumb.style.borderBottom = '1px solid #444'; + breadcrumb.style.display = 'flex'; + breadcrumb.style.alignItems = 'center'; + breadcrumb.style.gap = '8px'; + + // Add "up" button if not at root + if (UI.currentDownloadPath) { + const upBtn = document.createElement('button'); + upBtn.textContent = '← Back'; + upBtn.style.padding = '5px 10px'; + upBtn.style.fontSize = '12px'; + upBtn.style.cursor = 'pointer'; + upBtn.addEventListener('click', () => { + // Go up one level + const pathParts = UI.currentDownloadPath.split('/').filter(p => p); + pathParts.pop(); + UI.currentDownloadPath = pathParts.join('/'); + UI.refreshDownloadsList(); + }); + breadcrumb.appendChild(upBtn); + } + + // Show current path + const pathLabel = document.createElement('span'); + pathLabel.textContent = UI.currentDownloadPath ? '/' + UI.currentDownloadPath : '/Downloads'; + pathLabel.style.color = '#cccccc'; + pathLabel.style.fontSize = '12px'; + pathLabel.style.fontWeight = 'bold'; + breadcrumb.appendChild(pathLabel); + + downloadsList.appendChild(breadcrumb); + + if (files.length > 0) { + // Display each file + files.forEach(file => { + const fileItem = document.createElement('div'); + fileItem.className = 'noVNC_download_item'; + fileItem.style.marginBottom = '8px'; + fileItem.style.padding = '8px'; + fileItem.style.border = '1px solid #ccc'; + fileItem.style.borderRadius = '4px'; + fileItem.style.display = 'flex'; + fileItem.style.justifyContent = 'space-between'; + fileItem.style.alignItems = 'center'; + + const fileInfo = document.createElement('div'); + fileInfo.style.flex = '1'; + fileInfo.style.minWidth = '0'; + + const fileName = document.createElement('div'); + // Add folder icon for directories + fileName.textContent = (file.is_dir ? '📁 ' : '') + file.filename; + fileName.style.fontSize = '13px'; + fileName.style.fontWeight = 'bold'; + fileName.style.color = '#ffffff'; + fileName.style.wordBreak = 'break-all'; + + const fileDetails = document.createElement('div'); + fileDetails.style.fontSize = '11px'; + fileDetails.style.color = '#cccccc'; + fileDetails.style.marginTop = '3px'; + + if (file.is_dir) { + fileDetails.textContent = 'Folder - Click to open'; + // Make directories clickable + fileItem.style.cursor = 'pointer'; + fileItem.style.transition = 'background-color 0.2s'; + fileItem.addEventListener('mouseenter', () => { + fileItem.style.backgroundColor = 'rgba(255, 255, 255, 0.1)'; + }); + fileItem.addEventListener('mouseleave', () => { + fileItem.style.backgroundColor = ''; + }); + fileItem.addEventListener('click', () => { + // Navigate into directory + UI.currentDownloadPath = UI.currentDownloadPath + ? UI.currentDownloadPath + '/' + file.filename + : file.filename; + UI.refreshDownloadsList(); + }); + } else { + fileDetails.textContent = UI.formatFileSize(file.size); + } + + fileInfo.appendChild(fileName); + fileInfo.appendChild(fileDetails); + + // Only add download button for files, not directories + if (!file.is_dir) { + const downloadBtn = document.createElement('button'); + downloadBtn.textContent = 'Download'; + downloadBtn.style.marginLeft = '10px'; + downloadBtn.style.padding = '5px 10px'; + downloadBtn.style.fontSize = '12px'; + downloadBtn.style.cursor = 'pointer'; + downloadBtn.addEventListener('click', (e) => { + e.stopPropagation(); // Prevent any parent click handlers + UI.downloadFile(file.filename); + }); + fileItem.appendChild(fileInfo); + fileItem.appendChild(downloadBtn); + } else { + fileItem.appendChild(fileInfo); + } + + downloadsList.appendChild(fileItem); + }); + } else { + downloadsList.innerHTML = '
No files available
'; + } + } catch (e) { + console.error('Error parsing downloads response:', e, xhr.responseText); + downloadsList.innerHTML = ''; + const errorDiv = document.createElement('div'); + errorDiv.style.padding = '10px'; + errorDiv.style.textAlign = 'center'; + errorDiv.style.color = '#f44336'; + errorDiv.textContent = 'Error parsing response: ' + e.message; + downloadsList.appendChild(errorDiv); + } + } else { + // Try to parse error message from server + let errorMsg = 'Failed to load files (HTTP ' + xhr.status + ')'; + try { + const errorData = JSON.parse(xhr.responseText); + if (errorData.error) { + errorMsg = errorData.error; + } + } catch (e) { + // Ignore parse errors, use default message + } + console.error('Downloads API error:', xhr.status, xhr.responseText); + downloadsList.innerHTML = ''; + const errorDiv = document.createElement('div'); + errorDiv.style.padding = '10px'; + errorDiv.style.textAlign = 'center'; + errorDiv.style.color = '#f44336'; + errorDiv.textContent = errorMsg; + downloadsList.appendChild(errorDiv); + } + }); + + xhr.addEventListener('error', () => { + console.error('Network error loading downloads'); + downloadsList.innerHTML = '
Network error
'; + }); + + xhr.send(); + }, + + downloadFile(filename) { + // Create a temporary anchor element and trigger download + const a = document.createElement('a'); + + // Build the full path including current directory + let fullPath = UI.currentDownloadPath + ? UI.currentDownloadPath + '/' + filename + : filename; + + a.href = '/Downloads/' + encodeURIComponent(fullPath); + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }, + clipboardReceive(e) { if (UI.rfb.clipboardDown) { var curvalue = document.getElementById('noVNC_clipboard_text').value; @@ -2248,7 +2686,6 @@ const UI = { disconnectedRx(event) { const detail = event.detail || {}; if (detail.serverNotice && detail.serverNotice.graceful) { - window.location.replace('disconnected.html'); return; } parent.postMessage({ action: 'disconnectrx', value: detail.reason}, '*' ); diff --git a/index.html b/index.html index 788331376..200bd9949 100644 --- a/index.html +++ b/index.html @@ -178,6 +178,44 @@

+ +
+ + Upload Files +
+
+
+ Upload Files +
+ +
+ +
+
+
+
+ + +
+ + Download Files +
+
+
+ Download Files +
+ +
+ +
+
+
+
+