feat: added collapsible view in project data files.
This commit is contained in:
@@ -185,49 +185,90 @@ async function loadFTPFiles(unitId, path) {
|
|||||||
return a.name.localeCompare(b.name);
|
return a.name.localeCompare(b.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Render file list
|
function escapeForAttribute(str) {
|
||||||
let html = '';
|
return String(str).replace(/'/g, "\\'").replace(/"/g, '"');
|
||||||
for (const file of sorted) {
|
|
||||||
const icon = file.is_dir
|
|
||||||
? '<svg class="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"></path></svg>'
|
|
||||||
: '<svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"></path></svg>';
|
|
||||||
|
|
||||||
const sizeStr = file.is_dir ? '' : formatFileSize(file.size);
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="flex items-center gap-3 px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 rounded">
|
|
||||||
${icon}
|
|
||||||
<div class="flex-1 min-w-0 ${file.is_dir ? 'cursor-pointer' : ''}" ${file.is_dir ? `onclick="loadFTPFiles('${unitId}', '${file.path}')"` : ''}>
|
|
||||||
<div class="text-sm font-medium text-gray-900 dark:text-white truncate">${file.name}</div>
|
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">${file.modified}${sizeStr ? ' • ' + sizeStr : ''}</div>
|
|
||||||
</div>
|
|
||||||
${file.is_dir ? `
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button onclick="event.stopPropagation(); downloadFolderToServer('${unitId}', '${file.path}', '${file.name}')"
|
|
||||||
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded hover:bg-seismo-navy transition-colors"
|
|
||||||
title="Download folder from device to server and add to project database">
|
|
||||||
<svg class="w-3 h-3 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
|
||||||
</svg>
|
|
||||||
Download & Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
` : `
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button onclick="downloadToServer('${unitId}', '${file.path}', '${file.name}')"
|
|
||||||
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded hover:bg-seismo-navy transition-colors"
|
|
||||||
title="Download file from device to server and add to project database">
|
|
||||||
<svg class="w-3 h-3 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
|
||||||
</svg>
|
|
||||||
Download & Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileIcon(file) {
|
||||||
|
if (file.is_dir) {
|
||||||
|
return '<svg class="w-5 h-5 mr-3 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path></svg>';
|
||||||
|
} else if (file.name.toLowerCase().endsWith('.csv')) {
|
||||||
|
return '<svg class="w-5 h-5 mr-3 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>';
|
||||||
|
} else if (file.name.toLowerCase().match(/\.(txt|log)$/)) {
|
||||||
|
return '<svg class="w-5 h-5 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>';
|
||||||
|
}
|
||||||
|
return '<svg class="w-5 h-5 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render file list with collapsible folders
|
||||||
|
let html = '<div class="space-y-1">';
|
||||||
|
for (const file of sorted) {
|
||||||
|
const icon = getFileIcon(file);
|
||||||
|
const sizeStr = file.is_dir ? '' : formatFileSize(file.size);
|
||||||
|
const folderId = 'proj-folder-' + file.path.replace(/[^a-zA-Z0-9]/g, '-');
|
||||||
|
|
||||||
|
if (file.is_dir) {
|
||||||
|
// Collapsible folder row
|
||||||
|
html += `
|
||||||
|
<div class="border border-gray-200 dark:border-gray-600 rounded mb-1">
|
||||||
|
<div class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors cursor-pointer"
|
||||||
|
onclick="toggleFTPFolderProject('${unitId}', '${escapeForAttribute(file.path)}', '${folderId}', this)">
|
||||||
|
<div class="flex items-center flex-1">
|
||||||
|
<svg class="w-4 h-4 mr-2 text-gray-400 transition-transform folder-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
${icon}
|
||||||
|
<span class="text-gray-900 dark:text-white font-medium">${escapeHtml(file.name)}</span>
|
||||||
|
<span class="ml-2 text-xs text-gray-400 folder-status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0 ml-4">
|
||||||
|
<span class="text-xs text-gray-500 hidden sm:inline">${file.modified || ''}</span>
|
||||||
|
<button onclick="event.stopPropagation(); downloadFolderToServer('${unitId}', '${escapeForAttribute(file.path)}', '${escapeForAttribute(file.name)}')"
|
||||||
|
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded hover:bg-seismo-navy transition-colors flex items-center"
|
||||||
|
title="Download folder from device to server and add to project database">
|
||||||
|
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
|
</svg>
|
||||||
|
Download & Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="${folderId}" class="hidden pl-6 pr-2 pb-2 border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<div class="text-sm text-gray-500 py-2">Click to load contents...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
html += `
|
||||||
|
<div class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors">
|
||||||
|
<div class="flex items-center flex-1 min-w-0">
|
||||||
|
${icon}
|
||||||
|
<span class="text-gray-900 dark:text-white truncate">${escapeHtml(file.name)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 flex-shrink-0 ml-4">
|
||||||
|
<span class="text-xs text-gray-500 hidden sm:inline">${sizeStr}</span>
|
||||||
|
<span class="text-xs text-gray-500 hidden md:inline">${file.modified || ''}</span>
|
||||||
|
<button onclick="downloadToServer('${unitId}', '${escapeForAttribute(file.path)}', '${escapeForAttribute(file.name)}')"
|
||||||
|
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded hover:bg-seismo-navy transition-colors flex items-center"
|
||||||
|
title="Download file from device to server and add to project database">
|
||||||
|
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
|
</svg>
|
||||||
|
Download & Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
fileListDiv.innerHTML = html;
|
fileListDiv.innerHTML = html;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fileListDiv.innerHTML = '<div class="text-center py-4 text-red-500">Error loading files: ' + error + '</div>';
|
fileListDiv.innerHTML = '<div class="text-center py-4 text-red-500">Error loading files: ' + error + '</div>';
|
||||||
@@ -235,6 +276,156 @@ async function loadFTPFiles(unitId, path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toggle folder expand/collapse and load contents for Project FTP browser
|
||||||
|
async function toggleFTPFolderProject(unitId, folderPath, folderId, headerElement) {
|
||||||
|
const contentDiv = document.getElementById(folderId);
|
||||||
|
const chevron = headerElement.querySelector('.folder-chevron');
|
||||||
|
const statusSpan = headerElement.querySelector('.folder-status');
|
||||||
|
|
||||||
|
if (!contentDiv) return;
|
||||||
|
|
||||||
|
const isExpanded = !contentDiv.classList.contains('hidden');
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
// Collapse
|
||||||
|
contentDiv.classList.add('hidden');
|
||||||
|
chevron.style.transform = 'rotate(0deg)';
|
||||||
|
} else {
|
||||||
|
// Expand and load contents if not already loaded
|
||||||
|
contentDiv.classList.remove('hidden');
|
||||||
|
chevron.style.transform = 'rotate(90deg)';
|
||||||
|
|
||||||
|
// Check if already loaded
|
||||||
|
if (contentDiv.dataset.loaded === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
contentDiv.innerHTML = '<div class="text-sm text-gray-500 py-2 flex items-center"><svg class="w-4 h-4 mr-2 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>Loading...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unitId}/ftp/files?path=${encodeURIComponent(folderPath)}`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.status !== 'ok' && !result.files) {
|
||||||
|
throw new Error(result.detail || 'Failed to list files');
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = result.files || [];
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
contentDiv.innerHTML = '<div class="text-sm text-gray-500 py-2 italic">Empty folder</div>';
|
||||||
|
statusSpan.textContent = '(empty)';
|
||||||
|
contentDiv.dataset.loaded = 'true';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: directories first, then by name
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update status with file count
|
||||||
|
const dirCount = files.filter(f => f.is_dir).length;
|
||||||
|
const fileCount = files.length - dirCount;
|
||||||
|
let statusText = [];
|
||||||
|
if (dirCount > 0) statusText.push(`${dirCount} folder${dirCount > 1 ? 's' : ''}`);
|
||||||
|
if (fileCount > 0) statusText.push(`${fileCount} file${fileCount > 1 ? 's' : ''}`);
|
||||||
|
statusSpan.textContent = `(${statusText.join(', ')})`;
|
||||||
|
|
||||||
|
function escapeForAttribute(str) {
|
||||||
|
return String(str).replace(/'/g, "\\'").replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileIcon(file) {
|
||||||
|
if (file.is_dir) {
|
||||||
|
return '<svg class="w-4 h-4 mr-2 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path></svg>';
|
||||||
|
} else if (file.name.toLowerCase().endsWith('.csv')) {
|
||||||
|
return '<svg class="w-4 h-4 mr-2 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>';
|
||||||
|
} else if (file.name.toLowerCase().match(/\.(txt|log)$/)) {
|
||||||
|
return '<svg class="w-4 h-4 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>';
|
||||||
|
}
|
||||||
|
return '<svg class="w-4 h-4 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="space-y-1 mt-2">';
|
||||||
|
|
||||||
|
files.forEach(file => {
|
||||||
|
const fullPath = file.path || `${folderPath}/${file.name}`;
|
||||||
|
const icon = getFileIcon(file);
|
||||||
|
const sizeText = file.size ? formatFileSize(file.size) : '';
|
||||||
|
const subFolderId = 'proj-folder-' + fullPath.replace(/[^a-zA-Z0-9]/g, '-');
|
||||||
|
|
||||||
|
if (file.is_dir) {
|
||||||
|
// Nested collapsible folder
|
||||||
|
html += `
|
||||||
|
<div class="border border-gray-200 dark:border-gray-600 rounded">
|
||||||
|
<div class="flex items-center justify-between p-2 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors cursor-pointer text-sm"
|
||||||
|
onclick="toggleFTPFolderProject('${unitId}', '${escapeForAttribute(fullPath)}', '${subFolderId}', this)">
|
||||||
|
<div class="flex items-center flex-1">
|
||||||
|
<svg class="w-3 h-3 mr-2 text-gray-400 transition-transform folder-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
${icon}
|
||||||
|
<span class="text-gray-900 dark:text-white font-medium">${escapeHtml(file.name)}</span>
|
||||||
|
<span class="ml-2 text-xs text-gray-400 folder-status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0 ml-2">
|
||||||
|
<button onclick="event.stopPropagation(); downloadFolderToServer('${unitId}', '${escapeForAttribute(fullPath)}', '${escapeForAttribute(file.name)}')"
|
||||||
|
class="px-2 py-1 bg-seismo-orange hover:bg-seismo-navy text-white text-xs rounded transition-colors flex items-center"
|
||||||
|
title="Download folder to server">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="${subFolderId}" class="hidden pl-4 pr-2 pb-2 border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<div class="text-sm text-gray-500 py-2">Click to load contents...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
html += `
|
||||||
|
<div class="flex items-center justify-between p-2 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors text-sm">
|
||||||
|
<div class="flex items-center flex-1 min-w-0">
|
||||||
|
${icon}
|
||||||
|
<span class="text-gray-900 dark:text-white truncate">${escapeHtml(file.name)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0 ml-2">
|
||||||
|
<span class="text-xs text-gray-500 hidden sm:inline">${sizeText}</span>
|
||||||
|
<button onclick="downloadToServer('${unitId}', '${escapeForAttribute(fullPath)}', '${escapeForAttribute(file.name)}')"
|
||||||
|
class="px-2 py-1 bg-seismo-orange hover:bg-seismo-navy text-white text-xs rounded transition-colors"
|
||||||
|
title="Download to server">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
contentDiv.innerHTML = html;
|
||||||
|
contentDiv.dataset.loaded = 'true';
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load folder contents:', error);
|
||||||
|
contentDiv.innerHTML = `<div class="text-sm text-red-500 py-2">Error: ${error.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function downloadFTPFile(unitId, remotePath, fileName) {
|
async function downloadFTPFile(unitId, remotePath, fileName) {
|
||||||
const btn = event.target;
|
const btn = event.target;
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!-- Unified Files View - Database + Filesystem -->
|
<!-- Unified Files View - Database + Filesystem - Collapsible Sessions -->
|
||||||
{% if sessions %}
|
{% if sessions %}
|
||||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{% for session_data in sessions %}
|
{% for session_data in sessions %}
|
||||||
@@ -8,146 +8,154 @@
|
|||||||
{% set files = session_data.files %}
|
{% set files = session_data.files %}
|
||||||
|
|
||||||
{% if files %}
|
{% if files %}
|
||||||
<!-- Session Header -->
|
<!-- Session Container -->
|
||||||
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-900/50">
|
<div class="session-container">
|
||||||
<div class="flex items-center justify-between">
|
<!-- Session Header - Clickable to expand/collapse -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800/50 transition-colors"
|
||||||
<svg class="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
|
onclick="toggleSession('session-{{ session.id }}', this)">
|
||||||
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"></path>
|
<div class="flex items-center justify-between">
|
||||||
</svg>
|
<div class="flex items-center gap-3">
|
||||||
<div>
|
<!-- Chevron indicator -->
|
||||||
<div class="font-semibold text-gray-900 dark:text-white">
|
<svg class="w-4 h-4 text-gray-400 transition-transform session-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
{{ session.started_at.strftime('%Y-%m-%d %H:%M') if session.started_at else 'Unknown Date' }}
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
</div>
|
</svg>
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
<svg class="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
{% if unit %}{{ unit.id }}{% else %}Unknown Unit{% endif %}
|
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"></path>
|
||||||
{% if location %} @ {{ location.name }}{% endif %}
|
</svg>
|
||||||
<span class="mx-2">•</span>
|
<div>
|
||||||
{{ files|length }} file{{ 's' if files|length != 1 else '' }}
|
<div class="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ session.started_at.strftime('%Y-%m-%d %H:%M') if session.started_at else 'Unknown Date' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{% if unit %}{{ unit.id }}{% else %}Unknown Unit{% endif %}
|
||||||
|
{% if location %} @ {{ location.name }}{% endif %}
|
||||||
|
<span class="mx-2">•</span>
|
||||||
|
{{ files|length }} file{{ 's' if files|length != 1 else '' }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="flex items-center gap-2">
|
||||||
<div class="flex items-center gap-2">
|
<span class="px-2 py-1 text-xs rounded-full
|
||||||
<span class="px-2 py-1 text-xs rounded-full
|
{% if session.status == 'recording' %}bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
|
||||||
{% if session.status == 'recording' %}bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
|
{% elif session.status == 'completed' %}bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300
|
||||||
{% elif session.status == 'completed' %}bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300
|
{% elif session.status == 'paused' %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300
|
||||||
{% elif session.status == 'paused' %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300
|
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
|
||||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
|
{{ session.status or 'unknown' }}
|
||||||
{{ session.status or 'unknown' }}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Files List -->
|
<!-- Files List - Hidden by default -->
|
||||||
<div class="px-6 py-2">
|
<div id="session-{{ session.id }}" class="hidden px-6 py-2 border-t border-gray-100 dark:border-gray-700">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
{% for file_data in files %}
|
{% for file_data in files %}
|
||||||
{% set file = file_data.file %}
|
{% set file = file_data.file %}
|
||||||
{% set exists = file_data.exists_on_disk %}
|
{% set exists = file_data.exists_on_disk %}
|
||||||
{% set metadata = file_data.metadata %}
|
{% set metadata = file_data.metadata %}
|
||||||
|
|
||||||
<div class="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-lg transition-colors group">
|
<div class="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-lg transition-colors group">
|
||||||
<!-- File Icon -->
|
<!-- File Icon -->
|
||||||
{% if file.file_type == 'audio' %}
|
{% if file.file_type == 'audio' %}
|
||||||
<svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
|
||||||
</svg>
|
</svg>
|
||||||
{% elif file.file_type == 'archive' %}
|
{% elif file.file_type == 'archive' %}
|
||||||
<svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||||
</svg>
|
</svg>
|
||||||
{% elif file.file_type == 'log' %}
|
{% elif file.file_type == 'log' %}
|
||||||
<svg class="w-6 h-6 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
{% elif file.file_type == 'image' %}
|
{% elif file.file_type == 'image' %}
|
||||||
<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
{% else %}
|
{% else %}
|
||||||
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- File Info -->
|
<!-- File Info -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="font-medium text-gray-900 dark:text-white truncate">
|
<div class="font-medium text-gray-900 dark:text-white truncate">
|
||||||
{{ file.file_path.split('/')[-1] if file.file_path else 'Unknown' }}
|
{{ file.file_path.split('/')[-1] if file.file_path else 'Unknown' }}
|
||||||
</div>
|
</div>
|
||||||
{% if not exists %}
|
{% if not exists %}
|
||||||
<span class="px-2 py-0.5 text-xs bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded">
|
<span class="px-2 py-0.5 text-xs bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded">
|
||||||
Missing on disk
|
Missing on disk
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
||||||
<!-- File Type Badge -->
|
|
||||||
<span class="px-1.5 py-0.5 rounded font-medium
|
|
||||||
{% if file.file_type == 'audio' %}bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300
|
|
||||||
{% elif file.file_type == 'data' %}bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300
|
|
||||||
{% elif file.file_type == 'log' %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300
|
|
||||||
{% elif file.file_type == 'archive' %}bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300
|
|
||||||
{% elif file.file_type == 'image' %}bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300
|
|
||||||
{% else %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300{% endif %}">
|
|
||||||
{{ file.file_type or 'unknown' }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- File Size -->
|
|
||||||
<span class="mx-1">•</span>
|
|
||||||
{% if file.file_size_bytes %}
|
|
||||||
{% if file.file_size_bytes < 1024 %}
|
|
||||||
{{ file.file_size_bytes }} B
|
|
||||||
{% elif file.file_size_bytes < 1048576 %}
|
|
||||||
{{ "%.1f"|format(file.file_size_bytes / 1024) }} KB
|
|
||||||
{% elif file.file_size_bytes < 1073741824 %}
|
|
||||||
{{ "%.1f"|format(file.file_size_bytes / 1048576) }} MB
|
|
||||||
{% else %}
|
|
||||||
{{ "%.2f"|format(file.file_size_bytes / 1073741824) }} GB
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
</div>
|
||||||
Unknown size
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
{% endif %}
|
<!-- File Type Badge -->
|
||||||
|
<span class="px-1.5 py-0.5 rounded font-medium
|
||||||
|
{% if file.file_type == 'audio' %}bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300
|
||||||
|
{% elif file.file_type == 'data' %}bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300
|
||||||
|
{% elif file.file_type == 'log' %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300
|
||||||
|
{% elif file.file_type == 'archive' %}bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300
|
||||||
|
{% elif file.file_type == 'image' %}bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300
|
||||||
|
{% else %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300{% endif %}">
|
||||||
|
{{ file.file_type or 'unknown' }}
|
||||||
|
</span>
|
||||||
|
|
||||||
<!-- Download Time -->
|
<!-- File Size -->
|
||||||
{% if file.downloaded_at %}
|
<span class="mx-1">•</span>
|
||||||
<span class="mx-1">•</span>
|
{% if file.file_size_bytes %}
|
||||||
{{ file.downloaded_at.strftime('%Y-%m-%d %H:%M') }}
|
{% if file.file_size_bytes < 1024 %}
|
||||||
{% endif %}
|
{{ file.file_size_bytes }} B
|
||||||
|
{% elif file.file_size_bytes < 1048576 %}
|
||||||
|
{{ "%.1f"|format(file.file_size_bytes / 1024) }} KB
|
||||||
|
{% elif file.file_size_bytes < 1073741824 %}
|
||||||
|
{{ "%.1f"|format(file.file_size_bytes / 1048576) }} MB
|
||||||
|
{% else %}
|
||||||
|
{{ "%.2f"|format(file.file_size_bytes / 1073741824) }} GB
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
Unknown size
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Source Info from Metadata -->
|
<!-- Download Time -->
|
||||||
{% if metadata.unit_id %}
|
{% if file.downloaded_at %}
|
||||||
<span class="mx-1">•</span>
|
<span class="mx-1">•</span>
|
||||||
from {{ metadata.unit_id }}
|
{{ file.downloaded_at.strftime('%Y-%m-%d %H:%M') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Checksum Indicator -->
|
<!-- Source Info from Metadata -->
|
||||||
{% if file.checksum %}
|
{% if metadata.unit_id %}
|
||||||
<span class="mx-1" title="SHA256: {{ file.checksum[:16] }}...">
|
<span class="mx-1">•</span>
|
||||||
<svg class="w-3 h-3 inline text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
from {{ metadata.unit_id }}
|
||||||
<path fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
{% endif %}
|
||||||
</svg>
|
|
||||||
</span>
|
<!-- Checksum Indicator -->
|
||||||
{% endif %}
|
{% if file.checksum %}
|
||||||
|
<span class="mx-1" title="SHA256: {{ file.checksum[:16] }}...">
|
||||||
|
<svg class="w-3 h-3 inline text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Download Button -->
|
<!-- Download Button -->
|
||||||
{% if exists %}
|
{% if exists %}
|
||||||
<div class="opacity-0 group-hover:opacity-100 transition-opacity">
|
<div class="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button onclick="downloadFile('{{ file.id }}')"
|
<button onclick="event.stopPropagation(); downloadFile('{{ file.id }}')"
|
||||||
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||||
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Download
|
Download
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -167,6 +175,25 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
function toggleSession(sessionId, headerElement) {
|
||||||
|
const contentDiv = document.getElementById(sessionId);
|
||||||
|
const chevron = headerElement.querySelector('.session-chevron');
|
||||||
|
|
||||||
|
if (!contentDiv) return;
|
||||||
|
|
||||||
|
const isExpanded = !contentDiv.classList.contains('hidden');
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
// Collapse
|
||||||
|
contentDiv.classList.add('hidden');
|
||||||
|
chevron.style.transform = 'rotate(0deg)';
|
||||||
|
} else {
|
||||||
|
// Expand
|
||||||
|
contentDiv.classList.remove('hidden');
|
||||||
|
chevron.style.transform = 'rotate(90deg)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function downloadFile(fileId) {
|
function downloadFile(fileId) {
|
||||||
window.location.href = `/api/projects/{{ project_id }}/files/${fileId}/download`;
|
window.location.href = `/api/projects/{{ project_id }}/files/${fileId}/download`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1546,44 +1546,62 @@ async function loadFTPFiles(unitId, path) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate unique ID for this folder listing
|
||||||
|
const listingId = 'ftp-listing-' + Date.now();
|
||||||
|
|
||||||
|
function escapeForAttribute(str) {
|
||||||
|
return String(str).replace(/'/g, "\\'").replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileIcon(file) {
|
||||||
|
if (file.is_dir || file.type === 'directory') {
|
||||||
|
return '<svg class="w-5 h-5 mr-3 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path></svg>';
|
||||||
|
} else if (file.name.toLowerCase().endsWith('.csv')) {
|
||||||
|
return '<svg class="w-5 h-5 mr-3 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>';
|
||||||
|
} else if (file.name.toLowerCase().match(/\.(txt|log)$/)) {
|
||||||
|
return '<svg class="w-5 h-5 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>';
|
||||||
|
}
|
||||||
|
return '<svg class="w-5 h-5 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
// Add files and directories
|
// Add files and directories
|
||||||
files.forEach(file => {
|
files.forEach(file => {
|
||||||
const fullPath = file.path || (path === '/' ? `/${file.name}` : `${path}/${file.name}`);
|
const fullPath = file.path || (path === '/' ? `/${file.name}` : `${path}/${file.name}`);
|
||||||
const isDir = file.is_dir || file.type === 'directory';
|
const isDir = file.is_dir || file.type === 'directory';
|
||||||
|
const icon = getFileIcon(file);
|
||||||
// Determine file type icon and color
|
|
||||||
let icon, iconColor = 'text-gray-400';
|
|
||||||
if (isDir) {
|
|
||||||
icon = '<svg class="w-5 h-5 mr-3 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path></svg>';
|
|
||||||
} else if (file.name.toLowerCase().endsWith('.csv')) {
|
|
||||||
icon = '<svg class="w-5 h-5 mr-3 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>';
|
|
||||||
} else if (file.name.toLowerCase().match(/\.(txt|log)$/)) {
|
|
||||||
icon = '<svg class="w-5 h-5 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>';
|
|
||||||
} else {
|
|
||||||
icon = '<svg class="w-5 h-5 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path></svg>';
|
|
||||||
}
|
|
||||||
|
|
||||||
const sizeText = file.size ? formatFileSize(file.size) : '';
|
const sizeText = file.size ? formatFileSize(file.size) : '';
|
||||||
const dateText = file.modified || file.modified_time || '';
|
const dateText = file.modified || file.modified_time || '';
|
||||||
const canPreview = !isDir && (file.name.toLowerCase().match(/\.(csv|txt|log)$/));
|
const canPreview = !isDir && (file.name.toLowerCase().match(/\.(csv|txt|log)$/));
|
||||||
|
const folderId = 'folder-' + fullPath.replace(/[^a-zA-Z0-9]/g, '-');
|
||||||
|
|
||||||
if (isDir) {
|
if (isDir) {
|
||||||
|
// Collapsible folder row
|
||||||
html += `
|
html += `
|
||||||
<div class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors group">
|
<div class="border border-gray-200 dark:border-gray-600 rounded mb-1">
|
||||||
<div class="flex items-center flex-1 cursor-pointer" onclick="loadFTPFiles('${unitId}', '${escapeForAttribute(fullPath)}')">
|
<div class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors cursor-pointer"
|
||||||
${icon}
|
onclick="toggleFTPFolder('${unitId}', '${escapeForAttribute(fullPath)}', '${folderId}', this)">
|
||||||
<span class="text-gray-900 dark:text-white font-medium">${escapeHtml(file.name)}</span>
|
<div class="flex items-center flex-1">
|
||||||
</div>
|
<svg class="w-4 h-4 mr-2 text-gray-400 transition-transform folder-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<div class="flex items-center gap-3 flex-shrink-0 ml-4">
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
<span class="text-xs text-gray-500 hidden sm:inline">${dateText}</span>
|
|
||||||
<button onclick="event.stopPropagation(); downloadFTPFolderModal('${unitId}', '${escapeForAttribute(fullPath)}', '${escapeForAttribute(file.name)}', this)"
|
|
||||||
class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded transition-colors flex items-center"
|
|
||||||
title="Download entire folder as ZIP">
|
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
|
||||||
</svg>
|
</svg>
|
||||||
<span class="hidden lg:inline">Download ZIP</span>
|
${icon}
|
||||||
</button>
|
<span class="text-gray-900 dark:text-white font-medium">${escapeHtml(file.name)}</span>
|
||||||
|
<span class="ml-2 text-xs text-gray-400 folder-status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 flex-shrink-0 ml-4">
|
||||||
|
<span class="text-xs text-gray-500 hidden sm:inline">${dateText}</span>
|
||||||
|
<button onclick="event.stopPropagation(); downloadFTPFolderModal('${unitId}', '${escapeForAttribute(fullPath)}', '${escapeForAttribute(file.name)}', this)"
|
||||||
|
class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded transition-colors flex items-center"
|
||||||
|
title="Download entire folder as ZIP">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="hidden lg:inline">Download ZIP</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="${folderId}" class="hidden pl-6 pr-2 pb-2 border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<div class="text-sm text-gray-500 py-2">Click to load contents...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -1621,10 +1639,6 @@ async function loadFTPFiles(unitId, path) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function escapeForAttribute(str) {
|
|
||||||
return String(str).replace(/'/g, "\\'").replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
|
|
||||||
@@ -1636,6 +1650,169 @@ async function loadFTPFiles(unitId, path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toggle folder expand/collapse and load contents
|
||||||
|
async function toggleFTPFolder(unitId, folderPath, folderId, headerElement) {
|
||||||
|
const contentDiv = document.getElementById(folderId);
|
||||||
|
const chevron = headerElement.querySelector('.folder-chevron');
|
||||||
|
const statusSpan = headerElement.querySelector('.folder-status');
|
||||||
|
|
||||||
|
if (!contentDiv) return;
|
||||||
|
|
||||||
|
const isExpanded = !contentDiv.classList.contains('hidden');
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
// Collapse
|
||||||
|
contentDiv.classList.add('hidden');
|
||||||
|
chevron.style.transform = 'rotate(0deg)';
|
||||||
|
} else {
|
||||||
|
// Expand and load contents if not already loaded
|
||||||
|
contentDiv.classList.remove('hidden');
|
||||||
|
chevron.style.transform = 'rotate(90deg)';
|
||||||
|
|
||||||
|
// Check if already loaded
|
||||||
|
if (contentDiv.dataset.loaded === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
contentDiv.innerHTML = '<div class="text-sm text-gray-500 py-2 flex items-center"><svg class="w-4 h-4 mr-2 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>Loading...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unitId}/ftp/files?path=${encodeURIComponent(folderPath)}`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.status !== 'ok') {
|
||||||
|
throw new Error(result.detail || 'Failed to list files');
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = result.files || [];
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
contentDiv.innerHTML = '<div class="text-sm text-gray-500 py-2 italic">Empty folder</div>';
|
||||||
|
statusSpan.textContent = '(empty)';
|
||||||
|
contentDiv.dataset.loaded = 'true';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: directories first, then by name
|
||||||
|
files.sort((a, b) => {
|
||||||
|
if (a.type === 'directory' && b.type !== 'directory') return -1;
|
||||||
|
if (a.type !== 'directory' && b.type === 'directory') return 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update status with file count
|
||||||
|
const dirCount = files.filter(f => f.type === 'directory' || f.is_dir).length;
|
||||||
|
const fileCount = files.length - dirCount;
|
||||||
|
let statusText = [];
|
||||||
|
if (dirCount > 0) statusText.push(`${dirCount} folder${dirCount > 1 ? 's' : ''}`);
|
||||||
|
if (fileCount > 0) statusText.push(`${fileCount} file${fileCount > 1 ? 's' : ''}`);
|
||||||
|
statusSpan.textContent = `(${statusText.join(', ')})`;
|
||||||
|
|
||||||
|
function escapeForAttribute(str) {
|
||||||
|
return String(str).replace(/'/g, "\\'").replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileIcon(file) {
|
||||||
|
if (file.is_dir || file.type === 'directory') {
|
||||||
|
return '<svg class="w-4 h-4 mr-2 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path></svg>';
|
||||||
|
} else if (file.name.toLowerCase().endsWith('.csv')) {
|
||||||
|
return '<svg class="w-4 h-4 mr-2 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>';
|
||||||
|
} else if (file.name.toLowerCase().match(/\.(txt|log)$/)) {
|
||||||
|
return '<svg class="w-4 h-4 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>';
|
||||||
|
}
|
||||||
|
return '<svg class="w-4 h-4 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="space-y-1 mt-2">';
|
||||||
|
|
||||||
|
files.forEach(file => {
|
||||||
|
const fullPath = file.path || `${folderPath}/${file.name}`;
|
||||||
|
const isDir = file.is_dir || file.type === 'directory';
|
||||||
|
const icon = getFileIcon(file);
|
||||||
|
const sizeText = file.size ? formatFileSize(file.size) : '';
|
||||||
|
const dateText = file.modified || file.modified_time || '';
|
||||||
|
const canPreview = !isDir && (file.name.toLowerCase().match(/\.(csv|txt|log)$/));
|
||||||
|
const subFolderId = 'folder-' + fullPath.replace(/[^a-zA-Z0-9]/g, '-');
|
||||||
|
|
||||||
|
if (isDir) {
|
||||||
|
// Nested collapsible folder
|
||||||
|
html += `
|
||||||
|
<div class="border border-gray-200 dark:border-gray-600 rounded">
|
||||||
|
<div class="flex items-center justify-between p-2 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors cursor-pointer text-sm"
|
||||||
|
onclick="toggleFTPFolder('${unitId}', '${escapeForAttribute(fullPath)}', '${subFolderId}', this)">
|
||||||
|
<div class="flex items-center flex-1">
|
||||||
|
<svg class="w-3 h-3 mr-2 text-gray-400 transition-transform folder-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
${icon}
|
||||||
|
<span class="text-gray-900 dark:text-white font-medium">${escapeHtml(file.name)}</span>
|
||||||
|
<span class="ml-2 text-xs text-gray-400 folder-status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0 ml-2">
|
||||||
|
<button onclick="event.stopPropagation(); downloadFTPFolderModal('${unitId}', '${escapeForAttribute(fullPath)}', '${escapeForAttribute(file.name)}', this)"
|
||||||
|
class="px-2 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded transition-colors flex items-center"
|
||||||
|
title="Download entire folder as ZIP">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="${subFolderId}" class="hidden pl-4 pr-2 pb-2 border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<div class="text-sm text-gray-500 py-2">Click to load contents...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
html += `
|
||||||
|
<div class="flex items-center justify-between p-2 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors text-sm">
|
||||||
|
<div class="flex items-center flex-1 min-w-0">
|
||||||
|
${icon}
|
||||||
|
<span class="text-gray-900 dark:text-white truncate">${escapeHtml(file.name)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0 ml-2">
|
||||||
|
<span class="text-xs text-gray-500 hidden sm:inline">${sizeText}</span>
|
||||||
|
${canPreview ? `
|
||||||
|
<button onclick="previewFile('${unitId}', '${escapeForAttribute(fullPath)}', '${escapeForAttribute(file.name)}')"
|
||||||
|
class="px-2 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded transition-colors"
|
||||||
|
title="Preview file">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
<button onclick="downloadFTPFile('${unitId}', '${escapeForAttribute(fullPath)}', '${escapeForAttribute(file.name)}')"
|
||||||
|
class="px-2 py-1 bg-seismo-orange hover:bg-orange-600 text-white text-xs rounded transition-colors"
|
||||||
|
title="Download to your computer">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
contentDiv.innerHTML = html;
|
||||||
|
contentDiv.dataset.loaded = 'true';
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load folder contents:', error);
|
||||||
|
contentDiv.innerHTML = `<div class="text-sm text-red-500 py-2">Error: ${error.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function downloadFTPFile(unitId, filePath, fileName) {
|
async function downloadFTPFile(unitId, filePath, fileName) {
|
||||||
try {
|
try {
|
||||||
// Show download indicator
|
// Show download indicator
|
||||||
|
|||||||
Reference in New Issue
Block a user