Browse Source

feat(admin-ui): New app layout with updated nav menu

Relates to #1645
Michael Bromley 2 years ago
parent
commit
e6f8584a3f
46 changed files with 1574 additions and 622 deletions
  1. 1 1
      packages/admin-ui/src/lib/core/src/common/detail-breadcrumb.ts
  2. 39 17
      packages/admin-ui/src/lib/core/src/components/app-shell/app-shell.component.html
  3. 89 5
      packages/admin-ui/src/lib/core/src/components/app-shell/app-shell.component.scss
  4. 6 0
      packages/admin-ui/src/lib/core/src/components/app-shell/app-shell.component.ts
  5. 309 0
      packages/admin-ui/src/lib/core/src/components/base-nav/base-nav.component.ts
  6. 3 4
      packages/admin-ui/src/lib/core/src/components/breadcrumb/breadcrumb.component.scss
  7. 5 2
      packages/admin-ui/src/lib/core/src/components/breadcrumb/breadcrumb.component.spec.ts
  8. 6 137
      packages/admin-ui/src/lib/core/src/components/breadcrumb/breadcrumb.component.ts
  9. 3 3
      packages/admin-ui/src/lib/core/src/components/channel-switcher/channel-switcher.component.html
  10. 32 4
      packages/admin-ui/src/lib/core/src/components/channel-switcher/channel-switcher.component.scss
  11. 11 12
      packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.html
  12. 30 7
      packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.scss
  13. 12 296
      packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.ts
  14. 22 0
      packages/admin-ui/src/lib/core/src/components/page-title/page-title.component.scss
  15. 47 0
      packages/admin-ui/src/lib/core/src/components/page-title/page-title.component.ts
  16. 57 0
      packages/admin-ui/src/lib/core/src/components/settings-nav/settings-nav.component.html
  17. 21 0
      packages/admin-ui/src/lib/core/src/components/settings-nav/settings-nav.component.scss
  18. 23 0
      packages/admin-ui/src/lib/core/src/components/settings-nav/settings-nav.component.ts
  19. 5 3
      packages/admin-ui/src/lib/core/src/components/user-menu/user-menu.component.html
  20. 51 9
      packages/admin-ui/src/lib/core/src/components/user-menu/user-menu.component.scss
  21. 6 0
      packages/admin-ui/src/lib/core/src/core.module.ts
  22. 147 0
      packages/admin-ui/src/lib/core/src/providers/breadcrumb/breadcrumb.service.ts
  23. 2 0
      packages/admin-ui/src/lib/core/src/providers/nav-builder/nav-builder-types.ts
  24. 10 10
      packages/admin-ui/src/lib/core/src/providers/nav-builder/nav-builder.service.spec.ts
  25. 2 2
      packages/admin-ui/src/lib/core/src/providers/nav-builder/nav-builder.service.ts
  26. 5 0
      packages/admin-ui/src/lib/core/src/public_api.ts
  27. 1 1
      packages/admin-ui/src/lib/core/src/shared/components/action-bar/action-bar.component.scss
  28. 4 5
      packages/admin-ui/src/lib/core/src/shared/components/channel-badge/channel-badge.component.scss
  29. 1 1
      packages/admin-ui/src/lib/core/src/shared/components/ui-extension-point/ui-extension-point.component.html
  30. 0 2
      packages/admin-ui/src/lib/core/src/shared/components/ui-extension-point/ui-extension-point.component.scss
  31. 4 1
      packages/admin-ui/src/lib/core/src/shared/components/ui-extension-point/ui-extension-point.component.ts
  32. 1 0
      packages/admin-ui/src/lib/ng-package.json
  33. 193 0
      packages/admin-ui/src/lib/static/fonts/fonts.css
  34. BIN
      packages/admin-ui/src/lib/static/fonts/inter-cyrillic-ext.woff2
  35. BIN
      packages/admin-ui/src/lib/static/fonts/inter-cyrillic.woff2
  36. BIN
      packages/admin-ui/src/lib/static/fonts/inter-greek-ext.woff2
  37. BIN
      packages/admin-ui/src/lib/static/fonts/inter-greek.woff2
  38. BIN
      packages/admin-ui/src/lib/static/fonts/inter-latin-ext.woff2
  39. BIN
      packages/admin-ui/src/lib/static/fonts/inter-latin.woff2
  40. BIN
      packages/admin-ui/src/lib/static/fonts/inter-vietnamese.woff2
  41. 4 0
      packages/admin-ui/src/lib/static/styles/_variables.scss
  42. 5 0
      packages/admin-ui/src/lib/static/styles/global/_overrides.scss
  43. 390 98
      packages/admin-ui/src/lib/static/styles/global/_utilities.scss
  44. 1 0
      packages/admin-ui/src/lib/static/styles/styles.scss
  45. 2 0
      packages/admin-ui/src/lib/static/styles/theme/dark.scss
  46. 24 2
      packages/admin-ui/src/lib/static/styles/theme/default.scss

+ 1 - 1
packages/admin-ui/src/lib/core/src/common/detail-breadcrumb.ts

@@ -2,7 +2,7 @@ import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import { Observable } from 'rxjs';
 import { map, take } from 'rxjs/operators';
 
-import { BreadcrumbValue } from '../components/breadcrumb/breadcrumb.component';
+import { BreadcrumbValue } from '../providers/breadcrumb/breadcrumb.service';
 
 /**
  * Creates an observable of breadcrumb links for use in the route config of a detail route.

+ 39 - 17
packages/admin-ui/src/lib/core/src/components/app-shell/app-shell.component.html

@@ -1,22 +1,44 @@
-<clr-main-container>
-    <clr-header>
-        <div class="branding">
-            <a [routerLink]="['/']"><img src="assets/logo-75px.png" class="logo" /><span class="wordmark" *ngIf="!hideVendureBranding">vendure</span></a>
+<div class="app-container">
+    <div class="left-nav">
+        <div class="branding py-2">
+            <a [routerLink]="['/']"
+                ><img src="assets/logo-75px.png" class="logo" /><span
+                    class="wordmark md:hidden"
+                    *ngIf="!hideVendureBranding"
+                    >vendure</span
+                ></a
+            >
         </div>
-        <div class="header-nav"></div>
-        <div class="header-actions">
+        <div class="mx-2">
             <vdr-channel-switcher *vdrIfMultichannel></vdr-channel-switcher>
-            <vdr-user-menu [userName]="userName$ | async"
-                           [uiLanguageAndLocale]="uiLanguageAndLocale$ | async"
-                           [availableLanguages]="availableLanguages"
-                           (selectUiLanguage)="selectUiLanguage()"
-                           (logOut)="logOut()"></vdr-user-menu>
         </div>
-    </clr-header>
-    <nav class="subnav"><vdr-breadcrumb></vdr-breadcrumb></nav>
+        <div class="px-2 main-nav-container">
+            <vdr-main-nav></vdr-main-nav>
+        </div>
+        <div class="m-2">
+            <vdr-settings-nav></vdr-settings-nav>
+        </div>
+        <div class="mx-2"></div>
+    </div>
 
-    <div class="content-container">
-        <div class="content-area"><router-outlet></router-outlet></div>
-        <vdr-main-nav></vdr-main-nav>
+    <div class="surface">
+        <div class="content-container">
+            <div class="top-bar">
+                <div></div>
+                <div class="universal-search flex-spacer"></div>
+                <div>
+                    <vdr-user-menu
+                        [userName]="userName$ | async"
+                        [uiLanguageAndLocale]="uiLanguageAndLocale$ | async"
+                        [availableLanguages]="availableLanguages"
+                        (selectUiLanguage)="selectUiLanguage()"
+                        (logOut)="logOut()"
+                    ></vdr-user-menu>
+                </div>
+            </div>
+            <vdr-breadcrumb></vdr-breadcrumb>
+            <vdr-page-title [value]="(pageTitle$ | async) || '' | translate"></vdr-page-title>
+            <div class="content-area"><router-outlet></router-outlet></div>
+        </div>
     </div>
-</clr-main-container>
+</div>

+ 89 - 5
packages/admin-ui/src/lib/core/src/components/app-shell/app-shell.component.scss

@@ -1,31 +1,115 @@
-@import "variables";
+@import 'variables';
+
+.app-container {
+    display: flex;
+    height: 100vh;
+    overflow: hidden;
+}
+
+.left-nav {
+    background-color: var(--color-left-nav-bg);
+    color: var(--color-left-nav-text);
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    box-shadow: -3px 1px 10px 0px rgb(0 0 0 / 18%);
+    z-index: 1;
+}
+
+.top-bar {
+    height: 48px;
+    width: 100%;
+    display: flex;
+    justify-content: space-between;
+    background-color: var(--color-top-bar-bg);
+    border-bottom: 1px solid var(--color-component-border-200);
+}
+
+.main-nav-container {
+    overflow: auto;
+    flex: 1;
+    /* ===== Scrollbar CSS ===== */
+    /* Firefox */
+    scrollbar-width: auto;
+    scrollbar-color: var(--color-primary-700) #052731;
+
+    /* Chrome, Edge, and Safari */
+    &::-webkit-scrollbar {
+        width: 16px;
+    }
+
+    &::-webkit-scrollbar-track {
+        background-color: #052731;
+    }
+
+    &::-webkit-scrollbar-thumb {
+        background-color: var(--color-primary-700);
+        border-radius: var(--border-radius);
+        &:hover {
+            background-color: #0f5070;
+        }
+    }
+}
+
+.surface {
+    display: flex;
+    flex-direction: column;
+    flex: 1;
+    background-color: var(--color-component-bg-100);
+}
+
+.content-container {
+    overflow: auto;
+}
 
 .branding {
+    display: flex;
+    align-items: center;
+    justify-content: center;
     min-width: 0;
 }
+
 .logo {
     width: 40px;
 }
+
 .wordmark {
     font-weight: bold;
     margin-left: 12px;
     font-size: 24px;
     color: var(--color-primary-500);
+    @media screen and (max-width: $breakpoint-medium) {
+        display: none;
+    }
 }
 vdr-breadcrumb {
-    @media screen and (min-width: $breakpoint-small){
-        margin-left: $clr-sidenav-width;
-    }
+    width: 100%;
+    max-width: var(--layout-content-max-width);
+    background-color: var(--color-component-bg-100);
+    padding: var(--space-unit) 0;
+    z-index: 5;
+    margin: 0 auto;
+    position: sticky;
+    top: 0;
 }
+
 .header-actions {
     align-items: center;
 }
+
 .content-area {
     position: relative;
+    max-width: var(--layout-content-max-width);
+    margin: 0 auto;
+    padding: 0 var(--space-unit);
 }
 
 ::ng-deep {
     .header {
-        background-image: linear-gradient(to right, var(--color-header-gradient-from), var(--color-header-gradient-to));
+        background-image: linear-gradient(
+            to right,
+            var(--color-header-gradient-from),
+            var(--color-header-gradient-to)
+        );
     }
 }

+ 6 - 0
packages/admin-ui/src/lib/core/src/components/app-shell/app-shell.component.ts

@@ -7,6 +7,7 @@ import { getAppConfig } from '../../app.config';
 import { LanguageCode } from '../../common/generated-types';
 import { DataService } from '../../data/providers/data.service';
 import { AuthService } from '../../providers/auth/auth.service';
+import { BreadcrumbService } from '../../providers/breadcrumb/breadcrumb.service';
 import { I18nService } from '../../providers/i18n/i18n.service';
 import { LocalStorageService } from '../../providers/local-storage/local-storage.service';
 import { ModalService } from '../../providers/modal/modal.service';
