Last active
March 11, 2026 18:02
-
-
Save VTSTech/8014e5b8b7fa26cec3457c15d77c2a17 to your computer and use it in GitHub Desktop.
GLM-5 File Manager by VTSTech
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # -*- coding: utf-8 -*- | |
| #!/usr/bin/env python3 | |
| """GLM-5 File Manager Panel v1.31 for z.ai by VTSTech""" | |
| # | |
| # ╔══════════════════════════════════════════════════════════════════════════════╗ | |
| # ║ TO RUN THIS FILE MANAGER: ║ | |
| # ║ ║ | |
| # ║ 1. Start the server: ║ | |
| # ║ python3 GLM-FileManager.py & ║ | |
| # ║ ║ | |
| # ║ 2. Set up cloudflared tunnel (for remote access): ║ | |
| # ║ ║ | |
| # ║ Install cloudflared (if not installed): ║ | |
| # ║ - Linux: curl -L https://github.com/cloudflare/cloudflared/releases/ ║ | |
| # ║ latest/download/cloudflared-linux-amd64 -o /tmp/cloudflared && ║ | |
| # ║ chmod +x /tmp/cloudflared ║ | |
| # ║ - Or download from: https://github.com/cloudflare/cloudflared/releases ║ | |
| # ║ ║ | |
| # ║ Run the tunnel: ║ | |
| # ║ /tmp/cloudflared tunnel --url http://localhost:8765 ║ | |
| # ║ ║ | |
| # ║ 3. Access via the cloudflared URL provided (e.g., https://xxx.trycloudflare.com) ║ | |
| # ║ ║ | |
| # ║ 4. Login with credentials (default): ║ | |
| # ║ Username: admin ║ | |
| # ║ Password: changeme ║ | |
| # ║ (Change AUTH_USERNAME and AUTH_PASSWORD in the script to customize) ║ | |
| # ║ ║ | |
| # ║ To stop: pkill -f GLM-FileManager OR use the new STOP button in the UI ║ | |
| # ╚══════════════════════════════════════════════════════════════════════════════╝ | |
| # | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # CHANGELOG | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # v1.31 - 2025-03-10 | |
| # + ADDED STOP BUTTON to shutdown server from the web UI | |
| # | |
| # v1.3 - 2025-03-07 | |
| # + Added archive extraction (zip, tar, tar.gz, tar.bz2) | |
| # + Added archive compression (create zip from selected files/folders) | |
| # + Improved folder upload with webkitGetAsEntry API for recursive directory upload | |
| # + Added right-click context menu for quick actions | |
| # + Added PHP syntax highlighting support | |
| # + Added file actions on hover (Extract button for archives) | |
| # + Improved error handling with try-catch blocks and user notifications | |
| # + Added SO_REUSEADDR for faster server restarts | |
| # * Fixed file viewing functionality (double-click to open) | |
| # * Fixed Prism.js syntax highlighting in editor | |
| # * Fixed context menu compress to use all selected files (not just clicked one) | |
| # | |
| # v1.2 - Previous release | |
| # + Basic file manager with upload, download, delete, edit | |
| # + Syntax highlighting for common languages | |
| # + Image preview | |
| # + Drag and drop upload | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| import http.server | |
| import socketserver | |
| import os | |
| import json | |
| import mimetypes | |
| import base64 | |
| import zipfile | |
| import tarfile | |
| import shutil | |
| import tempfile | |
| import threading | |
| import time | |
| from datetime import datetime | |
| from urllib.parse import unquote | |
| BASE_DIR = "/home/z/my-project" | |
| PORT = 8765 | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # AUTH CONFIGURATION | |
| # Change these credentials to secure your File Manager | |
| AUTH_USERNAME = "admin" | |
| AUTH_PASSWORD = "changeme" | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| def is_archive(filename): | |
| """Check if file is a supported archive format.""" | |
| ext = filename.lower().split('.')[-1] | |
| return ext in ['zip', 'tar', 'gz', 'bz2', 'tgz', 'tbz2'] | |
| def get_archive_type(filename): | |
| """Get archive type from filename.""" | |
| lower = filename.lower() | |
| if lower.endswith('.zip'): | |
| return 'zip' | |
| elif lower.endswith('.tar.gz') or lower.endswith('.tgz'): | |
| return 'tar.gz' | |
| elif lower.endswith('.tar.bz2') or lower.endswith('.tbz2'): | |
| return 'tar.bz2' | |
| elif lower.endswith('.tar'): | |
| return 'tar' | |
| return None | |
| def extract_archive(archive_path, dest_dir): | |
| """Extract archive to destination directory.""" | |
| archive_type = get_archive_type(archive_path) | |
| if archive_type == 'zip': | |
| with zipfile.ZipFile(archive_path, 'r') as zf: | |
| zf.extractall(dest_dir) | |
| elif archive_type in ['tar.gz', 'tar']: | |
| with tarfile.open(archive_path, 'r:*') as tf: | |
| tf.extractall(dest_dir) | |
| elif archive_type == 'tar.bz2': | |
| with tarfile.open(archive_path, 'r:bz2') as tf: | |
| tf.extractall(dest_dir) | |
| else: | |
| raise ValueError(f"Unsupported archive type: {archive_type}") | |
| def create_zip(source_path, dest_zip): | |
| """Create a zip archive from file or directory.""" | |
| with zipfile.ZipFile(dest_zip, 'w', zipfile.ZIP_DEFLATED) as zf: | |
| if os.path.isfile(source_path): | |
| zf.write(source_path, os.path.basename(source_path)) | |
| else: | |
| for root, dirs, files in os.walk(source_path): | |
| for file in files: | |
| file_path = os.path.join(root, file) | |
| arcname = os.path.relpath(file_path, os.path.dirname(source_path)) | |
| zf.write(file_path, arcname) | |
| class FileManager(http.server.SimpleHTTPRequestHandler): | |
| def check_auth(self): | |
| """Check Basic Auth credentials.""" | |
| auth = self.headers.get('Authorization') | |
| if not auth: | |
| return False | |
| try: | |
| # Parse "Basic <base64 credentials>" | |
| auth_decoded = base64.b64decode(auth.split()[1]).decode('utf-8') | |
| username, password = auth_decoded.split(':', 1) | |
| return username == AUTH_USERNAME and password == AUTH_PASSWORD | |
| except: | |
| return False | |
| def send_auth_required(self): | |
| """Send 401 Unauthorized response.""" | |
| self.send_response(401) | |
| self.send_header('WWW-Authenticate', 'Basic realm="File Manager"') | |
| self.send_header('Content-type', 'text/html') | |
| self.end_headers() | |
| self.wfile.write(b'<h1>401 Unauthorized</h1><p>Valid credentials required.</p>') | |
| def get_dir_listing(self, path, sort_by='name', sort_dir='asc'): | |
| """Get directory listing with sorting support.""" | |
| try: | |
| items = [] | |
| for item in os.listdir(path): | |
| full_path = os.path.join(path, item) | |
| stat = os.stat(full_path) | |
| items.append({ | |
| 'name': item, | |
| 'is_dir': os.path.isdir(full_path), | |
| 'size': stat.st_size if os.path.isfile(full_path) else '-', | |
| 'modified': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M'), | |
| 'modified_ts': stat.st_mtime, # For sorting | |
| 'is_archive': is_archive(item) | |
| }) | |
| # Sort: folders first, then files | |
| folders = [i for i in items if i['is_dir']] | |
| files = [i for i in items if not i['is_dir']] | |
| # Sort folders and files separately | |
| reverse = sort_dir == 'desc' | |
| if sort_by == 'name': | |
| folders.sort(key=lambda x: x['name'].lower(), reverse=reverse) | |
| files.sort(key=lambda x: x['name'].lower(), reverse=reverse) | |
| elif sort_by == 'date': | |
| folders.sort(key=lambda x: x['modified_ts'], reverse=reverse) | |
| files.sort(key=lambda x: x['modified_ts'], reverse=reverse) | |
| elif sort_by == 'size': | |
| folders.sort(key=lambda x: x['name'].lower()) # Folders by name | |
| files.sort(key=lambda x: x['size'] if x['size'] != '-' else 0, reverse=reverse) | |
| # Remove temp sorting field before returning | |
| for item in items: | |
| item.pop('modified_ts', None) | |
| return folders + files | |
| except Exception as e: | |
| return [] | |
| def send_json(self, data): | |
| self.send_response(200) | |
| self.send_header('Content-type', 'application/json') | |
| self.end_headers() | |
| self.wfile.write(json.dumps(data).encode()) | |
| def do_GET(self): | |
| # Check auth for all requests | |
| if not self.check_auth(): | |
| self.send_auth_required() | |
| return | |
| if self.path == '/' or self.path == '/index.html': | |
| self.serve_panel() | |
| elif self.path == '/api/list': | |
| rel_path = self.headers.get('X-Path', '') | |
| sort_by = self.headers.get('X-Sort-By', 'name') | |
| sort_dir = self.headers.get('X-Sort-Dir', 'asc') | |
| full_path = os.path.normpath(os.path.join(BASE_DIR, rel_path)) | |
| if not full_path.startswith(BASE_DIR): | |
| self.send_json({'error': 'Access denied'}) | |
| return | |
| self.send_json({ | |
| 'path': rel_path, | |
| 'items': self.get_dir_listing(full_path, sort_by, sort_dir) | |
| }) | |
| elif self.path == '/api/download': | |
| rel_path = self.headers.get('X-Path', '') | |
| full_path = os.path.normpath(os.path.join(BASE_DIR, rel_path)) | |
| if not full_path.startswith(BASE_DIR) or not os.path.isfile(full_path): | |
| self.send_response(404) | |
| self.end_headers() | |
| return | |
| self.send_response(200) | |
| self.send_header('Content-type', 'application/octet-stream') | |
| self.send_header('Content-Disposition', f'attachment; filename="{os.path.basename(full_path)}"') | |
| self.end_headers() | |
| with open(full_path, 'rb') as f: | |
| self.wfile.write(f.read()) | |
| elif self.path == '/api/view': | |
| rel_path = self.headers.get('X-Path', '') | |
| full_path = os.path.normpath(os.path.join(BASE_DIR, rel_path)) | |
| if not full_path.startswith(BASE_DIR) or not os.path.isfile(full_path): | |
| self.send_json({'error': 'File not found'}) | |
| return | |
| try: | |
| with open(full_path, 'r', encoding='utf-8', errors='replace') as f: | |
| content = f.read() | |
| lines = content.count('\n') + 1 | |
| self.send_json({'content': content, 'path': rel_path, 'lines': lines}) | |
| except: | |
| self.send_json({'error': 'Cannot read file'}) | |
| elif self.path == '/api/image': | |
| rel_path = self.headers.get('X-Path', '') | |
| full_path = os.path.normpath(os.path.join(BASE_DIR, rel_path)) | |
| if not full_path.startswith(BASE_DIR) or not os.path.isfile(full_path): | |
| self.send_response(404) | |
| self.end_headers() | |
| return | |
| mime_type, _ = mimetypes.guess_type(full_path) | |
| if not mime_type or not mime_type.startswith('image/'): | |
| self.send_response(400) | |
| self.end_headers() | |
| return | |
| with open(full_path, 'rb') as f: | |
| data = f.read() | |
| self.send_response(200) | |
| self.send_header('Content-type', mime_type) | |
| self.send_header('Content-Length', str(len(data))) | |
| self.end_headers() | |
| self.wfile.write(data) | |
| elif self.path == '/api/stats': | |
| total_files = 0 | |
| total_dirs = 0 | |
| total_size = 0 | |
| for root, dirs, files in os.walk(BASE_DIR): | |
| total_dirs += len(dirs) | |
| total_files += len(files) | |
| for f in files: | |
| try: | |
| total_size += os.path.getsize(os.path.join(root, f)) | |
| except: | |
| pass | |
| self.send_json({ | |
| 'files': total_files, | |
| 'dirs': total_dirs, | |
| 'size': total_size, | |
| 'size_str': f"{total_size / 1024 / 1024:.1f} MB" | |
| }) | |
| else: | |
| self.send_response(404) | |
| self.end_headers() | |
| def do_POST(self): | |
| # Check auth for all requests | |
| if not self.check_auth(): | |
| self.send_auth_required() | |
| return | |
| content_length = int(self.headers.get('Content-Length', 0)) | |
| content_type = self.headers.get('Content-Type', '') | |
| if self.path == '/api/upload': | |
| try: | |
| rel_path = self.headers.get('X-Path', '') | |
| filename = self.headers.get('X-Filename', '') | |
| if not filename: | |
| self.send_json({'error': 'No filename'}) | |
| return | |
| full_dir = os.path.normpath(os.path.join(BASE_DIR, rel_path)) | |
| if not full_dir.startswith(BASE_DIR): | |
| self.send_json({'error': 'Access denied'}) | |
| return | |
| os.makedirs(full_dir, exist_ok=True) | |
| full_path = os.path.join(full_dir, filename) | |
| body = self.rfile.read(content_length) | |
| with open(full_path, 'wb') as f: | |
| f.write(body) | |
| self.send_json({'success': True, 'message': f'Uploaded {filename}'}) | |
| except Exception as e: | |
| self.send_json({'error': str(e)}) | |
| elif self.path == '/api/save': | |
| try: | |
| body = self.rfile.read(content_length).decode() | |
| data = json.loads(body) | |
| rel_path = data.get('path', '') | |
| content = data.get('content', '') | |
| full_path = os.path.normpath(os.path.join(BASE_DIR, rel_path)) | |
| if not full_path.startswith(BASE_DIR): | |
| self.send_json({'error': 'Access denied'}) | |
| return | |
| with open(full_path, 'w', encoding='utf-8') as f: | |
| f.write(content) | |
| self.send_json({'success': True, 'message': 'File saved'}) | |
| except Exception as e: | |
| self.send_json({'error': str(e)}) | |
| elif self.path == '/api/delete': | |
| try: | |
| body = self.rfile.read(content_length).decode() | |
| data = json.loads(body) | |
| rel_path = data.get('path', '') | |
| full_path = os.path.normpath(os.path.join(BASE_DIR, rel_path)) | |
| if not full_path.startswith(BASE_DIR): | |
| self.send_json({'error': 'Access denied'}) | |
| return | |
| if os.path.isfile(full_path): | |
| os.remove(full_path) | |
| elif os.path.isdir(full_path): | |
| shutil.rmtree(full_path) | |
| self.send_json({'success': True, 'message': 'Deleted'}) | |
| except Exception as e: | |
| self.send_json({'error': str(e)}) | |
| elif self.path == '/api/mkdir': | |
| try: | |
| body = self.rfile.read(content_length).decode() | |
| data = json.loads(body) | |
| rel_path = data.get('path', '') | |
| name = data.get('name', '') | |
| full_path = os.path.normpath(os.path.join(BASE_DIR, rel_path, name)) | |
| if not full_path.startswith(BASE_DIR): | |
| self.send_json({'error': 'Access denied'}) | |
| return | |
| os.makedirs(full_path, exist_ok=True) | |
| self.send_json({'success': True, 'message': 'Folder created'}) | |
| except Exception as e: | |
| self.send_json({'error': str(e)}) | |
| elif self.path == '/api/extract': | |
| try: | |
| body = self.rfile.read(content_length).decode() | |
| data = json.loads(body) | |
| rel_path = data.get('path', '') | |
| full_path = os.path.normpath(os.path.join(BASE_DIR, rel_path)) | |
| if not full_path.startswith(BASE_DIR) or not os.path.isfile(full_path): | |
| self.send_json({'error': 'File not found'}) | |
| return | |
| if not is_archive(full_path): | |
| self.send_json({'error': 'Not a supported archive format'}) | |
| return | |
| # Extract to same directory as archive | |
| dest_dir = os.path.dirname(full_path) | |
| extract_archive(full_path, dest_dir) | |
| self.send_json({'success': True, 'message': f'Extracted to {os.path.basename(dest_dir) or "root"}'}) | |
| except Exception as e: | |
| self.send_json({'error': str(e)}) | |
| elif self.path == '/api/compress': | |
| try: | |
| body = self.rfile.read(content_length).decode() | |
| data = json.loads(body) | |
| items = data.get('items', []) | |
| rel_path = data.get('path', '') | |
| archive_name = data.get('name', 'archive.zip') | |
| if not items: | |
| self.send_json({'error': 'No items selected'}) | |
| return | |
| # Ensure .zip extension | |
| if not archive_name.endswith('.zip'): | |
| archive_name += '.zip' | |
| dest_dir = os.path.normpath(os.path.join(BASE_DIR, rel_path)) | |
| if not dest_dir.startswith(BASE_DIR): | |
| self.send_json({'error': 'Access denied'}) | |
| return | |
| archive_path = os.path.join(dest_dir, archive_name) | |
| # Create zip with selected items | |
| with zipfile.ZipFile(archive_path, 'w', zipfile.ZIP_DEFLATED) as zf: | |
| for item in items: | |
| item_path = os.path.normpath(os.path.join(BASE_DIR, rel_path, item)) | |
| if not item_path.startswith(BASE_DIR): | |
| continue | |
| if os.path.isfile(item_path): | |
| zf.write(item_path, item) | |
| elif os.path.isdir(item_path): | |
| for root, dirs, files in os.walk(item_path): | |
| for file in files: | |
| file_path = os.path.join(root, file) | |
| arcname = os.path.join(item, os.path.relpath(file_path, item_path)) | |
| zf.write(file_path, arcname) | |
| self.send_json({'success': True, 'message': f'Created {archive_name}'}) | |
| except Exception as e: | |
| self.send_json({'error': str(e)}) | |
| elif self.path == '/api/stop': | |
| try: | |
| self.send_json({'success': True, 'message': 'Server shutting down...'}) | |
| # Run shutdown in a separate thread to allow response to be sent | |
| threading.Thread(target=self.server.shutdown).start() | |
| except Exception as e: | |
| self.send_json({'error': str(e)}) | |
| else: | |
| self.send_response(404) | |
| self.end_headers() | |
| def serve_panel(self): | |
| self.send_response(200) | |
| self.send_header('Content-type', 'text/html; charset=utf-8') | |
| self.end_headers() | |
| html_content = '''<!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title>File Manager - my-project</title> | |
| <link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" rel="stylesheet" /> | |
| <style> | |
| * { box-sizing: border-box; } | |
| body { font-family: system-ui, sans-serif; margin: 0; background: #0d1117; color: #e6edf3; } | |
| .container { display: flex; height: 100vh; } | |
| /* Sidebar */ | |
| .sidebar { width: 260px; background: #161b22; border-right: 1px solid #30363d; padding: 16px; display: flex; flex-direction: column; } | |
| .logo { font-size: 1.2rem; font-weight: 800; color: #3fb950; margin-bottom: 4px; } | |
| .logo-sub { font-size: 0.75rem; color: #8b949e; margin-bottom: 8px; } | |
| .branding { font-size: 0.7rem; color: #484f58; margin-bottom: 16px; padding-top: 8px; border-top: 1px solid #21262d; } | |
| .branding a { color: #58a6ff; text-decoration: none; } | |
| .branding a:hover { text-decoration: underline; } | |
| .stats { background: #21262d; border-radius: 8px; padding: 12px; margin-bottom: 16px; } | |
| .stat-row { display: flex; justify-content: space-between; font-size: 0.85rem; margin: 4px 0; } | |
| .stat-label { color: #8b949e; } | |
| .stat-value { color: #e6edf3; font-weight: 600; } | |
| .nav-btn { background: transparent; border: none; color: #8b949e; text-align: left; padding: 10px 12px; border-radius: 6px; cursor: pointer; width: 100%; margin: 2px 0; } | |
| .nav-btn:hover { background: #21262d; color: #e6edf3; } | |
| .nav-btn.active { background: #21262d; color: #3fb950; } | |
| .actions { margin-top: auto; } | |
| .btn { padding: 10px 16px; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; width: 100%; margin: 4px 0; } | |
| .btn-primary { background: #3fb950; color: white; } | |
| .btn-primary:hover { background: #2ea043; } | |
| .btn-danger { background: #f85149; color: white; } | |
| .btn-danger:hover { background: #da3633; } | |
| .btn-secondary { background: #21262d; color: #e6edf3; } | |
| .btn-secondary:hover { background: #30363d; } | |
| .btn-warning { background: #d29922; color: white; } | |
| .btn-warning:hover { background: #bb8009; } | |
| /* Main content */ | |
| .main { flex: 1; display: flex; flex-direction: column; overflow: hidden; } | |
| .header { background: #161b22; border-bottom: 1px solid #30363d; padding: 12px 20px; display: flex; align-items: center; gap: 12px; } | |
| .breadcrumb { display: flex; align-items: center; gap: 4px; flex: 1; overflow-x: auto; } | |
| .breadcrumb-item { background: #21262d; padding: 6px 12px; border-radius: 6px; cursor: pointer; white-space: nowrap; } | |
| .breadcrumb-item:hover { background: #30363d; } | |
| .breadcrumb-item.home { background: #3fb950; color: white; } | |
| .toolbar { display: flex; gap: 8px; } | |
| .tool-btn { background: #21262d; border: none; color: #e6edf3; padding: 8px 12px; border-radius: 6px; cursor: pointer; } | |
| .tool-btn:hover { background: #30363d; } | |
| .sort-btn { background: #21262d; border: none; color: #8b949e; padding: 8px 12px; border-radius: 6px; cursor: pointer; font-size: 0.8rem; transition: all 0.15s; } | |
| .sort-btn:hover { background: #30363d; color: #e6edf3; } | |
| .sort-btn.active { background: #3fb950; color: white; font-weight: 600; } | |
| /* File list */ | |
| .file-list { flex: 1; overflow-y: auto; padding: 16px 20px; } | |
| .file-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; } | |
| .file-item { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 12px; cursor: pointer; display: flex; align-items: center; gap: 10px; transition: all 0.15s; position: relative; } | |
| .file-item:hover { border-color: #3fb950; background: #21262d; } | |
| .file-item.selected { border-color: #58a6ff; background: rgba(88,166,255,0.1); } | |
| .file-icon { font-size: 1.5rem; } | |
| .file-info { flex: 1; min-width: 0; } | |
| .file-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |
| .file-meta { font-size: 0.75rem; color: #8b949e; } | |
| .file-actions { display: none; position: absolute; right: 8px; top: 50%; transform: translateY(-50%); gap: 4px; } | |
| .file-item:hover .file-actions { display: flex; } | |
| .file-action-btn { background: #30363d; border: none; color: #e6edf3; padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 0.75rem; } | |
| .file-action-btn:hover { background: #3fb950; } | |
| /* Editor with syntax highlighting */ | |
| .editor-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.85); display: none; z-index: 100; } | |
| .editor-overlay.active { display: flex; align-items: center; justify-content: center; } | |
| .editor-panel { background: #161b22; border: 1px solid #30363d; border-radius: 12px; width: 95%; max-width: 1200px; height: 90vh; display: flex; flex-direction: column; } | |
| .editor-header { padding: 12px 16px; border-bottom: 1px solid #30363d; display: flex; align-items: center; gap: 12px; background: #21262d; border-radius: 12px 12px 0 0; } | |
| .editor-title { flex: 1; font-weight: 600; font-family: monospace; } | |
| .editor-info { color: #8b949e; font-size: 0.8rem; margin-right: 12px; } | |
| .editor-content { flex: 1; overflow: hidden; background: #0d1117; border-radius: 0 0 12px 12px; } | |
| .editor-content pre { margin: 0; padding: 16px; height: 100%; overflow: auto; } | |
| .editor-content pre code { font-family: 'JetBrains Mono', 'Consolas', monospace; font-size: 13px; line-height: 1.5; } | |
| /* Editable textarea overlay */ | |
| .edit-wrapper { position: relative; height: 100%; overflow: hidden; } | |
| .edit-textarea { | |
| position: absolute; top: 0; left: 0; width: 100%; height: 100%; | |
| background: transparent; color: transparent; caret-color: #e6edf3; | |
| border: none; padding: 16px; resize: none; outline: none; | |
| font-family: 'JetBrains Mono', 'Consolas', monospace; font-size: 13px; line-height: 1.5; | |
| z-index: 2; white-space: pre; overflow: auto; tab-size: 4; | |
| } | |
| .edit-highlight { | |
| position: absolute; top: 0; left: 0; width: 100%; height: 100%; | |
| padding: 16px; overflow: hidden; pointer-events: none; z-index: 1; | |
| } | |
| .edit-highlight pre { | |
| margin: 0; padding: 0; background: transparent; white-space: pre; overflow: visible; | |
| } | |
| .edit-highlight code { | |
| font-family: 'JetBrains Mono', 'Consolas', monospace; font-size: 13px; line-height: 1.5; | |
| white-space: pre; tab-size: 4; display: block; | |
| } | |
| /* Image viewer */ | |
| .image-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.9); display: none; z-index: 100; align-items: center; justify-content: center; } | |
| .image-overlay.active { display: flex; } | |
| .image-container { max-width: 95%; max-height: 95%; } | |
| .image-container img { max-width: 100%; max-height: 90vh; object-fit: contain; border-radius: 8px; } | |
| .image-close { position: absolute; top: 20px; right: 20px; background: #21262d; border: 1px solid #30363d; color: #e6edf3; padding: 10px 16px; border-radius: 6px; cursor: pointer; font-weight: 600; } | |
| .image-close:hover { background: #30363d; } | |
| .image-info { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); background: #21262d; border: 1px solid #30363d; padding: 8px 16px; border-radius: 6px; font-size: 0.85rem; color: #8b949e; } | |
| /* Upload drop zone */ | |
| .drop-zone { position: fixed; inset: 0; background: rgba(63,185,80,0.2); border: 4px dashed #3fb950; display: none; z-index: 50; align-items: center; justify-content: center; flex-direction: column; gap: 16px; } | |
| .drop-zone.active { display: flex; } | |
| .drop-zone-text { font-size: 2rem; font-weight: 800; color: #3fb950; } | |
| .drop-zone-sub { font-size: 1rem; color: #8b949e; } | |
| /* Notifications */ | |
| .notifications { position: fixed; top: 16px; right: 16px; z-index: 200; } | |
| .notification { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 12px 16px; margin-bottom: 8px; display: flex; align-items: center; gap: 8px; animation: slideIn 0.3s; } | |
| .notification.success { border-color: #3fb950; } | |
| .notification.error { border-color: #f85149; } | |
| .notification.info { border-color: #58a6ff; } | |
| @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } | |
| /* Upload progress */ | |
| .upload-progress { position: fixed; bottom: 16px; right: 16px; background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 12px 16px; min-width: 300px; display: none; z-index: 201; } | |
| .upload-progress.active { display: block; } | |
| .upload-bar { height: 4px; background: #21262d; border-radius: 2px; margin-top: 8px; } | |
| .upload-bar-fill { height: 100%; background: #3fb950; border-radius: 2px; transition: width 0.3s; } | |
| /* Context menu */ | |
| .context-menu { position: fixed; background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 4px 0; min-width: 180px; z-index: 300; display: none; } | |
| .context-menu.active { display: block; } | |
| .context-item { padding: 8px 16px; cursor: pointer; display: flex; align-items: center; gap: 8px; font-size: 0.9rem; } | |
| .context-item:hover { background: #21262d; } | |
| .context-item.danger { color: #f85149; } | |
| .context-divider { height: 1px; background: #30363d; margin: 4px 0; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <div class="sidebar"> | |
| <div class="logo">📁 File Manager v1.31</div> | |
| <div class="logo-sub">/home/z/my-project</div> | |
| <div class="branding"> | |
| Written by <a href="https://www.vts-tech.org" target="_blank">VTSTech</a> · | |
| <a href="https://github.com/VTSTech" target="_blank">GitHub</a> | |
| </div> | |
| <div class="stats" id="stats"> | |
| <div class="stat-row"><span class="stat-label">Files</span><span class="stat-value" id="stat-files">-</span></div> | |
| <div class="stat-row"><span class="stat-label">Folders</span><span class="stat-value" id="stat-dirs">-</span></div> | |
| <div class="stat-row"><span class="stat-label">Size</span><span class="stat-value" id="stat-size">-</span></div> | |
| </div> | |
| <button class="nav-btn active" onclick="navigateTo('')">🏠 Root</button> | |
| <button class="nav-btn" onclick="navigateTo('upload')">📤 Upload</button> | |
| <button class="nav-btn" onclick="navigateTo('download')">📥 Download</button> | |
| <button class="nav-btn" onclick="refresh()">🔄 Refresh</button> | |
| <div class="actions"> | |
| <button class="btn btn-primary" onclick="showUpload()">📤 Upload Files/Folder</button> | |
| <button class="btn btn-secondary" onclick="createFolder()">📁 New Folder</button> | |
| <button class="btn btn-warning" onclick="compressSelected()">📦 Compress Selected</button> | |
| <button class="btn btn-danger" onclick="stopServer()">🛑 Stop Server</button> | |
| </div> | |
| </div> | |
| <div class="main"> | |
| <div class="header"> | |
| <div class="breadcrumb" id="breadcrumb"></div> | |
| <div class="toolbar"> | |
| <button class="sort-btn active" data-sort="name" data-label="Name" onclick="changeSort('name')">A→Z</button> | |
| <button class="sort-btn" data-sort="date" data-label="Date" onclick="changeSort('date')">Date</button> | |
| <button class="sort-btn" data-sort="size" data-label="Size" onclick="changeSort('size')">Size</button> | |
| <span style="color:#30363d;margin:0 8px;">|</span> | |
| <button class="tool-btn" onclick="refresh()">🔄</button> | |
| <button class="tool-btn" onclick="downloadSelected()">⬇️</button> | |
| <button class="tool-btn" onclick="deleteSelected()">🗑️</button> | |
| </div> | |
| </div> | |
| <div class="file-list"> | |
| <div class="file-grid" id="file-grid"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Editor Modal --> | |
| <div class="editor-overlay" id="editor-overlay" onclick="closeEditor(event)"> | |
| <div class="editor-panel" onclick="event.stopPropagation()"> | |
| <div class="editor-header"> | |
| <span class="editor-title" id="editor-title">file.py</span> | |
| <span class="editor-info" id="editor-info"></span> | |
| <button class="btn btn-secondary" onclick="closeEditor()" style="width:auto;">Cancel</button> | |
| <button class="btn btn-primary" onclick="saveFile()" style="width:auto;">💾 Save</button> | |
| </div> | |
| <div class="editor-content"> | |
| <div class="edit-wrapper"> | |
| <div class="edit-highlight" id="edit-highlight"><code class="language-plaintext"></code></div> | |
| <textarea class="edit-textarea" id="edit-textarea" oninput="updateHighlight()" onscroll="syncScroll()" spellcheck="false"></textarea> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Image Viewer --> | |
| <div class="image-overlay" id="image-overlay" onclick="closeImageViewer()"> | |
| <button class="image-close">✕ Close</button> | |
| <div class="image-container"> | |
| <img id="image-preview" src="" alt="Preview"> | |
| </div> | |
| <div class="image-info" id="image-info"></div> | |
| </div> | |
| <div class="drop-zone" id="drop-zone"> | |
| <span class="drop-zone-text">📁 Drop files or folders here</span> | |
| <span class="drop-zone-sub">Supports folder upload and archive extraction</span> | |
| </div> | |
| <div class="notifications" id="notifications"></div> | |
| <div class="upload-progress" id="upload-progress"> | |
| <div id="upload-filename">Uploading...</div> | |
| <div class="upload-bar"><div class="upload-bar-fill" id="upload-bar-fill"></div></div> | |
| </div> | |
| <!-- Context Menu --> | |
| <div class="context-menu" id="context-menu"> | |
| <div class="context-item" onclick="contextAction('open')">📂 Open</div> | |
| <div class="context-item" onclick="contextAction('download')">⬇️ Download</div> | |
| <div class="context-divider"></div> | |
| <div class="context-item" onclick="contextAction('extract')">📦 Extract Archive</div> | |
| <div class="context-item" onclick="contextAction('compress')">🗜️ Compress</div> | |
| <div class="context-divider"></div> | |
| <div class="context-item danger" onclick="contextAction('delete')">🗑️ Delete</div> | |
| </div> | |
| <!-- Prism.js for syntax highlighting --> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-css.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markup.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markup-templating.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-php.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-sql.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-yaml.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markdown.min.js"></script> | |
| <script> | |
| let currentPath = ''; | |
| let selectedFiles = []; | |
| let currentEditPath = ''; | |
| let currentLang = 'plaintext'; | |
| let sortBy = 'name'; | |
| let sortDir = 'asc'; | |
| let authCredentials = null; | |
| let contextTarget = null; | |
| // Prompt for credentials if not set | |
| function getAuthHeader() { | |
| if (!authCredentials) { | |
| const username = prompt('Username:'); | |
| if (!username) return null; | |
| const password = prompt('Password:'); | |
| authCredentials = btoa(`${username}:${password}`); | |
| } | |
| return `Basic ${authCredentials}`; | |
| } | |
| document.addEventListener('DOMContentLoaded', () => { | |
| loadStats(); | |
| navigateTo(''); | |
| setupDragDrop(); | |
| setupContextMenu(); | |
| }); | |
| async function api(endpoint, options = {}) { | |
| try { | |
| const auth = getAuthHeader(); | |
| if (!auth) { | |
| alert('Authentication required'); | |
| return null; | |
| } | |
| options.headers = options.headers || {}; | |
| options.headers['Authorization'] = auth; | |
| const res = await fetch(endpoint, options); | |
| if (res.status === 401) { | |
| authCredentials = null; | |
| alert('Invalid credentials. Please try again.'); | |
| const newAuth = getAuthHeader(); | |
| if (newAuth) return api(endpoint, options); | |
| return null; | |
| } | |
| if (!res.ok) { | |
| return { error: `HTTP ${res.status}: ${res.statusText}` }; | |
| } | |
| return res.json(); | |
| } catch (err) { | |
| console.error('API error:', err); | |
| return { error: err.message }; | |
| } | |
| } | |
| async function loadStats() { | |
| const data = await api('/api/stats'); | |
| if (data) { | |
| document.getElementById('stat-files').textContent = data.files; | |
| document.getElementById('stat-dirs').textContent = data.dirs; | |
| document.getElementById('stat-size').textContent = data.size_str; | |
| } | |
| } | |
| async function loadDirectory(path) { | |
| const auth = getAuthHeader(); | |
| if (!auth) return; | |
| const data = await api('/api/list', { | |
| headers: { | |
| 'X-Path': path, | |
| 'X-Sort-By': sortBy, | |
| 'X-Sort-Dir': sortDir | |
| } | |
| }); | |
| if (data) { | |
| renderBreadcrumb(path); | |
| renderFiles(data.items); | |
| } | |
| } | |
| function changeSort(newSort) { | |
| if (sortBy === newSort) { | |
| sortDir = sortDir === 'asc' ? 'desc' : 'asc'; | |
| } else { | |
| sortBy = newSort; | |
| sortDir = 'asc'; | |
| } | |
| updateSortButtons(); | |
| loadDirectory(currentPath); | |
| } | |
| function updateSortButtons() { | |
| document.querySelectorAll('.sort-btn').forEach(btn => { | |
| btn.classList.remove('active'); | |
| const sort = btn.dataset.sort; | |
| if (sort === sortBy) { | |
| btn.classList.add('active'); | |
| btn.textContent = sort === 'name' ? (sortDir === 'asc' ? 'A→Z' : 'Z→A') : | |
| sort === 'date' ? (sortDir === 'asc' ? 'Oldest' : 'Newest') : | |
| (sortDir === 'asc' ? 'Smallest' : 'Largest'); | |
| } else { | |
| btn.textContent = btn.dataset.label; | |
| } | |
| }); | |
| } | |
| function renderBreadcrumb(path) { | |
| const parts = path.split('/').filter(p => p); | |
| let html = `<div class="breadcrumb-item home" onclick="navigateTo('')">🏠</div>`; | |
| let buildPath = ''; | |
| for (const part of parts) { | |
| buildPath += '/' + part; | |
| html += `<div class="breadcrumb-item" onclick="navigateTo('${buildPath.slice(1)}')">${part}</div>`; | |
| } | |
| document.getElementById('breadcrumb').innerHTML = html; | |
| } | |
| function renderFiles(items) { | |
| const grid = document.getElementById('file-grid'); | |
| if (items.length === 0) { | |
| grid.innerHTML = '<div style="color:#8b949e;padding:20px;">Empty folder - drag & drop files or folders to upload</div>'; | |
| return; | |
| } | |
| grid.innerHTML = items.map(item => ` | |
| <div class="file-item ${selectedFiles.includes(item.name) ? 'selected' : ''}" | |
| data-name="${item.name.replace(/"/g, '"')}" | |
| data-is-dir="${item.is_dir}" | |
| data-is-archive="${item.is_archive || false}" | |
| onclick="selectFile(event, '${item.name.replace(/'/g, "\\'")}', ${item.is_dir})" | |
| ondblclick="openItem('${item.name.replace(/'/g, "\\'")}', ${item.is_dir})" | |
| oncontextmenu="showContextMenu(event, '${item.name.replace(/'/g, "\\'")}', ${item.is_dir}, ${item.is_archive || false})"> | |
| <span class="file-icon">${item.is_dir ? '📁' : getFileIcon(item.name)}</span> | |
| <div class="file-info"> | |
| <div class="file-name">${item.name}</div> | |
| <div class="file-meta">${item.is_dir ? 'Folder' : formatSize(item.size)} • ${item.modified}${item.is_archive ? ' • 📦' : ''}</div> | |
| </div> | |
| <div class="file-actions"> | |
| ${item.is_archive ? '<button class="file-action-btn" onclick="event.stopPropagation(); extractFile(\\'' + item.name.replace(/'/g, "\\'") + '\\')">Extract</button>' : ''} | |
| </div> | |
| </div> | |
| `).join(''); | |
| } | |
| function getFileIcon(name) { | |
| const ext = name.split('.').pop().toLowerCase(); | |
| const icons = { | |
| 'py': '🐍', 'js': '📜', 'ts': '💠', 'jsx': '⚛️', 'tsx': '⚛️', | |
| 'html': '🌐', 'css': '🎨', 'json': '📋', 'yaml': '⚙️', 'yml': '⚙️', | |
| 'md': '📝', 'txt': '📄', 'csv': '📊', 'sql': '🗃️', | |
| 'png': '🖼️', 'jpg': '🖼️', 'jpeg': '🖼️', 'gif': '🖼️', 'svg': '🖼️', 'webp': '🖼️', | |
| 'pdf': '📕', 'zip': '📦', 'tar': '📦', 'gz': '📦', 'bz2': '📦', 'tgz': '📦', | |
| 'sh': '💻', 'env': '🔐', 'php': '🐘' | |
| }; | |
| return icons[ext] || '📄'; | |
| } | |
| function isImageFile(name) { | |
| const ext = name.split('.').pop().toLowerCase(); | |
| return ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico'].includes(ext); | |
| } | |
| function isArchiveFile(name) { | |
| const lower = name.toLowerCase(); | |
| return lower.endsWith('.zip') || lower.endsWith('.tar') || | |
| lower.endsWith('.tar.gz') || lower.endsWith('.tgz') || | |
| lower.endsWith('.tar.bz2') || lower.endsWith('.tbz2'); | |
| } | |
| function getLanguage(name) { | |
| const ext = name.split('.').pop().toLowerCase(); | |
| const langMap = { | |
| 'py': 'python', 'js': 'javascript', 'ts': 'typescript', | |
| 'jsx': 'jsx', 'tsx': 'tsx', 'html': 'markup', 'htm': 'markup', | |
| 'css': 'css', 'json': 'json', 'yaml': 'yaml', 'yml': 'yaml', | |
| 'md': 'markdown', 'sh': 'bash', 'bash': 'bash', 'sql': 'sql', | |
| 'xml': 'markup', 'svg': 'markup', 'php': 'php' | |
| }; | |
| return langMap[ext] || 'plaintext'; | |
| } | |
| function formatSize(bytes) { | |
| if (bytes < 1024) return bytes + ' B'; | |
| if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB'; | |
| return (bytes/1024/1024).toFixed(1) + ' MB'; | |
| } | |
| function navigateTo(path) { | |
| currentPath = path; | |
| selectedFiles = []; | |
| loadDirectory(path); | |
| } | |
| function refresh() { | |
| loadDirectory(currentPath); | |
| loadStats(); | |
| notify('Refreshed', 'success'); | |
| } | |
| function selectFile(event, name, isDir) { | |
| if (event.ctrlKey || event.metaKey) { | |
| const idx = selectedFiles.indexOf(name); | |
| if (idx >= 0) selectedFiles.splice(idx, 1); | |
| else selectedFiles.push(name); | |
| } else { | |
| selectedFiles = [name]; | |
| } | |
| loadDirectory(currentPath); | |
| } | |
| function openItem(name, isDir) { | |
| try { | |
| console.log('openItem:', name, 'isDir:', isDir); | |
| if (isDir) { | |
| navigateTo(currentPath ? `${currentPath}/${name}` : name); | |
| } else if (isImageFile(name)) { | |
| viewImage(currentPath ? `${currentPath}/${name}` : name, name); | |
| } else { | |
| viewFile(currentPath ? `${currentPath}/${name}` : name); | |
| } | |
| } catch (err) { | |
| console.error('openItem error:', err); | |
| notify('Error opening item: ' + err.message, 'error'); | |
| } | |
| } | |
| // Context menu | |
| function setupContextMenu() { | |
| document.addEventListener('click', () => { | |
| document.getElementById('context-menu').classList.remove('active'); | |
| }); | |
| } | |
| function showContextMenu(event, name, isDir, isArchive) { | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| contextTarget = { name, isDir, isArchive }; | |
| // Preserve existing selection if right-clicked item is part of it | |
| // Otherwise, set selection to just the clicked item | |
| if (!selectedFiles.includes(name)) { | |
| selectedFiles = [name]; | |
| } | |
| const menu = document.getElementById('context-menu'); | |
| menu.style.left = event.pageX + 'px'; | |
| menu.style.top = event.pageY + 'px'; | |
| menu.classList.add('active'); | |
| // Show/hide extract option based on archive status | |
| const extractItem = menu.querySelector('[onclick*="extract"]'); | |
| if (extractItem) extractItem.style.display = isArchive ? 'flex' : 'none'; | |
| } | |
| function contextAction(action) { | |
| if (!contextTarget) return; | |
| const { name, isDir, isArchive } = contextTarget; | |
| const path = currentPath ? `${currentPath}/${name}` : name; | |
| switch(action) { | |
| case 'open': | |
| openItem(name, isDir); | |
| break; | |
| case 'download': | |
| downloadFile(path); | |
| break; | |
| case 'extract': | |
| if (isArchive) extractFile(name); | |
| break; | |
| case 'compress': | |
| // Selection is already preserved in showContextMenu | |
| compressSelected(); | |
| break; | |
| case 'delete': | |
| deleteSelected(); | |
| break; | |
| } | |
| document.getElementById('context-menu').classList.remove('active'); | |
| } | |
| // Archive functions | |
| async function extractFile(name) { | |
| const path = currentPath ? `${currentPath}/${name}` : name; | |
| notify('Extracting archive...', 'info'); | |
| const res = await api('/api/extract', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ path }) | |
| }); | |
| if (res && res.success) { | |
| notify(res.message, 'success'); | |
| refresh(); | |
| } else { | |
| notify(res ? res.error : 'Extraction failed', 'error'); | |
| } | |
| } | |
| async function compressSelected() { | |
| if (selectedFiles.length === 0) { | |
| notify('No files selected', 'error'); | |
| return; | |
| } | |
| const archiveName = prompt('Archive name:', 'archive.zip'); | |
| if (!archiveName) return; | |
| notify('Creating archive...', 'info'); | |
| const res = await api('/api/compress', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| path: currentPath, | |
| items: selectedFiles, | |
| name: archiveName | |
| }) | |
| }); | |
| if (res && res.success) { | |
| notify(res.message, 'success'); | |
| selectedFiles = []; | |
| refresh(); | |
| } else { | |
| notify(res ? res.error : 'Compression failed', 'error'); | |
| } | |
| } | |
| // Image viewer | |
| async function viewImage(path, name) { | |
| const auth = getAuthHeader(); | |
| const res = await fetch('/api/image', { headers: { 'X-Path': path, 'Authorization': auth } }); | |
| const blob = await res.blob(); | |
| const img = document.getElementById('image-preview'); | |
| img.src = URL.createObjectURL(blob); | |
| document.getElementById('image-info').textContent = `${name} • ${formatSize(blob.size)}`; | |
| document.getElementById('image-overlay').classList.add('active'); | |
| } | |
| function closeImageViewer() { | |
| document.getElementById('image-overlay').classList.remove('active'); | |
| } | |
| // Editor with syntax highlighting | |
| async function viewFile(path) { | |
| try { | |
| const data = await api('/api/view', { headers: { 'X-Path': path } }); | |
| if (!data) { | |
| notify('Failed to load file', 'error'); | |
| return; | |
| } | |
| if (data.error) { | |
| notify(data.error, 'error'); | |
| return; | |
| } | |
| currentEditPath = path; | |
| currentLang = getLanguage(path); | |
| const filename = path.split('/').pop(); | |
| document.getElementById('editor-title').textContent = filename; | |
| document.getElementById('editor-info').textContent = `${data.lines || 0} lines • ${formatSize(data.content.length)} • ${currentLang}`; | |
| const textarea = document.getElementById('edit-textarea'); | |
| textarea.value = data.content; | |
| updateHighlight(); | |
| document.getElementById('editor-overlay').classList.add('active'); | |
| } catch (err) { | |
| console.error('viewFile error:', err); | |
| notify('Error loading file: ' + err.message, 'error'); | |
| } | |
| } | |
| function updateHighlight() { | |
| const textarea = document.getElementById('edit-textarea'); | |
| const highlight = document.getElementById('edit-highlight'); | |
| const code = textarea.value | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>'); | |
| highlight.innerHTML = `<pre class="language-${currentLang}"><code class="language-${currentLang}">${code}</code></pre>`; | |
| Prism.highlightElement(highlight.querySelector('code')); | |
| // Update line count | |
| const lines = textarea.value.split('\\n').length; | |
| document.getElementById('editor-info').textContent = `${lines} lines • ${formatSize(textarea.value.length)} • ${currentLang}`; | |
| } | |
| function syncScroll() { | |
| const textarea = document.getElementById('edit-textarea'); | |
| const highlight = document.getElementById('edit-highlight'); | |
| highlight.scrollTop = textarea.scrollTop; | |
| highlight.scrollLeft = textarea.scrollLeft; | |
| } | |
| function closeEditor(event) { | |
| if (event && event.target !== event.currentTarget) return; | |
| document.getElementById('editor-overlay').classList.remove('active'); | |
| } | |
| async function saveFile() { | |
| const content = document.getElementById('edit-textarea').value; | |
| const res = await api('/api/save', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ path: currentEditPath, content }) | |
| }); | |
| if (res.success) { | |
| notify('File saved!', 'success'); | |
| closeEditor(); | |
| } else { | |
| notify(res.error, 'error'); | |
| } | |
| } | |
| async function downloadFile(path) { | |
| const auth = getAuthHeader(); | |
| const res = await fetch('/api/download', { headers: { 'X-Path': path, 'Authorization': auth } }); | |
| const blob = await res.blob(); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = path.split('/').pop(); | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| } | |
| function downloadSelected() { | |
| if (selectedFiles.length === 0) { | |
| notify('No files selected', 'error'); | |
| return; | |
| } | |
| for (const name of selectedFiles) { | |
| const path = currentPath ? `${currentPath}/${name}` : name; | |
| downloadFile(path); | |
| } | |
| } | |
| async function deleteSelected() { | |
| if (selectedFiles.length === 0) { | |
| notify('No files selected', 'error'); | |
| return; | |
| } | |
| if (!confirm(`Delete ${selectedFiles.length} item(s)?`)) return; | |
| for (const name of selectedFiles) { | |
| const path = currentPath ? `${currentPath}/${name}` : name; | |
| await api('/api/delete', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ path }) | |
| }); | |
| } | |
| notify('Deleted!', 'success'); | |
| selectedFiles = []; | |
| refresh(); | |
| } | |
| async function createFolder() { | |
| const name = prompt('Folder name:'); | |
| if (!name) return; | |
| const res = await api('/api/mkdir', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ path: currentPath, name }) | |
| }); | |
| if (res.success) { | |
| notify('Folder created!', 'success'); | |
| refresh(); | |
| } else { | |
| notify(res.error, 'error'); | |
| } | |
| } | |
| function showUpload() { | |
| const input = document.createElement('input'); | |
| input.type = 'file'; | |
| input.multiple = true; | |
| // Enable directory selection | |
| input.setAttribute('webkitdirectory', ''); | |
| input.setAttribute('directory', ''); | |
| input.onchange = () => { | |
| if (input.files.length > 0) { | |
| uploadFiles(input.files); | |
| } | |
| }; | |
| input.click(); | |
| } | |
| async function stopServer() { | |
| if (!confirm('Are you sure you want to STOP the server? The page will become unavailable.')) return; | |
| notify('Sending stop signal...', 'info'); | |
| try { | |
| await api('/api/stop', { method: 'POST' }); | |
| document.body.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100vh;background:#0d1117;color:#e6edf3;font-family:system-ui;flex-direction:column;"><h1>Server Stopped</h1><p>You can close this tab.</p></div>'; | |
| } catch (e) { | |
| document.body.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100vh;background:#0d1117;color:#e6edf3;font-family:system-ui;flex-direction:column;"><h1>Server Stopped</h1><p>You can close this tab.</p></div>'; | |
| } | |
| } | |
| async function uploadFiles(files) { | |
| const progress = document.getElementById('upload-progress'); | |
| const bar = document.getElementById('upload-bar-fill'); | |
| const filenameEl = document.getElementById('upload-filename'); | |
| progress.classList.add('active'); | |
| // Build a map of directories to create | |
| const dirs = new Set(); | |
| const fileArray = Array.from(files); | |
| for (const file of fileArray) { | |
| // Handle both relativePath (webkitRelativePath) and webkitRelativePath | |
| const relPath = file.webkitRelativePath || file.relativePath || ''; | |
| if (relPath) { | |
| const parts = relPath.split('/'); | |
| for (let i = 0; i < parts.length - 1; i++) { | |
| dirs.add(parts.slice(0, i + 1).join('/')); | |
| } | |
| } | |
| } | |
| // Create directories first | |
| const auth = getAuthHeader(); | |
| for (const dir of Array.from(dirs).sort()) { | |
| const dirPath = currentPath ? `${currentPath}/${dir}` : dir; | |
| await fetch('/api/mkdir', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': auth | |
| }, | |
| body: JSON.stringify({ path: '', name: dirPath }) | |
| }); | |
| } | |
| // Upload files | |
| for (let i = 0; i < fileArray.length; i++) { | |
| const file = fileArray[i]; | |
| filenameEl.textContent = `Uploading ${file.name}... (${i + 1}/${fileArray.length})`; | |
| bar.style.width = ((i / fileArray.length) * 100) + '%'; | |
| const content = await file.arrayBuffer(); | |
| const relPath = file.webkitRelativePath || file.relativePath || ''; | |
| const filePath = relPath ? relPath.split('/').slice(0, -1).join('/') : ''; | |
| const uploadPath = currentPath ? (filePath ? `${currentPath}/${filePath}` : currentPath) : filePath; | |
| await fetch('/api/upload', { | |
| method: 'POST', | |
| headers: { | |
| 'X-Path': uploadPath, | |
| 'X-Filename': file.name, | |
| 'Authorization': auth | |
| }, | |
| body: content | |
| }); | |
| } | |
| bar.style.width = '100%'; | |
| setTimeout(() => { | |
| progress.classList.remove('active'); | |
| notify(`Uploaded ${fileArray.length} file(s)!`, 'success'); | |
| refresh(); | |
| }, 500); | |
| } | |
| function setupDragDrop() { | |
| const dropZone = document.getElementById('drop-zone'); | |
| document.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| dropZone.classList.add('active'); | |
| }); | |
| document.addEventListener('dragleave', (e) => { | |
| if (!e.relatedTarget) dropZone.classList.remove('active'); | |
| }); | |
| document.addEventListener('drop', async (e) => { | |
| e.preventDefault(); | |
| dropZone.classList.remove('active'); | |
| const items = e.dataTransfer.items; | |
| const files = []; | |
| // Use webkitGetAsEntry for better folder support | |
| if (items && items.length > 0) { | |
| const entries = []; | |
| for (let i = 0; i < items.length; i++) { | |
| const item = items[i]; | |
| if (item.kind === 'file') { | |
| const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null; | |
| if (entry) { | |
| entries.push(entry); | |
| } else { | |
| const file = item.getAsFile(); | |
| if (file) files.push(file); | |
| } | |
| } | |
| } | |
| if (entries.length > 0) { | |
| // Process entries recursively | |
| const allFiles = await processEntries(entries); | |
| if (allFiles.length > 0) { | |
| uploadFiles(allFiles); | |
| } | |
| } else if (files.length > 0) { | |
| uploadFiles(files); | |
| } | |
| } else if (e.dataTransfer.files.length) { | |
| uploadFiles(e.dataTransfer.files); | |
| } | |
| }); | |
| } | |
| async function processEntries(entries) { | |
| const files = []; | |
| async function readEntry(entry, path = '') { | |
| if (entry.isFile) { | |
| return new Promise((resolve) => { | |
| entry.file((file) => { | |
| // Add relative path info | |
| file.relativePath = path + file.name; | |
| files.push(file); | |
| resolve(); | |
| }); | |
| }); | |
| } else if (entry.isDirectory) { | |
| const reader = entry.createReader(); | |
| const entries = await new Promise((resolve) => { | |
| reader.readEntries(resolve); | |
| }); | |
| for (const childEntry of entries) { | |
| await readEntry(childEntry, path + entry.name + '/'); | |
| } | |
| } | |
| } | |
| for (const entry of entries) { | |
| await readEntry(entry); | |
| } | |
| return files; | |
| } | |
| function notify(message, type = 'success') { | |
| const container = document.getElementById('notifications'); | |
| const div = document.createElement('div'); | |
| div.className = `notification ${type}`; | |
| const icon = type === 'success' ? '✅' : type === 'error' ? '❌' : 'ℹ️'; | |
| div.innerHTML = `${icon} ${message}`; | |
| container.appendChild(div); | |
| setTimeout(() => div.remove(), 3000); | |
| } | |
| </script> | |
| </body> | |
| </html>''' | |
| self.wfile.write(html_content.encode('utf-8')) | |
| def log_message(self, format, *args): | |
| print(f"[FileManager v1.31] {args[0]}" if args else "") | |
| if __name__ == "__main__": | |
| os.makedirs(BASE_DIR, exist_ok=True) | |
| socketserver.TCPServer.allow_reuse_address = True | |
| with socketserver.TCPServer(("0.0.0.0", PORT), FileManager) as httpd: | |
| print(f"✅ File Manager v1.31 (build 2025-03-10) running on port {PORT}") | |
| print(f"📁 Managing: {BASE_DIR}") | |
| print(f"📦 Archive support: zip, tar, tar.gz, tar.bz2") | |
| httpd.serve_forever() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment