Kaynağa Gözat

feat(ui): Add a collapsible side navigation

Fold the sidebar to an icon rail with a menu button in the top bar.
Hover or keyboard focus unfolds it, and the choice is kept per browser.
The logo stays pinned while the menu scrolls, and the unused pin state
left over from v3.x is removed.

Fixes #16392
Martin Hauser 1 hafta önce
ebeveyn
işleme
e4ee29caa6

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
netbox/project-static/dist/netbox.css


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
netbox/project-static/dist/netbox.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 13 - 0
netbox/project-static/js/setmode.js

@@ -39,3 +39,16 @@ function initMode() {
     // Default to light mode
     return setMode("light", true);
 }
+
+/**
+ * Fold the sidebar before rendering if the user folded it.
+ */
+function initSidebar() {
+    try {
+        if (localStorage.getItem("netbox-sidebar") === "folded") {
+            document.documentElement.setAttribute("data-bs-sidebar", "folded-hover");
+        }
+    } catch {
+        // Blocked storage leaves the sidebar expanded
+    }
+}

+ 119 - 167
netbox/project-static/src/sidenav.ts

@@ -1,15 +1,14 @@
-import { StateManager } from './state';
 import { getElements, isElement } from './util';
 
-type NavState = { pinned: boolean };
-type BodyAttr = 'show' | 'hide' | 'hidden' | 'pinned';
-
 // Keep in sync with Bootstrap's `lg` breakpoint and `navbar-expand-lg` in base/layout.html.
 const SIDENAV_DESKTOP_MEDIA = '(min-width: 992px)';
 
-// Session-scoped, unlike the localStorage `netbox-sidenav` pin state.
+// Session-scoped, unlike the localStorage fold state.
 const SCROLL_STATE_KEY = 'netbox-sidenav-scroll';
 
