diff --git a/README.md b/README.md index 4ab0051..fc4daa9 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Release-Tag oder ein bestimmter Commit verwendet werden: ```bash /opt/netbox/venv/bin/pip install --upgrade --force-reinstall \ - "git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@v0.5.0" + "git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@v0.5.1" ``` Alternativ kann hinter dem `@` die vollständige Commit-ID stehen. @@ -202,14 +202,13 @@ Unter **Plugins > NetBox Utilities > Navigation personalisieren** sieht der Benutzer alle Menüs, für die er aktuell Berechtigungen besitzt. Die Pfeiltasten ändern die Reihenfolge; der Schalter blendet ein Menü aus. -Zusätzlich kann jeder Benutzer die Desktop-Navigation auf einen reinen -**Icon-Modus** einklappen und die Breite der ausgeklappten Navigation zwischen -216 und 408 Pixeln einstellen. Am unteren Rand der Navigation stehen dafür -direkte Schaltflächen für **schmaler**, **einklappen/ausklappen** und **breiter** -zur Verfügung. Änderungen über diese Schaltflächen werden sofort im -Benutzerprofil gespeichert. Im Icon-Modus öffnen sich die Menüinhalte als -seitliches Flyout; auf kleinen beziehungsweise mobilen Ansichten bleibt das -normale NetBox-Menü erhalten. +Zusätzlich kann jeder Benutzer die Desktop-Navigation direkt an der rechten +Kante zwischen 216 und 408 Pixeln breiter oder schmaler ziehen. Ein kleiner +Pfeilgriff in der Mitte dieser Kante klappt sie auf einen reinen **Icon-Modus** +ein und wieder aus. Dafür gibt es kein zusätzliches Bedienmenü. Beim Ziehen +wird die neue Breite nach dem Loslassen im Benutzerprofil gespeichert. Im +Icon-Modus öffnen sich die Menüinhalte als seitliches Flyout; auf kleinen +beziehungsweise mobilen Ansichten bleibt das normale NetBox-Menü erhalten. **Zurücksetzen** stellt neben Reihenfolge und Sichtbarkeit auch die normale ausgeklappte Breite von 288 Pixeln wieder her. diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py index 2e715f6..11dccfb 100644 --- a/netbox_utilities/__init__.py +++ b/netbox_utilities/__init__.py @@ -1,11 +1,13 @@ from netbox.plugins import PluginConfig, get_plugin_config +__version__ = "0.5.1" + class NetBoxUtilitiesConfig(PluginConfig): name = "netbox_utilities" verbose_name = "NetBox Utilities" description = "Navigation, tenant filtering, bulk module installation, and atomic rack reordering" - version = "0.5.0" + version = __version__ author = "LKE" base_url = "utilities" min_version = "4.6.5" diff --git a/netbox_utilities/navigation_helpers.py b/netbox_utilities/navigation_helpers.py index 39e5832..8d3c3a5 100644 --- a/netbox_utilities/navigation_helpers.py +++ b/netbox_utilities/navigation_helpers.py @@ -2,7 +2,6 @@ from netbox.navigation.menu import get_menus SIDEBAR_WIDTH_MIN = 216 SIDEBAR_WIDTH_MAX = 408 -SIDEBAR_WIDTH_STEP = 24 SIDEBAR_WIDTH_DEFAULT = 288 @@ -12,19 +11,15 @@ def normalize_sidebar_width(value): except (TypeError, ValueError): return SIDEBAR_WIDTH_DEFAULT - width = min(SIDEBAR_WIDTH_MAX, max(SIDEBAR_WIDTH_MIN, width)) - steps = (width - SIDEBAR_WIDTH_MIN + SIDEBAR_WIDTH_STEP // 2) // SIDEBAR_WIDTH_STEP - return SIDEBAR_WIDTH_MIN + steps * SIDEBAR_WIDTH_STEP + return min(SIDEBAR_WIDTH_MAX, max(SIDEBAR_WIDTH_MIN, width)) -def update_sidebar_layout(collapsed, width, action): +def update_sidebar_layout(collapsed, width, action, requested_width=None): width = normalize_sidebar_width(width) if action == "toggle": return not collapsed, width - if action == "smaller": - return collapsed, max(SIDEBAR_WIDTH_MIN, width - SIDEBAR_WIDTH_STEP) - if action == "larger": - return collapsed, min(SIDEBAR_WIDTH_MAX, width + SIDEBAR_WIDTH_STEP) + if action == "resize": + return False, normalize_sidebar_width(requested_width) raise ValueError("Unbekannte Navigationsaktion.") diff --git a/netbox_utilities/static/netbox_utilities/navigation.js b/netbox_utilities/static/netbox_utilities/navigation.js index f582171..8c83a14 100644 --- a/netbox_utilities/static/netbox_utilities/navigation.js +++ b/netbox_utilities/static/netbox_utilities/navigation.js @@ -16,10 +16,9 @@ function normalizeWidth(value) { const minimum = Number(preferences.width_min) || 216; const maximum = Number(preferences.width_max) || 408; - const step = Number(preferences.width_step) || 24; - const requested = Number(value) || 288; - const snapped = minimum + Math.round((requested - minimum) / step) * step; - return Math.min(maximum, Math.max(minimum, snapped)); + const requested = Number(value); + const width = Number.isFinite(requested) ? Math.round(requested) : 288; + return Math.min(maximum, Math.max(minimum, width)); } function applyRootLayout() { @@ -58,7 +57,8 @@ sidebar.querySelectorAll(':scope > li.nav-item.dropdown > button.nav-link').forEach((button) => { if (preferences.collapsed) { button.dataset.netboxUtilitiesTitle = 'true'; - button.title = button.getAttribute('aria-label') || ''; + button.title = + button.getAttribute('aria-label') || button.querySelector('.nav-link-title')?.textContent.trim() || ''; const menu = button.closest('.nav-item.dropdown')?.querySelector(':scope > .dropdown-menu.show'); if (menu) { const top = Math.max(8, button.getBoundingClientRect().top); @@ -94,111 +94,149 @@ }); } - function createControl(action, icon, label) { - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'btn btn-sm btn-ghost-secondary netbox-utilities-sidebar-control'; - button.dataset.action = action; - button.title = label; - button.setAttribute('aria-label', label); - const iconElement = document.createElement('i'); - iconElement.className = `mdi ${icon}`; - iconElement.setAttribute('aria-hidden', 'true'); - button.appendChild(iconElement); - return button; + async function saveLayout(action, width) { + const body = new URLSearchParams({action}); + if (width !== undefined) body.set('width', String(width)); + const response = await fetch(preferences.layout_update_url, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + 'X-CSRFToken': preferences.csrf_token, + }, + body, + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); } - function updateControls(controls, sidebar, pending = false) { - const minimum = Number(preferences.width_min) || 216; - const maximum = Number(preferences.width_max) || 408; - const smaller = controls.querySelector('[data-action="smaller"]'); - const larger = controls.querySelector('[data-action="larger"]'); - const toggle = controls.querySelector('[data-action="toggle"]'); - const toggleIcon = toggle.querySelector('i'); - - smaller.disabled = pending || preferences.collapsed || preferences.width <= minimum; - larger.disabled = pending || preferences.collapsed || preferences.width >= maximum; - toggle.disabled = pending; - toggleIcon.className = preferences.collapsed - ? 'mdi mdi-arrow-expand-right' - : 'mdi mdi-arrow-collapse-left'; - const toggleLabel = preferences.collapsed ? 'Navigation ausklappen' : 'Navigation auf Icons einklappen'; - toggle.title = toggleLabel; - toggle.setAttribute('aria-label', toggleLabel); - updateMenuTitles(sidebar); - } - - function applyAction(action) { - const step = Number(preferences.width_step) || 24; - if (action === 'toggle') preferences.collapsed = !preferences.collapsed; - if (action === 'smaller') preferences.width = normalizeWidth(preferences.width - step); - if (action === 'larger') preferences.width = normalizeWidth(preferences.width + step); - applyRootLayout(); - } - - function addLayoutControls(sidebarMenu, sidebar) { + function addResizeHandle(sidebarElement, sidebar) { if (!preferences.layout_update_url || !preferences.csrf_token) return; - const controls = document.createElement('div'); - controls.className = 'netbox-utilities-sidebar-controls'; - controls.setAttribute('role', 'group'); - controls.setAttribute('aria-label', 'Navigationsgröße'); - controls.append( - createControl('smaller', 'mdi-minus', 'Navigation schmaler'), - createControl('toggle', 'mdi-arrow-collapse-left', 'Navigation auf Icons einklappen'), - createControl('larger', 'mdi-plus', 'Navigation breiter') - ); + const handle = document.createElement('div'); + handle.className = 'netbox-utilities-sidebar-resizer'; + handle.tabIndex = 0; + handle.setAttribute('role', 'separator'); + handle.setAttribute('aria-orientation', 'vertical'); + handle.setAttribute('aria-label', 'Breite der Navigation ändern'); - const releaseInfo = sidebarMenu.querySelector(':scope > .text-muted'); - sidebarMenu.insertBefore(controls, releaseInfo || null); - updateControls(controls, sidebar); + const toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'netbox-utilities-sidebar-toggle'; + const toggleIcon = document.createElement('i'); + toggleIcon.className = 'mdi'; + toggleIcon.setAttribute('aria-hidden', 'true'); + toggle.appendChild(toggleIcon); + handle.appendChild(toggle); + sidebarElement.appendChild(handle); - controls.addEventListener('click', async (event) => { - const button = event.target.closest('button[data-action]'); - if (!button || button.disabled) return; + let resizing = false; + let previousLayout = null; - const previous = {collapsed: preferences.collapsed, width: preferences.width}; - applyAction(button.dataset.action); - updateControls(controls, sidebar, true); + function updateHandle() { + const minimum = Number(preferences.width_min) || 216; + const maximum = Number(preferences.width_max) || 408; + handle.setAttribute('aria-valuemin', String(minimum)); + handle.setAttribute('aria-valuemax', String(maximum)); + handle.setAttribute('aria-valuenow', String(preferences.collapsed ? minimum : preferences.width)); + const label = preferences.collapsed ? 'Navigation ausklappen' : 'Navigation auf Icons einklappen'; + toggle.title = label; + toggle.setAttribute('aria-label', label); + toggleIcon.className = preferences.collapsed ? 'mdi mdi-chevron-right' : 'mdi mdi-chevron-left'; + updateMenuTitles(sidebar); + } + function applyWidth(clientX) { + preferences.collapsed = false; + preferences.width = normalizeWidth(clientX); + applyRootLayout(); + updateHandle(); + } + + async function persist(action, width, fallback) { try { - const response = await fetch(preferences.layout_update_url, { - method: 'POST', - credentials: 'same-origin', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', - 'X-CSRFToken': preferences.csrf_token, - }, - body: new URLSearchParams({action: button.dataset.action}), - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const saved = await response.json(); + const saved = await saveLayout(action, width); preferences.collapsed = Boolean(saved.collapsed); preferences.width = normalizeWidth(saved.width); - controls.classList.remove('text-danger'); - controls.removeAttribute('title'); + handle.classList.remove('netbox-utilities-sidebar-save-error'); + handle.removeAttribute('data-save-error'); } catch (error) { - preferences.collapsed = previous.collapsed; - preferences.width = previous.width; - controls.classList.add('text-danger'); - controls.title = 'Die Navigationseinstellung konnte nicht gespeichert werden.'; + preferences.collapsed = fallback.collapsed; + preferences.width = fallback.width; + handle.classList.add('netbox-utilities-sidebar-save-error'); + handle.dataset.saveError = 'Die Navigationseinstellung konnte nicht gespeichert werden.'; console.error('Could not save NetBox navigation layout', error); } - applyRootLayout(); - updateControls(controls, sidebar); + updateHandle(); + } + + toggle.addEventListener('click', async () => { + if (toggle.disabled) return; + const previous = {collapsed: preferences.collapsed, width: preferences.width}; + preferences.collapsed = !preferences.collapsed; + applyRootLayout(); + updateHandle(); + toggle.disabled = true; + await persist('toggle', undefined, previous); + toggle.disabled = false; }); + + handle.addEventListener('pointerdown', (event) => { + if (event.target.closest('button')) return; + event.preventDefault(); + resizing = true; + previousLayout = {collapsed: preferences.collapsed, width: preferences.width}; + handle.setPointerCapture(event.pointerId); + root.classList.add('netbox-utilities-navigation-resizing'); + applyWidth(event.clientX); + }); + + handle.addEventListener('pointermove', (event) => { + if (resizing) applyWidth(event.clientX); + }); + + async function finishResize(event) { + if (!resizing) return; + resizing = false; + if (handle.hasPointerCapture(event.pointerId)) handle.releasePointerCapture(event.pointerId); + root.classList.remove('netbox-utilities-navigation-resizing'); + const fallback = previousLayout; + previousLayout = null; + await persist('resize', preferences.width, fallback); + } + + handle.addEventListener('pointerup', finishResize); + handle.addEventListener('pointercancel', finishResize); + + handle.addEventListener('keydown', async (event) => { + const minimum = Number(preferences.width_min) || 216; + const maximum = Number(preferences.width_max) || 408; + const directions = {ArrowLeft: -16, ArrowRight: 16}; + let width; + if (event.key in directions) width = preferences.width + directions[event.key]; + if (event.key === 'Home') width = minimum; + if (event.key === 'End') width = maximum; + if (width === undefined) return; + event.preventDefault(); + const previous = {collapsed: preferences.collapsed, width: preferences.width}; + applyWidth(width); + await persist('resize', preferences.width, previous); + }); + + updateHandle(); } function initializeNavigation() { const sidebarMenu = document.getElementById('sidebar-menu'); const sidebar = document.querySelector('#sidebar-menu > ul.navbar-nav'); - if (!sidebarMenu || !sidebar) return; + const sidebarElement = document.querySelector('.page > aside.navbar-vertical.navbar-expand-lg'); + if (!sidebarMenu || !sidebar || !sidebarElement) return; applyMenuPreferences(sidebar); updateMenuTitles(sidebar); configureCollapsedDropdowns(sidebar); - addLayoutControls(sidebarMenu, sidebar); + addResizeHandle(sidebarElement, sidebar); } applyRootLayout(); diff --git a/netbox_utilities/static/netbox_utilities/netbox_utilities.css b/netbox_utilities/static/netbox_utilities/netbox_utilities.css index f410f21..b156873 100644 --- a/netbox_utilities/static/netbox_utilities/netbox_utilities.css +++ b/netbox_utilities/static/netbox_utilities/netbox_utilities.css @@ -10,7 +10,7 @@ white-space: nowrap; } -.netbox-utilities-sidebar-controls { +.netbox-utilities-sidebar-resizer { display: none; } @@ -38,24 +38,77 @@ width: var(--netbox-utilities-sidebar-effective-width); } - .netbox-utilities-sidebar-controls { - position: sticky; - bottom: 0.5rem; - z-index: 2; - display: flex; - justify-content: center; - gap: 0.25rem; - margin: 0.75rem; - padding: 0.35rem; - border: 1px solid var(--tblr-border-color-translucent); - border-radius: var(--tblr-border-radius); - background: var(--tblr-bg-surface); - box-shadow: var(--tblr-box-shadow-sm); + .netbox-utilities-sidebar-resizer { + position: fixed; + top: 0; + bottom: 0; + left: calc(var(--netbox-utilities-sidebar-effective-width) - 5px); + z-index: 1045; + display: block; + width: 10px; + cursor: col-resize; + touch-action: none; + outline: 0; + transition: left 160ms ease; } - .netbox-utilities-sidebar-control { - flex: 0 1 3rem; - padding-inline: 0.5rem; + .netbox-utilities-sidebar-resizer::before { + position: absolute; + top: 0; + bottom: 0; + left: 4px; + width: 2px; + content: ''; + background: transparent; + transition: background-color 120ms ease; + } + + .netbox-utilities-sidebar-resizer:hover::before, + .netbox-utilities-sidebar-resizer:focus-visible::before, + html.netbox-utilities-navigation-resizing .netbox-utilities-sidebar-resizer::before { + background: var(--tblr-primary); + } + + .netbox-utilities-sidebar-toggle { + position: absolute; + top: 50%; + left: 50%; + display: grid; + width: 24px; + height: 44px; + padding: 0; + color: var(--tblr-secondary-color); + cursor: pointer; + background: var(--tblr-bg-surface); + border: 1px solid var(--tblr-border-color); + border-radius: 0 var(--tblr-border-radius) var(--tblr-border-radius) 0; + box-shadow: var(--tblr-box-shadow-sm); + transform: translate(0, -50%); + place-items: center; + } + + .netbox-utilities-sidebar-toggle:hover, + .netbox-utilities-sidebar-toggle:focus-visible { + color: var(--tblr-primary); + border-color: var(--tblr-primary); + outline: 0; + } + + .netbox-utilities-sidebar-save-error::before { + background: var(--tblr-danger) !important; + } + + html.netbox-utilities-navigation-resizing, + html.netbox-utilities-navigation-resizing body { + cursor: col-resize !important; + user-select: none; + } + + html.netbox-utilities-navigation-resizing .page > aside.navbar-vertical.navbar-expand-lg, + html.netbox-utilities-navigation-resizing .page > aside.navbar-vertical.navbar-expand-lg ~ .navbar, + html.netbox-utilities-navigation-resizing .page > aside.navbar-vertical.navbar-expand-lg ~ .page-wrapper, + html.netbox-utilities-navigation-resizing .netbox-utilities-sidebar-resizer { + transition: none; } html.netbox-utilities-navigation-collapsed .navbar-vertical.navbar-expand-lg > .container-fluid { @@ -105,19 +158,17 @@ box-shadow: var(--tblr-box-shadow-lg); } - html.netbox-utilities-navigation-collapsed .netbox-utilities-sidebar-controls { - margin-inline: 0; - } - - html.netbox-utilities-navigation-collapsed .netbox-utilities-sidebar-control:not([data-action="toggle"]) { - display: none; + html.netbox-utilities-navigation-collapsed .netbox-utilities-sidebar-resizer { + cursor: e-resize; } } @media (prefers-reduced-motion: reduce) { html.netbox-utilities-navigation-layout .page > aside.navbar-vertical.navbar-expand-lg, html.netbox-utilities-navigation-layout .page > aside.navbar-vertical.navbar-expand-lg ~ .navbar, - html.netbox-utilities-navigation-layout .page > aside.navbar-vertical.navbar-expand-lg ~ .page-wrapper { + html.netbox-utilities-navigation-layout .page > aside.navbar-vertical.navbar-expand-lg ~ .page-wrapper, + .netbox-utilities-sidebar-resizer, + .netbox-utilities-sidebar-resizer::before { transition: none; } } diff --git a/netbox_utilities/template_content.py b/netbox_utilities/template_content.py index 7365f5d..7e8758f 100644 --- a/netbox_utilities/template_content.py +++ b/netbox_utilities/template_content.py @@ -4,12 +4,12 @@ from django.urls import reverse from netbox.plugins import PluginTemplateExtension from tenancy.models import Tenant, TenantGroup +from . import __version__ from .models import NavigationPreference from .navigation_helpers import ( SIDEBAR_WIDTH_DEFAULT, SIDEBAR_WIDTH_MAX, SIDEBAR_WIDTH_MIN, - SIDEBAR_WIDTH_STEP, get_visible_menus, normalize_preferences, normalize_sidebar_width, @@ -43,7 +43,6 @@ class UtilitiesGlobalContent(PluginTemplateExtension): "width": normalize_sidebar_width(preference.sidebar_width if preference else SIDEBAR_WIDTH_DEFAULT), "width_min": SIDEBAR_WIDTH_MIN, "width_max": SIDEBAR_WIDTH_MAX, - "width_step": SIDEBAR_WIDTH_STEP, "layout_update_url": reverse("plugins:netbox_utilities:navigation_layout"), "csrf_token": get_token(request), } @@ -52,6 +51,7 @@ class UtilitiesGlobalContent(PluginTemplateExtension): { "navigation_enabled": request.user.is_authenticated and navigation_customization_enabled(), "navigation_preferences": preference_data, + "asset_version": __version__, }, ) diff --git a/netbox_utilities/templates/netbox_utilities/head.html b/netbox_utilities/templates/netbox_utilities/head.html index 80441ea..503550a 100644 --- a/netbox_utilities/templates/netbox_utilities/head.html +++ b/netbox_utilities/templates/netbox_utilities/head.html @@ -1,6 +1,6 @@ {% load static %} - + {% if navigation_enabled %} {{ navigation_preferences|json_script:"netbox-utilities-navigation-data" }} - + {% endif %} diff --git a/netbox_utilities/templates/netbox_utilities/navigation_preferences.html b/netbox_utilities/templates/netbox_utilities/navigation_preferences.html index 75d9f15..aa7e19a 100644 --- a/netbox_utilities/templates/netbox_utilities/navigation_preferences.html +++ b/netbox_utilities/templates/netbox_utilities/navigation_preferences.html @@ -22,46 +22,11 @@ {% else %}

- Passe Breite und Darstellung an, verschiebe Menüs mit den Pfeilen und blende nicht benötigte Bereiche aus. + Verschiebe Menüs mit den Pfeilen und blende nicht benötigte Bereiche aus. Berechtigungen von NetBox werden dadurch nicht verändert.