Files
Portfolio/src/app/app.ts
Antonio Ledebuhr 646b39b200 Make the primary nav customer-facing and keep the services submenu readable.
Stack and the CV leave the chrome so Contact stays the main action. The desktop submenu now uses an opaque surface token and a stacking context above page content.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 12:52:17 +02:00

110 lines
3.5 KiB
TypeScript

import { DOCUMENT, ViewportScroller } from '@angular/common';
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
computed,
inject,
Injector,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { filter } from 'rxjs';
import { DotBackground } from './components/dot-background/dot-background';
import { SHELL_COPY } from './core/content/shell-copy';
import { SITE_CONFIG } from './core/content/site-config';
import { LOCALE_HTML_LANG, otherLocale } from './core/i18n/locale';
import { LocaleService } from './core/i18n/locale.service';
import { NavigationService } from './core/navigation/navigation.service';
import { isBrowserPlatform, viewportMatches } from './core/platform/browser';
import { TerminalDock } from './shared/terminal/terminal-dock';
/** Matches `md` in `src/_breakpoints.scss` (48rem). */
export const WIDE_NAV_QUERY = '(min-width: 48rem)';
@Component({
selector: 'app-root',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterOutlet, RouterLink, RouterLinkActive, DotBackground, TerminalDock],
templateUrl: './app.html',
styleUrl: './app.scss',
})
export class App {
protected readonly navigation = inject(NavigationService);
protected readonly localeService = inject(LocaleService);
private readonly router = inject(Router);
private readonly injector = inject(Injector);
private readonly document = inject(DOCUMENT);
private readonly viewportScroller = inject(ViewportScroller);
private readonly isBrowser = isBrowserPlatform();
private readonly wideNav = viewportMatches(WIDE_NAV_QUERY);
protected readonly siteConfig = SITE_CONFIG;
protected readonly navOpen = signal(true);
protected readonly shell = computed(() => SHELL_COPY[this.localeService.locale()]);
protected readonly otherLocale = computed(() => otherLocale(this.localeService.locale()));
protected readonly otherHtmlLang = computed(() => LOCALE_HTML_LANG[this.otherLocale()]);
protected readonly menuLabel = computed(() =>
this.navOpen() ? this.shell().menuClose : this.shell().menuOpen,
);
constructor() {
afterNextRender(
() => {
this.collapseNavIfNarrow();
this.bindHeaderScrollOffset();
},
{ injector: this.injector },
);
this.router.events
.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
takeUntilDestroyed(),
)
.subscribe(() => this.collapseNavIfNarrow());
}
protected toggleNav(): void {
this.navOpen.update((open) => !open);
}
private collapseNavIfNarrow(): void {
if (!this.isBrowser || this.wideNav) {
return;
}
this.navOpen.set(false);
}
private bindHeaderScrollOffset(): void {
if (!this.isBrowser) {
return;
}
this.viewportScroller.setOffset(() => [0, this.headerOffsetPx()]);
}
private headerOffsetPx(): number {
const view = this.document.defaultView;
if (!view) {
return 0;
}
const styles = view.getComputedStyle(this.document.documentElement);
const raw = styles.getPropertyValue('--header-offset').trim();
const numeric = Number.parseFloat(raw);
if (!Number.isFinite(numeric)) {
return 0;
}
if (raw.endsWith('rem')) {
const rootSize = Number.parseFloat(styles.fontSize);
return numeric * (Number.isFinite(rootSize) ? rootSize : 16);
}
return numeric;
}
}