Files
flat-pilot/apps/frontend/src/app/layout/app-shell.ts
Bastian Wagner e62673ac11 mvp
2026-07-20 09:01:36 +02:00

304 lines
9.1 KiB
TypeScript

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">HausPilot</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]="loginHref()">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]="loginHref()"
>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: 'Projekte', path: '/projekte', permission: 'projects.use' },
{ label: 'Einladungen', path: '/einladungen', permission: 'projects.use' },
{ 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);
}
loginHref(): string {
const returnTo = this.router.url.startsWith('/einladungen') ? this.router.url : '/';
return `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`;
}
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);
}
}