const root = document.querySelector('.u-nav') || document.querySelector('.app-shell'); const API_BASE = window.UNAV_API_BASE || '/api'; const PHP_API = Boolean(window.UNAV_API_BASE); const APP_VERSION = window.UNAV_VERSION || 'dev'; const state = { config: null, path: '', entries: [], loading: true, error: null, selected: new Set(), anchor: null, query: '', view: localStorage.getItem('unav-view') || 'list', sort: { key: 'name', direction: 'asc' }, history: [], historyIndex: -1, detailsOpen: localStorage.getItem('unav-details') !== 'closed', sidebarOpen: true, drawer: null, preview: null, previewLoading: false, jobs: new Map(), uploads: [], context: null, status: 'Initialisiere …' }; const iconPaths = { logo: '', back: '', forward: '', up: '', refresh: '', search: '', upload: '', folderUpload: '', list: '', grid: '', info: '', transfer: '', settings: '', more: '', close: '', folder: '', file: '', link: '', image: '', video: '', music: '', archive: '', code: '', download: '', edit: '', copy: '', move: '', trash: '', check: '', chevron: '' }; const previewable = new Set(['txt', 'log', 'md', 'json', 'xml', 'yaml', 'yml', 'ini', 'cfg', 'conf', 'toml', 'csv', 'tsv', 'sh', 'js', 'ts', 'css', 'html', 'php', 'py', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'mp3', 'flac', 'm4a', 'ogg', 'wav', 'mp4', 'mkv', 'webm', 'mov']); init(); async function init() { renderShell(); bindShell(); try { state.config = await api(endpoint('config')); if (!state.config.roots?.length) throw new Error('Kein verfügbarer Root-Pfad'); renderSidebar(); await navigate(state.config.roots[0].path, { replace: true }); } catch (error) { state.loading = false; state.error = error.message; state.status = 'Verbindung fehlgeschlagen'; renderContent(); } } function renderShell() { root.classList.add('u-nav'); root.innerHTML = `
${iconButton('list', 'Listenansicht', 'view-list')} ${iconButton('grid', 'Symbolansicht', 'view-grid')} ${iconButton('info', 'Details', 'details')}
`; } function bindShell() { root.addEventListener('click', handleAction); root.addEventListener('keydown', handleKeydown); root.querySelector('#searchInput').addEventListener('input', (event) => { state.query = event.target.value; renderFiles(); }); root.querySelector('#fileUpload').addEventListener('change', (event) => uploadFiles([...event.target.files])); root.querySelector('#folderUpload').addEventListener('change', (event) => uploadFiles([...event.target.files])); document.addEventListener('click', closeContext); } async function handleAction(event) { const actionElement = event.target.closest('[data-action]'); if (!actionElement || actionElement.disabled) return; const action = actionElement.dataset.action; if (action === 'home') return navigate(state.config?.roots?.[0]?.path); if (action === 'back') return goHistory(-1); if (action === 'forward') return goHistory(1); if (action === 'up') { const currentRoot = activeRoot(); if (currentRoot && state.path !== currentRoot.path) return navigate(parentPath(state.path)); return; } if (action === 'refresh') return navigate(state.path, { replace: true }); if (action === 'path') return navigate(actionElement.dataset.path); if (action === 'root') return navigate(actionElement.dataset.path); if (action === 'view-list') return setView('list'); if (action === 'view-grid') return setView('grid'); if (action === 'details') return toggleDetails(); if (action === 'transfers') return toggleDrawer('transfers'); if (action === 'settings') return toggleDrawer('settings'); if (action === 'close-drawer') return toggleDrawer(null); if (action === 'upload') return root.querySelector('#fileUpload').click(); if (action === 'upload-folder') return root.querySelector('#folderUpload').click(); if (action === 'download') return downloadSelected(); if (action === 'rename') return renameSelected(); if (action === 'copy') return transferSelected('copy'); if (action === 'move') return transferSelected('move'); if (action === 'delete') return deleteSelected(); if (action === 'open') return activateEntry(findEntry(actionElement.dataset.path)); if (action === 'clear-selection') return clearSelection(); if (action === 'save-settings') return saveSettings(actionElement.closest('form')); } function handleKeydown(event) { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); root.querySelector('#searchInput').focus(); } if (event.key === 'Escape') { state.drawer = null; state.context = null; renderDrawer(); closeContext(); } if (event.key === 'Backspace' && !isTypingTarget(event.target)) { event.preventDefault(); goHistory(-1); } } async function navigate(path, options = {}) { if (!path) return; state.loading = true; state.error = null; state.preview = null; state.selected.clear(); state.anchor = null; state.status = 'Ordner wird geladen …'; renderContent(); try { const result = await api(endpoint('list', { path })); state.path = result.path; state.entries = result.entries || []; state.status = `${state.entries.length} Elemente`; if (options.replace && state.historyIndex >= 0) { state.history[state.historyIndex] = state.path; } else if (!options.history) { state.history = state.history.slice(0, state.historyIndex + 1); if (state.history.at(-1) !== state.path) state.history.push(state.path); state.historyIndex = state.history.length - 1; } } catch (error) { state.error = error.message; state.status = 'Ordner konnte nicht geladen werden'; } finally { state.loading = false; renderContent(); } } function goHistory(offset) { const next = state.historyIndex + offset; if (next < 0 || next >= state.history.length) return; state.historyIndex = next; navigate(state.history[next], { history: true }); } function renderContent() { renderSidebar(); renderBreadcrumbs(); renderFiles(); renderDetails(); renderStatus(); updateControls(); } function renderSidebar() { const sidebar = root.querySelector('#sidebar'); if (!sidebar || !state.config) return; const roots = state.config.roots || []; sidebar.innerHTML = ` `; } function renderBreadcrumbs() { const host = root.querySelector('#breadcrumbs'); if (!host) return; const rootEntry = (state.config?.roots || []).find((item) => isInside(state.path, item.path)); if (!rootEntry) { host.innerHTML = ``; return; } const relative = state.path.slice(rootEntry.path.length).split('/').filter(Boolean); const parts = [{ name: rootName(rootEntry), path: rootEntry.path }]; let current = rootEntry.path; for (const name of relative) { current = joinPath(current, name); parts.push({ name, path: current }); } host.innerHTML = parts.map((part, index) => ` ${index ? '' : ''} `).join(''); } function renderFiles() { const host = root.querySelector('#filePane'); if (!host) return; if (state.loading) { host.innerHTML = `
Ordner wird geladenEinen kleinen Moment …
`; return; } if (state.error) { host.innerHTML = `
${icon('folder')}Dieser Ordner ist nicht erreichbar${escapeHtml(state.error)}
`; return; } const entries = visibleEntries(); if (!entries.length) { const filtered = Boolean(state.query); host.innerHTML = `
${icon(filtered ? 'search' : 'folder')}${filtered ? 'Keine Treffer' : 'Dieser Ordner ist leer'}${filtered ? 'Versuche einen anderen Suchbegriff.' : 'Ziehe Dateien hierher oder verwende „Hochladen“.'}
`; return; } host.className = `file-pane ${state.view}-view`; host.innerHTML = state.view === 'grid' ? gridMarkup(entries) : listMarkup(entries); bindEntryEvents(host); } function listMarkup(entries) { return `
${columnHeader('name', 'Name')}${columnHeader('size', 'Größe')}${columnHeader('type', 'Typ')}${columnHeader('modifiedAt', 'Geändert')}
${entries.map((entry) => `
${icon(fileIcon(entry))} ${highlight(entry.name, state.query)}${escapeHtml(entry.type === 'directory' ? 'Ordner' : extensionLabel(entry))}
${entry.type === 'file' ? formatBytes(entry.size) : '—'}
${escapeHtml(typeLabel(entry))}
${formatDate(entry.modifiedAt)}
`).join('')}
`; } function gridMarkup(entries) { return `
${entries.map((entry) => ` `).join('')}
`; } function bindEntryEvents(host) { host.querySelector('[data-select-all]')?.addEventListener('click', () => { const entries = visibleEntries(); if (state.selected.size === entries.length) state.selected.clear(); else entries.forEach((entry) => state.selected.add(entry.path)); selectionChanged(); }); host.querySelectorAll('[data-entry]').forEach((element) => { element.addEventListener('click', (event) => selectEntry(event, element.dataset.entry)); element.addEventListener('dblclick', () => activateEntry(findEntry(element.dataset.entry))); element.addEventListener('keydown', (event) => { if (event.key === 'Enter') activateEntry(findEntry(element.dataset.entry)); if (event.key === ' ') { event.preventDefault(); selectEntry(event, element.dataset.entry); } }); element.addEventListener('contextmenu', (event) => showContext(event, element.dataset.entry)); element.querySelector('.row-more')?.addEventListener('click', (event) => showContext(event, element.dataset.entry)); }); host.ondragover = (event) => { event.preventDefault(); host.classList.add('drag-over'); }; host.ondragleave = () => host.classList.remove('drag-over'); host.ondrop = (event) => { event.preventDefault(); host.classList.remove('drag-over'); if (event.dataTransfer?.files?.length) uploadFiles([...event.dataTransfer.files]); }; } function selectEntry(event, path) { if (event.target.closest('.row-more')) return; const entries = visibleEntries(); if (event.shiftKey && state.anchor) { const start = entries.findIndex((entry) => entry.path === state.anchor); const end = entries.findIndex((entry) => entry.path === path); if (start >= 0 && end >= 0) { if (!event.metaKey && !event.ctrlKey) state.selected.clear(); entries.slice(Math.min(start, end), Math.max(start, end) + 1).forEach((entry) => state.selected.add(entry.path)); } } else if (event.metaKey || event.ctrlKey || event.target.closest('.row-check')) { if (state.selected.has(path)) state.selected.delete(path); else state.selected.add(path); state.anchor = path; } else { state.selected.clear(); state.selected.add(path); state.anchor = path; } state.preview = null; selectionChanged(); } function selectionChanged() { renderFiles(); renderDetails(); renderStatus(); updateControls(); } function clearSelection() { state.selected.clear(); selectionChanged(); } async function activateEntry(entry) { if (!entry) return; if (entry.type === 'directory') return navigate(entry.path); state.selected.clear(); state.selected.add(entry.path); state.detailsOpen = true; localStorage.setItem('unav-details', 'open'); selectionChanged(); if (PHP_API && isPreviewable(entry)) await loadPreview(entry); } async function loadPreview(entry) { state.previewLoading = true; renderDetails(); try { state.preview = await api(endpoint('preview', { path: entry.path })); } catch (error) { state.preview = { error: error.message }; } finally { state.previewLoading = false; renderDetails(); } } function renderDetails() { const pane = root.querySelector('#detailsPane'); if (!pane) return; pane.classList.toggle('closed', !state.detailsOpen); if (!state.detailsOpen) { pane.innerHTML = ''; return; } const entries = selectedEntries(); if (!entries.length) { pane.innerHTML = `
${icon('info')}DetailsWähle eine Datei oder einen Ordner aus.
`; return; } if (entries.length > 1) { const size = entries.reduce((sum, entry) => sum + Number(entry.size || 0), 0); pane.innerHTML = `
${entries.length} Elemente${iconButton('close', 'Details schließen', 'details')}
${entries.length}
Ausgewählt
${entries.length}
Dateigröße
${formatBytes(size)}
`; return; } const entry = entries[0]; pane.innerHTML = `
Details${iconButton('close', 'Details schließen', 'details')}
${previewMarkup(entry)}
${icon(fileIcon(entry))}
${escapeHtml(entry.name)}${escapeHtml(typeLabel(entry))}
Größe
${entry.type === 'file' ? formatBytes(entry.size) : 'Ordner'}
Geändert
${formatDate(entry.modifiedAt, true)}
${entry.owner != null ? `
Besitzer
${escapeHtml(entry.owner)}
` : ''} ${entry.group != null ? `
Gruppe
${escapeHtml(entry.group)}
` : ''} ${entry.permissions ? `
Rechte
${escapeHtml(entry.permissions)}
` : ''}
Pfad
${escapeHtml(entry.path)}
${PHP_API && isPreviewable(entry) && !state.preview ? `` : ''} `; } function previewMarkup(entry) { if (state.previewLoading) return ''; if (!state.preview) return `${icon(fileIcon(entry))}`; if (state.preview.error) return `${escapeHtml(state.preview.error)}`; const data = state.preview; const raw = endpointRaw(data.rawUrl); if (data.type === 'image') return `${escapeAttr(entry.name)}`; if (data.type === 'audio') return ``; if (data.type === 'video') return ``; if (data.type === 'pdf') return ``; return `
${escapeHtml(String(data.content || '').slice(0, 6000))}
`; } function renderStatus() { root.querySelector('#statusText').textContent = state.status; const selected = selectedEntries(); root.querySelector('#selectionStatus').textContent = selected.length ? `${selected.length} ausgewählt · ${formatBytes(selected.reduce((sum, item) => sum + Number(item.size || 0), 0))}` : ''; root.querySelector('#itemCount').textContent = `${visibleEntries().length} ${visibleEntries().length === 1 ? 'Element' : 'Elemente'}`; } function updateControls() { const selected = selectedEntries(); root.querySelectorAll('.selection-action').forEach((button) => { button.disabled = !selected.length; }); root.querySelector('[data-action="rename"]').disabled = selected.length !== 1 || isReadOnly(); for (const action of ['copy', 'move', 'delete']) root.querySelector(`[data-action="${action}"]`).disabled = !selected.length || isReadOnly(); root.querySelector('[data-action="upload"]').disabled = isReadOnly(); root.querySelector('[data-action="upload-folder"]').disabled = isReadOnly(); root.querySelector('[data-action="back"]').disabled = state.historyIndex <= 0; root.querySelector('[data-action="forward"]').disabled = state.historyIndex >= state.history.length - 1; root.querySelector('[data-action="up"]').disabled = !activeRoot() || state.path === activeRoot().path; root.querySelector('[data-action="view-list"]').classList.toggle('active', state.view === 'list'); root.querySelector('[data-action="view-grid"]').classList.toggle('active', state.view === 'grid'); root.querySelector('[data-action="details"]').classList.toggle('active', state.detailsOpen); } function setView(view) { state.view = view; localStorage.setItem('unav-view', view); renderFiles(); updateControls(); } function toggleDetails() { state.detailsOpen = !state.detailsOpen; localStorage.setItem('unav-details', state.detailsOpen ? 'open' : 'closed'); renderDetails(); updateControls(); } function toggleDrawer(name) { state.drawer = state.drawer === name ? null : name; renderDrawer(); } function renderDrawer() { const host = root.querySelector('#drawerHost'); if (!state.drawer) { host.innerHTML = ''; return; } if (state.drawer === 'transfers') { const items = [...state.uploads].reverse().concat([...state.jobs.values()].reverse()); host.innerHTML = drawerMarkup('Transfers', items.length ? items.map(transferMarkup).join('') : '
Keine Transfers in dieser Sitzung.
'); } else { host.innerHTML = drawerMarkup('Einstellungen', `
`); } } function drawerMarkup(title, content) { return `
`; } function transferMarkup(item) { const progress = Math.max(0, Math.min(100, Number(item.progress || 0))); return `
${icon(item.type === 'upload' ? 'upload' : 'transfer')}
${escapeHtml(item.name || `${item.type} #${item.id}`)}${escapeHtml(item.message || item.status || '')}
${progress}%
`; } async function saveSettings(form) { const body = new URLSearchParams({ readOnly: form.elements.write.checked ? '0' : '1', debug: form.elements.debug.checked ? '1' : '0' }); appendCsrf(body); try { state.config = await api(endpoint('config'), { method: 'POST', headers: formHeaders(), body }); state.status = 'Einstellungen gespeichert'; state.drawer = null; renderContent(); renderDrawer(); } catch (error) { showToast(error.message, 'error'); } } async function uploadFiles(files) { if (!files.length || isReadOnly()) return; const input = root.querySelector('#fileUpload'); const folderInput = root.querySelector('#folderUpload'); const transfer = { id: Date.now(), type: 'upload', name: files.length === 1 ? files[0].name : `${files.length} Dateien`, status: 'Wird hochgeladen', message: state.path, progress: 5 }; state.uploads.push(transfer); updateTransferBadge(); state.drawer = 'transfers'; renderDrawer(); const form = new FormData(); files.forEach((file) => { form.append(PHP_API ? 'files[]' : 'files', file, file.name); form.append('paths[]', file.webkitRelativePath || file.name); }); const token = getCsrfToken(); if (token) form.append('csrf_token', token); try { await api(endpoint('upload', { path: state.path }), { method: 'POST', body: form }); transfer.progress = 100; transfer.status = 'Abgeschlossen'; showToast(`${files.length} ${files.length === 1 ? 'Datei' : 'Dateien'} hochgeladen`); await navigate(state.path, { replace: true }); } catch (error) { transfer.status = 'Fehlgeschlagen'; transfer.message = error.message; showToast(error.message, 'error'); } finally { input.value = ''; folderInput.value = ''; updateTransferBadge(); renderDrawer(); } } function downloadSelected() { if (!PHP_API) return showToast('Downloads stehen nur im Unraid-Plugin zur Verfügung.', 'error'); const paths = selectedEntries().map((entry) => entry.path); if (!paths.length) return; const query = paths.length === 1 ? `path=${encodeURIComponent(paths[0])}` : `paths=${encodeURIComponent(JSON.stringify(paths))}`; const link = document.createElement('a'); link.href = `${API_BASE}/download.php?${query}`; link.click(); } async function renameSelected() { const entry = selectedEntries()[0]; if (!entry) return; const next = await promptDialog('Umbenennen', 'Neuer Name', entry.name); if (!next || next === entry.name) return; if (next.includes('/') || next.includes('\\') || next === '.' || next === '..' || next.includes('\0')) { return showToast('Der Dateiname enthält ungültige Zeichen.', 'error'); } await createJob('move', entry.path, joinPath(parentPath(entry.path), next)); } async function transferSelected(type) { const entries = selectedEntries(); const destination = await promptDialog(type === 'copy' ? 'Kopieren' : 'Verschieben', 'Zielordner', state.path); if (!destination) return; for (const entry of entries) await createJob(type, entry.path, joinPath(destination, entry.name)); } async function deleteSelected() { const entries = selectedEntries(); if (!entries.length) return; const confirmed = await confirmDialog('Endgültig löschen?', entries.length === 1 ? `„${entries[0].name}“ wird direkt vom Dateisystem gelöscht.` : `${entries.length} Elemente werden direkt vom Dateisystem gelöscht.`); if (!confirmed) return; for (const entry of entries) await createJob('delete', entry.path); } async function createJob(type, source, destination = null) { const body = new URLSearchParams({ source }); if (destination) body.set('destination', destination); appendCsrf(body); try { const job = await api(endpoint('job', { action: type }), { method: 'POST', headers: formHeaders(), body }); state.jobs.set(job.id, job); state.drawer = 'transfers'; renderDrawer(); updateTransferBadge(); pollJob(job.id); } catch (error) { showToast(error.message, 'error'); } } async function pollJob(id) { try { const job = await api(endpoint('job', { id })); state.jobs.set(id, job); renderDrawer(); updateTransferBadge(); if (['queued', 'running', 'cancel_requested'].includes(job.status)) setTimeout(() => pollJob(id), 700); else { if (job.status === 'completed') { showToast(`${typeLabel({ type: job.type })} abgeschlossen`); await navigate(state.path, { replace: true }); } else showToast(job.error || job.message || 'Dateioperation fehlgeschlagen', 'error'); } } catch (error) { showToast(error.message, 'error'); } } function updateTransferBadge() { const active = [...state.jobs.values()].filter((item) => ['queued', 'running'].includes(item.status)).length + state.uploads.filter((item) => item.progress < 100 && item.status !== 'Fehlgeschlagen').length; const badge = root.querySelector('#transferBadge'); badge.hidden = !active; badge.textContent = active; } function showContext(event, path) { event.preventDefault(); event.stopPropagation(); if (!state.selected.has(path)) { state.selected.clear(); state.selected.add(path); selectionChanged(); } const host = root.querySelector('#contextHost'); const entry = findEntry(path); host.innerHTML = `
`; } function closeContext(event) { if (event?.target.closest('.context-menu, .row-more')) return; const host = root.querySelector('#contextHost'); if (host) host.innerHTML = ''; } function showToast(message, type = 'success') { const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.innerHTML = `${icon(type === 'error' ? 'info' : 'check')}${escapeHtml(message)}`; root.appendChild(toast); requestAnimationFrame(() => toast.classList.add('visible')); setTimeout(() => { toast.classList.remove('visible'); setTimeout(() => toast.remove(), 250); }, 3200); } function confirmDialog(title, message) { return dialog({ title, message, confirm: 'Löschen', danger: true }); } function promptDialog(title, label, value) { return dialog({ title, message: label, value, prompt: true, confirm: 'Übernehmen' }); } function dialog(options) { return new Promise((resolve) => { const host = root.querySelector('#dialogHost'); host.innerHTML = ``; const form = host.querySelector('form'); const close = (value) => { host.innerHTML = ''; resolve(value); }; form.addEventListener('submit', (event) => { event.preventDefault(); close(options.prompt ? form.elements.value.value.trim() : true); }); host.querySelector('[data-cancel]').addEventListener('click', () => close(options.prompt ? null : false)); host.querySelector('.modal-scrim').addEventListener('click', (event) => { if (event.target === event.currentTarget) close(options.prompt ? null : false); }); setTimeout(() => { const input = form.elements.value; if (input) { input.focus(); input.select(); } }, 0); }); } function visibleEntries() { const needle = state.query.trim().toLocaleLowerCase('de'); const result = needle ? state.entries.filter((entry) => entry.name.toLocaleLowerCase('de').includes(needle)) : [...state.entries]; const { key, direction } = state.sort; result.sort((a, b) => { if (key === 'name' && a.type === 'directory' !== (b.type === 'directory')) return a.type === 'directory' ? -1 : 1; let left = a[key] ?? ''; let right = b[key] ?? ''; if (key === 'size') { left = Number(left); right = Number(right); } const compared = typeof left === 'number' ? left - right : String(left).localeCompare(String(right), 'de', { numeric: true, sensitivity: 'base' }); return direction === 'asc' ? compared : -compared; }); return result; } function columnHeader(key, label) { const active = state.sort.key === key; return ``; } root.addEventListener('click', (event) => { const button = event.target.closest('[data-sort]'); if (!button) return; const key = button.dataset.sort; state.sort = { key, direction: state.sort.key === key && state.sort.direction === 'asc' ? 'desc' : 'asc' }; renderFiles(); }); function selectedEntries() { return state.entries.filter((entry) => state.selected.has(entry.path)); } function findEntry(path) { return state.entries.find((entry) => entry.path === path); } function isReadOnly() { return Boolean(state.config?.readOnly); } function isPreviewable(entry) { return entry?.type === 'file' && previewable.has(extension(entry.name)); } function activeRoot() { return (state.config?.roots || []).find((item) => isInside(state.path, item.path)) || null; } function quickPlaces() { const rootEntry = (state.config?.roots || []).find((item) => isInside(state.path, item.path)); if (!rootEntry || state.path === rootEntry.path) return []; const pieces = state.path.slice(rootEntry.path.length).split('/').filter(Boolean); let current = rootEntry.path; return pieces.slice(0, -1).map((name) => ({ name, path: current = joinPath(current, name) })).slice(-4).reverse(); } function fileIcon(entry) { if (entry.type === 'directory') return 'folder'; if (entry.type === 'symlink') return 'link'; const ext = extension(entry.name); if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) return 'image'; if (['mp4', 'mkv', 'webm', 'mov'].includes(ext)) return 'video'; if (['mp3', 'flac', 'm4a', 'ogg', 'wav'].includes(ext)) return 'music'; if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(ext)) return 'archive'; if (['js', 'ts', 'css', 'html', 'php', 'py', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'json', 'xml', 'yaml', 'yml', 'sh'].includes(ext)) return 'code'; return 'file'; } function fileTone(entry) { return `file-${fileIcon(entry)}`; } function typeLabel(entry) { if (entry.type === 'directory') return 'Ordner'; if (entry.type === 'symlink') return 'Verknüpfung'; if (entry.type === 'copy') return 'Kopieren'; if (entry.type === 'move') return 'Verschieben'; if (entry.type === 'delete') return 'Löschen'; return extensionLabel(entry); } function extensionLabel(entry) { const ext = extension(entry.name); return ext ? `${ext.toUpperCase()}-Datei` : 'Datei'; } function extension(name) { const index = String(name).lastIndexOf('.'); return index > 0 ? String(name).slice(index + 1).toLowerCase() : ''; } function rootName(item) { return item.label && item.label !== item.path ? item.label : basename(item.path) || 'Dateien'; } function basename(path) { return String(path || '').split('/').filter(Boolean).at(-1) || '/'; } function parentPath(path) { const parts = String(path || '').split('/').filter(Boolean); return parts.length <= 1 ? '/' : `/${parts.slice(0, -1).join('/')}`; } function joinPath(parent, name) { return `${String(parent).replace(/\/+$/, '')}/${String(name).replace(/^\/+/, '')}`; } function isInside(path, parent) { return path === parent || String(path || '').startsWith(`${String(parent || '').replace(/\/$/, '')}/`); } function formatBytes(bytes) { const value = Number(bytes || 0); if (!value) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1); return `${new Intl.NumberFormat('de-DE', { maximumFractionDigits: index ? 1 : 0 }).format(value / (1024 ** index))} ${units[index]}`; } function formatDate(value, full = false) { const date = new Date(value); if (Number.isNaN(date.getTime())) return '—'; return new Intl.DateTimeFormat('de-DE', full ? { dateStyle: 'medium', timeStyle: 'short' } : { day: '2-digit', month: 'short', year: date.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined, hour: '2-digit', minute: '2-digit' }).format(date); } function icon(name) { return ``; } function iconButton(name, label, action, extra = '') { return ``; } function highlight(value, query) { const safe = escapeHtml(value); if (!query.trim()) return safe; const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return safe.replace(new RegExp(`(${escaped})`, 'ig'), '$1'); } function escapeHtml(value) { return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]); } function escapeAttr(value) { return escapeHtml(value); } function isTypingTarget(element) { return ['INPUT', 'TEXTAREA', 'SELECT'].includes(element?.tagName) || element?.isContentEditable; } function endpoint(name, params = {}) { const query = new URLSearchParams(params).toString(); let path; if (PHP_API) path = `${name}.php`; else if (name === 'config') path = 'config'; else if (name === 'list') path = 'list'; else if (name === 'upload') path = 'upload'; else if (name === 'job' && params.action) return `${API_BASE}/jobs/${encodeURIComponent(params.action)}`; else if (name === 'job' && params.id) return `${API_BASE}/jobs/${encodeURIComponent(params.id)}`; else path = name; return `${API_BASE.replace(/\/$/, '')}/${path}${query ? `?${query}` : ''}`; } function endpointRaw(rawUrl) { return PHP_API ? `${API_BASE.replace(/\/$/, '')}/${String(rawUrl).replace(/^\//, '')}` : rawUrl; } function formHeaders() { return { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }; } function appendCsrf(body) { const token = getCsrfToken(); if (token) body.append('csrf_token', token); } function getCsrfToken() { return window.csrf_token || document.querySelector('input[name="csrf_token"]')?.value || document.querySelector('meta[name="csrf_token"]')?.content || ''; } async function api(url, options) { const response = await fetch(url, options); const text = await response.text(); let payload; try { payload = JSON.parse(text); } catch { payload = null; } if (!response.ok) throw new Error(payload?.error || text || `${response.status} ${response.statusText}`); if (!payload) throw new Error('Der Server hat keine gültige JSON-Antwort geliefert.'); return payload; }