jeremystretch 4 лет назад
Родитель
Сommit
3ba122afd4
32 измененных файлов с 1649 добавлено и 251 удалено
  1. 2 0
      docs/release-notes/version-3.0.md
  2. 0 0
      netbox/project-static/dist/config.js.map
  3. 0 0
      netbox/project-static/dist/jobs.js
  4. 0 0
      netbox/project-static/dist/jobs.js.map
  5. 0 0
      netbox/project-static/dist/lldp.js.map
  6. 0 0
      netbox/project-static/dist/netbox-dark.css
  7. 0 0
      netbox/project-static/dist/netbox-external.css
  8. 0 0
      netbox/project-static/dist/netbox-light.css
  9. 0 0
      netbox/project-static/dist/netbox.js
  10. 0 0
      netbox/project-static/dist/netbox.js.map
  11. 0 0
      netbox/project-static/dist/status.js
  12. 0 0
      netbox/project-static/dist/status.js.map
  13. 26 0
      netbox/project-static/package.json
  14. 1 3
      netbox/project-static/src/search.ts
  15. 29 4
      netbox/project-static/src/select/api.ts
  16. 238 14
      netbox/project-static/src/sidenav.ts
  17. 23 0
      netbox/project-static/src/util.ts
  18. 1 0
      netbox/project-static/styles/_external.scss
  19. 89 13
      netbox/project-static/styles/netbox.scss
  20. 4 0
      netbox/project-static/styles/select.scss
  21. 420 0
      netbox/project-static/styles/sidenav.scss
  22. 11 0
      netbox/project-static/styles/theme-base.scss
  23. 3 5
      netbox/project-static/styles/theme-dark.scss
  24. 592 9
      netbox/project-static/yarn.lock
  25. 1 1
      netbox/templates/base/base.html
  26. 77 89
      netbox/templates/base/layout.html
  27. 41 0
      netbox/templates/base/sidenav.html
  28. 2 2
      netbox/templates/dcim/devicetype.html
  29. 2 2
      netbox/templates/inc/table_controls.html
  30. 1 1
      netbox/templates/search.html
  31. 54 75
      netbox/utilities/templates/navigation/nav_items.html
  32. 32 33
      netbox/utilities/templates/search/searchbar.html

+ 2 - 0
docs/release-notes/version-3.0.md

@@ -5,6 +5,8 @@
 ### Bug Fixes
 
 * [#6811](https://github.com/netbox-community/netbox/issues/6811) - Fix exception when editing users
+* [#6827](https://github.com/netbox-community/netbox/issues/6827) - Fix circuit termination connection dropdown
+* [#6846](https://github.com/netbox-community/netbox/issues/6846) - Form-driven REST API calls should use brief mode
 
 ---
 

Разница между файлами не показана из-за своего большого размера
+ 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",
     "prettier": "^2.2.1",
     "prettier-eslint": "^12.0.0",
+    "stylelint": "^13.13.1",
+    "stylelint-config-twbs-bootstrap": "^2.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
+    }
   }
 }

+ 1 - 3
netbox/project-static/src/search.ts

