ZMB Webui: Complete Project – Rebrand & Initial Clean Commit
ARCHITECTURE ============ Backend: FastAPI + uvicorn (port 8000) - JWT authentication with PAM system users - ZFS CLI wrapper with caching (30-60s TTL) - WebSocket pool status broadcaster (30s interval) - Services: auth, zfs_runner, file_manager, shares, identities, system_info - Routers: pools, datasets, snapshots, shares, identities, navigator, system Frontend: Next.js 15 + TypeScript (static export) - Incremental Static Regeneration (ISR) for weak hardware - Type-safe API client (lib/api.ts) - Dark mode + custom Tailwind theme - Pages: Dashboard, Login, Snapshots, Datasets, Shares, etc. DEPLOYMENT ========== Test Target: 192.168.1.179:8090 (Debian LXC) Production: 10.66.120.3:9090 (Raspberry Pi 4GB ARM64) Updater: Automated Gitea-based deployment (update-test.sh, update-pi.sh) FEATURES COMPLETED ================== Phase 3a: Dashboard Quick Stats (System, CPU, Memory, Storage) - Real-time stats with color-coded progress bars - Responsive grid layout (mobile: 1, tablet: 2, desktop: 4 columns) - ISR-optimized for fast loads on weak hardware REBRANDING ========== Renamed throughout: - Project: 'ZFS Manager' → 'ZMB Webui' - Services: 'zfs-manager' → 'zmb-webui' - Systemd units: zfs-manager-backend → zmb-webui-backend - Configuration files and documentation Co-Authored-By: Patrick <patrick@perlbach24.de>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,680 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>File Navigator</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #1b1b1f;
|
||||
--bg-light: #252526;
|
||||
--border: #3c3c3d;
|
||||
--text: #f3f3f3;
|
||||
--text-muted: #a0a0a0;
|
||||
--primary: #0066ff;
|
||||
--danger: #d52f2f;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.nav-header {
|
||||
background: var(--bg-light);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nav-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: var(--bg-light);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: #0055dd;
|
||||
}
|
||||
|
||||
.address-bar {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 12px;
|
||||
color: var(--text);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
width: 200px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 12px;
|
||||
color: var(--text);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.nav-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nav-info-panel {
|
||||
width: 280px;
|
||||
background: var(--bg-light);
|
||||
border-left: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nav-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.nav-table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg);
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.nav-table th {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.nav-table th:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.nav-table td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nav-table tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-table tr:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.nav-table tr.selected {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.nav-file-icon {
|
||||
margin-right: 8px;
|
||||
width: 20px;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-footer {
|
||||
background: var(--bg-light);
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.nav-properties {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-property {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nav-property-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.nav-property-value {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.nav-property-input {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 6px 8px;
|
||||
border-radius: 3px;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.nav-permissions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.nav-permission-row {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.nav-permission-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
padding: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-permission-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-permission-checkbox input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hidden-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hidden-toggle input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
<div class="nav-header">
|
||||
<div class="nav-buttons">
|
||||
<button title="Back" onclick="navBack()">← Zurück</button>
|
||||
<button title="Forward" onclick="navForward()">Vorwärts →</button>
|
||||
<button title="Up" onclick="navUp()">↑ Nach oben</button>
|
||||
<button title="Refresh" onclick="loadDirectory()">🔄 Aktualisieren</button>
|
||||
</div>
|
||||
|
||||
<input type="text" class="address-bar" id="addressBar" placeholder="/tank/share"
|
||||
onkeypress="if(event.key==='Enter') loadDirectory(this.value)">
|
||||
|
||||
<input type="text" class="search-bar" id="searchBar" placeholder="Suchen..."
|
||||
onkeyup="filterFiles()">
|
||||
|
||||
<div class="nav-buttons">
|
||||
<button class="primary" title="Neuer Ordner" onclick="createFolder()">📁 + Ordner</button>
|
||||
<button class="primary" title="Neue Datei" onclick="createFile()">📄 + Datei</button>
|
||||
<button class="primary" title="Upload" onclick="uploadFile()">⬆ Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="nav-main">
|
||||
<!-- File List -->
|
||||
<div class="nav-content">
|
||||
<table class="nav-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th onclick="sortBy('name')" style="width: 40%;">📁 Name <span id="sort-name"></span></th>
|
||||
<th onclick="sortBy('size')" style="width: 15%;">Größe <span id="sort-size"></span></th>
|
||||
<th onclick="sortBy('modified')" style="width: 15%;">Geändert <span id="sort-modified"></span></th>
|
||||
<th onclick="sortBy('permissions')" style="width: 15%;">Rechte <span id="sort-permissions"></span></th>
|
||||
<th onclick="sortBy('owner')" style="width: 15%;">Besitzer <span id="sort-owner"></span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="fileList"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Info Panel -->
|
||||
<div class="nav-info-panel">
|
||||
<div id="infoContent" style="display: none;">
|
||||
<h3 id="selectedFileName" style="margin-bottom: 16px; font-size: 14px;"></h3>
|
||||
|
||||
<div id="viewProperties">
|
||||
<div class="nav-property">
|
||||
<div class="nav-property-label">Typ</div>
|
||||
<div class="nav-property-value" id="propType">-</div>
|
||||
</div>
|
||||
<div class="nav-property">
|
||||
<div class="nav-property-label">Größe</div>
|
||||
<div class="nav-property-value" id="propSize">-</div>
|
||||
</div>
|
||||
<div class="nav-property">
|
||||
<div class="nav-property-label">Geändert</div>
|
||||
<div class="nav-property-value" id="propModified">-</div>
|
||||
</div>
|
||||
<div class="nav-property">
|
||||
<div class="nav-property-label">Besitzer</div>
|
||||
<div class="nav-property-value" id="propOwner">-</div>
|
||||
</div>
|
||||
<div class="nav-property">
|
||||
<div class="nav-property-label">Gruppe</div>
|
||||
<div class="nav-property-value" id="propGroup">-</div>
|
||||
</div>
|
||||
<div class="nav-property">
|
||||
<div class="nav-property-label">Rechte</div>
|
||||
<div class="nav-property-value" id="propPermissions">-</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 16px; display: flex; gap: 8px;">
|
||||
<button onclick="editFile()" class="primary" style="flex: 1;">✎ Bearbeiten</button>
|
||||
<button onclick="deleteFile()" style="flex: 1; background: var(--danger); border-color: var(--danger);">🗑 Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="editProperties" style="display: none;">
|
||||
<div class="nav-property">
|
||||
<div class="nav-property-label">Besitzer</div>
|
||||
<input type="text" id="editOwner" class="nav-property-input" placeholder="root">
|
||||
</div>
|
||||
<div class="nav-property">
|
||||
<div class="nav-property-label">Gruppe</div>
|
||||
<input type="text" id="editGroup" class="nav-property-input" placeholder="root">
|
||||
</div>
|
||||
|
||||
<div class="nav-property">
|
||||
<div class="nav-property-label">Berechtigungen</div>
|
||||
<div class="nav-permissions-grid">
|
||||
<div class="nav-permission-label"></div>
|
||||
<div class="nav-permission-label">Lesen</div>
|
||||
<div class="nav-permission-label">Schreiben</div>
|
||||
<div class="nav-permission-label">Ausführen</div>
|
||||
|
||||
<div class="nav-permission-label">Besitzer</div>
|
||||
<div class="nav-permission-checkbox"><input type="checkbox" id="owner-r"></div>
|
||||
<div class="nav-permission-checkbox"><input type="checkbox" id="owner-w"></div>
|
||||
<div class="nav-permission-checkbox"><input type="checkbox" id="owner-x"></div>
|
||||
|
||||
<div class="nav-permission-label">Gruppe</div>
|
||||
<div class="nav-permission-checkbox"><input type="checkbox" id="group-r"></div>
|
||||
<div class="nav-permission-checkbox"><input type="checkbox" id="group-w"></div>
|
||||
<div class="nav-permission-checkbox"><input type="checkbox" id="group-x"></div>
|
||||
|
||||
<div class="nav-permission-label">Andere</div>
|
||||
<div class="nav-permission-checkbox"><input type="checkbox" id="other-r"></div>
|
||||
<div class="nav-permission-checkbox"><input type="checkbox" id="other-w"></div>
|
||||
<div class="nav-permission-checkbox"><input type="checkbox" id="other-x"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 16px; display: flex; gap: 8px;">
|
||||
<button onclick="cancelEdit()" style="flex: 1;">✕ Abbrechen</button>
|
||||
<button onclick="saveEdit()" class="primary" style="flex: 1;">💾 Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="noSelection" style="text-align: center; padding: 32px 16px; color: var(--text-muted);">
|
||||
<p style="font-size: 14px;">Wähle eine Datei aus</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="nav-footer">
|
||||
<div>
|
||||
<span id="fileStats">0 Dateien, 0 Ordner (0 B)</span>
|
||||
</div>
|
||||
<div style="flex: 1;"></div>
|
||||
<div class="hidden-toggle">
|
||||
<input type="checkbox" id="showHidden" onchange="loadDirectory()">
|
||||
<label for="showHidden">Versteckte Dateien</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_URL = '';
|
||||
let token = localStorage.getItem('access_token');
|
||||
let currentPath = '';
|
||||
let selectedFile = null;
|
||||
let historyStack = [];
|
||||
let historyIndex = -1;
|
||||
|
||||
async function apiCall(endpoint, options = {}) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}/api${endpoint}`, {
|
||||
...options,
|
||||
headers
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
alert('Session expired');
|
||||
window.location.href = '/';
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function loadDirectory(path = '') {
|
||||
if (path) {
|
||||
currentPath = path;
|
||||
}
|
||||
|
||||
document.getElementById('addressBar').value = currentPath;
|
||||
|
||||
const data = await apiCall(`/files/browse?path=${encodeURIComponent(currentPath)}`);
|
||||
|
||||
if (data && data.entries) {
|
||||
let entries = data.entries;
|
||||
|
||||
// Filter hidden files
|
||||
if (!document.getElementById('showHidden').checked) {
|
||||
entries = entries.filter(e => !e.name.startsWith('.'));
|
||||
}
|
||||
|
||||
// Sort by name
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
renderFileList(entries);
|
||||
updateStats(entries);
|
||||
}
|
||||
}
|
||||
|
||||
function renderFileList(entries) {
|
||||
const html = entries.map(file => `
|
||||
<tr onclick="selectFile('${file.path}', this)" class="file-row">
|
||||
<td><span class="nav-file-icon">${file.is_dir ? '📁' : '📄'}</span>${file.name}</td>
|
||||
<td>${file.is_dir ? '-' : formatSize(file.size)}</td>
|
||||
<td>${new Date(file.modified * 1000).toLocaleDateString('de-DE')}</td>
|
||||
<td>${file.permissions}</td>
|
||||
<td>${file.uid}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
document.getElementById('fileList').innerHTML = html;
|
||||
}
|
||||
|
||||
function selectFile(path, element) {
|
||||
document.querySelectorAll('.file-row').forEach(e => e.classList.remove('selected'));
|
||||
element.classList.add('selected');
|
||||
|
||||
// Load file info
|
||||
loadFileInfo(path);
|
||||
}
|
||||
|
||||
async function loadFileInfo(path) {
|
||||
selectedFile = path;
|
||||
const data = await apiCall(`/files/info?path=${encodeURIComponent(path)}`);
|
||||
|
||||
if (data) {
|
||||
const isDir = data.is_dir;
|
||||
document.getElementById('selectedFileName').textContent = data.name;
|
||||
document.getElementById('propType').textContent = isDir ? 'Verzeichnis' : 'Datei';
|
||||
document.getElementById('propSize').textContent = isDir ? '-' : formatSize(data.size);
|
||||
document.getElementById('propModified').textContent = new Date(data.modified * 1000).toLocaleString('de-DE');
|
||||
document.getElementById('propOwner').textContent = data.uid;
|
||||
document.getElementById('propGroup').textContent = data.gid;
|
||||
document.getElementById('propPermissions').textContent = data.permissions;
|
||||
|
||||
document.getElementById('noSelection').style.display = 'none';
|
||||
document.getElementById('infoContent').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function editFile() {
|
||||
document.getElementById('viewProperties').style.display = 'none';
|
||||
document.getElementById('editProperties').style.display = 'block';
|
||||
|
||||
// Load current permissions into checkboxes
|
||||
const perms = document.getElementById('propPermissions').textContent;
|
||||
if (perms && perms.length >= 9) {
|
||||
document.getElementById('owner-r').checked = perms[1] === 'r';
|
||||
document.getElementById('owner-w').checked = perms[2] === 'w';
|
||||
document.getElementById('owner-x').checked = perms[3] === 'x';
|
||||
document.getElementById('group-r').checked = perms[4] === 'r';
|
||||
document.getElementById('group-w').checked = perms[5] === 'w';
|
||||
document.getElementById('group-x').checked = perms[6] === 'x';
|
||||
document.getElementById('other-r').checked = perms[7] === 'r';
|
||||
document.getElementById('other-w').checked = perms[8] === 'w';
|
||||
document.getElementById('other-x').checked = perms[9] === 'x';
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
document.getElementById('viewProperties').style.display = 'block';
|
||||
document.getElementById('editProperties').style.display = 'none';
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
// Calculate mode from checkboxes
|
||||
const mode = calculateMode();
|
||||
|
||||
try {
|
||||
await apiCall(`/files/permissions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
path: selectedFile,
|
||||
mode: mode
|
||||
})
|
||||
});
|
||||
|
||||
alert('Rechte aktualisiert');
|
||||
cancelEdit();
|
||||
loadDirectory();
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e);
|
||||
}
|
||||
}
|
||||
|
||||
function calculateMode() {
|
||||
let mode = 0;
|
||||
if (document.getElementById('owner-r').checked) mode += 400;
|
||||
if (document.getElementById('owner-w').checked) mode += 200;
|
||||
if (document.getElementById('owner-x').checked) mode += 100;
|
||||
if (document.getElementById('group-r').checked) mode += 40;
|
||||
if (document.getElementById('group-w').checked) mode += 20;
|
||||
if (document.getElementById('group-x').checked) mode += 10;
|
||||
if (document.getElementById('other-r').checked) mode += 4;
|
||||
if (document.getElementById('other-w').checked) mode += 2;
|
||||
if (document.getElementById('other-x').checked) mode += 1;
|
||||
return mode.toString(8).padStart(3, '0');
|
||||
}
|
||||
|
||||
async function deleteFile() {
|
||||
if (!confirm(`Löschen: ${selectedFile}?`)) return;
|
||||
|
||||
try {
|
||||
await apiCall(`/files/delete?path=${encodeURIComponent(selectedFile)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
loadDirectory();
|
||||
document.getElementById('noSelection').style.display = 'block';
|
||||
document.getElementById('infoContent').style.display = 'none';
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e);
|
||||
}
|
||||
}
|
||||
|
||||
async function createFolder() {
|
||||
const name = prompt('Ordnername:');
|
||||
if (!name) return;
|
||||
|
||||
const path = currentPath ? `${currentPath}/${name}` : name;
|
||||
|
||||
try {
|
||||
await apiCall(`/files/mkdir`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path })
|
||||
});
|
||||
loadDirectory();
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e);
|
||||
}
|
||||
}
|
||||
|
||||
async function createFile() {
|
||||
const name = prompt('Dateiname:');
|
||||
if (!name) return;
|
||||
|
||||
const path = currentPath ? `${currentPath}/${name}` : name;
|
||||
|
||||
try {
|
||||
await apiCall(`/files/create`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path })
|
||||
});
|
||||
loadDirectory();
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e);
|
||||
}
|
||||
}
|
||||
|
||||
function uploadFile() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.multiple = true;
|
||||
input.onchange = async (e) => {
|
||||
const files = e.target.files;
|
||||
for (let file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/files/upload?path=${encodeURIComponent(currentPath)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: formData
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
} catch (e) {
|
||||
alert(`Fehler bei ${file.name}: ${e}`);
|
||||
}
|
||||
}
|
||||
loadDirectory();
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
function navBack() {
|
||||
if (historyIndex > 0) {
|
||||
historyIndex--;
|
||||
currentPath = historyStack[historyIndex];
|
||||
loadDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
function navForward() {
|
||||
if (historyIndex < historyStack.length - 1) {
|
||||
historyIndex++;
|
||||
currentPath = historyStack[historyIndex];
|
||||
loadDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
function navUp() {
|
||||
const parts = currentPath.split('/').filter(p => p);
|
||||
parts.pop();
|
||||
currentPath = '/' + parts.join('/');
|
||||
if (currentPath === '/') currentPath = '';
|
||||
loadDirectory();
|
||||
}
|
||||
|
||||
function sortBy(key) {
|
||||
// TODO: Implement sorting
|
||||
}
|
||||
|
||||
function filterFiles() {
|
||||
const search = document.getElementById('searchBar').value.toLowerCase();
|
||||
document.querySelectorAll('.file-row').forEach(row => {
|
||||
const name = row.textContent.toLowerCase();
|
||||
row.style.display = name.includes(search) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function updateStats(entries) {
|
||||
const files = entries.filter(e => !e.is_dir).length;
|
||||
const dirs = entries.filter(e => e.is_dir).length;
|
||||
const size = entries.reduce((sum, e) => sum + (e.size || 0), 0);
|
||||
|
||||
document.getElementById('fileStats').textContent =
|
||||
`${files} Datei(en), ${dirs} Ordner (${formatSize(size)})`;
|
||||
}
|
||||
|
||||
function 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];
|
||||
}
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadDirectory();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user