Files
Unraid-Navigator/public/app.js
T
2026-06-22 22:21:49 +02:00

454 lines
14 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,
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'));
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) {
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));
}
}
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);
el.append(titlebar, body);
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('dragover', (event) => {
event.preventDefault();
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');
await handleDrop(event, win, data.path);
});
for (const row of body.querySelectorAll('.file-row')) {
const entry = data.entries.find((item) => item.path === row.dataset.path);
row.addEventListener('click', () => selectEntry(win, entry));
row.addEventListener('dblclick', () => {
if (entry.type === 'directory') {
loadExplorer(win, entry.path);
}
});
row.addEventListener('dragstart', (event) => {
event.dataTransfer.setData('application/x-u-navigator-path', entry.path);
event.dataTransfer.effectAllowed = 'copyMove';
});
row.addEventListener('dragover', (event) => {
if (entry.type === 'directory') {
event.preventDefault();
event.dataTransfer.dropEffect = event.altKey ? 'copy' : 'move';
}
});
row.addEventListener('drop', async (event) => {
if (entry.type !== 'directory') return;
event.preventDefault();
await handleDrop(event, win, entry.path);
});
}
}
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" 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');
if (internalPath) {
const name = internalPath.split('/').filter(Boolean).at(-1);
const destination = `${targetPath.replace(/\/$/, '')}/${name}`;
await createJob(event.altKey ? 'copy' : 'move', internalPath, destination);
await loadExplorer(win, win.data.path);
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 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) {
win.data.selectedPath = entry.path;
state.selectedEntry = entry;
focusWindow(win);
render();
}
function focusWindow(win) {
state.focusedId = win.id;
win.zIndex = ++state.zIndex;
}
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 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(/^\//, '')}`;
}