diff --git a/src/app/app.component.ts b/src/app/app.component.ts
index 10d1994..1769d74 100644
--- a/src/app/app.component.ts
+++ b/src/app/app.component.ts
@@ -25,7 +25,7 @@ export class AppComponent implements OnInit, OnDestroy {
private readonly document = inject(DOCUMENT);
private readonly languageService = inject(LanguageService);
- protected readonly content = this.languageService.content;
+ protected readonly content = this.languageService.shellContent;
protected readonly currentLanguageCode = this.languageService.languageCode;
protected readonly isLanguageConfirmed = this.languageService.isLanguageConfirmed;
protected readonly isLanguageSelectorOpen = signal(false);
diff --git a/src/app/footer/footer.component.ts b/src/app/footer/footer.component.ts
index de34e47..b105ff7 100644
--- a/src/app/footer/footer.component.ts
+++ b/src/app/footer/footer.component.ts
@@ -13,7 +13,7 @@ import { LanguageService } from '../services/language.service';
export class FooterComponent {
private readonly languageService = inject(LanguageService);
- protected readonly content = this.languageService.content;
+ protected readonly content = this.languageService.shellContent;
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 553944e..55f146b 100644
--- a/src/app/home/home.component.html
+++ b/src/app/home/home.component.html
@@ -124,7 +124,7 @@
@if (project.icon) {
{
isLongBioMounted: boolean;
isPortraitSwitching: boolean;
currentPortraitHighlightIndex: number;
- sub: Subscription;
bioHideTimeoutId: number | null;
scrollAnimationFrameId: number | null;
projectEntryAnimationFrameId: number | null;
@@ -113,14 +111,13 @@ describe('HomeComponent', (): void => {
});
});
- describe('interval-driven bio cycling (subscription)', (): void => {
+ describe('interval-driven bio cycling', (): void => {
it('should start with currentIndex 0', (): void => {
expect(comp.currentIndex).toBe(0);
});
- it('should be backed by an active RxJS Subscription', (): void => {
- expect(comp.sub).toBeDefined();
- expect(comp.sub.closed).toBeFalse();
+ it('should expose a valid bio entry for the current index', (): void => {
+ expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[0]);
});
it('should correctly wrap from the last entry back to 0 using modulo', (): void => {
@@ -352,10 +349,13 @@ describe('HomeComponent', (): void => {
// ── Lifecycle cleanup ──────────────────────────────────────────────────────
describe('ngOnDestroy', (): void => {
- it('should unsubscribe the interval subscription on destroy', fakeAsync((): void => {
- expect(comp.sub.closed).toBeFalse();
+ it('should clear pending portrait switch timers on destroy', fakeAsync((): void => {
+ comp.showNextPortraitHighlight();
+ const clearSpy = spyOn(window, 'clearTimeout').and.callThrough();
+
fixture.destroy();
- expect(comp.sub.closed).toBeTrue();
+
+ expect(clearSpy).toHaveBeenCalled();
discardPeriodicTasks();
}));
diff --git a/src/app/home/home.component.ts b/src/app/home/home.component.ts
index 12c912e..0607a33 100644
--- a/src/app/home/home.component.ts
+++ b/src/app/home/home.component.ts
@@ -2,7 +2,7 @@ import type { AfterViewInit, OnDestroy, OnInit } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
- computed,
+ DestroyRef,
inject,
NgZone,
signal,
@@ -12,15 +12,12 @@ import { faArrowRight, faArrowDown, faEnvelope, faPhone } from '@fortawesome/fre
import { faDiscord, faGithub, faLinkedin } from '@fortawesome/free-brands-svg-icons';
import type { IconDefinition } from '@fortawesome/angular-fontawesome';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
-import type { Subscription } from 'rxjs';
import { interval } from 'rxjs';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
-import { deLanguage } from '../../languages/de';
-import { enLanguage } from '../../languages/en';
import { global } from '../../global';
import type {
LanguageBioTextEntry,
- LanguagePack,
LanguagePortraitHighlight,
LanguageProject,
} from '../../languages/language.types';
@@ -35,11 +32,10 @@ import { LanguageService } from '../services/language.service';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
+ private readonly destroyRef = inject(DestroyRef);
private readonly zone = inject(NgZone);
private readonly languageService = inject(LanguageService);
- protected readonly content = computed(() =>
- this.languageService.languageCode() === 'de' ? deLanguage : enLanguage,
- );
+ protected readonly content = this.languageService.content;
private scrollAnimationFrameId: number | null = null;
private projectEntryAnimationFrameId: number | null = null;
@@ -73,18 +69,18 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
protected readonly faArrowDown: IconDefinition = faArrowDown;
protected readonly faEnvelope: IconDefinition = faEnvelope;
protected readonly faPhone: IconDefinition = faPhone;
- readonly faDiscord: IconDefinition = faDiscord;
- readonly faGithub: IconDefinition = faGithub;
- readonly faLinkedin: IconDefinition = faLinkedin;
+ protected readonly faDiscord: IconDefinition = faDiscord;
+ protected readonly faGithub: IconDefinition = faGithub;
+ protected readonly faLinkedin: IconDefinition = faLinkedin;
- protected readonly fallbackBioEntry: LanguageBioTextEntry = { label: 'my stack', value: '' };
private readonly isLongBioShownState = signal(false);
private readonly isLongBioMountedState = signal(false);
private readonly currentPortraitHighlightIndexState = signal(0);
private readonly isPortraitSwitchingState = signal(false);
private readonly currentIndexState = signal(0);
- private sub!: Subscription;
private bioHideTimeoutId: number | null = null;
+ private portraitSwitchTimeoutId: number | null = null;
+ private portraitSwitchDoneTimeoutId: number | null = null;
protected get isLongBioShown(): boolean {
return this.isLongBioShownState();
@@ -143,12 +139,14 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
ngOnInit(): void {
this.initNameGradientScrollAnimation();
this.zone.runOutsideAngular((): void => {
- this.sub = interval(1000).subscribe((): void => {
- this.zone.run((): void => {
- const len = this.content().bioTextsList.length;
- this.currentIndex = len > 0 ? (this.currentIndex + 1) % len : 0;
+ interval(1000)
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe((): void => {
+ this.zone.run((): void => {
+ const len = this.content().bioTextsList.length;
+ this.currentIndex = len > 0 ? (this.currentIndex + 1) % len : 0;
+ });
});
- });
});
}
@@ -157,11 +155,11 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
this.initAboutSectionScrollAnimation();
}
ngOnDestroy(): void {
- this.sub.unsubscribe();
if (this.bioHideTimeoutId !== null) {
window.clearTimeout(this.bioHideTimeoutId);
this.bioHideTimeoutId = null;
}
+ this.clearPortraitSwitchTimeouts();
this.destroyNameGradientScrollAnimation();
this.destroyProjectEntryRevealObserver();
this.destroyAboutSectionScrollAnimation();
@@ -199,12 +197,14 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
protected showNextPortraitHighlight(): void {
if (this.portraitHighlights.length <= 1 || this.isPortraitSwitching) return;
this.isPortraitSwitching = true;
- setTimeout((): void => {
+ this.portraitSwitchTimeoutId = window.setTimeout((): void => {
this.currentPortraitHighlightIndex =
(this.currentPortraitHighlightIndex + 1) % this.portraitHighlights.length;
+ this.portraitSwitchTimeoutId = null;
}, 125);
- setTimeout((): void => {
+ this.portraitSwitchDoneTimeoutId = window.setTimeout((): void => {
this.isPortraitSwitching = false;
+ this.portraitSwitchDoneTimeoutId = null;
}, 250);
}
protected scrollToAboutSection(): void {
@@ -281,6 +281,17 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
}
document.documentElement.style.removeProperty('--name-pride-progress');
}
+ private clearPortraitSwitchTimeouts(): void {
+ if (typeof window === 'undefined') return;
+ if (this.portraitSwitchTimeoutId !== null) {
+ window.clearTimeout(this.portraitSwitchTimeoutId);
+ this.portraitSwitchTimeoutId = null;
+ }
+ if (this.portraitSwitchDoneTimeoutId !== null) {
+ window.clearTimeout(this.portraitSwitchDoneTimeoutId);
+ this.portraitSwitchDoneTimeoutId = null;
+ }
+ }
private updateNameGradientProgress(): void {
if (typeof window === 'undefined') return;
const root: HTMLElement = document.documentElement;
diff --git a/src/app/imprint/imprint.component.ts b/src/app/imprint/imprint.component.ts
index b9a9795..70b6f8b 100644
--- a/src/app/imprint/imprint.component.ts
+++ b/src/app/imprint/imprint.component.ts
@@ -1,13 +1,10 @@
-import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import type { IconDefinition } from '@fortawesome/angular-fontawesome';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
-import { deLanguage } from '../../languages/de';
-import { enLanguage } from '../../languages/en';
import { global } from '../../global';
-import type { LanguagePack } from '../../languages/language.types';
import { FooterComponent } from '../footer/footer.component';
import { LanguageService } from '../services/language.service';
@@ -21,9 +18,7 @@ import { LanguageService } from '../services/language.service';
export class ImprintComponent {
private readonly languageService = inject(LanguageService);
- protected readonly content = computed(() =>
- this.languageService.languageCode() === 'de' ? deLanguage : enLanguage,
- );
+ protected readonly content = this.languageService.content;
protected readonly global = global;
protected readonly faArrowLeft: IconDefinition = faArrowLeft;
protected readonly contactMailHref = `mailto:${global.contactMail}`;
diff --git a/src/app/services/language.service.ts b/src/app/services/language.service.ts
index d39ba0a..e134baf 100644
--- a/src/app/services/language.service.ts
+++ b/src/app/services/language.service.ts
@@ -1,36 +1,8 @@
import { Injectable, computed, signal } from '@angular/core';
-import type { LanguageCode, LanguageShellPack } from '../../languages/language.types';
-
-const enShellLanguage: LanguageShellPack = {
- app: {
- selectorTitle: 'Select language',
- changeLanguage: 'Language',
- changeLanguageAriaLabel: 'Change language',
- closeSelector: 'Close language selection',
- languageEnglish: 'English',
- languageGerman: 'German',
- },
- footer: {
- noscriptMessage: 'Please enable JavaScript for the full experience.',
- imprintLink: 'Imprint',
- },
-};
-
-const deShellLanguage: LanguageShellPack = {
- app: {
- selectorTitle: 'Sprache wählen',
- changeLanguage: 'Sprache',
- changeLanguageAriaLabel: 'Sprache wechseln',
- closeSelector: 'Sprachauswahl schliessen',
- languageEnglish: 'Englisch',
- languageGerman: 'Deutsch',
- },
- footer: {
- noscriptMessage: 'Bitte aktiviere JavaScript für die volle Darstellung.',
- imprintLink: 'Impressum',
- },
-};
+import { deLanguage } from '../../languages/de';
+import { enLanguage } from '../../languages/en';
+import type { LanguageCode, LanguagePack, LanguageShellPack } from '../../languages/language.types';
@Injectable({ providedIn: 'root' })
export class LanguageService {
@@ -40,9 +12,8 @@ export class LanguageService {
readonly languageCode = this.currentLanguageCode.asReadonly();
readonly isLanguageConfirmed = this.languageConfirmed.asReadonly();
- readonly content = computed(() =>
- this.getShellPack(this.currentLanguageCode()),
- );
+ readonly content = computed(() => this.getPack(this.currentLanguageCode()));
+ readonly shellContent = computed(() => this.toShellPack(this.content()));
initializeFromStorage(): LanguageCode {
if (typeof window === 'undefined') {
@@ -67,6 +38,17 @@ export class LanguageService {
}
getShellPack(code: LanguageCode): LanguageShellPack {
- return code === 'de' ? deShellLanguage : enShellLanguage;
+ return this.toShellPack(this.getPack(code));
+ }
+
+ getPack(code: LanguageCode): LanguagePack {
+ return code === 'de' ? deLanguage : enLanguage;
+ }
+
+ private toShellPack(pack: LanguagePack): LanguageShellPack {
+ return {
+ app: pack.app,
+ footer: pack.footer,
+ };
}
}
diff --git a/src/languages/de.ts b/src/languages/de.ts
index f83ef42..8cdddfe 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -188,7 +188,7 @@ export const deLanguage: LanguagePack = {
iconSrcset:
'assets/optimized/logos/sobit-64.webp 64w, assets/optimized/logos/sobit-128.webp 128w, assets/optimized/logos/sobit.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
- CircleIcon: false,
+ circleIcon: false,
},
{
title: 'SynHost',
@@ -202,7 +202,7 @@ export const deLanguage: LanguagePack = {
iconSizes: '(max-width: 700px) 64px, 108px',
iconWidth: 108,
iconHeight: 64,
- CircleIcon: false,
+ circleIcon: false,
},
{
title: 'SynRadio',
@@ -214,7 +214,7 @@ export const deLanguage: LanguagePack = {
iconSrcset:
'assets/optimized/logos/synradio-64.webp 64w, assets/optimized/logos/synradio-128.webp 128w, assets/optimized/logos/synradio.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
- CircleIcon: true,
+ circleIcon: true,
},
{
title: 'Discord Audio Stream Library',
@@ -226,7 +226,7 @@ export const deLanguage: LanguagePack = {
iconSrcset:
'assets/optimized/logos/discord-64.webp 64w, assets/optimized/logos/discord-128.webp 128w, assets/optimized/logos/discord.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
- CircleIcon: true,
+ circleIcon: true,
},
{
title: 'Portfolio',
@@ -240,7 +240,7 @@ export const deLanguage: LanguagePack = {
iconSizes: '(max-width: 700px) 64px, 108px',
iconWidth: 320,
iconHeight: 385,
- CircleIcon: true,
+ circleIcon: true,
},
{
title: 'Tauchertreff-Mostviertel',
@@ -252,7 +252,7 @@ export const deLanguage: LanguagePack = {
iconSrcset:
'assets/optimized/logos/tauchertreff-64.webp 64w, assets/optimized/logos/tauchertreff-128.webp 128w, assets/optimized/logos/tauchertreff.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
- CircleIcon: true,
+ circleIcon: true,
},
],
};
diff --git a/src/languages/en.ts b/src/languages/en.ts
index be584fd..7f78426 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -188,7 +188,7 @@ export const enLanguage: LanguagePack = {
iconSrcset:
'assets/optimized/logos/sobit-64.webp 64w, assets/optimized/logos/sobit-128.webp 128w, assets/optimized/logos/sobit.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
- CircleIcon: false,
+ circleIcon: false,
},
{
title: 'SynHost',
@@ -202,7 +202,7 @@ export const enLanguage: LanguagePack = {
iconSizes: '(max-width: 700px) 64px, 108px',
iconWidth: 108,
iconHeight: 64,
- CircleIcon: false,
+ circleIcon: false,
},
{
title: 'SynRadio',
@@ -214,7 +214,7 @@ export const enLanguage: LanguagePack = {
iconSrcset:
'assets/optimized/logos/synradio-64.webp 64w, assets/optimized/logos/synradio-128.webp 128w, assets/optimized/logos/synradio.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
- CircleIcon: true,
+ circleIcon: true,
},
{
title: 'Discord Audio Stream Library',
@@ -226,7 +226,7 @@ export const enLanguage: LanguagePack = {
iconSrcset:
'assets/optimized/logos/discord-64.webp 64w, assets/optimized/logos/discord-128.webp 128w, assets/optimized/logos/discord.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
- CircleIcon: true,
+ circleIcon: true,
},
{
title: 'Portfolio',
@@ -240,7 +240,7 @@ export const enLanguage: LanguagePack = {
iconSizes: '(max-width: 700px) 64px, 108px',
iconWidth: 320,
iconHeight: 385,
- CircleIcon: true,
+ circleIcon: true,
},
{
title: 'Tauchertreff-Mostviertel',
@@ -252,7 +252,7 @@ export const enLanguage: LanguagePack = {
iconSrcset:
'assets/optimized/logos/tauchertreff-64.webp 64w, assets/optimized/logos/tauchertreff-128.webp 128w, assets/optimized/logos/tauchertreff.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
- CircleIcon: true,
+ circleIcon: true,
},
],
};
diff --git a/src/languages/language.types.ts b/src/languages/language.types.ts
index 65a4c39..3b0066d 100644
--- a/src/languages/language.types.ts
+++ b/src/languages/language.types.ts
@@ -10,7 +10,7 @@ export interface LanguageProject {
iconSizes?: string;
iconWidth?: number;
iconHeight?: number;
- CircleIcon?: boolean;
+ circleIcon?: boolean;
}
export interface LanguageBioTextEntry {