generated from bastian/boilerplate
Initial commit
This commit is contained in:
296
apps/frontend/src/app/layout/app-shell.ts
Normal file
296
apps/frontend/src/app/layout/app-shell.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { Component, HostListener, computed, effect, inject, signal } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive, RouterOutlet, Router } from '@angular/router';
|
||||
import type { Permission } from '@boilerplate/api-client';
|
||||
import { NotificationStore } from '../core/notification.store';
|
||||
import { AuthService } from '../core/auth.service';
|
||||
import { NotificationPanelComponent } from './notification-panel';
|
||||
import { UiIconButtonComponent, UiIconComponent, UiToastHostComponent } from '../shared/ui';
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
path: string;
|
||||
permission?: Permission;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-shell',
|
||||
standalone: true,
|
||||
imports: [
|
||||
RouterOutlet,
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
NotificationPanelComponent,
|
||||
UiIconButtonComponent,
|
||||
UiIconComponent,
|
||||
UiToastHostComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="shell">
|
||||
<header class="topbar">
|
||||
@if (auth.user()) {
|
||||
<ui-icon-button icon="menu" label="Navigation" (pressed)="toggleDrawer()" />
|
||||
}
|
||||
<strong class="brand">Business App</strong>
|
||||
@if (auth.user()) {
|
||||
<button
|
||||
class="ui-icon-button notification-button"
|
||||
type="button"
|
||||
aria-label="Benachrichtigungen"
|
||||
[attr.aria-expanded]="notificationPanelOpen()"
|
||||
aria-controls="notification-panel"
|
||||
(click)="toggleNotifications()"
|
||||
>
|
||||
<ui-icon name="bell" />
|
||||
@if (notifications.unreadCount() > 0) {
|
||||
<span class="badge">{{ notifications.unreadCount() }}</span>
|
||||
}
|
||||
</button>
|
||||
<a class="ui-button ui-button--ghost logout" href="/api/auth/logout">Abmelden</a>
|
||||
} @else {
|
||||
<a class="ui-button ui-button--primary logout" href="/api/auth/login">Anmelden</a>
|
||||
}
|
||||
@if (auth.user() && notificationPanelOpen()) {
|
||||
<app-notification-panel
|
||||
id="notification-panel"
|
||||
(closed)="notificationPanelOpen.set(false)"
|
||||
/>
|
||||
}
|
||||
</header>
|
||||
|
||||
@if (auth.loaded()) {
|
||||
@if (auth.user()) {
|
||||
@if (drawerOpen()) {
|
||||
<button
|
||||
class="drawer-backdrop"
|
||||
type="button"
|
||||
aria-label="Navigation schliessen"
|
||||
(click)="drawerOpen.set(false)"
|
||||
></button>
|
||||
}
|
||||
<aside class="sidebar" [class.open]="drawerOpen()">
|
||||
<nav aria-label="Hauptnavigation">
|
||||
@for (item of nav; track item.path) {
|
||||
@if (visible(item)) {
|
||||
<a
|
||||
[routerLink]="item.path"
|
||||
routerLinkActive="active"
|
||||
[routerLinkActiveOptions]="{ exact: item.path === '/' }"
|
||||
(click)="closeDrawerOnNavigation()"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
}
|
||||
}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="content">
|
||||
<nav class="breadcrumbs">Start / {{ title() }}</nav>
|
||||
<router-outlet />
|
||||
</main>
|
||||
} @else {
|
||||
<main class="content public-content">
|
||||
<section class="login-panel">
|
||||
<h1>Anmelden</h1>
|
||||
<p>Bitte melden Sie sich ueber den zentralen Identity Provider an.</p>
|
||||
<a class="ui-button ui-button--primary ui-button--mobile-full" href="/api/auth/login"
|
||||
>Mit OIDC anmelden</a
|
||||
>
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
} @else {
|
||||
<main class="content public-content">
|
||||
<section class="login-panel">
|
||||
<h1>Session wird geprueft</h1>
|
||||
<p>Bitte warten.</p>
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
<ui-toast-host />
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-header);
|
||||
height: var(--header-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: 0 var(--space-5);
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.brand {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.logout {
|
||||
margin-left: auto;
|
||||
}
|
||||
.notification-button {
|
||||
margin-left: auto;
|
||||
}
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: var(--space-1);
|
||||
right: var(--space-1);
|
||||
min-width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-danger);
|
||||
color: var(--color-surface);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: var(--font-size-xs);
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
.public-content {
|
||||
min-height: calc(100vh - var(--header-height));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-left: 0;
|
||||
}
|
||||
.login-panel {
|
||||
width: min(100%, 440px);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.login-panel h1,
|
||||
.login-panel p {
|
||||
margin: 0;
|
||||
}
|
||||
.login-panel p {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: var(--header-height) 0 0;
|
||||
z-index: calc(var(--z-drawer) - 1);
|
||||
border: 0;
|
||||
background: color-mix(in srgb, var(--color-text-primary) 36%, transparent);
|
||||
}
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
inset: var(--header-height) auto 0 0;
|
||||
width: var(--sidebar-width);
|
||||
background: var(--color-surface);
|
||||
border-right: 1px solid var(--color-border);
|
||||
transform: translateX(-100%);
|
||||
transition: transform var(--transition-base) ease;
|
||||
z-index: var(--z-drawer);
|
||||
}
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
nav a {
|
||||
display: block;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
color: var(--color-text-primary);
|
||||
text-decoration: none;
|
||||
min-height: var(--touch-target);
|
||||
}
|
||||
nav a.active {
|
||||
background: var(--color-primary-subtle);
|
||||
border-left: 4px solid var(--color-primary);
|
||||
}
|
||||
.content {
|
||||
min-width: 0;
|
||||
padding: var(--space-5) var(--space-5) var(--space-8);
|
||||
}
|
||||
.breadcrumbs {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
@media (min-width: 64rem) {
|
||||
ui-icon-button {
|
||||
display: none;
|
||||
}
|
||||
.drawer-backdrop {
|
||||
display: none;
|
||||
}
|
||||
.sidebar {
|
||||
transform: none;
|
||||
}
|
||||
.content {
|
||||
margin-left: var(--sidebar-width);
|
||||
padding: var(--space-7);
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class AppShellComponent {
|
||||
private readonly router = inject(Router);
|
||||
readonly auth = inject(AuthService);
|
||||
readonly notifications = inject(NotificationStore);
|
||||
readonly drawerOpen = signal(false);
|
||||
readonly notificationPanelOpen = signal(false);
|
||||
readonly title = computed(
|
||||
() => this.router.routerState.snapshot.root.firstChild?.firstChild?.title ?? 'Dashboard',
|
||||
);
|
||||
readonly nav: NavItem[] = [
|
||||
{ label: 'Dashboard', path: '/' },
|
||||
{ label: 'Profil', path: '/profil' },
|
||||
{ label: 'Sicherheit', path: '/account/security' },
|
||||
{
|
||||
label: 'Benachrichtigungen',
|
||||
path: '/notifications',
|
||||
permission: 'notifications.readOwn',
|
||||
},
|
||||
{ label: 'Sessions', path: '/sessions', permission: 'sessions.readOwn' },
|
||||
{ label: 'Items', path: '/items', permission: 'items.read' },
|
||||
{ label: 'Admin Benutzer', path: '/admin/users', permission: 'users.read' },
|
||||
{ label: 'Admin Rollen', path: '/admin/roles', permission: 'roles.read' },
|
||||
{ label: 'Admin Audit', path: '/admin/audit', permission: 'audit.read' },
|
||||
];
|
||||
|
||||
constructor() {
|
||||
this.auth.loadMe();
|
||||
effect(() => {
|
||||
if (this.auth.user()) {
|
||||
this.notifications.startPolling();
|
||||
} else {
|
||||
this.notifications.stopPolling();
|
||||
this.notificationPanelOpen.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
visible(item: NavItem): boolean {
|
||||
return !item.permission || this.auth.has(item.permission);
|
||||
}
|
||||
|
||||
toggleNotifications(): void {
|
||||
this.notificationPanelOpen.update((open) => !open);
|
||||
if (this.notificationPanelOpen()) {
|
||||
this.notifications.openPanel();
|
||||
}
|
||||
}
|
||||
|
||||
toggleDrawer(): void {
|
||||
this.drawerOpen.update((open) => !open);
|
||||
}
|
||||
|
||||
closeDrawerOnNavigation(): void {
|
||||
this.drawerOpen.set(false);
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
closeOverlays(): void {
|
||||
this.drawerOpen.set(false);
|
||||
this.notificationPanelOpen.set(false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user