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;
|
||||
|
||||
Reference in New Issue
Block a user