861 lines
44 KiB
JavaScript
861 lines
44 KiB
JavaScript
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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;
|
||
}
|