diff --git a/backend/main.py b/backend/main.py index 6442ab3..b471ec2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -524,4 +524,6 @@ def health_check(): if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8001) + import os + port = int(os.getenv("PORT", 8001)) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/docker-compose.yml b/docker-compose.yml index ddb9e1d..1c94b56 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,14 +4,14 @@ services: terra-view-prod: build: . container_name: terra-view - ports: - - "8001:8001" + network_mode: host volumes: - ./data:/app/data environment: - PYTHONUNBUFFERED=1 - ENVIRONMENT=production - - SLMM_BASE_URL=http://slmm:8100 + - PORT=8001 + - SLMM_BASE_URL=http://localhost:8100 restart: unless-stopped depends_on: - slmm @@ -26,19 +26,21 @@ services: terra-view-dev: build: . container_name: terra-view-dev - ports: - - "1001:8001" + network_mode: host volumes: - ./data-dev:/app/data environment: - PYTHONUNBUFFERED=1 - ENVIRONMENT=development - - SLMM_BASE_URL=http://slmm:8100 + - PORT=1001 + - SLMM_BASE_URL=http://localhost:8100 restart: unless-stopped depends_on: - slmm + profiles: + - dev healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8001/health"] + test: ["CMD", "curl", "-f", "http://localhost:1001/health"] interval: 30s timeout: 10s retries: 3 @@ -50,8 +52,7 @@ services: context: ../../slmm dockerfile: Dockerfile container_name: slmm - ports: - - "8100:8100" + network_mode: host volumes: - ../../slmm/data:/app/data environment: diff --git a/templates/partials/slm_live_view.html b/templates/partials/slm_live_view.html index c5ee656..54e2560 100644 --- a/templates/partials/slm_live_view.html +++ b/templates/partials/slm_live_view.html @@ -23,18 +23,31 @@ {% endif %} - -
- {% if is_measuring %} - - - Measuring - - {% else %} - - Stopped - - {% endif %} + +
+
+ +
+
+ + + + 00:00:00 +
+
+ + + {% if is_measuring %} + + + Measuring + + {% else %} + + Stopped + + {% endif %} +
@@ -209,6 +222,171 @@ + + +
+

Device Settings

+ + +
+
+
+ + + + Device Clock +
+ +
+ +
+ Set the device clock manually or sync to your computer's current time. Device must be stopped to change clock. +
+ +
+ + +
+ + +
+ + +
+
+
+ + + + Store Name (Index) +
+
+ ---- + +
+
+ +
+ Set the file index number (0000-9999) for the next measurement storage. Requirements: Device must be stopped and FTP must be disabled to change this setting. +
+ + + +
+ + + +
+ + +
+
+ + +
+
+

Measurement Data Files

+
+ + + +
+
+ + + + + +
+ +
+ + + + / +
+ + +
+
+ + + +

Click "Refresh" to load files from the device

