Files
Unraid-Navigator/server/public/app.js
T

1389 lines
42 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,
draggedEntry: null,
pointerDrag: null,
suppressClickPath: null,
suppressItemActivationUntil: 0,
debug: [],
nextDebugId: 1,
jobs: new Map(),
jobRefresh: new Map(),
sizeJobs: new Map(),
sizeResults: new Map(),
transferPanelVisible: false,
transferPanelClosing: false,
transferAutoHideTimer: null,
settingsPanelVisible: false,
nextWindowId: 1,
zIndex: 20
};
const icons = {
directory: 'Folder',
file: 'File',
symlink: 'Link',
other: 'Item'
};
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) });
});
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,
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 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());
}
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);
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>
<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')}
</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'));
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', () => {
if (shouldSuppressItemActivation()) return;
selectEntry(win, entry, body);
});
row.addEventListener('dblclick', () => {
if (shouldSuppressItemActivation()) return;
if (entry.type === 'directory') {
loadExplorer(win, entry.path);
}
});
row.addEventListener('contextmenu', (event) => {
event.preventDefault();
event.stopPropagation();
selectEntry(win, entry);
showContextMenu(event, win, entry);
});
}
}
function renderFileEntries(entries, view) {
if (!entries.length) {
return '<p class="muted">Dieser Ordner ist leer. Dateien können hier abgelegt werden.</p>';
}
return view === 'icons' ? renderFileIcons(entries) : renderFileTable(entries);
}
function renderFileTable(entries) {
const rows = entries.map((entry) => `
<tr class="file-row ${entry.path === state.selectedEntry?.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) {
const items = entries.map((entry) => `
<button type="button" class="file-row icon-item ${entry.path === state.selectedEntry?.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 renderQueue(body) {
body.innerHTML = queueMarkup();
bindQueueActions(body);
}
function queueMarkup() {
const jobs = [...state.jobs.values()].reverse();
const jobMarkup = jobs.length ? 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>
`).join('') : '<p class="muted">Keine Transfers.</p>';
const debugSection = state.config?.debug ? debugMarkup() : '';
return `
<div class="queue-list">${jobMarkup}</div>
${debugSection}
`;
}
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() {
return [...state.jobs.values()].filter((job) => job.type !== 'size' && ['queued', 'running', 'cancel_requested'].includes(job.status)).length;
}
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;
} catch (error) {
win.data.error = error.message;
} finally {
win.data.loading = false;
render();
}
}
async function handleDrop(event, win, targetPath) {
recordDebug('html.drop', {
targetPath,
types: dataTransferTypes(event),
files: getDroppedFiles(event).map((file) => ({ name: file.name, size: file.size }))
});
if (state.config.readOnly) {
showError(new Error('Read-only mode ist aktiv.'));
return;
}
const files = getDroppedFiles(event);
if (files.length) {
const form = new FormData();
for (const file of files) {
form.append('files', file, file.name);
}
appendCsrf(form);
await fetchChecked(apiUrl(`upload.php?path=${encodeURIComponent(targetPath)}`), {
method: 'POST',
body: form
});
await loadExplorer(win, targetPath);
}
}
async function createJob(type, source, destination, refreshWindowIds = []) {
if (['move', 'copy'].includes(type) && isReadOnly()) {
showError(new Error('Read-only mode ist aktiv.'));
return null;
}
recordDebug('job.create.request', { type, source, destination, refreshWindowIds });
const body = new URLSearchParams();
body.set('source', source);
body.set('destination', destination);
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;
}
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}`, []);
}
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)]);
}
actions.push(['Eigenschaften', () => showPropertiesForEntry(win, entry)]);
if (!isReadOnly()) {
actions.push(['Umbenennen', () => renameEntry(win, entry)]);
actions.push(['Kopieren nach...', () => promptTransfer(win, entry, 'copy')]);
actions.push(['Verschieben nach...', () => promptTransfer(win, entry, 'move')]);
}
} else {
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) {
if (state.suppressClickPath === entry.path) {
state.suppressClickPath = null;
restoreExplorerScroll(win, scrollBody);
return;
}
win.data.selectedPath = entry.path;
state.selectedEntry = entry;
focusWindow(win);
updateSelectedRows();
updatePropertiesWindows();
restoreExplorerScroll(win, scrollBody);
}
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 row of workspace.querySelectorAll('.file-row')) {
row.classList.toggle('selected', row.dataset.path === state.selectedEntry?.path);
}
}
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;
recordDebug('pointer.start', {
windowId: win.id,
path: entry.path,
type: entry.type,
x: event.clientX,
y: event.clientY
});
state.pointerDrag = {
sourceWindowId: win.id,
path: entry.path,
name: entry.name,
type: entry.type,
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');
markDraggingRow(drag.path, true);
markDropCandidates(drag.path, 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;
markDraggingRow(drag.path, false);
markDropCandidates(drag.path, 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,
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 destination = joinPath(target.path, drag.name);
recordDebug('pointer.destination', {
source: drag.path,
targetPath: target.path,
destination,
targetIsRow: Boolean(target.row),
copy: event.altKey
});
if (destination === drag.path || target.path === drag.path) {
throw new Error('Quelle und Ziel sind identisch.');
}
if (drag.type === 'directory' && isPathInside(target.path, drag.path)) {
throw new Error('Ein Ordner kann nicht in sich selbst verschoben werden.');
}
await createJob(event.altKey ? 'copy' : 'move', drag.path, destination, [drag.sourceWindowId, target.windowId]);
} catch (error) {
showError(error);
}
}
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 (candidateRow.dataset.path === drag?.path) {
continue;
}
if (drag?.type === 'directory' && candidateRow.dataset.path.startsWith(`${drag.path.replace(/\/$/, '')}/`)) {
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 markDraggingRow(path, dragging) {
for (const row of workspace.querySelectorAll('.file-row')) {
if (row.dataset.path === path) {
row.classList.toggle('dragging', dragging);
}
}
}
function markDropCandidates(sourcePath, active) {
const source = state.pointerDrag;
for (const row of workspace.querySelectorAll('.file-row[data-type="directory"]')) {
const path = row.dataset.path || '';
const valid = path !== sourcePath && !(source?.type === 'directory' && path.startsWith(`${sourcePath.replace(/\/$/, '')}/`));
row.classList.toggle('drop-candidate', active && valid);
}
}
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 || ''}`;
}
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 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 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) };
}