From 735b5fc206fc3a42c6b24d7811139125122ff4c1 Mon Sep 17 00:00:00 2001 From: Julian Lechner Date: Tue, 28 Apr 2026 13:52:38 +0200 Subject: [PATCH] feat: added languages --- src/app/app.component.html | 66 ++++++- src/app/app.component.spec.ts | 69 +++++-- src/app/app.component.ts | 91 ++++++++- src/app/app.config.ts | 5 +- src/app/app.types.ts | 6 + src/app/footer/footer.component.html | 4 +- src/app/footer/footer.component.ts | 13 +- src/app/home/home.component.html | 110 ++++------- src/app/home/home.component.spec.ts | 34 ++-- src/app/home/home.component.ts | 209 +++++++------------- src/app/imprint/imprint.component.html | 129 ++++--------- src/app/imprint/imprint.component.ts | 43 ++--- src/app/services/language.service.ts | 43 +++++ src/global.ts | 214 +------------------- src/global.types.ts | 18 ++ src/languages/de.ts | 258 +++++++++++++++++++++++++ src/languages/en.ts | 258 +++++++++++++++++++++++++ src/languages/language.types.ts | 95 +++++++++ src/styles.scss | 227 ++++++++++++++++++++++ 19 files changed, 1300 insertions(+), 592 deletions(-) create mode 100644 src/app/app.types.ts create mode 100644 src/app/services/language.service.ts create mode 100644 src/global.types.ts create mode 100644 src/languages/de.ts create mode 100644 src/languages/en.ts create mode 100644 src/languages/language.types.ts diff --git a/src/app/app.component.html b/src/app/app.component.html index 0680b43..5aab10b 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1 +1,65 @@ - +
+ +
+ +@if (isLanguageConfirmed()) { + +} + +@if (isLanguageSelectorOpen()) { + +} diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 5e036b7..3d471b8 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -9,6 +9,8 @@ describe('AppComponent', (): void => { let component: AppComponent; beforeEach(async (): Promise => { + localStorage.clear(); + await TestBed.configureTestingModule({ imports: [AppComponent], providers: [provideRouter([])], @@ -23,28 +25,65 @@ describe('AppComponent', (): void => { expect(component).toBeTruthy(); }); - it('should have title "Lechner Julian"', (): void => { - expect(component.title).toBe('Lechner Julian'); + it('should render the language selector on first visit', (): void => { + const el = fixture.nativeElement as HTMLElement; + expect(el.querySelector('.language-overlay')).not.toBeNull(); }); - it('should render a router-outlet element', (): void => { + it('should preselect English by default', (): void => { + const comp = component as unknown as { selectedLanguage(): string }; + expect(comp.selectedLanguage()).toBe('en'); + }); + + it('should always render a router-outlet element', (): void => { const el = fixture.nativeElement as HTMLElement; expect(el.querySelector('router-outlet')).not.toBeNull(); }); - it('should not render any additional root elements besides router-outlet', (): void => { - const el = fixture.nativeElement as HTMLElement; - const children = Array.from(el.children); - expect(children.length).toBe(1); - expect(children[0].tagName.toLowerCase()).toBe('router-outlet'); + it('should confirm and persist the selected language', (): void => { + const comp = component as unknown as { + chooseLanguage(language: 'en' | 'de'): void; + currentLanguageCode(): string; + isLanguageConfirmed(): boolean; + isLanguageSelectorOpen(): boolean; + }; + + comp.chooseLanguage('de'); + fixture.detectChanges(); + + expect(comp.currentLanguageCode()).toBe('de'); + expect(comp.isLanguageConfirmed()).toBeTrue(); + expect(comp.isLanguageSelectorOpen()).toBeFalse(); + expect(localStorage.getItem('portfolio-language')).toBe('de'); }); - describe('Performance', (): void => { - it('should create and run initial change detection within 100 ms', (): void => { - const start = performance.now(); - const f = TestBed.createComponent(AppComponent); - f.detectChanges(); - expect(performance.now() - start).toBeLessThan(100); - }); + it('should restore a saved language from localStorage', async (): Promise => { + localStorage.setItem('portfolio-language', 'de'); + + const restoredFixture = TestBed.createComponent(AppComponent); + restoredFixture.detectChanges(); + const restoredComponent = restoredFixture.componentInstance as unknown as { + currentLanguageCode(): string; + isLanguageConfirmed(): boolean; + isLanguageSelectorOpen(): boolean; + }; + + expect(restoredComponent.currentLanguageCode()).toBe('de'); + expect(restoredComponent.isLanguageConfirmed()).toBeTrue(); + expect(restoredComponent.isLanguageSelectorOpen()).toBeFalse(); + }); + + it('should reopen the selector after confirmation', (): void => { + const comp = component as unknown as { + chooseLanguage(language: 'en' | 'de'): void; + reopenLanguageSelector(): void; + isLanguageSelectorOpen(): boolean; + }; + + comp.chooseLanguage('en'); + comp.reopenLanguageSelector(); + fixture.detectChanges(); + + expect(comp.isLanguageSelectorOpen()).toBeTrue(); }); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index e102b86..cb5fdf0 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,11 +1,96 @@ -import { Component } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + signal, +} from '@angular/core'; +import type { OnDestroy, OnInit } from '@angular/core'; import { RouterOutlet } from '@angular/router'; +import type { LanguageCode } from '../languages/language.types'; +import type { LanguageOption } from './app.types'; +import { LanguageService } from './services/language.service'; + @Component({ selector: 'app-root', + standalone: true, imports: [RouterOutlet], templateUrl: './app.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AppComponent { - title: string = 'Lechner Julian'; +export class AppComponent implements OnInit, OnDestroy { + private readonly document = inject(DOCUMENT); + private readonly languageService = inject(LanguageService); + + protected readonly content = this.languageService.content; + protected readonly currentLanguageCode = this.languageService.languageCode; + protected readonly isLanguageConfirmed = this.languageService.isLanguageConfirmed; + protected readonly isLanguageSelectorOpen = signal(true); + protected readonly selectedLanguage = signal('en'); + protected readonly languageOptions: readonly LanguageOption[] = [ + { code: 'en', accent: 'EN' }, + { code: 'de', accent: 'DE' }, + ]; + protected readonly dialogContent = computed(() => + this.languageService.getPack(this.selectedLanguage()), + ); + protected readonly languageSwitcherLabel = computed(() => + this.getLanguageLabel(this.currentLanguageCode(), this.content()), + ); + + constructor() { + effect((): void => { + const overflowValue = this.isLanguageSelectorOpen() ? 'hidden' : ''; + this.document.body.style.overflow = overflowValue; + this.document.documentElement.style.overflow = overflowValue; + }); + } + + ngOnInit(): void { + const initialLanguage = this.languageService.initializeFromStorage(); + this.selectedLanguage.set(initialLanguage); + this.isLanguageSelectorOpen.set(!this.isLanguageConfirmed()); + } + + protected chooseLanguage(language: LanguageCode): void { + this.selectedLanguage.set(language); + this.confirmLanguage(); + } + + protected confirmLanguage(): void { + this.languageService.confirmLanguage(this.selectedLanguage()); + this.isLanguageSelectorOpen.set(false); + } + + protected reopenLanguageSelector(): void { + this.selectedLanguage.set(this.currentLanguageCode()); + this.isLanguageSelectorOpen.set(true); + } + + protected closeLanguageSelector(): void { + if (!this.isLanguageConfirmed()) { + return; + } + + this.selectedLanguage.set(this.currentLanguageCode()); + this.isLanguageSelectorOpen.set(false); + } + + protected closeLanguageSelectorOnBackdrop(event: MouseEvent): void { + if (event.target === event.currentTarget) { + this.closeLanguageSelector(); + } + } + + protected getLanguageLabel(language: LanguageCode, content = this.dialogContent()): string { + return language === 'de' ? content.app.languageGerman : content.app.languageEnglish; + } + + ngOnDestroy(): void { + this.document.body.style.overflow = ''; + this.document.documentElement.style.overflow = ''; + } } diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 2de7dd7..71c2a7b 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,7 +1,7 @@ import type { ApplicationConfig } from '@angular/core'; -import { importProvidersFrom, provideZoneChangeDetection } from '@angular/core'; +import { provideZoneChangeDetection } from '@angular/core'; import { provideRouter, withInMemoryScrolling } from '@angular/router'; -import { BrowserModule, provideClientHydration, withEventReplay } from '@angular/platform-browser'; +import { provideClientHydration, withEventReplay } from '@angular/platform-browser'; import { routes } from './app.routes'; @@ -16,6 +16,5 @@ export const appConfig: ApplicationConfig = { }), ), provideClientHydration(withEventReplay()), - importProvidersFrom(BrowserModule), ], }; diff --git a/src/app/app.types.ts b/src/app/app.types.ts new file mode 100644 index 0000000..b8ef453 --- /dev/null +++ b/src/app/app.types.ts @@ -0,0 +1,6 @@ +import type { LanguageCode } from '../languages/language.types'; + +export interface LanguageOption { + code: LanguageCode; + accent: string; +} diff --git a/src/app/footer/footer.component.html b/src/app/footer/footer.component.html index 73e64a4..8d473e3 100644 --- a/src/app/footer/footer.component.html +++ b/src/app/footer/footer.component.html @@ -1,7 +1,7 @@ @@ -9,6 +9,6 @@ diff --git a/src/app/footer/footer.component.ts b/src/app/footer/footer.component.ts index 52a3582..de34e47 100644 --- a/src/app/footer/footer.component.ts +++ b/src/app/footer/footer.component.ts @@ -1,14 +1,19 @@ -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { RouterLink } from '@angular/router'; -import type { Global } from '../../global'; import { global } from '../../global'; +import { LanguageService } from '../services/language.service'; @Component({ selector: 'app-footer', + standalone: true, imports: [RouterLink], templateUrl: './footer.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, }) export class FooterComponent { - protected readonly global: Global = global; - protected readonly currentYear: number = new Date().getFullYear(); + private readonly languageService = inject(LanguageService); + + protected readonly content = this.languageService.content; + protected readonly global = global; + protected readonly currentYear = new Date().getFullYear(); } diff --git a/src/app/home/home.component.html b/src/app/home/home.component.html index 746230f..1ff28ab 100644 --- a/src/app/home/home.component.html +++ b/src/app/home/home.component.html @@ -2,53 +2,44 @@
-

Portfolio

+

{{ content().home.portfolioEyebrow }}

{{ global.firstname }} {{ global.lastname }}

-

- Building reliable systems, clean interfaces, and practical digital solutions that actually hold up in production. -

+

{{ content().home.heroIntro }}

-

Contact me

- +

{{ content().home.contactTitle }}

- {{ currentBioEntry.label }} - {{ currentBioEntry.value }} + {{ currentBioEntry.label }}{{ currentBioEntry.value }}
@@ -60,8 +51,8 @@ type="button" class="portrait-highlight-button" (click)="showNextPortraitHighlight()" - [attr.aria-label]="'Show next highlight image'" - [title]="portraitHighlights.length > 1 ? 'Show next image' : 'Single image'" + [attr.aria-label]="content().home.nextHighlightAriaLabel" + [title]="portraitHighlights.length > 1 ? content().home.nextHighlightTitle : content().home.singleHighlightTitle" >
@if (currentPortraitHighlightIndex === 0) { - Click me! + {{ content().home.clickHint }} } {{ currentPortraitHighlight.text }}
@@ -89,47 +80,27 @@
-

About me

+

{{ content().home.aboutEyebrow }}

-
-

- My name is {{ global.firstname }}, though most people call me Julie. I’m {{ age }} and based in Austria. A large part of my day - happens on the move — usually on trains, going between meetings, offices, and time with my family. -

-

- I care deeply about digital security and privacy. I design systems with minimal exposure, sensible defaults, and a strong focus on - data protection. Anonymity, secure communication, and reducing unnecessary data collection aren’t extras for me — they’re baseline - requirements for my personal infrastructure. -

-

- I work with modern web technologies and on business-critical systems. I don’t care much for unnecessary complexity — I focus on - clear, stable solutions that actually hold up in day-to-day use and deliver real value. -

+

{{ interpolate(content().home.aboutParagraphs[0]) }}

+

{{ content().home.aboutParagraphs[1] }}

+

{{ content().home.aboutParagraphs[2] }}

-
- @if (isLongBioMounted) {
-

- What really interests me is modern code — well-structured, maintainable, and built with performance in mind. No unnecessary - bloat, just clean, readable solutions that reliably work under real-world conditions… in theory 😁 -

-

- Outside of software, my main hobby is scuba diving. The silence underwater and the focus on the present moment keep pulling me - back into Austria’s dark, cold lakes. -

-

- Right now, I mainly work for SobIT GmbH in Vienna, alongside freelance projects for companies, organizations, and private - clients. No gimmicks — just software that does what it’s supposed to do, built properly. -

- +

{{ content().home.detailParagraphs[0] }}

+

{{ content().home.detailParagraphs[1] }}

+

{{ content().home.detailParagraphs[2] }}

+
}
@@ -138,9 +109,8 @@
-

current Jobs & Projects

+

{{ content().home.projectsEyebrow }}

-
@for (project of projects; track project.link + '-' + $index; let i = $index) { @if (project.icon) { @@ -165,19 +135,17 @@ [alt]="project.title + ' icon'" /> } -
{{ project.title }}
-
+
@for (language of project.skills; track language + '-' + $index) { {{ language }} }
-

{{ project.description }}

diff --git a/src/app/home/home.component.spec.ts b/src/app/home/home.component.spec.ts index 3e6cae7..efc0f11 100644 --- a/src/app/home/home.component.spec.ts +++ b/src/app/home/home.component.spec.ts @@ -8,7 +8,11 @@ import type { Subscription } from 'rxjs'; import { HomeComponent } from './home.component'; import { global } from '../../global'; -import type { BioTextEntry, PortraitHighlight } from '../../global'; +import { enLanguage } from '../../languages/en'; +import type { + LanguageBioTextEntry, + LanguagePortraitHighlight, +} from '../../languages/language.types'; describe('HomeComponent', (): void => { let fixture: ComponentFixture; @@ -34,8 +38,8 @@ describe('HomeComponent', (): void => { scrollToAboutSection(): void; scrollToProjectsSection(): void; scrollToContactLinks(): void; - currentBioEntry: BioTextEntry; - currentPortraitHighlight: PortraitHighlight | null; + currentBioEntry: LanguageBioTextEntry; + currentPortraitHighlight: LanguagePortraitHighlight | null; } let comp: ComponentAccess; @@ -93,12 +97,12 @@ describe('HomeComponent', (): void => { describe('currentBioEntry getter', (): void => { it('should return the first bioTextsList entry at index 0', (): void => { comp.currentIndex = 0; - expect(comp.currentBioEntry).toEqual(global.bioTextsList[0]); + expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[0]); }); it('should return the correct entry when index changes', (): void => { comp.currentIndex = 3; - expect(comp.currentBioEntry).toEqual(global.bioTextsList[3]); + expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[3]); }); it('should return the fallback entry when index is out of range', (): void => { @@ -119,12 +123,12 @@ describe('HomeComponent', (): void => { }); it('should correctly wrap from the last entry back to 0 using modulo', (): void => { - const last = global.bioTextsList.length - 1; + const last = enLanguage.bioTextsList.length - 1; comp.currentIndex = last; - expect(comp.currentBioEntry).toEqual(global.bioTextsList[last]); - comp.currentIndex = (last + 1) % global.bioTextsList.length; + expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[last]); + comp.currentIndex = (last + 1) % enLanguage.bioTextsList.length; expect(comp.currentIndex).toBe(0); - expect(comp.currentBioEntry).toEqual(global.bioTextsList[0]); + expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[0]); }); }); @@ -133,7 +137,7 @@ describe('HomeComponent', (): void => { describe('currentPortraitHighlight getter', (): void => { it('should return the highlight at the current index', (): void => { comp.currentPortraitHighlightIndex = 0; - expect(comp.currentPortraitHighlight).toEqual(global.portraitHighlights[0]); + expect(comp.currentPortraitHighlight).toEqual(enLanguage.portraitHighlights[0]); }); it('should return null when index is out of range', (): void => { @@ -159,7 +163,7 @@ describe('HomeComponent', (): void => { }); it('should not call setTimeout when only one portrait exists', (): void => { - const highlights = (comp as unknown as { portraitHighlights: PortraitHighlight[] }) + const highlights = (comp as unknown as { portraitHighlights: LanguagePortraitHighlight[] }) .portraitHighlights; const saved = [...highlights]; // snapshot before mutation highlights.splice(1, saved.length - 1); // reduce to a single entry @@ -193,7 +197,7 @@ describe('HomeComponent', (): void => { })); it('should wrap portrait index from the last position back to 0', fakeAsync((): void => { - comp.currentPortraitHighlightIndex = global.portraitHighlights.length - 1; + comp.currentPortraitHighlightIndex = enLanguage.portraitHighlights.length - 1; const zone = TestBed.inject(NgZone); zone.runOutsideAngular((): void => { comp.showNextPortraitHighlight(); @@ -400,15 +404,15 @@ describe('HomeComponent', (): void => { expect(el.querySelector('#projects')).not.toBeNull(); }); - it('should render one project entry per global.projects entry', (): void => { + it('should render one project entry per localized project entry', (): void => { const el = fixture.nativeElement as HTMLElement; const entries = el.querySelectorAll('.project-entry'); - expect(entries.length).toBe(global.projects.length); + expect(entries.length).toBe(enLanguage.projects.length); }); it('should render all project titles', (): void => { const el = fixture.nativeElement as HTMLElement; - global.projects.forEach((project): void => { + enLanguage.projects.forEach((project): void => { expect(el.textContent).toContain(project.title); }); }); diff --git a/src/app/home/home.component.ts b/src/app/home/home.component.ts index e49be76..54d5529 100644 --- a/src/app/home/home.component.ts +++ b/src/app/home/home.component.ts @@ -1,67 +1,64 @@ import type { AfterViewInit, OnDestroy, OnInit } from '@angular/core'; -import { Component, inject, NgZone } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, NgZone } from '@angular/core'; import { NgOptimizedImage } from '@angular/common'; import { faArrowRight, faArrowDown, faEnvelope, faPhone } from '@fortawesome/free-solid-svg-icons'; import { faDiscord, faGithub, faLinkedin } from '@fortawesome/free-brands-svg-icons'; import type { IconDefinition } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { DomSanitizer } from '@angular/platform-browser'; -import type { SafeUrl } from '@angular/platform-browser'; import type { Subscription } from 'rxjs'; import { interval } from 'rxjs'; -import type { BioTextEntry, Global, PortraitHighlight, PortfolioProject } from '../../global'; import { global } from '../../global'; +import type { + LanguageBioTextEntry, + LanguagePortraitHighlight, + LanguageProject, +} from '../../languages/language.types'; import { FooterComponent } from '../footer/footer.component'; +import { LanguageService } from '../services/language.service'; @Component({ selector: 'app-home', standalone: true, imports: [FooterComponent, FaIconComponent, NgOptimizedImage], templateUrl: './home.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, }) export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { - private readonly sanitizer = inject(DomSanitizer); private readonly zone = inject(NgZone); + private readonly languageService = inject(LanguageService); + protected readonly content = this.languageService.content; + private scrollAnimationFrameId: number | null = null; private projectEntryAnimationFrameId: number | null = null; private aboutSectionAnimationFrameId: number | null = null; private readonly scheduleNameGradientUpdate: () => void = (): void => { - if (this.scrollAnimationFrameId !== null || typeof window === 'undefined') { - return; - } - + if (this.scrollAnimationFrameId !== null || typeof window === 'undefined') return; this.scrollAnimationFrameId = window.requestAnimationFrame((): void => { this.scrollAnimationFrameId = null; this.updateNameGradientProgress(); }); }; private readonly scheduleProjectEntryRevealUpdate: () => void = (): void => { - if (this.projectEntryAnimationFrameId !== null || typeof window === 'undefined') { - return; - } - + if (this.projectEntryAnimationFrameId !== null || typeof window === 'undefined') return; this.projectEntryAnimationFrameId = window.requestAnimationFrame((): void => { this.projectEntryAnimationFrameId = null; this.updateProjectEntryRevealProgress(); }); }; private readonly scheduleAboutSectionScrollAnimationUpdate: () => void = (): void => { - if (this.aboutSectionAnimationFrameId !== null || typeof window === 'undefined') { - return; - } - + if (this.aboutSectionAnimationFrameId !== null || typeof window === 'undefined') return; this.aboutSectionAnimationFrameId = window.requestAnimationFrame((): void => { this.aboutSectionAnimationFrameId = null; this.updateAboutSectionScrollProgress(); }); }; - protected readonly global: Global = global; - protected readonly age: number = this.calculateAge(global.birthdate); - - protected isLongBioShown: boolean = false; - protected isLongBioMounted: boolean = false; + protected readonly global = global; + protected readonly age = this.calculateAge(global.birthdate); + protected readonly contactMailHref = `mailto:${global.contactMail}`; + protected isLongBioShown = false; + protected isLongBioMounted = false; protected readonly faArrowRight: IconDefinition = faArrowRight; protected readonly faArrowDown: IconDefinition = faArrowDown; protected readonly faEnvelope: IconDefinition = faEnvelope; @@ -70,28 +67,44 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { readonly faGithub: IconDefinition = faGithub; readonly faLinkedin: IconDefinition = faLinkedin; - protected readonly contactSafeMail: SafeUrl; - protected readonly portraitHighlights: PortraitHighlight[] = global.portraitHighlights; - protected currentPortraitHighlightIndex: number = 0; - protected isPortraitSwitching: boolean = false; - + protected currentPortraitHighlightIndex = 0; + protected isPortraitSwitching = false; protected currentIndex = 0; - protected readonly projects: PortfolioProject[] = global.projects; - protected readonly fallbackBioEntry: BioTextEntry = { label: 'my stack', value: '' }; + protected readonly fallbackBioEntry: LanguageBioTextEntry = { label: 'my stack', value: '' }; private sub!: Subscription; private bioHideTimeoutId: number | null = null; - constructor() { - this.contactSafeMail = this.sanitizer.bypassSecurityTrustUrl(`mailto:${global.contactMail}`); + protected get portraitHighlights(): LanguagePortraitHighlight[] { + return this.content().portraitHighlights; + } + protected get projects(): LanguageProject[] { + return this.content().projects; + } + protected get currentPortraitHighlight(): LanguagePortraitHighlight | null { + return this.portraitHighlights[this.currentPortraitHighlightIndex] ?? null; + } + protected get currentBioEntry(): LanguageBioTextEntry { + return ( + this.content().bioTextsList[this.currentIndex] ?? { + label: this.content().home.fallbackBioLabel, + value: '', + } + ); + } + + protected interpolate(text: string): string { + return text + .replaceAll('{{firstname}}', this.global.firstname) + .replaceAll('{{age}}', String(this.age)); } ngOnInit(): void { this.initNameGradientScrollAnimation(); - this.zone.runOutsideAngular((): void => { this.sub = interval(1000).subscribe((): void => { this.zone.run((): void => { - this.currentIndex = (this.currentIndex + 1) % this.global.bioTextsList.length; + const len = this.content().bioTextsList.length; + this.currentIndex = len > 0 ? (this.currentIndex + 1) % len : 0; }); }); }); @@ -101,7 +114,6 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { this.initProjectEntryRevealObserver(); this.initAboutSectionScrollAnimation(); } - ngOnDestroy(): void { this.sub.unsubscribe(); if (this.bioHideTimeoutId !== null) { @@ -117,9 +129,7 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { if (this.isLongBioShown) { this.isLongBioShown = false; if (typeof window !== 'undefined') { - if (this.bioHideTimeoutId !== null) { - window.clearTimeout(this.bioHideTimeoutId); - } + if (this.bioHideTimeoutId !== null) window.clearTimeout(this.bioHideTimeoutId); this.bioHideTimeoutId = window.setTimeout((): void => { this.isLongBioMounted = false; this.bioHideTimeoutId = null; @@ -129,12 +139,10 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { } return; } - if (this.bioHideTimeoutId !== null && typeof window !== 'undefined') { window.clearTimeout(this.bioHideTimeoutId); this.bioHideTimeoutId = null; } - this.isLongBioMounted = true; if (typeof window !== 'undefined') { window.requestAnimationFrame((): void => { @@ -146,88 +154,52 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { this.isLongBioShown = true; } } - - protected get currentPortraitHighlight(): PortraitHighlight | null { - return this.portraitHighlights[this.currentPortraitHighlightIndex] ?? null; - } - - protected get currentBioEntry(): BioTextEntry { - return this.global.bioTextsList[this.currentIndex] ?? this.fallbackBioEntry; - } - protected showNextPortraitHighlight(): void { - if (this.portraitHighlights.length <= 1 || this.isPortraitSwitching) { - return; - } - + if (this.portraitHighlights.length <= 1 || this.isPortraitSwitching) return; this.isPortraitSwitching = true; - setTimeout((): void => { this.currentPortraitHighlightIndex = (this.currentPortraitHighlightIndex + 1) % this.portraitHighlights.length; }, 125); - setTimeout((): void => { this.isPortraitSwitching = false; }, 250); } - protected scrollToAboutSection(): void { this.scrollToElementById('about', this.getResponsiveScrollOffset(0.035)); } - protected scrollToProjectsSection(): void { this.scrollToElementById('projects', this.getResponsiveScrollOffset(0.035)); } - protected scrollToContactLinks(): void { this.scrollToElementById('contact-links', this.getResponsiveScrollOffset(0.05)); } - private initNameGradientScrollAnimation(): void { - if (typeof window === 'undefined') { - return; - } - + if (typeof window === 'undefined') return; window.addEventListener('scroll', this.scheduleNameGradientUpdate, { passive: true }); window.addEventListener('resize', this.scheduleNameGradientUpdate, { passive: true }); this.scheduleNameGradientUpdate(); } - private initProjectEntryRevealObserver(): void { - if (typeof window === 'undefined') { - return; - } - + if (typeof window === 'undefined') return; window.addEventListener('scroll', this.scheduleProjectEntryRevealUpdate, { passive: true }); window.addEventListener('resize', this.scheduleProjectEntryRevealUpdate, { passive: true }); this.scheduleProjectEntryRevealUpdate(); } - private destroyProjectEntryRevealObserver(): void { - if (typeof window === 'undefined' || typeof document === 'undefined') { - return; - } - + if (typeof window === 'undefined' || typeof document === 'undefined') return; window.removeEventListener('scroll', this.scheduleProjectEntryRevealUpdate); window.removeEventListener('resize', this.scheduleProjectEntryRevealUpdate); - if (this.projectEntryAnimationFrameId !== null) { window.cancelAnimationFrame(this.projectEntryAnimationFrameId); this.projectEntryAnimationFrameId = null; } - - const projectEntries: NodeListOf = document.querySelectorAll('.project-entry'); - projectEntries.forEach((entry: HTMLElement): void => { + document.querySelectorAll('.project-entry').forEach((entry: HTMLElement): void => { entry.style.removeProperty('--project-entry-progress'); }); } - private initAboutSectionScrollAnimation(): void { - if (typeof window === 'undefined') { - return; - } - + if (typeof window === 'undefined') return; window.addEventListener('scroll', this.scheduleAboutSectionScrollAnimationUpdate, { passive: true, }); @@ -236,45 +208,28 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { }); this.scheduleAboutSectionScrollAnimationUpdate(); } - private destroyAboutSectionScrollAnimation(): void { - if (typeof window === 'undefined' || typeof document === 'undefined') { - return; - } - + if (typeof window === 'undefined' || typeof document === 'undefined') return; window.removeEventListener('scroll', this.scheduleAboutSectionScrollAnimationUpdate); window.removeEventListener('resize', this.scheduleAboutSectionScrollAnimationUpdate); - if (this.aboutSectionAnimationFrameId !== null) { window.cancelAnimationFrame(this.aboutSectionAnimationFrameId); this.aboutSectionAnimationFrameId = null; } - - const aboutSection: HTMLElement | null = document.getElementById('about'); - aboutSection?.style.removeProperty('--about-scroll-progress'); + document.getElementById('about')?.style.removeProperty('--about-scroll-progress'); } - private destroyNameGradientScrollAnimation(): void { - if (typeof window === 'undefined') { - return; - } - + if (typeof window === 'undefined') return; window.removeEventListener('scroll', this.scheduleNameGradientUpdate); window.removeEventListener('resize', this.scheduleNameGradientUpdate); - if (this.scrollAnimationFrameId !== null) { window.cancelAnimationFrame(this.scrollAnimationFrameId); this.scrollAnimationFrameId = null; } - document.documentElement.style.removeProperty('--name-pride-progress'); } - private updateNameGradientProgress(): void { - if (typeof window === 'undefined') { - return; - } - + if (typeof window === 'undefined') return; const root: HTMLElement = document.documentElement; const portfolioEyebrow: HTMLElement | null = document.getElementById('portfolio-eyebrow'); const portfolioTriggerY: number = @@ -292,20 +247,12 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { window.scrollY > 0 ? Math.max(acceleratedProgress, isMobileViewport ? 0.18 : 0.05) : acceleratedProgress; - root.style.setProperty('--name-pride-progress', progress.toFixed(4)); } - private updateAboutSectionScrollProgress(): void { - if (typeof window === 'undefined') { - return; - } - + if (typeof window === 'undefined') return; const aboutSection: HTMLElement | null = document.getElementById('about'); - if (aboutSection === null) { - return; - } - + if (aboutSection === null) return; const sectionRect: DOMRect = aboutSection.getBoundingClientRect(); const viewportHeight: number = window.innerHeight || 1; const startY: number = viewportHeight * 0.96; @@ -313,21 +260,15 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { const totalDistance: number = Math.max(startY - endY, 1); const rawProgress: number = (startY - sectionRect.top) / totalDistance; const clampedProgress: number = Math.min(Math.max(rawProgress, 0), 1); - aboutSection.style.setProperty('--about-scroll-progress', clampedProgress.toFixed(4)); } - private updateProjectEntryRevealProgress(): void { - if (typeof window === 'undefined' || typeof document === 'undefined') { - return; - } - + if (typeof window === 'undefined' || typeof document === 'undefined') return; const projectEntries: NodeListOf = document.querySelectorAll('.project-entry'); const viewportHeight: number = window.innerHeight || 1; const startY: number = viewportHeight * 0.98; const endY: number = viewportHeight * 0.56; const totalDistance: number = Math.max(startY - endY, 1); - projectEntries.forEach((entry: HTMLElement): void => { const entryRect: DOMRect = entry.getBoundingClientRect(); const rawProgress: number = (startY - entryRect.top) / totalDistance; @@ -335,7 +276,6 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { entry.style.setProperty('--project-entry-progress', clampedProgress.toFixed(4)); }); } - private calculateAge(birthDateString: string): number { const birthDate: Date = new Date(birthDateString); const now: Date = new Date(); @@ -343,40 +283,21 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { const hasBirthdayPassedThisYear: boolean = now.getMonth() > birthDate.getMonth() || (now.getMonth() === birthDate.getMonth() && now.getDate() >= birthDate.getDate()); - - if (!hasBirthdayPassedThisYear) { - age -= 1; - } - + if (!hasBirthdayPassedThisYear) age -= 1; return age; } - private scrollToElementById(elementId: string, offsetPx: number = 0): void { - if (typeof document === 'undefined' || typeof window === 'undefined') { - return; - } - + if (typeof document === 'undefined' || typeof window === 'undefined') return; const element: HTMLElement | null = document.getElementById(elementId); - if (element === null) { - return; - } - + if (element === null) return; const targetTop: number = Math.max( element.getBoundingClientRect().top + window.scrollY - offsetPx, 0, ); - - window.scrollTo({ - top: targetTop, - behavior: 'smooth', - }); + window.scrollTo({ top: targetTop, behavior: 'smooth' }); } - private getResponsiveScrollOffset(viewportRatio: number = 0.05): number { - if (typeof window === 'undefined') { - return 0; - } - + if (typeof window === 'undefined') return 0; const minOffsetPx: number = 20; const maxOffsetPx: number = 48; const responsiveOffsetPx: number = window.innerHeight * viewportRatio; diff --git a/src/app/imprint/imprint.component.html b/src/app/imprint/imprint.component.html index 976106c..a19b689 100644 --- a/src/app/imprint/imprint.component.html +++ b/src/app/imprint/imprint.component.html @@ -2,43 +2,34 @@