@@ -22,6 +23,7 @@ export class AppShellComponent implements OnInit {
     uiLanguageAndLocale$: Observable<[LanguageCode, string | undefined]>;
     availableLanguages: LanguageCode[] = [];
     hideVendureBranding = getAppConfig().hideVendureBranding;
+    pageTitle$: Observable<string>;
 
     constructor(
         private authService: AuthService,
@@ -30,6 +32,7 @@ export class AppShellComponent implements OnInit {
         private i18nService: I18nService,
         private modalService: ModalService,
         private localStorageService: LocalStorageService,
+        private breadcrumbService: BreadcrumbService,
     ) {}
 
     ngOnInit() {
@@ -40,6 +43,9 @@ export class AppShellComponent implements OnInit {
             .uiState()
             .stream$.pipe(map(({ uiState }) => [uiState.language, uiState.locale ?? undefined]));
         this.availableLanguages = this.i18nService.availableLanguages;
+        this.pageTitle$ = this.breadcrumbService.breadcrumbs$.pipe(
+            map(breadcrumbs => breadcrumbs[breadcrumbs.length - 1].label),
+        );
     }
 
     selectUiLanguage() {

+ 309 - 0
packages/admin-ui/src/lib/core/src/components/base-nav/base-nav.component.ts

@@ -0,0 +1,309 @@
+import { Component, Directive, OnDestroy, OnInit } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
+import { Subscription } from 'rxjs';
+import { map, startWith } from 'rxjs/operators';
+
+import { Permission } from '../../common/generated-types';
+import { DataService } from '../../data/providers/data.service';
+import { HealthCheckService } from '../../providers/health-check/health-check.service';
+import { JobQueueService } from '../../providers/job-queue/job-queue.service';
+import { NavMenuBadge, NavMenuItem } from '../../providers/nav-builder/nav-builder-types';
+import { NavBuilderService } from '../../providers/nav-builder/nav-builder.service';
+
+@Directive({
+    selector: '[vdrBaseNav]',
+})
+// eslint-disable-next-line @angular-eslint/directive-class-suffix
+export class BaseNavComponent implements OnInit, OnDestroy {
+    constructor(
+        protected route: ActivatedRoute,
+        protected router: Router,
+        public navBuilderService: NavBuilderService,
+        protected healthCheckService: HealthCheckService,
+        protected jobQueueService: JobQueueService,
+        protected dataService: DataService,
+    ) {}
+
+    private userPermissions: string[];
+    private subscription: Subscription;
+
+    shouldDisplayLink(menuItem: Pick<NavMenuItem, 'requiresPermission'>) {
+        if (!this.userPermissions) {
+            return false;
+        }
+        if (!menuItem.requiresPermission) {
+            return true;
+        }
+        if (typeof menuItem.requiresPermission === 'string') {
+            return this.userPermissions.includes(menuItem.requiresPermission);
+        }
+        if (typeof menuItem.requiresPermission === 'function') {
+            return menuItem.requiresPermission(this.userPermissions);
+        }
+    }
+
+    ngOnInit(): void {
+        this.defineNavMenu();
+        this.subscription = this.dataService.client
+            .userStatus()
+            .mapStream(({ userStatus }) => {
+                this.userPermissions = userStatus.permissions;
+            })
+            .subscribe();
+    }
+
+    ngOnDestroy() {
+        if (this.subscription) {
+            this.subscription.unsubscribe();
+        }
+    }
+
+    getRouterLink(item: NavMenuItem) {
+        return this.navBuilderService.getRouterLink(item, this.route);
+    }
+
+    private defineNavMenu() {
+        function allow(...permissions: string[]): (userPermissions: string[]) => boolean {
+            return userPermissions => {
+                for (const permission of permissions) {
+                    if (userPermissions.includes(permission)) {
+                        return true;
+                    }
+                }
+                return false;
+            };
+        }
+
+        this.navBuilderService.defineNavMenuSections([
+            {
+                requiresPermission: allow(
+                    Permission.ReadCatalog,
+                    Permission.ReadProduct,
+                    Permission.ReadFacet,
+                    Permission.ReadCollection,
+                    Permission.ReadAsset,
+                ),
+                id: 'catalog',
+                label: _('nav.catalog'),
+                items: [
+                    {
+                        requiresPermission: allow(Permission.ReadCatalog, Permission.ReadProduct),
+                        id: 'products',
+                        label: _('nav.products'),
+                        icon: 'library',
+                        routerLink: ['/catalog', 'products'],
+                    },
+                    {
+                        requiresPermission: allow(Permission.ReadCatalog, Permission.ReadFacet),
+                        id: 'facets',
+                        label: _('nav.facets'),
+                        icon: 'tag',
+                        routerLink: ['/catalog', 'facets'],
+                    },
+                    {
+                        requiresPermission: allow(Permission.ReadCatalog, Permission.ReadCollection),
+                        id: 'collections',
+                        label: _('nav.collections'),
+                        icon: 'folder-open',
+                        routerLink: ['/catalog', 'collections'],
+                    },
+                    {
+                        requiresPermission: allow(Permission.ReadCatalog, Permission.ReadAsset),
+                        id: 'assets',
+                        label: _('nav.assets'),
+                        icon: 'image-gallery',
+                        routerLink: ['/catalog', 'assets'],
+                    },
+                ],
+            },
+            {
+                id: 'sales',
+                label: _('nav.sales'),
+                requiresPermission: allow(Permission.ReadOrder),
+                items: [
+                    {
+                        requiresPermission: allow(Permission.ReadOrder),
+                        id: 'orders',
+                        label: _('nav.orders'),
+                        routerLink: ['/orders'],
+                        icon: 'shopping-cart',
+                    },
+                ],
+            },
+            {
+                id: 'customers',
+                label: _('nav.customers'),
+                requiresPermission: allow(Permission.ReadCustomer, Permission.ReadCustomerGroup),
+                items: [
+                    {
+                        requiresPermission: allow(Permission.ReadCustomer),
+                        id: 'customers',
+                        label: _('nav.customers'),
+                        routerLink: ['/customer', 'customers'],
+                        icon: 'user',
+                    },
+                    {
+                        requiresPermission: allow(Permission.ReadCustomerGroup),
+                        id: 'customer-groups',
+                        label: _('nav.customer-groups'),
+                        routerLink: ['/customer', 'groups'],
+                        icon: 'users',
+                    },
+                ],
+            },
+            {
+                id: 'marketing',
+                label: _('nav.marketing'),
+                requiresPermission: allow(Permission.ReadPromotion),
+                items: [
+                    {
+                        requiresPermission: allow(Permission.ReadPromotion),
+                        id: 'promotions',
+                        label: _('nav.promotions'),
+                        routerLink: ['/marketing', 'promotions'],
+                        icon: 'asterisk',
+                    },
+                ],
+            },
+            {
+                id: 'settings',
+                label: _('nav.settings'),
+                icon: 'cog',
+                displayMode: 'settings',
+                requiresPermission: allow(
+                    Permission.ReadSettings,
+                    Permission.ReadChannel,
+                    Permission.ReadAdministrator,
+                    Permission.ReadShippingMethod,
+                    Permission.ReadPaymentMethod,
+                    Permission.ReadTaxCategory,
+                    Permission.ReadTaxRate,
+                    Permission.ReadCountry,
+                    Permission.ReadZone,
+                    Permission.UpdateGlobalSettings,
+                ),
+                collapsible: true,
+                collapsedByDefault: true,
+                items: [
+                    {
+                        requiresPermission: allow(Permission.ReadSeller),
+                        id: 'sellers',
+                        label: _('nav.sellers'),
+                        routerLink: ['/settings', 'sellers'],
+                        icon: 'store',
+                    },
+                    {
+                        requiresPermission: allow(Permission.ReadChannel),
+                        id: 'channels',
+                        label: _('nav.channels'),
+                        routerLink: ['/settings', 'channels'],
+                        icon: 'layers',
+                    },
+                    {
+                        requiresPermission: allow(Permission.ReadAdministrator),
+                        id: 'administrators',
+                        label: _('nav.administrators'),
+                        routerLink: ['/settings', 'administrators'],
+                        icon: 'administrator',
+                    },
+                    {
+                        requiresPermission: allow(Permission.ReadAdministrator),
+                        id: 'roles',
+                        label: _('nav.roles'),
+                        routerLink: ['/settings', 'roles'],
+                        icon: 'users',
+                    },
+                    {
+                        requiresPermission: allow(Permission.ReadShippingMethod),
+                        id: 'shipping-methods',
+                        label: _('nav.shipping-methods'),
+                        routerLink: ['/settings', 'shipping-methods'],
+                        icon: 'truck',
+                    },
+                    {
+                        requiresPermission: allow(Permission.ReadPaymentMethod),
+                        id: 'payment-methods',
+                        label: _('nav.payment-methods'),
+                        routerLink: ['/settings', 'payment-methods'],
+                        icon: 'credit-card',
+                    },
+                    {
+                        requiresPermission: allow(Permission.ReadTaxCategory),
+                        id: 'tax-categories',
+                        label: _('nav.tax-categories'),
+                        routerLink: ['/settings', 'tax-categories'],
+                        icon: 'view-list',
+                    },
+                    {
+                        requiresPermission: allow(Permission.ReadTaxRate),
+                        id: 'tax-rates',
+                        label: _('nav.tax-rates'),
+                        routerLink: ['/settings', 'tax-rates'],
+                        icon: 'calculator',
+                    },
+                    {
+                        requiresPermission: allow(Permission.ReadCountry),
+                        id: 'countries',
+                        label: _('nav.countries'),
+                        routerLink: ['/settings', 'countries'],
+                        icon: 'flag',
+                    },
+                    {
+                        requiresPermission: allow(Permission.ReadZone),
+                        id: 'zones',
+                        label: _('nav.zones'),
+                        routerLink: ['/settings', 'zones'],
+                        icon: 'world',
+                    },
+                    {
+                        requiresPermission: allow(Permission.UpdateGlobalSettings),
+                        id: 'global-settings',
+                        label: _('nav.global-settings'),
+                        routerLink: ['/settings', 'global-settings'],
+                        icon: 'cog',
+                    },
+                ],
+            },
+            {
+                id: 'system',
+                label: _('nav.system'),
+                icon: 'computer',
+                displayMode: 'settings',
+                requiresPermission: Permission.ReadSystem,
+                collapsible: true,
+                collapsedByDefault: true,
+                items: [
+                    {
+                        id: 'job-queue',
+                        label: _('nav.job-queue'),
+                        routerLink: ['/system', 'jobs'],
+                        icon: 'tick-chart',
+                        statusBadge: this.jobQueueService.activeJobs$.pipe(
+                            startWith([]),
+                            map(
+                                jobs =>
+                                    ({
+                                        type: jobs.length === 0 ? 'none' : 'info',
+                                        propagateToSection: jobs.length > 0,
+                                    } as NavMenuBadge),
+                            ),
+                        ),
+                    },
+                    {
+                        id: 'system-status',
+                        label: _('nav.system-status'),
+                        routerLink: ['/system', 'system-status'],
+                        icon: 'rack-server',
+                        statusBadge: this.healthCheckService.status$.pipe(
+                            map(status => ({
+                                type: status === 'ok' ? 'success' : 'error',
+                                propagateToSection: status === 'error',
+                            })),
+                        ),
+                    },
+                ],
+            },
+        ]);
+    }
+}

+ 3 - 4
packages/admin-ui/src/lib/core/src/components/breadcrumb/breadcrumb.component.scss

@@ -2,9 +2,7 @@
 
 :host {
     display: block;
-    @media screen and (min-width: $breakpoint-small) {
-        padding: 0 1rem;
-    }
+    padding: 0;
 }
 .breadcrumbs {
     list-style-type: none;
@@ -12,11 +10,12 @@
     overflow-x: auto;
     max-width: 100vw;
     padding: 0 3px;
+    margin: 0 var(--space-unit);
+    font-size: var(--font-size-sm);
     @media screen and (min-width: $breakpoint-small) {
         padding: 0;
     }
     li {
-        font-size: 16px;
         display: inline-block;
         margin-right: 10px;
         white-space: nowrap;

+ 5 - 2
packages/admin-ui/src/lib/core/src/components/breadcrumb/breadcrumb.component.spec.ts

@@ -8,8 +8,9 @@ import { BehaviorSubject, Observable, of as observableOf } from 'rxjs';
 
 import { MockTranslatePipe } from '../../../../../testing/translate.pipe.mock';
 import { DataService } from '../../data/providers/data.service';
+import { BreadcrumbLabelLinkPair } from '../../providers/breadcrumb/breadcrumb.service';
 
-import { BreadcrumbComponent, BreadcrumbLabelLinkPair } from './breadcrumb.component';
+import { BreadcrumbComponent } from './breadcrumb.component';
 
 describe('BeadcrumbsComponent', () => {
     let baseRouteConfig: Routes;
@@ -450,7 +451,9 @@ class TestParentComponent {}
 @Component({
     // eslint-disable-next-line @angular-eslint/component-selector
     selector: 'test-child-component',
-    template: ` <vdr-breadcrumb></vdr-breadcrumb> `,
+    template: `
+        <vdr-breadcrumb></vdr-breadcrumb>
+    `,
 })
 class TestChildComponent {}
 

+ 6 - 137
packages/admin-ui/src/lib/core/src/components/breadcrumb/breadcrumb.component.ts

@@ -1,22 +1,6 @@
-import { Component, OnDestroy } from '@angular/core';
-import { ActivatedRoute, Data, NavigationEnd, Params, PRIMARY_OUTLET, Router } from '@angular/router';
-import { flatten } from 'lodash';
-import { combineLatest as observableCombineLatest, Observable, of as observableOf, Subject } from 'rxjs';
-import { filter, map, startWith, switchMap, takeUntil } from 'rxjs/operators';
-import { DataService } from '../../data/providers/data.service';
-
-export type BreadcrumbString = string;
-export interface BreadcrumbLabelLinkPair {
-    label: string;
-    link: any[];
-}
-export type BreadcrumbValue = BreadcrumbString | BreadcrumbLabelLinkPair | BreadcrumbLabelLinkPair[];
-export type BreadcrumbFunction = (
-    data: Data,
-    params: Params,
-    dataService: DataService,
-) => BreadcrumbValue | Observable<BreadcrumbValue>;
-export type BreadcrumbDefinition = BreadcrumbValue | BreadcrumbFunction | Observable<BreadcrumbValue>;
+import { Component } from '@angular/core';
+import { Observable, Subject } from 'rxjs';
+import { BreadcrumbService } from '../../providers/breadcrumb/breadcrumb.service';
 
 /**
  * A breadcrumbs component which reads the route config and any route that has a `data.breadcrumb` property will
@@ -32,126 +16,11 @@ export type BreadcrumbDefinition = BreadcrumbValue | BreadcrumbFunction | Observ
     templateUrl: './breadcrumb.component.html',
     styleUrls: ['./breadcrumb.component.scss'],
 })
-export class BreadcrumbComponent implements OnDestroy {
+export class BreadcrumbComponent {
     breadcrumbs$: Observable<Array<{ link: string | any[]; label: string }>>;
     private destroy$ = new Subject<void>();
 
-    constructor(private router: Router, private route: ActivatedRoute, private dataService: DataService) {
-        this.breadcrumbs$ = this.router.events.pipe(
-            filter(event => event instanceof NavigationEnd),
-            takeUntil(this.destroy$),
-            startWith(true),
-            switchMap(() => this.generateBreadcrumbs(this.route.root)),
-        );
-    }
-
-    ngOnDestroy(): void {
-        this.destroy$.next();
-        this.destroy$.complete();
-    }
-
-    private generateBreadcrumbs(
-        rootRoute: ActivatedRoute,
-    ): Observable<Array<{ link: Array<string | any>; label: string }>> {
-        const breadcrumbParts = this.assembleBreadcrumbParts(rootRoute);
-        const breadcrumbObservables$ = breadcrumbParts.map(
-            ({ value$, path }) =>
-                value$.pipe(
-                    map(value => {
-                        if (isBreadcrumbLabelLinkPair(value)) {
-                            return {
-                                label: value.label,
-                                link: this.normalizeRelativeLinks(value.link, path),
-                            };
-                        } else if (isBreadcrumbPairArray(value)) {
-                            return value.map(val => ({
-                                label: val.label,
-                                link: this.normalizeRelativeLinks(val.link, path),
-                            }));
-                        } else {
-                            return {
-                                label: value,
-                                link: '/' + path.join('/'),
-                            };
-                        }
-                    }),
-                ) as Observable<BreadcrumbLabelLinkPair | BreadcrumbLabelLinkPair[]>,
-        );
-
-        return observableCombineLatest(breadcrumbObservables$).pipe(map(links => flatten(links)));
-    }
-
-    /**
-     * Walks the route definition tree to assemble an array from which the breadcrumbs can be derived.
-     */
-    private assembleBreadcrumbParts(
-        rootRoute: ActivatedRoute,
-    ): Array<{ value$: Observable<BreadcrumbValue>; path: string[] }> {
-        const breadcrumbParts: Array<{ value$: Observable<BreadcrumbValue>; path: string[] }> = [];
-        const inferredUrl = '';
-        const segmentPaths: string[] = [];
-        let currentRoute: ActivatedRoute | null = rootRoute;
-        do {
-            const childRoutes = currentRoute.children;
-            currentRoute = null;
-            childRoutes.forEach((route: ActivatedRoute) => {
-                if (route.outlet === PRIMARY_OUTLET) {
-                    const routeSnapshot = route.snapshot;
-                    let breadcrumbDef: BreadcrumbDefinition | undefined =
-                        route.routeConfig && route.routeConfig.data && route.routeConfig.data['breadcrumb'];
-                    segmentPaths.push(routeSnapshot.url.map(segment => segment.path).join('/'));
-
-                    if (breadcrumbDef) {
-                        if (isBreadcrumbFunction(breadcrumbDef)) {
-                            breadcrumbDef = breadcrumbDef(
-                                routeSnapshot.data,
-                                routeSnapshot.params,
-                                this.dataService,
-                            );
-                        }
-                        const observableValue = isObservable(breadcrumbDef)
-                            ? breadcrumbDef
-                            : observableOf(breadcrumbDef);
-                        breadcrumbParts.push({ value$: observableValue, path: segmentPaths.slice() });
-                    }
-                    currentRoute = route;
-                }
-            });
-        } while (currentRoute);
-
-        return breadcrumbParts;
+    constructor(private breadcrumbService: BreadcrumbService) {
+        this.breadcrumbs$ = this.breadcrumbService.breadcrumbs$;
     }
-
-    /**
-     * Accounts for relative routes in the link array, i.e. arrays whose first element is either:
-     * * `./`   - this appends the rest of the link segments to the current active route
-     * * `../`  - this removes the last segment of the current active route, and appends the link segments
-     *            to the parent route.
-     */
-    private normalizeRelativeLinks(link: any[], segmentPaths: string[]): any[] {
-        const clone = link.slice();
-        if (clone[0] === './') {
-            clone[0] = segmentPaths.join('/');
-        }
-        if (clone[0] === '../') {
-            clone[0] = segmentPaths.slice(0, -1).join('/');
-        }
-        return clone.filter(segment => segment !== '');
-    }
-}
-
-function isBreadcrumbFunction(value: BreadcrumbDefinition): value is BreadcrumbFunction {
-    return typeof value === 'function';
-}
-
-function isObservable(value: BreadcrumbDefinition): value is Observable<BreadcrumbValue> {
-    return value instanceof Observable;
-}
-
-function isBreadcrumbLabelLinkPair(value: BreadcrumbValue): value is BreadcrumbLabelLinkPair {
-    return value.hasOwnProperty('label') && value.hasOwnProperty('link');
-}
-
-function isBreadcrumbPairArray(value: BreadcrumbValue): value is BreadcrumbLabelLinkPair[] {
-    return Array.isArray(value) && isBreadcrumbLabelLinkPair(value[0]);
 }

+ 3 - 3
packages/admin-ui/src/lib/core/src/components/channel-switcher/channel-switcher.component.html

@@ -1,11 +1,11 @@
 <ng-container>
     <vdr-dropdown>
-        <button class="btn btn-link active-channel" vdrDropdownTrigger>
+        <button class="active-channel m-auto" vdrDropdownTrigger>
             <vdr-channel-badge [channelCode]="activeChannelCode$ | async"></vdr-channel-badge>
-            <span class="active-channel">{{
+            <span class="md:hidden channel-label">{{
                 activeChannelCode$ | async | channelCodeToLabel | translate
             }}</span>
-            <span class="trigger"><clr-icon shape="caret down"></clr-icon></span>
+            <span class="trigger md:hidden"><clr-icon shape="caret down"></clr-icon></span>
         </button>
         <vdr-dropdown-menu vdrPosition="bottom-right">
             <input

+ 32 - 4
packages/admin-ui/src/lib/core/src/components/channel-switcher/channel-switcher.component.scss

@@ -1,15 +1,43 @@
+@import 'variables';
 
 :host {
-    display: flex;
+    display: block;
     align-items: center;
-    margin: 0 0.5rem;
     height: 2.5rem;
 }
 
 .active-channel {
-    color: var(--color-grey-200);
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    font-size: var(--font-size-xs);
+    color: var(--color-left-nav-text);
+    background-color: var(--color-primary-700);
+    border: 1px solid var(--color-left-nav-bg);
+    cursor: pointer;
+    width: 100%;
+    border-radius: var(--border-radius);
+    padding: var(--space-unit);
+
+    &:hover {
+        background-color: var(--color-primary-700);
+        color: var(--color-left-nav-text-hover);
+    }
 
     clr-icon {
-        color: white;
+        color: var(--color-left-nav-text);
+    }
+    @media screen and (max-width: $breakpoint-medium) {
+        justify-content: center;
+    }
+}
+
+.channel-label {
+    margin: 0 3px;
+    overflow: hidden;
+    max-width: 100px;
+    text-overflow: ellipsis;
+    @media screen and (max-width: $breakpoint-medium) {
+        display: none;
     }
 }

+ 11 - 12
packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.html

@@ -1,24 +1,23 @@
-<nav class="sidenav" [clr-nav-level]="2">
-    <section class="sidenav-content">
-        <ng-container *ngFor="let section of navBuilderService.navMenuConfig$ | async">
+<nav class="main-nav">
+    <section class="">
+        <ng-container *ngFor="let section of mainMenuConfig$ | async">
             <section
                 class="nav-group"
                 [attr.data-section-id]="section.id"
                 [class.collapsible]="section.collapsible"
                 *ngIf="shouldDisplayLink(section)"
             >
-                <vdr-ui-extension-point [locationId]="section.id" api="navMenu" [topPx]="-6" [leftPx]="8">
+                <vdr-ui-extension-point [locationId]="section.id" api="navMenu" [topPx]="-6" [leftPx]="8" display="block">
                     <ng-container *ngIf="navBuilderService.sectionBadges[section.id] | async as sectionBadge">
                         <vdr-status-badge
                             *ngIf="sectionBadge !== 'none'"
                             [type]="sectionBadge"
                         ></vdr-status-badge>
                     </ng-container>
-                    <input [id]="section.id" type="checkbox" [checked]="section.collapsedByDefault" />
-                    <label class="nav-group-header" [for]="section.id">{{ section.label | translate }}</label>
-                    <ul class="nav-list">
+                    <label class="nav-group-header md:hidden" [for]="section.id">{{ section.label | translate }}</label>
+                    <div class="nav-list">
                         <ng-container *ngFor="let item of section.items">
-                            <li *ngIf="shouldDisplayLink(item)">
+                            <div *ngIf="shouldDisplayLink(item)">
                                 <a
                                     class="nav-link"
                                     [attr.data-item-id]="section.id"
@@ -32,12 +31,12 @@
                                             [type]="itemBadge.type"
                                         ></vdr-status-badge>
                                     </ng-container>
-                                    <clr-icon [attr.shape]="item.icon || 'block'" size="20"></clr-icon>
-                                    {{ item.label | translate }}
+                                    <clr-icon [attr.shape]="item.icon || 'block'" size="20" [title]="item.label | translate"></clr-icon>
+                                    <span class="md:hidden">{{ item.label | translate }}</span>
                                 </a>
-                            </li>
+                            </div>
                         </ng-container>
-                    </ul>
+                    </div>
                 </vdr-ui-extension-point>
             </section>
         </ng-container>

+ 30 - 7
packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.scss

@@ -1,34 +1,57 @@
 @import 'variables';
 
 :host {
-    // flex: 0 0 auto;
-    order: -1;
     background-color: var(--clr-nav-background-color);
 }
 
-nav.sidenav {
+nav.main-nav {
     height: 100%;
     border-right-color: var(--clr-sidenav-border-color);
 }
 
-.sidenav .nav-group {
+.main-nav .nav-group {
+    margin-bottom: calc(var(--space-unit) * 2);
     .nav-list {
         margin: 0;
     }
     .nav-group-header {
         margin: 0;
         line-height: 1.2;
+        font-size: var(--font-size-sm);
+        padding-left: calc(var(--space-unit) * 1);
+        //font-weight: bold;
+        color: var(--color-left-nav-text);
+        opacity: 0.7;
     }
     .nav-link {
-        display: inline-flex;
+        display: flex;
         line-height: 1rem;
-        padding-right: 0.6rem;
+        font-size: var(--font-size-sm);
+        padding: var(--space-unit);
+        border-radius: var(--border-radius);
+        &:link,
+        &:visited {
+            color: var(--color-text-600);
+        }
+        &:hover {
+            color: var(--color-primary-200);
+        }
+
+        &.active {
+            background-color: var(--color-primary-600);
+            color: var(--color-text-inverse);
+        }
+        @media screen and (max-width: $breakpoint-medium) {
+            justify-content: center;
+        }
     }
 }
 
 .nav-list clr-icon {
     flex-shrink: 0;
-    margin-right: 12px;
+    @media screen and (min-width: $breakpoint-medium) {
+        margin-right: 12px;
+    }
 }
 
 .nav-group {

+ 12 - 296
packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.ts

@@ -1,306 +1,22 @@
-import { Component, OnDestroy, OnInit } from '@angular/core';
-import { ActivatedRoute, Router } from '@angular/router';
-import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
-import { Subscription } from 'rxjs';
-import { map, startWith } from 'rxjs/operators';
-
-import { Permission } from '../../common/generated-types';
-import { DataService } from '../../data/providers/data.service';
-import { HealthCheckService } from '../../providers/health-check/health-check.service';
-import { JobQueueService } from '../../providers/job-queue/job-queue.service';
-import { NavMenuBadge, NavMenuItem } from '../../providers/nav-builder/nav-builder-types';
-import { NavBuilderService } from '../../providers/nav-builder/nav-builder.service';
+import { Component, OnInit } from '@angular/core';
+import { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+import { NavMenuSection } from '../../providers/nav-builder/nav-builder-types';
+import { BaseNavComponent } from '../base-nav/base-nav.component';
 
 @Component({
     selector: 'vdr-main-nav',
     templateUrl: './main-nav.component.html',
     styleUrls: ['./main-nav.component.scss'],
 })
-export class MainNavComponent implements OnInit, OnDestroy {
-    constructor(
-        private route: ActivatedRoute,
-        private router: Router,
-        public navBuilderService: NavBuilderService,
-        private healthCheckService: HealthCheckService,
-        private jobQueueService: JobQueueService,
-        private dataService: DataService,
-    ) {}
-
-    private userPermissions: string[];
-    private subscription: Subscription;
-
-    shouldDisplayLink(menuItem: Pick<NavMenuItem, 'requiresPermission'>) {
-        if (!this.userPermissions) {
-            return false;
-        }
-        if (!menuItem.requiresPermission) {
-            return true;
-        }
-        if (typeof menuItem.requiresPermission === 'string') {
-            return this.userPermissions.includes(menuItem.requiresPermission);
-        }
-        if (typeof menuItem.requiresPermission === 'function') {
-            return menuItem.requiresPermission(this.userPermissions);
-        }
-    }
-
-    ngOnInit(): void {
-        this.defineNavMenu();
-        this.subscription = this.dataService.client
-            .userStatus()
-            .mapStream(({ userStatus }) => {
-                this.userPermissions = userStatus.permissions;
-            })
-            .subscribe();
-    }
-
-    ngOnDestroy() {
-        if (this.subscription) {
-            this.subscription.unsubscribe();
-        }
-    }
-
-    getRouterLink(item: NavMenuItem) {
-        return this.navBuilderService.getRouterLink(item, this.route);
-    }
+export class MainNavComponent extends BaseNavComponent implements OnInit {
+    mainMenuConfig$: Observable<NavMenuSection[]>;
 
-    private defineNavMenu() {
-        function allow(...permissions: string[]): (userPermissions: string[]) => boolean {
-            return userPermissions => {
-                for (const permission of permissions) {
-                    if (userPermissions.includes(permission)) {
-                        return true;
-                    }
-                }
-                return false;
-            };
-        }
+    override ngOnInit(): void {
+        super.ngOnInit();
 
-        this.navBuilderService.defineNavMenuSections([
-            {
-                requiresPermission: allow(
-                    Permission.ReadCatalog,
-                    Permission.ReadProduct,
-                    Permission.ReadFacet,
-                    Permission.ReadCollection,
-                    Permission.ReadAsset,
-                ),
-                id: 'catalog',
-                label: _('nav.catalog'),
-                items: [
-                    {
-                        requiresPermission: allow(Permission.ReadCatalog, Permission.ReadProduct),
-                        id: 'products',
-                        label: _('nav.products'),
-                        icon: 'library',
-                        routerLink: ['/catalog', 'products'],
-                    },
-                    {
-                        requiresPermission: allow(Permission.ReadCatalog, Permission.ReadFacet),
-                        id: 'facets',
-                        label: _('nav.facets'),
-                        icon: 'tag',
-                        routerLink: ['/catalog', 'facets'],
-                    },
-                    {
-                        requiresPermission: allow(Permission.ReadCatalog, Permission.ReadCollection),
-                        id: 'collections',
-                        label: _('nav.collections'),
-                        icon: 'folder-open',
-                        routerLink: ['/catalog', 'collections'],
-                    },
-                    {
-                        requiresPermission: allow(Permission.ReadCatalog, Permission.ReadAsset),
-                        id: 'assets',
-                        label: _('nav.assets'),
-                        icon: 'image-gallery',
-                        routerLink: ['/catalog', 'assets'],
-                    },
-                ],
-            },
-            {
-                id: 'sales',
-                label: _('nav.sales'),
-                requiresPermission: allow(Permission.ReadOrder),
-                items: [
-                    {
-                        requiresPermission: allow(Permission.ReadOrder),
-                        id: 'orders',
-                        label: _('nav.orders'),
-                        routerLink: ['/orders'],
-                        icon: 'shopping-cart',
-                    },
-                ],
-            },
-            {
-                id: 'customers',
-                label: _('nav.customers'),
-                requiresPermission: allow(Permission.ReadCustomer, Permission.ReadCustomerGroup),
-                items: [
-                    {
-                        requiresPermission: allow(Permission.ReadCustomer),
-                        id: 'customers',
-                        label: _('nav.customers'),
-                        routerLink: ['/customer', 'customers'],
-                        icon: 'user',
-                    },
-                    {
-                        requiresPermission: allow(Permission.ReadCustomerGroup),
-                        id: 'customer-groups',
-                        label: _('nav.customer-groups'),
-                        routerLink: ['/customer', 'groups'],
-                        icon: 'users',
-                    },
-                ],
-            },
-            {
-                id: 'marketing',
-                label: _('nav.marketing'),
-                requiresPermission: allow(Permission.ReadPromotion),
-                items: [
-                    {
-                        requiresPermission: allow(Permission.ReadPromotion),
-                        id: 'promotions',
-                        label: _('nav.promotions'),
-                        routerLink: ['/marketing', 'promotions'],
-                        icon: 'asterisk',
-                    },
-                ],
-            },
-            {
-                id: 'settings',
-                label: _('nav.settings'),
-                requiresPermission: allow(
-                    Permission.ReadSettings,
-                    Permission.ReadChannel,
-                    Permission.ReadAdministrator,
-                    Permission.ReadShippingMethod,
-                    Permission.ReadPaymentMethod,
-                    Permission.ReadTaxCategory,
-                    Permission.ReadTaxRate,
-                    Permission.ReadCountry,
-                    Permission.ReadZone,
-                    Permission.UpdateGlobalSettings,
-                ),
-                collapsible: true,
-                collapsedByDefault: true,
-                items: [
-                    {
-                        requiresPermission: allow(Permission.ReadSeller),
-                        id: 'sellers',
-                        label: _('nav.sellers'),
-                        routerLink: ['/settings', 'sellers'],
-                        icon: 'store',
-                    },
-                    {
-                        requiresPermission: allow(Permission.ReadChannel),
-                        id: 'channels',
-                        label: _('nav.channels'),
-                        routerLink: ['/settings', 'channels'],
-                        icon: 'layers',
-                    },
-                    {
-                        requiresPermission: allow(Permission.ReadAdministrator),
-                        id: 'administrators',
-                        label: _('nav.administrators'),
-                        routerLink: ['/settings', 'administrators'],
-                        icon: 'administrator',
-                    },
-                    {
-                        requiresPermission: allow(Permission.ReadAdministrator),
-                        id: 'roles',
-                        label: _('nav.roles'),
-                        routerLink: ['/settings', 'roles'],
-                        icon: 'users',
-                    },
-                    {
-                        requiresPermission: allow(Permission.ReadShippingMethod),
-                        id: 'shipping-methods',
-                        label: _('nav.shipping-methods'),
-                        routerLink: ['/settings', 'shipping-methods'],
-                        icon: 'truck',
-                    },
-                    {
-                        requiresPermission: allow(Permission.ReadPaymentMethod),
-                        id: 'payment-methods',
-                        label: _('nav.payment-methods'),
-                        routerLink: ['/settings', 'payment-methods'],
-                        icon: 'credit-card',
-                    },
-                    {
-                        requiresPermission: allow(Permission.ReadTaxCategory),
-                        id: 'tax-categories',
-                        label: _('nav.tax-categories'),
-                        routerLink: ['/settings', 'tax-categories'],
-                        icon: 'view-list',
-                    },
-                    {
-                        requiresPermission: allow(Permission.ReadTaxRate),
-                        id: 'tax-rates',
-                        label: _('nav.tax-rates'),
-                        routerLink: ['/settings', 'tax-rates'],
-                        icon: 'calculator',
-                    },
-                    {
-                        requiresPermission: allow(Permission.ReadCountry),
-                        id: 'countries',
-                        label: _('nav.countries'),
-                        routerLink: ['/settings', 'countries'],
-                        icon: 'flag',
-                    },
-                    {
-                        requiresPermission: allow(Permission.ReadZone),
-                        id: 'zones',
-                        label: _('nav.zones'),
-                        routerLink: ['/settings', 'zones'],
-                        icon: 'world',
-                    },
-                    {
-                        requiresPermission: allow(Permission.UpdateGlobalSettings),
-                        id: 'global-settings',
-                        label: _('nav.global-settings'),
-                        routerLink: ['/settings', 'global-settings'],
-                        icon: 'cog',
-                    },
-                ],
-            },
-            {
-                id: 'system',
-                label: _('nav.system'),
-                requiresPermission: Permission.ReadSystem,
-                collapsible: true,
-                collapsedByDefault: true,
-                items: [
-                    {
-                        id: 'job-queue',
-                        label: _('nav.job-queue'),
-                        routerLink: ['/system', 'jobs'],
-                        icon: 'tick-chart',
-                        statusBadge: this.jobQueueService.activeJobs$.pipe(
-                            startWith([]),
-                            map(
-                                jobs =>
-                                    ({
-                                        type: jobs.length === 0 ? 'none' : 'info',
-                                        propagateToSection: jobs.length > 0,
-                                    } as NavMenuBadge),
-                            ),
-                        ),
-                    },
-                    {
-                        id: 'system-status',
-                        label: _('nav.system-status'),
-                        routerLink: ['/system', 'system-status'],
-                        icon: 'rack-server',
-                        statusBadge: this.healthCheckService.status$.pipe(
-                            map(status => ({
-                                type: status === 'ok' ? 'success' : 'error',
-                                propagateToSection: status === 'error',
-                            })),
-                        ),
-                    },
-                ],
-            },
-        ]);
+        this.mainMenuConfig$ = this.navBuilderService.menuConfig$.pipe(
+            map(sections => sections.filter(s => s.displayMode === 'regular' || !s.displayMode)),
+        );
     }
 }

+ 22 - 0
packages/admin-ui/src/lib/core/src/components/page-title/page-title.component.scss

@@ -0,0 +1,22 @@
+:host {
+    display: block;
+    width: 100%;
+    max-width: var(--layout-content-max-width);
+    padding: 0 var(--space-unit);
+    margin: 0 auto;
+}
+
+.page-title {
+    h1 {
+        margin-top: 0;
+        color: var(--color-text-100);
+        // max-height: 48px;
+        //transition: all 0.5s;
+        opacity: 1;
+        overflow: hidden;
+        &.folded {
+            // max-height: 0;
+            opacity: 1;
+        }
+    }
+}

+ 47 - 0
packages/admin-ui/src/lib/core/src/components/page-title/page-title.component.ts

@@ -0,0 +1,47 @@
+import {
+    AfterViewInit,
+    ChangeDetectionStrategy,
+    ChangeDetectorRef,
+    Component,
+    HostListener,
+    Input,
+} from '@angular/core';
+import { fromEvent } from 'rxjs';
+import { debounceTime, throttleTime } from 'rxjs/operators';
+
+@Component({
+    selector: 'vdr-page-title',
+    template: `
+        <div class="page-title">
+            <h1 [class.folded]="isFolded">{{ value | translate }}</h1>
+        </div>
+    `,
+    styleUrls: [`./page-title.component.scss`],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class PageTitleComponent implements AfterViewInit {
+    @Input() value = '';
+    isFolded = false;
+    private lastScrollTop = 0;
+
+    constructor(private changeDetector: ChangeDetectorRef) {}
+
+    ngAfterViewInit() {
+        const contentContainer = document.querySelector('.content-container');
+        if (contentContainer) {
+            // fromEvent(contentContainer, 'scroll', { passive: true }).subscribe(() => {
+            //     const scrollTop = contentContainer.scrollTop;
+            //     const delta = this.lastScrollTop - scrollTop;
+            //     this.lastScrollTop = scrollTop;
+            //     if (100 < scrollTop && this.isFolded === false) {
+            //         this.isFolded = true;
+            //         this.changeDetector.markForCheck();
+            //     }
+            //     if ((scrollTop === 0 || (scrollTop < 50 && 0 < delta)) && this.isFolded === true) {
+            //         this.isFolded = false;
+            //         this.changeDetector.markForCheck();
+            //     }
+            // });
+        }
+    }
+}

+ 57 - 0
packages/admin-ui/src/lib/core/src/components/settings-nav/settings-nav.component.html

@@ -0,0 +1,57 @@
+<nav>
+    <section class="settings-nav-content">
+        <ng-container *ngFor="let section of settingsMenuConfig$ | async">
+            <vdr-ui-extension-point
+                [locationId]="section.id"
+                api="navMenu"
+                [topPx]="-6"
+                [leftPx]="8"
+                display="block"
+            >
+                <vdr-dropdown>
+                    <button class="setting-link" vdrDropdownTrigger>
+                        <div>
+                            <ng-container
+                                *ngIf="navBuilderService.sectionBadges[section.id] | async as sectionBadge"
+                            >
+                                <vdr-status-badge
+                                    *ngIf="sectionBadge !== 'none'"
+                                    [type]="sectionBadge"
+                                ></vdr-status-badge>
+                            </ng-container>
+                            <clr-icon
+                                *ngIf="section.icon"
+                                [attr.shape]="section.icon || 'block'"
+                                size="20"
+                            ></clr-icon>
+                            <label class="md:hidden" [for]="section.id">{{ section.label | translate }}</label>
+                        </div>
+                        <clr-icon class="md:hidden" shape="caret right"></clr-icon>
+                    </button>
+                    <vdr-dropdown-menu vdrPosition="top-right">
+                        <ng-container *ngFor="let item of section.items">
+                            <div *ngIf="shouldDisplayLink(item)">
+                                <a
+                                    vdrDropdownItem
+                                    [attr.data-item-id]="section.id"
+                                    [routerLink]="getRouterLink(item)"
+                                    routerLinkActive="active"
+                                    (click)="item.onClick && item.onClick($event)"
+                                >
+                                    <ng-container *ngIf="item.statusBadge | async as itemBadge">
+                                        <vdr-status-badge
+                                            *ngIf="itemBadge.type !== 'none'"
+                                            [type]="itemBadge.type"
+                                        ></vdr-status-badge>
+                                    </ng-container>
+                                    <clr-icon [attr.shape]="item.icon || 'block'" size="20"></clr-icon>
+                                    <div class="">{{ item.label | translate }}</div>
+                                </a>
+                            </div>
+                        </ng-container>
+                    </vdr-dropdown-menu>
+                </vdr-dropdown>
+            </vdr-ui-extension-point>
+        </ng-container>
+    </section>
+</nav>

+ 21 - 0
packages/admin-ui/src/lib/core/src/components/settings-nav/settings-nav.component.scss

@@ -0,0 +1,21 @@
+@import "variables";
+
+.setting-link {
+    width: 100%;
+    border: none;
+    display: flex;
+    justify-content: space-between;
+    font-size: var(--font-size-xs);
+    align-items: center;
+    cursor: pointer;
+    background-color: transparent;
+    padding: var(--space-unit);
+    color: var(--color-left-nav-text);
+    &:hover {
+        color: var(--color-left-nav-text-hover);
+    }
+
+    clr-icon {
+        margin-right: 6px;
+    }
+}

+ 23 - 0
packages/admin-ui/src/lib/core/src/components/settings-nav/settings-nav.component.ts

@@ -0,0 +1,23 @@
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { NavMenuSection } from '@vendure/admin-ui/core';
+import { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+import { BaseNavComponent } from '../base-nav/base-nav.component';
+
+@Component({
+    selector: 'vdr-settings-nav',
+    templateUrl: './settings-nav.component.html',
+    styleUrls: ['./settings-nav.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class SettingsNavComponent extends BaseNavComponent implements OnInit {
+    settingsMenuConfig$: Observable<NavMenuSection[]>;
+
+    override ngOnInit(): void {
+        super.ngOnInit();
+
+        this.settingsMenuConfig$ = this.navBuilderService.menuConfig$.pipe(
+            map(sections => sections.filter(s => s.displayMode === 'settings')),
+        );
+    }
+}

+ 5 - 3
packages/admin-ui/src/lib/core/src/components/user-menu/user-menu.component.html

@@ -1,8 +1,10 @@
 <vdr-dropdown>
-    <button class="btn btn-link trigger" vdrDropdownTrigger>
+    <button class="trigger user-menu-btn" vdrDropdownTrigger>
+        <div class="user-circle">
+        <clr-icon shape="user" size="16"></clr-icon>
+        </div>
         <span class="user-name">{{ userName }}</span>
-        <clr-icon shape="user" size="24"></clr-icon>
-        <clr-icon shape="caret down"></clr-icon>
+        <clr-icon class="md:hidden" shape="caret down"></clr-icon>
     </button>
     <vdr-dropdown-menu vdrPosition="bottom-right">
         <a [routerLink]="['/settings', 'profile']" vdrDropdownItem>

+ 51 - 9
packages/admin-ui/src/lib/core/src/components/user-menu/user-menu.component.scss

@@ -1,20 +1,62 @@
-@import "variables";
+@import 'variables';
 
 :host {
     display: flex;
     align-items: center;
-    margin: 0 0.5rem;
     height: 2.5rem;
+    width: 100%;
+    @media screen and (max-width: $breakpoint-medium) {
+        margin: 0;
+    }
+    width: 100%;
+    padding: var(--space-unit);
+    padding-left: 0;
+    vdr-dropdown {
+        width: 100%;
+    }
 }
 
-.user-name {
-    color: var(--color-grey-200);
-    margin-right: 12px;
-    @media screen and (max-width: $breakpoint-small) {
-        display: none;
+.user-menu-btn {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    border: none;
+    border-radius: var(--border-radius);
+    text-transform: uppercase;
+    font-size: var(--font-size-xs);
+    width: 100%;
+    background-color: transparent;
+    color: var(--color-text-200);
+    cursor: pointer;
+    &:hover {
+        color: var(--color-text-300);
     }
 }
 
-.trigger clr-icon {
-    color: white;
+.user-circle {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 100%;
+    background-color: var(--color-primary-600);
+    width: 24px;
+    height: 24px;
+    margin-right: 6px;
+    @media screen and (max-width: $breakpoint-medium) {
+        margin-right: 0;
+    }
+
+    clr-icon {
+        color: var(--color-text-inverse);
+    }
+}
+
+.user-name {
+    margin-right: var(--space-unit);
+    overflow: hidden;
+    max-width: 100px;
+    text-overflow: ellipsis;
+    @media screen and (max-width: $breakpoint-medium) {
+        display: none;
+    }
 }

+ 6 - 0
packages/admin-ui/src/lib/core/src/core.module.ts

@@ -8,11 +8,14 @@ import { TranslateCompiler, TranslateLoader, TranslateModule } from '@ngx-transl
 import { getAppConfig } from './app.config';
 import { getDefaultUiLanguage } from './common/utilities/get-default-ui-language';
 import { AppShellComponent } from './components/app-shell/app-shell.component';
+import { BaseNavComponent } from './components/base-nav/base-nav.component';
 import { BreadcrumbComponent } from './components/breadcrumb/breadcrumb.component';
 import { ChannelSwitcherComponent } from './components/channel-switcher/channel-switcher.component';
 import { MainNavComponent } from './components/main-nav/main-nav.component';
 import { NotificationComponent } from './components/notification/notification.component';
 import { OverlayHostComponent } from './components/overlay-host/overlay-host.component';
+import { PageTitleComponent } from './components/page-title/page-title.component';
+import { SettingsNavComponent } from './components/settings-nav/settings-nav.component';
 import { ThemeSwitcherComponent } from './components/theme-switcher/theme-switcher.component';
 import { UiLanguageSwitcherDialogComponent } from './components/ui-language-switcher-dialog/ui-language-switcher-dialog.component';
 import { UserMenuComponent } from './components/user-menu/user-menu.component';
@@ -45,13 +48,16 @@ import { SharedModule } from './shared/shared.module';
     declarations: [
         AppShellComponent,
         UserMenuComponent,
+        BaseNavComponent,
         MainNavComponent,
+        SettingsNavComponent,
         BreadcrumbComponent,
         OverlayHostComponent,
         NotificationComponent,
         UiLanguageSwitcherDialogComponent,
         ChannelSwitcherComponent,
         ThemeSwitcherComponent,
+        PageTitleComponent,
     ],
 })
 export class CoreModule {

+ 147 - 0
packages/admin-ui/src/lib/core/src/providers/breadcrumb/breadcrumb.service.ts

@@ -0,0 +1,147 @@
+import { Injectable, OnDestroy } from '@angular/core';
+import { ActivatedRoute, Data, NavigationEnd, Params, PRIMARY_OUTLET, Router } from '@angular/router';
+import { DataService } from '@vendure/admin-ui/core';
+import { flatten } from 'lodash';
+import { combineLatest as observableCombineLatest, Observable, of as observableOf, Subject } from 'rxjs';
+import { filter, map, startWith, switchMap, takeUntil } from 'rxjs/operators';
+
+export type BreadcrumbString = string;
+
+export interface BreadcrumbLabelLinkPair {
+    label: string;
+    link: any[];
+}
+
+export type BreadcrumbValue = BreadcrumbString | BreadcrumbLabelLinkPair | BreadcrumbLabelLinkPair[];
+export type BreadcrumbFunction = (
+    data: Data,
+    params: Params,
+    dataService: DataService,
+) => BreadcrumbValue | Observable<BreadcrumbValue>;
+export type BreadcrumbDefinition = BreadcrumbValue | BreadcrumbFunction | Observable<BreadcrumbValue>;
+
+@Injectable({
+    providedIn: 'root',
+})
+export class BreadcrumbService implements OnDestroy {
+    breadcrumbs$: Observable<Array<{ link: string | any[]; label: string }>>;
+    private destroy$ = new Subject<void>();
+
+    constructor(private router: Router, private route: ActivatedRoute, private dataService: DataService) {
+        this.breadcrumbs$ = this.router.events.pipe(
+            filter(event => event instanceof NavigationEnd),
+            takeUntil(this.destroy$),
+            startWith(true),
+            switchMap(() => this.generateBreadcrumbs(this.route.root)),
+        );
+    }
+
+    ngOnDestroy(): void {
+        this.destroy$.next();
+        this.destroy$.complete();
+    }
+
+    private generateBreadcrumbs(
+        rootRoute: ActivatedRoute,
+    ): Observable<Array<{ link: Array<string | any>; label: string }>> {
+        const breadcrumbParts = this.assembleBreadcrumbParts(rootRoute);
+        const breadcrumbObservables$ = breadcrumbParts.map(
+            ({ value$, path }) =>
+                value$.pipe(
+                    map(value => {
+                        if (isBreadcrumbLabelLinkPair(value)) {
+                            return {
+                                label: value.label,
+                                link: this.normalizeRelativeLinks(value.link, path),
+                            };
+                        } else if (isBreadcrumbPairArray(value)) {
+                            return value.map(val => ({
+                                label: val.label,
+                                link: this.normalizeRelativeLinks(val.link, path),
+                            }));
+                        } else {
+                            return {
+                                label: value,
+                                link: '/' + path.join('/'),
+                            };
+                        }
+                    }),
+                ) as Observable<BreadcrumbLabelLinkPair | BreadcrumbLabelLinkPair[]>,
+        );
+
+        return observableCombineLatest(breadcrumbObservables$).pipe(map(links => flatten(links)));
+    }
+
+    /**
+     * Walks the route definition tree to assemble an array from which the breadcrumbs can be derived.
+     */
+    private assembleBreadcrumbParts(
+        rootRoute: ActivatedRoute,
+    ): Array<{ value$: Observable<BreadcrumbValue>; path: string[] }> {
+        const breadcrumbParts: Array<{ value$: Observable<BreadcrumbValue>; path: string[] }> = [];
+        const segmentPaths: string[] = [];
+        let currentRoute: ActivatedRoute | null = rootRoute;
+        do {
+            const childRoutes = currentRoute.children;
+            currentRoute = null;
+            childRoutes.forEach((route: ActivatedRoute) => {
+                if (route.outlet === PRIMARY_OUTLET) {
+                    const routeSnapshot = route.snapshot;
+                    let breadcrumbDef: BreadcrumbDefinition | undefined =
+                        route.routeConfig && route.routeConfig.data && route.routeConfig.data['breadcrumb'];
+                    segmentPaths.push(routeSnapshot.url.map(segment => segment.path).join('/'));
+
+                    if (breadcrumbDef) {
+                        if (isBreadcrumbFunction(breadcrumbDef)) {
+                            breadcrumbDef = breadcrumbDef(
+                                routeSnapshot.data,
+                                routeSnapshot.params,
+                                this.dataService,
+                            );
+                        }
+                        const observableValue = isObservable(breadcrumbDef)
+                            ? breadcrumbDef
+                            : observableOf(breadcrumbDef);
+                        breadcrumbParts.push({ value$: observableValue, path: segmentPaths.slice() });
+                    }
+                    currentRoute = route;
+                }
+            });
+        } while (currentRoute);
+
+        return breadcrumbParts;
+    }
+
+    /**
+     * Accounts for relative routes in the link array, i.e. arrays whose first element is either:
+     * * `./`   - this appends the rest of the link segments to the current active route
+     * * `../`  - this removes the last segment of the current active route, and appends the link segments
+     *            to the parent route.
+     */
+    private normalizeRelativeLinks(link: any[], segmentPaths: string[]): any[] {
+        const clone = link.slice();
+        if (clone[0] === './') {
+            clone[0] = segmentPaths.join('/');
+        }
+        if (clone[0] === '../') {
+            clone[0] = segmentPaths.slice(0, -1).join('/');
+        }
+        return clone.filter(segment => segment !== '');
+    }
+}
+
+function isBreadcrumbFunction(value: BreadcrumbDefinition): value is BreadcrumbFunction {
+    return typeof value === 'function';
+}
+
+function isObservable(value: BreadcrumbDefinition): value is Observable<BreadcrumbValue> {
+    return value instanceof Observable;
+}
+
+function isBreadcrumbLabelLinkPair(value: BreadcrumbValue): value is BreadcrumbLabelLinkPair {
+    return value.hasOwnProperty('label') && value.hasOwnProperty('link');
+}
+
+function isBreadcrumbPairArray(value: BreadcrumbValue): value is BreadcrumbLabelLinkPair[] {
+    return Array.isArray(value) && isBreadcrumbLabelLinkPair(value[0]);
+}

+ 2 - 0
packages/admin-ui/src/lib/core/src/providers/nav-builder/nav-builder-types.ts

@@ -57,6 +57,8 @@ export interface NavMenuSection {
     id: string;
     label: string;
     items: NavMenuItem[];
+    icon?: string;
+    displayMode?: 'regular' | 'settings';
     /**
      * @description
      * Control the display of this item based on the user permissions.

+ 10 - 10
packages/admin-ui/src/lib/core/src/providers/nav-builder/nav-builder.service.spec.ts

@@ -16,7 +16,7 @@ describe('NavBuilderService', () => {
     it('defineNavMenuSections', done => {
         service.defineNavMenuSections(getBaseNav());
 
-        service.navMenuConfig$.pipe(take(1)).subscribe(result => {
+        service.mainMenuConfig$.pipe(take(1)).subscribe(result => {
             expect(result).toEqual(getBaseNav());
             done();
         });
@@ -31,7 +31,7 @@ describe('NavBuilderService', () => {
                 items: [],
             });
 
-            service.navMenuConfig$.pipe(take(1)).subscribe(result => {
+            service.mainMenuConfig$.pipe(take(1)).subscribe(result => {
                 expect(result.map(section => section.id)).toEqual(['catalog', 'sales', 'reports']);
                 done();
             });
@@ -48,7 +48,7 @@ describe('NavBuilderService', () => {
                 'sales',
             );
 
-            service.navMenuConfig$.pipe(take(1)).subscribe(result => {
+            service.mainMenuConfig$.pipe(take(1)).subscribe(result => {
                 expect(result.map(section => section.id)).toEqual(['catalog', 'reports', 'sales']);
                 done();
             });
@@ -62,7 +62,7 @@ describe('NavBuilderService', () => {
                 items: [],
             });
 
-            service.navMenuConfig$.pipe(take(1)).subscribe(result => {
+            service.mainMenuConfig$.pipe(take(1)).subscribe(result => {
                 expect(result.map(section => section.id)).toEqual(['catalog', 'sales']);
                 expect(result[1].label).toBe('Custom Sales');
                 done();
@@ -80,7 +80,7 @@ describe('NavBuilderService', () => {
                 'catalog',
             );
 
-            service.navMenuConfig$.pipe(take(1)).subscribe(result => {
+            service.mainMenuConfig$.pipe(take(1)).subscribe(result => {
                 expect(result.map(section => section.id)).toEqual(['sales', 'catalog']);
                 expect(result[0].label).toBe('Custom Sales');
                 done();
@@ -101,7 +101,7 @@ describe('NavBuilderService', () => {
                 'farm-tools',
             );
 
-            service.navMenuConfig$.pipe(take(1)).subscribe(result => {
+            service.mainMenuConfig$.pipe(take(1)).subscribe(result => {
                 expect(console.error).toHaveBeenCalledWith(
                     'Could not add menu item "fulfillments", section "farm-tools" does not exist',
                 );
@@ -120,7 +120,7 @@ describe('NavBuilderService', () => {
                 'sales',
             );
 
-            service.navMenuConfig$.pipe(take(1)).subscribe(result => {
+            service.mainMenuConfig$.pipe(take(1)).subscribe(result => {
                 const salesSection = result.find(r => r.id === 'sales')!;
 
                 expect(salesSection.items.map(item => item.id)).toEqual(['orders', 'fulfillments']);
@@ -140,7 +140,7 @@ describe('NavBuilderService', () => {
                 'orders',
             );
 
-            service.navMenuConfig$.pipe(take(1)).subscribe(result => {
+            service.mainMenuConfig$.pipe(take(1)).subscribe(result => {
                 const salesSection = result.find(r => r.id === 'sales')!;
 
                 expect(salesSection.items.map(item => item.id)).toEqual(['fulfillments', 'orders']);
@@ -159,7 +159,7 @@ describe('NavBuilderService', () => {
                 'catalog',
             );
 
-            service.navMenuConfig$.pipe(take(1)).subscribe(result => {
+            service.mainMenuConfig$.pipe(take(1)).subscribe(result => {
                 const catalogSection = result.find(r => r.id === 'catalog')!;
 
                 expect(catalogSection.items.map(item => item.id)).toEqual(['products', 'facets']);
@@ -180,7 +180,7 @@ describe('NavBuilderService', () => {
                 'products',
             );
 
-            service.navMenuConfig$.pipe(take(1)).subscribe(result => {
+            service.mainMenuConfig$.pipe(take(1)).subscribe(result => {
                 const catalogSection = result.find(r => r.id === 'catalog')!;
 
                 expect(catalogSection.items.map(item => item.id)).toEqual(['facets', 'products']);

+ 2 - 2
packages/admin-ui/src/lib/core/src/providers/nav-builder/nav-builder.service.ts

@@ -135,7 +135,7 @@ export function addActionBarItem(config: ActionBarItem): Provider {
     providedIn: 'root',
 })
 export class NavBuilderService {
-    navMenuConfig$: Observable<NavMenuSection[]>;
+    menuConfig$: Observable<NavMenuSection[]>;
     actionBarConfig$: Observable<ActionBarItem[]>;
     sectionBadges: { [sectionId: string]: Observable<NavMenuBadgeType> } = {};
 
@@ -236,7 +236,7 @@ export class NavBuilderService {
             shareReplay(1),
         );
 
-        this.navMenuConfig$ = combineLatest(combinedConfig$, itemAdditions$).pipe(
+        this.menuConfig$ = combineLatest(combinedConfig$, itemAdditions$).pipe(
             map(([sections, additionalItems]) => {
                 for (const item of additionalItems) {
                     const section = sections.find(s => s.id === item.sectionId);

+ 5 - 0
packages/admin-ui/src/lib/core/src/public_api.ts

@@ -243,3 +243,8 @@ export * from './shared/pipes/time-ago.pipe';
 export * from './shared/providers/routing/can-deactivate-detail-guard';
 export * from './shared/shared.module';
 export * from './validators/unicode-pattern.validator';
+export { BreadcrumbDefinition } from './providers/breadcrumb/breadcrumb.service';
+export { BreadcrumbFunction } from './providers/breadcrumb/breadcrumb.service';
+export { BreadcrumbValue } from './providers/breadcrumb/breadcrumb.service';
+export { BreadcrumbLabelLinkPair } from './providers/breadcrumb/breadcrumb.service';
+export { BreadcrumbString } from './providers/breadcrumb/breadcrumb.service';

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/components/action-bar/action-bar.component.scss

@@ -6,7 +6,7 @@
     align-items: baseline;
     background-color: var(--color-component-bg-100);
     position: sticky;
-    top: -24px;
+    top: 40px;
     z-index: 25;
     border-bottom: 1px solid var(--color-component-border-200);
 

+ 4 - 5
packages/admin-ui/src/lib/core/src/shared/components/channel-badge/channel-badge.component.scss

@@ -1,12 +1,11 @@
+@import "variables";
 
 :host {
     display: inline-block;
-
-    button & {
-        margin-bottom: -1px;
-    }
 }
 
 clr-icon {
-    margin-right: 6px;
+    @media screen and (max-width: $breakpoint-medium) {
+        margin-right: 0;
+    }
 }

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/components/ui-extension-point/ui-extension-point.component.html

@@ -1,4 +1,4 @@
-<div [class.highlight]="isDevMode && (display$ | async)" class="wrapper">
+<div [class.highlight]="isDevMode && (display$ | async)" class="wrapper" [style.display]="display">
     <vdr-dropdown *ngIf="isDevMode && (display$ | async)">
         <button class="btn btn-icon btn-link extension-point-info-trigger"
                 [style.top.px]="topPx ?? 0"

+ 0 - 2
packages/admin-ui/src/lib/core/src/shared/components/ui-extension-point/ui-extension-point.component.scss

@@ -2,11 +2,9 @@
 
 :host {
     position: relative;
-    display: inline-block;
 }
 
 .wrapper {
-    display: inline-block;
     height: 100%;
 }
 

+ 4 - 1
packages/admin-ui/src/lib/core/src/shared/components/ui-extension-point/ui-extension-point.component.ts

@@ -1,4 +1,4 @@
-import { ChangeDetectionStrategy, Component, Input, isDevMode, OnInit } from '@angular/core';
+import { ChangeDetectionStrategy, Component, HostBinding, Input, isDevMode, OnInit } from '@angular/core';
 import { Observable } from 'rxjs';
 
 import { UIExtensionLocationId } from '../../../common/component-registry-types';
@@ -14,6 +14,9 @@ export class UiExtensionPointComponent implements OnInit {
     @Input() locationId: UIExtensionLocationId;
     @Input() topPx: number;
     @Input() leftPx: number;
+    @HostBinding('style.display')
+    @Input()
+    display: 'block' | 'inline-block' = 'inline-block';
     @Input() api: 'actionBar' | 'navMenu' | 'detailComponent';
     display$: Observable<boolean>;
     readonly isDevMode = isDevMode();

+ 1 - 0
packages/admin-ui/src/lib/ng-package.json

@@ -9,6 +9,7 @@
     "./static/vendure-ui-config.json",
     "./static/assets/*.*",
     "./static/styles/**/*.scss",
+    "./static/fonts/*.woff2",
     "./static/i18n-messages/*.json"
   ],
   "lib": {

+ 193 - 0
packages/admin-ui/src/lib/static/fonts/fonts.css

@@ -0,0 +1,193 @@
+/* cyrillic-ext */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 300;
+  font-display: swap;
+  src: url('./inter-cyrillic-ext.woff2') format('woff2');
+  unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+/* cyrillic */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 300;
+  font-display: swap;
+  src: url('./inter-cyrillic.woff2') format('woff2');
+  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+/* greek-ext */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 300;
+  font-display: swap;
+  src: url('./inter-greek-ext.woff2') format('woff2');
+  unicode-range: U+1F00-1FFF;
+}
+/* greek */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 300;
+  font-display: swap;
+  src: url('./inter-greek.woff2') format('woff2');
+  unicode-range: U+0370-03FF;
+}
+/* vietnamese */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 300;
+  font-display: swap;
+  src: url('./inter-vietnamese.woff2') format('woff2');
+  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 300;
+  font-display: swap;
+  src: url('./inter-latin-ext.woff2') format('woff2');
+  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 300;
+  font-display: swap;
+  src: url('./inter-latin.woff2') format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* cyrillic-ext */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 500;
+  font-display: swap;
+  src: url('./inter-cyrillic-ext.woff2') format('woff2');
+  unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+/* cyrillic */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 500;
+  font-display: swap;
+  src: url('./inter-cyrillic.woff2') format('woff2');
+  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+/* greek-ext */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 500;
+  font-display: swap;
+  src: url('./inter-greek-ext.woff2') format('woff2');
+  unicode-range: U+1F00-1FFF;
+}
+/* greek */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 500;
+  font-display: swap;
+  src: url('./inter-greek.woff2') format('woff2');
+  unicode-range: U+0370-03FF;
+}
+/* vietnamese */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 500;
+  font-display: swap;
+  src: url('./inter-vietnamese.woff2') format('woff2');
+  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 500;
+  font-display: swap;
+  src: url('./inter-latin-ext.woff2') format('woff2');
+  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 500;
+  font-display: swap;
+  src: url('./inter-latin.woff2') format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* cyrillic-ext */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 700;
+  font-display: swap;
+  src: url('./inter-cyrillic-ext.woff2') format('woff2');
+  unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+/* cyrillic */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 700;
+  font-display: swap;
+  src: url('./inter-cyrillic.woff2') format('woff2');
+  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+/* greek-ext */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 700;
+  font-display: swap;
+  src: url('./inter-greek-ext.woff2') format('woff2');
+  unicode-range: U+1F00-1FFF;
+}
+/* greek */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 700;
+  font-display: swap;
+  src: url('./inter-greek.woff2') format('woff2');
+  unicode-range: U+0370-03FF;
+}
+/* vietnamese */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 700;
+  font-display: swap;
+  src: url('./inter-vietnamese.woff2') format('woff2');
+  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 700;
+  font-display: swap;
+  src: url('./inter-latin-ext.woff2') format('woff2');
+  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 700;
+  font-display: swap;
+  src: url('./inter-latin.woff2') format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+html, body:not([cds-text]) {
+    font-family: Inter, sans-serif !important;
+}

BIN
packages/admin-ui/src/lib/static/fonts/inter-cyrillic-ext.woff2


BIN
packages/admin-ui/src/lib/static/fonts/inter-cyrillic.woff2


BIN
packages/admin-ui/src/lib/static/fonts/inter-greek-ext.woff2


BIN
packages/admin-ui/src/lib/static/fonts/inter-greek.woff2


BIN
packages/admin-ui/src/lib/static/fonts/inter-latin-ext.woff2


BIN
packages/admin-ui/src/lib/static/fonts/inter-latin.woff2


BIN
packages/admin-ui/src/lib/static/fonts/inter-vietnamese.woff2


+ 4 - 0
packages/admin-ui/src/lib/static/styles/_variables.scss

@@ -3,5 +3,9 @@
 @import "@clr/ui/src/utils/_variables.clarity.scss";
 
 // breakpoints
+// Breakpoints are defined in SCSS because CSS does not
+// support custom properties in media queries.
+// See https://stackoverflow.com/a/40723269/772859
 $breakpoint-small: 768px;
 $breakpoint-medium: 992px;
+$breakpoint-large: 1280px;

+ 5 - 0
packages/admin-ui/src/lib/static/styles/global/_overrides.scss

@@ -5,6 +5,11 @@
     background-color: var(--clr-global-app-background);
 }
 
+a:link, a:visited {
+    color: var(--clr-global-link-color);
+    text-decoration: none;
+}
+
 .content-area img, .modal-content img {
     object-fit: cover;
     width: 100%;

+ 390 - 98
packages/admin-ui/src/lib/static/styles/global/_utilities.scss

@@ -1,4 +1,4 @@
-@use "sass:math";
+@use 'sass:math';
 
 // spacing
 $space-unit: 6px;
@@ -9,13 +9,14 @@ $space-3: $space-unit * 2;
 $space-4: $space-unit * 3;
 $space-5: $space-unit * 4;
 
+
 /////////////// layout ///////////////
 .block {
-    display: block;
+    display: block !important;
 }
 
 .flex {
-    display: flex;
+    display: flex !important;
 }
 
 .flex.center {
@@ -31,101 +32,392 @@ $space-5: $space-unit * 4;
     flex: 1;
 }
 
+.sm\:hidden {
+    @media screen and (max-width: $breakpoint-small) {
+        display: none !important;
+    }
+}
+
+.md\:hidden {
+    @media screen and (max-width: $breakpoint-medium) {
+        display: none !important;
+    }
+}
+
 /////////////// margins ///////////////
-.m0  { margin:        0 }
-.mt0 { margin-top:    0 }
-.mr0 { margin-right:  0 }
-.mb0 { margin-bottom: 0 }
-.ml0 { margin-left:   0 }
-.mx0 { margin-left:   0; margin-right:  0 }
-.my0 { margin-top:    0; margin-bottom: 0 }
-
-.m1  { margin:        $space-1 }
-.mt1 { margin-top:    $space-1 }
-.mr1 { margin-right:  $space-1 }
-.mb1 { margin-bottom: $space-1 }
-.ml1 { margin-left:   $space-1 }
-.mx1 { margin-left:   $space-1; margin-right:  $space-1 }
-.my1 { margin-top:    $space-1; margin-bottom: $space-1 }
-
-.m2  { margin:        $space-2 }
-.mt2 { margin-top:    $space-2 }
-.mr2 { margin-right:  $space-2 }
-.mb2 { margin-bottom: $space-2 }
-.ml2 { margin-left:   $space-2 }
-.mx2 { margin-left:   $space-2; margin-right:  $space-2 }
-.my2 { margin-top:    $space-2; margin-bottom: $space-2 }
-
-.m3  { margin:        $space-3 }
-.mt3 { margin-top:    $space-3 }
-.mr3 { margin-right:  $space-3 }
-.mb3 { margin-bottom: $space-3 }
-.ml3 { margin-left:   $space-3 }
-.mx3 { margin-left:   $space-3; margin-right:  $space-3 }
-.my3 { margin-top:    $space-3; margin-bottom: $space-3 }
-
-.m4  { margin:        $space-4 }
-.mt4 { margin-top:    $space-4 }
-.mr4 { margin-right:  $space-4 }
-.mb4 { margin-bottom: $space-4 }
-.ml4 { margin-left:   $space-4 }
-.mx4 { margin-left:   $space-4; margin-right:  $space-4 }
-.my4 { margin-top:    $space-4; margin-bottom: $space-4 }
-
-.m5  { margin:        $space-5 }
-.mt5 { margin-top:    $space-5 }
-.mr5 { margin-right:  $space-5 }
-.mb5 { margin-bottom: $space-5 }
-.ml5 { margin-left:   $space-5 }
-.mx5 { margin-left:   $space-5; margin-right:  $space-5 }
-.my5 { margin-top:    $space-5; margin-bottom: $space-5 }
-
-.mxn1 { margin-left: -$space-1; margin-right: -$space-1; }
-.mxn2 { margin-left: -$space-2; margin-right: -$space-2; }
-.mxn3 { margin-left: -$space-3; margin-right: -$space-3; }
-.mxn4 { margin-left: -$space-4; margin-right: -$space-4; }
-
-.ml-auto { margin-left: auto }
-.mr-auto { margin-right: auto }
-.mx-auto { margin-left: auto; margin-right: auto; }
+.m0 {
+    margin: 0;
+}
+.mt0 {
+    margin-top: 0;
+}
+.mr0 {
+    margin-right: 0;
+}
+.mb0 {
+    margin-bottom: 0;
+}
+.ml0 {
+    margin-left: 0;
+}
+.mx0 {
+    margin-left: 0;
+    margin-right: 0;
+}
+.my0 {
+    margin-top: 0;
+    margin-bottom: 0;
+}
+
+.m1 {
+    margin: $space-1;
+}
+.mt1 {
+    margin-top: $space-1;
+}
+.mr1 {
+    margin-right: $space-1;
+}
+.mb1 {
+    margin-bottom: $space-1;
+}
+.ml1 {
+    margin-left: $space-1;
+}
+.mx1 {
+    margin-left: $space-1;
+    margin-right: $space-1;
+}
+.my1 {
+    margin-top: $space-1;
+    margin-bottom: $space-1;
+}
+
+.m2 {
+    margin: $space-2;
+}
+.mt2 {
+    margin-top: $space-2;
+}
+.mr2 {
+    margin-right: $space-2;
+}
+.mb2 {
+    margin-bottom: $space-2;
+}
+.ml2 {
+    margin-left: $space-2;
+}
+.mx2 {
+    margin-left: $space-2;
+    margin-right: $space-2;
+}
+.my2 {
+    margin-top: $space-2;
+    margin-bottom: $space-2;
+}
+
+.m3 {
+    margin: $space-3;
+}
+.mt3 {
+    margin-top: $space-3;
+}
+.mr3 {
+    margin-right: $space-3;
+}
+.mb3 {
+    margin-bottom: $space-3;
+}
+.ml3 {
+    margin-left: $space-3;
+}
+.mx3 {
+    margin-left: $space-3;
+    margin-right: $space-3;
+}
+.my3 {
+    margin-top: $space-3;
+    margin-bottom: $space-3;
+}
+
+.m4 {
+    margin: $space-4;
+}
+.mt4 {
+    margin-top: $space-4;
+}
+.mr4 {
+    margin-right: $space-4;
+}
+.mb4 {
+    margin-bottom: $space-4;
+}
+.ml4 {
+    margin-left: $space-4;
+}
+.mx4 {
+    margin-left: $space-4;
+    margin-right: $space-4;
+}
+.my4 {
+    margin-top: $space-4;
+    margin-bottom: $space-4;
+}
+
+.m5 {
+    margin: $space-5;
+}
+.mt5 {
+    margin-top: $space-5;
+}
+.mr5 {
+    margin-right: $space-5;
+}
+.mb5 {
+    margin-bottom: $space-5;
+}
+.ml5 {
+    margin-left: $space-5;
+}
+.mx5 {
+    margin-left: $space-5;
+    margin-right: $space-5;
+}
+.my5 {
+    margin-top: $space-5;
+    margin-bottom: $space-5;
+}
+
+.mxn1 {
+    margin-left: -$space-1;
+    margin-right: -$space-1;
+}
+.mxn2 {
+    margin-left: -$space-2;
+    margin-right: -$space-2;
+}
+.mxn3 {
+    margin-left: -$space-3;
+    margin-right: -$space-3;
+}
+.mxn4 {
+    margin-left: -$space-4;
+    margin-right: -$space-4;
+}
+
+.ml-auto {
+    margin-left: auto;
+}
+.mr-auto {
+    margin-right: auto;
+}
+.mx-auto {
+    margin-left: auto;
+    margin-right: auto;
+}
 
 /////////////// padding ///////////////
-.p0  { padding: 0 }
-.pt0 { padding-top: 0 }
-.pr0 { padding-right: 0 }
-.pb0 { padding-bottom: 0 }
-.pl0 { padding-left: 0 }
-.px0 { padding-left: 0; padding-right:  0 }
-.py0 { padding-top: 0;  padding-bottom: 0 }
-
-.p1  { padding:        $space-1 }
-.pt1 { padding-top:    $space-1 }
-.pr1 { padding-right:  $space-1 }
-.pb1 { padding-bottom: $space-1 }
-.pl1 { padding-left:   $space-1 }
-.py1 { padding-top:    $space-1; padding-bottom: $space-1 }
-.px1 { padding-left:   $space-1; padding-right:  $space-1 }
-
-.p2  { padding:        $space-2 }
-.pt2 { padding-top:    $space-2 }
-.pr2 { padding-right:  $space-2 }
-.pb2 { padding-bottom: $space-2 }
-.pl2 { padding-left:   $space-2 }
-.py2 { padding-top:    $space-2; padding-bottom: $space-2 }
-.px2 { padding-left:   $space-2; padding-right:  $space-2 }
-
-.p3  { padding:        $space-3 }
-.pt3 { padding-top:    $space-3 }
-.pr3 { padding-right:  $space-3 }
-.pb3 { padding-bottom: $space-3 }
-.pl3 { padding-left:   $space-3 }
-.py3 { padding-top:    $space-3; padding-bottom: $space-3 }
-.px3 { padding-left:   $space-3; padding-right:  $space-3 }
-
-.p4  { padding:        $space-4 }
-.pt4 { padding-top:    $space-4 }
-.pr4 { padding-right:  $space-4 }
-.pb4 { padding-bottom: $space-4 }
-.pl4 { padding-left:   $space-4 }
-.py4 { padding-top:    $space-4; padding-bottom: $space-4 }
-.px4 { padding-left:   $space-4; padding-right:  $space-4 }
+.p0 {
+    padding: 0;
+}
+.pt0 {
+    padding-top: 0;
+}
+.pr0 {
+    padding-right: 0;
+}
+.pb0 {
+    padding-bottom: 0;
+}
+.pl0 {
+    padding-left: 0;
+}
+.px0 {
+    padding-left: 0;
+    padding-right: 0;
+}
+.py0 {
+    padding-top: 0;
+    padding-bottom: 0;
+}
+
+.p1 {
+    padding: $space-1;
+}
+.pt1 {
+    padding-top: $space-1;
+}
+.pr1 {
+    padding-right: $space-1;
+}
+.pb1 {
+    padding-bottom: $space-1;
+}
+.pl1 {
+    padding-left: $space-1;
+}
+.py1 {
+    padding-top: $space-1;
+    padding-bottom: $space-1;
+}
+.px1 {
+    padding-left: $space-1;
+    padding-right: $space-1;
+}
+
+.p2 {
+    padding: $space-2;
+}
+.pt2 {
+    padding-top: $space-2;
+}
+.pr2 {
+    padding-right: $space-2;
+}
+.pb2 {
+    padding-bottom: $space-2;
+}
+.pl2 {
+    padding-left: $space-2;
+}
+.py2 {
+    padding-top: $space-2;
+    padding-bottom: $space-2;
+}
+.px2 {
+    padding-left: $space-2;
+    padding-right: $space-2;
+}
+
+.p3 {
+    padding: $space-3;
+}
+.pt3 {
+    padding-top: $space-3;
+}
+.pr3 {
+    padding-right: $space-3;
+}
+.pb3 {
+    padding-bottom: $space-3;
+}
+.pl3 {
+    padding-left: $space-3;
+}
+.py3 {
+    padding-top: $space-3;
+    padding-bottom: $space-3;
+}
+.px3 {
+    padding-left: $space-3;
+    padding-right: $space-3;
+}
+
+.p4 {
+    padding: $space-4;
+}
+.pt4 {
+    padding-top: $space-4;
+}
+.pr4 {
+    padding-right: $space-4;
+}
+.pb4 {
+    padding-bottom: $space-4;
+}
+.pl4 {
+    padding-left: $space-4;
+}
+.py4 {
+    padding-top: $space-4;
+    padding-bottom: $space-4;
+}
+.px4 {
+    padding-left: $space-4;
+    padding-right: $space-4;
+}
+
+$spacings: (1, 2, 3, 4, 5, auto);
+
+$sides: (
+    't': 'top',
+    'b': 'bottom',
+    'l': 'left',
+    'r': 'right',
+    '': (
+        'top',
+        'left',
+        'bottom',
+        'right',
+    ),
+    'x': (
+        'left',
+        'right',
+    ),
+    'y': (
+        'top',
+        'bottom',
+    ),
+);
+
+$breakpoints: (
+    '': '',
+    'sm': var(--breakpoint-sm),
+    'md': var(--breakpoint-md),
+    'lg': var(--breakpoint-lg),
+    'xl': var(--breakpoint-xl),
+);
+
+$breakpoint-glue: '\\:' !default;
+@each $breakName, $breakValue in $breakpoints {
+    @if $breakName != '' {
+        @media (min-width: $breakValue) {
+            @each $space in $spacings {
+                @each $prefix, $positions in $sides {
+                    .#{$breakName}\:p#{$prefix}-#{$space} {
+                        @each $pos in $positions {
+                            padding-#{$pos}: if(
+                                $space == auto,
+                                $space,
+                                calc(#{$space} * var(--space-unit))
+                            ) !important;
+                        }
+                    }
+                    .#{$breakName}\:m#{$prefix}-#{$space} {
+                        @each $pos in $positions {
+                            margin-#{$pos}: if(
+                                $space == auto,
+                                $space,
+                                calc(#{$space} * var(--space-unit))
+                            ) !important;
+                        }
+                    }
+                }
+            }
+        }
+    } @else {
+        @each $space in $spacings {
+            @each $prefix, $positions in $sides {
+                .p#{$prefix}-#{$space} {
+                    @each $pos in $positions {
+                        padding-#{$pos}: if(
+                            $space == auto,
+                            $space,
+                            calc(#{$space} * var(--space-unit))
+                        ) !important;
+                    }
+                }
+                .m#{$prefix}-#{$space} {
+                    @each $pos in $positions {
+                        margin-#{$pos}: if(
+                            $space == auto,
+                            $space,
+                            calc(#{$space} * var(--space-unit))
+                        ) !important;
+                    }
+                }
+            }
+        }
+    }
+}

+ 1 - 0
packages/admin-ui/src/lib/static/styles/styles.scss

@@ -6,6 +6,7 @@
 @import "global/forms";
 @import "global/overrides";
 @import "global/utilities";
+@import "../fonts/fonts.css";
 
 @import 'theme/default';
 @import 'theme/dark';

+ 2 - 0
packages/admin-ui/src/lib/static/styles/theme/dark.scss

@@ -21,6 +21,7 @@
     --color-text-100: hsl(210, 16%, 93%);
     --color-text-200: hsl(203, 16%, 72%);
     --color-text-300: var(--color-grey-300);
+    --color-text-inverse: var(--clr-global-font-color);
 
     --color-chip-warning-border: var(--color-warning-700);
     --color-chip-warning-text: #fff;
@@ -32,6 +33,7 @@
     --color-chip-error-text: #fff;
     --color-chip-error-bg: var(--color-error-600);
 
+    --color-top-bar-bg: var(--color-component-bg-200);
     --color-icon-button: var(--color-grey-200);
     --color-form-input-bg: hsl(212, 35%, 95%);
     --color-timeline-thread: var(--color-primary-700);

+ 24 - 2
packages/admin-ui/src/lib/static/styles/theme/default.scss

@@ -69,8 +69,15 @@
     --color-text-100: var(--clr-global-font-color);
     --color-text-200: var(--clr-global-font-color-secondary);
     --color-text-300: var(--color-grey-400);
+    --color-text-inverse: white;
 
     // Component-specific colors
+    --color-top-bar-bg: white;
+
+    --color-left-nav-bg: var(--color-primary-800);
+    --color-left-nav-text: var(--color-primary-100);
+    --color-left-nav-text-hover: var(--color-primary-200);
+
     --color-icon-button: var(--color-grey-600);
     --color-form-input-bg: white;
     --color-timeline-thread: var(--color-primary-100);
@@ -94,6 +101,9 @@
     --color-json-editor-key: var(--color-success-500);
     --color-json-editor-error: var(--color-error-500);
 
+    // Layout
+    --layout-content-max-width: 1200px;
+
     // Login page
     --login-page-bg: url(data:image/gif;base64,R0lGODlhFAAhAKIAABwcHBsbGxcXFx0dHRUVFRkZGR4eHhQUFCH/C1hNUCBEYXRhWE1QPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS4zLWMwMTEgNjYuMTQ2NzI5LCAyMDEyLzA1LzAzLTEzOjQwOjAzICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgRWxlbWVudHMgMTIuMCBXaW5kb3dzIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjhBOTg1NDlCNjMwNDExRUFBNEUwQ0M2RDdENUQ3RTBFIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjhBOTg1NDlDNjMwNDExRUFBNEUwQ0M2RDdENUQ3RTBFIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6OEE5ODU0OTk2MzA0MTFFQUE0RTBDQzZEN0Q1RDdFMEUiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6OEE5ODU0OUE2MzA0MTFFQUE0RTBDQzZEN0Q1RDdFMEUiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz4B//79/Pv6+fj39vX08/Lx8O/u7ezr6uno5+bl5OPi4eDf3t3c29rZ2NfW1dTT0tHQz87NzMvKycjHxsXEw8LBwL++vby7urm4t7a1tLOysbCvrq2sq6qpqKempaSjoqGgn56dnJuamZiXlpWUk5KRkI+OjYyLiomIh4aFhIOCgYB/fn18e3p5eHd2dXRzcnFwb25tbGtqaWhnZmVkY2JhYF9eXVxbWllYV1ZVVFNSUVBPTk1MS0pJSEdGRURDQkFAPz49PDs6OTg3NjU0MzIxMC8uLSwrKikoJyYlJCMiISAfHh0cGxoZGBcWFRQTEhEQDw4NDAsKCQgHBgUEAwIBAAAh+QQAAAAAACwAAAAAFAAhAAADfSi23M7igEcZOIfUSvApm1N42RAuQ0cqZ0Ri0xa8nlaltAdSY44RJsfAt3q4iLDGDFlj4Ji6RQ/6GwypqyOtwO12BQKv+EawnRq93dlwLa0NWtZpSYptniRzZfpSP9o0QBVaNHJKUHYoKkh6BnxIaoBMgnBYGAp0lgCLlgQJADs=);
     --color-login-gradient-top: var(--color-primary-800);
@@ -103,8 +113,20 @@
     --color-header-gradient-from: var(--color-primary-800);
     --color-header-gradient-to: var(--color-primary-900);
 
-    // Other variables
-    --breakpoint-small: 768px;
+    // Border radius
+    --border-radius-sm: div(var(--clr-global-borderradius), 2);
+    --border-radius: var(--clr-global-borderradius);
+    --border-radius-lg: var(--clr-global-borderradius) * 2;
     --border-radius-img: var(--clr-global-borderradius);
     --border-radius-input: var(--clr-global-borderradius);
+
+    // typography
+    --font-size-xs: 12px;
+    --font-size-sm: 14px;
+    --font-size-base: 16px;
+    --font-size-lg: 18px;
+    --font-size-xl: 20px;
+
+    // spacing
+    --space-unit: 8px;
 }