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>
110 lines
3.5 KiB
TypeScript
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;
|
|
}
|
|
}
|