@@ -1,7 +1,7 @@
|
||||
import type { ApplicationConfig } from '@angular/core';
|
||||
import { provideZoneChangeDetection } from '@angular/core';
|
||||
import { importProvidersFrom, provideZoneChangeDetection } from '@angular/core';
|
||||
import { provideRouter, withInMemoryScrolling } from '@angular/router';
|
||||
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
|
||||
import { BrowserModule, provideClientHydration, withEventReplay } from '@angular/platform-browser';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
|
||||
@@ -16,5 +16,6 @@ export const appConfig: ApplicationConfig = {
|
||||
}),
|
||||
),
|
||||
provideClientHydration(withEventReplay()),
|
||||
importProvidersFrom(BrowserModule),
|
||||
],
|
||||
};
|
||||
|
||||
+5
-12
@@ -1,17 +1,10 @@
|
||||
import type { Routes } from '@angular/router';
|
||||
|
||||
import { HomeComponent } from './home/home.component';
|
||||
import { ImprintComponent } from './imprint/imprint.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: async () =>
|
||||
(await import('./home/home.component')).HomeComponent,
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'imprint',
|
||||
loadComponent: async () =>
|
||||
(await import('./imprint/imprint.component')).ImprintComponent,
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{ path: '', component: HomeComponent, pathMatch: 'full' },
|
||||
{ path: 'imprint', component: ImprintComponent, pathMatch: 'full' },
|
||||
{ path: '**', redirectTo: '' },
|
||||
];
|
||||
|
||||
@@ -64,28 +64,13 @@
|
||||
[title]="portraitHighlights.length > 1 ? 'Show next image' : 'Single image'"
|
||||
>
|
||||
<div class="portrait-media" [class.portrait-media-switching]="isPortraitSwitching">
|
||||
<picture>
|
||||
<source
|
||||
type="image/avif"
|
||||
[attr.srcset]="currentPortraitHighlight.avifSrcset"
|
||||
sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 900px) min(560px, calc(100vw - 2rem)), 560px"
|
||||
/>
|
||||
<source
|
||||
type="image/webp"
|
||||
[attr.srcset]="currentPortraitHighlight.webpSrcset"
|
||||
sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 900px) min(560px, calc(100vw - 2rem)), 560px"
|
||||
/>
|
||||
<img
|
||||
class="hero-feature-image"
|
||||
[src]="currentPortraitHighlight.image"
|
||||
[attr.width]="currentPortraitHighlight.width"
|
||||
[attr.height]="currentPortraitHighlight.height"
|
||||
[alt]="currentPortraitHighlight.alt"
|
||||
fetchpriority="high"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
/>
|
||||
</picture>
|
||||
<img
|
||||
class="hero-feature-image"
|
||||
[src]="currentPortraitHighlight.image"
|
||||
width="560"
|
||||
height="420"
|
||||
[alt]="global.firstname + ' highlight'"
|
||||
/>
|
||||
@if (currentPortraitHighlightIndex === 0) {
|
||||
<span class="portrait-click-hint">Click me!</span>
|
||||
}
|
||||
@@ -163,11 +148,7 @@
|
||||
class="project-icon"
|
||||
[class.project-icon-circle]="project.CircleIcon !== false"
|
||||
[src]="project.icon"
|
||||
[attr.width]="project.iconWidth ?? 108"
|
||||
[attr.height]="project.iconHeight ?? 108"
|
||||
[alt]="project.title + ' icon'"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { FooterComponent } from '../footer/footer.component';
|
||||
export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
private readonly sanitizer = inject(DomSanitizer);
|
||||
private readonly zone = inject(NgZone);
|
||||
private preloadedImages: HTMLImageElement[] = [];
|
||||
private tallestPortraitAspectRatio: number | null = null;
|
||||
private lockedHeroSectionMinHeight: number = 0;
|
||||
private projectEntryObserver: IntersectionObserver | null = null;
|
||||
@@ -81,7 +82,7 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.initPortraitAspectRatio();
|
||||
this.preloadImageAssets();
|
||||
this.initNameGradientScrollAnimation();
|
||||
|
||||
this.zone.runOutsideAngular((): void => {
|
||||
@@ -180,21 +181,88 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.scrollToElementById('contact-links', this.getResponsiveScrollOffset(0.05));
|
||||
}
|
||||
|
||||
private initPortraitAspectRatio(): void {
|
||||
const minAspectRatio: number = this.portraitHighlights.reduce(
|
||||
(minRatio: number, highlight: PortraitHighlight): number => {
|
||||
if (highlight.width <= 0 || highlight.height <= 0) {
|
||||
return minRatio;
|
||||
}
|
||||
private preloadImageAssets(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRatio: number = highlight.width / highlight.height;
|
||||
return minRatio === 0 ? currentRatio : Math.min(minRatio, currentRatio);
|
||||
},
|
||||
0,
|
||||
const projectIcons: string[] = this.projects
|
||||
.map((project: PortfolioProject): string | undefined => project.icon)
|
||||
.filter((icon: string | undefined): icon is string => Boolean(icon));
|
||||
|
||||
const uniqueImageUrls: string[] = Array.from(
|
||||
new Set<string>([
|
||||
...this.portraitHighlights.map((highlight: PortraitHighlight): string => highlight.image),
|
||||
...projectIcons,
|
||||
]),
|
||||
);
|
||||
|
||||
if (minAspectRatio > 0) {
|
||||
this.tallestPortraitAspectRatio = minAspectRatio;
|
||||
this.preloadedImages = uniqueImageUrls.map((url: string): HTMLImageElement => {
|
||||
const image = new Image();
|
||||
image.decoding = 'async';
|
||||
image.loading = 'eager';
|
||||
image.src = url;
|
||||
return image;
|
||||
});
|
||||
|
||||
this.syncHeroHeightToLargestPortraitImage();
|
||||
}
|
||||
|
||||
private syncHeroHeightToLargestPortraitImage(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const portraitImageUrls: Set<string> = new Set<string>(
|
||||
this.portraitHighlights.map(
|
||||
(highlight: PortraitHighlight): string =>
|
||||
new URL(highlight.image, window.location.href).href,
|
||||
),
|
||||
);
|
||||
|
||||
const portraitImages: HTMLImageElement[] = this.preloadedImages.filter(
|
||||
(image: HTMLImageElement): boolean => portraitImageUrls.has(image.src),
|
||||
);
|
||||
|
||||
if (portraitImages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const finalizeAspectRatio = (): void => {
|
||||
const minAspectRatio: number = portraitImages.reduce(
|
||||
(minRatio: number, image: HTMLImageElement): number => {
|
||||
if (image.naturalWidth === 0 || image.naturalHeight === 0) {
|
||||
return minRatio;
|
||||
}
|
||||
|
||||
const currentRatio: number = image.naturalWidth / image.naturalHeight;
|
||||
return minRatio === 0 ? currentRatio : Math.min(minRatio, currentRatio);
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
if (minAspectRatio > 0) {
|
||||
this.tallestPortraitAspectRatio = minAspectRatio;
|
||||
this.scheduleHeroSectionHeightUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
let pendingLoads: number = portraitImages.length;
|
||||
const onImageSettled = (): void => {
|
||||
pendingLoads -= 1;
|
||||
if (pendingLoads === 0) {
|
||||
finalizeAspectRatio();
|
||||
}
|
||||
};
|
||||
|
||||
for (const image of portraitImages) {
|
||||
if (image.complete) {
|
||||
onImageSettled();
|
||||
continue;
|
||||
}
|
||||
|
||||
image.addEventListener('load', onImageSettled, { once: true });
|
||||
image.addEventListener('error', onImageSettled, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user