...
This commit is contained in:
290
static/js/file-tree.js
Normal file
290
static/js/file-tree.js
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* File Tree Component
|
||||
* Manages the hierarchical file tree display and interactions
|
||||
*/
|
||||
|
||||
class FileTree {
|
||||
constructor(containerId, webdavClient) {
|
||||
this.container = document.getElementById(containerId);
|
||||
this.webdavClient = webdavClient;
|
||||
this.tree = [];
|
||||
this.selectedPath = null;
|
||||
this.onFileSelect = null;
|
||||
this.onFolderSelect = null;
|
||||
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Click handler for tree nodes
|
||||
this.container.addEventListener('click', (e) => {
|
||||
const node = e.target.closest('.tree-node');
|
||||
if (!node) return;
|
||||
|
||||
const path = node.dataset.path;
|
||||
const isDir = node.dataset.isdir === 'true';
|
||||
|
||||
// Toggle folder
|
||||
if (e.target.closest('.tree-toggle')) {
|
||||
this.toggleFolder(node);
|
||||
return;
|
||||
}
|
||||
|
||||
// Select node
|
||||
if (isDir) {
|
||||
this.selectFolder(path);
|
||||
} else {
|
||||
this.selectFile(path);
|
||||
}
|
||||
});
|
||||
|
||||
// Context menu
|
||||
this.container.addEventListener('contextmenu', (e) => {
|
||||
const node = e.target.closest('.tree-node');
|
||||
if (!node) return;
|
||||
|
||||
e.preventDefault();
|
||||
const path = node.dataset.path;
|
||||
const isDir = node.dataset.isdir === 'true';
|
||||
|
||||
window.showContextMenu(e.clientX, e.clientY, { path, isDir });
|
||||
});
|
||||
}
|
||||
|
||||
async load() {
|
||||
try {
|
||||
const items = await this.webdavClient.propfind('', 'infinity');
|
||||
this.tree = this.webdavClient.buildTree(items);
|
||||
this.render();
|
||||
} catch (error) {
|
||||
console.error('Failed to load file tree:', error);
|
||||
showNotification('Failed to load files', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container.innerHTML = '';
|
||||
this.renderNodes(this.tree, this.container, 0);
|
||||
}
|
||||
|
||||
renderNodes(nodes, parentElement, level) {
|
||||
nodes.forEach(node => {
|
||||
const nodeElement = this.createNodeElement(node, level);
|
||||
parentElement.appendChild(nodeElement);
|
||||
|
||||
if (node.children && node.children.length > 0) {
|
||||
const childContainer = document.createElement('div');
|
||||
childContainer.className = 'tree-children';
|
||||
childContainer.style.display = 'none';
|
||||
nodeElement.appendChild(childContainer);
|
||||
this.renderNodes(node.children, childContainer, level + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createNodeElement(node, level) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'tree-node';
|
||||
div.dataset.path = node.path;
|
||||
div.dataset.isdir = node.isDirectory;
|
||||
div.style.paddingLeft = `${level * 20 + 10}px`;
|
||||
|
||||
// Toggle arrow for folders
|
||||
if (node.isDirectory) {
|
||||
const toggle = document.createElement('span');
|
||||
toggle.className = 'tree-toggle';
|
||||
toggle.innerHTML = '<i class="bi bi-chevron-right"></i>';
|
||||
div.appendChild(toggle);
|
||||
} else {
|
||||
const spacer = document.createElement('span');
|
||||
spacer.className = 'tree-spacer';
|
||||
spacer.style.width = '16px';
|
||||
spacer.style.display = 'inline-block';
|
||||
div.appendChild(spacer);
|
||||
}
|
||||
|
||||
// Icon
|
||||
const icon = document.createElement('i');
|
||||
if (node.isDirectory) {
|
||||
icon.className = 'bi bi-folder-fill';
|
||||
icon.style.color = '#dcb67a';
|
||||
} else {
|
||||
icon.className = 'bi bi-file-earmark-text';
|
||||
icon.style.color = '#6a9fb5';
|
||||
}
|
||||
div.appendChild(icon);
|
||||
|
||||
// Name
|
||||
const name = document.createElement('span');
|
||||
name.className = 'tree-name';
|
||||
name.textContent = node.name;
|
||||
div.appendChild(name);
|
||||
|
||||
// Size for files
|
||||
if (!node.isDirectory && node.size) {
|
||||
const size = document.createElement('span');
|
||||
size.className = 'tree-size';
|
||||
size.textContent = this.formatSize(node.size);
|
||||
div.appendChild(size);
|
||||
}
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
toggleFolder(nodeElement) {
|
||||
const childContainer = nodeElement.querySelector('.tree-children');
|
||||
if (!childContainer) return;
|
||||
|
||||
const toggle = nodeElement.querySelector('.tree-toggle i');
|
||||
const isExpanded = childContainer.style.display !== 'none';
|
||||
|
||||
if (isExpanded) {
|
||||
childContainer.style.display = 'none';
|
||||
toggle.className = 'bi bi-chevron-right';
|
||||
} else {
|
||||
childContainer.style.display = 'block';
|
||||
toggle.className = 'bi bi-chevron-down';
|
||||
}
|
||||
}
|
||||
|
||||
selectFile(path) {
|
||||
this.selectedPath = path;
|
||||
this.updateSelection();
|
||||
if (this.onFileSelect) {
|
||||
this.onFileSelect({ path, isDirectory: false });
|
||||
}
|
||||
}
|
||||
|
||||
selectFolder(path) {
|
||||
this.selectedPath = path;
|
||||
this.updateSelection();
|
||||
if (this.onFolderSelect) {
|
||||
this.onFolderSelect({ path, isDirectory: true });
|
||||
}
|
||||
}
|
||||
|
||||
updateSelection() {
|
||||
// Remove previous selection
|
||||
this.container.querySelectorAll('.tree-node').forEach(node => {
|
||||
node.classList.remove('selected');
|
||||
});
|
||||
|
||||
// Add selection to current
|
||||
if (this.selectedPath) {
|
||||
const node = this.container.querySelector(`[data-path="${this.selectedPath}"]`);
|
||||
if (node) {
|
||||
node.classList.add('selected');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
formatSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 10) / 10 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
async createFile(parentPath, filename) {
|
||||
try {
|
||||
const fullPath = parentPath ? `${parentPath}/${filename}` : filename;
|
||||
await this.webdavClient.put(fullPath, '# New File\n\nStart typing...\n');
|
||||
await this.load();
|
||||
showNotification('File created', 'success');
|
||||
return fullPath;
|
||||
} catch (error) {
|
||||
console.error('Failed to create file:', error);
|
||||
showNotification('Failed to create file', 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createFolder(parentPath, foldername) {
|
||||
try {
|
||||
const fullPath = parentPath ? `${parentPath}/${foldername}` : foldername;
|
||||
await this.webdavClient.mkcol(fullPath);
|
||||
await this.load();
|
||||
showNotification('Folder created', 'success');
|
||||
return fullPath;
|
||||
} catch (error) {
|
||||
console.error('Failed to create folder:', error);
|
||||
showNotification('Failed to create folder', 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async uploadFile(parentPath, file) {
|
||||
try {
|
||||
const fullPath = parentPath ? `${parentPath}/${file.name}` : file.name;
|
||||
const content = await file.arrayBuffer();
|
||||
await this.webdavClient.putBinary(fullPath, content);
|
||||
await this.load();
|
||||
showNotification(`Uploaded ${file.name}`, 'success');
|
||||
return fullPath;
|
||||
} catch (error) {
|
||||
console.error('Failed to upload file:', error);
|
||||
showNotification('Failed to upload file', 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async downloadFile(path) {
|
||||
try {
|
||||
const content = await this.webdavClient.get(path);
|
||||
const filename = path.split('/').pop();
|
||||
this.triggerDownload(content, filename);
|
||||
showNotification('Downloaded', 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to download file:', error);
|
||||
showNotification('Failed to download file', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async downloadFolder(path) {
|
||||
try {
|
||||
showNotification('Creating zip...', 'info');
|
||||
// Get all files in folder
|
||||
const items = await this.webdavClient.propfind(path, 'infinity');
|
||||
const files = items.filter(item => !item.isDirectory);
|
||||
|
||||
// Use JSZip to create zip file
|
||||
const JSZip = window.JSZip;
|
||||
if (!JSZip) {
|
||||
throw new Error('JSZip not loaded');
|
||||
}
|
||||
|
||||
const zip = new JSZip();
|
||||
const folder = zip.folder(path.split('/').pop() || 'download');
|
||||
|
||||
// Add all files to zip
|
||||
for (const file of files) {
|
||||
const content = await this.webdavClient.get(file.path);
|
||||
const relativePath = file.path.replace(path + '/', '');
|
||||
folder.file(relativePath, content);
|
||||
}
|
||||
|
||||
// Generate zip
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
const zipFilename = `${path.split('/').pop() || 'download'}.zip`;
|
||||
this.triggerDownload(zipBlob, zipFilename);
|
||||
showNotification('Downloaded', 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to download folder:', error);
|
||||
showNotification('Failed to download folder', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
triggerDownload(content, filename) {
|
||||
const blob = content instanceof Blob ? content : new Blob([content]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user