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);
|
||||
});
|
||||
|
||||
// Render file list
|
||||
let html = '';
|
||||
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 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-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;
|
||||
} catch (error) {
|
||||
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) {
|
||||
const btn = event.target;
|
||||
btn.disabled = true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Unified Files View - Database + Filesystem -->
|
||||
<!-- Unified Files View - Database + Filesystem - Collapsible Sessions -->
|
||||
{% if sessions %}
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for session_data in sessions %}
|
||||
@@ -8,146 +8,154 @@
|
||||
{% set files = session_data.files %}
|
||||
|
||||
{% if files %}
|
||||
<!-- Session Header -->
|
||||
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-900/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<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>
|
||||
<div>
|
||||
<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 '' }}
|
||||
<!-- Session Container -->
|
||||
<div class="session-container">
|
||||
<!-- Session Header - Clickable to expand/collapse -->
|
||||
<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"
|
||||
onclick="toggleSession('session-{{ session.id }}', this)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Chevron indicator -->
|
||||
<svg class="w-4 h-4 text-gray-400 transition-transform session-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>
|
||||
<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>
|
||||
<div>
|
||||
<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 class="flex items-center gap-2">
|
||||
<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
|
||||
{% 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
|
||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
|
||||
{{ session.status or 'unknown' }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<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
|
||||
{% 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
|
||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
|
||||
{{ session.status or 'unknown' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Files List -->
|
||||
<div class="px-6 py-2">
|
||||
<div class="space-y-1">
|
||||
{% for file_data in files %}
|
||||
{% set file = file_data.file %}
|
||||
{% set exists = file_data.exists_on_disk %}
|
||||
{% set metadata = file_data.metadata %}
|
||||
<!-- Files List - Hidden by default -->
|
||||
<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">
|
||||
{% for file_data in files %}
|
||||
{% set file = file_data.file %}
|
||||
{% set exists = file_data.exists_on_disk %}
|
||||
{% 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">
|
||||
<!-- File Icon -->
|
||||
{% if file.file_type == 'audio' %}
|
||||
<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>
|
||||
</svg>
|
||||
{% elif file.file_type == 'archive' %}
|
||||
<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>
|
||||
</svg>
|
||||
{% elif file.file_type == 'log' %}
|
||||
<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>
|
||||
</svg>
|
||||
{% elif file.file_type == 'image' %}
|
||||
<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>
|
||||
</svg>
|
||||
{% else %}
|
||||
<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>
|
||||
</svg>
|
||||
{% endif %}
|
||||
<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 -->
|
||||
{% if file.file_type == 'audio' %}
|
||||
<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>
|
||||
</svg>
|
||||
{% elif file.file_type == 'archive' %}
|
||||
<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>
|
||||
</svg>
|
||||
{% elif file.file_type == 'log' %}
|
||||
<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>
|
||||
</svg>
|
||||
{% elif file.file_type == 'image' %}
|
||||
<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>
|
||||
</svg>
|
||||
{% else %}
|
||||
<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>
|
||||
</svg>
|
||||
{% endif %}
|
||||
|
||||
<!-- File Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="font-medium text-gray-900 dark:text-white truncate">
|
||||
{{ file.file_path.split('/')[-1] if file.file_path else 'Unknown' }}
|
||||
</div>
|
||||
{% 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">
|
||||
Missing on disk
|
||||
</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
|
||||
<!-- File Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="font-medium text-gray-900 dark:text-white truncate">
|
||||
{{ file.file_path.split('/')[-1] if file.file_path else 'Unknown' }}
|
||||
</div>
|
||||
{% 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">
|
||||
Missing on disk
|
||||
</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Unknown size
|
||||
{% 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>
|
||||
|
||||
<!-- Download Time -->
|
||||
{% if file.downloaded_at %}
|
||||
<span class="mx-1">•</span>
|
||||
{{ file.downloaded_at.strftime('%Y-%m-%d %H:%M') }}
|
||||
{% endif %}
|
||||
<!-- 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 %}
|
||||
{% else %}
|
||||
Unknown size
|
||||
{% endif %}
|
||||
|
||||
<!-- Source Info from Metadata -->
|
||||
{% if metadata.unit_id %}
|
||||
<span class="mx-1">•</span>
|
||||
from {{ metadata.unit_id }}
|
||||
{% endif %}
|
||||
<!-- Download Time -->
|
||||
{% if file.downloaded_at %}
|
||||
<span class="mx-1">•</span>
|
||||
{{ file.downloaded_at.strftime('%Y-%m-%d %H:%M') }}
|
||||
{% endif %}
|
||||
|
||||
<!-- Checksum Indicator -->
|
||||
{% 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 %}
|
||||
<!-- Source Info from Metadata -->
|
||||
{% if metadata.unit_id %}
|
||||
<span class="mx-1">•</span>
|
||||
from {{ metadata.unit_id }}
|
||||
{% endif %}
|
||||
|
||||
<!-- Checksum Indicator -->
|
||||
{% 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>
|
||||
|
||||
<!-- Download Button -->
|
||||
{% if exists %}
|
||||
<div class="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onclick="downloadFile('{{ file.id }}')"
|
||||
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">
|
||||
<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
|
||||
</button>
|
||||
<!-- Download Button -->
|
||||
{% if exists %}
|
||||
<div class="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<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">
|
||||
<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>
|
||||
</svg>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -167,6 +175,25 @@
|
||||
{% endif %}
|
||||
|
||||
<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) {
|
||||
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
|
||||
files.forEach(file => {
|
||||
const fullPath = file.path || (path === '/' ? `/${file.name}` : `${path}/${file.name}`);
|
||||
const isDir = file.is_dir || file.type === 'directory';
|
||||
|
||||
// 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 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 folderId = 'folder-' + fullPath.replace(/[^a-zA-Z0-9]/g, '-');
|
||||
|
||||
if (isDir) {
|
||||
// Collapsible folder row
|
||||
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="flex items-center flex-1 cursor-pointer" onclick="loadFTPFiles('${unitId}', '${escapeForAttribute(fullPath)}')">
|
||||
${icon}
|
||||
<span class="text-gray-900 dark:text-white font-medium">${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">${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>
|
||||
<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="toggleFTPFolder('${unitId}', '${escapeForAttribute(fullPath)}', '${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>
|
||||
<span class="hidden lg:inline">Download ZIP</span>
|
||||
</button>
|
||||
${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-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>
|
||||
`;
|
||||
@@ -1621,10 +1639,6 @@ async function loadFTPFiles(unitId, path) {
|
||||
}
|
||||
});
|
||||
|
||||
function escapeForAttribute(str) {
|
||||
return String(str).replace(/'/g, "\\'").replace(/"/g, '"');
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
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) {
|
||||
try {
|
||||
// Show download indicator
|
||||
|
||||
Reference in New Issue
Block a user