Merge branch 'feature-sidebar' into feature

# Conflicts:
#	netbox/project-static/dist/netbox.js
#	netbox/project-static/dist/netbox.js.map
This commit is contained in:
checktheroads 2021-07-29 17:39:07 -07:00
commit 007d660ce1
24 changed files with 2166 additions and 205 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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
}
}
}

View File

@ -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);
}
}

View File

@ -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

View File

@ -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';

View File

@ -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},
@ -359,6 +371,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 +388,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);
@ -425,7 +449,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 +497,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 +521,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 +553,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%;
@ -963,12 +989,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;
}
}
}

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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>");

File diff suppressed because it is too large Load Diff

View File

@ -6,103 +6,46 @@
{% 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">
{# 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">
{# 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' %}
</div>
{# Navigation menu #}
{% nav %}
</ul>
</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-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>
</nav>
{% include 'base/sidenav.html' %}
{# 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">
<nav class="navbar navbar-light sticky-top flex-md-nowrap ps-6 p-3 search container-fluid">
{# Mobile Navigation #}
<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"
>
<button type="button" aria-label="Toggle Navigation" class="navbar-toggler sidenav-toggle-mobile">
<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' %}
{# Desktop Navigation #}
<div class="d-none d-md-flex w-100 row search-container">
{# Empty spacer column to ensure search is centered. #}
<div class="col-3 d-flex flex-grow-1 ps-0"></div>
{# Search bar #}
<div class="col-6 d-flex flex-grow-1 justify-content-center">
{% search_options %}
</div>
{# Proflie/login button #}
<div class="col-3 d-flex flex-grow-1 pe-0 justify-content-end">
{% include 'inc/profile_button.html' %}
</div>
</div>
</nav>
{% if settings.BANNER_TOP %}
@ -153,11 +96,51 @@
{# 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">
<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="ms-md-3 d-block d-md-inline">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</span>
</div>
</div>
</footer>

View File

@ -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>

View File

@ -1,79 +1,57 @@
{% load helpers %}
<div id="sidenav-accordion" class="accordion accordion-flush nav-item">
{% for menu in nav_items %}
<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">
{# 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 %}
{% 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 %}
</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>

View File

@ -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"