582 lines
18 KiB
JavaScript
582 lines
18 KiB
JavaScript
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,
|
||
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.scrollTop = win.data.scrollTop || 0;
|
||
body.scrollLeft = win.data.scrollLeft || 0;
|
||
body.addEventListener('scroll', () => {
|
||
win.data.scrollTop = body.scrollTop;
|
||
win.data.scrollLeft = body.scrollLeft;
|
||
});
|
||
|
||
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();
|
||
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');
|
||
await handleDrop(event, win, data.path);
|
||
});
|
||
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('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.setData('text/plain', entry.path);
|
||
event.dataTransfer.effectAllowed = 'copyMove';
|
||
row.classList.add('dragging');
|
||
});
|
||
row.addEventListener('dragend', () => {
|
||
row.classList.remove('dragging');
|
||
});
|
||
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();
|
||
await handleDrop(event, win, entry.path);
|
||
});
|
||
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');
|
||
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 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) {
|
||
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 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) => ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": '''
|
||
})[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(/^\//, '')}`;
|
||
}
|