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('back', 'Zurück', 'back')}
${iconButton('forward', 'Vor', 'forward')}
${iconButton('up', 'Übergeordneter Ordner', 'up')}
${iconButton('refresh', 'Neu laden', 'refresh')}
${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}
- Ausgewählt
- ${entries.length}
- Dateigröße
- ${formatBytes(size)}
`;
return;
}
const entry = entries[0];
pane.innerHTML = `
${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 `
`;
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;
}