This commit is contained in:
2026-02-06 08:39:05 +00:00
parent 2b27e6a7ae
commit 42d6dc0959
2 changed files with 335 additions and 0 deletions

119
app.py Normal file
View File

@@ -0,0 +1,119 @@
import os
import mimetypes
from flask import Flask, render_template, request, jsonify, send_file, abort
app = Flask(__name__)
# Configuration
IGNORE_FOLDERS = {'.git', '__pycache__', 'node_modules', '$RECYCLE.BIN', 'System Volume Information'}
def get_file_info(filepath):
"""Helper to extract file details."""
try:
stats = os.stat(filepath)
mime_type, _ = mimetypes.guess_type(filepath)
return {
"name": os.path.basename(filepath),
"path": filepath,
"size": stats.st_size,
"is_dir": os.path.isdir(filepath),
"mime": mime_type or "application/octet-stream"
}
except PermissionError:
return None
@app.route('/')
def index():
"""Serves the main UI."""
# Default to current working directory
start_path = os.getcwd()
return render_template('index.html', start_path='/toor')
@app.route('/api/scan')
def scan_files():
"""
Scans a directory.
Query Params:
- path: The root path to scan.
- recursive: 'true' to scan subfolders, 'false' for just top level.
"""
root_path = request.args.get('path', '.')
recursive = request.args.get('recursive', 'false') == 'true'
if not os.path.exists(root_path):
return jsonify({"error": "Path does not exist"}), 404
file_list = []
try:
if recursive:
# Recursive walk (Limit to avoid freezing on massive drives)
file_count = 0
MAX_FILES = 2000
for root, dirs, files in os.walk(root_path):
# Filter out ignored folders
dirs[:] = [d for d in dirs if d not in IGNORE_FOLDERS]
for name in files:
if file_count >= MAX_FILES:
break
full_path = os.path.join(root, name)
info = get_file_info(full_path)
if info:
file_list.append(info)
file_count += 1
if file_count >= MAX_FILES:
break
else:
# Standard directory listing (Non-recursive)
print(f"Scanning {root_path}")
with os.scandir(root_path) as entries:
for entry in entries:
info = get_file_info(entry.path)
if info:
file_list.append(info)
except Exception as e:
return jsonify({"error": str(e)}), 500
# Sort: Directories first, then files
file_list.sort(key=lambda x: (not x['is_dir'], x['name'].lower()))
return jsonify({"files": file_list, "current_path": root_path})
@app.route('/api/download')
def download_file():
"""Downloads a specific file."""
filepath = request.args.get('path')
if not filepath or not os.path.exists(filepath):
return abort(404)
return send_file(filepath, as_attachment=True)
@app.route('/api/preview')
def preview_file():
"""Serves file content for preview (images/audio)."""
filepath = request.args.get('path')
if not filepath or not os.path.exists(filepath):
return abort(404)
return send_file(filepath, as_attachment=False)
@app.route('/api/delete', methods=['DELETE'])
def delete_file():
"""Deletes a file."""
filepath = request.args.get('path')
if not filepath or not os.path.exists(filepath):
return jsonify({"error": "File not found"}), 404
try:
if os.path.isdir(filepath):
os.rmdir(filepath) # Only removes empty dirs for safety
else:
os.remove(filepath)
return jsonify({"success": True})
except Exception as e:
return jsonify({"error": str(e)}), 500
if __name__ == '__main__':
# Run on localhost
print("Starting File Explorer on http://127.0.0.1:80")
app.run(debug=True, port=5005, host='0.0.0.0')

216
templates/index.html Normal file
View File