+// Read before the first paint by initSidebar() in js/setmode.js.
+const FOLD_STATE_KEY = 'netbox-sidebar';
+
 class SideNav {
   /**
    * Sidenav container element.
@@ -17,145 +16,131 @@ class SideNav {
   private base: HTMLElement;
 
   /**
-   * SideNav internal state manager.
+   * The menu below the logo, which scrolls in the desktop layout.
+   */
+  private menu: HTMLElement;
+
+  /**
+   * The desktop layout, the only one where the sidebar folds.
    */
-  private state: StateManager<NavState>;
+  private desktopMedia: MediaQueryList;
 
   /**
    * First nav item matching the current page, cached at construction.
    */
   private activePageLink: Nullable<HTMLDivElement> = null;
 
+  /**
+   * Menu offset of the unfolded layout, the expanded sidebar or the hover overlay. The compact
+   * rail hides the open groups, so it cannot hold this offset.
+   */
+  private unfoldedScrollTop = 0;
+
+  /**
+   * A folded rail reveals the active item on its first hover, unless the unfolded menu was
+   * scrolled before.
+   */
+  private revealPending = false;
+
   constructor(base: HTMLElement) {
     this.base = base;
-    this.state = new StateManager<NavState>(
-      { pinned: true },
-      { persist: true, key: 'netbox-sidenav' },
-    );
+    this.menu = base.querySelector<HTMLElement>('.navbar-collapse') ?? base;
+    this.desktopMedia = window.matchMedia(SIDENAV_DESKTOP_MEDIA);
 
     this.init();
     this.initLinks();
     this.initScrollPosition();
+    this.initMenuScroll();
   }
 
   /**
-   * Determine if `document.body` has a sidenav attribute.
+   * Whether the sidebar is set to fold on desktop.
    */
-  private bodyHas(attr: BodyAttr): boolean {
-    return document.body.hasAttribute(`data-sidenav-${attr}`);
+  private isFolded(): boolean {
+    return document.documentElement.getAttribute('data-bs-sidebar') === 'folded-hover';
   }
 
   /**
-   * Remove sidenav attributes from `document.body`.
+   * Whether the menu shows its unfolded layout, expanded or opened by hover or keyboard focus.
    */
-  private bodyRemove(...attrs: BodyAttr[]): void {
-    for (const attr of attrs) {
-      document.body.removeAttribute(`data-sidenav-${attr}`);
-    }
-  }
-
-  /**
-   * Add sidenav attributes to `document.body`.
-   */
-  private bodyAdd(...attrs: BodyAttr[]): void {
-    for (const attr of attrs) {
-      document.body.setAttribute(`data-sidenav-${attr}`, '');
-    }
+  private isUnfolded(): boolean {
+    return !this.isFolded() || this.base.matches(':hover, :has(:focus-visible)');
   }
 
   /**
    * Set initial values & add event listeners.
    */
-  private init() {
-    for (const toggler of this.base.querySelectorAll('.sidenav-toggle')) {
-      toggler.addEventListener('click', event => this.onToggle(event));
+  private init(): void {
+    for (const toggle of getElements<HTMLButtonElement>('.sidebar-toggle')) {
+      toggle.addEventListener('click', () => this.setFolded(!this.isFolded()));
     }
+    this.syncToggles();
 
-    for (const toggler of getElements<HTMLButtonElement>('.sidenav-toggle-mobile')) {
-      toggler.addEventListener('click', event => this.onMobileToggle(event));
-    }
-
-    const desktopMedia = window.matchMedia(SIDENAV_DESKTOP_MEDIA);
-    this.setResponsiveState(desktopMedia.matches);
-    desktopMedia.addEventListener('change', event => {
-      this.setResponsiveState(event.matches);
+    // Below lg the menu does not scroll and loses its offset.
+    this.desktopMedia.addEventListener('change', () => {
       this.initLinks();
+      if (!this.desktopMedia.matches) return;
+      if (this.isUnfolded()) this.menu.scrollTop = this.unfoldedScrollTop;
+      this.shade();
     });
 
-    window.addEventListener('resize', () => this.onResize());
-
-    this.base.addEventListener('mouseenter', () => this.onEnter());
-    this.base.addEventListener('mouseleave', () => this.onLeave());
-  }
-
-  /**
-   * Apply the appropriate sidenav state for the current responsive layout.
-   */
-  private setResponsiveState(isDesktop: boolean): void {
-    this.bodyRemove('hide');
+    // The hover overlay returns to where the unfolded menu was, unless keyboard focus opened it.
+    this.base.addEventListener('mouseenter', () => {
+      if (this.isFolded() && !this.base.matches(':has(:focus-visible)')) {
+        this.menu.scrollTop = this.unfoldedScrollTop;
+        if (this.revealPending && this.desktopMedia.matches) {
+          this.revealPending = false;
+          this.revealActiveLink();
+        }
+      }
+    });
 
-    if (isDesktop && this.state.get('pinned')) {
-      this.bodyRemove('hidden');
-      this.bodyAdd('show', 'pinned');
-    } else {
-      this.bodyRemove('show', 'pinned');
-      this.bodyAdd('hidden');
-    }
+    // A mouse unpin would leave focus on the pin that the folded rail clips away.
+    this.base.addEventListener('mouseleave', () => {
+      if (!this.isFolded()) return;
+      for (const toggle of getElements<HTMLButtonElement>('.sidebar-toggle')) {
+        if (toggle === document.activeElement && !toggle.matches(':focus-visible')) {
+          toggle.blur();
+        }
+      }
+    });
   }
 
   /**
-   * If the sidenav is shown, expand active nav links. Otherwise, collapse them.
+   * Expand the active menu group on desktop, folded or not, and collapse it on mobile.
    */
   private initLinks(): void {
     for (const link of this.getActiveLinks()) {
       this.activePageLink ??= link;
-
-      if (this.bodyHas('show')) {
-        this.activateLink(link, 'expand');
-      } else if (this.bodyHas('hidden')) {
-        this.activateLink(link, 'collapse');
-      }
+      this.activateLink(link, this.desktopMedia.matches ? 'expand' : 'collapse');
     }
   }
 
   /**
-   * Show the sidenav.
-   */
-  private show(): void {
-    this.bodyAdd('show');
-    this.bodyRemove('hidden', 'hide');
-  }
-
-  /**
-   * Hide the sidenav and close any nested collapse elements.
+   * Fold or unfold the desktop sidebar and remember the choice. Open menu groups stay open.
    */
-  private hide(): void {
-    this.bodyAdd('hidden');
-    this.bodyRemove('pinned', 'show');
-    for (const collapse of this.base.querySelectorAll('.collapse')) {
-      collapse.classList.remove('show');
+  private setFolded(folded: boolean): void {
+    if (folded) {
+      document.documentElement.setAttribute('data-bs-sidebar', 'folded-hover');
+    } else {
+      document.documentElement.removeAttribute('data-bs-sidebar');
+      this.menu.scrollTop = this.unfoldedScrollTop;
     }
+    try {
+      localStorage.setItem(FOLD_STATE_KEY, folded ? 'folded' : 'expanded');
+    } catch {
+      // A failed write still applies to the current page.
+    }
+    this.syncToggles();
   }
 
   /**
-   * Pin the sidenav.
-   */
-  private pin(): void {
-    this.bodyAdd('show', 'pinned');
-    this.bodyRemove('hidden');
-    this.state.set('pinned', true);
-  }
-
-  /**
-   * Unpin the sidenav.
+   * Mark the pin buttons pressed while the sidebar is pinned.
    */
-  private unpin(): void {
-    this.bodyRemove('pinned', 'show');
-    this.bodyAdd('hidden');
-    for (const collapse of this.base.querySelectorAll('.collapse')) {
-      collapse.classList.remove('show');
+  private syncToggles(): void {
+    for (const toggle of getElements<HTMLButtonElement>('.sidebar-toggle')) {
+      toggle.setAttribute('aria-pressed', String(!this.isFolded()));
     }
-    this.state.set('pinned', false);
   }
 
   /**
@@ -211,107 +196,74 @@ class SideNav {
     }
   }
 
-  /**
-   * Whether the aside currently overflows, as opposed to whether the layout lets it scroll at all.
-   */
-  private isScrollable(): boolean {
-    return this.base.scrollHeight > this.base.clientHeight;
-  }
-
   /**
    * Restore the offset saved for this browser tab, reveal the active item when it falls outside
-   * the sidebar viewport, and save the offset again on the way out. One offset per tab, so a
-   * shorter menu can clamp a deeper value.
+   * the menu viewport, and save the offset again on the way out. One offset per tab, so a shorter
+   * menu can clamp a deeper value. A folded sidebar applies both when the overlay opens on hover,
+   * the reveal only the first time.
    */
   private initScrollPosition(): void {
-    const initiallyScrollable = this.isScrollable();
     const storedScrollTop = sessionStorage.getItem(SCROLL_STATE_KEY);
 
     // Leave a missing offset alone so the browser's own restoration survives.
     if (storedScrollTop !== null) {
-      this.base.scrollTop = Number(storedScrollTop);
+      this.unfoldedScrollTop = Number(storedScrollTop);
+      // The pointer can already rest on a folded rail.
+      if (this.isUnfolded()) {
+        this.menu.scrollTop = this.unfoldedScrollTop;
+      }
     }
 
     // Reveal only in the fixed desktop layout. In the mobile flow layout, scrollIntoView would scroll the page.
-    if (window.matchMedia(SIDENAV_DESKTOP_MEDIA).matches) {
-      const menu = this.activePageLink?.closest<HTMLElement>('.nav-item.dropdown');
-      // A closed dropdown has no box to measure, so reveal its heading instead.
-      const target =
-        menu && !menu.querySelector('.dropdown-menu.show') ? menu : this.activePageLink;
-
-      target?.scrollIntoView({ block: 'nearest' });
+    if (this.desktopMedia.matches) {
+      if (this.isUnfolded()) {
+        this.revealActiveLink();
+      } else {
+        this.revealPending = true;
+      }
     }
 
+    // Only scrolling changes the offset, so a menu that cannot scroll keeps the stored one.
     window.addEventListener('pagehide', () => {
-      // Zero counts only from a sidebar that was already scrollable at load, not one that grew on hover.
-      if (this.isScrollable() && (initiallyScrollable || this.base.scrollTop > 0)) {
-        sessionStorage.setItem(SCROLL_STATE_KEY, String(this.base.scrollTop));
-      }
+      sessionStorage.setItem(SCROLL_STATE_KEY, String(this.unfoldedScrollTop));
     });
   }
 
   /**
-   * Show the sidenav and expand any active menu groups.
-   */
-  private onEnter(): void {
-    if (!this.bodyHas('pinned')) {
-      this.bodyRemove('hide', 'hidden');
-      this.bodyAdd('show');
-      for (const link of this.getActiveLinks()) {
-        this.activateLink(link, 'expand');
-      }
-    }
-  }
-
-  /**
-   * Hide the sidenav and collapse any active menu groups.
+   * Scroll the active item into the menu viewport when it falls outside.
    */
-  private onLeave(): void {
-    if (!this.bodyHas('pinned')) {
-      this.bodyRemove('show');
-      this.bodyAdd('hide');
-      for (const link of this.getActiveLinks()) {
-        this.activateLink(link, 'collapse');
-      }
-      this.bodyRemove('hide');
-      this.bodyAdd('hidden');
-    }
-  }
+  private revealActiveLink(): void {
+    const menu = this.activePageLink?.closest<HTMLElement>('.nav-item.dropdown');
+    // A closed dropdown has no box to measure, so reveal its heading instead.
+    const target = menu && !menu.querySelector('.dropdown-menu.show') ? menu : this.activePageLink;
 
-  /**
-   * Close the (unpinned) sidenav when the window is resized.
-   */
-  private onResize(): void {
-    if (this.bodyHas('show') && !this.bodyHas('pinned')) {
-      this.bodyRemove('show');
-      this.bodyAdd('hidden');
-    }
+    target?.scrollIntoView({ block: 'nearest' });
   }
 
   /**
-   * Pin & unpin the sidenav when the pin button is toggled.
+   * Track the unfolded menu's offset and shade the logo row while the menu is scrolled.
    */
-  private onToggle(event: Event): void {
-    event.preventDefault();
-
-    if (this.state.get('pinned')) {
-      this.unpin();
-    } else {
-      this.pin();
-    }
+  private initMenuScroll(): void {
+    this.menu.addEventListener(
+      'scroll',
+      () => {
+        // Neither the compact rail nor the mobile menu holds the unfolded desktop offset.
+        if (this.desktopMedia.matches && this.isUnfolded()) {
+          this.unfoldedScrollTop = this.menu.scrollTop;
+          this.revealPending = false;
+        }
+        this.shade();
+      },
+      { passive: true },
+    );
+    this.shade();
   }
 
   /**
-   * Handle sidenav visibility state for small screens. On small screens, there is no pinned state,
-   * only open/closed.
+   * Mark the sidebar while its menu is scrolled, so the logo row casts a shadow.
    */
-  private onMobileToggle(event: Event): void {
-    event.preventDefault();
-    if (this.bodyHas('hidden')) {
-      this.show();
-    } else {
-      this.hide();
-    }
+  private shade(): void {
+    this.base.classList.toggle('menu-scrolled', this.menu.scrollTop > 0);
   }
 }
 

+ 3 - 0
netbox/project-static/styles/_tabler-config.scss

@@ -7,6 +7,7 @@
   $font-family-sans-serif: netbox.$font-family-sans-serif,
   $font-family-monospace: netbox.$font-family-monospace,
   $sidebar-width: netbox.$sidebar-width,
+  $sidebar-folded-width: netbox.$sidebar-folded-width,
   $btn-padding-x: netbox.$btn-padding-x,
   $btn-padding-y: netbox.$btn-padding-y,
   $table-cell-padding-x: netbox.$table-cell-padding-x,
@@ -60,6 +61,8 @@
   $dropdown-divider-margin-y: 0.25rem,
   $enable-smooth-scroll: false,
   $mark-padding: 0.1875em 0,
+  // About 1.25:1 on both the white and the navy bars
+  $navbar-border-color: light-dark(var(--gray-200), var(--gray-800)),
   $navbar-light-active-bg: rgba(0, 0, 0, 0.06),
   $pre-bg: transparent,
   $pre-color: inherit,

+ 3 - 0
netbox/project-static/styles/_variables.scss

@@ -7,6 +7,9 @@ $font-family-monospace: 'Roboto Mono Variable', monospace;
 // Set the navigation sidebar width
 $sidebar-width: 18rem;
 
+// Folded sidebar width, centering the 1.25rem menu icons between their 1rem link paddings
+$sidebar-folded-width: 3.25rem;
+
 // Reduce the default button padding
 $btn-padding-x: 0.5rem;
 $btn-padding-y: 0.25rem;

+ 124 - 11
netbox/project-static/styles/transitional/_navigation.scss

@@ -1,19 +1,47 @@
 // Navbar and light theme styling
 .navbar-vertical.navbar-expand-lg {
-  // Scroll the whole sidebar as Tabler 1.4 did. sidenav.ts saves and restores its offset.
   @include media-breakpoint-up(lg) {
-    overflow-y: auto;
+    // Clip the wide logo while the hover unfold grows the width, without making the aside scroll
+    overflow-x: clip;
 
-    > [class^='container'] {
-      height: auto;
-      min-height: 100%;
+    // Match the top bar's height, folded or not, even in a short window
+    .navbar-brand {
+      flex-shrink: 0;
+      justify-content: center;
+      height: $navbar-height;
+      padding-block: 0;
     }
-    .navbar-collapse {
-      min-height: auto;
-      overflow-y: visible;
+
+    // Shade the logo row once the menu below it is scrolled (sidenav.ts sets the class)
+    &.menu-scrolled .navbar-brand {
+      position: relative;
+      z-index: 2;
+      // The top bar's bottom line, continued across the logo row
+      box-shadow:
+        inset 0 calc(-1 * var(--tblr-navbar-border-width)) 0 0 var(--tblr-navbar-border-color),
+        var(--tblr-shadow-md);
     }
-    .navbar-brand {
+
+    // The top bar buttons' box, which Tabler sets on .navbar .navbar-nav .nav-link
+    $pin-box: 2.5rem;
+
+    .sidebar-toggle {
+      position: absolute;
+      top: $navbar-height * 0.5;
+      // From the start edge, so the hover unfold reveals the pin instead of moving it. The box is
+      // centered on an icon at the menu icons' inset.
+      inset-inline-start: calc(
+        #{$sidebar-width} - var(--tblr-page-padding) - #{($sidebar-nav-icon-width + $pin-box) * 0.5}
+      );
+      // Above the shaded logo row
+      z-index: 3;
       justify-content: center;
+      min-width: $pin-box;
+      min-height: $pin-box;
+      border-radius: var(--tblr-border-radius);
+      // .nav-link's hover tint, which only .navbar-nav defines
+      --tblr-nav-link-hover-bg: var(--tblr-hover-bg);
+      translate: 0 -50%;
     }
 
     // Tabler 1.6 drops the bar that marks the active menu group
@@ -105,6 +133,18 @@
     a:hover {
       text-decoration: none;
     }
+
+    // Stack the logo and the edition label without line box gaps
+    > a {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      gap: 0.125rem;
+    }
+    // The label hangs below the logo, which sits 0.6rem from the row's top
+    .netbox-edition {
+      margin-bottom: -0.45rem;
+    }
   }
 
   // Navigation geometric graphic for non-community editions
@@ -114,7 +154,8 @@
     left: 0;
     mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.3) 100%);
     opacity: 0.5;
-    position: fixed;
+    // The aside clips it while the hover unfold grows
+    position: absolute;
     user-drag: none;
     user-select: none;
     -moz-user-select: none;
@@ -129,6 +170,77 @@
   }
 }
 
+// Tabler hides submenus opened by class on a folded sidebar, keep them for the hover unfold
+html[data-bs-sidebar='folded-hover'] .navbar-vertical.navbar-expand-lg {
+  @include media-breakpoint-up(lg) {
+    .navbar-collapse .navbar-nav .dropdown-menu.show {
+      display: block;
+    }
+
+    // Keep the expanded width, so the unfold animation reveals the menu instead of rewrapping it
+    .navbar-collapse {
+      width: $sidebar-width;
+    }
+  }
+}
+
+// initSidebar() sets the attribute before the first paint
+html:not([data-bs-sidebar='folded-hover']) .sidebar-toggle .mdi-pin-off,
+html[data-bs-sidebar='folded-hover'] .sidebar-toggle .mdi-pin {
+  display: none;
+}
+
+// Folded rail, unfolded again by hover or keyboard focus (Tabler's folded-hover)
+html[data-bs-sidebar='folded-hover']
+  .navbar-vertical.navbar-expand-lg:not(:hover):not(:has(:focus-visible)) {
+  @include media-breakpoint-up(lg) {
+    // Clip the expanded-width menu to the rail, since NetBox opens no flyouts
+    overflow-x: clip;
+
+    // Crop the logo to its leading square mark, centered over the icons
+    .navbar-brand {
+      --tblr-sidebar-brand-padding-x: #{($sidebar-folded-width - $navbar-brand-image-height) * 0.5};
+      justify-content: flex-start;
+    }
+    .navbar-brand-image {
+      width: $navbar-brand-image-height;
+      object-fit: cover;
+      object-position: left center;
+    }
+
+    // Collapse the edition label like the titles. Its line stays, so nothing shifts on unfold.
+    .netbox-edition {
+      width: 0;
+      overflow: hidden;
+      opacity: 0;
+    }
+
+    // The release info stays as an invisible spacer, so the menu keeps its height
+    .netbox-release {
+      visibility: hidden;
+    }
+    // The 18rem motif does not fit the rail
+    img.motif {
+      display: none;
+    }
+
+    // Open submenus stay open, out of view but in the accessibility tree, until the rail unfolds.
+    // Without Tabler's flyout offset and min-width, the hidden box stays inside the menu.
+    .dropdown-menu.show {
+      inset-inline-start: 0 !important;
+      min-width: 0 !important;
+      @include visually-hidden();
+    }
+
+    // Keep the menu a scroll container, so the overlay can return to the unfolded offset, but
+    // never a sideways one
+    .navbar-collapse {
+      overflow-x: hidden;
+      overflow-y: auto;
+    }
+  }
+}
+
 // Light theme styling
 html[data-bs-theme='light'] .navbar-vertical.navbar-expand-lg {
   // Background Gradient
@@ -161,7 +273,8 @@ html[data-bs-theme='dark'] .navbar-vertical.navbar-expand-lg {
       background-color: $navbar-dark-active-bg !important;
     }
   }
-  .text-secondary {
+  // The pin keeps the top bar's button color
+  .text-secondary:not(.sidebar-toggle) {
     color: $bright-teal !important;
   }
 

+ 4 - 1
netbox/templates/base/base.html

@@ -24,7 +24,7 @@
     {# Page title #}
     <title>{% block title %}{% trans "Home" %}{% endblock %} | NetBox</title>
 
-    {# Initialize color mode #}
+    {# Initialize color mode and sidebar folding #}
     <script
       type="text/javascript"
       src="{% static_with_params 'setmode.js' v=settings.RELEASE.version %}"
@@ -36,6 +36,9 @@
       })();
       window.CSRF_TOKEN = "{{ csrf_token }}";
     </script>
+    <script type="text/javascript">
+      initSidebar();
+    </script>
 
     {# Static resources #}
     <link

+ 8 - 2
netbox/templates/base/layout.html

@@ -37,10 +37,16 @@ Blocks:
           <a href="{% url 'home' %}">
             <img src="{% static 'logo_netbox_dark_teal.svg' %}" alt="{% trans "NetBox Logo" %}" class="navbar-brand-image hide-theme-dark">
             <img src="{% static 'logo_netbox_bright_teal.svg' %}" alt="{% trans "NetBox Logo" %}" class="navbar-brand-image hide-theme-light">
-            <div class="netbox-edition fw-semibold text-center fs-5 my-1">{{ settings.RELEASE.edition }}</div>
+            <div class="netbox-edition fw-semibold text-center fs-6">{{ settings.RELEASE.edition }}</div>
           </a>
         </h1>
 
+        {# Sidebar pin (desktop view) #}
+        <button type="button" class="nav-link sidebar-toggle fs-2 p-0 text-secondary d-none d-lg-flex" title="{% trans "Pin sidebar" %}" aria-label="{% trans "Pin sidebar" %}" aria-pressed="true" data-bs-toggle="tooltip" data-bs-trigger="hover" data-bs-placement="bottom">
+          <i class="mdi mdi-pin" aria-hidden="true"></i>
+          <i class="mdi mdi-pin-off" aria-hidden="true"></i>
+        </button>
+
         {# User menu (mobile view) #}
         <div class="navbar-nav flex-row align-items-center d-lg-none">
           {% plugin_navbar %}
@@ -53,7 +59,7 @@ Blocks:
           {% nav %}
 
           {# Release info #}
-          <div class="text-muted text-center fs-5 my-3 px-3">
+          <div class="netbox-release text-muted text-center fs-5 my-3 px-3">
             {{ settings.RELEASE.name }}
             {% if not settings.RELEASE.features.commercial and not settings.ISOLATED_DEPLOYMENT %}
               <div>

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor