Initial U-Navigator prototype

This commit is contained in:
Mikei386
2026-06-22 22:11:54 +02:00
commit 37ddea08f4
11 changed files with 1841 additions and 0 deletions
+448
View File
@@ -0,0 +1,448 @@
const workspace = document.querySelector('#workspace');
const statusText = document.querySelector('#statusText');
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('/api/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(`/api/list?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(`/api/upload?path=${encodeURIComponent(targetPath)}`, {
method: 'POST',
body: form
});
await loadExplorer(win, targetPath);
}
}
async function createJob(type, source, destination) {
const job = await api(`/api/jobs/${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(`/api/jobs/${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));
}
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>U-Navigator</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="app-shell">
<header class="topbar">
<div>
<h1>U-Navigator</h1>
<p id="statusText">Initialisiere...</p>
</div>
<nav class="toolbar" aria-label="Workspace actions">
<button id="newExplorerButton" title="Explorer öffnen">Explorer</button>
<button id="newPropertiesButton" title="Eigenschaften öffnen">Eigenschaften</button>
<button id="newQueueButton" title="Transfer-Queue öffnen">Transfers</button>
</nav>
</header>
<main id="workspace" class="workspace" aria-label="U-Navigator Workspace"></main>
</div>
<script src="/app.js" type="module"></script>
</body>
</html>
+331
View File
@@ -0,0 +1,331 @@
:root {
color-scheme: light dark;
--bg: #f4f6f8;
--surface: #ffffff;
--surface-2: #eef2f5;
--text: #18222d;
--muted: #617182;
--line: #cfd8e1;
--accent: #e86f2d;
--accent-2: #287a65;
--danger: #b52c34;
--shadow: 0 18px 55px rgba(24, 34, 45, 0.22);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #101418;
--surface: #1b2229;
--surface-2: #242e37;
--text: #eef3f7;
--muted: #a3b0bd;
--line: #36434f;
--shadow: 0 18px 55px rgba(0, 0, 0, 0.44);
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font: 14px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button {
border: 1px solid var(--line);
border-radius: 6px;
background: var(--surface);
color: var(--text);
cursor: pointer;
font: inherit;
min-height: 32px;
padding: 0 10px;
}
button:hover {
border-color: var(--accent);
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
button.icon {
align-items: center;
display: inline-flex;
height: 30px;
justify-content: center;
min-height: 30px;
padding: 0;
width: 32px;
}
.app-shell {
display: grid;
grid-template-rows: auto 1fr;
min-height: 100vh;
}
.topbar {
align-items: center;
background: var(--surface);
border-bottom: 1px solid var(--line);
display: flex;
gap: 16px;
justify-content: space-between;
padding: 12px 16px;
}
.topbar h1 {
font-size: 18px;
line-height: 1;
margin: 0 0 4px;
}
.topbar p {
color: var(--muted);
margin: 0;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.workspace {
min-height: 0;
overflow: hidden;
position: relative;
}
.window {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
display: grid;
grid-template-rows: auto 1fr;
min-height: 280px;
min-width: 320px;
overflow: hidden;
position: absolute;
}
.window.focused {
border-color: color-mix(in srgb, var(--accent) 62%, var(--line));
}
.window.maximized {
border-radius: 0;
}
.titlebar {
align-items: center;
background: var(--surface-2);
border-bottom: 1px solid var(--line);
cursor: move;
display: grid;
gap: 8px;
grid-template-columns: 1fr auto;
min-height: 40px;
padding: 5px 8px 5px 12px;
user-select: none;
}
.titlebar strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.window-actions {
display: flex;
gap: 6px;
}
.window-body {
min-height: 0;
overflow: auto;
padding: 10px;
}
.resize-handle {
bottom: 0;
cursor: nwse-resize;
height: 18px;
position: absolute;
right: 0;
width: 18px;
}
.resize-handle::after {
border-bottom: 2px solid var(--muted);
border-right: 2px solid var(--muted);
bottom: 5px;
content: "";
height: 8px;
position: absolute;
right: 5px;
width: 8px;
}
.pathbar {
align-items: center;
display: grid;
gap: 8px;
grid-template-columns: auto 1fr auto auto;
margin-bottom: 10px;
}
.pathbar input {
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: 6px;
color: var(--text);
font: inherit;
min-height: 32px;
padding: 0 9px;
width: 100%;
}
.drop-zone {
border: 1px dashed transparent;
border-radius: 8px;
min-height: 180px;
}
.drop-zone.drag-over {
background: color-mix(in srgb, var(--accent) 10%, transparent);
border-color: var(--accent);
}
.file-table {
border-collapse: collapse;
width: 100%;
}
.file-table th,
.file-table td {
border-bottom: 1px solid var(--line);
padding: 8px 7px;
text-align: left;
vertical-align: middle;
}
.file-table th {
color: var(--muted);
font-size: 12px;
font-weight: 600;
}
.file-row {
cursor: default;
}
.file-row:hover,
.file-row.selected {
background: var(--surface-2);
}
.name-cell {
align-items: center;
display: flex;
gap: 8px;
min-width: 150px;
}
.badge {
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
display: inline-flex;
font-size: 12px;
justify-content: center;
min-width: 58px;
padding: 2px 8px;
}
.muted {
color: var(--muted);
}
.queue-list {
display: grid;
gap: 10px;
}
.job {
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px;
}
.progress {
background: var(--surface-2);
border-radius: 999px;
height: 8px;
margin-top: 8px;
overflow: hidden;
}
.progress div {
background: var(--accent-2);
height: 100%;
width: 0;
}
.properties dl {
display: grid;
grid-template-columns: max-content 1fr;
gap: 8px 14px;
margin: 0;
}
.properties dt {
color: var(--muted);
}
.properties dd {
margin: 0;
overflow-wrap: anywhere;
}
@media (max-width: 720px) {
.topbar {
align-items: flex-start;
flex-direction: column;
}
.workspace {
overflow: auto;
}
.window,
.window.maximized {
border-left: 0;
border-radius: 0;
border-right: 0;
height: calc(100vh - 120px) !important;
left: 0 !important;
min-width: 0;
position: relative;
top: 0 !important;
width: 100% !important;
}
.window:not(.focused) {
display: none;
}
.resize-handle {
display: none;
}
}