@@ -0,0 +1,216 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sam File Explorer</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
body { background-color: #121212; color: #e0e0e0; }
.file-row { cursor: pointer; transition: background 0.2s; }
.file-row:hover { background-color: #2c2c2c; }
.icon-col { width: 40px; text-align: center; }
.action-btn { opacity: 0.6; transition: opacity 0.2s; }
.file-row:hover .action-btn { opacity: 1; }
#preview-content { text-align: center; }
img.preview-media { max-width: 100%; max-height: 70vh; border-radius: 5px; }
audio.preview-media { width: 100%; margin-top: 20px; }
</style>
</head>
<body>
<div class="container mt-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3><i class="fa-solid fa-folder-open text-warning me-2"></i>File Explorer</h3>
</div>
<!-- Controls -->
<div class="card p-3 mb-4 bg-dark border-secondary">
<div class="row g-3">
<div class="col-md-8">
<div class="input-group">
<span class="input-group-text bg-secondary border-secondary text-light">Path</span>
<input type="text" id="pathInput" class="form-control bg-dark text-light border-secondary" value="{{ start_path }}">
<button class="btn btn-primary" onclick="loadFiles()">Go</button>
</div>
</div>
<div class="col-md-4 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="recursiveToggle">
<label class="form-check-label" for="recursiveToggle">Recursive Scan (Subfolders)</label>
</div>
</div>
</div>
</div>
<!-- File List -->
<div class="table-responsive">
<table class="table table-dark table-hover align-middle">
<thead>
<tr>
<th class="icon-col"></th>
<th>Name</th>
<th>Size</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody id="fileTableBody">
<!-- Content injected by JS -->
</tbody>
</table>
<div id="loading" class="text-center py-4 d-none">
<div class="spinner-border text-primary" role="status"></div>
<p class="mt-2 text-muted">Scanning files...</p>
</div>
</div>
</div>
<!-- Preview Modal -->
<div class="modal fade" id="previewModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content bg-dark border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title" id="previewTitle">Preview</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="preview-content">
<!-- Media injected here -->
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
const API_BASE = '/api';
let currentPath = "";
// Initial Load
document.addEventListener('DOMContentLoaded', () => loadFiles());
async function loadFiles() {
const path = document.getElementById('pathInput').value;
const recursive = document.getElementById('recursiveToggle').checked;
const tbody = document.getElementById('fileTableBody');
const loading = document.getElementById('loading');
tbody.innerHTML = '';
loading.classList.remove('d-none');
try {
const res = await fetch(`${API_BASE}/scan?path=${encodeURIComponent(path)}&recursive=${recursive}`);
const data = await res.json();
if (data.error) {
alert(data.error);
return;
}
currentPath = data.current_path;
renderTable(data.files);
} catch (err) {
console.error(err);
alert("Failed to load files.");
} finally {
loading.classList.add('d-none');
}
}
function renderTable(files) {
const tbody = document.getElementById('fileTableBody');
if (files.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted">No files found.</td></tr>';
return;
}
files.forEach(file => {
const tr = document.createElement('tr');
tr.className = 'file-row';
// Icon logic
let icon = 'fa-file';
let color = 'text-secondary';
if (file.is_dir) { icon = 'fa-folder'; color = 'text-warning'; }
else if (file.mime.startsWith('image')) { icon = 'fa-image'; color = 'text-info'; }
else if (file.mime.startsWith('audio')) { icon = 'fa-music'; color = 'text-success'; }
// Size formatting
const size = file.is_dir ? '-' : (file.size / 1024).toFixed(1) + ' KB';
tr.innerHTML = `
<td class="icon-col"><i class="fa-solid ${icon} ${color}"></i></td>
<td onclick="${file.is_dir ? `enterDir('${file.path.replace(/\\/g, '\\\\')}')` : ''}">
${file.name}
</td>
<td>${size}</td>
<td class="text-end">
${getActions(file)}
</td>
`;
tbody.appendChild(tr);
});
}
function getActions(file) {
if (file.is_dir) return '';
let previewBtn = '';
if (file.mime.startsWith('image') || file.mime.startsWith('audio')) {
previewBtn = `<button class="btn btn-sm btn-outline-light action-btn me-1" onclick="previewFile('${file.path.replace(/\\/g, '\\\\')}', '${file.mime}', '${file.name}')" title="Preview"><i class="fa-solid fa-eye"></i></button>`;
}
return `
${previewBtn}
<a href="${API_BASE}/download?path=${encodeURIComponent(file.path)}" class="btn btn-sm btn-outline-primary action-btn me-1" title="Download"><i class="fa-solid fa-download"></i></a>
<button class="btn btn-sm btn-outline-danger action-btn" onclick="deleteFile('${file.path.replace(/\\/g, '\\\\')}')" title="Delete"><i class="fa-solid fa-trash"></i></button>
`;
}
function enterDir(path) {
document.getElementById('pathInput').value = path;
document.getElementById('recursiveToggle').checked = false; // Reset recursive when diving into folders
loadFiles();
}
function previewFile(path, mime, name) {
const modal = new bootstrap.Modal(document.getElementById('previewModal'));
const container = document.getElementById('preview-content');
document.getElementById('previewTitle').innerText = name;
const url = `${API_BASE}/preview?path=${encodeURIComponent(path)}`;
if (mime.startsWith('image')) {
container.innerHTML = `<img src="${url}" class="preview-media" alt="Preview">`;
} else if (mime.startsWith('audio')) {
container.innerHTML = `<audio controls class="preview-media" autoplay><source src="${url}" type="${mime}"></audio>`;
}
modal.show();
// Stop audio when modal closes
document.getElementById('previewModal').addEventListener('hidden.bs.modal', () => {
container.innerHTML = '';
});
}
async function deleteFile(path) {
if (!confirm("Are you sure you want to delete this file? This cannot be undone.")) return;
try {
const res = await fetch(`${API_BASE}/delete?path=${encodeURIComponent(path)}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
loadFiles(); // Refresh list
} else {
alert("Error deleting file: " + (data.error || "Unknown error"));
}
} catch (err) {
alert("Network error");
}
}
</script>
</body>
</html>