diff --git a/angular.json b/angular.json index 4768edf..4bedad4 100644 --- a/angular.json +++ b/angular.json @@ -34,7 +34,7 @@ "input": "public" } ], - "styles": ["node_modules/@angular/material/prebuilt-themes/rose-red.css", "src/styles.scss"], + "styles": ["src/styles.scss"], "scripts": [], "server": "src/main.server.ts", "prerender": true, @@ -119,7 +119,7 @@ "output": "public" } ], - "styles": ["node_modules/@angular/material/prebuilt-themes/rose-red.css", "src/styles.scss"], + "styles": ["src/styles.scss"], "scripts": [] } } diff --git a/src/app/app.component.ts b/src/app/app.component.ts index cb5fdf0..99fb9cc 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -12,6 +12,7 @@ import { RouterOutlet } from '@angular/router'; import type { LanguageCode } from '../languages/language.types'; import type { LanguageOption } from './app.types'; +import { KofiWidgetService } from './services/kofi-widget.service'; import { LanguageService } from './services/language.service'; @Component({ @@ -23,6 +24,7 @@ import { LanguageService } from './services/language.service'; }) export class AppComponent implements OnInit, OnDestroy { private readonly document = inject(DOCUMENT); + private readonly kofiWidgetService = inject(KofiWidgetService); private readonly languageService = inject(LanguageService); protected readonly content = this.languageService.content; @@ -53,6 +55,7 @@ export class AppComponent implements OnInit, OnDestroy { const initialLanguage = this.languageService.initializeFromStorage(); this.selectedLanguage.set(initialLanguage); this.isLanguageSelectorOpen.set(!this.isLanguageConfirmed()); + this.kofiWidgetService.scheduleLoad(); } protected chooseLanguage(language: LanguageCode): void { @@ -90,6 +93,7 @@ export class AppComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { + this.kofiWidgetService.teardown(); this.document.body.style.overflow = ''; this.document.documentElement.style.overflow = ''; } diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 8d83b3d..7dd00ee 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,10 +1,16 @@ import type { Routes } from '@angular/router'; -import { HomeComponent } from './home/home.component'; -import { ImprintComponent } from './imprint/imprint.component'; - export const routes: Routes = [ - { path: '', component: HomeComponent, pathMatch: 'full' }, - { path: 'imprint', component: ImprintComponent, pathMatch: 'full' }, + { + path: '', + loadComponent: () => import('./home/home.component').then((module) => module.HomeComponent), + pathMatch: 'full', + }, + { + path: 'imprint', + loadComponent: () => + import('./imprint/imprint.component').then((module) => module.ImprintComponent), + pathMatch: 'full', + }, { path: '**', redirectTo: '' }, ]; diff --git a/src/app/home/home.component.ts b/src/app/home/home.component.ts index 54d5529..bd46931 100644 --- a/src/app/home/home.component.ts +++ b/src/app/home/home.component.ts @@ -269,9 +269,15 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { 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; + const entriesWithTop: Array<{ entry: HTMLElement; top: number }> = Array.from( + projectEntries, + (entry: HTMLElement) => ({ + entry, + top: entry.getBoundingClientRect().top, + }), + ); + entriesWithTop.forEach(({ entry, top }): void => { + const rawProgress: number = (startY - top) / totalDistance; const clampedProgress: number = Math.min(Math.max(rawProgress, 0), 1); entry.style.setProperty('--project-entry-progress', clampedProgress.toFixed(4)); }); diff --git a/src/app/services/kofi-widget.service.ts b/src/app/services/kofi-widget.service.ts new file mode 100644 index 0000000..3ff2292 --- /dev/null +++ b/src/app/services/kofi-widget.service.ts @@ -0,0 +1,218 @@ +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { inject, Injectable, PLATFORM_ID } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class KofiWidgetService { + private readonly document = inject(DOCUMENT); + private readonly platformId = inject(PLATFORM_ID); + + 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 loadTimeoutId: number | null = null; + private loadIdleCallbackId: number | null = null; + private loadListenerAbortController: AbortController | null = null; + private iframeObserver: MutationObserver | null = null; + private isWidgetScheduled = false; + private isWidgetLoaded = false; + private isWidgetDrawn = false; + + scheduleLoad(): void { + if (!isPlatformBrowser(this.platformId) || this.isWidgetScheduled) { + return; + } + + this.isWidgetScheduled = true; + + const windowRef = this.document.defaultView; + if (windowRef === null) { + return; + } + + const queueWidgetLoad = (): void => { + if (this.isWidgetLoaded || this.isWidgetDrawn) { + return; + } + + const loadWidget = (): void => { + this.loadIdleCallbackId = null; + void this.loadWidget(); + }; + + if (typeof windowRef.requestIdleCallback === 'function') { + this.loadIdleCallbackId = windowRef.requestIdleCallback(loadWidget, { timeout: 2500 }); + return; + } + + this.loadTimeoutId = windowRef.setTimeout(loadWidget, 1800); + }; + + const triggerWidgetLoad = (): void => { + this.clearStartListeners(); + queueWidgetLoad(); + }; + + const scheduleFallbackWidgetLoad = (): void => { + if (this.isWidgetLoaded || this.isWidgetDrawn) { + return; + } + + this.loadTimeoutId = windowRef.setTimeout((): void => { + this.loadTimeoutId = null; + triggerWidgetLoad(); + }, this.fallbackDelayMs); + }; + + this.loadListenerAbortController = new AbortController(); + const listenerOptions: AddEventListenerOptions = { + passive: true, + signal: this.loadListenerAbortController.signal, + }; + + windowRef.addEventListener('pointerdown', triggerWidgetLoad, listenerOptions); + windowRef.addEventListener('keydown', triggerWidgetLoad, listenerOptions); + windowRef.addEventListener('touchstart', triggerWidgetLoad, listenerOptions); + windowRef.addEventListener('scroll', triggerWidgetLoad, listenerOptions); + + if (this.document.readyState === 'complete') { + scheduleFallbackWidgetLoad(); + } else { + windowRef.addEventListener('load', scheduleFallbackWidgetLoad, { + ...listenerOptions, + once: true, + }); + } + } + + teardown(): void { + const windowRef = this.document.defaultView; + + this.clearStartListeners(); + + if (windowRef !== null && this.loadTimeoutId !== null) { + windowRef.clearTimeout(this.loadTimeoutId); + this.loadTimeoutId = null; + } + + if ( + windowRef !== null && + this.loadIdleCallbackId !== null && + typeof windowRef.cancelIdleCallback === 'function' + ) { + 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 { + if (this.isWidgetLoaded || this.isWidgetDrawn) { + return; + } + + const windowRef = this.document.defaultView; + if (windowRef === null) { + return; + } + + if (windowRef.kofiWidgetOverlay !== undefined) { + this.isWidgetLoaded = true; + this.drawWidget(); + return; + } + + const existingScript = this.document.querySelector( + `script[src="${this.widgetScriptUrl}"]`, + ); + + if (existingScript !== null) { + existingScript.addEventListener( + 'load', + (): void => { + this.isWidgetLoaded = true; + this.drawWidget(); + }, + { once: true }, + ); + return; + } + + await new Promise((resolve, reject): void => { + const scriptElement = this.document.createElement('script'); + scriptElement.src = this.widgetScriptUrl; + scriptElement.async = true; + scriptElement.crossOrigin = 'anonymous'; + scriptElement.addEventListener( + 'load', + (): void => { + this.isWidgetLoaded = true; + resolve(); + }, + { once: true }, + ); + scriptElement.addEventListener( + 'error', + (): void => reject(new Error('Failed to load the Ko-fi widget script.')), + { once: true }, + ); + this.document.body.appendChild(scriptElement); + }).catch((): void => { + this.isWidgetLoaded = false; + }); + + this.drawWidget(); + } + + private drawWidget(): void { + if (this.isWidgetDrawn) { + return; + } + + const windowRef = this.document.defaultView; + if (windowRef?.kofiWidgetOverlay === undefined) { + return; + } + + windowRef.kofiWidgetOverlay.draw('fraujulian', { + type: 'floating-chat', + 'floating-chat.donateButton.text': 'Coffee', + 'floating-chat.donateButton.background-color': '#ff5f5f', + 'floating-chat.donateButton.text-color': '#fff', + }); + + this.isWidgetDrawn = true; + this.ensureAccessibleIframeTitles(); + this.observeInjectedIframes(); + } + + private ensureAccessibleIframeTitles(): void { + this.document + .querySelectorAll('iframe[id^="kofi-wo-container"]') + .forEach((iframe: HTMLIFrameElement): void => { + iframe.title = this.widgetIframeTitle; + }); + } + + private observeInjectedIframes(): void { + if (this.iframeObserver !== null) { + return; + } + + this.iframeObserver = new MutationObserver((): void => { + this.ensureAccessibleIframeTitles(); + }); + + this.iframeObserver.observe(this.document.body, { + childList: true, + subtree: true, + }); + } +} diff --git a/src/index.html b/src/index.html index 254215d..1fb682c 100644 --- a/src/index.html +++ b/src/index.html @@ -17,15 +17,5 @@ - - - diff --git a/src/kofi-widget.d.ts b/src/kofi-widget.d.ts new file mode 100644 index 0000000..3a2cb49 --- /dev/null +++ b/src/kofi-widget.d.ts @@ -0,0 +1,9 @@ +interface KofiWidgetOverlay { + draw(userName: string, options: Record): void; +} + +interface Window { + kofiWidgetOverlay?: KofiWidgetOverlay; + requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number; + cancelIdleCallback?: (handle: number) => void; +}