Files
Unraid-Navigator/server/public/app.js
T
2026-06-24 20:20:57 +02:00

2430 lines
79 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const workspace = document.querySelector('#workspace');
const appShell = document.querySelector('.u-nav');
const statusText = document.querySelector('#statusText');
const transferButton = document.querySelector('#newQueueButton');
const settingsButton = document.querySelector('#settingsButton');
const API_BASE = window.UNAV_API_BASE || '/api';
const APP_VERSION = window.UNAV_VERSION || 'dev';
const state = {
config: null,
windows: [],
focusedId: null,
selectedEntry: null,
contextMenu: null,
permissionsDialog: null,
uploadConflictDialog: null,
draggedEntry: null,
pointerDrag: null,
suppressClickPath: null,
suppressItemActivationUntil: 0,
debug: [],
nextDebugId: 1,
jobs: new Map(),
uploadTransfers: new Map(),
nextUploadTransferId: 1,
jobRefresh: new Map(),
sizeJobs: new Map(),
sizeResults: new Map(),
transferPanelVisible: false,
transferPanelClosing: false,
transferAutoHideTimer: null,
settingsPanelVisible: false,
typeAhead: {
key: '',
windowId: null,
at: 0
},
nextWindowId: 1,
zIndex: 20
};
const icons = {
directory: 'Folder',
file: 'File',
symlink: 'Link',
other: 'Item'
};
const previewExtensions = new Set(['txt', 'log', 'md', 'rd', 'rst', 'yaml', 'yml', 'json', 'xml', 'csv', 'tsv', 'ini', 'cfg', 'conf', 'env', 'toml', 'sql', 'nfo', 'srt', 'sub', 'vtt', 'm3u', 'm3u8', 'pls', 'sh', 'bash', 'zsh', 'php', 'js', 'ts', 'tsx', 'jsx', 'css', 'html', 'py', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'hpp', 'bat', 'ps1', 'properties', 'service', 'timer', 'mount', 'rules', 'cron', 'plg', 'page', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'mp3', 'flac', 'm4a', 'ogg', 'wav', 'mp4', 'mkv', 'webm', 'mov']);
const previewFilenames = new Set(['dockerfile', 'makefile']);
document.querySelector('#newExplorerButton').addEventListener('click', () => openExplorer());
document.querySelector('#newPropertiesButton').addEventListener('click', () => openProperties());
transferButton.addEventListener('click', () => toggleTransferPanel());
settingsButton.addEventListener('click', () => toggleSettingsPanel());
window.addEventListener('error', (event) => {
recordDebug('window.error', {
message: event.message,
source: event.filename,
line: event.lineno,
column: event.colno,
error: serializeError(event.error)
});
});
window.addEventListener('unhandledrejection', (event) => {
recordDebug('window.unhandledrejection', { reason: serializeError(event.reason) });
});
document.addEventListener('keydown', handleTypeAheadKey);
init();
async function init() {
try {
state.config = await api(apiUrl('config.php'));
if (!state.config.roots?.length) {
throw new Error('No U-Navigator roots configured');
}
updateStatusText();
openExplorer(state.config.roots[0].path);
} catch (error) {
statusText.textContent = error.message;
}
}
function openExplorer(initialPath = state.config?.roots?.[0]?.path) {
if (!initialPath) {
statusText.textContent = 'Kein Root-Pfad konfiguriert';
return null;
}
const win = createWindow('Explorer', 'explorer', {
width: 760,
height: 460,
x: 24 + state.windows.length * 24,
y: 20 + state.windows.length * 20,
data: {
path: initialPath,
entries: [],
selectedPath: null,
selectedPaths: [],
selectionAnchorPath: null,
view: 'icons',
loading: false,
error: null
}
});
loadExplorer(win, initialPath);
return win;
}
function openProperties(entry = state.selectedEntry) {
if (entry) {
state.selectedEntry = entry;
}
const existing = state.windows.find((item) => item.kind === 'properties');
if (existing) {
focusWindow(existing);
render();
return existing;
}
return createWindow('Eigenschaften', 'properties', {
width: 420,
height: 320,
x: 160,
y: 90,
data: {}
});
}
function openPreview(entry, sourceWindowId = state.focusedId) {
if (!entry || !isPreviewable(entry)) {
showError(new Error('Für diesen Dateityp ist keine Vorschau verfügbar.'));
return null;
}
const existing = state.windows.find((item) => item.kind === 'preview');
if (existing) {
existing.data.entry = entry;
existing.data.sourceWindowId = sourceWindowId;
existing.data.content = '';
existing.data.loading = true;
existing.data.error = null;
focusWindow(existing);
render();
loadPreview(existing, entry);
return existing;
}
const win = createWindow('Vorschau', 'preview', {
width: 620,
height: 460,
x: 190,
y: 110,
data: {
entry,
sourceWindowId,
content: '',
loading: true,
error: null
}
});
loadPreview(win, entry);
return win;
}
function openQueue() {
showTransferPanel();
return null;
}
function createWindow(title, kind, options) {
const id = `win-${state.nextWindowId++}`;
const win = {
id,
kind,
title,
x: options.x,
y: options.y,
width: options.width,
height: options.height,
maximized: false,
zIndex: ++state.zIndex,
data: options.data || {}
};
state.windows.push(win);
state.focusedId = id;
render();
return win;
}
function render() {
workspace.innerHTML = '';
for (const win of state.windows) {
workspace.appendChild(renderWindow(win));
}
if (state.contextMenu) {
workspace.appendChild(renderContextMenu());
}
if (state.permissionsDialog) {
workspace.appendChild(renderPermissionsDialog());
}
if (state.uploadConflictDialog) {
workspace.appendChild(renderUploadConflictDialog());
}
renderTransferPanel();
renderSettingsPanel();
}
function renderWindow(win) {
const el = document.createElement('section');
el.className = `window ${state.focusedId === win.id ? 'focused' : ''} ${win.maximized ? 'maximized' : ''}`;
el.style.left = `${win.maximized ? 0 : win.x}px`;
el.style.top = `${win.maximized ? 0 : win.y}px`;
el.style.width = `${win.maximized ? workspace.clientWidth : win.width}px`;
el.style.height = `${win.maximized ? workspace.clientHeight : win.height}px`;
el.style.zIndex = win.zIndex;
el.dataset.windowId = win.id;
el.addEventListener('pointerdown', () => focusWindow(win));
const titlebar = document.createElement('div');
titlebar.className = 'titlebar';
titlebar.innerHTML = `
<strong>${escapeHtml(windowTitle(win))}</strong>
<div class="window-actions">
<button class="icon" data-action="maximize" title="Maximieren">${win.maximized ? '↙' : '□'}</button>
<button class="icon" data-action="close" title="Schließen">×</button>
</div>
`;
titlebar.addEventListener('pointerdown', (event) => startDragWindow(event, win));
titlebar.querySelector('[data-action="maximize"]').addEventListener('click', (event) => {
event.stopPropagation();
win.maximized = !win.maximized;
focusWindow(win);
render();
});
titlebar.querySelector('[data-action="close"]').addEventListener('click', (event) => {
event.stopPropagation();
state.windows = state.windows.filter((item) => item.id !== win.id);
state.focusedId = state.windows.at(-1)?.id || null;
render();
});
const body = document.createElement('div');
body.className = 'window-body';
if (win.kind === 'explorer') renderExplorer(body, win);
if (win.kind === 'properties') renderProperties(body);
if (win.kind === 'preview') renderPreview(body, win);
body.addEventListener('scroll', () => {
if (win.data.lockScroll) {
body.scrollTop = win.data.lockScrollTop || 0;
body.scrollLeft = win.data.lockScrollLeft || 0;
return;
}
win.data.scrollTop = body.scrollTop;
win.data.scrollLeft = body.scrollLeft;
});
el.append(titlebar, body);
requestAnimationFrame(() => {
body.scrollTop = win.data.scrollTop || 0;
body.scrollLeft = win.data.scrollLeft || 0;
});
if (!win.maximized) {
const resize = document.createElement('div');
resize.className = 'resize-handle';
resize.addEventListener('pointerdown', (event) => startResizeWindow(event, win));
el.appendChild(resize);
}
return el;
}
function renderExplorer(body, win) {
const data = win.data;
body.innerHTML = `
<div class="pathbar">
<button class="icon up-button" data-action="up" title="Eine Ebene zurueck" aria-label="Eine Ebene zurueck">←</button>
<input value="${escapeAttr(data.path || '')}" aria-label="Pfad">
<button class="icon" data-action="go" title="Öffnen" aria-label="Öffnen">↵</button>
<button class="icon" data-action="refresh" title="Neu laden" aria-label="Neu laden">↻</button>
<button class="icon" data-action="upload-files" title="Dateien hochladen" aria-label="Dateien hochladen" ${isReadOnly() ? 'disabled' : ''}>⤴</button>
<button class="icon" data-action="upload-folder" title="Ordner hochladen" aria-label="Ordner hochladen" ${isReadOnly() ? 'disabled' : ''}>⇪</button>
<button class="icon danger" data-action="delete-selected" title="Auswahl löschen" aria-label="Auswahl löschen" ${isReadOnly() ? 'disabled' : ''}>×</button>
<input class="upload-input" data-upload="files" type="file" multiple>
<input class="upload-input" data-upload="folder" type="file" multiple webkitdirectory directory>
<div class="view-toggle" aria-label="Ansicht">
<button class="icon ${data.view !== 'icons' ? 'active' : ''}" data-action="view-list" title="Listenansicht" aria-label="Listenansicht">☰</button>
<button class="icon ${data.view === 'icons' ? 'active' : ''}" data-action="view-icons" title="Symbolansicht" aria-label="Symbolansicht">▦</button>
</div>
</div>
<div class="drop-zone ${data.dragOver ? 'drag-over' : ''}">
${data.error ? `<p class="muted">${escapeHtml(data.error)}</p>` : ''}
${data.loading ? '<p class="muted">Lade...</p>' : renderFileEntries(data.entries || [], data.view || 'list', selectedPathSet(win))}
</div>
`;
const input = body.querySelector('input');
body.querySelector('[data-action="go"]').addEventListener('click', () => loadExplorer(win, input.value));
body.querySelector('[data-action="refresh"]').addEventListener('click', () => loadExplorer(win, data.path));
body.querySelector('[data-action="up"]').addEventListener('click', () => loadExplorer(win, parentPath(data.path)));
body.querySelector('[data-action="view-list"]').addEventListener('click', () => setExplorerView(win, 'list'));
body.querySelector('[data-action="view-icons"]').addEventListener('click', () => setExplorerView(win, 'icons'));
body.querySelector('[data-action="upload-files"]').addEventListener('click', () => body.querySelector('[data-upload="files"]').click());
body.querySelector('[data-action="upload-folder"]').addEventListener('click', () => body.querySelector('[data-upload="folder"]').click());
body.querySelector('[data-action="delete-selected"]').addEventListener('click', () => deleteEntries(win));
for (const inputEl of body.querySelectorAll('.upload-input')) {
inputEl.addEventListener('change', async () => {
try {
await uploadFileList(win, [...inputEl.files], data.path);
} catch (error) {
showError(error);
} finally {
inputEl.value = '';
}
});
}
const dropZone = body.querySelector('.drop-zone');
dropZone.addEventListener('dragenter', (event) => {
if (hasDropPayload(event)) {
event.preventDefault();
data.dragOver = true;
dropZone.classList.add('drag-over');
}
});
dropZone.addEventListener('dragover', (event) => {
if (hasDropPayload(event)) {
event.preventDefault();
setDropEffect(event, 'copy');
data.dragOver = true;
dropZone.classList.add('drag-over');
}
});
dropZone.addEventListener('dragleave', () => {
data.dragOver = false;
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', async (event) => {
event.preventDefault();
data.dragOver = false;
dropZone.classList.remove('drag-over');
try {
await handleDrop(event, win, data.path);
} catch (error) {
showError(error);
}
});
dropZone.addEventListener('contextmenu', (event) => {
event.preventDefault();
showContextMenu(event, win, null);
});
for (const row of body.querySelectorAll('.file-row')) {
const entry = data.entries.find((item) => item.path === row.dataset.path);
row.draggable = false;
row.addEventListener('dragstart', (event) => event.preventDefault());
row.addEventListener('selectstart', (event) => event.preventDefault());
row.addEventListener('pointerdown', (event) => {
lockExplorerScroll(win, body);
startItemPointerDrag(event, win, entry);
});
row.addEventListener('click', (event) => {
if (shouldSuppressItemActivation()) return;
selectEntry(win, entry, body, event);
});
row.addEventListener('dblclick', () => {
if (shouldSuppressItemActivation()) return;
if (entry.type === 'directory') {
loadExplorer(win, entry.path);
} else if (isPreviewable(entry)) {
openPreview(entry, win.id);
}
});
row.addEventListener('contextmenu', (event) => {
event.preventDefault();
event.stopPropagation();
if (selectedPathSet(win).has(entry.path)) {
state.selectedEntry = entry;
focusWindow(win);
updatePropertiesWindows();
} else {
selectEntry(win, entry, null, event);
}
showContextMenu(event, win, entry);
});
}
}
function renderFileEntries(entries, view, selectedPaths = new Set()) {
if (!entries.length) {
return '<p class="muted">Dieser Ordner ist leer. Dateien können hier abgelegt werden.</p>';
}
return view === 'icons' ? renderFileIcons(entries, selectedPaths) : renderFileTable(entries, selectedPaths);
}
function renderFileTable(entries, selectedPaths) {
const rows = entries.map((entry) => `
<tr class="file-row ${selectedPaths.has(entry.path) ? 'selected' : ''}" data-path="${escapeAttr(entry.path)}" data-type="${escapeAttr(entry.type)}">
<td><span class="name-cell"><span class="badge">${icons[entry.type] || 'Item'}</span>${escapeHtml(entry.name)}</span></td>
<td>${entry.type}</td>
<td>${entry.type === 'file' ? formatBytes(entry.size) : ''}</td>
<td>${new Date(entry.modifiedAt).toLocaleString()}</td>
</tr>
`).join('');
return `
<table class="file-table">
<thead><tr><th>Name</th><th>Typ</th><th>Größe</th><th>Geändert</th></tr></thead>
<tbody>${rows}</tbody>
</table>
`;
}
function renderFileIcons(entries, selectedPaths) {
const items = entries.map((entry) => `
<button type="button" class="file-row icon-item ${selectedPaths.has(entry.path) ? 'selected' : ''}" data-path="${escapeAttr(entry.path)}" data-type="${escapeAttr(entry.type)}">
<span class="file-icon ${escapeAttr(entry.type)}" aria-label="${escapeAttr(icons[entry.type] || 'Item')}"></span>
<span class="icon-name">${escapeHtml(entry.name)}</span>
<span class="icon-meta">${entry.type === 'file' ? formatBytes(entry.size) : entry.type}</span>
</button>
`).join('');
return `<div class="icon-grid">${items}</div>`;
}
function setExplorerView(win, view) {
win.data.view = view;
render();
}
function renderProperties(body) {
const entry = state.selectedEntry;
if (!entry) {
body.innerHTML = '<p class="muted">Keine Datei ausgewählt.</p>';
return;
}
const sizeLabel = entry.type === 'directory' ? directorySizeLabel(entry) : formatBytes(entry.size);
body.classList.add('properties');
body.innerHTML = `
<dl>
<dt>Name</dt><dd>${escapeHtml(entry.name)}</dd>
<dt>Typ</dt><dd>${escapeHtml(entry.type)}</dd>
<dt>Pfad</dt><dd>${escapeHtml(entry.path)}</dd>
<dt>Größe</dt><dd>${escapeHtml(sizeLabel)}</dd>
<dt>Geändert</dt><dd>${new Date(entry.modifiedAt).toLocaleString()}</dd>
<dt>Besitzer</dt><dd>${escapeHtml(formatPrincipal(entry.owner, entry.ownerId))}</dd>
<dt>Gruppe</dt><dd>${escapeHtml(formatPrincipal(entry.group, entry.groupId))}</dd>
<dt>Rechte</dt><dd>${escapeHtml(entry.permissions ? `0${entry.permissions}`.slice(-4) : '-')}</dd>
</dl>
`;
if (entry.type === 'directory') {
ensureDirectorySize(entry);
}
}
function renderPreview(body, win) {
const data = win.data;
body.classList.add('preview-body');
if (data.loading) {
body.innerHTML = '<p class="muted">Lade Vorschau...</p>';
return;
}
if (data.error) {
body.innerHTML = `<p class="muted">${escapeHtml(data.error)}</p>`;
return;
}
const entry = data.entry;
const navigation = previewNavigation(data);
body.innerHTML = `
<div class="preview-meta">
<div class="preview-nav">
<button class="icon" data-action="preview-prev" title="Vorherige Datei" aria-label="Vorherige Datei" ${navigation.previous ? '' : 'disabled'}>←</button>
<button class="icon" data-action="preview-next" title="Nächste Datei" aria-label="Nächste Datei" ${navigation.next ? '' : 'disabled'}>→</button>
</div>
<strong>${escapeHtml(entry?.name || data.name || 'Vorschau')}</strong>
<span>${escapeHtml(formatBytes(data.size || entry?.size || 0))}</span>
</div>
${renderPreviewContent(data)}
`;
body.querySelector('[data-action="preview-prev"]')?.addEventListener('click', () => openPreview(navigation.previous, data.sourceWindowId));
body.querySelector('[data-action="preview-next"]')?.addEventListener('click', () => openPreview(navigation.next, data.sourceWindowId));
}
function renderPreviewContent(data) {
if (data.type === 'markdown') {
return `<article class="preview-rendered">${renderMarkdown(data.content || '')}</article>`;
}
if (data.type === 'csv') {
return renderCsvPreview(data.content || '', data.extension === 'tsv' ? '\t' : ',');
}
if (data.type === 'image') {
return `<div class="preview-media"><img src="${escapeAttr(apiUrl(data.rawUrl))}" alt="${escapeAttr(data.name || 'Vorschau')}"></div>`;
}
if (data.type === 'pdf') {
return `<iframe class="preview-frame" src="${escapeAttr(apiUrl(data.rawUrl))}" title="${escapeAttr(data.name || 'PDF Vorschau')}"></iframe>`;
}
if (data.type === 'audio') {
return `<div class="preview-media"><audio controls src="${escapeAttr(apiUrl(data.rawUrl))}"></audio></div>`;
}
if (data.type === 'video') {
return `<div class="preview-media"><video controls src="${escapeAttr(apiUrl(data.rawUrl))}"></video></div>`;
}
return `<pre class="preview-text">${escapeHtml(data.content || '')}</pre>`;
}
function previewNavigation(data) {
const source = state.windows.find((win) => win.id === data.sourceWindowId && win.kind === 'explorer');
const entries = (source?.data.entries || []).filter(isPreviewable);
if (entries.length < 2) {
return { previous: null, next: null };
}
const index = entries.findIndex((entry) => entry.path === data.entry?.path);
if (index < 0) {
return { previous: null, next: null };
}
return {
previous: entries[(index - 1 + entries.length) % entries.length],
next: entries[(index + 1) % entries.length]
};
}
async function loadPreview(win, entry) {
win.data.loading = true;
win.data.error = null;
render();
try {
const result = await api(apiUrl(`preview.php?path=${encodeURIComponent(entry.path)}`));
win.data.entry = entry;
win.data.sourceWindowId = win.data.sourceWindowId || state.focusedId;
win.data.name = result.name;
win.data.size = result.size;
win.data.type = result.type;
win.data.extension = result.extension;
win.data.rawUrl = result.rawUrl;
win.data.content = result.content;
} catch (error) {
win.data.error = error.message;
} finally {
win.data.loading = false;
render();
}
}
function renderMarkdown(content) {
const blocks = [];
let listItems = [];
const flushList = () => {
if (listItems.length) {
blocks.push(`<ul>${listItems.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join('')}</ul>`);
listItems = [];
}
};
for (const line of String(content).split(/\r?\n/)) {
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
if (heading) {
flushList();
const level = heading[1].length;
blocks.push(`<h${level}>${renderInlineMarkdown(heading[2])}</h${level}>`);
continue;
}
const list = /^\s*[-*]\s+(.*)$/.exec(line);
if (list) {
listItems.push(list[1]);
continue;
}
flushList();
if (!line.trim()) continue;
blocks.push(`<p>${renderInlineMarkdown(line)}</p>`);
}
flushList();
return blocks.join('');
}
function renderInlineMarkdown(value) {
return escapeHtml(value)
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>');
}
function renderCsvPreview(content, delimiter) {
const rows = parseDelimitedRows(content, delimiter).slice(0, 80);
if (!rows.length) {
return '<p class="muted">Keine Tabellenzeilen.</p>';
}
return `
<div class="preview-table-wrap">
<table class="preview-table">
<tbody>
${rows.map((row) => `<tr>${row.map((cell) => `<td>${escapeHtml(cell)}</td>`).join('')}</tr>`).join('')}
</tbody>
</table>
</div>
`;
}
function parseDelimitedRows(content, delimiter) {
const rows = [];
let row = [];
let cell = '';
let quoted = false;
const text = String(content || '');
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (char === '"') {
if (quoted && text[index + 1] === '"') {
cell += '"';
index += 1;
} else {
quoted = !quoted;
}
continue;
}
if (!quoted && char === delimiter) {
row.push(cell);
cell = '';
continue;
}
if (!quoted && (char === '\n' || char === '\r')) {
if (char === '\r' && text[index + 1] === '\n') index += 1;
row.push(cell);
rows.push(row);
row = [];
cell = '';
continue;
}
cell += char;
}
if (cell || row.length) {
row.push(cell);
rows.push(row);
}
return rows;
}
function renderQueue(body) {
body.innerHTML = queueMarkup();
bindQueueActions(body);
}
function queueMarkup() {
const jobs = [...state.jobs.values()].reverse();
const uploads = [...state.uploadTransfers.values()].reverse();
const transferItems = [
...uploads.map(uploadTransferMarkup),
...jobs.map((job) => `
<article class="job">
<strong>${escapeHtml(job.type)} #${escapeHtml(job.id)}</strong>
<div class="muted">${escapeHtml(job.status)} · ${escapeHtml(job.message || '')}</div>
<div class="progress"><div style="width:${Number(job.progress) || 0}%"></div></div>
</article>
`)
];
const jobMarkup = transferItems.length ? transferItems.join('') : '<p class="muted">Keine Transfers.</p>';
const debugSection = state.config?.debug ? debugMarkup() : '';
return `
<div class="queue-list">${jobMarkup}</div>
${debugSection}
`;
}
function uploadTransferMarkup(upload) {
const progress = Math.max(0, Math.min(100, Number(upload.progress) || 0));
const fileText = `${upload.completedFiles + upload.skippedFiles}/${upload.totalFiles} Dateien`;
const byteText = `${formatBytes(upload.uploadedBytes)} / ${formatBytes(upload.totalBytes)}`;
const detail = upload.currentFile ? ` · ${upload.currentFile}` : '';
const skipped = upload.skippedFiles ? ` · ${upload.skippedFiles} übersprungen` : '';
const overwritten = upload.overwrittenFiles ? ` · ${upload.overwrittenFiles} überschrieben` : '';
return `
<article class="job upload-job">
<strong>Upload #${escapeHtml(upload.id)}</strong>
<div class="muted">${escapeHtml(upload.status)} · ${escapeHtml(fileText)} · ${escapeHtml(byteText)}${escapeHtml(detail)}${escapeHtml(skipped)}${escapeHtml(overwritten)}</div>
<div class="progress"><div style="width:${progress}%"></div></div>
</article>
`;
}
function debugMarkup() {
const debugMarkup = state.debug.length ? state.debug.slice(-12).reverse().map((item) => `
<article class="debug-entry">
<strong>#${item.id} ${escapeHtml(item.label)}</strong>
<time>${escapeHtml(item.time)}</time>
<pre>${escapeHtml(JSON.stringify(item.data, null, 2))}</pre>
</article>
`).join('') : '<p class="muted">Noch keine Debug-Einträge.</p>';
return `
<section class="debug-log" aria-label="Debug-Protokoll">
<header>
<strong>Debug</strong>
<button type="button" data-action="clear-debug">Leeren</button>
</header>
${debugMarkup}
</section>
`;
}
function bindQueueActions(root) {
root.querySelector('[data-action="clear-debug"]')?.addEventListener('click', () => {
state.debug = [];
updateTransferPanel();
});
}
function toggleTransferPanel() {
if (state.transferPanelVisible) {
hideTransferPanel();
} else {
showTransferPanel();
}
}
function showTransferPanel() {
if (state.transferAutoHideTimer) {
clearTimeout(state.transferAutoHideTimer);
state.transferAutoHideTimer = null;
}
state.transferPanelVisible = true;
state.transferPanelClosing = false;
renderTransferPanel();
}
function hideTransferPanel() {
if (!state.transferPanelVisible && !state.transferPanelClosing) {
return;
}
if (state.transferAutoHideTimer) {
clearTimeout(state.transferAutoHideTimer);
state.transferAutoHideTimer = null;
}
state.transferPanelVisible = false;
state.transferPanelClosing = true;
renderTransferPanel();
setTimeout(() => {
if (!state.transferPanelVisible) {
state.transferPanelClosing = false;
renderTransferPanel();
}
}, 220);
}
function scheduleTransferAutoHide() {
if (!state.transferPanelVisible || activeTransferCount() > 0 || state.transferAutoHideTimer) {
return;
}
state.transferAutoHideTimer = setTimeout(() => {
state.transferAutoHideTimer = null;
if (activeTransferCount() === 0) {
hideTransferPanel();
}
}, 2200);
}
function activeTransferCount() {
const activeJobs = [...state.jobs.values()].filter((job) => job.type !== 'size' && ['queued', 'running', 'cancel_requested'].includes(job.status)).length;
const activeUploads = [...state.uploadTransfers.values()].filter((upload) => ['queued', 'running'].includes(upload.status)).length;
return activeJobs + activeUploads;
}
function renderTransferPanel() {
const existing = appShell.querySelector('.transfer-panel');
if (!state.transferPanelVisible && !state.transferPanelClosing) {
existing?.remove();
transferButton.classList.remove('active');
transferButton.setAttribute('aria-expanded', 'false');
return;
}
const panel = existing || document.createElement('aside');
panel.className = `transfer-panel ${state.transferPanelClosing ? 'closing' : 'open'}`;
panel.setAttribute('aria-label', 'Transfers');
panel.innerHTML = `
<header class="transfer-panel-header">
<strong>Transfers</strong>
<button type="button" class="icon" data-action="close-transfers" title="Schließen">×</button>
</header>
<div class="transfer-panel-body">${queueMarkup()}</div>
`;
if (!existing) {
appShell.appendChild(panel);
}
const buttonRect = transferButton.getBoundingClientRect();
const shellRect = appShell.getBoundingClientRect();
const panelWidth = Math.min(520, Math.max(360, shellRect.width - 32));
const right = Math.max(16, shellRect.right - buttonRect.right);
panel.style.width = `${panelWidth}px`;
panel.style.right = `${right}px`;
panel.style.top = `${buttonRect.bottom - shellRect.top + 8}px`;
panel.querySelector('[data-action="close-transfers"]').addEventListener('click', () => hideTransferPanel());
bindQueueActions(panel);
transferButton.classList.add('active');
transferButton.setAttribute('aria-expanded', 'true');
}
function toggleSettingsPanel() {
state.settingsPanelVisible = !state.settingsPanelVisible;
renderSettingsPanel();
}
function renderSettingsPanel() {
const existing = appShell.querySelector('.settings-panel');
if (!state.settingsPanelVisible) {
existing?.remove();
settingsButton.classList.remove('active');
settingsButton.setAttribute('aria-expanded', 'false');
return;
}
const panel = existing || document.createElement('aside');
panel.className = 'settings-panel';
panel.setAttribute('aria-label', 'Einstellungen');
panel.innerHTML = `
<header class="settings-panel-header">
<strong>Einstellungen</strong>
<button type="button" class="icon" data-action="close-settings" title="Schließen">×</button>
</header>
<div class="settings-panel-body">
<label class="setting-row">
<span>
<strong>Debug</strong>
<small>Debug-Protokoll im Transfers-Panel anzeigen</small>
</span>
<input type="checkbox" data-setting="debug" ${state.config?.debug ? 'checked' : ''}>
</label>
<label class="setting-row">
<span>
<strong>Schreibzugriff</strong>
<small>Upload, Rename, Move und Copy erlauben</small>
</span>
<input type="checkbox" data-setting="write" ${state.config && !state.config.readOnly ? 'checked' : ''}>
</label>
</div>
`;
if (!existing) {
appShell.appendChild(panel);
}
const buttonRect = settingsButton.getBoundingClientRect();
const shellRect = appShell.getBoundingClientRect();
panel.style.right = `${Math.max(16, shellRect.right - buttonRect.right)}px`;
panel.style.top = `${buttonRect.bottom - shellRect.top + 8}px`;
panel.querySelector('[data-action="close-settings"]').addEventListener('click', () => {
state.settingsPanelVisible = false;
renderSettingsPanel();
});
panel.querySelector('[data-setting="debug"]').addEventListener('change', (event) => {
saveSettings({ debug: event.currentTarget.checked });
});
panel.querySelector('[data-setting="write"]').addEventListener('change', (event) => {
saveSettings({ readOnly: !event.currentTarget.checked });
});
settingsButton.classList.add('active');
settingsButton.setAttribute('aria-expanded', 'true');
}
async function saveSettings(changes) {
const body = new URLSearchParams();
const readOnly = changes.readOnly ?? state.config.readOnly;
const debug = changes.debug ?? state.config.debug;
body.set('readOnly', readOnly ? '1' : '0');
body.set('debug', debug ? '1' : '0');
appendCsrf(body);
try {
state.config = await api(apiUrl('config.php'), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body
});
updateStatusText();
if (!state.config.debug) {
state.debug = [];
}
render();
} catch (error) {
showError(error);
}
}
function updateStatusText() {
if (!state.config) return;
statusText.textContent = `${state.config.roots.length} Root, ${state.config.readOnly ? 'Read-only' : 'Schreibzugriff aktiv'} · Debug ${state.config.debug ? 'aktiv' : 'aus'} · ${APP_VERSION}`;
}
function isReadOnly() {
return Boolean(state.config?.readOnly);
}
async function loadExplorer(win, targetPath) {
win.data.loading = true;
win.data.error = null;
win.data.path = targetPath;
render();
try {
const result = await api(apiUrl(`list.php?path=${encodeURIComponent(targetPath)}`));
win.data.path = result.path;
win.data.entries = result.entries;
reconcileExplorerSelection(win);
} catch (error) {
win.data.error = error.message;
} finally {
win.data.loading = false;
render();
}
}
async function handleDrop(event, win, targetPath) {
const uploadItems = await getDroppedUploadItems(event);
recordDebug('html.drop', {
targetPath,
types: dataTransferTypes(event),
files: uploadItems.map((item) => ({ name: item.path, size: item.file.size }))
});
if (state.config.readOnly) {
showError(new Error('Read-only mode ist aktiv.'));
return;
}
if (uploadItems.length) {
await uploadFileList(win, uploadItems, targetPath);
}
}
async function uploadFileList(win, files, targetPath) {
if (isReadOnly()) {
showError(new Error('Read-only mode ist aktiv.'));
return;
}
if (!files.length) return;
const uploadItems = normalizeUploadItems(files);
const transfer = createUploadTransfer(targetPath, uploadItems);
let overwriteAll = false;
for (const [index, item] of uploadItems.entries()) {
updateUploadTransfer(transfer, {
status: 'running',
currentFile: item.path,
currentIndex: index + 1
});
recordDebug('upload.file.request', { path: item.path, size: item.file.size, index: index + 1, total: uploadItems.length });
try {
await uploadFileChunked(targetPath, item.file, item.path, false, (bytes) => {
addUploadProgress(transfer, bytes);
});
updateUploadTransfer(transfer, {
completedFiles: transfer.completedFiles + 1,
currentFile: item.path,
progress: uploadProgressPercent(transfer)
});
recordDebug('upload.file.done', { path: item.path, index: index + 1, total: uploadItems.length, overwrite: false });
} catch (error) {
if (error.status !== 409) {
updateUploadTransfer(transfer, {
status: 'failed',
message: error.message || 'Upload fehlgeschlagen',
currentFile: item.path,
progress: uploadProgressPercent(transfer)
}, true);
throw error;
}
const conflictAction = overwriteAll ? 'overwrite' : await askUploadConflict({
path: item.path,
targetPath,
index: index + 1,
total: uploadItems.length
});
if (conflictAction === 'overwrite-all') {
overwriteAll = true;
}
const overwrite = overwriteAll || conflictAction === 'overwrite';
recordDebug('upload.overwrite.prompt', { path: item.path, action: conflictAction, overwrite });
if (!overwrite) {
updateUploadTransfer(transfer, {
skippedFiles: transfer.skippedFiles + 1,
skippedBytes: transfer.skippedBytes + Number(item.file.size || 0),
currentFile: item.path,
progress: uploadProgressPercent(transfer)
});
recordDebug('upload.file.skipped', { path: item.path, index: index + 1, total: uploadItems.length });
continue;
}
await uploadFileChunked(targetPath, item.file, item.path, true, (bytes) => {
addUploadProgress(transfer, bytes);
});
updateUploadTransfer(transfer, {
completedFiles: transfer.completedFiles + 1,
overwrittenFiles: transfer.overwrittenFiles + 1,
currentFile: item.path,
progress: uploadProgressPercent(transfer)
});
recordDebug('upload.file.done', { path: item.path, index: index + 1, total: uploadItems.length, overwrite: true });
}
}
updateUploadTransfer(transfer, {
status: 'completed',
message: 'Abgeschlossen',
currentFile: '',
progress: 100
}, true);
scheduleTransferAutoHide();
await loadExplorer(win, targetPath);
}
async function uploadFileChunked(targetPath, file, relativePath, overwrite = false, onChunkUploaded = null) {
const chunkSize = 256 * 1024;
const totalChunks = Math.max(1, Math.ceil(file.size / chunkSize));
let finalPayload = null;
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) {
const start = chunkIndex * chunkSize;
const chunk = file.slice(start, Math.min(file.size, start + chunkSize));
const data = arrayBufferToBase64(await chunk.arrayBuffer());
const body = new URLSearchParams();
body.set('uploadMode', 'chunk');
body.set('relativePath', relativePath);
body.set('chunkIndex', String(chunkIndex));
body.set('totalChunks', String(totalChunks));
body.set('data', data);
if (overwrite) {
body.set('overwrite', '1');
}
appendCsrf(body);
const response = await fetch(apiUrl(`upload.php?path=${encodeURIComponent(targetPath)}`), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body
});
const text = await response.text();
recordDebug('upload.chunk.response', {
path: relativePath,
chunk: chunkIndex + 1,
totalChunks,
status: response.status,
ok: response.ok,
contentType: response.headers.get('content-type'),
body: text.slice(0, 1000)
});
if (!response.ok) {
const error = new Error(text || `${response.status} ${response.statusText}`);
error.status = response.status;
error.path = relativePath;
throw error;
}
onChunkUploaded?.(chunk.size);
try {
finalPayload = JSON.parse(text);
} catch (error) {
throw new Error(`Upload lieferte keine JSON-Antwort: ${text.slice(0, 200) || response.headers.get('content-type') || 'leer'}`);
}
}
if (!finalPayload?.uploaded?.length) {
throw new Error('Upload wurde vom Server nicht bestätigt.');
}
}
function createUploadTransfer(targetPath, uploadItems) {
const transfer = {
id: `upload-${state.nextUploadTransferId++}`,
status: 'queued',
message: 'Wartet',
targetPath,
totalFiles: uploadItems.length,
completedFiles: 0,
skippedFiles: 0,
overwrittenFiles: 0,
totalBytes: uploadItems.reduce((sum, item) => sum + Number(item.file?.size || 0), 0),
uploadedBytes: 0,
skippedBytes: 0,
currentFile: '',
currentIndex: 0,
progress: 0,
updatedAt: Date.now(),
lastRenderAt: 0
};
state.uploadTransfers.set(transfer.id, transfer);
showTransferPanel();
updateTransferPanel();
return transfer;
}
function updateUploadTransfer(transfer, patch, force = false) {
Object.assign(transfer, patch, {
message: patch.message ?? `${transfer.currentIndex || 0}/${transfer.totalFiles}`,
updatedAt: Date.now()
});
const now = Date.now();
if (force || now - transfer.lastRenderAt > 180) {
transfer.lastRenderAt = now;
updateTransferPanel();
}
}
function addUploadProgress(transfer, bytes) {
transfer.uploadedBytes += Number(bytes || 0);
updateUploadTransfer(transfer, {
progress: uploadProgressPercent(transfer)
});
}
function uploadProgressPercent(transfer) {
if (transfer.totalBytes > 0) {
return ((transfer.uploadedBytes + transfer.skippedBytes) / transfer.totalBytes) * 100;
}
if (transfer.totalFiles > 0) {
return ((transfer.completedFiles + transfer.skippedFiles) / transfer.totalFiles) * 100;
}
return 0;
}
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let index = 0; index < bytes.length; index += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
}
return btoa(binary);
}
function normalizeUploadItems(files) {
return files.map((item) => {
if (item?.file && item?.path) {
return item;
}
const file = item;
return {
file,
path: file.webkitRelativePath || file.name
};
}).filter((item) => item.file && item.path);
}
async function createJob(type, source, destination = null, refreshWindowIds = [], extra = {}) {
if (['move', 'copy', 'permissions'].includes(type) && isReadOnly()) {
showError(new Error('Read-only mode ist aktiv.'));
return null;
}
recordDebug('job.create.request', { type, source, destination, refreshWindowIds, extra });
const body = new URLSearchParams();
body.set('source', source);
if (destination !== null) {
body.set('destination', destination);
}
for (const [key, value] of Object.entries(extra)) {
if (value !== null && value !== undefined) {
body.set(key, String(value));
}
}
appendCsrf(body);
const job = await api(apiUrl(`job.php?action=${encodeURIComponent(type)}`), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body
});
recordDebug('job.create.response', job);
state.jobs.set(job.id, job);
state.jobRefresh.set(job.id, {
windowIds: [...new Set(refreshWindowIds)].filter(Boolean),
done: false
});
showTransferPanel();
pollJob(job.id);
updateTransferPanel();
return job;
}
async function createSizeJob(entry) {
recordDebug('size.create.request', { source: entry.path });
const body = new URLSearchParams();
body.set('source', entry.path);
appendCsrf(body);
const job = await api(apiUrl('job.php?action=size'), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body
});
recordDebug('size.create.response', job);
state.jobs.set(job.id, job);
state.sizeJobs.set(entry.path, job.id);
pollJob(job.id);
render();
return job;
}
async function reloadExplorerWindows(ids) {
const uniqueIds = new Set(ids);
const targets = state.windows.filter((win) => win.kind === 'explorer' && (uniqueIds.has(win.id) || !uniqueIds.size));
await Promise.all(targets.map((win) => loadExplorer(win, win.data.path)));
}
function hasDropPayload(event) {
const types = dataTransferTypes(event);
return types.includes('Files') || getDroppedFiles(event).length > 0;
}
async function getDroppedUploadItems(event) {
const entries = getDroppedEntries(event);
if (entries.length) {
const items = [];
for (const entry of entries) {
items.push(...await readDroppedEntry(entry, ''));
}
if (items.length) {
return items;
}
}
return getDroppedFiles(event).filter((file) => file.size > 0 || file.type || file.name.includes('.')).map((file) => ({
file,
path: file.webkitRelativePath || file.name
}));
}
function getDroppedEntries(event) {
try {
return [...(event.dataTransfer?.items || [])]
.map((item) => item.webkitGetAsEntry?.())
.filter(Boolean);
} catch (error) {
recordDebug('dataTransfer.entries.error', serializeError(error));
return [];
}
}
async function readDroppedEntry(entry, parentPath) {
const path = parentPath ? `${parentPath}/${entry.name}` : entry.name;
if (entry.isFile) {
const file = await new Promise((resolve, reject) => entry.file(resolve, reject));
return [{ file, path }];
}
if (!entry.isDirectory) {
return [];
}
const reader = entry.createReader();
const children = await readAllDirectoryEntries(reader);
const results = [];
for (const child of children) {
results.push(...await readDroppedEntry(child, path));
}
return results;
}
async function readAllDirectoryEntries(reader) {
const entries = [];
while (true) {
const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
if (!batch.length) {
return entries;
}
entries.push(...batch);
}
}
function showError(error) {
const debug = recordDebug('error.shown', serializeError(error));
const message = error?.message || String(error);
const detail = debug ? `\n\nDebug #${debug.id}: ${debug.label}` : '';
alert(`${message}${detail}`);
}
function getDroppedFiles(event) {
try {
return [...(event.dataTransfer?.files || [])];
} catch {
return [];
}
}
function dataTransferTypes(event) {
try {
return [...(event.dataTransfer?.types || [])];
} catch (error) {
recordDebug('dataTransfer.types.error', serializeError(error));
return [];
}
}
function setEffectAllowed(event) {
try {
event.dataTransfer.effectAllowed = 'copyMove';
} catch (error) {
recordDebug('dataTransfer.effectAllowed.error', serializeError(error));
}
}
function setDropEffect(event, effect) {
try {
event.dataTransfer.dropEffect = effect;
} catch {
recordDebug('dataTransfer.dropEffect.error', { effect });
}
}
async function renameEntry(win, entry) {
if (isReadOnly()) {
showError(new Error('Read-only mode ist aktiv.'));
return;
}
const nextName = prompt('Neuer Name', entry.name);
if (!nextName || nextName === entry.name) {
return;
}
if (nextName.includes('/')) {
showError(new Error('Der Name darf keinen Slash enthalten.'));
return;
}
await createJob('move', entry.path, `${dirname(entry.path)}/${nextName}`, [win.id]);
}
async function promptTransfer(win, entry, type) {
if (isReadOnly()) {
showError(new Error('Read-only mode ist aktiv.'));
return;
}
const targetDir = prompt(type === 'copy' ? 'Kopieren nach Ordner' : 'Verschieben nach Ordner', win.data.path);
if (!targetDir) {
return;
}
await createJob(type, entry.path, `${targetDir.replace(/\/$/, '')}/${entry.name}`, []);
}
async function deleteEntries(win, entry = null) {
if (isReadOnly()) {
showError(new Error('Read-only mode ist aktiv.'));
return;
}
const entries = entry && selectedPathSet(win).has(entry.path) ? selectedEntries(win) : (entry ? [entry] : selectedEntries(win));
if (!entries.length) {
showError(new Error('Keine Datei ausgewählt.'));
return;
}
const label = entries.length === 1 ? `"${entries[0].name}" wirklich löschen?` : `${entries.length} Objekte wirklich löschen?`;
if (!confirm(`${label}\n\nDieser Vorgang löscht direkt vom Dateisystem.`)) {
return;
}
for (const item of entries) {
await createJob('delete', item.path, null, [win.id]);
}
}
function openPermissionsDialog(win, entry) {
if (isReadOnly()) {
showError(new Error('Read-only mode ist aktiv.'));
return;
}
const entries = selectedPathSet(win).has(entry.path) ? selectedEntries(win) : [entry];
state.permissionsDialog = {
winId: win.id,
entries,
mode: normalizeMode(entry.permissions || '0755'),
owner: entry.owner || '',
group: entry.group || '',
recursive: false,
hasDirectory: entries.some((item) => item.type === 'directory')
};
render();
}
function downloadEntries(win, entry = null) {
const entries = entry && selectedPathSet(win).has(entry.path) ? selectedEntries(win) : (entry ? [entry] : [{
path: win.data.path,
name: basename(win.data.path),
type: 'directory'
}]);
const paths = entries.map((item) => item.path);
const query = paths.length === 1
? `path=${encodeURIComponent(paths[0])}`
: `paths=${encodeURIComponent(JSON.stringify(paths))}`;
recordDebug('download.start', { paths });
const link = document.createElement('a');
link.href = apiUrl(`download.php?${query}`);
link.download = '';
document.body.appendChild(link);
link.click();
link.remove();
}
function renderPermissionsDialog() {
const dialog = state.permissionsDialog;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
<form class="permissions-modal" aria-label="Berechtigungen ändern">
<header>
<strong>Berechtigungen ändern</strong>
<button type="button" class="icon" data-action="cancel" title="Schließen" aria-label="Schließen">×</button>
</header>
<div class="modal-body">
<p class="muted">${dialog.entries.length === 1 ? escapeHtml(dialog.entries[0].name) : `${dialog.entries.length} Objekte ausgewählt`}</p>
<label class="check-row">
<input type="checkbox" name="changeMode" checked>
<span>Berechtigungen ändern</span>
</label>
${permissionMatrixMarkup(dialog.mode)}
<label class="field-row">
<span>Numerischer Wert</span>
<input name="mode" class="mode-input" inputmode="numeric" pattern="[0-7]{3,4}" value="${escapeAttr(dialog.mode)}" aria-label="Numerischer Wert">
</label>
<label class="check-row">
<input type="checkbox" name="changeOwner">
<span>Besitzer ändern</span>
</label>
<input name="owner" value="${escapeAttr(dialog.owner)}" aria-label="Besitzer" disabled>
<label class="check-row">
<input type="checkbox" name="changeGroup">
<span>Gruppe ändern</span>
</label>
<input name="group" value="${escapeAttr(dialog.group)}" aria-label="Gruppe" disabled>
<label class="check-row ${dialog.hasDirectory ? '' : 'disabled'}">
<input type="checkbox" name="recursive" ${dialog.hasDirectory ? '' : 'disabled'}>
<span>Rekursiv auf Unterordner und Dateien anwenden</span>
</label>
</div>
<footer>
<button type="button" data-action="cancel">Abbrechen</button>
<button type="submit" class="primary">Anwenden</button>
</footer>
</form>
`;
const form = overlay.querySelector('form');
form.addEventListener('click', (event) => event.stopPropagation());
overlay.addEventListener('click', () => closePermissionsDialog());
overlay.querySelectorAll('[data-action="cancel"]').forEach((button) => {
button.addEventListener('click', () => closePermissionsDialog());
});
for (const checkboxName of ['changeMode', 'changeOwner', 'changeGroup']) {
const checkbox = form.elements[checkboxName];
const inputName = checkboxName.replace('change', '').toLowerCase();
const input = form.elements[inputName];
checkbox.addEventListener('change', () => {
input.disabled = !checkbox.checked;
if (!input.disabled) input.focus();
});
}
bindPermissionMatrix(form);
form.addEventListener('submit', (event) => {
event.preventDefault();
applyPermissionsDialog(form).catch(showError);
});
return overlay;
}
function askUploadConflict(details) {
return new Promise((resolve) => {
state.uploadConflictDialog = { ...details, resolve };
render();
});
}
function renderUploadConflictDialog() {
const dialog = state.uploadConflictDialog;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
<form class="permissions-modal conflict-modal" aria-label="Datei existiert bereits">
<header>
<strong>Datei existiert bereits</strong>
<button type="button" class="icon" data-action="skip" title="Überspringen" aria-label="Überspringen">×</button>
</header>
<div class="modal-body">
<p>Im Zielordner existiert bereits eine Datei mit diesem Namen.</p>
<dl class="conflict-details">
<dt>Datei</dt>
<dd>${escapeHtml(dialog.path)}</dd>
<dt>Ziel</dt>
<dd>${escapeHtml(dialog.targetPath)}</dd>
<dt>Upload</dt>
<dd>${escapeHtml(`${dialog.index}/${dialog.total}`)}</dd>
</dl>
</div>
<footer>
<button type="button" data-action="skip">Überspringen</button>
<button type="button" data-action="overwrite">Überschreiben</button>
<button type="submit" class="primary">Alle überschreiben</button>
</footer>
</form>
`;
const form = overlay.querySelector('form');
form.addEventListener('click', (event) => event.stopPropagation());
overlay.addEventListener('click', () => closeUploadConflictDialog('skip'));
overlay.querySelectorAll('[data-action="skip"]').forEach((button) => {
button.addEventListener('click', () => closeUploadConflictDialog('skip'));
});
overlay.querySelector('[data-action="overwrite"]').addEventListener('click', () => closeUploadConflictDialog('overwrite'));
form.addEventListener('submit', (event) => {
event.preventDefault();
closeUploadConflictDialog('overwrite-all');
});
setTimeout(() => {
form.querySelector('.primary')?.focus();
}, 0);
return overlay;
}
function closeUploadConflictDialog(action) {
const dialog = state.uploadConflictDialog;
if (!dialog) return;
state.uploadConflictDialog = null;
dialog.resolve(action);
render();
}
function permissionMatrixMarkup(mode) {
const digits = modeToDigits(mode);
const rows = [
['owner', 'Besitzer-Berechtigungen', digits[0]],
['group', 'Gruppen-Berechtigungen', digits[1]],
['public', 'Öffentliche Berechtigungen', digits[2]]
];
return rows.map(([scope, label, digit]) => `
<fieldset class="permission-scope" data-scope="${scope}">
<legend>${label}</legend>
${permissionCheck(scope, 'read', 'Lesen', digit & 4)}
${permissionCheck(scope, 'write', 'Schreiben', digit & 2)}
${permissionCheck(scope, 'execute', 'Ausführen', digit & 1)}
</fieldset>
`).join('');
}
function permissionCheck(scope, bit, label, checked) {
return `
<label>
<input type="checkbox" data-permission="${scope}.${bit}" ${checked ? 'checked' : ''}>
<span>${label}</span>
</label>
`;
}
function bindPermissionMatrix(form) {
const changeMode = form.elements.changeMode;
const modeInput = form.elements.mode;
const permissionInputs = [...form.querySelectorAll('[data-permission]')];
const setModeDisabled = () => {
modeInput.disabled = !changeMode.checked;
for (const input of permissionInputs) {
input.disabled = !changeMode.checked;
}
};
changeMode.addEventListener('change', setModeDisabled);
for (const input of permissionInputs) {
input.addEventListener('change', () => {
changeMode.checked = true;
setModeDisabled();
modeInput.value = modeFromPermissionInputs(form);
});
}
modeInput.addEventListener('input', () => {
changeMode.checked = true;
setModeDisabled();
syncPermissionInputsFromMode(form, modeInput.value);
});
setModeDisabled();
}
function modeFromPermissionInputs(form) {
const scopes = ['owner', 'group', 'public'];
const values = scopes.map((scope) => {
let value = 0;
if (form.querySelector(`[data-permission="${scope}.read"]`)?.checked) value += 4;
if (form.querySelector(`[data-permission="${scope}.write"]`)?.checked) value += 2;
if (form.querySelector(`[data-permission="${scope}.execute"]`)?.checked) value += 1;
return String(value);
});
return values.join('');
}
function syncPermissionInputsFromMode(form, mode) {
if (!/^[0-7]{3,4}$/.test(mode)) return;
const digits = modeToDigits(mode);
for (const [index, scope] of ['owner', 'group', 'public'].entries()) {
const digit = digits[index];
const map = { read: 4, write: 2, execute: 1 };
for (const [bit, value] of Object.entries(map)) {
const input = form.querySelector(`[data-permission="${scope}.${bit}"]`);
if (input) input.checked = Boolean(digit & value);
}
}
}
function normalizeMode(mode) {
const value = String(mode || '');
if (!/^[0-7]{3,4}$/.test(value)) return '755';
return value.length === 4 ? value.slice(1) : value;
}
function modeToDigits(mode) {
const normalized = normalizeMode(mode);
return normalized.split('').slice(-3).map((digit) => Number(digit));
}
function closePermissionsDialog() {
state.permissionsDialog = null;
render();
}
async function applyPermissionsDialog(form) {
const dialog = state.permissionsDialog;
if (!dialog) return;
const options = {
mode: form.elements.changeMode.checked ? form.elements.mode.value.trim() : '',
owner: form.elements.changeOwner.checked ? form.elements.owner.value.trim() : '',
group: form.elements.changeGroup.checked ? form.elements.group.value.trim() : '',
recursive: form.elements.recursive.checked ? '1' : '0'
};
if (!options.mode && !options.owner && !options.group) {
showError(new Error('Keine Änderung angegeben.'));
return;
}
if (options.mode && !/^[0-7]{3,4}$/.test(options.mode)) {
showError(new Error('Berechtigungen müssen aus 3 oder 4 Oktalziffern bestehen.'));
return;
}
const win = state.windows.find((item) => item.id === dialog.winId);
closePermissionsDialog();
for (const item of dialog.entries) {
await createJob('permissions', item.path, null, [win?.id], options);
}
}
function showPropertiesForEntry(win, entry) {
selectEntry(win, entry);
openProperties(entry);
}
function showContextMenu(event, win, entry) {
const rect = workspace.getBoundingClientRect();
state.contextMenu = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
winId: win.id,
entryPath: entry?.path || null
};
render();
}
function renderContextMenu() {
const menu = document.createElement('div');
menu.className = 'context-menu';
menu.style.left = `${state.contextMenu.x}px`;
menu.style.top = `${state.contextMenu.y}px`;
const win = state.windows.find((item) => item.id === state.contextMenu.winId);
const entry = win?.data.entries?.find((item) => item.path === state.contextMenu.entryPath) || null;
const actions = [];
if (entry) {
if (entry.type === 'directory') {
actions.push(['Öffnen', () => loadExplorer(win, entry.path)]);
} else if (isPreviewable(entry)) {
actions.push(['Vorschau', () => openPreview(entry, win.id)]);
}
actions.push(['Eigenschaften', () => showPropertiesForEntry(win, entry)]);
actions.push(['Herunterladen', () => downloadEntries(win, entry)]);
if (!isReadOnly()) {
actions.push(['Umbenennen', () => renameEntry(win, entry)]);
actions.push(['Berechtigungen...', () => openPermissionsDialog(win, entry)]);
actions.push(['Kopieren nach...', () => promptTransfer(win, entry, 'copy')]);
actions.push(['Verschieben nach...', () => promptTransfer(win, entry, 'move')]);
actions.push(['Löschen', () => deleteEntries(win, entry)]);
}
} else {
actions.push(['Aktuellen Ordner herunterladen', () => downloadEntries(win, null)]);
actions.push(['Neu laden', () => loadExplorer(win, win.data.path)]);
}
for (const [label, action] of actions) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = label;
button.addEventListener('click', async () => {
state.contextMenu = null;
render();
try {
await action();
} catch (error) {
showError(error);
}
});
menu.appendChild(button);
}
setTimeout(() => {
const close = () => {
state.contextMenu = null;
render();
document.removeEventListener('click', close);
document.removeEventListener('keydown', keyClose);
};
const keyClose = (event) => {
if (event.key === 'Escape') close();
};
document.addEventListener('click', close);
document.addEventListener('keydown', keyClose);
}, 0);
return menu;
}
async function pollJob(id) {
try {
const job = await api(apiUrl(`job.php?id=${encodeURIComponent(id)}`));
state.jobs.set(id, job);
updateTransferPanel();
if (['queued', 'running', 'cancel_requested'].includes(job.status)) {
setTimeout(() => pollJob(id), 700);
} else if (job.type === 'size') {
finishSizeJob(job);
} else {
await refreshAfterJob(job);
scheduleTransferAutoHide();
}
} catch (error) {
recordDebug('job.poll.error', { id, error: serializeError(error) });
showError(error);
}
}
function ensureDirectorySize(entry) {
if (state.sizeResults.has(entry.path) || state.sizeJobs.has(entry.path)) {
return;
}
state.sizeJobs.set(entry.path, 'pending');
createSizeJob(entry).catch((error) => {
state.sizeJobs.delete(entry.path);
showError(error);
});
}
function finishSizeJob(job) {
state.sizeJobs.delete(job.source);
if (job.status === 'completed' && job.result) {
state.sizeResults.set(job.source, job.result);
}
recordDebug('size.finish', { id: job.id, source: job.source, status: job.status, result: job.result || null });
updatePropertiesWindows();
}
async function refreshAfterJob(job) {
const refresh = state.jobRefresh.get(job.id);
if (refresh?.done) {
return;
}
if (refresh) {
refresh.done = true;
}
const windowIds = refresh?.windowIds || [];
recordDebug('job.refresh', { id: job.id, status: job.status, windowIds });
await reloadExplorerWindows(windowIds);
if (refresh) {
state.jobRefresh.delete(job.id);
}
}
function selectEntry(win, entry, scrollBody = null, event = null) {
if (state.suppressClickPath === entry.path) {
state.suppressClickPath = null;
restoreExplorerScroll(win, scrollBody);
return;
}
applyExplorerSelection(win, entry, event);
state.selectedEntry = entry;
focusWindow(win);
updateSelectedRows();
updatePropertiesWindows();
restoreExplorerScroll(win, scrollBody);
}
function applyExplorerSelection(win, entry, event = null) {
const entries = win.data.entries || [];
const current = selectedPathSet(win);
const toggle = Boolean(event?.ctrlKey || event?.metaKey);
const range = Boolean(event?.shiftKey && win.data.selectionAnchorPath);
if (range) {
const anchorIndex = entries.findIndex((item) => item.path === win.data.selectionAnchorPath);
const entryIndex = entries.findIndex((item) => item.path === entry.path);
if (anchorIndex >= 0 && entryIndex >= 0) {
const [start, end] = anchorIndex < entryIndex ? [anchorIndex, entryIndex] : [entryIndex, anchorIndex];
const paths = entries.slice(start, end + 1).map((item) => item.path);
win.data.selectedPaths = toggle ? [...new Set([...current, ...paths])] : paths;
} else {
win.data.selectedPaths = [entry.path];
win.data.selectionAnchorPath = entry.path;
}
} else if (toggle) {
if (current.has(entry.path)) {
current.delete(entry.path);
} else {
current.add(entry.path);
}
win.data.selectedPaths = [...current];
win.data.selectionAnchorPath = entry.path;
} else {
win.data.selectedPaths = [entry.path];
win.data.selectionAnchorPath = entry.path;
}
win.data.selectedPath = entry.path;
if (!win.data.selectedPaths.length) {
win.data.selectedPath = null;
win.data.selectionAnchorPath = null;
}
}
function reconcileExplorerSelection(win) {
const available = new Set((win.data.entries || []).map((entry) => entry.path));
win.data.selectedPaths = (win.data.selectedPaths || []).filter((path) => available.has(path));
if (!available.has(win.data.selectedPath)) {
win.data.selectedPath = win.data.selectedPaths[0] || null;
}
if (!available.has(win.data.selectionAnchorPath)) {
win.data.selectionAnchorPath = win.data.selectedPath;
}
if (state.selectedEntry && !available.has(state.selectedEntry.path) && state.focusedId === win.id) {
state.selectedEntry = null;
}
}
function selectedPathSet(win) {
return new Set(win?.data?.selectedPaths || (win?.data?.selectedPath ? [win.data.selectedPath] : []));
}
function selectedEntries(win) {
const paths = selectedPathSet(win);
return (win?.data?.entries || []).filter((entry) => paths.has(entry.path));
}
function shouldSuppressItemActivation() {
if (Date.now() <= state.suppressItemActivationUntil) {
recordDebug('item.activation.suppressed', { until: state.suppressItemActivationUntil });
return true;
}
return false;
}
function focusWindow(win) {
state.focusedId = win.id;
win.zIndex = ++state.zIndex;
for (const windowEl of workspace.querySelectorAll('.window')) {
windowEl.classList.toggle('focused', windowEl.dataset.windowId === win.id);
}
const current = workspace.querySelector(`.window[data-window-id="${win.id}"]`);
if (current) {
current.style.zIndex = win.zIndex;
}
}
function updateSelectedRows() {
for (const win of state.windows.filter((item) => item.kind === 'explorer')) {
const selected = selectedPathSet(win);
for (const row of workspace.querySelectorAll(`.window[data-window-id="${win.id}"] .file-row`)) {
row.classList.toggle('selected', selected.has(row.dataset.path));
}
}
}
function handleTypeAheadKey(event) {
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
if (state.contextMenu || state.settingsPanelVisible) return;
if (isTypingTarget(document.activeElement)) return;
if (event.key.length !== 1) return;
const key = event.key.toLocaleLowerCase();
if (!/^[\p{L}\p{N}]$/u.test(key)) return;
const win = state.windows.find((item) => item.id === state.focusedId && item.kind === 'explorer');
if (!win || !win.data.entries?.length) return;
const now = Date.now();
const repeated = state.typeAhead.windowId === win.id && state.typeAhead.key === key && now - state.typeAhead.at < 1000;
const entries = win.data.entries;
const selectedIndex = entries.findIndex((entry) => entry.path === win.data.selectedPath);
const startIndex = repeated ? selectedIndex + 1 : Math.max(0, selectedIndex + 1);
const match = findTypeAheadMatch(entries, key, startIndex);
if (!match) return;
event.preventDefault();
state.typeAhead = { key, windowId: win.id, at: now };
win.data.selectedPath = match.path;
win.data.selectedPaths = [match.path];
win.data.selectionAnchorPath = match.path;
state.selectedEntry = match;
focusWindow(win);
updateSelectedRows();
updatePropertiesWindows();
scrollEntryIntoView(win, match.path);
recordDebug('typeahead.match', { key, path: match.path });
}
function findTypeAheadMatch(entries, key, startIndex) {
for (let offset = 0; offset < entries.length; offset += 1) {
const index = (startIndex + offset) % entries.length;
const entry = entries[index];
if (entry.name?.toLocaleLowerCase().startsWith(key)) {
return entry;
}
}
return null;
}
function scrollEntryIntoView(win, path) {
const row = workspace.querySelector(`.window[data-window-id="${win.id}"] .file-row[data-path="${cssEscape(path)}"]`);
row?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
function isTypingTarget(element) {
if (!element) return false;
const tag = element.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || element.isContentEditable;
}
function updatePropertiesWindows() {
for (const win of state.windows.filter((item) => item.kind === 'properties')) {
const body = workspace.querySelector(`.window[data-window-id="${win.id}"] .window-body`);
if (body) {
renderProperties(body);
}
}
}
function startItemPointerDrag(event, win, entry) {
if (event.button !== 0 || !entry) return;
const items = dragItemsForEntry(win, entry);
recordDebug('pointer.start', {
windowId: win.id,
path: entry.path,
type: entry.type,
count: items.length,
x: event.clientX,
y: event.clientY
});
state.pointerDrag = {
sourceWindowId: win.id,
path: entry.path,
name: entry.name,
type: entry.type,
items,
paths: items.map((item) => item.path),
startX: event.clientX,
startY: event.clientY,
active: false,
targetWindowId: null,
targetPath: null
};
const move = (moveEvent) => updateItemPointerDrag(moveEvent);
const up = (upEvent) => finishItemPointerDrag(upEvent, move, up);
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up, { once: true });
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {}
}
function updateItemPointerDrag(event) {
const drag = state.pointerDrag;
if (!drag) return;
const distance = Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY);
if (!drag.active && distance < 6) {
return;
}
drag.active = true;
event.preventDefault();
document.body.classList.add('u-nav-dragging');
markDraggingRows(drag.paths, true);
markDropCandidates(drag.paths, true);
updatePointerDropTarget(event);
}
async function finishItemPointerDrag(event, move, up) {
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', up);
const drag = state.pointerDrag;
clearPointerDropTarget();
document.body.classList.remove('u-nav-dragging');
if (!drag) return;
markDraggingRows(drag.paths, false);
markDropCandidates(drag.paths, false);
state.pointerDrag = null;
if (!drag.active) {
return;
}
event.preventDefault();
state.suppressClickPath = drag.path;
state.suppressItemActivationUntil = Date.now() + 450;
const target = findPointerDropTarget(event);
recordDebug('pointer.finish', {
source: drag.path,
sources: drag.paths,
target: target ? { windowId: target.windowId, path: target.path, rowPath: target.row?.dataset?.path || null } : null,
copy: event.altKey,
x: event.clientX,
y: event.clientY
});
if (!target) {
return;
}
try {
const jobs = drag.items.map((item) => ({
item,
destination: joinPath(target.path, item.name)
}));
recordDebug('pointer.destination', {
source: drag.path,
sources: drag.paths,
targetPath: target.path,
destinations: jobs.map((job) => job.destination),
targetIsRow: Boolean(target.row),
copy: event.altKey
});
validatePointerDropJobs(jobs, target.path);
for (const job of jobs) {
await createJob(event.altKey ? 'copy' : 'move', job.item.path, job.destination, [drag.sourceWindowId, target.windowId]);
}
} catch (error) {
showError(error);
}
}
function dragItemsForEntry(win, entry) {
const selected = selectedPathSet(win);
if (selected.has(entry.path)) {
const entries = selectedEntries(win);
if (entries.length) return entries;
}
return [entry];
}
function validatePointerDropJobs(jobs, targetPath) {
const destinations = new Set();
for (const { item, destination } of jobs) {
if (destinations.has(destination)) {
throw new Error('Mehrere Quellen würden am Ziel denselben Namen verwenden.');
}
destinations.add(destination);
if (destination === item.path || targetPath === item.path) {
throw new Error('Quelle und Ziel sind identisch.');
}
if (item.type === 'directory' && isPathInside(targetPath, item.path)) {
throw new Error('Ein Ordner kann nicht in sich selbst verschoben werden.');
}
}
}
function updatePointerDropTarget(event) {
clearPointerDropTarget();
const target = findPointerDropTarget(event);
if (!target) return;
state.pointerDrag.targetWindowId = target.windowId;
state.pointerDrag.targetPath = target.path;
const windowEl = workspace.querySelector(`.window[data-window-id="${target.windowId}"]`);
windowEl?.querySelector('.drop-zone')?.classList.add('drag-over');
if (target.row) {
target.row.classList.add('drop-target');
}
}
function clearPointerDropTarget() {
for (const item of workspace.querySelectorAll('.drop-zone.drag-over')) {
item.classList.remove('drag-over');
}
for (const item of workspace.querySelectorAll('.file-row.drop-target')) {
item.classList.remove('drop-target');
}
}
function findPointerDropTarget(event) {
let element;
let elements = [];
try {
element = document.elementFromPoint(event.clientX, event.clientY);
elements = document.elementsFromPoint ? document.elementsFromPoint(event.clientX, event.clientY) : [element];
} catch (error) {
recordDebug('elementFromPoint.error', {
x: event.clientX,
y: event.clientY,
error: serializeError(error)
});
return null;
}
let windowEl;
try {
windowEl = element?.closest?.('.window');
} catch (error) {
recordDebug('closest.window.error', { error: serializeError(error), element: element?.tagName });
return null;
}
if (!windowEl) return null;
const win = state.windows.find((item) => item.id === windowEl.dataset.windowId);
if (!win || win.kind !== 'explorer') return null;
const drag = state.pointerDrag;
let row = null;
for (const candidate of elements) {
try {
const candidateRow = candidate.closest?.('.file-row');
if (!candidateRow || candidateRow.dataset.type !== 'directory') {
continue;
}
if (drag?.paths?.includes(candidateRow.dataset.path)) {
continue;
}
if (!isValidPointerTargetPath(candidateRow.dataset.path, drag)) {
continue;
}
if (candidateRow.closest?.('.window') !== windowEl) {
continue;
}
row = candidateRow;
break;
} catch (error) {
recordDebug('closest.row.error', { error: serializeError(error), element: candidate?.tagName });
}
}
if (row) {
return { windowId: win.id, path: row.dataset.path, row };
}
return { windowId: win.id, path: win.data.path, row: null };
}
function markDraggingRows(paths, dragging) {
const pathSet = new Set(paths || []);
for (const row of workspace.querySelectorAll('.file-row')) {
if (pathSet.has(row.dataset.path)) {
row.classList.toggle('dragging', dragging);
}
}
}
function markDropCandidates(sourcePaths, active) {
const source = state.pointerDrag;
for (const row of workspace.querySelectorAll('.file-row[data-type="directory"]')) {
const path = row.dataset.path || '';
const valid = !sourcePaths.includes(path) && isValidPointerTargetPath(path, source);
row.classList.toggle('drop-candidate', active && valid);
}
}
function isValidPointerTargetPath(path, drag = state.pointerDrag) {
if (!drag) return false;
return (drag.items || []).every((item) => {
if (path === item.path) return false;
return !(item.type === 'directory' && isPathInside(path, item.path));
});
}
function lockExplorerScroll(win, body) {
win.data.lockScroll = true;
win.data.lockScrollTop = body.scrollTop;
win.data.lockScrollLeft = body.scrollLeft;
}
function restoreExplorerScroll(win, body) {
if (!body) {
body = workspace.querySelector(`.window[data-window-id="${win.id}"] .window-body`);
}
if (!body) return;
const top = win.data.lockScrollTop ?? win.data.scrollTop ?? 0;
const left = win.data.lockScrollLeft ?? win.data.scrollLeft ?? 0;
const restore = () => {
body.scrollTop = top;
body.scrollLeft = left;
};
restore();
requestAnimationFrame(restore);
setTimeout(restore, 0);
setTimeout(() => {
restore();
win.data.lockScroll = false;
win.data.scrollTop = body.scrollTop;
win.data.scrollLeft = body.scrollLeft;
}, 120);
}
function startDragWindow(event, win) {
if (event.target.closest('button') || win.maximized) return;
event.preventDefault();
const startX = event.clientX;
const startY = event.clientY;
const originX = win.x;
const originY = win.y;
const pointerId = event.pointerId;
event.currentTarget.setPointerCapture(pointerId);
const move = (moveEvent) => {
win.x = clamp(originX + moveEvent.clientX - startX, 0, Math.max(0, workspace.clientWidth - 120));
win.y = clamp(originY + moveEvent.clientY - startY, 0, Math.max(0, workspace.clientHeight - 80));
render();
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
}
function startResizeWindow(event, win) {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startY = event.clientY;
const originWidth = win.width;
const originHeight = win.height;
const move = (moveEvent) => {
win.width = Math.max(320, originWidth + moveEvent.clientX - startX);
win.height = Math.max(280, originHeight + moveEvent.clientY - startY);
render();
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
}
async function api(url, options) {
const response = await fetchChecked(url, options);
const text = await response.text();
try {
return JSON.parse(text);
} catch (error) {
recordDebug('api.json.error', {
url,
status: response.status,
contentType: response.headers.get('content-type'),
body: text.slice(0, 2000),
error: serializeError(error)
});
throw error;
}
}
async function fetchChecked(url, options) {
recordDebug('fetch.request', { url, method: options?.method || 'GET' });
const response = await fetch(url, options);
recordDebug('fetch.response', {
url,
status: response.status,
ok: response.ok,
contentType: response.headers.get('content-type')
});
if (!response.ok) {
let message = `${response.status} ${response.statusText}`;
const text = await response.text();
try {
message = JSON.parse(text).error || message;
} catch {}
recordDebug('fetch.error.body', { url, status: response.status, body: text.slice(0, 2000) });
throw new Error(message);
}
return response;
}
function windowTitle(win) {
if (win.kind === 'explorer') {
return `Explorer · ${win.data.path || ''}`;
}
if (win.kind === 'preview') {
return `Vorschau · ${win.data.entry?.name || ''}`;
}
return win.title;
}
function parentPath(value) {
const parts = String(value || '').split('/').filter(Boolean);
if (parts.length <= 1) return value;
return `/${parts.slice(0, -1).join('/')}`;
}
function dirname(value) {
const parts = String(value || '').split('/').filter(Boolean);
if (parts.length <= 1) return '/';
return `/${parts.slice(0, -1).join('/')}`;
}
function basename(value) {
const parts = String(value || '').split('/').filter(Boolean);
return parts[parts.length - 1] || '';
}
function extensionOf(name) {
const value = String(name || '');
const index = value.lastIndexOf('.');
return index >= 0 ? value.slice(index + 1).toLowerCase() : '';
}
function isPreviewable(entry) {
if (entry?.type !== 'file') return false;
const name = String(entry.name || entry.path || '').toLowerCase();
return previewFilenames.has(name) || previewExtensions.has(extensionOf(name));
}
function joinPath(parent, name) {
return `${String(parent || '').replace(/\/+$/, '')}/${String(name || '').replace(/^\/+/, '')}`;
}
function normalizePath(value) {
const parts = String(value || '').split('/').filter(Boolean);
return `/${parts.join('/')}`;
}
function isPathInside(path, parent) {
const normalizedPath = normalizePath(path);
const normalizedParent = normalizePath(parent);
return normalizedPath.startsWith(`${normalizedParent.replace(/\/$/, '')}/`);
}
function formatBytes(bytes) {
const value = Number(bytes || 0);
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
if (value < 1024 * 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB`;
return `${(value / 1024 / 1024 / 1024).toFixed(1)} GB`;
}
function directorySizeLabel(entry) {
const result = state.sizeResults.get(entry.path);
if (!result) {
return state.sizeJobs.has(entry.path) ? 'Berechne...' : 'Noch nicht berechnet';
}
const files = Number(result.files || 0);
const directories = Math.max(0, Number(result.directories || 0) - 1);
return `${formatBytes(result.size)} · ${files} Dateien · ${directories} Ordner`;
}
function formatPrincipal(name, id) {
if (name == null && id == null) return '-';
if (name == null || String(name) === String(id)) return String(id);
if (id == null) return String(name);
return `${name} (${id})`;
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
})[char]);
}
function escapeAttr(value) {
return escapeHtml(value);
}
function cssEscape(value) {
if (window.CSS?.escape) {
return window.CSS.escape(String(value));
}
return String(value).replace(/["\\]/g, '\\$&');
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function apiUrl(path) {
return `${API_BASE.replace(/\/$/, '')}/${path.replace(/^\//, '')}`;
}
function appendCsrf(body) {
const token = getCsrfToken();
if (!token) {
recordDebug('csrf.missing', {});
return;
}
body.append('csrf_token', token);
recordDebug('csrf.attached', { length: token.length });
}
function getCsrfToken() {
return window.csrf_token
|| document.querySelector('input[name="csrf_token"]')?.value
|| document.querySelector('meta[name="csrf_token"]')?.content
|| document.querySelector('meta[name="csrf-token"]')?.content
|| '';
}
function recordDebug(label, data = {}) {
if (!state.config?.debug) {
return null;
}
const item = {
id: state.nextDebugId++,
time: new Date().toLocaleTimeString(),
label,
data
};
state.debug.push(item);
if (state.debug.length > 80) {
state.debug.splice(0, state.debug.length - 80);
}
updateTransferPanel();
return item;
}
function updateTransferPanel() {
renderTransferPanel();
}
function serializeError(error) {
if (!error) return null;
if (error instanceof Error || typeof error === 'object') {
return {
name: error.name,
message: error.message || String(error),
stack: error.stack
};
}
return { message: String(error) };
}