+
+
+ + + + + + +
+
@@ -236,9 +414,14 @@ function initializeChart() { if (window.liveChart && typeof window.liveChart.destroy === 'function') { console.log('Destroying existing chart'); window.liveChart.destroy(); + window.liveChart = null; } const ctx = canvas.getContext('2d'); + if (!ctx) { + console.error('Failed to get 2D context from canvas'); + return; + } console.log('Creating new chart...'); // Dark mode detection @@ -246,7 +429,8 @@ function initializeChart() { const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; const textColor = isDarkMode ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)'; - window.liveChart = new Chart(ctx, { + try { + window.liveChart = new Chart(ctx, { type: 'line', data: { labels: [], @@ -317,12 +501,33 @@ function initializeChart() { } }); - console.log('Chart created successfully:', window.liveChart); + console.log('Chart created successfully:', window.liveChart); + } catch (error) { + console.error('Failed to create chart:', error); + // Display error message in the chart container + const container = canvas.parentElement; + if (container) { + container.innerHTML = ` +
+
+ + + +

Chart failed to load

+

Try refreshing the page

+
+
+ `; + } + } } // Initialize chart when DOM is ready console.log('Executing initializeChart...'); -initializeChart(); +// Use a small delay to ensure DOM is fully ready +setTimeout(() => { + initializeChart(); +}, 50); // WebSocket management (use global scope to avoid redeclaration) if (typeof window.currentWebSocket === 'undefined') { @@ -455,13 +660,19 @@ async function controlUnit(unitId, action) { const result = await response.json(); if (result.status === 'ok') { - // Reload the live view to update status + // Give device more time to change state before checking + // Start/Stop commands need time to propagate + setTimeout(() => { + checkMeasurementState(); + }, 1500); + + // Reload the live view to update all status displays setTimeout(() => { htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, { target: '#live-view-panel', swap: 'innerHTML' }); - }, 500); + }, 2000); } else { alert(`Error: ${result.detail || 'Unknown error'}`); } @@ -472,7 +683,9 @@ async function controlUnit(unitId, action) { // Auto-refresh status every 30 seconds -let refreshInterval; +if (typeof window.refreshInterval === 'undefined') { + window.refreshInterval = null; +} const REFRESH_INTERVAL_MS = 30000; // 30 seconds const unit_id = '{{ unit.id }}'; @@ -556,6 +769,182 @@ function stopAutoRefresh() { } // Start auto-refresh when page loads + + +// Backend-Synced Measurement Timer +// Uses measurement_start_time from backend (tracked via state transitions) +// This works even for manually-started measurements! +if (typeof window.timerInterval === 'undefined') { + window.timerInterval = null; + window.measurementStartTime = null; // ISO string from backend +} + +// Format elapsed time as HH:MM:SS +function formatElapsedTime(milliseconds) { + const totalSeconds = Math.floor(milliseconds / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + return hours.toString().padStart(2, '0') + ':' + minutes.toString().padStart(2, '0') + ':' + seconds.toString().padStart(2, '0'); +} + +// Update timer display based on backend measurement_start_time +function updateTimerDisplay() { + const timerElement = document.getElementById('timer-display'); + if (!timerElement) { + console.error('Timer display element not found!'); + return; + } + + if (!window.measurementStartTime) { + timerElement.textContent = '00:00:00'; + return; + } + + // Calculate elapsed time from backend-provided start time (UTC) + // Ensure the timestamp is treated as UTC by adding 'Z' if not present + let timestamp = window.measurementStartTime; + if (!timestamp.endsWith('Z') && !timestamp.includes('+')) { + timestamp = timestamp + 'Z'; + } + const startTime = new Date(timestamp); + const elapsed = Date.now() - startTime.getTime(); + const formattedTime = formatElapsedTime(elapsed); + timerElement.textContent = formattedTime; +} + +// Sync timer with backend data (with FTP fallback) +async function syncTimerWithBackend(measurementState, measurementStartTime) { + // Device returns "Start" when measuring, "Stop" when stopped + const isMeasuring = measurementState === 'Start'; + + if (isMeasuring && measurementStartTime) { + // Measurement is running - check both backend and FTP timestamps + // Use whichever is earlier (older = actual measurement start) + + // First check FTP for potentially older timestamp + try { + const response = await fetch(`/api/slmm/${unit_id}/ftp/latest-measurement-time`); + const result = await response.json(); + + if (result.status === 'ok' && result.latest_timestamp) { + const backendTime = new Date(measurementStartTime + 'Z'); + const ftpTime = new Date(result.latest_timestamp + 'Z'); + + // Use the earlier timestamp (represents actual measurement start) + if (ftpTime < backendTime) { + window.measurementStartTime = result.latest_timestamp; + window.timerSource = 'ftp'; + console.log('Timer synced with FTP folder (earlier):', result.latest_folder, '@', result.latest_timestamp); + } else { + window.measurementStartTime = measurementStartTime; + window.timerSource = 'backend'; + console.log('Timer synced with backend state (earlier):', measurementStartTime); + } + } else { + // No FTP timestamp, use backend + window.measurementStartTime = measurementStartTime; + window.timerSource = 'backend'; + console.log('Timer synced with backend state:', measurementStartTime); + } + } catch (error) { + console.error('Failed to check FTP timestamp:', error); + // Fallback to backend on error + window.measurementStartTime = measurementStartTime; + window.timerSource = 'backend'; + console.log('Timer synced with backend state (FTP check failed):', measurementStartTime); + } + + // Start interval if not already running + if (!window.timerInterval) { + window.timerInterval = setInterval(updateTimerDisplay, 1000); + console.log('Timer display started'); + } + + // Update display immediately + updateTimerDisplay(); + } else if (isMeasuring && !measurementStartTime) { + // Device is measuring but backend doesn't have start time yet + // Try FTP fallback to get measurement start from latest folder timestamp + if (!window.measurementStartTime || window.timerSource !== 'ftp') { + console.log('Device measuring but no backend start time - checking FTP fallback...'); + try { + const response = await fetch(`/api/slmm/${unit_id}/ftp/latest-measurement-time`); + const result = await response.json(); + + if (result.status === 'ok' && result.latest_timestamp) { + window.measurementStartTime = result.latest_timestamp; + window.timerSource = 'ftp'; + console.log('Timer synced with FTP folder:', result.latest_folder, '@', result.latest_timestamp); + + // Start timer interval if not already running + if (!window.timerInterval) { + window.timerInterval = setInterval(updateTimerDisplay, 1000); + console.log('Timer display started (FTP source)'); + } + + updateTimerDisplay(); + } else { + console.log('No FTP timestamp available'); + } + } catch (error) { + console.error('Failed to get FTP timestamp:', error); + } + } + } else { + // Not measuring - stop timer + if (window.timerInterval) { + clearInterval(window.timerInterval); + window.timerInterval = null; + console.log('Timer display stopped'); + } + window.measurementStartTime = null; + window.timerSource = null; + document.getElementById('timer-display').textContent = '00:00:00'; + } +} + +// Check measurement state and sync timer +async function checkMeasurementState() { + try { + const response = await fetch(`/api/slmm/${unit_id}/live`); + const result = await response.json(); + + if (result.status === 'ok' && result.data) { + await syncTimerWithBackend( + result.data.measurement_state, + result.data.measurement_start_time + ); + } + } catch (error) { + console.error('Failed to check measurement state:', error); + } +} + +// Initialize timer immediately (this partial is loaded via HTMX, so DOMContentLoaded may have already fired) +if (typeof window.timerInitialized === 'undefined') { + window.timerInitialized = true; + + // Execute initialization immediately + (async function() { + console.log('Timer initialization: Starting immediately'); + + // Check measurement state immediately + await checkMeasurementState(); + + // Load store name (index number) + await loadStoreName(); + + // Check FTP status and update buttons + await checkFTPStatus(); + + // Check measurement state periodically (every 5 seconds) + setInterval(checkMeasurementState, 5000); + + console.log('Timer initialization: Complete, polling every 5 seconds'); + })(); +} document.addEventListener('DOMContentLoaded', startAutoRefresh); // Cleanup on page unload @@ -564,4 +953,510 @@ window.addEventListener('beforeunload', function() { window.currentWebSocket.close(); } }); + + +// File Browser Functions +if (typeof window.currentPath === 'undefined') { + window.currentPath = '/'; +} + +// Format file size +function 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 Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; +} + +// Format date +function formatDate(dateStr) { + try { + const date = new Date(dateStr); + return date.toLocaleString(); + } catch (e) { + return dateStr; + } +} + +// Enable FTP on device +async function enableFTP() { + try { + const response = await fetch(`/api/slmm/${unit_id}/ftp/enable`, { + method: 'POST' + }); + const result = await response.json(); + + if (result.status === 'ok') { + document.getElementById('ftp-status').classList.add('hidden'); + updateFTPButtons(true); + refreshFileList(); + } else { + alert('Failed to enable FTP: ' + (result.detail || 'Unknown error')); + } + } catch (error) { + alert('Failed to enable FTP: ' + error.message); + } +} + +async function disableFTP() { + try { + const response = await fetch(`/api/slmm/${unit_id}/ftp/disable`, { + method: 'POST' + }); + const result = await response.json(); + + if (result.status === 'ok') { + updateFTPButtons(false); + alert('FTP disabled. TCP commands are now available.'); + } else { + alert('Failed to disable FTP: ' + (result.detail || 'Unknown error')); + } + } catch (error) { + alert('Failed to disable FTP: ' + error.message); + } +} + +function updateFTPButtons(ftpEnabled) { + const enableBtn = document.getElementById('enable-ftp-btn'); + const disableBtn = document.getElementById('disable-ftp-btn'); + + console.log('updateFTPButtons called with ftpEnabled:', ftpEnabled); + console.log('Enable button element:', enableBtn); + console.log('Disable button element:', disableBtn); + + if (enableBtn && disableBtn) { + if (ftpEnabled) { + enableBtn.classList.add('hidden'); + disableBtn.classList.remove('hidden'); + console.log('FTP is enabled - showing Disable FTP button'); + } else { + enableBtn.classList.remove('hidden'); + disableBtn.classList.add('hidden'); + console.log('FTP is disabled - showing Enable FTP button'); + } + } else { + console.error('FTP button elements not found in DOM'); + } +} + +async function checkFTPStatus() { + console.log('checkFTPStatus: Checking FTP status for unit_id:', unit_id); + try { + const response = await fetch(`/api/slmm/${unit_id}/ftp/status`); + const result = await response.json(); + + console.log('checkFTPStatus: Response:', result); + + if (result.status === 'ok') { + console.log('checkFTPStatus: FTP enabled status:', result.ftp_enabled); + updateFTPButtons(result.ftp_enabled); + } else { + console.error('checkFTPStatus: Unexpected response status:', result.status); + } + } catch (error) { + console.error('checkFTPStatus: Failed to check FTP status:', error); + } +} + +// Clock Management Functions +async function syncClockToNow() { + const now = new Date(); + // Format as YYYY-MM-DDTHH:mm for datetime-local input + const localDateTime = new Date(now.getTime() - (now.getTimezoneOffset() * 60000)).toISOString().slice(0, 16); + + // Update the input field + document.getElementById('clock-input').value = localDateTime; + + // Auto-submit + await setDeviceClock(); +} + +async function setDeviceClock() { + const clockInput = document.getElementById('clock-input'); + const statusDiv = document.getElementById('clock-status'); + + if (!clockInput.value) { + showClockStatus('Please select a date and time', 'error'); + return; + } + + // Convert datetime-local to NL43 format: YYYY/MM/DD,HH:MM:SS + const dateTime = new Date(clockInput.value); + const nl43Format = dateTime.getFullYear() + '/' + + String(dateTime.getMonth() + 1).padStart(2, '0') + '/' + + String(dateTime.getDate()).padStart(2, '0') + ',' + + String(dateTime.getHours()).padStart(2, '0') + ':' + + String(dateTime.getMinutes()).padStart(2, '0') + ':' + + String(dateTime.getSeconds()).padStart(2, '0'); + + showClockStatus('Setting device clock...', 'info'); + + try { + const response = await fetch(`/api/slmm/${unit_id}/clock`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ datetime: nl43Format }) + }); + + const result = await response.json(); + + if (response.ok && result.status === 'ok') { + showClockStatus('✓ Clock set successfully to ' + dateTime.toLocaleString(), 'success'); + } else { + showClockStatus('✗ Failed: ' + (result.detail || 'Unknown error'), 'error'); + } + } catch (error) { + showClockStatus('✗ Failed: ' + error.message, 'error'); + } +} + +function showClockStatus(message, type) { + const statusDiv = document.getElementById('clock-status'); + statusDiv.textContent = message; + statusDiv.classList.remove('hidden', 'text-green-600', 'text-red-600', 'text-blue-600'); + + if (type === 'success') { + statusDiv.classList.add('text-green-600', 'dark:text-green-400'); + } else if (type === 'error') { + statusDiv.classList.add('text-red-600', 'dark:text-red-400'); + } else { + statusDiv.classList.add('text-blue-600', 'dark:text-blue-400'); + } +} + +// Store Name (Index) Management Functions +async function loadStoreName() { + try { + const response = await fetch(`/api/slmm/${unit_id}/index-number`); + const result = await response.json(); + + if (response.ok && result.status === 'ok') { + const indexNum = result.index_number; + document.getElementById('store-name-display').textContent = indexNum; + document.getElementById('store-name-input').value = parseInt(indexNum); + + // Check overwrite status after loading + await checkOverwriteStatus(); + } else { + console.error('Failed to load store name:', result); + } + } catch (error) { + console.error('Error loading store name:', error); + } +} + +async function setStoreName() { + const input = document.getElementById('store-name-input'); + const statusDiv = document.getElementById('store-name-status'); + + const index = parseInt(input.value); + + if (isNaN(index) || index < 0 || index > 9999) { + showStoreNameStatus('Please enter a number between 0 and 9999', 'error'); + return; + } + + showStoreNameStatus('Setting store name...', 'info'); + + try { + const response = await fetch(`/api/slmm/${unit_id}/index-number`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ index: index }) + }); + + const result = await response.json(); + + if (response.ok && result.status === 'ok') { + showStoreNameStatus('✓ Store name set successfully to ' + index.toString().padStart(4, '0'), 'success'); + // Update display + document.getElementById('store-name-display').textContent = index.toString().padStart(4, '0'); + // Check overwrite status for new index + await checkOverwriteStatus(); + } else { + showStoreNameStatus('✗ Failed: ' + (result.detail || 'Unknown error'), 'error'); + } + } catch (error) { + showStoreNameStatus('✗ Failed: ' + error.message, 'error'); + } +} + +function showStoreNameStatus(message, type) { + const statusDiv = document.getElementById('store-name-status'); + statusDiv.textContent = message; + statusDiv.classList.remove('hidden', 'text-green-600', 'text-red-600', 'text-blue-600'); + + if (type === 'success') { + statusDiv.classList.add('text-green-600', 'dark:text-green-400'); + } else if (type === 'error') { + statusDiv.classList.add('text-red-600', 'dark:text-red-400'); + } else { + statusDiv.classList.add('text-blue-600', 'dark:text-blue-400'); + } +} + +// Check Overwrite Status +async function checkOverwriteStatus() { + try { + const response = await fetch(`/api/slmm/${unit_id}/overwrite-check`); + const result = await response.json(); + + const warningDiv = document.getElementById('overwrite-warning'); + const indicator = document.getElementById('overwrite-indicator'); + + if (response.ok && result.status === 'ok') { + if (result.will_overwrite) { + // Show warning + warningDiv.classList.remove('hidden'); + indicator.classList.remove('hidden', 'bg-green-100', 'text-green-800', 'dark:bg-green-900', 'dark:text-green-200'); + indicator.classList.add('bg-yellow-100', 'text-yellow-800', 'dark:bg-yellow-900', 'dark:text-yellow-200'); + indicator.textContent = 'Data Exists'; + } else { + // Hide warning + warningDiv.classList.add('hidden'); + indicator.classList.remove('hidden', 'bg-yellow-100', 'text-yellow-800', 'dark:bg-yellow-900', 'dark:text-yellow-200'); + indicator.classList.add('bg-green-100', 'text-green-800', 'dark:bg-green-900', 'dark:text-green-200'); + indicator.textContent = 'Safe'; + } + } else { + // Hide on error + warningDiv.classList.add('hidden'); + indicator.classList.add('hidden'); + } + } catch (error) { + console.error('Error checking overwrite status:', error); + } +} + +// Auto-increment to find next available index +async function autoIncrementStoreName() { + showStoreNameStatus('Finding next available index...', 'info'); + + try { + const currentIndexResponse = await fetch(`/api/slmm/${unit_id}/index-number`); + const currentIndexResult = await currentIndexResponse.json(); + + if (!currentIndexResponse.ok || currentIndexResult.status !== 'ok') { + showStoreNameStatus('✗ Failed to get current index', 'error'); + return; + } + + let currentIndex = parseInt(currentIndexResult.index_number); + let foundEmpty = false; + let checkedCount = 0; + const maxChecks = 100; // Safety limit + + // Start from next index + currentIndex++; + + while (!foundEmpty && checkedCount < maxChecks && currentIndex <= 9999) { + // Set the index + const setResponse = await fetch(`/api/slmm/${unit_id}/index-number`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ index: currentIndex }) + }); + + if (!setResponse.ok) { + currentIndex++; + checkedCount++; + continue; + } + + // Check if data exists at this index + const checkResponse = await fetch(`/api/slmm/${unit_id}/overwrite-check`); + const checkResult = await checkResponse.json(); + + if (checkResponse.ok && checkResult.status === 'ok') { + if (!checkResult.will_overwrite) { + // Found empty slot! + foundEmpty = true; + document.getElementById('store-name-display').textContent = currentIndex.toString().padStart(4, '0'); + document.getElementById('store-name-input').value = currentIndex; + showStoreNameStatus(`✓ Found empty slot at index ${currentIndex.toString().padStart(4, '0')}`, 'success'); + await checkOverwriteStatus(); + break; + } + } + + currentIndex++; + checkedCount++; + } + + if (!foundEmpty) { + if (currentIndex > 9999) { + showStoreNameStatus('✗ No empty slots found (reached max 9999)', 'error'); + } else { + showStoreNameStatus(`✗ No empty slot found in next ${maxChecks} indices`, 'error'); + } + } + } catch (error) { + showStoreNameStatus('✗ Error: ' + error.message, 'error'); + } +} + +// Navigate to directory +function navigateToDir(path) { + window.currentPath = path; + document.getElementById('current-path').textContent = path; + loadFiles(path); +} + +// Go up one directory +function navigateUp() { + const parts = window.currentPath.split('/').filter(p => p); + parts.pop(); + const newPath = '/' + parts.join('/'); + navigateToDir(newPath || '/'); +} + +// Download file +async function downloadFile(path, filename) { + try { + const response = await fetch(`/api/slmm/${unit_id}/ftp/download`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ remote_path: path }) + }); + + if (!response.ok) { + throw new Error('Download failed'); + } + + // Create blob and download + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } catch (error) { + alert('Failed to download file: ' + error.message); + } +} + +// Load files from path +async function loadFiles(path) { + const fileList = document.getElementById('file-list'); + const loading = document.getElementById('file-list-loading'); + const error = document.getElementById('file-list-error'); + const ftpStatus = document.getElementById('ftp-status'); + + // Show loading state + fileList.classList.add('hidden'); + loading.classList.remove('hidden'); + error.classList.add('hidden'); + + try { + const response = await fetch(`/api/slmm/${unit_id}/ftp/files?path=${encodeURIComponent(path)}`); + const result = await response.json(); + + if (result.status === 'ok') { + ftpStatus.classList.add('hidden'); + displayFiles(result.files || []); + } else { + throw new Error(result.detail || 'Failed to load files'); + } + } catch (err) { + loading.classList.add('hidden'); + + // Check if it's an FTP disabled error + if (err.message.includes('FTP') || err.message.includes('502')) { + ftpStatus.classList.remove('hidden'); + fileList.classList.remove('hidden'); + fileList.innerHTML = '
FTP is disabled on the device
'; + } else { + error.classList.remove('hidden'); + document.getElementById('file-list-error-text').textContent = err.message; + fileList.classList.remove('hidden'); + } + } +} + +// Display files in the list +function displayFiles(files) { + const fileList = document.getElementById('file-list'); + const loading = document.getElementById('file-list-loading'); + + loading.classList.add('hidden'); + fileList.classList.remove('hidden'); + + if (files.length === 0) { + fileList.innerHTML = '
No files found
'; + return; + } + + // Sort: directories first, then files + files.sort((a, b) => { + if (a.is_dir && !b.is_dir) return -1; + if (!a.is_dir && b.is_dir) return 1; + return a.name.localeCompare(b.name); + }); + + let html = ''; + + // Add "up" button if not at root + if (window.currentPath !== '/') { + html += ` +
+ + + + .. +
+ `; + } + + files.forEach(file => { + const icon = file.is_dir ? + '' : + ''; + + const clickHandler = file.is_dir ? + `navigateToDir('${file.path}')` : + `downloadFile('${file.path}', '${file.name}')`; + + html += ` +
+
+ ${icon} +
+

${file.name}

+

+ ${file.is_dir ? 'Folder' : formatFileSize(file.size)} + ${file.modified_timestamp ? ' • ' + formatDate(file.modified_timestamp) : ''} +

+
+
+ ${!file.is_dir ? ` + + ` : ''} +
+ `; + }); + + fileList.innerHTML = html; +} + +// Refresh file list +function refreshFileList() { + loadFiles(window.currentPath); +}