92bed208e0
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>
681 lines
24 KiB
HTML
681 lines
24 KiB
HTML
<!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>
|