checktheroads 4 лет назад
Родитель
Сommit
3752cb3e56

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/config.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/jobs.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/jobs.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/lldp.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-dark.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-external.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-light.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/status.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/status.js.map


+ 26 - 0
netbox/project-static/package.json

@@ -41,6 +41,32 @@
     "eslint-plugin-prettier": "^3.3.1",
     "eslint-plugin-prettier": "^3.3.1",
     "prettier": "^2.2.1",
     "prettier": "^2.2.1",
     "prettier-eslint": "^12.0.0",
     "prettier-eslint": "^12.0.0",
+    "stylelint": "^13.13.1",
+    "stylelint-config-twbs-bootstrap": "^2.2.3",
     "typescript": "^4.2.3"
     "typescript": "^4.2.3"
+  },
+  "stylelint": {
+    "extends": "stylelint-config-twbs-bootstrap/scss",
+    "rules": {
+      "selector-max-class": 16,
+      "selector-max-compound-selectors": 16,
+      "selector-no-qualifying-type": [
+        true,
+        {
+          "ignore": [
+            "attribute",
+            "class"
+          ]
+        }
+      ],
+      "number-leading-zero": "always",
+      "string-quotes": "single",
+      "selector-pseudo-element-colon-notation": "single",
+      "declaration-property-value-disallowed-list": {
+        "border": "none",
+        "outline": "none"
+      },
+      "scss/selector-no-union-class-name": true
+    }
   }
   }
 }
 }

+ 238 - 14
netbox/project-static/src/sidenav.ts

