diff --git a/build-plugin.sh b/build-plugin.sh
index cd8fe7c..819bef3 100755
--- a/build-plugin.sh
+++ b/build-plugin.sh
@@ -2,7 +2,7 @@
set -eu
PLUGIN="u-navigator"
-VERSION="2026.06.23.r022"
+VERSION="2026.06.23.r023"
PKG_DIR="packages"
WORK_DIR=".plugin-build"
PKG_NAME="${PLUGIN}-${VERSION}.tgz"
diff --git a/packages/u-navigator-2026.06.23.r022.tgz b/packages/u-navigator-2026.06.23.r022.tgz
deleted file mode 100644
index c0e65ed..0000000
Binary files a/packages/u-navigator-2026.06.23.r022.tgz and /dev/null differ
diff --git a/packages/u-navigator-2026.06.23.r023.tgz b/packages/u-navigator-2026.06.23.r023.tgz
new file mode 100644
index 0000000..68285e4
Binary files /dev/null and b/packages/u-navigator-2026.06.23.r023.tgz differ
diff --git a/plugin-root/usr/local/emhttp/plugins/u-navigator/U-Navigator.page b/plugin-root/usr/local/emhttp/plugins/u-navigator/U-Navigator.page
index 5040f87..a71a65b 100644
--- a/plugin-root/usr/local/emhttp/plugins/u-navigator/U-Navigator.page
+++ b/plugin-root/usr/local/emhttp/plugins/u-navigator/U-Navigator.page
@@ -16,7 +16,7 @@ $version = "__VERSION__";
diff --git a/server/public/app.js b/server/public/app.js
index 3df80c1..985a1a4 100644
--- a/server/public/app.js
+++ b/server/public/app.js
@@ -1,5 +1,7 @@
const workspace = document.querySelector('#workspace');
+const appShell = document.querySelector('.u-nav');
const statusText = document.querySelector('#statusText');
+const transferButton = document.querySelector('#newQueueButton');
const API_BASE = window.UNAV_API_BASE || '/api';
const APP_VERSION = window.UNAV_VERSION || 'dev';
const state = {
@@ -17,6 +19,9 @@ const state = {
jobRefresh: new Map(),
sizeJobs: new Map(),
sizeResults: new Map(),
+ transferPanelVisible: false,
+ transferPanelClosing: false,
+ transferAutoHideTimer: null,
nextWindowId: 1,
zIndex: 20
};
@@ -30,7 +35,7 @@ const icons = {
document.querySelector('#newExplorerButton').addEventListener('click', () => openExplorer());
document.querySelector('#newPropertiesButton').addEventListener('click', () => openProperties());
-document.querySelector('#newQueueButton').addEventListener('click', () => openQueue());
+transferButton.addEventListener('click', () => toggleTransferPanel());
window.addEventListener('error', (event) => {
recordDebug('window.error', {
message: event.message,
@@ -54,7 +59,6 @@ async function init() {
}
statusText.textContent = `${state.config.roots.length} Root, ${state.config.readOnly ? 'Read-only' : 'Schreibzugriff aktiv'} · ${APP_VERSION}`;
openExplorer(state.config.roots[0].path);
- openQueue();
} catch (error) {
statusText.textContent = error.message;
}
@@ -105,13 +109,8 @@ function openProperties(entry = state.selectedEntry) {
}
function openQueue() {
- return createWindow('Transfers', 'queue', {
- width: 440,
- height: 340,
- x: 250,
- y: 130,
- data: {}
- });
+ showTransferPanel();
+ return null;
}
function createWindow(title, kind, options) {
@@ -142,6 +141,7 @@ function render() {
if (state.contextMenu) {
workspace.appendChild(renderContextMenu());
}
+ renderTransferPanel();
}
function renderWindow(win) {
@@ -182,7 +182,6 @@ function renderWindow(win) {
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;
@@ -361,6 +360,11 @@ function renderProperties(body) {
}
function renderQueue(body) {
+ body.innerHTML = queueMarkup();
+ bindQueueActions(body);
+}
+
+function queueMarkup() {
const jobs = [...state.jobs.values()].reverse();
const jobMarkup = jobs.length ? jobs.map((job) => `
@@ -377,7 +381,7 @@ function renderQueue(body) {
`).join('') : '
Noch keine Debug-Einträge.
';
- body.innerHTML = `
+ return `
${jobMarkup}
@@ -387,12 +391,105 @@ function renderQueue(body) {
${debugMarkup}
`;
- body.querySelector('[data-action="clear-debug"]')?.addEventListener('click', () => {
+}
+
+function bindQueueActions(root) {
+ root.querySelector('[data-action="clear-debug"]')?.addEventListener('click', () => {
state.debug = [];
- render();
+ updateTransferPanel();
});
}
+function toggleTransferPanel() {
+ if (state.transferPanelVisible) {
+ hideTransferPanel();
+ } else {
+ showTransferPanel();
+ }
+}
+
+function showTransferPanel() {
+ if (state.transferAutoHideTimer) {
+ clearTimeout(state.transferAutoHideTimer);
+ state.transferAutoHideTimer = null;
+ }
+ state.transferPanelVisible = true;
+ state.transferPanelClosing = false;
+ renderTransferPanel();
+}
+
+function hideTransferPanel() {
+ if (!state.transferPanelVisible && !state.transferPanelClosing) {
+ return;
+ }
+ if (state.transferAutoHideTimer) {
+ clearTimeout(state.transferAutoHideTimer);
+ state.transferAutoHideTimer = null;
+ }
+ state.transferPanelVisible = false;
+ state.transferPanelClosing = true;
+ renderTransferPanel();
+ setTimeout(() => {
+ if (!state.transferPanelVisible) {
+ state.transferPanelClosing = false;
+ renderTransferPanel();
+ }
+ }, 220);
+}
+
+function scheduleTransferAutoHide() {
+ if (!state.transferPanelVisible || activeTransferCount() > 0 || state.transferAutoHideTimer) {
+ return;
+ }
+ state.transferAutoHideTimer = setTimeout(() => {
+ state.transferAutoHideTimer = null;
+ if (activeTransferCount() === 0) {
+ hideTransferPanel();
+ }
+ }, 2200);
+}
+
+function activeTransferCount() {
+ return [...state.jobs.values()].filter((job) => job.type !== 'size' && ['queued', 'running', 'cancel_requested'].includes(job.status)).length;
+}
+
+function renderTransferPanel() {
+ const existing = appShell.querySelector('.transfer-panel');
+ if (!state.transferPanelVisible && !state.transferPanelClosing) {
+ existing?.remove();
+ transferButton.classList.remove('active');
+ transferButton.setAttribute('aria-expanded', 'false');
+ return;
+ }
+
+ const panel = existing || document.createElement('aside');
+ panel.className = `transfer-panel ${state.transferPanelClosing ? 'closing' : 'open'}`;
+ panel.setAttribute('aria-label', 'Transfers');
+ panel.innerHTML = `
+
+ ${queueMarkup()}
+ `;
+ if (!existing) {
+ appShell.appendChild(panel);
+ }
+
+ const buttonRect = transferButton.getBoundingClientRect();
+ const shellRect = appShell.getBoundingClientRect();
+ const panelWidth = Math.min(520, Math.max(360, shellRect.width - 32));
+ const right = Math.max(16, shellRect.right - buttonRect.right);
+ panel.style.width = `${panelWidth}px`;
+ panel.style.right = `${right}px`;
+ panel.style.top = `${buttonRect.bottom - shellRect.top + 8}px`;
+
+ panel.querySelector('[data-action="close-transfers"]').addEventListener('click', () => hideTransferPanel());
+ bindQueueActions(panel);
+ transferButton.classList.add('active');
+ transferButton.setAttribute('aria-expanded', 'true');
+}
+
async function loadExplorer(win, targetPath) {
win.data.loading = true;
win.data.error = null;
@@ -453,8 +550,9 @@ async function createJob(type, source, destination, refreshWindowIds = []) {
windowIds: [...new Set(refreshWindowIds)].filter(Boolean),
done: false
});
+ showTransferPanel();
pollJob(job.id);
- render();
+ updateTransferPanel();
return job;
}
@@ -622,13 +720,14 @@ async function pollJob(id) {
try {
const job = await api(apiUrl(`job.php?id=${encodeURIComponent(id)}`));
state.jobs.set(id, job);
- render();
+ updateTransferPanel();
if (['queued', 'running', 'cancel_requested'].includes(job.status)) {
setTimeout(() => pollJob(id), 700);
} else if (job.type === 'size') {
finishSizeJob(job);
} else {
await refreshAfterJob(job);
+ scheduleTransferAutoHide();
}
} catch (error) {
recordDebug('job.poll.error', { id, error: serializeError(error) });
@@ -1109,17 +1208,12 @@ function recordDebug(label, data = {}) {
if (state.debug.length > 80) {
state.debug.splice(0, state.debug.length - 80);
}
- updateQueueWindows();
+ updateTransferPanel();
return item;
}
-function updateQueueWindows() {
- for (const win of state.windows.filter((item) => item.kind === 'queue')) {
- const body = workspace.querySelector(`.window[data-window-id="${win.id}"] .window-body`);
- if (body) {
- renderQueue(body);
- }
- }
+function updateTransferPanel() {
+ renderTransferPanel();
}
function serializeError(error) {
diff --git a/server/public/styles.css b/server/public/styles.css
index 9f710ab..fd3f8b6 100644
--- a/server/public/styles.css
+++ b/server/public/styles.css
@@ -14,6 +14,8 @@
font: 14px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
grid-template-rows: auto 1fr;
min-height: calc(100vh - 170px);
+ overflow: hidden;
+ position: relative;
}
@media (prefers-color-scheme: dark) {
@@ -88,6 +90,11 @@
gap: 8px;
}
+.u-nav .toolbar button.active {
+ background: var(--surface-2);
+ border-color: var(--accent);
+}
+
.u-nav .workspace {
min-height: 0;
overflow: hidden;
@@ -472,6 +479,71 @@
border-color: var(--line);
}
+.u-nav .transfer-panel {
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ box-shadow: var(--shadow);
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ max-height: min(520px, calc(100% - 78px));
+ min-height: 220px;
+ min-width: 320px;
+ overflow: hidden;
+ position: absolute;
+ transform-origin: top right;
+ z-index: 10000;
+}
+
+.u-nav .transfer-panel.open {
+ animation: transfer-slide-down 180ms ease-out both;
+}
+
+.u-nav .transfer-panel.closing {
+ animation: transfer-slide-up 180ms ease-in both;
+ pointer-events: none;
+}
+
+.u-nav .transfer-panel-header {
+ align-items: center;
+ background: var(--surface-2);
+ border-bottom: 1px solid var(--line);
+ display: flex;
+ justify-content: space-between;
+ min-height: 40px;
+ padding: 5px 8px 5px 12px;
+}
+
+.u-nav .transfer-panel-body {
+ min-height: 0;
+ overflow: auto;
+ padding: 10px;
+}
+
+@keyframes transfer-slide-down {
+ from {
+ opacity: 0;
+ transform: translateY(-18px) scaleY(0.96);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0) scaleY(1);
+ }
+}
+
+@keyframes transfer-slide-up {
+ from {
+ opacity: 1;
+ transform: translateY(0) scaleY(1);
+ }
+
+ to {
+ opacity: 0;
+ transform: translateY(-18px) scaleY(0.96);
+ }
+}
+
.u-nav .queue-list {
display: grid;
gap: 10px;
@@ -580,4 +652,12 @@
.u-nav .resize-handle {
display: none;
}
+
+ .u-nav .transfer-panel {
+ left: 10px !important;
+ max-height: min(70vh, 520px);
+ min-width: 0;
+ right: 10px !important;
+ width: auto !important;
+ }
}
diff --git a/u-navigator.plg b/u-navigator.plg
index 3017996..b5a5892 100644
--- a/u-navigator.plg
+++ b/u-navigator.plg
@@ -1,7 +1,7 @@
-
+
@@ -24,7 +24,7 @@
https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/packages/&package;
-84c66337fdd03108c2c04690f88ee5b8
+c57cb70f4d040d65869b6ffde469b18a