Add settings panel and readonly default

This commit is contained in:
Mikei386
2026-06-23 13:06:12 +02:00
parent 775d979429
commit 2d56f57c54
9 changed files with 254 additions and 16 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
set -eu set -eu
PLUGIN="u-navigator" PLUGIN="u-navigator"
VERSION="2026.06.23.r026" VERSION="2026.06.23.r027"
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.
@@ -17,6 +17,7 @@ $version = "__VERSION__";
<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="Transfers einblenden" aria-expanded="false">Transfers</button> <button id="newQueueButton" title="Transfers einblenden" aria-expanded="false">Transfers</button>
<button id="settingsButton" class="icon" title="Einstellungen" aria-label="Einstellungen" aria-expanded="false">⚙</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>
@@ -2,6 +2,39 @@
const UNAV_DEFAULT_ROOT = '/mnt/user'; const UNAV_DEFAULT_ROOT = '/mnt/user';
const UNAV_JOB_DIR = '/tmp/u-navigator/jobs'; const UNAV_JOB_DIR = '/tmp/u-navigator/jobs';
const UNAV_SETTINGS_FILE = '/boot/config/plugins/u-navigator/settings.json';
function unav_settings(): array {
$defaults = [
'readOnly' => true,
'debug' => false,
];
if (!is_file(UNAV_SETTINGS_FILE)) {
return $defaults;
}
$settings = json_decode(file_get_contents(UNAV_SETTINGS_FILE) ?: '{}', true);
if (!is_array($settings)) {
return $defaults;
}
return array_merge($defaults, [
'readOnly' => filter_var($settings['readOnly'] ?? $defaults['readOnly'], FILTER_VALIDATE_BOOLEAN),
'debug' => filter_var($settings['debug'] ?? $defaults['debug'], FILTER_VALIDATE_BOOLEAN),
]);
}
function unav_settings_write(array $settings): array {
$current = unav_settings();
$next = [
'readOnly' => filter_var($settings['readOnly'] ?? $current['readOnly'], FILTER_VALIDATE_BOOLEAN),
'debug' => filter_var($settings['debug'] ?? $current['debug'], FILTER_VALIDATE_BOOLEAN),
];
$dir = dirname(UNAV_SETTINGS_FILE);
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
unav_error(500, 'Could not create settings directory');
}
file_put_contents(UNAV_SETTINGS_FILE, json_encode($next, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), LOCK_EX);
return $next;
}
function unav_roots(): array { function unav_roots(): array {
$configured = getenv('U_NAV_ROOTS') ?: UNAV_DEFAULT_ROOT; $configured = getenv('U_NAV_ROOTS') ?: UNAV_DEFAULT_ROOT;
@@ -29,7 +62,14 @@ function unav_roots(): array {
} }
function unav_read_only(): bool { function unav_read_only(): bool {
return getenv('U_NAV_READ_ONLY') === '1'; $env = getenv('U_NAV_READ_ONLY');
if ($env === '1') {
return true;
}
if ($env === '0') {
return false;
}
return unav_settings()['readOnly'];
} }
function unav_function_available(string $name): bool { function unav_function_available(string $name): bool {
@@ -1,7 +1,19 @@
<?php <?php
require_once __DIR__ . '/common.php'; require_once __DIR__ . '/common.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = unav_request_data();
$settings = unav_settings_write($data);
unav_json([
'readOnly' => $settings['readOnly'],
'debug' => $settings['debug'],
'roots' => unav_roots(),
]);
}
$settings = unav_settings();
unav_json([ unav_json([
'readOnly' => unav_read_only(), 'readOnly' => unav_read_only(),
'debug' => $settings['debug'],
'roots' => unav_roots(), 'roots' => unav_roots(),
]); ]);
+129 -6
View File
@@ -2,6 +2,7 @@ const workspace = document.querySelector('#workspace');
const appShell = document.querySelector('.u-nav'); const appShell = document.querySelector('.u-nav');
const statusText = document.querySelector('#statusText'); const statusText = document.querySelector('#statusText');
const transferButton = document.querySelector('#newQueueButton'); const transferButton = document.querySelector('#newQueueButton');
const settingsButton = document.querySelector('#settingsButton');
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 = {
@@ -23,6 +24,7 @@ const state = {
transferPanelVisible: false, transferPanelVisible: false,
transferPanelClosing: false, transferPanelClosing: false,
transferAutoHideTimer: null, transferAutoHideTimer: null,
settingsPanelVisible: false,
nextWindowId: 1, nextWindowId: 1,
zIndex: 20 zIndex: 20
}; };
@@ -37,6 +39,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());
transferButton.addEventListener('click', () => toggleTransferPanel()); transferButton.addEventListener('click', () => toggleTransferPanel());
settingsButton.addEventListener('click', () => toggleSettingsPanel());
window.addEventListener('error', (event) => { window.addEventListener('error', (event) => {
recordDebug('window.error', { recordDebug('window.error', {
message: event.message, message: event.message,
@@ -58,7 +61,7 @@ async function init() {
if (!state.config.roots?.length) { if (!state.config.roots?.length) {
throw new Error('No U-Navigator roots configured'); throw new Error('No U-Navigator roots configured');
} }
statusText.textContent = `${state.config.roots.length} Root, ${state.config.readOnly ? 'Read-only' : 'Schreibzugriff aktiv'} · ${APP_VERSION}`; updateStatusText();
openExplorer(state.config.roots[0].path); openExplorer(state.config.roots[0].path);
} catch (error) { } catch (error) {
statusText.textContent = error.message; statusText.textContent = error.message;
@@ -143,6 +146,7 @@ function render() {
workspace.appendChild(renderContextMenu()); workspace.appendChild(renderContextMenu());
} }
renderTransferPanel(); renderTransferPanel();
renderSettingsPanel();
} }
function renderWindow(win) { function renderWindow(win) {
@@ -213,11 +217,11 @@ function renderExplorer(body, win) {
<div class="pathbar"> <div class="pathbar">
<button class="icon up-button" data-action="up" title="Eine Ebene zurueck" aria-label="Eine Ebene zurueck">←</button> <button class="icon up-button" data-action="up" title="Eine Ebene zurueck" aria-label="Eine Ebene zurueck">←</button>
<input value="${escapeAttr(data.path || '')}" aria-label="Pfad"> <input value="${escapeAttr(data.path || '')}" aria-label="Pfad">
<button data-action="go">Öffnen</button> <button class="icon" data-action="go" title="Öffnen" aria-label="Öffnen">↵</button>
<button data-action="refresh">Neu laden</button> <button class="icon" data-action="refresh" title="Neu laden" aria-label="Neu laden">↻</button>
<div class="view-toggle" aria-label="Ansicht"> <div class="view-toggle" aria-label="Ansicht">
<button class="${data.view !== 'icons' ? 'active' : ''}" data-action="view-list" title="Listenansicht">Liste</button> <button class="icon ${data.view !== 'icons' ? 'active' : ''}" data-action="view-list" title="Listenansicht" aria-label="Listenansicht">☰</button>
<button class="${data.view === 'icons' ? 'active' : ''}" data-action="view-icons" title="Symbolansicht">Symbole</button> <button class="icon ${data.view === 'icons' ? 'active' : ''}" data-action="view-icons" title="Symbolansicht" aria-label="Symbolansicht">▦</button>
</div> </div>
</div> </div>
<div class="drop-zone ${data.dragOver ? 'drag-over' : ''}"> <div class="drop-zone ${data.dragOver ? 'drag-over' : ''}">
@@ -378,6 +382,15 @@ function queueMarkup() {
<div class="progress"><div style="width:${Number(job.progress) || 0}%"></div></div> <div class="progress"><div style="width:${Number(job.progress) || 0}%"></div></div>
</article> </article>
`).join('') : '<p class="muted">Keine Transfers.</p>'; `).join('') : '<p class="muted">Keine Transfers.</p>';
const debugSection = state.config?.debug ? debugMarkup() : '';
return `
<div class="queue-list">${jobMarkup}</div>
${debugSection}
`;
}
function debugMarkup() {
const debugMarkup = state.debug.length ? state.debug.slice(-12).reverse().map((item) => ` const debugMarkup = state.debug.length ? state.debug.slice(-12).reverse().map((item) => `
<article class="debug-entry"> <article class="debug-entry">
<strong>#${item.id} ${escapeHtml(item.label)}</strong> <strong>#${item.id} ${escapeHtml(item.label)}</strong>
@@ -387,7 +400,6 @@ function queueMarkup() {
`).join('') : '<p class="muted">Noch keine Debug-Einträge.</p>'; `).join('') : '<p class="muted">Noch keine Debug-Einträge.</p>';
return ` return `
<div class="queue-list">${jobMarkup}</div>
<section class="debug-log" aria-label="Debug-Protokoll"> <section class="debug-log" aria-label="Debug-Protokoll">
<header> <header>
<strong>Debug</strong> <strong>Debug</strong>
@@ -495,6 +507,100 @@ function renderTransferPanel() {
transferButton.setAttribute('aria-expanded', 'true'); transferButton.setAttribute('aria-expanded', 'true');
} }
function toggleSettingsPanel() {
state.settingsPanelVisible = !state.settingsPanelVisible;
renderSettingsPanel();
}
function renderSettingsPanel() {
const existing = appShell.querySelector('.settings-panel');
if (!state.settingsPanelVisible) {
existing?.remove();
settingsButton.classList.remove('active');
settingsButton.setAttribute('aria-expanded', 'false');
return;
}
const panel = existing || document.createElement('aside');
panel.className = 'settings-panel';
panel.setAttribute('aria-label', 'Einstellungen');
panel.innerHTML = `
<header class="settings-panel-header">
<strong>Einstellungen</strong>
<button type="button" class="icon" data-action="close-settings" title="Schließen">×</button>
</header>
<div class="settings-panel-body">
<label class="setting-row">
<span>
<strong>Debug</strong>
<small>Debug-Protokoll im Transfers-Panel anzeigen</small>
</span>
<input type="checkbox" data-setting="debug" ${state.config?.debug ? 'checked' : ''}>
</label>
<label class="setting-row">
<span>
<strong>Schreibzugriff</strong>
<small>Upload, Rename, Move und Copy erlauben</small>
</span>
<input type="checkbox" data-setting="write" ${state.config && !state.config.readOnly ? 'checked' : ''}>
</label>
</div>
`;
if (!existing) {
appShell.appendChild(panel);
}
const buttonRect = settingsButton.getBoundingClientRect();
const shellRect = appShell.getBoundingClientRect();
panel.style.right = `${Math.max(16, shellRect.right - buttonRect.right)}px`;
panel.style.top = `${buttonRect.bottom - shellRect.top + 8}px`;
panel.querySelector('[data-action="close-settings"]').addEventListener('click', () => {
state.settingsPanelVisible = false;
renderSettingsPanel();
});
panel.querySelector('[data-setting="debug"]').addEventListener('change', (event) => {
saveSettings({ debug: event.currentTarget.checked });
});
panel.querySelector('[data-setting="write"]').addEventListener('change', (event) => {
saveSettings({ readOnly: !event.currentTarget.checked });
});
settingsButton.classList.add('active');
settingsButton.setAttribute('aria-expanded', 'true');
}
async function saveSettings(changes) {
const body = new URLSearchParams();
const readOnly = changes.readOnly ?? state.config.readOnly;
const debug = changes.debug ?? state.config.debug;
body.set('readOnly', readOnly ? '1' : '0');
body.set('debug', debug ? '1' : '0');
appendCsrf(body);
try {
state.config = await api(apiUrl('config.php'), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body
});
updateStatusText();
if (!state.config.debug) {
state.debug = [];
}
render();
} catch (error) {
showError(error);
}
}
function updateStatusText() {
if (!state.config) return;
statusText.textContent = `${state.config.roots.length} Root, ${state.config.readOnly ? 'Read-only' : 'Schreibzugriff aktiv'} · Debug ${state.config.debug ? 'aktiv' : 'aus'} · ${APP_VERSION}`;
}
function isReadOnly() {
return Boolean(state.config?.readOnly);
}
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;
@@ -539,6 +645,10 @@ async function handleDrop(event, win, targetPath) {
} }
async function createJob(type, source, destination, refreshWindowIds = []) { async function createJob(type, source, destination, refreshWindowIds = []) {
if (['move', 'copy'].includes(type) && isReadOnly()) {
showError(new Error('Read-only mode ist aktiv.'));
return null;
}
recordDebug('job.create.request', { type, source, destination, refreshWindowIds }); recordDebug('job.create.request', { type, source, destination, refreshWindowIds });
const body = new URLSearchParams(); const body = new URLSearchParams();
body.set('source', source); body.set('source', source);
@@ -631,6 +741,10 @@ function setDropEffect(event, effect) {
} }
async function renameEntry(win, entry) { async function renameEntry(win, entry) {
if (isReadOnly()) {
showError(new Error('Read-only mode ist aktiv.'));
return;
}
const nextName = prompt('Neuer Name', entry.name); const nextName = prompt('Neuer Name', entry.name);
if (!nextName || nextName === entry.name) { if (!nextName || nextName === entry.name) {
return; return;
@@ -643,6 +757,10 @@ async function renameEntry(win, entry) {
} }
async function promptTransfer(win, entry, type) { async function promptTransfer(win, entry, type) {
if (isReadOnly()) {
showError(new Error('Read-only mode ist aktiv.'));
return;
}
const targetDir = prompt(type === 'copy' ? 'Kopieren nach Ordner' : 'Verschieben nach Ordner', win.data.path); const targetDir = prompt(type === 'copy' ? 'Kopieren nach Ordner' : 'Verschieben nach Ordner', win.data.path);
if (!targetDir) { if (!targetDir) {
return; return;
@@ -681,9 +799,11 @@ function renderContextMenu() {
actions.push(['Öffnen', () => loadExplorer(win, entry.path)]); actions.push(['Öffnen', () => loadExplorer(win, entry.path)]);
} }
actions.push(['Eigenschaften', () => showPropertiesForEntry(win, entry)]); actions.push(['Eigenschaften', () => showPropertiesForEntry(win, entry)]);
if (!isReadOnly()) {
actions.push(['Umbenennen', () => renameEntry(win, entry)]); actions.push(['Umbenennen', () => renameEntry(win, entry)]);
actions.push(['Kopieren nach...', () => promptTransfer(win, entry, 'copy')]); actions.push(['Kopieren nach...', () => promptTransfer(win, entry, 'copy')]);
actions.push(['Verschieben nach...', () => promptTransfer(win, entry, 'move')]); actions.push(['Verschieben nach...', () => promptTransfer(win, entry, 'move')]);
}
} else { } else {
actions.push(['Neu laden', () => loadExplorer(win, win.data.path)]); actions.push(['Neu laden', () => loadExplorer(win, win.data.path)]);
} }
@@ -1234,6 +1354,9 @@ function getCsrfToken() {
} }
function recordDebug(label, data = {}) { function recordDebug(label, data = {}) {
if (!state.config?.debug) {
return null;
}
const item = { const item = {
id: state.nextDebugId++, id: state.nextDebugId++,
time: new Date().toLocaleTimeString(), time: new Date().toLocaleTimeString(),
+65 -3
View File
@@ -193,12 +193,12 @@
} }
.u-nav .pathbar .up-button { .u-nav .pathbar .up-button {
flex: 0 0 38px; flex: 0 0 32px;
font-size: 18px; font-size: 18px;
line-height: 1; line-height: 1;
} }
.u-nav .pathbar > button:not(.icon), .u-nav .pathbar > button.icon,
.u-nav .pathbar .view-toggle { .u-nav .pathbar .view-toggle {
flex: 0 0 auto; flex: 0 0 auto;
} }
@@ -225,7 +225,7 @@
.u-nav .view-toggle button { .u-nav .view-toggle button {
border-radius: 0; border-radius: 0;
flex: 0 0 auto; flex: 0 0 auto;
min-width: 88px; min-width: 32px;
overflow: visible; overflow: visible;
} }
@@ -535,6 +535,61 @@
padding: 10px; padding: 10px;
} }
.u-nav .settings-panel {
animation: transfer-slide-down 160ms ease-out both;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
min-width: 320px;
overflow: hidden;
position: absolute;
transform-origin: top right;
width: min(380px, calc(100% - 32px));
z-index: 10010;
}
.u-nav .settings-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 .settings-panel-body {
display: grid;
gap: 2px;
padding: 8px;
}
.u-nav .setting-row {
align-items: center;
border-radius: 6px;
display: grid;
gap: 14px;
grid-template-columns: 1fr auto;
padding: 10px;
}
.u-nav .setting-row:hover {
background: var(--surface-2);
}
.u-nav .setting-row small {
color: var(--muted);
display: block;
margin-top: 2px;
}
.u-nav .setting-row input {
accent-color: var(--accent);
height: 18px;
width: 18px;
}
@keyframes transfer-slide-down { @keyframes transfer-slide-down {
from { from {
opacity: 0; opacity: 0;
@@ -675,4 +730,11 @@
right: 10px !important; right: 10px !important;
width: auto !important; width: auto !important;
} }
.u-nav .settings-panel {
left: 10px !important;
min-width: 0;
right: 10px !important;
width: auto !important;
}
} }
+2 -2
View File
@@ -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.r026"> <!ENTITY version "2026.06.23.r027">
<!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>9af5add68cb11a97e1c88afae8621497</MD5> <MD5>145d13dcb4bf35ced2981b0e219cb3a0</MD5>
</FILE> </FILE>
<FILE Run="/bin/bash"> <FILE Run="/bin/bash">