const workspace = document.querySelector('#workspace'); const statusText = document.querySelector('#statusText'); const API_BASE = window.UNAV_API_BASE || '/api'; const state = { config: null, windows: [], focusedId: null, selectedEntry: null, contextMenu: null, draggedEntry: null, pointerDrag: null, suppressClickPath: null, jobs: new Map(), 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()); document.querySelector('#newQueueButton').addEventListener('click', () => openQueue()); 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'); } statusText.textContent = `${state.config.roots.length} Root, ${state.config.readOnly ? 'Read-only' : 'Schreibzugriff aktiv'}`; openExplorer(state.config.roots[0].path); openProperties(); openQueue(); } 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, loading: false, error: null } }); loadExplorer(win, initialPath); return win; } function openProperties() { return createWindow('Eigenschaften', 'properties', { width: 420, height: 320, x: 160, y: 90, data: {} }); } function openQueue() { return createWindow('Transfers', 'queue', { width: 440, height: 340, x: 250, y: 130, data: {} }); } 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()); } } 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 = ` ${escapeHtml(windowTitle(win))}
`; 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 === 'queue') renderQueue(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 = `
${data.error ? `

${escapeHtml(data.error)}

` : ''} ${data.loading ? '

Lade...

' : renderFileTable(data.entries || [])}
`; 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))); 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, event.altKey ? 'copy' : 'move'); 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.addEventListener('pointerdown', (event) => { lockExplorerScroll(win, body); startItemPointerDrag(event, win, entry); }); row.addEventListener('click', () => selectEntry(win, entry, body)); row.addEventListener('dblclick', () => { if (entry.type === 'directory') { loadExplorer(win, entry.path); } }); row.addEventListener('contextmenu', (event) => { event.preventDefault(); event.stopPropagation(); selectEntry(win, entry); showContextMenu(event, win, entry); }); } } function renderFileTable(entries) { if (!entries.length) { return '

Dieser Ordner ist leer. Dateien können hier abgelegt werden.

'; } const rows = entries.map((entry) => ` ${icons[entry.type] || 'Item'}${escapeHtml(entry.name)} ${entry.type} ${entry.type === 'file' ? formatBytes(entry.size) : ''} ${new Date(entry.modifiedAt).toLocaleString()} `).join(''); return ` ${rows}
NameTypGrößeGeändert
`; } function renderProperties(body) { const entry = state.selectedEntry; if (!entry) { body.innerHTML = '

Keine Datei ausgewählt.

'; return; } body.classList.add('properties'); body.innerHTML = `
Name
${escapeHtml(entry.name)}
Typ
${escapeHtml(entry.type)}
Pfad
${escapeHtml(entry.path)}
Größe
${formatBytes(entry.size)}
Geändert
${new Date(entry.modifiedAt).toLocaleString()}
`; } function renderQueue(body) { const jobs = [...state.jobs.values()].reverse(); if (!jobs.length) { body.innerHTML = '

Keine Transfers.

'; return; } body.innerHTML = `
${jobs.map((job) => `
${escapeHtml(job.type)} #${escapeHtml(job.id)}
${escapeHtml(job.status)} · ${escapeHtml(job.message || '')}
`).join('')}
`; } 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) { if (state.config.readOnly) { alert('Read-only mode ist aktiv.'); return; } const internalPath = state.draggedEntry?.path || ''; if (internalPath) { const name = internalPath.split('/').filter(Boolean).at(-1); const destination = `${targetPath.replace(/\/$/, '')}/${name}`; if (internalPath === destination) { throw new Error('Quelle und Ziel sind identisch.'); } await createJob(event.altKey ? 'copy' : 'move', internalPath, destination); await reloadExplorerWindows([win.id, state.draggedEntry?.sourceWindowId].filter(Boolean)); return; } const files = getDroppedFiles(event); if (files.length) { const form = new FormData(); for (const file of files) { form.append('files', file, file.name); } await fetchChecked(apiUrl(`upload.php?path=${encodeURIComponent(targetPath)}`), { method: 'POST', body: form }); await loadExplorer(win, targetPath); } } async function createJob(type, source, destination) { const job = await api(apiUrl(`job.php?action=${encodeURIComponent(type)}`), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source, destination }) }); state.jobs.set(job.id, job); pollJob(job.id); render(); } 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) { return Boolean(state.draggedEntry || getDroppedFiles(event).length); } function showError(error) { const message = error?.message || String(error); alert(message); } function getDroppedFiles(event) { try { return [...(event.dataTransfer?.files || [])]; } catch { return []; } } function setEffectAllowed(event) { try { event.dataTransfer.effectAllowed = 'copyMove'; } catch {} } function setDropEffect(event, effect) { try { event.dataTransfer.dropEffect = effect; } catch { // Browser rejected the requested cursor hint; the app-state drop still works. } } async function renameEntry(win, entry) { const nextName = prompt('Neuer Name', entry.name); if (!nextName || nextName === entry.name) { return; } if (nextName.includes('/')) { alert('Der Name darf keinen Slash enthalten.'); return; } await createJob('move', entry.path, `${dirname(entry.path)}/${nextName}`); await loadExplorer(win, win.data.path); } async function promptTransfer(win, entry, type) { 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}`); await loadExplorer(win, win.data.path); } 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(['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) { alert(error.message); } }); 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) { const job = await api(apiUrl(`job.php?id=${encodeURIComponent(id)}`)); state.jobs.set(id, job); render(); if (['queued', 'running', 'cancel_requested'].includes(job.status)) { setTimeout(() => pollJob(id), 700); } } 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 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) return; state.pointerDrag = { sourceWindowId: win.id, path: entry.path, name: entry.name, 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 }); } 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); 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); state.pointerDrag = null; if (!drag.active) { return; } state.suppressClickPath = drag.path; const target = findPointerDropTarget(event); if (!target) { return; } try { const destination = `${target.path.replace(/\/$/, '')}/${drag.name}`; if (destination === drag.path) { throw new Error('Quelle und Ziel sind identisch.'); } await createJob(event.altKey ? 'copy' : 'move', drag.path, destination); await reloadExplorerWindows([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) { const element = document.elementFromPoint(event.clientX, event.clientY); const windowEl = element?.closest?.('.window'); if (!windowEl) return null; const win = state.windows.find((item) => item.id === windowEl.dataset.windowId); if (!win || win.kind !== 'explorer') return null; const row = element.closest?.('.file-row'); if (row?.dataset.type === 'directory') { 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 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); return response.json(); } async function fetchChecked(url, options) { const response = await fetch(url, options); if (!response.ok) { let message = `${response.status} ${response.statusText}`; try { message = (await response.json()).error || message; } catch {} 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 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 escapeHtml(value) { return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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(/^\//, '')}`; }