@@ -32,9 +32,7 @@ function handleSearchDropdownClick(event: Event, button: HTMLButtonElement) {
  * Initialize Search Bar Elements.
  */
 function initSearchBar() {
-  for (const dropdown of getElements<HTMLUListElement>(
-    'div.search-container ul.search-obj-selector',
-  )) {
+  for (const dropdown of getElements<HTMLUListElement>('.search-obj-selector')) {
     for (const button of dropdown.querySelectorAll<HTMLButtonElement>(
       'li > button.dropdown-item',
     )) {

+ 29 - 4
netbox/project-static/src/select/api.ts

@@ -123,6 +123,11 @@ class APISelect {
    */
   private disabledOptions: Array<string> = [];
 
+  /**
+   * Array of properties which if truthy on an API object should be considered disabled.
+   */
+  private disabledAttributes: Array<string> = DISABLED_ATTRIBUTES;
+
   constructor(base: HTMLSelectElement) {
     // Initialize readonly properties.
     this.base = base;
@@ -141,6 +146,7 @@ class APISelect {
     this.loadEvent = new Event(`netbox.select.onload.${base.name}`);
     this.placeholder = this.getPlaceholder();
     this.disabledOptions = this.getDisabledOptions();
+    this.disabledAttributes = this.getDisabledAttributes();
 
     this.slim = new SlimSelect({
       select: this.base,
@@ -158,12 +164,18 @@ class APISelect {
       this.updateQueryParams(filter);
     }
 
+    // Add any already-resolved key/value pairs to the API query parameters.
+    for (const [key, value] of this.filterParams.entries()) {
+      if (isTruthy(value)) {
+        this.queryParams.set(key, value);
+      }
+    }
+
     for (const filter of this.pathValues.keys()) {
       this.updatePathValues(filter);
     }
 
-    // TODO: Re-enable this. Disabled because `_depth` field is missing from brief responses.
-    // this.queryParams.set('brief', true);
+    this.queryParams.set('brief', true);
     this.queryParams.set('limit', 0);
     this.updateQueryUrl();
 
@@ -323,7 +335,7 @@ class APISelect {
         if (!this.preSorted) {
           this.preSorted = true;
         }
-        text = `<span class="depth">${'─'.repeat(result._depth)}</span> ${text}`;
+        text = `<span class="depth">${'─'.repeat(result._depth)}&nbsp;</span>${text}`;
       }
       const data = {} as Record<string, string>;
       const value = result.id.toString();
@@ -336,7 +348,7 @@ class APISelect {
           data[key] = String(v);
         }
         // Set option to disabled if the result contains a matching key and is truthy.
-        if (DISABLED_ATTRIBUTES.some(key => key.toLowerCase() === k.toLowerCase())) {
+        if (this.disabledAttributes.some(key => key.toLowerCase() === k.toLowerCase())) {
           if (typeof v === 'string' && v.toLowerCase() !== 'false') {
             disabled = true;
           } else if (typeof v === 'boolean' && v === true) {
@@ -547,6 +559,19 @@ class APISelect {
     return disabledOptions;
   }
 
+  /**
+   * Get this element's disabled attribute keys. For example, if `disabled-indicator` is set to
+   * `'_occupied'` and an API object contains `{ _occupied: true }`, the option will be disabled.
+   */
+  private getDisabledAttributes(): string[] {
+    let disabled = [...DISABLED_ATTRIBUTES] as string[];
+    const attr = this.base.getAttribute('disabled-indicator');
+    if (isTruthy(attr)) {
+      disabled = [...disabled, attr];
+    }
+    return disabled;
+  }
+
   /**
    * Parse the `data-url` attribute to add any Django template variables to `pathValues` as keys
    * with empty values. As those keys' corresponding form fields' values change, `pathValues` will

+ 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() {
-  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;
 }
 
+/**
+ * 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.
  */
@@ -152,6 +159,22 @@ export function getElement<E extends HTMLElement>(id: string): 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
  * 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 '../node_modules/@mdi/font/css/materialdesignicons.min.css';
 @import '../node_modules/flatpickr/dist/flatpickr.css';
+@import '../node_modules/simplebar/dist/simplebar.css';

+ 89 - 13
netbox/project-static/styles/netbox.scss

@@ -1,9 +1,11 @@
 // Netbox-specific Styles and Overrides.
 
 @use 'sass:map';
+@import './sidenav.scss';
 
 :root {
   --nbx-sidebar-bg: #{$gray-200};
+  --nbx-sidebar-scroll: #{$gray-500};
   --nbx-sidebar-link-color: #{$gray-800};
   --nbx-sidebar-link-hover-bg: #{$blue-100};
   --nbx-sidebar-title-color: #{$text-muted};
@@ -21,9 +23,11 @@
   --nbx-cable-termination-border-color: #{$gray-300};
   --nbx-search-filter-border-left-color: #{$gray-300};
   --nbx-color-mode-toggle-color: #{$primary};
+  --nbx-sidenav-pin-color: #{$orange};
 
   &[data-netbox-color-mode='dark'] {
     --nbx-sidebar-bg: #{$gray-900};
+    --nbx-sidebar-scroll: #{$gray-700};
     --nbx-sidebar-link-color: #{$gray-100};
     --nbx-sidebar-link-hover-bg: #{rgba($blue-300, 0.15)};
     --nbx-sidebar-title-color: #{$gray-600};
@@ -41,6 +45,7 @@
     --nbx-cable-termination-border-color: #{$gray-700};
     --nbx-search-filter-border-left-color: #{$gray-600};
     --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;
   }
 
+  .btn.btn-ghost-#{$color} {
+    color: $value;
+    &:hover {
+      background-color: rgba($value, 0.12);
+    }
+  }
+
   // Use Bootstrap's method of coloring the .alert-link class automatically.
   // See: https://github.com/twbs/bootstrap/blob/2bdbb42dcf6bfb99b5e9e5444d9e64589eb8c08f/scss/_alert.scss#L50-L52
   .toast.bg-#{$color},
@@ -160,6 +172,34 @@ table td > .progress {
   min-width: 6rem;
 }
 
+.nav-mobile {
+  display: none;
+  flex-direction: column;
+  align-items: center;
+  justify-content: space-between;
+  width: 100%;
+
+  @include media-breakpoint-down(lg) {
+    display: flex;
+  }
+
+  .nav-mobile-top {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    width: 100%;
+  }
+}
+
+.search-container {
+  display: flex;
+  width: 100%;
+
+  @include media-breakpoint-down(lg) {
+    display: none;
+  }
+}
+
 .card > .table.table-flush {
   margin-bottom: 0;
   overflow: hidden;
@@ -359,6 +399,8 @@ div.title-container {
 
 nav.search {
   background-color: var(--nbx-body-bg);
+  // Don't overtake dropdowns
+  z-index: 999;
   form button.dropdown-toggle {
     border-color: $input-border-color;
     font-weight: $input-group-addon-font-weight;
@@ -374,6 +416,16 @@ nav.search {
   }
 }
 
+main.layout {
+  display: flex;
+  flex-wrap: nowrap;
+  height: 100vh;
+  height: -webkit-fill-available;
+  max-height: 100vh;
+  overflow-x: auto;
+  overflow-y: hidden;
+}
+
 main.login-container {
   display: flex;
   height: calc(100vh - 4rem);
@@ -390,6 +442,18 @@ main.login-container {
   }
 }
 
+.footer {
+  padding-top: map.get($spacers, 4);
+  padding-right: 0;
+  padding-bottom: map.get($spacers, 3);
+  padding-left: 0;
+
+  @include media-breakpoint-down(md) {
+    // Pad the bottom of the footer on mobile devices to account for mobile browser controls.
+    margin-bottom: 8rem;
+  }
+}
+
 footer.login-footer {
   height: 4rem;
   margin-top: auto;
@@ -425,7 +489,6 @@ h3.accordion-item-title,
 h4.accordion-item-title,
 h5.accordion-item-title,
 h6.accordion-item-title {
-  // padding: 0 0.5rem;
   padding: 0.25rem 0.5rem;
   font-weight: $font-weight-bold;
   text-transform: uppercase;
@@ -474,7 +537,7 @@ li.dropdown-item.dropdown-item-btns {
   height: calc(100vh - 48px);
   padding-top: 0.5rem;
   overflow-x: hidden;
-  overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
+  overflow-y: auto; // Scrollable contents if viewport is shorter than content.
 }
 
 .navbar-brand {
@@ -498,13 +561,16 @@ nav.nav.nav-pills {
 // 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.
 div.content-container {
+  position: relative;
   min-height: 100vh;
   display: flex;
   flex-direction: column;
-  overflow: hidden;
+  width: calc(100% - 4.5rem);
+  overflow-x: hidden;
+  overflow-y: auto;
 
-  @include media-breakpoint-up(md) {
-    margin-left: $sidebar-width;
+  @include media-breakpoint-down(lg) {
+    width: 100%;
   }
 
   div.content {
@@ -527,7 +593,7 @@ div.content-container {
   top: 0;
   bottom: 0;
   left: 0;
-  z-index: 100; /* Behind the navbar */
+  z-index: 100; // Behind the navbar
   border-right: 1px solid $border-color;
   background-color: var(--nbx-sidebar-bg);
   max-height: 100%;
@@ -632,6 +698,11 @@ div.content-container {
 }
 
 .search-obj-selector {
+  @include media-breakpoint-down(lg) {
+    // Limit the height and enable scrolling on mobile devices.
+    max-height: 75vh;
+    overflow-y: auto;
+  }
   .dropdown-item,
   .dropdown-header {
     font-size: $font-size-sm;
@@ -807,7 +878,7 @@ div.field-group:not(:first-of-type) {
 
 label.required {
   font-weight: $font-weight-bold;
-  &::after {
+  &:after {
     font-family: 'Material Design Icons';
     content: '\f06C4';
     font-weight: normal;
@@ -827,29 +898,34 @@ div.bulk-buttons {
   display: flex;
   justify-content: space-between;
   margin: $spacer / 2 0;
+
   // Each group of buttons needs to be contained separately for alignment purposes. This way, you
   // can put some buttons in a group that aligns left, and other buttons in a group that aligns
   // right.
-  & > div.bulk-button-group {
+  > div.bulk-button-group {
     display: flex;
+    flex-wrap: wrap;
+
     &:first-of-type:not(:last-of-type) {
       // If there are multiple bulk button groups and this is the first, the first button in the
       // group should *not* have left spacing applied, so the button group aligns with the rest
       // of the page elements.
-      & > *:first-child {
+      > *:first-child {
         margin-left: 0;
       }
     }
+
     &:last-of-type:not(:first-of-type) {
       // If there are multiple bulk button groups and this is the last, the last button in the
       // group should *not* have right spacing applied, so the button group aligns with the rest
       // of the page elements.
-      & > *:last-child {
+      > *:last-child {
         margin-right: 0;
       }
     }
+
     // However, the rest of the buttons should have spacing applied in all directions.
-    & > * {
+    > * {
       margin: $spacer / 4;
     }
   }
@@ -963,12 +1039,12 @@ html {
   &[data-netbox-path='/'] {
     .content-container,
     .search {
-      background-color: $gray-100;
+      background-color: $gray-100 !important;
     }
     &[data-netbox-color-mode='dark'] {
       .content-container,
       .search {
-        background-color: $darkest;
+        background-color: $darkest !important;
       }
     }
   }

+ 4 - 0
netbox/project-static/styles/select.scss

@@ -71,6 +71,10 @@ $spacing-s: $input-padding-x;
         border-color: currentColor;
       }
     }
+    .placeholder .depth {
+      // Don't show the depth indicator outside of the menu.
+      display: none;
+    }
     span.placeholder > *,
     span.placeholder {
       line-height: $input-line-height;

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

@@ -0,0 +1,420 @@
+@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 mobile resolutions.
+  @include media-breakpoint-down(lg) {
+    transform: translateX(-$sidenav-width-closed);
+
+    + .content-container[class] {
+      margin-left: 0;
+    }
+
+    .profile-button-container[class] {
+      display: block;
+    }
+  }
+
+  .profile-button-container {
+    display: none;
+    padding: $sidenav-link-spacing-y $sidenav-link-spacing-x;
+  }
+
+  + .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 media-breakpoint-up(xl) {
+    + .content-container {
+      margin-left: $sidenav-width-open;
+    }
+  }
+}
+
+@include sidenav-peek() {
+  .sidenav-toggle-icon {
+    transform: rotate(0deg);
+  }
+}
+
+@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 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,
 );
 
+/* stylelint-disable */
 $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 Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
 $font-family-monospace: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
   'Courier New', monospace;
+/* stylelint-enable */
 
 // This is the same value as the default from Bootstrap, but it needs to be in scope prior to
 // importing _variables.scss from Bootstrap.
@@ -137,3 +139,12 @@ $accordion-padding-x: 0.8125rem;
 
 $sidebar-width: 280px;
 $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;

+ 3 - 5
netbox/project-static/styles/theme-dark.scss

@@ -1,7 +1,7 @@
 // Dark Mode Theme Variables and Overrides.
 
 @use 'sass:map';
-@import './theme-base.scss';
+@import './theme-base';
 
 $primary: $blue-300;
 $secondary: $gray-500;
@@ -175,13 +175,11 @@ $accordion-bg: transparent;
 $accordion-border-color: $border-color;
 $accordion-button-color: $accordion-color;
 $accordion-button-bg: $accordion-bg;
-$accordion-body-active-bg: rgba($blue-300, 0.2);
-$accordion-button-active-bg: rgba($blue-300, 0.25);
-$accordion-button-active-color: $gray-300;
+$accordion-button-active-bg: shade-color($blue-300, 10%);
+$accordion-button-active-color: color-contrast($accordion-button-active-bg);
 $accordion-button-focus-border-color: $input-focus-border-color;
 $accordion-icon-color: $accordion-color;
 $accordion-icon-active-color: $accordion-button-active-color;
-
 $accordion-button-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='#{$accordion-icon-color}'><path fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/></svg>");
 $accordion-button-active-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='#{$accordion-icon-active-color}'><path fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/></svg>");
 

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


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

@@ -15,7 +15,7 @@
     <meta charset="UTF-8" />
     <meta
       name="viewport"
-      content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width"
+      content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width, viewport-fit=cover"
     />
 
     {# Page title #}

+ 77 - 89
netbox/templates/base/layout.html

@@ -6,103 +6,51 @@
 {% load static %}
 
 {% block layout %}
+
   <div class="container-fluid px-0">
-    <main class="ms-sm-auto">
+    <main class="layout">
     {# Sidebar #}
-      <nav id="sidebar-menu" class="d-md-block sidebar collapse px-0" data-simplebar>
-
-        {# Sidebar content #}
-        <div class="position-sticky">
+    {% include 'base/sidenav.html' %}
 
-          {# Logo #}
-          <div class="py-2">
-            <a class="sidebar-logo d-none d-md-flex justify-content-center" href="{% url 'home' %}">
-              <img src="{% static 'netbox_logo.svg' %}" alt="NetBox logo" />
-            </a>
-          </div>
-
-          <ul class="nav flex-column">
+      {# Body #}
+      <div class="content-container">
 
-            {# Search bar for collapsed menu #}
-            <div class="d-block d-md-none mx-1 my-3 search-container">
-              {% search_options %}
-            </div>
-            <div class="d-flex d-md-none mx-1 my-3 justify-content-center justify-content-md-end order-last order-md-0">
-              {% include 'inc/profile_button.html' %}
+        {# Top bar #}
+        <nav class="navbar navbar-light sticky-top flex-md-nowrap ps-6 p-3 search container-fluid">
+
+            {# Mobile Navigation #}
+            <div class="nav-mobile">
+              <div class="nav-mobile-top">
+                <a class="sidebar-logo p-2 d-block" href="{% url 'home' %}">
+                  <img src="{% static 'netbox_logo.svg' %}" alt="NetBox logo" width="75%" />
+                </a>
+                <button type="button" aria-label="Toggle Navigation" class="navbar-toggler sidenav-toggle-mobile">
+                  <span class="navbar-toggler-icon"></span>
+                </button>
+              </div>
+              <div class="d-flex my-1 flex-grow-1 justify-content-center">
+                {% search_options %}
+              </div>
             </div>
 
-            {# Navigation menu #}
-            {% nav %}
+            {# Desktop Navigation #}
+            <div class="row search-container">
 
-          </ul>
+              {# Empty spacer column to ensure search is centered. #}
+              <div class="col-3 d-flex flex-grow-1 ps-0"></div>
 
-        </div>
+              {# Search bar #}
+              <div class="col-6 d-flex flex-grow-1 justify-content-center">
+                {% search_options %}
+              </div>
 
-        {# Sidebar footer #}
-        <div class="d-flex flex-column container-fluid mt-auto justify-content-end sidebar-bottom">
-          <nav class="nav">
-
-            {# 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-cloud-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>
+              {# Proflie/login button #}
+              <div class="col-3 d-flex flex-grow-1 pe-0 justify-content-end">
+                {% include 'inc/profile_button.html' %}
+              </div>
 
-      </nav>
-
-      {# Body #}
-      <div class="content-container">
-
-        {# Top bar #}
-        <nav class="navbar navbar-light sticky-top flex-md-nowrap p-3 search container-fluid">
-          <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' %}">
-                <img src="{% static 'netbox_logo.svg' %}" alt="NetBox logo" width="100%" />
-              </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"
-              >
-                <span class="navbar-toggler-icon"></span>
-              </button>
-            </div>
-            <div class="d-none d-md-flex w-100 search-container">
-              {% search_options %}
-              {% include 'inc/profile_button.html' %}
             </div>
+
         </nav>
 
         {% if settings.BANNER_TOP %}
@@ -152,12 +100,52 @@
         {% endif %}
 
         {# Page footer #}
-        <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">
+        <footer class="footer container-fluid">
+          <div class="row align-items-center justify-content-between mx-0">
+            
+            {# Docs & Community Links #}
+            <div class="col-sm-12 col-md-auto">
+              <nav class="nav justify-content-center justify-content-lg-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-cloud-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-sm-12 col-md-auto text-center text-lg-end small text-muted">
               <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>
             </div>
+
           </div>
         </footer>
 

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

@@ -0,0 +1,41 @@
+{% 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>
+  <div class="profile-button-container">
+    {% include 'inc/profile_button.html' %}
+  </div>
+</nav>

+ 2 - 2
netbox/templates/dcim/devicetype.html

@@ -95,7 +95,7 @@
                             <td>
                                 {% if object.front_image %}
                                     <a href="{{ object.front_image.url }}">
-                                        <img src="{{ object.front_image.url }}" alt="{{ object.front_image.name }}" class="img-responsive" />
+                                        <img src="{{ object.front_image.url }}" alt="{{ object.front_image.name }}" class="img-fluid" />
                                     </a>
                                 {% else %}
                                     <span class="text-muted">&mdash;</span>
@@ -107,7 +107,7 @@
                             <td>
                                 {% if object.rear_image %}
                                     <a href="{{ object.rear_image.url }}">
-                                        <img src="{{ object.rear_image.url }}" alt="{{ object.rear_image.name }}" class="img-responsive" />
+                                        <img src="{{ object.rear_image.url }}" alt="{{ object.rear_image.name }}" class="img-fluid" />
                                     </a>
                                 {% else %}
                                     <span class="text-muted">&mdash;</span>

+ 2 - 2
netbox/templates/inc/table_controls.html

@@ -8,13 +8,13 @@
                     title="Configure Table"
                     data-bs-target="#{{ table_modal }}"
                     class="btn btn-sm btn-outline-dark"
-                    >
+                >
                     <i class="mdi mdi-cog"></i> Configure Table
                 </button>
             </div>
         {% endif %}
     </div>
-    <div class="col col-md-4 d-flex noprint table-controls">
+    <div class="col col-12 col-lg-4 my-3 my-lg-0 d-flex noprint table-controls">
         <div class="input-group input-group-sm">
             <input
                 type="text"

+ 1 - 1
netbox/templates/search.html

@@ -13,7 +13,7 @@
                     {% for obj_type in results %}
                         <div class="card">
                             <h5 class="card-header">{{ obj_type.name|bettertitle }}</h5>
-                            <div class="card-body">
+                            <div class="card-body table-responsive">
                                 {% render_table obj_type.table 'inc/table.html' %}
                             </div>
                             <div class="card-footer text-end">

+ 54 - 75
netbox/utilities/templates/navigation/nav_items.html

@@ -1,79 +1,58 @@
 {% load helpers %}
 
-<div id="sidenav-accordion" class="accordion accordion-flush nav-item">
-  {% for menu in nav_items %}
-
-    {# Main Collapsible Menu #}
-    <div class="accordion-item">
-      <a
-        href="#"
-        role="button"
-        aria-expanded="true"
-        data-bs-toggle="collapse"
-        data-bs-target="#{{ menu.label|lower }}"
-        class="d-flex justify-content-between align-items-center accordion-button nav-link collapsed">
-          <span class="fw-bold sidebar-nav-link">
-            <i class="{{ menu.icon_class }} me-1 opacity-50"></i>
-            {{ menu.label }}
-          </span>
-        </a>
-
-      <div id="{{ menu.label|lower }}" class="accordion-collapse collapse" data-bs-parent="#sidenav-accordion">
-        <div class="multi-level accordion-body px-0">
-          
-          {% for group in menu.groups %}
-            {# Within each main menu, there are groups of menu items #}
-            <div class="flex-column nav">
-              
-              <h6 class="accordion-item-title">{{ group.label }}</h6>
-              
-              {% for item in group.items %}
-                {# Each Menu Item #}
-                <div class="nav-item d-flex justify-content-between align-items-center">
-                  
-                  {# Menu Link with Text #}
-                  {% if request.user|has_perms:item.permissions %}
-
-                    <a class="nav-link flex-grow-1" 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 %}
+<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 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>
+                                        <i class='mdi mdi-lock small'></i>
+                                        <span class="sidenav-normal">{{ item.link_text }}</span>
+                                    </a>
+                                </li>
+                            {% endif %}
                         {% endfor %}
-                      </div>
-                    {% endif %}
-
-                  {% else %}
-
-                    {# Display a disabled link (no permission) #}
-                    <a class="nav-link flex-grow-1 disabled">
-                      {{ item.link_text }}
-                    </a>
-
-                  {% endif %}
-
-                </div>
-              {% endfor %}
-
+                    {% endfor %}
+                </ul>
             </div>
-
-            {# Show a divider if not the last group #}
-            {% if forloop.counter != menu.groups|length %}
-              <hr class="dropdown-divider my-2" />
-            {% endif %}
-
-          {% endfor %}
-
-        </div>
-      </div>
-    </div>
-  {% endfor %}
-</div>
+        </li>
+    {% endfor %}
+</ul>

+ 32 - 33
netbox/utilities/templates/search/searchbar.html

@@ -1,4 +1,4 @@
-<form class="input-group w-100" action="{% url 'search' %}" method="get">
+<form class="input-group" action="{% url 'search' %}" method="get">
   <input
     name="q"
     type="text"
@@ -7,45 +7,44 @@
     class="form-control"
     value="{{ request.GET.q }}"
   />
+
   <input name="obj_type" hidden type="text" class="search-obj-type" />
+
   <span class="input-group-text search-obj-selected">All Objects</span>
-  <button
-    type="button"
-    aria-expanded="false"
-    data-bs-toggle="dropdown"
-    class="btn btn-outline-secondary dropdown-toggle"
-  >
+
+  <button type="button" aria-expanded="false" data-bs-toggle="dropdown" class="btn btn-outline-secondary dropdown-toggle">
     <i class="mdi mdi-filter-variant"></i>
   </button>
+
   <ul class="dropdown-menu dropdown-menu-end search-obj-selector">
-    {% for option in options %} {% if option.items|length == 0 %}
-    <li>
-      <button
-        class="dropdown-item"
-        type="button"
-        data-search-value="{{ option.value }}"
-      >
-        {{ option.label }}
-      </button>
-    </li>
-    {% else %}
-    <li><h6 class="dropdown-header">{{ option.label }}</h6></li>
-    {% endif %} {% for item in option.items %}
-    <li>
-      <button
-        class="dropdown-item"
-        type="button"
-        data-search-value="{{ item.value }}"
-      >
-        {{ item.label }}
-      </button>
-    </li>
-    {% endfor %} {% if forloop.counter != options|length %}
-    <li><hr class="dropdown-divider" /></li>
-    {% endif %} {% endfor %}
+    {% for option in options %}
+      {% if option.items|length == 0 %}
+        <li>
+          <button class="dropdown-item" type="button" data-search-value="{{ option.value }}">
+            {{ option.label }}
+          </button>
+        </li>
+      {% else %}
+        <li><h6 class="dropdown-header">{{ option.label }}</h6></li>
+      {% endif %}
+
+      {% for item in option.items %}
+        <li>
+          <button class="dropdown-item" type="button" data-search-value="{{ item.value }}">
+            {{ item.label }}
+          </button>
+        </li>
+      {% endfor %}
+
+      {% if forloop.counter != options|length %}
+        <li><hr class="dropdown-divider" /></li>
+      {% endif %}
+    {% endfor %}
+
   </ul>
+
   <button class="btn btn-primary" type="submit">
     <i class="mdi mdi-magnify"></i>
   </button>
-  
+
 </form>

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