Add LinkComponent, integrate with Home and Shell components, and enhance navigation structure

This commit is contained in:
Christian Werner 2025-10-21 01:25:42 +02:00
parent f391db4fba
commit 40fde36bc6
17 changed files with 480 additions and 21 deletions

View File

@ -3,7 +3,7 @@
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"start": "ng serve --host 0.0.0.0",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
@ -38,4 +38,4 @@
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.9.3"
}
}
}

View File

@ -19,12 +19,26 @@ export const routes: Routes = [
title: 'Home',
path: '',
component: HomeComponent,
data: { showInNav: true }
},
{
title: 'Dashboard',
path: 'dashboard',
component: HomeComponent,
data: { showInNav: true },
children: [
{
title: 'Test',
path: 'test',
component: HomeComponent,
}
]
},
],
canActivate: [authGuard],
resolve: {
user: subjectResolver,
}
},
},
{
path: '**',

View File

@ -1 +1,121 @@
<app-link href="/dashboard/test">test</app-link>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>
<p>home works!</p>

View File

@ -1,8 +1,11 @@
import { Component } from '@angular/core';
import {LinkComponent} from '../link/link.component';
@Component({
selector: 'app-home',
imports: [],
imports: [
LinkComponent
],
templateUrl: './home.component.html',
styleUrl: './home.component.scss',
standalone: true,

View File

@ -0,0 +1 @@
<a [href]="href()" (click)="click($event)"><ng-content></ng-content></a>

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LinkComponent } from './link.component';
describe('LinkComponent', () => {
let component: LinkComponent;
let fixture: ComponentFixture<LinkComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [LinkComponent]
})
.compileComponents();
fixture = TestBed.createComponent(LinkComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,24 @@
import {Component, inject, input} from '@angular/core';
import {Router} from '@angular/router';
@Component({
selector: 'app-link',
imports: [],
templateUrl: './link.component.html',
styleUrl: './link.component.scss'
})
export class LinkComponent {
public href = input.required<string>();
public external = input<boolean>(false);
private router = inject(Router);
click($event: PointerEvent) {
if (!this.external()) {
$event.preventDefault();
this.router.navigate([this.href()]).then();
}
}
}

View File

@ -0,0 +1,14 @@
<div class="drawer-container">
<ng-content select="drawer-content"></ng-content>
</div>
<div class="main-content-container">
<div class="top-nav-container">
<ng-content select="top-nav-container"></ng-content>
</div>
<div class="top-nav-breadcrumb-container">
<ng-content select="top-nav-breadcrumb"></ng-content>
</div>
<div class="content-container">
<router-outlet/>
</div>
</div>

View File

@ -0,0 +1,47 @@
:host {
display: flex;
height: 100%;
overflow: auto;
.drawer-container {
display: flex;
flex-direction: column;
padding: 1rem;
}
.main-content-container{
display: flex;
flex-direction: column;
padding: 1rem 0 0 1rem;
}
.drawer-container {
background-color: var(--neutral-70);
}
.main-content-container {
flex-grow: 1;
.top-nav-breadcrumb-container, .top-nav-container {
margin-right: 1rem;
}
.top-nav-breadcrumb-container {
margin-top: 1rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--neutral-70);
border-top: 1px solid var(--neutral-70);
}
.content-container {
padding-top: 1rem;
padding-right: 1rem;
flex-grow: 1;
overflow: auto;
}
}
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DrawerComponent } from './drawer.component';
describe('DrawerComponent', () => {
let component: DrawerComponent;
let fixture: ComponentFixture<DrawerComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DrawerComponent]
})
.compileComponents();
fixture = TestBed.createComponent(DrawerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,14 @@
import { Component } from '@angular/core';
import {RouterOutlet} from '@angular/router';
@Component({
selector: 'app-drawer',
imports: [
RouterOutlet
],
templateUrl: './drawer.component.html',
styleUrl: './drawer.component.scss'
})
export class DrawerComponent {
}

View File

@ -2,4 +2,4 @@
<span>{{user()}}</span>
<a href="" (click)="logout($event)">Logout</a>
</div>
<div class="profile-picture neutral-30"></div>
<div class="profile-picture neutral-70"></div>

View File

@ -15,5 +15,6 @@
height: 3rem;
overflow: hidden;
background-color: var(--bg-color);
border-radius: 0.5rem;
}
}

View File

