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

861 lines
44 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 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: '<path d="M3 7.5h7l2-2h9v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path><path d="M3 10h18"></path>',
back: '<path d="m15 18-6-6 6-6"></path>',
forward: '<path d="m9 18 6-6-6-6"></path>',
up: '<path d="m6 15 6-6 6 6"></path>',
refresh: '<path d="M20 6v5h-5"></path><path d="M4 18v-5h5"></path><path d="M19 11a7 7 0 0 0-12-5L4 9"></path><path d="M5 13a7 7 0 0 0 12 5l3-3"></path>',
search: '<circle cx="11" cy="11" r="7"></circle><path d="m20 20-4-4"></path>',
upload: '<path d="M12 16V4"></path><path d="m7 9 5-5 5 5"></path><path d="M5 16v4h14v-4"></path>',
folderUpload: '<path d="M3 7h7l2 2h9v10H3Z"></path><path d="M12 17v-6"></path><path d="m9 14 3-3 3 3"></path>',
list: '<path d="M9 6h11M9 12h11M9 18h11"></path><path d="M4 6h.01M4 12h.01M4 18h.01"></path>',
grid: '<rect x="4" y="4" width="6" height="6" rx="1"></rect><rect x="14" y="4" width="6" height="6" rx="1"></rect><rect x="4" y="14" width="6" height="6" rx="1"></rect><rect x="14" y="14" width="6" height="6" rx="1"></rect>',
info: '<circle cx="12" cy="12" r="9"></circle><path d="M12 11v6M12 7h.01"></path>',
transfer: '<path d="M7 7h12"></path><path d="m15 3 4 4-4 4"></path><path d="M17 17H5"></path><path d="m9 13-4 4 4 4"></path>',
settings: '<circle cx="12" cy="12" r="3"></circle><path d="M19 15a2 2 0 0 0 .4 2.2l.1.1-2.2 2.2-.1-.1A2 2 0 0 0 15 19l-.4.2V21h-3.2v-1.8L11 19a2 2 0 0 0-2.2.4l-.1.1-2.2-2.2.1-.1A2 2 0 0 0 7 15l-.2-.4H5v-3.2h1.8L7 11a2 2 0 0 0-.4-2.2l-.1-.1 2.2-2.2.1.1A2 2 0 0 0 11 7l.4-.2V5h3.2v1.8l.4.2a2 2 0 0 0 2.2-.4l.1-.1 2.2 2.2-.1.1A2 2 0 0 0 19 11l.2.4H21v3.2h-1.8Z"></path>',
more: '<circle cx="5" cy="12" r="1"></circle><circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle>',
close: '<path d="M18 6 6 18M6 6l12 12"></path>',
folder: '<path d="M3 7h7l2 2h9v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>',
file: '<path d="M6 3h8l4 4v14H6Z"></path><path d="M14 3v5h5"></path>',
link: '<path d="M10 13a5 5 0 0 0 7.5.5l2-2a5 5 0 0 0-7-7l-1.1 1"></path><path d="M14 11a5 5 0 0 0-7.5-.5l-2 2a5 5 0 0 0 7 7l1.1-1"></path>',
image: '<rect x="3" y="4" width="18" height="16" rx="2"></rect><circle cx="8" cy="9" r="1.5"></circle><path d="m4 17 5-5 4 4 2-2 5 4"></path>',
video: '<rect x="3" y="5" width="18" height="14" rx="2"></rect><path d="m10 9 5 3-5 3Z"></path>',
music: '<path d="M9 18V5l10-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="16" cy="16" r="3"></circle>',
archive: '<path d="M5 3h14v18H5Z"></path><path d="M9 3v4h6V3M9 11h6"></path>',
code: '<path d="m8 9-4 3 4 3M16 9l4 3-4 3M14 5l-4 14"></path>',
download: '<path d="M12 4v11"></path><path d="m7 11 5 5 5-5"></path><path d="M5 20h14"></path>',
edit: '<path d="m4 20 4.5-1 10-10a2.1 2.1 0 0 0-3-3l-10 10Z"></path><path d="m14 7 3 3"></path>',
copy: '<rect x="8" y="8" width="12" height="12" rx="2"></rect><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"></path>',
move: '<path d="M5 12h14M15 8l4 4-4 4"></path>',
trash: '<path d="M4 7h16M9 7V4h6v3M7 7l1 14h8l1-14"></path>',
check: '<path d="m5 12 4 4L19 6"></path>',
chevron: '<path d="m9 18 6-6-6-6"></path>'
};
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 = `
<div class="unav-frame">
<header class="app-header">
<button class="brand" type="button" data-action="home" aria-label="U-Navigator Startseite">
<span class="brand-mark">${icon('logo')}</span>
<span><strong>U-Navigator</strong><small>File browser</small></span>
</button>
<div class="header-search">
${icon('search')}
<input id="searchInput" type="search" placeholder="In diesem Ordner suchen" autocomplete="off" aria-label="Dateien suchen">
<kbd>⌘ K</kbd>
</div>
<nav class="header-actions" aria-label="Anwendungsaktionen">
${iconButton('transfer', 'Transfers', 'transfers', '<span id="transferBadge" class="action-badge" hidden>0</span>')}
${iconButton('settings', 'Einstellungen', 'settings')}
</nav>
</header>
<div class="browser-shell">
<aside id="sidebar" class="sidebar" aria-label="Orte"></aside>
<main class="content-shell">
<div class="nav-bar">
<div class="history-actions">
${iconButton('back', 'Zurück', 'back')}
${iconButton('forward', 'Vor', 'forward')}
${iconButton('up', 'Übergeordneter Ordner', 'up')}
</div>
<nav id="breadcrumbs" class="breadcrumbs" aria-label="Pfad"></nav>
${iconButton('refresh', 'Neu laden', 'refresh')}
</div>
<div class="command-bar">
<div class="primary-actions">
<button class="action primary" type="button" data-action="upload">${icon('upload')}<span>Hochladen</span></button>
<button class="action" type="button" data-action="upload-folder">${icon('folderUpload')}<span>Ordner</span></button>
<span class="command-separator"></span>
<button class="action selection-action" type="button" data-action="download" disabled>${icon('download')}<span>Laden</span></button>
<button class="action selection-action" type="button" data-action="rename" disabled>${icon('edit')}<span>Umbenennen</span></button>
<button class="action selection-action" type="button" data-action="copy" disabled>${icon('copy')}<span>Kopieren</span></button>
<button class="action selection-action" type="button" data-action="move" disabled>${icon('move')}<span>Verschieben</span></button>
<button class="action danger selection-action" type="button" data-action="delete" disabled>${icon('trash')}<span>Löschen</span></button>
</div>
<div class="view-actions">
<span id="itemCount" class="item-count"></span>
${iconButton('list', 'Listenansicht', 'view-list')}
${iconButton('grid', 'Symbolansicht', 'view-grid')}
${iconButton('info', 'Details', 'details')}
</div>
</div>
<div class="explorer-stage">
<section id="filePane" class="file-pane" aria-label="Dateien"></section>
<aside id="detailsPane" class="details-pane" aria-label="Details"></aside>
</div>
<footer class="status-bar">
<span id="statusText">Initialisiere …</span>
<span id="selectionStatus"></span>
<span class="version">${escapeHtml(APP_VERSION)}</span>
</footer>
</main>
</div>
</div>
<input id="fileUpload" class="hidden-input" type="file" multiple>
<input id="folderUpload" class="hidden-input" type="file" multiple webkitdirectory directory>
<div id="drawerHost"></div>
<div id="dialogHost"></div>
<div id="contextHost"></div>
`;
}
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 = `
<div class="sidebar-section">
<div class="sidebar-label">Speicherorte</div>
${roots.map((item, index) => `
<button type="button" class="place ${isInside(state.path, item.path) ? 'active' : ''}" data-action="root" data-path="${escapeAttr(item.path)}">
<span class="place-icon tone-${(index % 4) + 1}">${icon('folder')}</span>
<span><strong>${escapeHtml(rootName(item))}</strong><small>${escapeHtml(item.path)}</small></span>
${isInside(state.path, item.path) ? icon('chevron') : ''}
</button>
`).join('')}
</div>
<div class="sidebar-section sidebar-shortcuts">
<div class="sidebar-label">Schnellzugriff</div>
${quickPlaces().map((item) => `
<button type="button" class="place compact ${state.path === item.path ? 'active' : ''}" data-action="root" data-path="${escapeAttr(item.path)}">
${icon('folder')}<span>${escapeHtml(item.name)}</span>
</button>
`).join('') || '<p class="sidebar-empty">Ordner erscheinen hier, sobald du navigierst.</p>'}
</div>
<div class="sidebar-foot">
<span class="mode-dot ${state.config.readOnly ? 'readonly' : 'writable'}"></span>
<span>${state.config.readOnly ? 'Nur Lesen' : 'Schreibzugriff'}</span>
</div>
`;
}
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 = `<button type="button" data-action="path" data-path="${escapeAttr(state.path)}">${escapeHtml(state.path || '…')}</button>`;
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 ? '<span class="crumb-separator"></span>' : ''}
<button type="button" data-action="path" data-path="${escapeAttr(part.path)}" ${index === parts.length - 1 ? 'aria-current="page"' : ''}>${escapeHtml(part.name)}</button>
`).join('');
}
function renderFiles() {
const host = root.querySelector('#filePane');
if (!host) return;
if (state.loading) {
host.innerHTML = `<div class="state-view"><span class="spinner"></span><strong>Ordner wird geladen</strong><span>Einen kleinen Moment …</span></div>`;
return;
}
if (state.error) {
host.innerHTML = `<div class="state-view error-state">${icon('folder')}<strong>Dieser Ordner ist nicht erreichbar</strong><span>${escapeHtml(state.error)}</span><button type="button" class="action primary" data-action="refresh">Erneut versuchen</button></div>`;
return;
}
const entries = visibleEntries();
if (!entries.length) {
const filtered = Boolean(state.query);
host.innerHTML = `<div class="state-view">${icon(filtered ? 'search' : 'folder')}<strong>${filtered ? 'Keine Treffer' : 'Dieser Ordner ist leer'}</strong><span>${filtered ? 'Versuche einen anderen Suchbegriff.' : 'Ziehe Dateien hierher oder verwende „Hochladen“.'}</span></div>`;
return;
}
host.className = `file-pane ${state.view}-view`;
host.innerHTML = state.view === 'grid' ? gridMarkup(entries) : listMarkup(entries);
bindEntryEvents(host);
}
function listMarkup(entries) {
return `
<div class="file-table" role="grid" aria-label="Dateiliste">
<div class="file-head" role="row">
<button type="button" class="select-all" aria-label="Alle auswählen" data-select-all>${state.selected.size && state.selected.size === visibleEntries().length ? icon('check') : ''}</button>
${columnHeader('name', 'Name')}${columnHeader('size', 'Größe')}${columnHeader('type', 'Typ')}${columnHeader('modifiedAt', 'Geändert')}
</div>
<div class="file-rows">
${entries.map((entry) => `
<div class="file-row ${state.selected.has(entry.path) ? 'selected' : ''}" role="row" tabindex="0" data-entry="${escapeAttr(entry.path)}">
<button type="button" class="row-check" aria-label="${escapeAttr(entry.name)} auswählen">${state.selected.has(entry.path) ? icon('check') : ''}</button>
<div class="name-cell" role="gridcell">
<span class="file-type-icon ${fileTone(entry)}">${icon(fileIcon(entry))}</span>
<span><strong>${highlight(entry.name, state.query)}</strong><small>${escapeHtml(entry.type === 'directory' ? 'Ordner' : extensionLabel(entry))}</small></span>
</div>
<div class="meta-cell" role="gridcell">${entry.type === 'file' ? formatBytes(entry.size) : '—'}</div>
<div class="meta-cell type-column" role="gridcell">${escapeHtml(typeLabel(entry))}</div>
<div class="meta-cell date-column" role="gridcell">${formatDate(entry.modifiedAt)}</div>
<button type="button" class="row-more" aria-label="Aktionen für ${escapeAttr(entry.name)}">${icon('more')}</button>
</div>
`).join('')}
</div>
</div>
`;
}
function gridMarkup(entries) {
return `<div class="file-grid">${entries.map((entry) => `
<button type="button" class="file-card ${state.selected.has(entry.path) ? 'selected' : ''}" data-entry="${escapeAttr(entry.path)}">
<span class="card-check">${state.selected.has(entry.path) ? icon('check') : ''}</span>
<span class="card-icon ${fileTone(entry)}">${icon(fileIcon(entry))}</span>
<strong>${highlight(entry.name, state.query)}</strong>
<small>${entry.type === 'file' ? formatBytes(entry.size) : typeLabel(entry)}</small>
</button>
`).join('')}</div>`;
}
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 = `<div class="details-empty">${icon('info')}<strong>Details</strong><span>Wähle eine Datei oder einen Ordner aus.</span></div>`;
return;
}
if (entries.length > 1) {
const size = entries.reduce((sum, entry) => sum + Number(entry.size || 0), 0);
pane.innerHTML = `<div class="details-header"><strong>${entries.length} Elemente</strong>${iconButton('close', 'Details schließen', 'details')}</div><div class="multi-selection-mark">${entries.length}</div><dl class="detail-list"><dt>Ausgewählt</dt><dd>${entries.length}</dd><dt>Dateigröße</dt><dd>${formatBytes(size)}</dd></dl>`;
return;
}
const entry = entries[0];
pane.innerHTML = `
<div class="details-header"><strong>Details</strong>${iconButton('close', 'Details schließen', 'details')}</div>
<div class="preview-box">${previewMarkup(entry)}</div>
<div class="detail-title"><span class="file-type-icon large ${fileTone(entry)}">${icon(fileIcon(entry))}</span><div><strong>${escapeHtml(entry.name)}</strong><span>${escapeHtml(typeLabel(entry))}</span></div></div>
<dl class="detail-list">
<dt>Größe</dt><dd>${entry.type === 'file' ? formatBytes(entry.size) : 'Ordner'}</dd>
<dt>Geändert</dt><dd>${formatDate(entry.modifiedAt, true)}</dd>
${entry.owner != null ? `<dt>Besitzer</dt><dd>${escapeHtml(entry.owner)}</dd>` : ''}
${entry.group != null ? `<dt>Gruppe</dt><dd>${escapeHtml(entry.group)}</dd>` : ''}
${entry.permissions ? `<dt>Rechte</dt><dd><code>${escapeHtml(entry.permissions)}</code></dd>` : ''}
<dt>Pfad</dt><dd class="detail-path">${escapeHtml(entry.path)}</dd>
</dl>
${PHP_API && isPreviewable(entry) && !state.preview ? `<button type="button" class="action full" data-action="open" data-path="${escapeAttr(entry.path)}">${icon('image')}<span>Vorschau laden</span></button>` : ''}
`;
}
function previewMarkup(entry) {
if (state.previewLoading) return '<span class="spinner"></span>';
if (!state.preview) return `<span class="preview-placeholder ${fileTone(entry)}">${icon(fileIcon(entry))}</span>`;
if (state.preview.error) return `<span class="preview-error">${escapeHtml(state.preview.error)}</span>`;
const data = state.preview;
const raw = endpointRaw(data.rawUrl);
if (data.type === 'image') return `<img src="${escapeAttr(raw)}" alt="${escapeAttr(entry.name)}">`;
if (data.type === 'audio') return `<audio controls src="${escapeAttr(raw)}"></audio>`;
if (data.type === 'video') return `<video controls src="${escapeAttr(raw)}"></video>`;
if (data.type === 'pdf') return `<iframe src="${escapeAttr(raw)}" title="${escapeAttr(entry.name)}"></iframe>`;
return `<pre>${escapeHtml(String(data.content || '').slice(0, 6000))}</pre>`;
}
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('') : '<div class="drawer-empty">Keine Transfers in dieser Sitzung.</div>');
} else {
host.innerHTML = drawerMarkup('Einstellungen', `
<form class="settings-form">
<label><span><strong>Schreibzugriff</strong><small>Dateioperationen und Uploads erlauben</small></span><input type="checkbox" name="write" ${!isReadOnly() ? 'checked' : ''}></label>
<label><span><strong>Debug-Modus</strong><small>Zusätzliche Diagnoseinformationen aktivieren</small></span><input type="checkbox" name="debug" ${state.config?.debug ? 'checked' : ''}></label>
<button type="button" class="action primary full" data-action="save-settings">Speichern</button>
</form>
`);
}
}
function drawerMarkup(title, content) {
return `<div class="drawer-scrim" data-action="close-drawer"></div><aside class="drawer"><header><div><small>U-Navigator</small><strong>${escapeHtml(title)}</strong></div>${iconButton('close', 'Schließen', 'close-drawer')}</header><div class="drawer-body">${content}</div></aside>`;
}
function transferMarkup(item) {
const progress = Math.max(0, Math.min(100, Number(item.progress || 0)));
return `<article class="transfer-item"><div><span class="transfer-icon">${icon(item.type === 'upload' ? 'upload' : 'transfer')}</span><div><strong>${escapeHtml(item.name || `${item.type} #${item.id}`)}</strong><small>${escapeHtml(item.message || item.status || '')}</small></div><b>${progress}%</b></div><div class="progress"><span style="width:${progress}%"></span></div></article>`;
}
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 = `<div class="context-menu" style="left:${Math.min(event.clientX, window.innerWidth - 240)}px;top:${Math.min(event.clientY, window.innerHeight - 310)}px">
<button type="button" data-action="open" data-path="${escapeAttr(path)}">${icon(entry?.type === 'directory' ? 'folder' : 'image')}<span>Öffnen</span></button>
<span class="context-separator"></span>
<button type="button" data-action="download">${icon('download')}<span>Herunterladen</span></button>
<button type="button" data-action="rename" ${isReadOnly() ? 'disabled' : ''}>${icon('edit')}<span>Umbenennen</span></button>
<button type="button" data-action="copy" ${isReadOnly() ? 'disabled' : ''}>${icon('copy')}<span>Kopieren nach …</span></button>
<button type="button" data-action="move" ${isReadOnly() ? 'disabled' : ''}>${icon('move')}<span>Verschieben nach …</span></button>
<span class="context-separator"></span>
<button type="button" class="danger" data-action="delete" ${isReadOnly() ? 'disabled' : ''}>${icon('trash')}<span>Löschen</span></button>
</div>`;
}
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')}<span>${escapeHtml(message)}</span>`;
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 = `<div class="modal-scrim"><form class="modal"><header><span class="modal-icon ${options.danger ? 'danger' : ''}">${icon(options.danger ? 'trash' : 'edit')}</span><div><strong>${escapeHtml(options.title)}</strong><small>${escapeHtml(options.message)}</small></div></header>${options.prompt ? `<input name="value" value="${escapeAttr(options.value || '')}" aria-label="${escapeAttr(options.message)}">` : ''}<footer><button type="button" class="action" data-cancel>Abbrechen</button><button type="submit" class="action primary ${options.danger ? 'danger-button' : ''}">${escapeHtml(options.confirm)}</button></footer></form></div>`;
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 `<button type="button" class="column-title ${active ? 'active' : ''}" data-sort="${key}">${escapeHtml(label)}${active ? `<span>${state.sort.direction === 'asc' ? '↑' : '↓'}</span>` : ''}</button>`;
}
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 `<svg class="ui-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${iconPaths[name] || iconPaths.file}</svg>`; }
function iconButton(name, label, action, extra = '') { return `<button type="button" class="icon-button" title="${escapeAttr(label)}" aria-label="${escapeAttr(label)}" data-action="${escapeAttr(action)}">${icon(name)}${extra}</button>`; }
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'), '<mark>$1</mark>'); }
function escapeHtml(value) { return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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;
}