@@ -1,22 +1,246 @@
-import { getElement, getElements } from './util';
+import { StateManager } from './state';
+import { getElements, isElement } from './util';
 
 
-const breakpoints = {
-  sm: 540,
-  md: 720,
-  lg: 960,
-  xl: 1140,
-};
+type NavState = { pinned: boolean };
+type BodyAttr = 'show' | 'hide' | 'hidden' | 'pinned';
 
 
-function toggleBodyPosition(position: HTMLBodyElement['style']['position']): void {
-  for (const element of getElements('body')) {
-    element.style.position = position;
+class SideNav {
+  /**
+   * Sidenav container element.
+   */
+  private base: HTMLDivElement;
+
+  /**
+   * SideNav internal state manager.
+   */
+  private state: StateManager<NavState>;
+
+  constructor(base: HTMLDivElement) {
+    this.base = base;
+    this.state = new StateManager<NavState>({ pinned: true }, { persist: true });
+
+    this.init();
+    this.initLinks();
+  }
+
+  /**
+   * Determine if `document.body` has a sidenav attribute.
+   */
+  private bodyHas(attr: BodyAttr): boolean {
+    return document.body.hasAttribute(`data-sidenav-${attr}`);
+  }
+
+  /**
+   * Remove sidenav attributes from `document.body`.
+   */
+  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}`, '');
+    }
+  }
+
+  /**
+   * Set initial values & add event listeners.
+   */
+  private init() {
+    for (const toggler of this.base.querySelectorAll('.sidenav-toggle')) {
+      toggler.addEventListener('click', event => this.onToggle(event));
+    }
+
+    for (const toggler of getElements<HTMLButtonElement>('.sidenav-toggle-mobile')) {
+      toggler.addEventListener('click', event => this.onMobileToggle(event));
+    }
+
+    if (window.innerWidth > 1200) {
+      if (this.state.get('pinned')) {
+        this.pin();
+      }
+
+      if (!this.state.get('pinned')) {
+        this.unpin();
+      }
+      window.addEventListener('resize', () => this.onResize());
+    }
+
+    if (window.innerWidth < 1200) {
+      this.bodyRemove('hide');
+      this.bodyAdd('hidden');
+      window.addEventListener('resize', () => this.onResize());
+    }
+
+    this.base.addEventListener('mouseenter', () => this.onEnter());
+    this.base.addEventListener('mouseleave', () => this.onLeave());
+  }
+
+  /**
+   * If the sidenav is shown, expand active nav links. Otherwise, collapse them.
+   */
+  private initLinks(): void {
+    for (const link of this.getActiveLinks()) {
+      if (this.bodyHas('show')) {
+        this.activateLink(link, 'expand');
+      } else if (this.bodyHas('hidden')) {
+        this.activateLink(link, 'collapse');
+      }
+    }
+  }
+
+  private show(): void {
+    this.bodyAdd('show');
+    this.bodyRemove('hidden', 'hide');
+  }
+
+  private hide(): void {
+    this.bodyAdd('hidden');
+    this.bodyRemove('pinned', 'show');
+    for (const collapse of this.base.querySelectorAll('.collapse')) {
+      collapse.classList.remove('show');
+    }
+  }
+
+  /**
+   * Pin the sidenav.
+   */
+  private pin(): void {
+    this.bodyAdd('show', 'pinned');
+    this.bodyRemove('hidden');
+    this.state.set('pinned', true);
+  }
+
+  /**
+   * Unpin the sidenav.
+   */
+  private unpin(): void {
+    this.bodyRemove('pinned', 'show');
+    this.bodyAdd('hidden');
+    for (const collapse of this.base.querySelectorAll('.collapse')) {
+      collapse.classList.remove('show');
+    }
+    this.state.set('pinned', false);
+  }
+
+  /**
+   * Starting from the bottom-most active link in the element tree, work backwards to determine the
+   * link's containing `.collapse` element and the `.collapse` element's containing `.nav-link`
+   * element. Once found, expand (or collapse) the `.collapse` element and add (or remove) the
+   * `.active` class to the the parent `.nav-link` element.
+   *
+   * @param link Active nav link
+   * @param action Expand or Collapse
+   */
+  private activateLink(link: HTMLAnchorElement, action: 'expand' | 'collapse'): void {
+    // Find the closest .collapse element, which should contain `link`.
+    const collapse = link.closest('.collapse') as Nullable<HTMLDivElement>;
+    if (isElement(collapse)) {
+      // Find the closest `.nav-link`, which should be adjacent to the `.collapse` element.
+      const groupLink = collapse.parentElement?.querySelector('.nav-link');
+      if (isElement(groupLink)) {
+        groupLink.classList.add('active');
+        switch (action) {
+          case 'expand':
+            groupLink.setAttribute('aria-expanded', 'true');
+            collapse.classList.add('show');
+            link.classList.add('active');
+            break;
+          case 'collapse':
+            groupLink.setAttribute('aria-expanded', 'false');
+            collapse.classList.remove('show');
+            link.classList.remove('active');
+            break;
+        }
+      }
+    }
+  }
+
+  /**
+   * Find any nav links with `href` attributes matching the current path, to determine which nav
+   * link should be considered active.
+   */
+  private *getActiveLinks(): Generator<HTMLAnchorElement> {
+    for (const link of this.base.querySelectorAll<HTMLAnchorElement>(
+      '.navbar-nav .nav .nav-item a.nav-link',
+    )) {
+      const href = new RegExp(link.href, 'gi');
+      if (Boolean(window.location.href.match(href))) {
+        yield link;
+      }
+    }
+  }
+
+  /**
+   * Show the sidenav and expand any active sections.
+   */
+  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 sections.
+   */
+  private onLeave(): void {
+    if (!this.bodyHas('pinned')) {
+      this.bodyRemove('show');
+      this.bodyAdd('hide');
+      for (const link of this.getActiveLinks()) {
+        this.activateLink(link, 'collapse');
+      }
+      setTimeout(() => {
+        this.bodyRemove('hide');
+        this.bodyAdd('hidden');
+      }, 300);
+    }
+  }
+
+  /**
+   * 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');
+    }
+  }
+
+  /**
+   * Pin & unpin the sidenav when the pin button is toggled.
+   */
+  private onToggle(event: Event): void {
+    event.preventDefault();
+
+    if (this.state.get('pinned')) {
+      this.unpin();
+    } else {
+      this.pin();
+    }
+  }
+
+  private onMobileToggle(event: Event): void {
+    event.preventDefault();
+    if (this.bodyHas('hidden')) {
+      this.show();
+    } else {
+      this.hide();
+    }
   }
   }
 }
 }
 
 
 export function initSideNav() {
 export function initSideNav() {
-  const element = getElement<HTMLAnchorElement>('sidebarMenu');
-  if (element !== null && document.body.clientWidth < breakpoints.lg) {
-    element.addEventListener('shown.bs.collapse', () => toggleBodyPosition('fixed'));
-    element.addEventListener('hidden.bs.collapse', () => toggleBodyPosition('relative'));
+  for (const sidenav of getElements<HTMLDivElement>('.sidenav')) {
+    new SideNav(sidenav);
   }
   }
 }
 }

+ 23 - 0
netbox/project-static/src/util.ts

@@ -56,6 +56,13 @@ export function isTruthy<V extends string | number | boolean | null | undefined>
   return false;
   return false;
 }
 }
 
 
+/**
+ * Type guard to determine if a value is an `Element`.
+ */
+export function isElement(obj: Element | null | undefined): obj is Element {
+  return typeof obj !== null && typeof obj !== 'undefined';
+}
+
 /**
 /**
  * Retrieve the CSRF token from cookie storage.
  * Retrieve the CSRF token from cookie storage.
  */
  */
@@ -152,6 +159,22 @@ export function getElement<E extends HTMLElement>(id: string): Nullable<E> {
   return document.getElementById(id) as Nullable<E>;
   return document.getElementById(id) as Nullable<E>;
 }
 }
 
 
+export function removeElements(...selectors: string[]): void {
+  for (const element of getElements(...selectors)) {
+    element.remove();
+  }
+}
+
+export function elementWidth<E extends HTMLElement>(element: Nullable<E>): number {
+  let width = 0;
+  if (element !== null) {
+    const style = getComputedStyle(element);
+    const pre = style.width.replace('px', '');
+    width = parseFloat(pre);
+  }
+  return width;
+}
+
 /**
 /**
  * scrollTo() wrapper that calculates a Y offset relative to `element`, but also factors in an
  * scrollTo() wrapper that calculates a Y offset relative to `element`, but also factors in an
  * offset relative to div#content-title. This ensures we scroll to the element, but leave enough
  * offset relative to div#content-title. This ensures we scroll to the element, but leave enough

+ 1 - 0
netbox/project-static/styles/_external.scss

@@ -2,3 +2,4 @@
 @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
 @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
 @import '../node_modules/@mdi/font/css/materialdesignicons.min.css';
 @import '../node_modules/@mdi/font/css/materialdesignicons.min.css';
 @import '../node_modules/flatpickr/dist/flatpickr.css';
 @import '../node_modules/flatpickr/dist/flatpickr.css';
+@import '../node_modules/simplebar/dist/simplebar.css';

+ 19 - 147
netbox/project-static/styles/netbox.scss

@@ -1,9 +1,11 @@
 // Netbox-specific Styles and Overrides.
 // Netbox-specific Styles and Overrides.
 
 
 @use 'sass:map';
 @use 'sass:map';
+@import './sidenav.scss';
 
 
 :root {
 :root {
   --nbx-sidebar-bg: #{$gray-200};
   --nbx-sidebar-bg: #{$gray-200};
+  --nbx-sidebar-scroll: #{$gray-500};
   --nbx-sidebar-link-color: #{$gray-800};
   --nbx-sidebar-link-color: #{$gray-800};
   --nbx-sidebar-link-hover-bg: #{$blue-100};
   --nbx-sidebar-link-hover-bg: #{$blue-100};
   --nbx-sidebar-title-color: #{$text-muted};
   --nbx-sidebar-title-color: #{$text-muted};
@@ -21,9 +23,11 @@
   --nbx-cable-termination-border-color: #{$gray-300};
   --nbx-cable-termination-border-color: #{$gray-300};
   --nbx-search-filter-border-left-color: #{$gray-300};
   --nbx-search-filter-border-left-color: #{$gray-300};
   --nbx-color-mode-toggle-color: #{$primary};
   --nbx-color-mode-toggle-color: #{$primary};
+  --nbx-sidenav-pin-color: #{$orange};
 
 
   &[data-netbox-color-mode='dark'] {
   &[data-netbox-color-mode='dark'] {
     --nbx-sidebar-bg: #{$gray-900};
     --nbx-sidebar-bg: #{$gray-900};
+    --nbx-sidebar-scroll: #{$gray-700};
     --nbx-sidebar-link-color: #{$gray-100};
     --nbx-sidebar-link-color: #{$gray-100};
     --nbx-sidebar-link-hover-bg: #{rgba($blue-300, 0.15)};
     --nbx-sidebar-link-hover-bg: #{rgba($blue-300, 0.15)};
     --nbx-sidebar-title-color: #{$gray-600};
     --nbx-sidebar-title-color: #{$gray-600};
@@ -41,6 +45,7 @@
     --nbx-cable-termination-border-color: #{$gray-700};
     --nbx-cable-termination-border-color: #{$gray-700};
     --nbx-search-filter-border-left-color: #{$gray-600};
     --nbx-search-filter-border-left-color: #{$gray-600};
     --nbx-color-mode-toggle-color: #{$yellow-300};
     --nbx-color-mode-toggle-color: #{$yellow-300};
+    --nbx-sidenav-pin-color: #{$yellow};
   }
   }
 }
 }
 
 
@@ -119,6 +124,13 @@ small {
     background: transparent escape-svg($btn-close-bg) center / $btn-close-width auto no-repeat;
     background: transparent escape-svg($btn-close-bg) center / $btn-close-width auto no-repeat;
   }
   }
 
 
+  .btn.btn-ghost-#{$color} {
+    color: $value;
+    &:hover {
+      background-color: rgba($value, 0.12);
+    }
+  }
+
   // Use Bootstrap's method of coloring the .alert-link class automatically.
   // Use Bootstrap's method of coloring the .alert-link class automatically.
   // See: https://github.com/twbs/bootstrap/blob/2bdbb42dcf6bfb99b5e9e5444d9e64589eb8c08f/scss/_alert.scss#L50-L52
   // See: https://github.com/twbs/bootstrap/blob/2bdbb42dcf6bfb99b5e9e5444d9e64589eb8c08f/scss/_alert.scss#L50-L52
   .toast.bg-#{$color},
   .toast.bg-#{$color},
@@ -384,147 +396,6 @@ main.layout {
   max-height: 100vh;
   max-height: 100vh;
   overflow-x: auto;
   overflow-x: auto;
   overflow-y: hidden;
   overflow-y: hidden;
-
-  .sidenav {
-    width: 4.5rem;
-    background-color: var(--nbx-sidebar-bg);
-    border-right: 1px solid $border-color;
-    // TODO: Figure out how to make the menu vertically scroll properly.
-    // overflow-x: hidden;
-    // overflow-y: auto;
-    padding-bottom: 1.5rem;
-    z-index: 5000;
-
-    & {
-      -ms-overflow-style: none; // Internet Explorer 10+
-      scrollbar-width: none; // Firefox
-    }
-
-    &::-webkit-scrollbar {
-      display: none; // Safari and Chrome
-    }
-
-    .nav-link {
-      font-size: $font-size-lg;
-      border-radius: unset;
-      transition: color 0s;
-
-      @include media-breakpoint-up(sm) {
-        font-size: $font-size-sm;
-      }
-
-      @include media-breakpoint-up(md) {
-        font-size: $font-size-base;
-      }
-
-      @include media-breakpoint-up(lg) {
-        font-size: $font-size-lg;
-      }
-
-      @include media-breakpoint-up(xl) {
-        font-size: $h4-font-size;
-      }
-
-      &:hover:not(.active) {
-        background-color: $accordion-button-active-bg;
-      }
-
-      &:after {
-        display: none;
-      }
-    }
-
-    .nav-item {
-      position: relative;
-      .nav-label {
-        opacity: 0;
-        z-index: 0;
-        height: 100%;
-        display: flex;
-        padding: $spacer;
-        position: absolute;
-        align-items: center;
-        margin-left: 4.5rem;
-        pointer-events: none;
-        justify-content: flex-start;
-        font-weight: $font-weight-bold;
-        transition: opacity 0.1s ease-in-out, transform 0.12s ease-in-out, z-index 0.12s ease-in-out;
-        transform: translateX(-50px);
-        background-color: $accordion-button-active-bg;
-        color: $nav-link-color;
-        border-top-right-radius: $border-radius;
-        border-bottom-right-radius: $border-radius;
-
-        [data-netbox-color-mode='dark'] &[class] {
-          color: shade-color($primary, 75%);
-        }
-      }
-      &:hover .nav-label {
-        transform: translateX(-1px);
-        z-index: 99;
-        opacity: 1;
-        box-shadow: 1rem 0 2rem rgba($black, 0.15);
-      }
-
-      &:hover .nav-link {
-        color: $nav-link-color;
-
-        [data-netbox-color-mode='dark'] &[class] {
-          color: shade-color($primary, 50%);
-        }
-      }
-    }
-
-    .sidenav-logo {
-      position: relative;
-
-      & .sidenav-logo-reveal {
-        opacity: 0;
-        z-index: 0;
-        height: 100%;
-        width: max-content;
-        display: flex;
-        padding: $spacer;
-        position: absolute;
-        align-items: center;
-        justify-content: flex-start;
-        font-weight: $font-weight-bold;
-        transition: opacity 0.1s ease-in-out, transform 0.12s ease-in-out, z-index 0.12s ease-in-out;
-        transform: translateX(-100%);
-        background-color: var(--nbx-sidebar-bg);
-        border-bottom-right-radius: $border-radius;
-      }
-      &:hover .sidenav-logo-reveal {
-        transform: translateX(-1px);
-        z-index: 2000;
-        opacity: 1;
-      }
-    }
-
-    .dropdown {
-      .dropdown-header {
-        font-weight: $font-weight-bold;
-        text-transform: uppercase;
-        color: var(--nbx-sidebar-title-color);
-        font-size: $font-size-sm;
-      }
-      .dropdown-item-group {
-        display: inline-flex;
-        width: 100%;
-        justify-content: space-between;
-        align-items: center;
-        padding-right: map.get($spacers, 3);
-        &.disabled {
-          cursor: not-allowed;
-        }
-      }
-      .dropdown-item {
-        padding-left: map.get($spacers, 4);
-        border-top-right-radius: $border-radius;
-        border-bottom-right-radius: $border-radius;
-      }
-    }
-  }
 }
 }
 
 
 main.login-container {
 main.login-container {
@@ -650,6 +521,7 @@ nav.nav.nav-pills {
 // Ensure the content container is full-height, and that the content block is also full height so
 // Ensure the content container is full-height, and that the content block is also full height so
 // that the footer is always at the bottom.
 // that the footer is always at the bottom.
 div.content-container {
 div.content-container {
+  position: relative;
   min-height: 100vh;
   min-height: 100vh;
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
@@ -657,6 +529,10 @@ div.content-container {
   overflow-x: hidden;
   overflow-x: hidden;
   overflow-y: auto;
   overflow-y: auto;
 
 
+  @include media-breakpoint-down(lg) {
+    width: 100%;
+  }
+
   div.content {
   div.content {
     flex: 1;
     flex: 1;
   }
   }
@@ -1112,16 +988,12 @@ html {
   // Shade the home page content background-color.
   // Shade the home page content background-color.
   &[data-netbox-path='/'] {
   &[data-netbox-path='/'] {
     .content-container,
     .content-container,
-    .search
-    // ,.sidenav-logo-reveal
-    {
+    .search {
       background-color: $gray-100 !important;
       background-color: $gray-100 !important;
     }
     }
     &[data-netbox-color-mode='dark'] {
     &[data-netbox-color-mode='dark'] {
       .content-container,
       .content-container,
-      .search
-      // ,.sidenav-logo-reveal
-      {
+      .search {
         background-color: $darkest !important;
         background-color: $darkest !important;
       }
       }
     }
     }

+ 407 - 0
netbox/project-static/styles/sidenav.scss

@@ -0,0 +1,407 @@
+@use 'sass:map';
+
+@mixin parent-link {
+  .navbar-nav .nav-item .nav-link[data-bs-toggle] {
+    @content;
+  }
+}
+
+@mixin child-link {
+  .collapse .nav .nav-item .nav-link {
+    @content;
+  }
+}
+
+@mixin sidenav-open {
+  body[data-sidenav-show],
+  body[data-sidenav-pinned] {
+    .sidenav {
+      @content;
+    }
+  }
+}
+
+@mixin sidenav-closed {
+  body[data-sidenav-hide],
+  body[data-sidenav-hidden] {
+    .sidenav {
+      @content;
+    }
+  }
+}
+
+@mixin sidenav-pinned {
+  body[data-sidenav-pinned] {
+    .sidenav {
+      @content;
+    }
+  }
+}
+
+@mixin sidenav-show {
+  body[data-sidenav-show] {
+    .sidenav {
+      @content;
+    }
+  }
+}
+
+@mixin sidenav-hide {
+  body[data-sidenav-hide] {
+    .sidenav {
+      @content;
+    }
+  }
+}
+
+@mixin sidenav-peek {
+  .g-sidenav-show:not(.g-sidenav-pinned) {
+    .sidenav {
+      @content;
+    }
+  }
+}
+
+$transition-100ms-ease-in-out: all 0.1s ease-in-out;
+
+.sidenav {
+  position: fixed;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  z-index: 1050;
+  display: block;
+  width: 100%;
+  max-width: $sidenav-width-closed;
+  padding-top: 0;
+  padding-right: 0;
+  padding-left: 0;
+  background-color: var(--nbx-sidebar-bg);
+  border-right: 1px solid $border-color;
+  transition: $transition-100ms-ease-in-out;
+
+  // Media fixes for iPhone 5 like resolutions
+  @include media-breakpoint-down(lg) {
+    transform: translateX(-$sidenav-width-closed);
+    + .content-container[class] {
+      margin-left: 0;
+    }
+  }
+
+  + .content-container {
+    margin-left: $sidenav-width-closed;
+    transition: $transition-100ms-ease-in-out;
+  }
+
+  // Navbar brand
+  .sidenav-brand {
+    margin-right: 0;
+  }
+
+  .sidenav-inner {
+    padding-right: $sidenav-spacing-x;
+    padding-left: $sidenav-spacing-x;
+    @include media-breakpoint-up(md) {
+      padding-right: 0;
+      padding-left: 0;
+    }
+  }
+
+  .sidenav-brand-img,
+  .sidenav-brand > img {
+    max-width: 100%;
+    max-height: calc(#{$sidenav-width-open} - 1rem);
+  }
+
+  .navbar-heading {
+    padding-top: $nav-link-padding-y;
+    padding-bottom: $nav-link-padding-y;
+    font-size: $font-size-xs;
+    text-transform: uppercase;
+    letter-spacing: 0.04em;
+  }
+
+  .sidenav-header {
+    position: relative;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    height: 78px;
+    padding: $spacer;
+    transition: $transition-100ms-ease-in-out;
+  }
+
+  .sidenav-toggle {
+    display: none;
+  }
+
+  .sidenav-collapse {
+    display: flex;
+    flex: 1;
+    flex-direction: column;
+    align-items: stretch;
+    padding-right: $sidenav-spacing-x;
+    padding-left: $sidenav-spacing-x;
+    margin-right: -$sidenav-spacing-x;
+    margin-left: -$sidenav-spacing-x;
+
+    > * {
+      min-width: 100%;
+    }
+
+    @include media-breakpoint-up(md) {
+      margin-right: 0;
+      margin-left: 0;
+    }
+  }
+
+  // Child Link nav-item
+  .nav .nav-item {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    width: 100%;
+  }
+
+  @include child-link() {
+    width: 100%;
+    padding-top: $sidenav-link-spacing-y / 2.675;
+    padding-right: map.get($spacers, 1);
+    padding-bottom: $sidenav-link-spacing-y / 2.675;
+    /* stylelint-disable */
+    padding-left: $sidenav-link-spacing-x + $sidenav-icon-width + $sidenav-link-spacing-x / 4;
+    /* stylelint-enable */
+    margin-top: $sidenav-link-spacing-y / 3.3;
+    margin-bottom: $sidenav-link-spacing-y / 3.3;
+    font-size: $font-size-xs;
+    border-top-right-radius: $border-radius;
+    border-bottom-right-radius: $border-radius;
+
+    .sidenav-normal {
+      color: $text-muted;
+      &:hover {
+        opacity: 0.8;
+      }
+    }
+
+    .sidenav-mini-icon {
+      width: $sidenav-link-spacing-x;
+      text-align: center;
+      transition: $transition-100ms-ease-in-out;
+    }
+  }
+
+  @include parent-link() {
+    width: unset;
+    height: 100%;
+    font-weight: $font-weight-bold;
+
+    &.active {
+      color: $accordion-button-active-color;
+      background: $accordion-button-active-bg;
+    }
+
+    &:after {
+      display: inline-block;
+      margin-left: auto;
+      /* stylelint-disable */
+      font-family: 'Material Design Icons';
+      /* stylelint-enable */
+      font-style: normal;
+      font-weight: 700;
+      font-variant: normal;
+      color: $text-muted;
+      text-rendering: auto;
+      -webkit-font-smoothing: antialiased;
+      content: '\f0142';
+      transition: $transition-100ms-ease-in-out;
+    }
+
+    // Expanded
+    &[aria-expanded='true'] {
+      &.active:after {
+        color: $accordion-button-active-color;
+      }
+      &:after {
+        color: $primary;
+        transform: rotate(90deg);
+      }
+    }
+
+    .nav-link-text {
+      padding-left: 0.25rem;
+      transition: $transition-100ms-ease-in-out;
+    }
+  }
+
+  .navbar-nav {
+    flex-direction: column;
+    margin-right: -$sidenav-spacing-x;
+    margin-left: -$sidenav-spacing-x;
+
+    .nav-item {
+      margin-top: 2px;
+
+      &.disabled {
+        cursor: not-allowed;
+        opacity: 0.8;
+      }
+
+      // All Links
+      .nav-link {
+        display: flex;
+        align-items: center;
+        width: 100%;
+        padding: $sidenav-link-spacing-y $sidenav-link-spacing-x;
+        font-size: $font-size-sm;
+        white-space: nowrap;
+        transition: $transition-100ms-ease-in-out;
+
+        // &.disabled {
+        //   opacity: 0.8;
+        // }
+
+        &.active {
+          position: relative;
+          color: var(--nbx-sidebar-link-hover-bg);
+          background-color: var(--nbx-sidebar-link-hover-bg);
+        }
+
+        // Icon
+        > i {
+          min-width: $sidenav-icon-width;
+          font-size: calc(45px / 2);
+          text-align: center;
+        }
+      }
+    }
+
+    .nav-group-label {
+      display: block;
+      font-size: $font-size-xs;
+      font-weight: $font-weight-bold;
+      color: $primary;
+      text-transform: uppercase;
+      white-space: nowrap;
+    }
+  }
+}
+
+@include sidenav-pinned() {
+  .sidenav-toggle-icon {
+    color: var(--nbx-sidenav-pin-color);
+    transform: rotate(90deg);
+  }
+}
+
+@include sidenav-peek() {
+  .sidenav-toggle-icon {
+    transform: rotate(0deg);
+    // transform: rotate(90deg);
+  }
+}
+
+@include sidenav-open() {
+  max-width: $sidenav-width-open;
+
+  .sidenav-brand,
+  .navbar-heading {
+    display: block;
+  }
+
+  .sidenav-brand {
+    opacity: 1;
+    transform: translateX(0);
+  }
+
+  .sidenav-brand-icon {
+    position: absolute;
+    opacity: 0;
+  }
+  @include media-breakpoint-down(lg) {
+    transform: translateX(0);
+  }
+
+  @include media-breakpoint-up(xl) {
+    + .content-container {
+      margin-left: $sidenav-width-open;
+    }
+  }
+}
+
+@include sidenav-closed() {
+  .sidenav-header {
+    padding: $spacer * 0.5;
+  }
+  .sidenav-brand {
+    position: absolute;
+    opacity: 0;
+    transform: translateX(-150%);
+  }
+  .sidenav-brand-icon {
+    opacity: 1;
+  }
+  .navbar-nav > .nav-item {
+    > .nav-link {
+      &:after {
+        content: '';
+      }
+    }
+  }
+
+  .nav-item .collapse {
+    display: none;
+  }
+
+  .nav-link-text {
+    opacity: 0;
+  }
+  @include parent-link() {
+    &.active {
+      margin-right: 0;
+      margin-left: 0;
+      border-radius: unset;
+    }
+  }
+}
+
+@include sidenav-show() {
+  .sidenav-brand {
+    display: block;
+  }
+
+  .nav-item .collapse {
+    height: auto;
+    transition: $transition-100ms-ease-in-out;
+  }
+
+  .nav-item .nav-link .nav-link-text {
+    opacity: 1;
+  }
+
+  .nav-item .sidenav-mini-icon {
+    opacity: 0;
+  }
+
+  @include media-breakpoint-up(lg) {
+    .sidenav-toggle {
+      display: inline-block;
+    }
+  }
+}
+
+.simplebar-track.simplebar-vertical {
+  right: 0;
+  width: 6px;
+  background-color: transparent;
+
+  .simplebar-scrollbar:before {
+    right: 0;
+    width: 3px;
+    background: var(--nbx-sidebar-scroll);
+    border-radius: $border-radius;
+  }
+  &.simplebar-hover .simplebar-scrollbar:before {
+    width: 5px;
+  }
+}

+ 11 - 0
netbox/project-static/styles/theme-base.scss

@@ -122,11 +122,13 @@ $theme-color-addons: (
   'pink-900': $pink-900,
   'pink-900': $pink-900,
 );
 );
 
 
+/* stylelint-disable */
 $font-family-sans-serif: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
 $font-family-sans-serif: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
   'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji',
   'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji',
   'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
   'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
 $font-family-monospace: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
 $font-family-monospace: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
   'Courier New', monospace;
   'Courier New', monospace;
+/* stylelint-enable */
 
 
 // This is the same value as the default from Bootstrap, but it needs to be in scope prior to
 // This is the same value as the default from Bootstrap, but it needs to be in scope prior to
 // importing _variables.scss from Bootstrap.
 // importing _variables.scss from Bootstrap.
@@ -137,3 +139,12 @@ $accordion-padding-x: 0.8125rem;
 
 
 $sidebar-width: 280px;
 $sidebar-width: 280px;
 $sidebar-bottom-height: 4rem;
 $sidebar-bottom-height: 4rem;
+
+// Sidebar/Sidenav
+$sidenav-width-closed: 4rem;
+$sidenav-width-open: 16rem;
+$sidenav-icon-width: 2rem;
+$sidenav-link-px: 1rem;
+$sidenav-spacing-x: 1.5rem;
+$sidenav-link-spacing-x: 1rem;
+$sidenav-link-spacing-y: 0.675rem;

+ 2 - 2
netbox/project-static/styles/theme-dark.scss

@@ -1,7 +1,7 @@
 // Dark Mode Theme Variables and Overrides.
 // Dark Mode Theme Variables and Overrides.
 
 
 @use 'sass:map';
 @use 'sass:map';
-@import './theme-base.scss';
+@import './theme-base';
 
 
 $primary: $blue-300;
 $primary: $blue-300;
 $secondary: $gray-500;
 $secondary: $gray-500;
@@ -176,7 +176,7 @@ $accordion-border-color: $border-color;
 $accordion-button-color: $accordion-color;
 $accordion-button-color: $accordion-color;
 $accordion-button-bg: $accordion-bg;
 $accordion-button-bg: $accordion-bg;
 $accordion-button-active-bg: shade-color($blue-300, 10%);
 $accordion-button-active-bg: shade-color($blue-300, 10%);
-$accordion-button-active-color: shade-color($blue-500, 10%);
+$accordion-button-active-color: color-contrast($accordion-button-active-bg);
 $accordion-button-focus-border-color: $input-focus-border-color;
 $accordion-button-focus-border-color: $input-focus-border-color;
 $accordion-icon-color: $accordion-color;
 $accordion-icon-color: $accordion-color;
 $accordion-icon-active-color: $accordion-button-active-color;
 $accordion-icon-active-color: $accordion-button-active-color;

Разница между файлами не показана из-за своего большого размера
+ 592 - 9
netbox/project-static/yarn.lock


+ 47 - 15
netbox/templates/base/layout.html

@@ -6,32 +6,24 @@
 {% load static %}
 {% load static %}
 
 
 {% block layout %}
 {% block layout %}
-  
+
   <div class="container-fluid px-0">
   <div class="container-fluid px-0">
     <main class="layout">
     <main class="layout">
     {# Sidebar #}
     {# Sidebar #}
-    {% include 'base/sidebar.html' %}    
+    {% include 'base/sidenav.html' %}
 
 
       {# Body #}
       {# Body #}
       <div class="content-container">
       <div class="content-container">
 
 
         {# Top bar #}
         {# Top bar #}
         <nav class="navbar navbar-light sticky-top flex-md-nowrap ps-6 p-3 search container-fluid">
         <nav class="navbar navbar-light sticky-top flex-md-nowrap ps-6 p-3 search container-fluid">
-          
+
             {# Mobile Navigation #}
             {# Mobile Navigation #}
             <div class="d-md-none w-100 d-flex justify-content-between align-items-center my-3">
             <div class="d-md-none w-100 d-flex justify-content-between align-items-center my-3">
               <a class="p-2 sidebar-logo d-block d-md-none" href="{% url 'home' %}">
               <a class="p-2 sidebar-logo d-block d-md-none" href="{% url 'home' %}">
                 <img src="{% static 'netbox_logo.svg' %}" alt="NetBox logo" width="100%" />
                 <img src="{% static 'netbox_logo.svg' %}" alt="NetBox logo" width="100%" />
               </a>
               </a>
-              <button
-                type="button"
-                aria-expanded="false"
-                data-bs-toggle="collapse"
-                aria-controls="sidebar-menu"
-                data-bs-target="#sidebar-menu"
-                aria-label="Toggle Navigation"
-                class="navbar-toggler position-relative collapsed"
-              >
+              <button type="button" aria-label="Toggle Navigation" class="navbar-toggler sidenav-toggle-mobile">
                 <span class="navbar-toggler-icon"></span>
                 <span class="navbar-toggler-icon"></span>
               </button>
               </button>
             </div>
             </div>
@@ -53,7 +45,7 @@
               </div>
               </div>
 
 
             </div>
             </div>
-        
+
         </nav>
         </nav>
 
 
         {% if settings.BANNER_TOP %}
         {% if settings.BANNER_TOP %}
@@ -104,11 +96,51 @@
 
 
         {# Page footer #}
         {# Page footer #}
         <footer class="footer container-fluid pb-3 pt-4 px-0">
         <footer class="footer container-fluid pb-3 pt-4 px-0">
-          <div class="row align-items-center justify-content-end mx-0">
-            <div class="col text-center small text-muted">
+          <div class="row align-items-center justify-content-between mx-0">
+            
+            {# Docs & Community Links #}
+            <div class="col">
+              <nav class="nav justify-content-start">
+                {# Documentation #}
+                <a type="button" class="nav-link" href="{% static 'docs/' %}" target="_blank">
+                  <i title="Docs" class="mdi mdi-book-open-variant text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
+                </a>
+
+                {# REST API #}
+                <a type="button" class="nav-link" href="{% url 'api-root' %}" target="_blank">
+                  <i title="REST API" class="mdi mdi-code-braces text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
+                </a>
+
+                {# API docs #}
+                <a type="button" class="nav-link" href="{% url 'api_docs' %}" target="_blank">
+                  <i title="REST API documentation" class="mdi mdi-book text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
+                </a>
+
+                {# GraphQL API #}
+                {% if settings.GRAPHQL_ENABLED %}
+                  <a type="button" class="nav-link" href="{% url 'graphql' %}" target="_blank">
+                    <i title="GraphQL API" class="mdi mdi-graphql text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
+                  </a>
+                {% endif %}
+
+                {# GitHub #}
+                <a type="button" class="nav-link" href="https://github.com/netbox-community/netbox" target="_blank">
+                  <i title="Source Code" class="mdi mdi-github text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
+                </a>
+
+                {# NetDev Slack #}
+                <a type="button" class="nav-link" href="https://netdev.chat/" target="_blank">
+                  <i title="Community" class="mdi mdi-slack text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
+                </a>
+              </nav>
+            </div>
+            
+            {# System Info #}
+            <div class="col text-end small text-muted">
               <span class="fw-light d-block d-md-inline">{% annotated_now %} {% now 'T' %}</span>
               <span class="fw-light d-block d-md-inline">{% annotated_now %} {% now 'T' %}</span>
               <span class="ms-md-3 d-block d-md-inline">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</span>
               <span class="ms-md-3 d-block d-md-inline">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</span>
             </div>
             </div>
+
           </div>
           </div>
         </footer>
         </footer>
 
 

+ 0 - 24
netbox/templates/base/sidebar.html

@@ -1,24 +0,0 @@
-{% load nav %}
-{% load static %}
-
-<div class="d-flex flex-column flex-shrink-0 sidenav">
-
-    {# Logo Container #}
-    <div class="sidenav-logo">
-
-        {# Full logo, hidden until icon is hovered. #}
-        <a class="sidenav-logo-reveal" href="{% url 'home' %}">
-            <img src="{% static 'netbox_logo.svg' %}" alt="NetBox Logo" height="39px" />
-        </a>
-
-        {# Logo Icon #}
-        <a href="/" class="sidenav-logo-icon d-block p-3 link-dark text-decoration-none">
-            <img src="{% static 'netbox_icon.svg' %}" />
-        </a>
-
-    </div>
-
-    {# Navigation Items #}
-    {% nav %}
-
-</div>

+ 38 - 0
netbox/templates/base/sidenav.html

@@ -0,0 +1,38 @@
+{% load nav %}
+{% load static %}
+
+<nav class="sidenav" id="sidenav" data-simplebar>
+  <div class="sidenav-header">
+
+  {# Brand #}
+    
+    {# Full Logo #}
+    <a class="sidenav-brand" href="/">
+      <img src="{% static 'netbox_logo.svg' %}" height="48" class="sidenav-brand-img" alt="NetBox Logo">
+    </a>
+
+    {# Icon Logo #}
+    <a class="sidenav-brand-icon" href="/">
+      <img src="{% static 'netbox_icon.svg' %}" height="48" class="sidenav-brand-img" alt="NetBox Logo">
+    </a>
+
+    {# Pin/Unpin Toggle #}
+    <button class="btn btn-sm btn-ghost-primary sidenav-toggle">
+      <div class="sidenav-toggle-icon">
+        <i class="mdi mdi-pin"></i>
+      </div>
+    </button>
+
+  </div>
+
+  <div class="sidenav-inner h-100 mb-auto">
+    
+    {# Collapse #}
+    <div class="collapse sidenav-collapse">
+    
+      {# Nav Items #}
+      {% nav %}
+
+    </div>
+  </div>
+</nav>

+ 52 - 65
netbox/utilities/templates/navigation/nav_items.html

@@ -1,70 +1,57 @@
 {% load helpers %}
 {% load helpers %}
 
 
-<ul class="nav nav-pills nav-flush flex-column mb-auto text-center">
-  {% comment %} <li class="nav-item">
-    <a href="/" class="nav-link active py-3" aria-current="page" title="Home" data-bs-toggle="tooltip" data-bs-placement="right">
-      <i class="mdi mdi-home"></i>
-    </a>
-  </li> {% endcomment %}
+<ul class="navbar-nav">
+    {% for menu in nav_items %}
+        <li class="nav-item">
+            <a class="nav-link" href="#menu{{ menu.label }}" data-bs-toggle="collapse" role="button" aria-expanded="false" aria-controls="menu{{ menu.label }}">
+               <i class="{{ menu.icon_class }}"></i>
+               <span class="nav-link-text">{{ menu.label }}</span>
+             </a>
+            <div class="collapse" id="menu{{ menu.label }}">
+                <ul class="nav nav-sm flex-column">
 
 
-  {% for menu in nav_items %}
-    <li class="nav-item sidenav-dropdown">
-      <div class="nav-label">
-          {{ menu.label }}
-      </div>
-      <div class="dropdown dropend" title="{{ menu.label }}">
-          <a href="#" class="nav-link py-3 dropdown-toggle" id="menu{{ menu.label }}" data-bs-toggle="dropdown" aria-expanded="false">
-            <i class="{{ menu.icon_class }}"></i>
-          </a>
-
-        <ul class="dropdown-menu shadow" aria-labelledby="menu{{ menu.label }}">
-          <li><h4 class="dropdown-header text-dark">{{ menu.label }}</h4>{{ menu.has_link }}</li>
-          <hr class="dropdown-divider" />
-          
-          {% for group in menu.groups %}
-            {# Within each main menu, there are groups of menu items #}
-            
-            {% if group.label != menu.label %}
-              <li><h6 class="dropdown-header">{{ group.label }}</h6></li>
-            {% endif %}
-            
-            {% for item in group.items %}
-              {# Each Menu Item #}
-              {% if request.user|has_perms:item.permissions %}
-              <li class="dropdown-item-group">
-
-                  <a class="dropdown-item" href="{% url item.link %}">{{ item.link_text }}</a>
-                    {# Menu item buttons (if any) #}
-                    {% if item.buttons %}
-                      <div class="btn-group ps-1">
-                        {% for button in item.buttons %}
-                          {% if request.user|has_perms:button.permissions %}
-                            <a class="btn btn-sm btn-{{ button.color }} lh-1" href="{% url button.link %}" title="{{ button.title }}">
-                              <i class="{{ button.icon_class }}"></i>
-                            </a>
-                          {% endif %}
+                    {% for group in menu.groups %}
+                        {# Within each main menu, there are groups of menu items #}
+                            <li class="nav-item">
+                                <div class="nav-link">
+                                    {# Group Label #}
+                                    <span class="nav-group-label">{{ group.label }}</span>
+                                </div>
+                            </li>
+                        
+                        {% for item in group.items %}
+                            {# Each Item #}
+                            {% if request.user|has_perms:item.permissions %}
+                            <li class="nav-item">
+                                <a href="{% url item.link %}" class="nav-link">
+                                    <span class="sidenav-normal">{{ item.link_text }}</span>
+                                </a>
+                                
+                                {# Menu item buttons (if any) #}
+                                {% if item.buttons %}
+                                    <div class="btn-group px-2">
+                                        {% for button in item.buttons %}
+                                            {% if request.user|has_perms:button.permissions %}
+                                                    <a class="btn btn-sm btn-{{ button.color }} lh-1" href="{% url button.link %}" title="{{ button.title }}">
+                                                        <i class="{{ button.icon_class }}"></i>
+                                                    </a>
+                                            {% endif %}
+                                        {% endfor %}
+                                    </div>
+                                {% endif %}
+                            </li>
+                            {% else %}
+                                {# Display a disabled link (no permission) #}
+                                <li class="nav-item disabled">
+                                    <a href="#" class="nav-link disabled" aria-disabled="true" disabled>
+                                        <span class="sidenav-normal">{{ item.link_text }}</span>
+                                    </a>
+                                </li>
+                            {% endif %}
                         {% endfor %}
                         {% endfor %}
-                      </div>
-                    {% endif %}
-              </li>
-              {% else %}
-                  {# Display a disabled link (no permission) #}
-                  <li class="dropdown-item-group disabled">
-                    <a class="dropdown-item disabled" href="#" aria-disabled="true" disabled>
-                      {{ item.link_text }}
-                    </a>
-                  </li>
-              {% endif %}
-            {% endfor %}
-            {# Show a divider if not the last group #}
-            {% if forloop.counter != menu.groups|length %}
-              <hr class="dropdown-divider my-2" />
-            {% endif %}
-          {% endfor %}
-        </ul>
-
-      </div>
-    </li>
-  {% endfor %}
-
+                    {% endfor %}
+                </ul>
+            </div>
+        </li>
+    {% endfor %}
 </ul>
 </ul>

Некоторые файлы не были показаны из-за большого количества измененных файлов