improve: best practises
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
@if (project.icon) {
|
||||
<img
|
||||
class="project-icon"
|
||||
[class.project-icon-circle]="project.CircleIcon !== false"
|
||||
[class.project-icon-circle]="project.circleIcon !== false"
|
||||
[ngSrc]="project.icon"
|
||||
[attr.srcset]="project.iconSrcset ?? null"
|
||||
[attr.sizes]="project.iconSizes ?? null"
|
||||
|
||||
@@ -4,7 +4,6 @@ import { provideRouter } from '@angular/router';
|
||||
import { IMAGE_LOADER } from '@angular/common';
|
||||
import type { ImageLoaderConfig } from '@angular/common';
|
||||
import { NgZone } from '@angular/core';
|
||||
import type { Subscription } from 'rxjs';
|
||||
|
||||
import { HomeComponent } from './home.component';
|
||||
import { global } from '../../global';
|
||||
@@ -25,7 +24,6 @@ describe('HomeComponent', (): void => {
|
||||
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();
|
||||
}));
|
||||
|
||||
|
||||
@@ -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<LanguagePack>(() =>
|
||||
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,7 +139,9 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
ngOnInit(): void {
|
||||
this.initNameGradientScrollAnimation();
|
||||
this.zone.runOutsideAngular((): void => {
|
||||
this.sub = interval(1000).subscribe((): void => {
|
||||
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;
|
||||
|
||||
@@ -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<LanguagePack>(() =>
|
||||
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}`;
|
||||
|
||||
@@ -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<LanguageShellPack>(() =>
|
||||
this.getShellPack(this.currentLanguageCode()),
|
||||
);
|
||||
readonly content = computed<LanguagePack>(() => this.getPack(this.currentLanguageCode()));
|
||||
readonly shellContent = computed<LanguageShellPack>(() => 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
+6
-6
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface LanguageProject {
|
||||
iconSizes?: string;
|
||||
iconWidth?: number;
|
||||
iconHeight?: number;
|
||||
CircleIcon?: boolean;
|
||||
circleIcon?: boolean;
|
||||
}
|
||||
|
||||
export interface LanguageBioTextEntry {
|
||||
|
||||
Reference in New Issue
Block a user