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

684 lines
21 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 workspace = document.querySelector('#workspace');
const statusText = document.querySelector('#statusText');
const API_BASE = window.UNAV_API_BASE || '/api';
const state = {
config: null,
windows: [],
focusedId: null,
selectedEntry: null,
contextMenu: null,
draggedEntry: null,
jobs: new Map(),
nextWindowId: 1,
zIndex: 20
};
const icons = {
directory: 'Folder',
file: 'File',
symlink: 'Link',
other: 'Item'
};
document.querySelector('#newExplorerButton').addEventListener('click', () => openExplorer());
document.querySelector('#newPropertiesButton').addEventListener('click', () => openProperties());
document.querySelector('#newQueueButton').addEventListener('click', () => openQueue());
init();
async function init() {
try {
state.config = await api(apiUrl('config.php'));
if (!state.config.roots?.length) {
throw new Error('No U-Navigator roots configured');
}
statusText.textContent = `${state.config.roots.length} Root, ${state.config.readOnly ? 'Read-only' : 'Schreibzugriff aktiv'}`;
openExplorer(state.config.roots[0].path);
openProperties();
openQueue();
} catch (error) {
statusText.textContent = error.message;
}
}
function openExplorer(initialPath = state.config?.roots?.[0]?.path) {
if (!initialPath) {
statusText.textContent = 'Kein Root-Pfad konfiguriert';
return null;
}
const win = createWindow('Explorer', 'explorer', {
width: 760,
height: 460,
x: 24 + state.windows.length * 24,
y: 20 + state.windows.length * 20,
data: {
path: initialPath,
entries: [],
selectedPath: null,
loading: false,
error: null
}
});
loadExplorer(win, initialPath);
return win;
}
function openProperties() {
return createWindow('Eigenschaften', 'properties', {
width: 420,
height: 320,
x: 160,
y: 90,
data: {}
});
}
function openQueue() {
return createWindow('Transfers', 'queue', {
width: 440,
height: 340,
x: 250,
y: 130,
data: {}
});
}
function createWindow(title, kind, options) {
const id = `win-${state.nextWindowId++}`;
const win = {
id,
kind,
title,
x: options.x,
y: options.y,
width: options.width,
height: options.height,
maximized: false,
zIndex: ++state.zIndex,
data: options.data || {}
};
state.windows.push(win);
state.focusedId = id;
render();
return win;
}
function render() {
workspace.innerHTML = '';
for (const win of state.windows) {
workspace.appendChild(renderWindow(win));
}
if (state.contextMenu) {
workspace.appendChild(renderContextMenu());
}
}
function renderWindow(win) {
const el = document.createElement('section');
el.className = `window ${state.focusedId === win.id ? 'focused' : ''} ${win.maximized ? 'maximized' : ''}`;
el.style.left = `${win.maximized ? 0 : win.x}px`;
el.style.top = `${win.maximized ? 0 : win.y}px`;
el.style.width = `${win.maximized ? workspace.clientWidth : win.width}px`;
el.style.height = `${win.maximized ? workspace.clientHeight : win.height}px`;
el.style.zIndex = win.zIndex;
el.dataset.windowId = win.id;
el.addEventListener('pointerdown', () => focusWindow(win));
const titlebar = document.createElement('div');
titlebar.className = 'titlebar';
titlebar.innerHTML = `
<strong>${escapeHtml(windowTitle(win))}</strong>
<div class="window-actions">
<button class="icon" data-action="maximize" title="Maximieren">${win.maximized ? '↙' : '□'}</button>
<button class="icon" data-action="close" title="Schließen">×</button>
</div>
`;
titlebar.addEventListener('pointerdown', (event) => startDragWindow(event, win));
titlebar.querySelector('[data-action="maximize"]').addEventListener('click', (event) => {
event.stopPropagation();
win.maximized = !win.maximized;
focusWindow(win);
render();
});
titlebar.querySelector('[data-action="close"]').addEventListener('click', (event) => {
event.stopPropagation();
state.windows = state.windows.filter((item) => item.id !== win.id);
state.focusedId = state.windows.at(-1)?.id || null;
render();
});
const body = document.createElement('div');
body.className = 'window-body';
if (win.kind === 'explorer') renderExplorer(body, win);
if (win.kind === 'properties') renderProperties(body);
if (win.kind === 'queue') renderQueue(body);
body.addEventListener('scroll', () => {
if (win.data.lockScroll) {
body.scrollTop = win.data.lockScrollTop || 0;
body.scrollLeft = win.data.lockScrollLeft || 0;
return;
}
win.data.scrollTop = body.scrollTop;
win.data.scrollLeft = body.scrollLeft;
});
el.append(titlebar, body);
requestAnimationFrame(() => {
body.scrollTop = win.data.scrollTop || 0;
body.scrollLeft = win.data.scrollLeft || 0;
});
if (!win.maximized) {
const resize = document.createElement('div');
resize.className = 'resize-handle';
resize.addEventListener('pointerdown', (event) => startResizeWindow(event, win));
el.appendChild(resize);
}
return el;
}
function renderExplorer(body, win) {
const data = win.data;
body.innerHTML = `
<div class="pathbar">
<button data-action="up">Auf</button>
<input value="${escapeAttr(data.path || '')}" aria-label="Pfad">
<button data-action="go">Öffnen</button>
<button data-action="refresh">Neu laden</button>
</div>
<div class="drop-zone ${data.dragOver ? 'drag-over' : ''}">
${data.error ? `<p class="muted">${escapeHtml(data.error)}</p>` : ''}
${data.loading ? '<p class="muted">Lade...</p>' : renderFileTable(data.entries || [])}
</div>
`;
const input = body.querySelector('input');
body.querySelector('[data-action="go"]').addEventListener('click', () => loadExplorer(win, input.value));
body.querySelector('[data-action="refresh"]').addEventListener('click', () => loadExplorer(win, data.path));
body.querySelector('[data-action="up"]').addEventListener('click', () => loadExplorer(win, parentPath(data.path)));
const dropZone = body.querySelector('.drop-zone');
dropZone.addEventListener('dragenter', (event) => {
if (hasDropPayload(event)) {
event.preventDefault();
data.dragOver = true;
dropZone.classList.add('drag-over');
}
});
dropZone.addEventListener('dragover', (event) => {
if (hasDropPayload(event)) {
event.preventDefault();
event.dataTransfer.dropEffect = event.altKey ? 'copy' : 'move';
data.dragOver = true;
dropZone.classList.add('drag-over');
}
});
dropZone.addEventListener('dragleave', () => {
data.dragOver = false;
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', async (event) => {
event.preventDefault();
data.dragOver = false;
dropZone.classList.remove('drag-over');
try {
await handleDrop(event, win, data.path);
} catch (error) {
showError(error);
}
});
dropZone.addEventListener('contextmenu', (event) => {
event.preventDefault();
showContextMenu(event, win, null);
});
for (const row of body.querySelectorAll('.file-row')) {
const entry = data.entries.find((item) => item.path === row.dataset.path);
row.addEventListener('pointerdown', () => lockExplorerScroll(win, body));
row.addEventListener('click', () => selectEntry(win, entry, body));
row.addEventListener('dblclick', () => {
if (entry.type === 'directory') {
loadExplorer(win, entry.path);
}
});
row.addEventListener('dragstart', (event) => {
state.draggedEntry = { path: entry.path, name: entry.name, sourceWindowId: win.id };
event.dataTransfer.setData('application/x-u-navigator-path', entry.path);
event.dataTransfer.setData('text/plain', entry.path);
event.dataTransfer.effectAllowed = 'copyMove';
row.classList.add('dragging');
});
row.addEventListener('dragend', () => {
row.classList.remove('dragging');
setTimeout(() => {
state.draggedEntry = null;
}, 0);
});
row.addEventListener('dragover', (event) => {
if (entry.type === 'directory') {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = event.altKey ? 'copy' : 'move';
}
});
row.addEventListener('drop', async (event) => {
if (entry.type !== 'directory') return;
event.preventDefault();
event.stopPropagation();
try {
await handleDrop(event, win, entry.path);
} catch (error) {
showError(error);
}
});
row.addEventListener('contextmenu', (event) => {
event.preventDefault();
event.stopPropagation();
selectEntry(win, entry);
showContextMenu(event, win, entry);
});
}
}
function renderFileTable(entries) {
if (!entries.length) {
return '<p class="muted">Dieser Ordner ist leer. Dateien können hier abgelegt werden.</p>';
}
const rows = entries.map((entry) => `
<tr class="file-row ${entry.path === state.selectedEntry?.path ? 'selected' : ''}" draggable="true" data-path="${escapeAttr(entry.path)}">
<td><span class="name-cell"><span class="badge">${icons[entry.type] || 'Item'}</span>${escapeHtml(entry.name)}</span></td>
<td>${entry.type}</td>
<td>${entry.type === 'file' ? formatBytes(entry.size) : ''}</td>
<td>${new Date(entry.modifiedAt).toLocaleString()}</td>
</tr>
`).join('');
return `
<table class="file-table">
<thead><tr><th>Name</th><th>Typ</th><th>Größe</th><th>Geändert</th></tr></thead>
<tbody>${rows}</tbody>
</table>
`;
}
function renderProperties(body) {
const entry = state.selectedEntry;
if (!entry) {
body.innerHTML = '<p class="muted">Keine Datei ausgewählt.</p>';
return;
}
body.classList.add('properties');
body.innerHTML = `
<dl>
<dt>Name</dt><dd>${escapeHtml(entry.name)}</dd>
<dt>Typ</dt><dd>${escapeHtml(entry.type)}</dd>
<dt>Pfad</dt><dd>${escapeHtml(entry.path)}</dd>
<dt>Größe</dt><dd>${formatBytes(entry.size)}</dd>
<dt>Geändert</dt><dd>${new Date(entry.modifiedAt).toLocaleString()}</dd>
</dl>
`;
}
function renderQueue(body) {
const jobs = [...state.jobs.values()].reverse();
if (!jobs.length) {
body.innerHTML = '<p class="muted">Keine Transfers.</p>';
return;
}
body.innerHTML = `<div class="queue-list">${jobs.map((job) => `
<article class="job">
<strong>${escapeHtml(job.type)} #${escapeHtml(job.id)}</strong>
<div class="muted">${escapeHtml(job.status)} · ${escapeHtml(job.message || '')}</div>
<div class="progress"><div style="width:${Number(job.progress) || 0}%"></div></div>
</article>
`).join('')}</div>`;
}
async function loadExplorer(win, targetPath) {
win.data.loading = true;
win.data.error = null;
win.data.path = targetPath;
render();
try {
const result = await api(apiUrl(`list.php?path=${encodeURIComponent(targetPath)}`));
win.data.path = result.path;
win.data.entries = result.entries;
} catch (error) {
win.data.error = error.message;
} finally {
win.data.loading = false;
render();
}
}
async function handleDrop(event, win, targetPath) {
if (state.config.readOnly) {
alert('Read-only mode ist aktiv.');
return;
}
const internalPath = event.dataTransfer.getData('application/x-u-navigator-path') || event.dataTransfer.getData('text/plain') || state.draggedEntry?.path;
if (internalPath) {
const name = internalPath.split('/').filter(Boolean).at(-1);
const destination = `${targetPath.replace(/\/$/, '')}/${name}`;
if (internalPath === destination) {
throw new Error('Quelle und Ziel sind identisch.');
}
await createJob(event.altKey ? 'copy' : 'move', internalPath, destination);
await reloadExplorerWindows([win.id, state.draggedEntry?.sourceWindowId].filter(Boolean));
return;
}
if (event.dataTransfer.files.length) {
const form = new FormData();
for (const file of event.dataTransfer.files) {
form.append('files', file, file.name);
}
await fetchChecked(apiUrl(`upload.php?path=${encodeURIComponent(targetPath)}`), {
method: 'POST',
body: form
});
await loadExplorer(win, targetPath);
}
}
async function createJob(type, source, destination) {
const job = await api(apiUrl(`job.php?action=${encodeURIComponent(type)}`), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source, destination })
});
state.jobs.set(job.id, job);
pollJob(job.id);
render();
}
async function reloadExplorerWindows(ids) {
const uniqueIds = new Set(ids);
const targets = state.windows.filter((win) => win.kind === 'explorer' && (uniqueIds.has(win.id) || !uniqueIds.size));
await Promise.all(targets.map((win) => loadExplorer(win, win.data.path)));
}
function hasDropPayload(event) {
return Boolean(state.draggedEntry || event.dataTransfer?.types?.includes('Files') || event.dataTransfer?.types?.includes('application/x-u-navigator-path') || event.dataTransfer?.types?.includes('text/plain'));
}
function showError(error) {
const message = error?.message || String(error);
alert(message);
}
async function renameEntry(win, entry) {
const nextName = prompt('Neuer Name', entry.name);
if (!nextName || nextName === entry.name) {
return;
}
if (nextName.includes('/')) {
alert('Der Name darf keinen Slash enthalten.');
return;
}
await createJob('move', entry.path, `${dirname(entry.path)}/${nextName}`);
await loadExplorer(win, win.data.path);
}
async function promptTransfer(win, entry, type) {
const targetDir = prompt(type === 'copy' ? 'Kopieren nach Ordner' : 'Verschieben nach Ordner', win.data.path);
if (!targetDir) {
return;
}
await createJob(type, entry.path, `${targetDir.replace(/\/$/, '')}/${entry.name}`);
await loadExplorer(win, win.data.path);
}
function showContextMenu(event, win, entry) {
const rect = workspace.getBoundingClientRect();
state.contextMenu = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
winId: win.id,
entryPath: entry?.path || null
};
render();
}
function renderContextMenu() {
const menu = document.createElement('div');
menu.className = 'context-menu';
menu.style.left = `${state.contextMenu.x}px`;
menu.style.top = `${state.contextMenu.y}px`;
const win = state.windows.find((item) => item.id === state.contextMenu.winId);
const entry = win?.data.entries?.find((item) => item.path === state.contextMenu.entryPath) || null;
const actions = [];
if (entry) {
if (entry.type === 'directory') {
actions.push(['Öffnen', () => loadExplorer(win, entry.path)]);
}
actions.push(['Umbenennen', () => renameEntry(win, entry)]);
actions.push(['Kopieren nach...', () => promptTransfer(win, entry, 'copy')]);
actions.push(['Verschieben nach...', () => promptTransfer(win, entry, 'move')]);
} else {
actions.push(['Neu laden', () => loadExplorer(win, win.data.path)]);
}
for (const [label, action] of actions) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = label;
button.addEventListener('click', async () => {
state.contextMenu = null;
render();
try {
await action();
} catch (error) {
alert(error.message);
}
});
menu.appendChild(button);
}
setTimeout(() => {
const close = () => {
state.contextMenu = null;
render();
document.removeEventListener('click', close);
document.removeEventListener('keydown', keyClose);
};
const keyClose = (event) => {
if (event.key === 'Escape') close();
};
document.addEventListener('click', close);
document.addEventListener('keydown', keyClose);
}, 0);
return menu;
}
async function pollJob(id) {
const job = await api(apiUrl(`job.php?id=${encodeURIComponent(id)}`));
state.jobs.set(id, job);
render();
if (['queued', 'running', 'cancel_requested'].includes(job.status)) {
setTimeout(() => pollJob(id), 700);
}
}
function selectEntry(win, entry, scrollBody = null) {
win.data.selectedPath = entry.path;
state.selectedEntry = entry;
focusWindow(win);
updateSelectedRows();
updatePropertiesWindows();
restoreExplorerScroll(win, scrollBody);
}
function focusWindow(win) {
state.focusedId = win.id;
win.zIndex = ++state.zIndex;
for (const windowEl of workspace.querySelectorAll('.window')) {
windowEl.classList.toggle('focused', windowEl.dataset.windowId === win.id);
}
const current = workspace.querySelector(`.window[data-window-id="${win.id}"]`);
if (current) {
current.style.zIndex = win.zIndex;
}
}
function updateSelectedRows() {
for (const row of workspace.querySelectorAll('.file-row')) {
row.classList.toggle('selected', row.dataset.path === state.selectedEntry?.path);
}
}
function updatePropertiesWindows() {
for (const win of state.windows.filter((item) => item.kind === 'properties')) {
const body = workspace.querySelector(`.window[data-window-id="${win.id}"] .window-body`);
if (body) {
renderProperties(body);
}
}
}
function lockExplorerScroll(win, body) {
win.data.lockScroll = true;
win.data.lockScrollTop = body.scrollTop;
win.data.lockScrollLeft = body.scrollLeft;
}
function restoreExplorerScroll(win, body) {
if (!body) {
body = workspace.querySelector(`.window[data-window-id="${win.id}"] .window-body`);
}
if (!body) return;
const top = win.data.lockScrollTop ?? win.data.scrollTop ?? 0;
const left = win.data.lockScrollLeft ?? win.data.scrollLeft ?? 0;
const restore = () => {
body.scrollTop = top;
body.scrollLeft = left;
};
restore();
requestAnimationFrame(restore);
setTimeout(restore, 0);
setTimeout(() => {
restore();
win.data.lockScroll = false;
win.data.scrollTop = body.scrollTop;
win.data.scrollLeft = body.scrollLeft;
}, 120);
}
function startDragWindow(event, win) {
if (event.target.closest('button') || win.maximized) return;
event.preventDefault();
const startX = event.clientX;
const startY = event.clientY;
const originX = win.x;
const originY = win.y;
const pointerId = event.pointerId;
event.currentTarget.setPointerCapture(pointerId);
const move = (moveEvent) => {
win.x = clamp(originX + moveEvent.clientX - startX, 0, Math.max(0, workspace.clientWidth - 120));
win.y = clamp(originY + moveEvent.clientY - startY, 0, Math.max(0, workspace.clientHeight - 80));
render();
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
}
function startResizeWindow(event, win) {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startY = event.clientY;
const originWidth = win.width;
const originHeight = win.height;
const move = (moveEvent) => {
win.width = Math.max(320, originWidth + moveEvent.clientX - startX);
win.height = Math.max(280, originHeight + moveEvent.clientY - startY);
render();
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
}
async function api(url, options) {
const response = await fetchChecked(url, options);
return response.json();
}
async function fetchChecked(url, options) {
const response = await fetch(url, options);
if (!response.ok) {
let message = `${response.status} ${response.statusText}`;
try {
message = (await response.json()).error || message;
} catch {}
throw new Error(message);
}
return response;
}
function windowTitle(win) {
if (win.kind === 'explorer') {
return `Explorer · ${win.data.path || ''}`;
}
return win.title;
}
function parentPath(value) {
const parts = String(value || '').split('/').filter(Boolean);
if (parts.length <= 1) return value;
return `/${parts.slice(0, -1).join('/')}`;
}
function dirname(value) {
const parts = String(value || '').split('/').filter(Boolean);
if (parts.length <= 1) return '/';
return `/${parts.slice(0, -1).join('/')}`;
}
function formatBytes(bytes) {
const value = Number(bytes || 0);
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
if (value < 1024 * 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB`;
return `${(value / 1024 / 1024 / 1024).toFixed(1)} GB`;
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
})[char]);
}
function escapeAttr(value) {
return escapeHtml(value);
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function apiUrl(path) {
return `${API_BASE.replace(/\/$/, '')}/${path.replace(/^\//, '')}`;
}