fix: performance

This commit is contained in:
2026-04-28 21:44:20 +02:00
parent 07000e539c
commit 1e2b1efc66
8 changed files with 388 additions and 24 deletions
+3 -1
View File
@@ -37,7 +37,7 @@ export class AppComponent implements OnInit, OnDestroy {
{ code: 'de', accent: 'DE' },
];
protected readonly dialogContent = computed(() =>
this.languageService.getPack(this.selectedLanguage()),
this.languageService.getShellPack(this.selectedLanguage()),
);
protected readonly languageSwitcherLabel = computed(() =>
this.getLanguageLabel(this.currentLanguageCode(), this.content()),
@@ -66,6 +66,7 @@ export class AppComponent implements OnInit, OnDestroy {
protected confirmLanguage(): void {
this.languageService.confirmLanguage(this.selectedLanguage());
this.isLanguageSelectorOpen.set(false);
this.kofiWidgetService.prioritizeLoad();
}
protected reopenLanguageSelector(): void {
@@ -80,6 +81,7 @@ export class AppComponent implements OnInit, OnDestroy {
this.selectedLanguage.set(this.currentLanguageCode());
this.isLanguageSelectorOpen.set(false);
this.kofiWidgetService.prioritizeLoad();
}
protected closeLanguageSelectorOnBackdrop(event: MouseEvent): void {
+23 -5
View File
@@ -1,5 +1,5 @@
import type { AfterViewInit, OnDestroy, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, NgZone } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, 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';
@@ -8,9 +8,12 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import type { Subscription } from 'rxjs';
import { interval } from 'rxjs';
import { deLanguage } from '../../languages/de';
import { enLanguage } from '../../languages/en';
import { global } from '../../global';
import type {
LanguageBioTextEntry,
LanguagePack,
LanguagePortraitHighlight,
LanguageProject,
} from '../../languages/language.types';
@@ -27,7 +30,9 @@ import { LanguageService } from '../services/language.service';
export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly zone = inject(NgZone);
private readonly languageService = inject(LanguageService);
protected readonly content = this.languageService.content;
protected readonly content = computed<LanguagePack>(() =>
this.languageService.languageCode() === 'de' ? deLanguage : enLanguage,
);
private scrollAnimationFrameId: number | null = null;
private projectEntryAnimationFrameId: number | null = null;
@@ -174,14 +179,27 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
protected scrollToContactLinks(): void {
this.scrollToElementById('contact-links', this.getResponsiveScrollOffset(0.05));
}
private shouldEnableScrollEffects(): boolean {
if (typeof window === 'undefined') return false;
const isSmallViewport: boolean = window.innerWidth <= 700;
const prefersReducedMotion: boolean = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches;
const isCoarsePointer: boolean = window.matchMedia('(pointer: coarse)').matches;
return !isSmallViewport && !prefersReducedMotion && !isCoarsePointer;
}
private initNameGradientScrollAnimation(): void {
if (typeof window === 'undefined') return;
if (!this.shouldEnableScrollEffects()) 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 (!this.shouldEnableScrollEffects()) return;
window.addEventListener('scroll', this.scheduleProjectEntryRevealUpdate, { passive: true });
window.addEventListener('resize', this.scheduleProjectEntryRevealUpdate, { passive: true });
this.scheduleProjectEntryRevealUpdate();
@@ -199,7 +217,7 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
});
}
private initAboutSectionScrollAnimation(): void {
if (typeof window === 'undefined') return;
if (!this.shouldEnableScrollEffects()) return;
window.addEventListener('scroll', this.scheduleAboutSectionScrollAnimationUpdate, {
passive: true,
});
+7 -2
View File
@@ -1,10 +1,13 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, 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';
@@ -18,7 +21,9 @@ import { LanguageService } from '../services/language.service';
export class ImprintComponent {
private readonly languageService = inject(LanguageService);
protected readonly content = this.languageService.content;
protected readonly content = computed<LanguagePack>(() =>
this.languageService.languageCode() === 'de' ? deLanguage : enLanguage,
);
protected readonly global = global;
protected readonly faArrowLeft: IconDefinition = faArrowLeft;
protected readonly contactMailHref = `mailto:${global.contactMail}`;
+142 -10
View File
@@ -8,11 +8,14 @@ export class KofiWidgetService {
private readonly widgetScriptUrl = 'https://storage.ko-fi.com/cdn/scripts/overlay-widget.js';
private readonly widgetIframeTitle = 'Ko-fi support chat';
private readonly fallbackDelayMs = 10000;
private readonly fallbackDelayMs = 2500;
private readonly mobileBreakpointPx = 700;
private readonly mobileViewportOffsetPx = 24;
private loadTimeoutId: number | null = null;
private loadIdleCallbackId: number | null = null;
private loadListenerAbortController: AbortController | null = null;
private resizeListenerAbortController: AbortController | null = null;
private iframeObserver: MutationObserver | null = null;
private isWidgetScheduled = false;
private isWidgetLoaded = false;
@@ -85,10 +88,50 @@ export class KofiWidgetService {
}
}
teardown(): void {
prioritizeLoad(): void {
if (!isPlatformBrowser(this.platformId) || this.isWidgetLoaded || this.isWidgetDrawn) {
return;
}
const windowRef = this.document.defaultView;
if (windowRef === null) {
return;
}
this.clearStartListeners();
this.clearPendingLoadTimers();
const loadWidget = (): void => {
this.loadIdleCallbackId = null;
this.loadTimeoutId = null;
void this.loadWidget();
};
if (typeof windowRef.requestIdleCallback === 'function') {
this.loadIdleCallbackId = windowRef.requestIdleCallback(loadWidget, { timeout: 1200 });
return;
}
this.loadTimeoutId = windowRef.setTimeout(loadWidget, 200);
}
teardown(): void {
this.clearStartListeners();
this.resizeListenerAbortController?.abort();
this.resizeListenerAbortController = null;
this.clearPendingLoadTimers();
this.iframeObserver?.disconnect();
this.iframeObserver = null;
}
private clearStartListeners(): void {
this.loadListenerAbortController?.abort();
this.loadListenerAbortController = null;
}
private clearPendingLoadTimers(): void {
const windowRef = this.document.defaultView;
if (windowRef !== null && this.loadTimeoutId !== null) {
windowRef.clearTimeout(this.loadTimeoutId);
@@ -103,14 +146,6 @@ export class KofiWidgetService {
windowRef.cancelIdleCallback(this.loadIdleCallbackId);
this.loadIdleCallbackId = null;
}
this.iframeObserver?.disconnect();
this.iframeObserver = null;
}
private clearStartListeners(): void {
this.loadListenerAbortController?.abort();
this.loadListenerAbortController = null;
}
private async loadWidget(): Promise<void> {
@@ -190,7 +225,9 @@ export class KofiWidgetService {
this.isWidgetDrawn = true;
this.ensureAccessibleIframeTitles();
this.applyResponsiveWidgetLayout();
this.observeInjectedIframes();
this.observeViewportChanges();
}
private ensureAccessibleIframeTitles(): void {
@@ -201,6 +238,75 @@ export class KofiWidgetService {
});
}
private applyResponsiveWidgetLayout(): void {
const windowRef = this.document.defaultView;
if (windowRef === null) {
return;
}
const isMobileViewport: boolean = windowRef.innerWidth <= this.mobileBreakpointPx;
const mobileWidthPx: number = Math.max(windowRef.innerWidth - this.mobileViewportOffsetPx, 280);
const mobileHeightPx: number = Math.max(
Math.min(Math.round(windowRef.innerHeight * 0.68), 520),
360,
);
this.document
.querySelectorAll<HTMLElement>('[id*="kofi-widget-overlay"]')
.forEach((overlay: HTMLElement): void => {
if (isMobileViewport) {
overlay.style.left = '12px';
overlay.style.right = '12px';
overlay.style.bottom = '12px';
overlay.style.width = 'auto';
overlay.style.maxWidth = `${mobileWidthPx}px`;
} else {
overlay.style.removeProperty('left');
overlay.style.removeProperty('right');
overlay.style.removeProperty('width');
overlay.style.removeProperty('max-width');
}
});
this.document
.querySelectorAll<HTMLElement>(
'.floatingchat-container-wrap, .floatingchat-container-wrap-mobi',
)
.forEach((container: HTMLElement): void => {
if (isMobileViewport) {
container.style.width = `${mobileWidthPx}px`;
container.style.maxWidth = 'calc(100vw - 24px)';
container.style.left = '0';
container.style.right = '0';
} else {
container.style.removeProperty('width');
container.style.removeProperty('max-width');
container.style.removeProperty('left');
container.style.removeProperty('right');
}
});
this.document
.querySelectorAll<HTMLIFrameElement>('iframe[id^="kofi-wo-container"]')
.forEach((iframe: HTMLIFrameElement): void => {
iframe.title = this.widgetIframeTitle;
if (isMobileViewport) {
iframe.style.width = `${mobileWidthPx}px`;
iframe.style.maxWidth = 'calc(100vw - 24px)';
iframe.style.height = `${mobileHeightPx}px`;
iframe.style.maxHeight = '68vh';
iframe.style.borderRadius = '18px';
} else {
iframe.style.removeProperty('width');
iframe.style.removeProperty('max-width');
iframe.style.removeProperty('height');
iframe.style.removeProperty('max-height');
iframe.style.removeProperty('border-radius');
}
});
}
private observeInjectedIframes(): void {
if (this.iframeObserver !== null) {
return;
@@ -208,6 +314,7 @@ export class KofiWidgetService {
this.iframeObserver = new MutationObserver((): void => {
this.ensureAccessibleIframeTitles();
this.applyResponsiveWidgetLayout();
});
this.iframeObserver.observe(this.document.body, {
@@ -215,4 +322,29 @@ export class KofiWidgetService {
subtree: true,
});
}
private observeViewportChanges(): void {
if (this.resizeListenerAbortController !== null) {
return;
}
const windowRef = this.document.defaultView;
if (windowRef === null) {
return;
}
this.resizeListenerAbortController = new AbortController();
const listenerOptions: AddEventListenerOptions = {
passive: true,
signal: this.resizeListenerAbortController.signal,
};
windowRef.addEventListener(
'resize',
(): void => {
this.applyResponsiveWidgetLayout();
},
listenerOptions,
);
}
}
+36 -6
View File
@@ -1,8 +1,36 @@
import { Injectable, computed, signal } from '@angular/core';
import { deLanguage } from '../../languages/de';
import { enLanguage } from '../../languages/en';
import type { LanguageCode, LanguagePack } from '../../languages/language.types';
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',
},
};
@Injectable({ providedIn: 'root' })
export class LanguageService {
@@ -12,7 +40,9 @@ export class LanguageService {
readonly languageCode = this.currentLanguageCode.asReadonly();
readonly isLanguageConfirmed = this.languageConfirmed.asReadonly();
readonly content = computed<LanguagePack>(() => this.getPack(this.currentLanguageCode()));
readonly content = computed<LanguageShellPack>(() =>
this.getShellPack(this.currentLanguageCode()),
);
initializeFromStorage(): LanguageCode {
if (typeof window === 'undefined') {
@@ -37,7 +67,7 @@ export class LanguageService {
}
}
getPack(code: LanguageCode): LanguagePack {
return code === 'de' ? deLanguage : enLanguage;
getShellPack(code: LanguageCode): LanguageShellPack {
return code === 'de' ? deShellLanguage : enShellLanguage;
}
}