@ -1,4 +1,36 @@
<app-panel class="shell-nav-bar neutral-20">
<app-icon path="/full_logo.svg" class="logo primary-icon-50"></app-icon>
<app-nav-profile></app-nav-profile>
</app-panel>
<app-drawer>
<div class="drawer-content" ngProjectAs="drawer-content">
<app-icon path="/full_logo.svg" class="logo primary-icon-50"></app-icon>
<div class="nav-links">
@for (link of navLinks(); track link.label) {
<app-link [href]="link.url">
<span [ngClass]="{'active': activeTopLevelUrl() === link.url}" class="nav-link">{{link.label}}</span>
</app-link>
}
</div>
</div>
<div class="top-nav-container" ngProjectAs="top-nav-container">
<span class="title">{{ title() }}</span>
<app-nav-profile></app-nav-profile>
</div>
<div class="top-nav-breadcrumb" ngProjectAs="top-nav-breadcrumb">
<span> / </span>
@for (crumb of breadcrumbs(); let last = $last; track crumb.label) {
@if (crumb.url && !last) {
<a [routerLink]="crumb.url">{{ crumb.label }}</a>
} @else {
<span>{{ crumb.label }}</span>
}
@if (!last) {
<span> / </span>
}
}
</div>
</app-drawer>

View File

@ -1,9 +1,62 @@
.shell-nav-bar {
.logo {
$height: 3rem;
height: $height;
width: #{$height * 3.356876171875};
:host {
height: 100vh;
width: 100vw;
display: block;
.top-nav-container {
display: flex;
.title {
font-size: 2rem;
font-weight: bold;
}
app-nav-profile {
margin-left: auto;
}
}
.drawer-content {
display: flex;
flex-direction: column;
.logo {
$height: 3rem;
height: $height;
width: #{$height * 3.356876171875};
padding-bottom: 1rem;
border-bottom: 1px solid var(--neutral-60);
}
.nav-links {
margin-top: 1rem;
display: flex;
flex-direction: column;
.nav-link {
width: 100%;
padding: 0.5rem;
border-radius: 0.25rem;
&:hover, &.active {
background-color: var(--neutral-60);
}
}
::ng-deep app-link {
a {
display: flex;
width: 100%;
color: unset;
text-decoration: none;
}
}
}
}
}
.shell-nav-bar {
app-nav-profile {
margin-left: auto;
}

View File

@ -1,21 +1,111 @@
import {Component, computed, inject} from '@angular/core';
import {Component, computed, OnInit, signal} from '@angular/core';
import {NavProfileComponent} from './nav-profile/nav-profile.component';
import {ActivatedRoute, RouterOutlet} from '@angular/router';
import {PanelComponent} from '../panel/panel.component';
import {ActivatedRoute, NavigationEnd, Route, Router, RouterLink} from '@angular/router';
import {IconComponent} from '../icon/icon.component';
import {toSignal} from '@angular/core/rxjs-interop';
import {DrawerComponent} from './drawer/drawer.component';
import {filter} from 'rxjs';
import {NgClass} from '@angular/common';
import {LinkComponent} from '../link/link.component';
@Component({
selector: 'app-shell',
imports: [
NavProfileComponent,
PanelComponent,
IconComponent
IconComponent,
DrawerComponent,
RouterLink,
NgClass,
LinkComponent
],
templateUrl: './shell.component.html',
styleUrl: './shell.component.scss',
standalone: true,
})
export class ShellComponent {
export class ShellComponent implements OnInit {
protected breadcrumbs = signal<Breadcrumb[]>([]);
protected navLinks = signal<Breadcrumb[]>([]);
protected title = computed<string>(() => {
return this.breadcrumbs()[this.breadcrumbs().length - 1]?.label || 'Undefined';
});
protected activeTopLevelUrl = computed<string | null>(() => {
const breadcrumbs = this.breadcrumbs();
if (breadcrumbs.length === 0) {
return null;
}
return this.breadcrumbs()[0]?.url;
});
constructor(private router: Router, private route: ActivatedRoute) {
}
ngOnInit() {
this.initNavigation();
this.initBreadcrumbs();
this.router.events
.pipe(filter(event => event instanceof NavigationEnd))
.subscribe(() => {
this.initBreadcrumbs();
});
}
private initBreadcrumbs() {
this.breadcrumbs.set(this.buildBreadcrumbs(this.route.root));
console.log('breadcrumbs', this.breadcrumbs());
}
private initNavigation() {
this.navLinks.set(this.extractNavLinks(this.router.config));
console.log('navLinks', this.navLinks())
}
private extractNavLinks(routes: Route[]): Breadcrumb[] {
return routes
.filter(r => r.path === '')
.flatMap(r => r.children ?? [])
.filter(r => r.data?.['showInNav'])
.map(r => ({
label: r.data?.['title'] || r.title || '',
url: '/' + (r.path ?? '')
}));
}
private buildBreadcrumbs(route: ActivatedRoute, url: string = '/', breadcrumbs: Breadcrumb[] = []): Breadcrumb[] {
const children = route.children;
if (children.length === 0) {
return breadcrumbs;
}
const child = children[0];
const routeURL = child.snapshot.url.map(segment => segment.path).join('/');
if (routeURL) {
url += `${routeURL}`;
}
const label =
child.snapshot.data['title'] ||
child.snapshot.routeConfig?.title ||
'';
if (label) {
breadcrumbs.push({ label, url });
}
return this.buildBreadcrumbs(child, url, breadcrumbs);
}
}
export class Breadcrumb {
label: string = 'undefined';
url: string = '';
}