Add sliding transfers panel
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
PLUGIN="u-navigator"
|
PLUGIN="u-navigator"
|
||||||
VERSION="2026.06.23.r022"
|
VERSION="2026.06.23.r023"
|
||||||
PKG_DIR="packages"
|
PKG_DIR="packages"
|
||||||
WORK_DIR=".plugin-build"
|
WORK_DIR=".plugin-build"
|
||||||
PKG_NAME="${PLUGIN}-${VERSION}.tgz"
|
PKG_NAME="${PLUGIN}-${VERSION}.tgz"
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -16,7 +16,7 @@ $version = "__VERSION__";
|
|||||||
<nav class="toolbar" aria-label="Workspace actions">
|
<nav class="toolbar" aria-label="Workspace actions">
|
||||||
<button id="newExplorerButton" title="Explorer oeffnen">Explorer</button>
|
<button id="newExplorerButton" title="Explorer oeffnen">Explorer</button>
|
||||||
<button id="newPropertiesButton" title="Eigenschaften oeffnen">Eigenschaften</button>
|
<button id="newPropertiesButton" title="Eigenschaften oeffnen">Eigenschaften</button>
|
||||||
<button id="newQueueButton" title="Transfer-Queue oeffnen">Transfers</button>
|
<button id="newQueueButton" title="Transfers einblenden" aria-expanded="false">Transfers</button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main id="workspace" class="workspace" aria-label="U-Navigator Workspace"></main>
|
<main id="workspace" class="workspace" aria-label="U-Navigator Workspace"></main>
|
||||||
|
|||||||
+117
-23
@@ -1,5 +1,7 @@
|
|||||||
const workspace = document.querySelector('#workspace');
|
const workspace = document.querySelector('#workspace');
|
||||||
|
const appShell = document.querySelector('.u-nav');
|
||||||
const statusText = document.querySelector('#statusText');
|
const statusText = document.querySelector('#statusText');
|
||||||
|
const transferButton = document.querySelector('#newQueueButton');
|
||||||
const API_BASE = window.UNAV_API_BASE || '/api';
|
const API_BASE = window.UNAV_API_BASE || '/api';
|
||||||
const APP_VERSION = window.UNAV_VERSION || 'dev';
|
const APP_VERSION = window.UNAV_VERSION || 'dev';
|
||||||
const state = {
|
const state = {
|
||||||
@@ -17,6 +19,9 @@ const state = {
|
|||||||
jobRefresh: new Map(),
|
jobRefresh: new Map(),
|
||||||
sizeJobs: new Map(),
|
sizeJobs: new Map(),
|
||||||
sizeResults: new Map(),
|
sizeResults: new Map(),
|
||||||
|
transferPanelVisible: false,
|
||||||
|
transferPanelClosing: false,
|
||||||
|
transferAutoHideTimer: null,
|
||||||
nextWindowId: 1,
|
nextWindowId: 1,
|
||||||
zIndex: 20
|
zIndex: 20
|
||||||
};
|
};
|
||||||
@@ -30,7 +35,7 @@ const icons = {
|
|||||||
|
|
||||||
document.querySelector('#newExplorerButton').addEventListener('click', () => openExplorer());
|
document.querySelector('#newExplorerButton').addEventListener('click', () => openExplorer());
|
||||||
document.querySelector('#newPropertiesButton').addEventListener('click', () => openProperties());
|
document.querySelector('#newPropertiesButton').addEventListener('click', () => openProperties());
|
||||||
document.querySelector('#newQueueButton').addEventListener('click', () => openQueue());
|
transferButton.addEventListener('click', () => toggleTransferPanel());
|
||||||
window.addEventListener('error', (event) => {
|
window.addEventListener('error', (event) => {
|
||||||
recordDebug('window.error', {
|
recordDebug('window.error', {
|
||||||
message: event.message,
|
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}`;
|
statusText.textContent = `${state.config.roots.length} Root, ${state.config.readOnly ? 'Read-only' : 'Schreibzugriff aktiv'} · ${APP_VERSION}`;
|
||||||
openExplorer(state.config.roots[0].path);
|
openExplorer(state.config.roots[0].path);
|
||||||
openQueue();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
statusText.textContent = error.message;
|
statusText.textContent = error.message;
|
||||||
}
|
}
|
||||||
@@ -105,13 +109,8 @@ function openProperties(entry = state.selectedEntry) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openQueue() {
|
function openQueue() {
|
||||||
return createWindow('Transfers', 'queue', {
|
showTransferPanel();
|
||||||
width: 440,
|
return null;
|
||||||
height: 340,
|
|
||||||
x: 250,
|
|
||||||
y: 130,
|
|
||||||
data: {}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWindow(title, kind, options) {
|
function createWindow(title, kind, options) {
|
||||||
@@ -142,6 +141,7 @@ function render() {
|
|||||||
if (state.contextMenu) {
|
if (state.contextMenu) {
|
||||||
workspace.appendChild(renderContextMenu());
|
workspace.appendChild(renderContextMenu());
|
||||||
}
|
}
|
||||||
|
renderTransferPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderWindow(win) {
|
function renderWindow(win) {
|
||||||
@@ -182,7 +182,6 @@ function renderWindow(win) {
|
|||||||
body.className = 'window-body';
|
body.className = 'window-body';
|
||||||
if (win.kind === 'explorer') renderExplorer(body, win);
|
if (win.kind === 'explorer') renderExplorer(body, win);
|
||||||
if (win.kind === 'properties') renderProperties(body);
|
if (win.kind === 'properties') renderProperties(body);
|
||||||
if (win.kind === 'queue') renderQueue(body);
|
|
||||||
body.addEventListener('scroll', () => {
|
body.addEventListener('scroll', () => {
|
||||||
if (win.data.lockScroll) {
|
if (win.data.lockScroll) {
|
||||||
body.scrollTop = win.data.lockScrollTop || 0;
|
body.scrollTop = win.data.lockScrollTop || 0;
|
||||||
@@ -361,6 +360,11 @@ function renderProperties(body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderQueue(body) {
|
function renderQueue(body) {
|
||||||
|
body.innerHTML = queueMarkup();
|
||||||
|
bindQueueActions(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueMarkup() {
|
||||||
const jobs = [...state.jobs.values()].reverse();
|
const jobs = [...state.jobs.values()].reverse();
|
||||||
const jobMarkup = jobs.length ? jobs.map((job) => `
|
const jobMarkup = jobs.length ? jobs.map((job) => `
|
||||||
<article class="job">
|
<article class="job">
|
||||||
@@ -377,7 +381,7 @@ function renderQueue(body) {
|
|||||||
</article>
|
</article>
|
||||||
`).join('') : '<p class="muted">Noch keine Debug-Einträge.</p>';
|
`).join('') : '<p class="muted">Noch keine Debug-Einträge.</p>';
|
||||||
|
|
||||||
body.innerHTML = `
|
return `
|
||||||
<div class="queue-list">${jobMarkup}</div>
|
<div class="queue-list">${jobMarkup}</div>
|
||||||
<section class="debug-log" aria-label="Debug-Protokoll">
|
<section class="debug-log" aria-label="Debug-Protokoll">
|
||||||
<header>
|
<header>
|
||||||
@@ -387,12 +391,105 @@ function renderQueue(body) {
|
|||||||
${debugMarkup}
|
${debugMarkup}
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
body.querySelector('[data-action="clear-debug"]')?.addEventListener('click', () => {
|
}
|
||||||
|
|
||||||
|
function bindQueueActions(root) {
|
||||||
|
root.querySelector('[data-action="clear-debug"]')?.addEventListener('click', () => {
|
||||||
state.debug = [];
|
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 = `
|
||||||
|
<header class="transfer-panel-header">
|
||||||
|
<strong>Transfers</strong>
|
||||||
|
<button type="button" class="icon" data-action="close-transfers" title="Schließen">×</button>
|
||||||
|
</header>
|
||||||
|
<div class="transfer-panel-body">${queueMarkup()}</div>
|
||||||
|
`;
|
||||||
|
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) {
|
async function loadExplorer(win, targetPath) {
|
||||||
win.data.loading = true;
|
win.data.loading = true;
|
||||||
win.data.error = null;
|
win.data.error = null;
|
||||||
@@ -453,8 +550,9 @@ async function createJob(type, source, destination, refreshWindowIds = []) {
|
|||||||
windowIds: [...new Set(refreshWindowIds)].filter(Boolean),
|
windowIds: [...new Set(refreshWindowIds)].filter(Boolean),
|
||||||
done: false
|
done: false
|
||||||
});
|
});
|
||||||
|
showTransferPanel();
|
||||||
pollJob(job.id);
|
pollJob(job.id);
|
||||||
render();
|
updateTransferPanel();
|
||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,13 +720,14 @@ async function pollJob(id) {
|
|||||||
try {
|
try {
|
||||||
const job = await api(apiUrl(`job.php?id=${encodeURIComponent(id)}`));
|
const job = await api(apiUrl(`job.php?id=${encodeURIComponent(id)}`));
|
||||||
state.jobs.set(id, job);
|
state.jobs.set(id, job);
|
||||||
render();
|
updateTransferPanel();
|
||||||
if (['queued', 'running', 'cancel_requested'].includes(job.status)) {
|
if (['queued', 'running', 'cancel_requested'].includes(job.status)) {
|
||||||
setTimeout(() => pollJob(id), 700);
|
setTimeout(() => pollJob(id), 700);
|
||||||
} else if (job.type === 'size') {
|
} else if (job.type === 'size') {
|
||||||
finishSizeJob(job);
|
finishSizeJob(job);
|
||||||
} else {
|
} else {
|
||||||
await refreshAfterJob(job);
|
await refreshAfterJob(job);
|
||||||
|
scheduleTransferAutoHide();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
recordDebug('job.poll.error', { id, error: serializeError(error) });
|
recordDebug('job.poll.error', { id, error: serializeError(error) });
|
||||||
@@ -1109,17 +1208,12 @@ function recordDebug(label, data = {}) {
|
|||||||
if (state.debug.length > 80) {
|
if (state.debug.length > 80) {
|
||||||
state.debug.splice(0, state.debug.length - 80);
|
state.debug.splice(0, state.debug.length - 80);
|
||||||
}
|
}
|
||||||
updateQueueWindows();
|
updateTransferPanel();
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateQueueWindows() {
|
function updateTransferPanel() {
|
||||||
for (const win of state.windows.filter((item) => item.kind === 'queue')) {
|
renderTransferPanel();
|
||||||
const body = workspace.querySelector(`.window[data-window-id="${win.id}"] .window-body`);
|
|
||||||
if (body) {
|
|
||||||
renderQueue(body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializeError(error) {
|
function serializeError(error) {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
font: 14px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
font: 14px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
grid-template-rows: auto 1fr;
|
grid-template-rows: auto 1fr;
|
||||||
min-height: calc(100vh - 170px);
|
min-height: calc(100vh - 170px);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
@@ -88,6 +90,11 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.u-nav .toolbar button.active {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.u-nav .workspace {
|
.u-nav .workspace {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -472,6 +479,71 @@
|
|||||||
border-color: var(--line);
|
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 {
|
.u-nav .queue-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -580,4 +652,12 @@
|
|||||||
.u-nav .resize-handle {
|
.u-nav .resize-handle {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.u-nav .transfer-panel {
|
||||||
|
left: 10px !important;
|
||||||
|
max-height: min(70vh, 520px);
|
||||||
|
min-width: 0;
|
||||||
|
right: 10px !important;
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "u-navigator">
|
<!ENTITY name "u-navigator">
|
||||||
<!ENTITY author "Michael Roll">
|
<!ENTITY author "Michael Roll">
|
||||||
<!ENTITY version "2026.06.23.r022">
|
<!ENTITY version "2026.06.23.r023">
|
||||||
<!ENTITY package "&name;-&version;.tgz">
|
<!ENTITY package "&name;-&version;.tgz">
|
||||||
<!ENTITY pluginURL "https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/u-navigator.plg">
|
<!ENTITY pluginURL "https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/u-navigator.plg">
|
||||||
<!ENTITY support "https://git.casaderoll.de/michael/Unraid-Navigator">
|
<!ENTITY support "https://git.casaderoll.de/michael/Unraid-Navigator">
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
<FILE Name="/boot/config/plugins/&name;/&package;">
|
<FILE Name="/boot/config/plugins/&name;/&package;">
|
||||||
<URL>https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/packages/&package;</URL>
|
<URL>https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/packages/&package;</URL>
|
||||||
<MD5>84c66337fdd03108c2c04690f88ee5b8</MD5>
|
<MD5>c57cb70f4d040d65869b6ffde469b18a</MD5>
|
||||||
</FILE>
|
</FILE>
|
||||||
|
|
||||||
<FILE Run="/bin/bash">
|
<FILE Run="/bin/bash">
|
||||||
|
|||||||
Reference in New Issue
Block a user