Add settings panel and readonly default
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
||||
set -eu
|
||||
|
||||
PLUGIN="u-navigator"
|
||||
VERSION="2026.06.23.r026"
|
||||
VERSION="2026.06.23.r027"
|
||||
PKG_DIR="packages"
|
||||
WORK_DIR=".plugin-build"
|
||||
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="newPropertiesButton" title="Eigenschaften oeffnen">Eigenschaften</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>
|
||||
</header>
|
||||
<main id="workspace" class="workspace" aria-label="U-Navigator Workspace"></main>
|
||||
|
||||
@@ -2,6 +2,39 @@
|
||||
|
||||
const UNAV_DEFAULT_ROOT = '/mnt/user';
|
||||
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 {
|
||||
$configured = getenv('U_NAV_ROOTS') ?: UNAV_DEFAULT_ROOT;
|
||||
@@ -29,7 +62,14 @@ function unav_roots(): array {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
<?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([
|
||||
'readOnly' => unav_read_only(),
|
||||
'debug' => $settings['debug'],
|
||||
'roots' => unav_roots(),
|
||||
]);
|
||||
|
||||
+132
-9
@@ -2,6 +2,7 @@ const workspace = document.querySelector('#workspace');
|
||||
const appShell = document.querySelector('.u-nav');
|
||||
const statusText = document.querySelector('#statusText');
|
||||
const transferButton = document.querySelector('#newQueueButton');
|
||||
const settingsButton = document.querySelector('#settingsButton');
|
||||
const API_BASE = window.UNAV_API_BASE || '/api';
|
||||
const APP_VERSION = window.UNAV_VERSION || 'dev';
|
||||
const state = {
|
||||
@@ -23,6 +24,7 @@ const state = {
|
||||
transferPanelVisible: false,
|
||||
transferPanelClosing: false,
|
||||
transferAutoHideTimer: null,
|
||||
settingsPanelVisible: false,
|
||||
nextWindowId: 1,
|
||||
zIndex: 20
|
||||
};
|
||||
@@ -37,6 +39,7 @@ const icons = {
|
||||
document.querySelector('#newExplorerButton').addEventListener('click', () => openExplorer());
|
||||
document.querySelector('#newPropertiesButton').addEventListener('click', () => openProperties());
|
||||
transferButton.addEventListener('click', () => toggleTransferPanel());
|
||||
settingsButton.addEventListener('click', () => toggleSettingsPanel());
|
||||
window.addEventListener('error', (event) => {
|
||||
recordDebug('window.error', {
|
||||
message: event.message,
|
||||
@@ -58,7 +61,7 @@ async function init() {
|
||||
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'} · ${APP_VERSION}`;
|
||||
updateStatusText();
|
||||
openExplorer(state.config.roots[0].path);
|
||||
} catch (error) {
|
||||
statusText.textContent = error.message;
|
||||
@@ -143,6 +146,7 @@ function render() {
|
||||
workspace.appendChild(renderContextMenu());
|
||||
}
|
||||
renderTransferPanel();
|
||||
renderSettingsPanel();
|
||||
}
|
||||
|
||||
function renderWindow(win) {
|
||||
@@ -213,11 +217,11 @@ function renderExplorer(body, win) {
|
||||
<div class="pathbar">
|
||||
<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">
|
||||
<button data-action="go">Öffnen</button>
|
||||
<button data-action="refresh">Neu laden</button>
|
||||
<button class="icon" data-action="go" title="Öffnen" aria-label="Öffnen">↵</button>
|
||||
<button class="icon" data-action="refresh" title="Neu laden" aria-label="Neu laden">↻</button>
|
||||
<div class="view-toggle" aria-label="Ansicht">
|
||||
<button class="${data.view !== 'icons' ? 'active' : ''}" data-action="view-list" title="Listenansicht">Liste</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-list" title="Listenansicht" aria-label="Listenansicht">☰</button>
|
||||
<button class="icon ${data.view === 'icons' ? 'active' : ''}" data-action="view-icons" title="Symbolansicht" aria-label="Symbolansicht">▦</button>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</article>
|
||||
`).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) => `
|
||||
<article class="debug-entry">
|
||||
<strong>#${item.id} ${escapeHtml(item.label)}</strong>
|
||||
@@ -387,7 +400,6 @@ function queueMarkup() {
|
||||
`).join('') : '<p class="muted">Noch keine Debug-Einträge.</p>';
|
||||
|
||||
return `
|
||||
<div class="queue-list">${jobMarkup}</div>
|
||||
<section class="debug-log" aria-label="Debug-Protokoll">
|
||||
<header>
|
||||
<strong>Debug</strong>
|
||||
@@ -495,6 +507,100 @@ function renderTransferPanel() {
|
||||
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) {
|
||||
win.data.loading = true;
|
||||
win.data.error = null;
|
||||
@@ -539,6 +645,10 @@ async function handleDrop(event, win, targetPath) {
|
||||
}
|
||||
|
||||
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 });
|
||||
const body = new URLSearchParams();
|
||||
body.set('source', source);
|
||||
@@ -631,6 +741,10 @@ function setDropEffect(event, effect) {
|
||||
}
|
||||
|
||||
async function renameEntry(win, entry) {
|
||||
if (isReadOnly()) {
|
||||
showError(new Error('Read-only mode ist aktiv.'));
|
||||
return;
|
||||
}
|
||||
const nextName = prompt('Neuer Name', entry.name);
|
||||
if (!nextName || nextName === entry.name) {
|
||||
return;
|
||||
@@ -643,6 +757,10 @@ async function renameEntry(win, entry) {
|
||||
}
|
||||
|
||||
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);
|
||||
if (!targetDir) {
|
||||
return;
|
||||
@@ -681,9 +799,11 @@ function renderContextMenu() {
|
||||
actions.push(['Öffnen', () => loadExplorer(win, entry.path)]);
|
||||
}
|
||||
actions.push(['Eigenschaften', () => showPropertiesForEntry(win, entry)]);
|
||||
actions.push(['Umbenennen', () => renameEntry(win, entry)]);
|
||||
actions.push(['Kopieren nach...', () => promptTransfer(win, entry, 'copy')]);
|
||||
actions.push(['Verschieben nach...', () => promptTransfer(win, entry, 'move')]);
|
||||
if (!isReadOnly()) {
|
||||
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)]);
|
||||
}
|
||||
@@ -1234,6 +1354,9 @@ function getCsrfToken() {
|
||||
}
|
||||
|
||||
function recordDebug(label, data = {}) {
|
||||
if (!state.config?.debug) {
|
||||
return null;
|
||||
}
|
||||
const item = {
|
||||
id: state.nextDebugId++,
|
||||
time: new Date().toLocaleTimeString(),
|
||||
|
||||
@@ -193,12 +193,12 @@
|
||||
}
|
||||
|
||||
.u-nav .pathbar .up-button {
|
||||
flex: 0 0 38px;
|
||||
flex: 0 0 32px;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.u-nav .pathbar > button:not(.icon),
|
||||
.u-nav .pathbar > button.icon,
|
||||
.u-nav .pathbar .view-toggle {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
@@ -225,7 +225,7 @@
|
||||
.u-nav .view-toggle button {
|
||||
border-radius: 0;
|
||||
flex: 0 0 auto;
|
||||
min-width: 88px;
|
||||
min-width: 32px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@@ -535,6 +535,61 @@
|
||||
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 {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -675,4 +730,11 @@
|
||||
right: 10px !important;
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
.u-nav .settings-panel {
|
||||
left: 10px !important;
|
||||
min-width: 0;
|
||||
right: 10px !important;
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE PLUGIN [
|
||||
<!ENTITY name "u-navigator">
|
||||
<!ENTITY author "Michael Roll">
|
||||
<!ENTITY version "2026.06.23.r026">
|
||||
<!ENTITY version "2026.06.23.r027">
|
||||
<!ENTITY package "&name;-&version;.tgz">
|
||||
<!ENTITY pluginURL "https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/u-navigator.plg">
|
||||
<!ENTITY support "https://git.casaderoll.de/michael/Unraid-Navigator">
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
<FILE Name="/boot/config/plugins/&name;/&package;">
|
||||
<URL>https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/packages/&package;</URL>
|
||||
<MD5>9af5add68cb11a97e1c88afae8621497</MD5>
|
||||
<MD5>145d13dcb4bf35ced2981b0e219cb3a0</MD5>
|
||||
</FILE>
|
||||
|
||||
<FILE Run="/bin/bash">
|
||||
|
||||
Reference in New Issue
Block a user