feat(home): rework hero, skills ticker, project cards and image loading
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
20aaf4faa7
commit
1c8f76c81c
3 files changed
+289
-369
No files matched your search
@@ -11,85 +11,75 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<div class="hero-contact-block">
|
|
||||||
<p class="hero-contact-title">{{ content().home.contactTitle }}</p>
|
|
||||||
<div id="contact-links" class="contact-area hero-contact-area">
|
|
||||||
<a [href]="contactMailHref" aria-label="Send mail"><app-icon [icon]="envelopeIcon" size="lg"></app-icon></a>
|
|
||||||
<a href="tel:{{ global.hrefContactPhone }}" aria-label="Call phone number"
|
|
||||||
><app-icon [icon]="phoneIcon" size="lg"></app-icon
|
|
||||||
></a>
|
|
||||||
<a href="https://discord.com/users/860206216893693973" aria-label="Discord profile"
|
|
||||||
><app-icon [icon]="discordIcon" size="lg"></app-icon
|
|
||||||
></a>
|
|
||||||
<a href="https://git.lechner-systems.at/fraujulian" aria-label="Gitea profile"
|
|
||||||
><app-icon [icon]="githubIcon" size="lg"></app-icon
|
|
||||||
></a>
|
|
||||||
<a href="https://www.linkedin.com/in/julian-lechner-98b377356/" aria-label="LinkedIn profile"
|
|
||||||
><app-icon [icon]="linkedinIcon" size="lg"></app-icon
|
|
||||||
></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hero-action-links">
|
<div class="hero-action-links">
|
||||||
<button class="action-link" type="button" (click)="scrollToProjectsSection()">{{ content().home.viewProjects }}</button>
|
<button class="action-link" type="button" (click)="scrollToProjectsSection()">{{ content().home.viewProjects }}</button>
|
||||||
<button class="action-link" type="button" (click)="scrollToAboutSection()">{{ content().home.aboutMe }}</button>
|
<button class="action-link" type="button" (click)="scrollToAboutSection()">{{ content().home.aboutMe }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="hero-contact-block">
|
||||||
|
<p class="hero-contact-title">{{ content().home.contactTitle }}</p>
|
||||||
<div class="hero-meta">
|
<div id="contact-links" class="contact-area hero-contact-area">
|
||||||
<div>
|
<a [href]="contactMailHref" [attr.aria-label]="content().home.mailAriaLabel"
|
||||||
<span class="meta-label">{{ currentBioEntry.label }}</span
|
><app-icon [icon]="envelopeIcon" size="lg"></app-icon
|
||||||
><strong>{{ currentBioEntry.value }}</strong>
|
></a>
|
||||||
|
<a href="tel:{{ global.hrefContactPhone }}" [attr.aria-label]="content().home.phoneAriaLabel"
|
||||||
|
><app-icon [icon]="phoneIcon" size="lg"></app-icon
|
||||||
|
></a>
|
||||||
|
<a [href]="global.signalUrl" [attr.aria-label]="content().home.signalAriaLabel"
|
||||||
|
><app-icon [icon]="signalIcon" size="lg"></app-icon
|
||||||
|
></a>
|
||||||
|
<a href="https://git.lechner-systems.at/fraujulian" [attr.aria-label]="content().home.giteaAriaLabel"
|
||||||
|
><app-icon [icon]="giteaIcon" size="lg"></app-icon
|
||||||
|
></a>
|
||||||
|
<a href="https://www.linkedin.com/in/julianlechner2009/" [attr.aria-label]="content().home.linkedinAriaLabel"
|
||||||
|
><app-icon [icon]="linkedinIcon" size="lg"></app-icon
|
||||||
|
></a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hero-visual">
|
<div class="hero-visual">
|
||||||
@if (currentPortraitHighlight) {
|
@if (currentPortraitHighlight(); as highlight) {
|
||||||
<div class="portrait-highlight">
|
<div class="portrait-highlight">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="portrait-highlight-button"
|
class="portrait-highlight-button"
|
||||||
(click)="showNextPortraitHighlight()"
|
(click)="showNextPortraitHighlight()"
|
||||||
[attr.aria-label]="content().home.nextHighlightAriaLabel"
|
[attr.aria-label]="content().home.nextHighlightAriaLabel"
|
||||||
[title]="portraitHighlights.length > 1 ? content().home.nextHighlightTitle : content().home.singleHighlightTitle"
|
[title]="portraitHighlights().length > 1 ? content().home.nextHighlightTitle : content().home.singleHighlightTitle"
|
||||||
>
|
>
|
||||||
<div class="portrait-media" [class.portrait-media-switching]="isPortraitSwitching">
|
<div class="portrait-media" [class.portrait-media-switching]="isPortraitSwitching()">
|
||||||
<img
|
<img
|
||||||
class="hero-feature-image"
|
class="hero-feature-image"
|
||||||
[src]="currentPortraitHighlight.image"
|
[src]="highlight.image"
|
||||||
[attr.srcset]="currentPortraitHighlight.imageSrcset"
|
[attr.srcset]="highlight.imageSrcset"
|
||||||
[attr.sizes]="currentPortraitHighlight.imageSizes"
|
[attr.sizes]="highlight.imageSizes"
|
||||||
[width]="currentPortraitHighlight.imageWidth"
|
[width]="highlight.imageWidth"
|
||||||
[height]="currentPortraitHighlight.imageHeight"
|
[height]="highlight.imageHeight"
|
||||||
fetchpriority="high"
|
fetchpriority="high"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
[alt]="currentPortraitHighlight.text + ' ' + content().home.portraitAltSuffix"
|
[alt]="highlight.text + ' ' + content().home.portraitAltSuffix"
|
||||||
/>
|
/>
|
||||||
@if (currentPortraitHighlightIndex === 0) {
|
@if (currentPortraitHighlightIndex() === 0) {
|
||||||
<span class="portrait-click-hint">{{ content().home.clickHint }}</span>
|
<span class="portrait-click-hint">{{ content().home.clickHint }}</span>
|
||||||
}
|
}
|
||||||
<span class="portrait-caption">{{ currentPortraitHighlight.text }}</span>
|
<span class="portrait-caption">{{ highlight.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
<a
|
||||||
</section>
|
class="hero-meta"
|
||||||
|
[href]="global.linkedinSkillsUrl"
|
||||||
<section class="panel openpgp-section">
|
[attr.aria-label]="content().home.skillsLinkAriaLabel"
|
||||||
<div class="openpgp-copy">
|
(mouseenter)="isBioTickerPaused.set(true)"
|
||||||
<p class="eyebrow">{{ content().home.openPgpTitle }}</p>
|
(mouseleave)="isBioTickerPaused.set(false)"
|
||||||
<p class="openpgp-description">{{ content().home.openPgpDescription }}</p>
|
>
|
||||||
</div>
|
<div>
|
||||||
<div class="openpgp-actions">
|
<span class="meta-label">{{ content().home.skillsLabel }}</span
|
||||||
<a class="action-link openpgp-download-link" [href]="openPgpKey.assetPath" download="julian-lechner-public.asc">
|
><strong>{{ currentBioText() }}</strong>
|
||||||
{{ content().home.openPgpDownload }}
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<p class="openpgp-fingerprint">
|
|
||||||
<span>{{ content().home.openPgpFingerprintLabel }}</span>
|
|
||||||
<strong>{{ openPgpKey.formattedFingerprint }}</strong>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -105,11 +95,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="story-detail">
|
<div class="story-detail">
|
||||||
<button class="detail-toggle" type="button" (click)="toggleBio()">
|
<button class="detail-toggle" type="button" (click)="toggleBio()">
|
||||||
<span>{{ isLongBioShown ? content().home.detailToggleClose : content().home.detailToggleOpen }}</span
|
<span>{{ isLongBioShown() ? content().home.detailToggleClose : content().home.detailToggleOpen }}</span
|
||||||
><app-icon [icon]="isLongBioShown ? arrowDownIcon : arrowRightIcon"></app-icon>
|
><app-icon [icon]="isLongBioShown() ? arrowDownIcon : arrowRightIcon"></app-icon>
|
||||||
</button>
|
</button>
|
||||||
@if (isLongBioMounted) {
|
@if (isLongBioMounted()) {
|
||||||
<div class="detail-panel" [class.detail-panel-enter]="isLongBioShown" [class.detail-panel-leave]="!isLongBioShown">
|
<div class="detail-panel" [class.detail-panel-enter]="isLongBioShown()" [class.detail-panel-leave]="!isLongBioShown()">
|
||||||
<p>{{ content().home.detailParagraphs[0] }}</p>
|
<p>{{ content().home.detailParagraphs[0] }}</p>
|
||||||
<p>{{ content().home.detailParagraphs[1] }}</p>
|
<p>{{ content().home.detailParagraphs[1] }}</p>
|
||||||
<p>{{ content().home.detailParagraphs[2] }}</p>
|
<p>{{ content().home.detailParagraphs[2] }}</p>
|
||||||
@@ -127,7 +117,7 @@
|
|||||||
<p class="eyebrow">{{ content().home.projectsEyebrow }}</p>
|
<p class="eyebrow">{{ content().home.projectsEyebrow }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="project-list">
|
<div class="project-list">
|
||||||
@for (project of projects; track project.link + '-' + $index; let i = $index) {
|
@for (project of projects(); track project.link + '-' + $index; let i = $index) {
|
||||||
<a
|
<a
|
||||||
class="panel project-entry"
|
class="panel project-entry"
|
||||||
[class.project-entry-reverse]="i % 2 === 1"
|
[class.project-entry-reverse]="i % 2 === 1"
|
||||||
@@ -141,24 +131,25 @@
|
|||||||
<img
|
<img
|
||||||
class="project-icon"
|
class="project-icon"
|
||||||
[class.project-icon-circle]="project.circleIcon !== false"
|
[class.project-icon-circle]="project.circleIcon !== false"
|
||||||
[ngSrc]="project.icon"
|
[src]="project.icon"
|
||||||
[attr.srcset]="project.iconSrcset ?? null"
|
[attr.srcset]="project.iconSrcset ?? null"
|
||||||
[attr.sizes]="project.iconSizes ?? null"
|
[attr.sizes]="project.iconSizes ?? null"
|
||||||
[width]="project.iconWidth ?? 216"
|
[width]="project.iconWidth ?? 216"
|
||||||
[height]="project.iconHeight ?? 216"
|
[height]="project.iconHeight ?? 216"
|
||||||
disableOptimizedSrcset
|
loading="eager"
|
||||||
[alt]="project.title + ' icon'"
|
fetchpriority="low"
|
||||||
|
decoding="async"
|
||||||
|
[alt]="project.title + ' ' + content().home.projectIconAltSuffix"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
<div class="project-copy">
|
<div class="project-copy">
|
||||||
<div class="project-header">
|
<div class="project-header">
|
||||||
<div class="project-title-wrap">
|
<div class="project-title-wrap">
|
||||||
<span class="project-link">{{ project.title }}</span>
|
<span class="project-link"
|
||||||
</div>
|
>{{ project.titleHead
|
||||||
<div class="project-languages" [attr.aria-label]="content().home.projectLanguagesAriaLabel">
|
}}<span class="project-title-tail"
|
||||||
@for (language of project.skills; track language + '-' + $index) {
|
>{{ project.titleTail }}<app-icon class="project-arrow" [icon]="arrowRightIcon"></app-icon></span
|
||||||
<span class="project-language-chip">{{ language }}</span>
|
></span>
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="project-description">{{ project.description }}</p>
|
<p class="project-description">{{ project.description }}</p>
|
||||||
@@ -169,4 +160,4 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<app-footer ngSkipHydration></app-footer>
|
<app-footer></app-footer>
|
||||||
+126
-110
@@ -2,17 +2,13 @@ import type { Mock } from 'vitest';
|
|||||||
import type { ComponentFixture } from '@angular/core/testing';
|
import type { ComponentFixture } from '@angular/core/testing';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { IMAGE_LOADER } from '@angular/common';
|
|
||||||
import type { ImageLoaderConfig } from '@angular/common';
|
|
||||||
import { NgZone } from '@angular/core';
|
import { NgZone } from '@angular/core';
|
||||||
|
import type { Signal, WritableSignal } from '@angular/core';
|
||||||
|
|
||||||
import { HomeComponent } from './home.component';
|
import { HomeComponent } from './home.component';
|
||||||
import { global } from '../../global';
|
import { global } from '../../global';
|
||||||
import { enLanguage } from '../../languages/en';
|
import { enLanguage } from '../../languages/en';
|
||||||
import type {
|
import type { LanguagePortraitHighlight } from '../../languages/language.types';
|
||||||
LanguageBioTextEntry,
|
|
||||||
LanguagePortraitHighlight,
|
|
||||||
} from '../../languages/language.types';
|
|
||||||
|
|
||||||
describe('HomeComponent', (): void => {
|
describe('HomeComponent', (): void => {
|
||||||
beforeEach((): void => {
|
beforeEach((): void => {
|
||||||
@@ -42,26 +38,24 @@ describe('HomeComponent', (): void => {
|
|||||||
|
|
||||||
interface ComponentAccess {
|
interface ComponentAccess {
|
||||||
age: number;
|
age: number;
|
||||||
currentIndex: number;
|
currentIndex: WritableSignal<number>;
|
||||||
isLongBioShown: boolean;
|
isBioTickerPaused: WritableSignal<boolean>;
|
||||||
isLongBioMounted: boolean;
|
isLongBioShown: WritableSignal<boolean>;
|
||||||
isPortraitSwitching: boolean;
|
isLongBioMounted: WritableSignal<boolean>;
|
||||||
currentPortraitHighlightIndex: number;
|
isPortraitSwitching: WritableSignal<boolean>;
|
||||||
|
currentPortraitHighlightIndex: WritableSignal<number>;
|
||||||
|
portraitHighlights: Signal<LanguagePortraitHighlight[]>;
|
||||||
bioHideTimeoutId: number | null;
|
bioHideTimeoutId: number | null;
|
||||||
scrollAnimationFrameId: number | null;
|
scrollEffectsAnimationFrameId: number | null;
|
||||||
projectEntryAnimationFrameId: number | null;
|
scheduleScrollEffectsUpdate(): void;
|
||||||
aboutSectionAnimationFrameId: number | null;
|
|
||||||
scheduleNameGradientUpdate(): void;
|
|
||||||
scheduleProjectEntryRevealUpdate(): void;
|
|
||||||
scheduleAboutSectionScrollAnimationUpdate(): void;
|
|
||||||
shouldEnableScrollEffects(): boolean;
|
shouldEnableScrollEffects(): boolean;
|
||||||
toggleBio(): void;
|
toggleBio(): void;
|
||||||
showNextPortraitHighlight(): void;
|
showNextPortraitHighlight(): void;
|
||||||
scrollToAboutSection(): void;
|
scrollToAboutSection(): void;
|
||||||
scrollToProjectsSection(): void;
|
scrollToProjectsSection(): void;
|
||||||
scrollToContactLinks(): void;
|
scrollToContactLinks(): void;
|
||||||
currentBioEntry: LanguageBioTextEntry;
|
currentBioText: Signal<string>;
|
||||||
currentPortraitHighlight: LanguagePortraitHighlight | null;
|
currentPortraitHighlight: Signal<LanguagePortraitHighlight | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let comp: ComponentAccess;
|
let comp: ComponentAccess;
|
||||||
@@ -69,13 +63,7 @@ describe('HomeComponent', (): void => {
|
|||||||
beforeEach(async (): Promise<void> => {
|
beforeEach(async (): Promise<void> => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [HomeComponent],
|
imports: [HomeComponent],
|
||||||
providers: [
|
providers: [provideRouter([])],
|
||||||
provideRouter([]),
|
|
||||||
{
|
|
||||||
provide: IMAGE_LOADER,
|
|
||||||
useValue: (config: ImageLoaderConfig): string => config.src,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
fixture = TestBed.createComponent(HomeComponent);
|
fixture = TestBed.createComponent(HomeComponent);
|
||||||
@@ -85,7 +73,7 @@ describe('HomeComponent', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach((): void => {
|
afterEach((): void => {
|
||||||
// Explicitly destroy to stop the interval(1000) subscription started in ngOnInit.
|
// Explicitly destroy to stop the bio ticker interval started after the first render.
|
||||||
// Without this, the interval keeps firing after the test has finished.
|
// Without this, the interval keeps firing after the test has finished.
|
||||||
fixture.destroy();
|
fixture.destroy();
|
||||||
});
|
});
|
||||||
@@ -115,40 +103,69 @@ describe('HomeComponent', (): void => {
|
|||||||
|
|
||||||
// ── Bio cycling ────────────────────────────────────────────────────────────
|
// ── Bio cycling ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('currentBioEntry getter', (): void => {
|
describe('currentBioText', (): void => {
|
||||||
it('should return the first bioTextsList entry at index 0', (): void => {
|
it('should return the first bioTextsList entry at index 0', (): void => {
|
||||||
comp.currentIndex = 0;
|
comp.currentIndex.set(0);
|
||||||
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[0]);
|
expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return the correct entry when index changes', (): void => {
|
it('should return the correct entry when index changes', (): void => {
|
||||||
comp.currentIndex = 3;
|
comp.currentIndex.set(3);
|
||||||
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[3]);
|
expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[3]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return the fallback entry when index is out of range', (): void => {
|
it('should return an empty text when index is out of range', (): void => {
|
||||||
comp.currentIndex = 99999;
|
comp.currentIndex.set(99999);
|
||||||
expect(comp.currentBioEntry.label).toBeDefined();
|
expect(comp.currentBioText()).toBe('');
|
||||||
expect(comp.currentBioEntry.value).toBeDefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('interval-driven bio cycling', (): void => {
|
describe('interval-driven bio cycling', (): void => {
|
||||||
|
it('should advance to the next entry every 1250 ms', async (): Promise<void> => {
|
||||||
|
await vi.advanceTimersByTimeAsync(1249);
|
||||||
|
expect(comp.currentIndex()).toBe(0);
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(comp.currentIndex()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pause while the bio is hovered', async (): Promise<void> => {
|
||||||
|
const meta = (fixture.nativeElement as HTMLElement).querySelector('.hero-meta');
|
||||||
|
meta?.dispatchEvent(new MouseEvent('mouseenter'));
|
||||||
|
await vi.advanceTimersByTimeAsync(2500);
|
||||||
|
expect(comp.currentIndex()).toBe(0);
|
||||||
|
|
||||||
|
meta?.dispatchEvent(new MouseEvent('mouseleave'));
|
||||||
|
await vi.advanceTimersByTimeAsync(1250);
|
||||||
|
expect(comp.currentIndex()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not cycle when reduced motion is requested', async (): Promise<void> => {
|
||||||
|
vi.stubGlobal('matchMedia', (): MediaQueryList => ({ matches: true }) as MediaQueryList);
|
||||||
|
const reducedFixture = TestBed.createComponent(HomeComponent);
|
||||||
|
reducedFixture.detectChanges();
|
||||||
|
const reduced = reducedFixture.componentInstance as unknown as ComponentAccess;
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
|
||||||
|
expect(reduced.currentIndex()).toBe(0);
|
||||||
|
reducedFixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
it('should start with currentIndex 0', (): void => {
|
it('should start with currentIndex 0', (): void => {
|
||||||
expect(comp.currentIndex).toBe(0);
|
expect(comp.currentIndex()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should expose a valid bio entry for the current index', (): void => {
|
it('should expose a valid bio entry for the current index', (): void => {
|
||||||
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[0]);
|
expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly wrap from the last entry back to 0 using modulo', (): void => {
|
it('should correctly wrap from the last entry back to 0 using modulo', (): void => {
|
||||||
const last = enLanguage.bioTextsList.length - 1;
|
const last = enLanguage.bioTextsList.length - 1;
|
||||||
comp.currentIndex = last;
|
comp.currentIndex.set(last);
|
||||||
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[last]);
|
expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[last]);
|
||||||
comp.currentIndex = (last + 1) % enLanguage.bioTextsList.length;
|
comp.currentIndex.set((last + 1) % enLanguage.bioTextsList.length);
|
||||||
expect(comp.currentIndex).toBe(0);
|
expect(comp.currentIndex()).toBe(0);
|
||||||
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[0]);
|
expect(comp.currentBioText()).toEqual(enLanguage.bioTextsList[0]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,38 +173,52 @@ describe('HomeComponent', (): void => {
|
|||||||
|
|
||||||
describe('currentPortraitHighlight getter', (): void => {
|
describe('currentPortraitHighlight getter', (): void => {
|
||||||
it('should return the highlight at the current index', (): void => {
|
it('should return the highlight at the current index', (): void => {
|
||||||
comp.currentPortraitHighlightIndex = 0;
|
comp.currentPortraitHighlightIndex.set(0);
|
||||||
expect(comp.currentPortraitHighlight).toEqual(enLanguage.portraitHighlights[0]);
|
expect(comp.currentPortraitHighlight()).toEqual(enLanguage.portraitHighlights[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null when index is out of range', (): void => {
|
it('should return null when index is out of range', (): void => {
|
||||||
comp.currentPortraitHighlightIndex = 99999;
|
comp.currentPortraitHighlightIndex.set(99999);
|
||||||
expect(comp.currentPortraitHighlight).toBeNull();
|
expect(comp.currentPortraitHighlight()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('portrait preloading', (): void => {
|
||||||
|
it('should preload every highlight image except the visible one', (): void => {
|
||||||
|
const preloaded: string[] = [];
|
||||||
|
vi.spyOn(window, 'Image').mockImplementation(function (): HTMLImageElement {
|
||||||
|
const image = document.createElement('img');
|
||||||
|
Object.defineProperty(image, 'src', {
|
||||||
|
set: (value: string): void => {
|
||||||
|
preloaded.push(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return image;
|
||||||
|
});
|
||||||
|
|
||||||
|
const preloadFixture = TestBed.createComponent(HomeComponent);
|
||||||
|
preloadFixture.detectChanges();
|
||||||
|
|
||||||
|
expect(preloaded).toEqual(enLanguage.portraitHighlights.slice(1).map((h) => h.image));
|
||||||
|
preloadFixture.destroy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('showNextPortraitHighlight', (): void => {
|
describe('showNextPortraitHighlight', (): void => {
|
||||||
// Timer tests run outside Angular zone to prevent NgOptimizedImage from
|
|
||||||
// throwing NG02953 when height/width inputs change during re-render.
|
|
||||||
|
|
||||||
it('should set isPortraitSwitching to true immediately', (): void => {
|
it('should set isPortraitSwitching to true immediately', (): void => {
|
||||||
comp.isPortraitSwitching = false;
|
comp.isPortraitSwitching.set(false);
|
||||||
comp.showNextPortraitHighlight();
|
comp.showNextPortraitHighlight();
|
||||||
expect(comp.isPortraitSwitching).toBe(true);
|
expect(comp.isPortraitSwitching()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not advance index when already switching', (): void => {
|
it('should not advance index when already switching', (): void => {
|
||||||
comp.isPortraitSwitching = true;
|
comp.isPortraitSwitching.set(true);
|
||||||
comp.showNextPortraitHighlight();
|
comp.showNextPortraitHighlight();
|
||||||
expect(comp.currentPortraitHighlightIndex).toBe(0);
|
expect(comp.currentPortraitHighlightIndex()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not call setTimeout when only one portrait exists', (): void => {
|
it('should not call setTimeout when only one portrait exists', (): void => {
|
||||||
const highlights = (
|
const highlights = comp.portraitHighlights();
|
||||||
comp as unknown as {
|
|
||||||
portraitHighlights: LanguagePortraitHighlight[];
|
|
||||||
}
|
|
||||||
).portraitHighlights;
|
|
||||||
const saved = [...highlights]; // snapshot before mutation
|
const saved = [...highlights]; // snapshot before mutation
|
||||||
highlights.splice(1, saved.length - 1); // reduce to a single entry
|
highlights.splice(1, saved.length - 1); // reduce to a single entry
|
||||||
|
|
||||||
@@ -204,7 +235,7 @@ describe('HomeComponent', (): void => {
|
|||||||
comp.showNextPortraitHighlight();
|
comp.showNextPortraitHighlight();
|
||||||
});
|
});
|
||||||
await vi.advanceTimersByTimeAsync(125);
|
await vi.advanceTimersByTimeAsync(125);
|
||||||
expect(comp.currentPortraitHighlightIndex).toBe(1);
|
expect(comp.currentPortraitHighlightIndex()).toBe(1);
|
||||||
await vi.advanceTimersByTimeAsync(125);
|
await vi.advanceTimersByTimeAsync(125);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,17 +245,17 @@ describe('HomeComponent', (): void => {
|
|||||||
comp.showNextPortraitHighlight();
|
comp.showNextPortraitHighlight();
|
||||||
});
|
});
|
||||||
await vi.advanceTimersByTimeAsync(250);
|
await vi.advanceTimersByTimeAsync(250);
|
||||||
expect(comp.isPortraitSwitching).toBe(false);
|
expect(comp.isPortraitSwitching()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should wrap portrait index from the last position back to 0', async (): Promise<void> => {
|
it('should wrap portrait index from the last position back to 0', async (): Promise<void> => {
|
||||||
comp.currentPortraitHighlightIndex = enLanguage.portraitHighlights.length - 1;
|
comp.currentPortraitHighlightIndex.set(enLanguage.portraitHighlights.length - 1);
|
||||||
const zone = TestBed.inject(NgZone);
|
const zone = TestBed.inject(NgZone);
|
||||||
zone.runOutsideAngular((): void => {
|
zone.runOutsideAngular((): void => {
|
||||||
comp.showNextPortraitHighlight();
|
comp.showNextPortraitHighlight();
|
||||||
});
|
});
|
||||||
await vi.advanceTimersByTimeAsync(125);
|
await vi.advanceTimersByTimeAsync(125);
|
||||||
expect(comp.currentPortraitHighlightIndex).toBe(0);
|
expect(comp.currentPortraitHighlightIndex()).toBe(0);
|
||||||
await vi.advanceTimersByTimeAsync(125);
|
await vi.advanceTimersByTimeAsync(125);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -234,7 +265,7 @@ describe('HomeComponent', (): void => {
|
|||||||
describe('toggleBio', (): void => {
|
describe('toggleBio', (): void => {
|
||||||
it('should mount bio immediately on opening', async (): Promise<void> => {
|
it('should mount bio immediately on opening', async (): Promise<void> => {
|
||||||
comp.toggleBio();
|
comp.toggleBio();
|
||||||
expect(comp.isLongBioMounted).toBe(true);
|
expect(comp.isLongBioMounted()).toBe(true);
|
||||||
await vi.advanceTimersByTimeAsync(100);
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -242,7 +273,7 @@ describe('HomeComponent', (): void => {
|
|||||||
comp.toggleBio();
|
comp.toggleBio();
|
||||||
await vi.advanceTimersByTimeAsync(50); // allow both nested requestAnimationFrame calls to execute
|
await vi.advanceTimersByTimeAsync(50); // allow both nested requestAnimationFrame calls to execute
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(comp.isLongBioShown).toBe(true);
|
expect(comp.isLongBioShown()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set isLongBioShown to false immediately on close', async (): Promise<void> => {
|
it('should set isLongBioShown to false immediately on close', async (): Promise<void> => {
|
||||||
@@ -254,7 +285,7 @@ describe('HomeComponent', (): void => {
|
|||||||
// Close
|
// Close
|
||||||
comp.toggleBio();
|
comp.toggleBio();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(comp.isLongBioShown).toBe(false);
|
expect(comp.isLongBioShown()).toBe(false);
|
||||||
await vi.advanceTimersByTimeAsync(300);
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -265,14 +296,14 @@ describe('HomeComponent', (): void => {
|
|||||||
|
|
||||||
comp.toggleBio();
|
comp.toggleBio();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(comp.isLongBioMounted).toBe(true);
|
expect(comp.isLongBioMounted()).toBe(true);
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(299);
|
await vi.advanceTimersByTimeAsync(299);
|
||||||
expect(comp.isLongBioMounted).toBe(true);
|
expect(comp.isLongBioMounted()).toBe(true);
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(1);
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(comp.isLongBioMounted).toBe(false);
|
expect(comp.isLongBioMounted()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should cancel pending close timeout when re-opened during animation', async (): Promise<void> => {
|
it('should cancel pending close timeout when re-opened during animation', async (): Promise<void> => {
|
||||||
@@ -287,8 +318,8 @@ describe('HomeComponent', (): void => {
|
|||||||
await vi.advanceTimersByTimeAsync(50);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(comp.isLongBioMounted).toBe(true);
|
expect(comp.isLongBioMounted()).toBe(true);
|
||||||
expect(comp.isLongBioShown).toBe(true);
|
expect(comp.isLongBioShown()).toBe(true);
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(500);
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
});
|
});
|
||||||
@@ -400,7 +431,13 @@ describe('HomeComponent', (): void => {
|
|||||||
expect(h1?.textContent).toContain(global.lastname);
|
expect(h1?.textContent).toContain(global.lastname);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render the bio label from the current bioTextsList entry', (): void => {
|
it('should link the skills box to the LinkedIn skills page', (): void => {
|
||||||
|
const el = fixture.nativeElement as HTMLElement;
|
||||||
|
const link = el.querySelector<HTMLAnchorElement>('a.hero-meta');
|
||||||
|
expect(link?.getAttribute('href')).toBe(global.linkedinSkillsUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render the skills label above the rotating skill', (): void => {
|
||||||
const el = fixture.nativeElement as HTMLElement;
|
const el = fixture.nativeElement as HTMLElement;
|
||||||
expect(el.querySelector('.meta-label')).not.toBeNull();
|
expect(el.querySelector('.meta-label')).not.toBeNull();
|
||||||
});
|
});
|
||||||
@@ -426,6 +463,17 @@ describe('HomeComponent', (): void => {
|
|||||||
expect(entries.length).toBe(enLanguage.projects.length);
|
expect(entries.length).toBe(enLanguage.projects.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should keep the arrow on the same line as the last title word', (): void => {
|
||||||
|
const el = fixture.nativeElement as HTMLElement;
|
||||||
|
const tails = Array.from(el.querySelectorAll('.project-title-tail'));
|
||||||
|
expect(tails.map((tail): string => tail.textContent?.trim() ?? '')).toEqual(
|
||||||
|
enLanguage.projects.map((project): string => project.title.split(' ').at(-1) ?? ''),
|
||||||
|
);
|
||||||
|
tails.forEach((tail): void => {
|
||||||
|
expect(tail.querySelector('.project-arrow')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should render all project titles', (): void => {
|
it('should render all project titles', (): void => {
|
||||||
const el = fixture.nativeElement as HTMLElement;
|
const el = fixture.nativeElement as HTMLElement;
|
||||||
enLanguage.projects.forEach((project): void => {
|
enLanguage.projects.forEach((project): void => {
|
||||||
@@ -495,10 +543,10 @@ describe('HomeComponent', (): void => {
|
|||||||
expect(performance.now() - start).toBeLessThan(100);
|
expect(performance.now() - start).toBeLessThan(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should deduplicate rapid schedule calls to a single requestAnimationFrame', async (): Promise<void> => {
|
it('should batch all scroll effects into a single requestAnimationFrame', async (): Promise<void> => {
|
||||||
// The RAF registered during beforeEach's ngOnInit is still pending under fake timers.
|
// The RAF registered after the first render is still pending under fake timers.
|
||||||
// Resetting the ID to null makes the schedule function treat the slot as free.
|
// Resetting the ID to null makes the schedule function treat the slot as free.
|
||||||
comp.scrollAnimationFrameId = null;
|
comp.scrollEffectsAnimationFrameId = null;
|
||||||
|
|
||||||
let rafCallCount = 0;
|
let rafCallCount = 0;
|
||||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((): number => {
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((): number => {
|
||||||
@@ -506,41 +554,9 @@ describe('HomeComponent', (): void => {
|
|||||||
return rafCallCount; // returns non-null so subsequent calls deduplicate
|
return rafCallCount; // returns non-null so subsequent calls deduplicate
|
||||||
});
|
});
|
||||||
|
|
||||||
comp.scheduleNameGradientUpdate();
|
comp.scheduleScrollEffectsUpdate();
|
||||||
comp.scheduleNameGradientUpdate();
|
comp.scheduleScrollEffectsUpdate();
|
||||||
comp.scheduleNameGradientUpdate();
|
comp.scheduleScrollEffectsUpdate();
|
||||||
|
|
||||||
expect(rafCallCount).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should deduplicate project-entry reveal scheduling the same way', async (): Promise<void> => {
|
|
||||||
comp.projectEntryAnimationFrameId = null;
|
|
||||||
|
|
||||||
let rafCallCount = 0;
|
|
||||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((): number => {
|
|
||||||
rafCallCount++;
|
|
||||||
return rafCallCount;
|
|
||||||
});
|
|
||||||
|
|
||||||
comp.scheduleProjectEntryRevealUpdate();
|
|
||||||
comp.scheduleProjectEntryRevealUpdate();
|
|
||||||
comp.scheduleProjectEntryRevealUpdate();
|
|
||||||
|
|
||||||
expect(rafCallCount).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should deduplicate about-section scroll scheduling the same way', async (): Promise<void> => {
|
|
||||||
comp.aboutSectionAnimationFrameId = null;
|
|
||||||
|
|
||||||
let rafCallCount = 0;
|
|
||||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((): number => {
|
|
||||||
rafCallCount++;
|
|
||||||
return rafCallCount;
|
|
||||||
});
|
|
||||||
|
|
||||||
comp.scheduleAboutSectionScrollAnimationUpdate();
|
|
||||||
comp.scheduleAboutSectionScrollAnimationUpdate();
|
|
||||||
comp.scheduleAboutSectionScrollAnimationUpdate();
|
|
||||||
|
|
||||||
expect(rafCallCount).toBe(1);
|
expect(rafCallCount).toBe(1);
|
||||||
});
|
});
|
||||||
|
|||||||
+106
-193
@@ -1,142 +1,103 @@
|
|||||||
import type { AfterViewInit, OnDestroy, OnInit } from '@angular/core';
|
import type { OnDestroy } from '@angular/core';
|
||||||
import {
|
import {
|
||||||
|
afterNextRender,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
|
computed,
|
||||||
DestroyRef,
|
DestroyRef,
|
||||||
inject,
|
inject,
|
||||||
NgZone,
|
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NgOptimizedImage } from '@angular/common';
|
import { filter, interval } from 'rxjs';
|
||||||
import { interval } from 'rxjs';
|
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
import { global } from '../../global';
|
import { global } from '../../global';
|
||||||
import { openPgpKey } from '../generated/openpgp-key.generated';
|
|
||||||
import type {
|
|
||||||
LanguageBioTextEntry,
|
|
||||||
LanguagePortraitHighlight,
|
|
||||||
LanguageProject,
|
|
||||||
} from '../../languages/language.types';
|
|
||||||
import { FooterComponent } from '../footer/footer.component';
|
import { FooterComponent } from '../footer/footer.component';
|
||||||
import { LanguageService } from '../services/language.service';
|
import { LanguageService } from '../services/language.service';
|
||||||
import { IconComponent } from '../shared/icon/icon.component';
|
import { IconComponent } from '../shared/icon/icon.component';
|
||||||
import {
|
import {
|
||||||
arrowDownIcon,
|
arrowDownIcon,
|
||||||
arrowRightIcon,
|
arrowRightIcon,
|
||||||
discordIcon,
|
|
||||||
envelopeIcon,
|
envelopeIcon,
|
||||||
githubIcon,
|
giteaIcon,
|
||||||
linkedinIcon,
|
linkedinIcon,
|
||||||
phoneIcon,
|
phoneIcon,
|
||||||
|
signalIcon,
|
||||||
} from '../shared/icon/icons';
|
} from '../shared/icon/icons';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [FooterComponent, IconComponent, NgOptimizedImage],
|
imports: [FooterComponent, IconComponent],
|
||||||
templateUrl: './home.component.html',
|
templateUrl: './home.component.html',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
export class HomeComponent implements OnDestroy {
|
||||||
|
private static readonly bioTickerIntervalMs = 1250;
|
||||||
|
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly zone = inject(NgZone);
|
|
||||||
private readonly languageService = inject(LanguageService);
|
private readonly languageService = inject(LanguageService);
|
||||||
protected readonly content = this.languageService.content;
|
protected readonly content = this.languageService.content;
|
||||||
|
|
||||||
private scrollAnimationFrameId: number | null = null;
|
private scrollEffectsAnimationFrameId: number | null = null;
|
||||||
private projectEntryAnimationFrameId: number | null = null;
|
private readonly scheduleScrollEffectsUpdate: () => void = (): void => {
|
||||||
private aboutSectionAnimationFrameId: number | null = null;
|
if (this.scrollEffectsAnimationFrameId !== null) return;
|
||||||
private readonly scheduleNameGradientUpdate: () => void = (): void => {
|
this.scrollEffectsAnimationFrameId = window.requestAnimationFrame((): void => {
|
||||||
if (this.scrollAnimationFrameId !== null || typeof window === 'undefined') return;
|
this.scrollEffectsAnimationFrameId = null;
|
||||||
this.scrollAnimationFrameId = window.requestAnimationFrame((): void => {
|
|
||||||
this.scrollAnimationFrameId = null;
|
|
||||||
this.updateNameGradientProgress();
|
this.updateNameGradientProgress();
|
||||||
});
|
|
||||||
};
|
|
||||||
private readonly scheduleProjectEntryRevealUpdate: () => void = (): void => {
|
|
||||||
if (this.projectEntryAnimationFrameId !== null || typeof window === 'undefined') return;
|
|
||||||
this.projectEntryAnimationFrameId = window.requestAnimationFrame((): void => {
|
|
||||||
this.projectEntryAnimationFrameId = null;
|
|
||||||
this.updateProjectEntryRevealProgress();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
private readonly scheduleAboutSectionScrollAnimationUpdate: () => void = (): void => {
|
|
||||||
if (this.aboutSectionAnimationFrameId !== null || typeof window === 'undefined') return;
|
|
||||||
this.aboutSectionAnimationFrameId = window.requestAnimationFrame((): void => {
|
|
||||||
this.aboutSectionAnimationFrameId = null;
|
|
||||||
this.updateAboutSectionScrollProgress();
|
this.updateAboutSectionScrollProgress();
|
||||||
|
this.updateProjectEntryRevealProgress();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
protected readonly global = global;
|
protected readonly global = global;
|
||||||
protected readonly age = this.calculateAge(global.birthdate);
|
protected readonly age = this.calculateAge(global.birthdate);
|
||||||
protected readonly contactMailHref = `mailto:${global.contactMail}`;
|
protected readonly contactMailHref = `mailto:${global.contactMail}`;
|
||||||
protected readonly openPgpKey = openPgpKey;
|
|
||||||
protected readonly arrowRightIcon = arrowRightIcon;
|
protected readonly arrowRightIcon = arrowRightIcon;
|
||||||
protected readonly arrowDownIcon = arrowDownIcon;
|
protected readonly arrowDownIcon = arrowDownIcon;
|
||||||
protected readonly envelopeIcon = envelopeIcon;
|
protected readonly envelopeIcon = envelopeIcon;
|
||||||
protected readonly phoneIcon = phoneIcon;
|
protected readonly phoneIcon = phoneIcon;
|
||||||
protected readonly discordIcon = discordIcon;
|
protected readonly signalIcon = signalIcon;
|
||||||
protected readonly githubIcon = githubIcon;
|
protected readonly giteaIcon = giteaIcon;
|
||||||
protected readonly linkedinIcon = linkedinIcon;
|
protected readonly linkedinIcon = linkedinIcon;
|
||||||
|
|
||||||
private readonly isLongBioShownState = signal(false);
|
protected readonly isLongBioShown = signal(false);
|
||||||
private readonly isLongBioMountedState = signal(false);
|
protected readonly isLongBioMounted = signal(false);
|
||||||
private readonly currentPortraitHighlightIndexState = signal(0);
|
protected readonly currentPortraitHighlightIndex = signal(0);
|
||||||
private readonly isPortraitSwitchingState = signal(false);
|
protected readonly isPortraitSwitching = signal(false);
|
||||||
private readonly currentIndexState = signal(0);
|
protected readonly currentIndex = signal(0);
|
||||||
|
protected readonly isBioTickerPaused = signal(false);
|
||||||
private bioHideTimeoutId: number | null = null;
|
private bioHideTimeoutId: number | null = null;
|
||||||
private portraitSwitchTimeoutId: number | null = null;
|
private portraitSwitchTimeoutId: number | null = null;
|
||||||
private portraitSwitchDoneTimeoutId: number | null = null;
|
private portraitSwitchDoneTimeoutId: number | null = null;
|
||||||
|
|
||||||
protected get isLongBioShown(): boolean {
|
protected readonly portraitHighlights = computed(() => this.content().portraitHighlights);
|
||||||
return this.isLongBioShownState();
|
/** Splits off the last title word so the arrow stays glued to it and never wraps alone. */
|
||||||
}
|
protected readonly projects = computed(() =>
|
||||||
protected set isLongBioShown(value: boolean) {
|
this.content().projects.map((project) => {
|
||||||
this.isLongBioShownState.set(value);
|
const tailStart = project.title.lastIndexOf(' ') + 1;
|
||||||
}
|
return {
|
||||||
protected get isLongBioMounted(): boolean {
|
...project,
|
||||||
return this.isLongBioMountedState();
|
titleHead: project.title.slice(0, tailStart),
|
||||||
}
|
titleTail: project.title.slice(tailStart),
|
||||||
protected set isLongBioMounted(value: boolean) {
|
};
|
||||||
this.isLongBioMountedState.set(value);
|
}),
|
||||||
}
|
);
|
||||||
protected get currentPortraitHighlightIndex(): number {
|
protected readonly currentPortraitHighlight = computed(
|
||||||
return this.currentPortraitHighlightIndexState();
|
() => this.portraitHighlights()[this.currentPortraitHighlightIndex()] ?? null,
|
||||||
}
|
);
|
||||||
protected set currentPortraitHighlightIndex(value: number) {
|
protected readonly currentBioText = computed(
|
||||||
this.currentPortraitHighlightIndexState.set(value);
|
(): string => this.content().bioTextsList[this.currentIndex()] ?? '',
|
||||||
}
|
);
|
||||||
protected get isPortraitSwitching(): boolean {
|
|
||||||
return this.isPortraitSwitchingState();
|
private readonly startBrowserEffects = afterNextRender((): void => {
|
||||||
}
|
this.preloadPortraitHighlights();
|
||||||
protected set isPortraitSwitching(value: boolean) {
|
if (!this.shouldEnableScrollEffects()) return;
|
||||||
this.isPortraitSwitchingState.set(value);
|
this.startBioTicker();
|
||||||
}
|
window.addEventListener('scroll', this.scheduleScrollEffectsUpdate, { passive: true });
|
||||||
protected get currentIndex(): number {
|
window.addEventListener('resize', this.scheduleScrollEffectsUpdate, { passive: true });
|
||||||
return this.currentIndexState();
|
this.scheduleScrollEffectsUpdate();
|
||||||
}
|
});
|
||||||
protected set currentIndex(value: number) {
|
|
||||||
this.currentIndexState.set(value);
|
|
||||||
}
|
|
||||||
protected get portraitHighlights(): LanguagePortraitHighlight[] {
|
|
||||||
return this.content().portraitHighlights;
|
|
||||||
}
|
|
||||||
protected get projects(): LanguageProject[] {
|
|
||||||
return this.content().projects;
|
|
||||||
}
|
|
||||||
protected get currentPortraitHighlight(): LanguagePortraitHighlight | null {
|
|
||||||
return this.portraitHighlights[this.currentPortraitHighlightIndexState()] ?? null;
|
|
||||||
}
|
|
||||||
protected get currentBioEntry(): LanguageBioTextEntry {
|
|
||||||
return (
|
|
||||||
this.content().bioTextsList[this.currentIndexState()] ?? {
|
|
||||||
label: this.content().home.fallbackBioLabel,
|
|
||||||
value: '',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected interpolate(text: string): string {
|
protected interpolate(text: string): string {
|
||||||
return text
|
return text
|
||||||
@@ -144,78 +105,48 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
.replaceAll('{{age}}', String(this.age));
|
.replaceAll('{{age}}', String(this.age));
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.zone.runOutsideAngular((): void => {
|
|
||||||
this.initNameGradientScrollAnimation();
|
|
||||||
});
|
|
||||||
this.zone.runOutsideAngular((): 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;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
|
||||||
this.zone.runOutsideAngular((): void => {
|
|
||||||
this.initProjectEntryRevealObserver();
|
|
||||||
this.initAboutSectionScrollAnimation();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
if (this.bioHideTimeoutId !== null) {
|
if (this.bioHideTimeoutId !== null) {
|
||||||
window.clearTimeout(this.bioHideTimeoutId);
|
window.clearTimeout(this.bioHideTimeoutId);
|
||||||
this.bioHideTimeoutId = null;
|
this.bioHideTimeoutId = null;
|
||||||
}
|
}
|
||||||
this.clearPortraitSwitchTimeouts();
|
this.clearPortraitSwitchTimeouts();
|
||||||
this.destroyNameGradientScrollAnimation();
|
this.destroyScrollEffects();
|
||||||
this.destroyProjectEntryRevealObserver();
|
|
||||||
this.destroyAboutSectionScrollAnimation();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected toggleBio(): void {
|
protected toggleBio(): void {
|
||||||
if (this.isLongBioShown) {
|
if (this.isLongBioShown()) {
|
||||||
this.isLongBioShown = false;
|
this.isLongBioShown.set(false);
|
||||||
if (typeof window !== 'undefined') {
|
if (this.bioHideTimeoutId !== null) window.clearTimeout(this.bioHideTimeoutId);
|
||||||
if (this.bioHideTimeoutId !== null) window.clearTimeout(this.bioHideTimeoutId);
|
this.bioHideTimeoutId = window.setTimeout((): void => {
|
||||||
this.bioHideTimeoutId = window.setTimeout((): void => {
|
this.isLongBioMounted.set(false);
|
||||||
this.isLongBioMounted = false;
|
this.bioHideTimeoutId = null;
|
||||||
this.bioHideTimeoutId = null;
|
}, 300);
|
||||||
}, 300);
|
|
||||||
} else {
|
|
||||||
this.isLongBioMounted = false;
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.bioHideTimeoutId !== null && typeof window !== 'undefined') {
|
if (this.bioHideTimeoutId !== null) {
|
||||||
window.clearTimeout(this.bioHideTimeoutId);
|
window.clearTimeout(this.bioHideTimeoutId);
|
||||||
this.bioHideTimeoutId = null;
|
this.bioHideTimeoutId = null;
|
||||||
}
|
}
|
||||||
this.isLongBioMounted = true;
|
this.isLongBioMounted.set(true);
|
||||||
if (typeof window !== 'undefined') {
|
window.requestAnimationFrame((): void => {
|
||||||
window.requestAnimationFrame((): void => {
|
window.requestAnimationFrame((): void => {
|
||||||
window.requestAnimationFrame((): void => {
|
this.isLongBioShown.set(true);
|
||||||
this.isLongBioShown = true;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
} else {
|
});
|
||||||
this.isLongBioShown = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
protected showNextPortraitHighlight(): void {
|
protected showNextPortraitHighlight(): void {
|
||||||
if (this.portraitHighlights.length <= 1 || this.isPortraitSwitching) return;
|
if (this.portraitHighlights().length <= 1 || this.isPortraitSwitching()) return;
|
||||||
this.isPortraitSwitching = true;
|
this.isPortraitSwitching.set(true);
|
||||||
this.portraitSwitchTimeoutId = window.setTimeout((): void => {
|
this.portraitSwitchTimeoutId = window.setTimeout((): void => {
|
||||||
this.currentPortraitHighlightIndex =
|
this.currentPortraitHighlightIndex.update(
|
||||||
(this.currentPortraitHighlightIndex + 1) % this.portraitHighlights.length;
|
(index: number): number => (index + 1) % this.portraitHighlights().length,
|
||||||
|
);
|
||||||
this.portraitSwitchTimeoutId = null;
|
this.portraitSwitchTimeoutId = null;
|
||||||
}, 125);
|
}, 125);
|
||||||
this.portraitSwitchDoneTimeoutId = window.setTimeout((): void => {
|
this.portraitSwitchDoneTimeoutId = window.setTimeout((): void => {
|
||||||
this.isPortraitSwitching = false;
|
this.isPortraitSwitching.set(false);
|
||||||
this.portraitSwitchDoneTimeoutId = null;
|
this.portraitSwitchDoneTimeoutId = null;
|
||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
@@ -239,62 +170,47 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
return !prefersReducedMotion;
|
return !prefersReducedMotion;
|
||||||
}
|
}
|
||||||
|
|
||||||
private initNameGradientScrollAnimation(): void {
|
/** Loads the other highlight images at low priority so a click swaps them without a visible delay. */
|
||||||
if (!this.shouldEnableScrollEffects()) return;
|
private preloadPortraitHighlights(): void {
|
||||||
window.addEventListener('scroll', this.scheduleNameGradientUpdate, { passive: true });
|
const current = this.currentPortraitHighlight();
|
||||||
window.addEventListener('resize', this.scheduleNameGradientUpdate, { passive: true });
|
for (const highlight of this.portraitHighlights()) {
|
||||||
this.scheduleNameGradientUpdate();
|
if (highlight === current) continue;
|
||||||
}
|
const image = new Image();
|
||||||
private initProjectEntryRevealObserver(): void {
|
image.fetchPriority = 'low';
|
||||||
if (!this.shouldEnableScrollEffects()) return;
|
image.sizes = highlight.imageSizes;
|
||||||
window.addEventListener('scroll', this.scheduleProjectEntryRevealUpdate, { passive: true });
|
image.srcset = highlight.imageSrcset;
|
||||||
window.addEventListener('resize', this.scheduleProjectEntryRevealUpdate, { passive: true });
|
image.src = highlight.image;
|
||||||
this.scheduleProjectEntryRevealUpdate();
|
|
||||||
}
|
|
||||||
private destroyProjectEntryRevealObserver(): void {
|
|
||||||
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
|
||||||
window.removeEventListener('scroll', this.scheduleProjectEntryRevealUpdate);
|
|
||||||
window.removeEventListener('resize', this.scheduleProjectEntryRevealUpdate);
|
|
||||||
if (this.projectEntryAnimationFrameId !== null) {
|
|
||||||
window.cancelAnimationFrame(this.projectEntryAnimationFrameId);
|
|
||||||
this.projectEntryAnimationFrameId = null;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startBioTicker(): void {
|
||||||
|
interval(HomeComponent.bioTickerIntervalMs)
|
||||||
|
.pipe(
|
||||||
|
filter((): boolean => !this.isBioTickerPaused()),
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
)
|
||||||
|
.subscribe((): void => {
|
||||||
|
const length = this.content().bioTextsList.length;
|
||||||
|
this.currentIndex.update((index: number): number =>
|
||||||
|
length > 0 ? (index + 1) % length : 0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private destroyScrollEffects(): void {
|
||||||
|
window.removeEventListener('scroll', this.scheduleScrollEffectsUpdate);
|
||||||
|
window.removeEventListener('resize', this.scheduleScrollEffectsUpdate);
|
||||||
|
if (this.scrollEffectsAnimationFrameId !== null) {
|
||||||
|
window.cancelAnimationFrame(this.scrollEffectsAnimationFrameId);
|
||||||
|
this.scrollEffectsAnimationFrameId = null;
|
||||||
|
}
|
||||||
|
document.documentElement.style.removeProperty('--name-pride-progress');
|
||||||
|
document.getElementById('about')?.style.removeProperty('--about-scroll-progress');
|
||||||
document.querySelectorAll<HTMLElement>('.project-entry').forEach((entry: HTMLElement): void => {
|
document.querySelectorAll<HTMLElement>('.project-entry').forEach((entry: HTMLElement): void => {
|
||||||
entry.style.removeProperty('--project-entry-progress');
|
entry.style.removeProperty('--project-entry-progress');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
private initAboutSectionScrollAnimation(): void {
|
|
||||||
if (!this.shouldEnableScrollEffects()) return;
|
|
||||||
window.addEventListener('scroll', this.scheduleAboutSectionScrollAnimationUpdate, {
|
|
||||||
passive: true,
|
|
||||||
});
|
|
||||||
window.addEventListener('resize', this.scheduleAboutSectionScrollAnimationUpdate, {
|
|
||||||
passive: true,
|
|
||||||
});
|
|
||||||
this.scheduleAboutSectionScrollAnimationUpdate();
|
|
||||||
}
|
|
||||||
private destroyAboutSectionScrollAnimation(): void {
|
|
||||||
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
|
||||||
window.removeEventListener('scroll', this.scheduleAboutSectionScrollAnimationUpdate);
|
|
||||||
window.removeEventListener('resize', this.scheduleAboutSectionScrollAnimationUpdate);
|
|
||||||
if (this.aboutSectionAnimationFrameId !== null) {
|
|
||||||
window.cancelAnimationFrame(this.aboutSectionAnimationFrameId);
|
|
||||||
this.aboutSectionAnimationFrameId = null;
|
|
||||||
}
|
|
||||||
document.getElementById('about')?.style.removeProperty('--about-scroll-progress');
|
|
||||||
}
|
|
||||||
private destroyNameGradientScrollAnimation(): void {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
window.removeEventListener('scroll', this.scheduleNameGradientUpdate);
|
|
||||||
window.removeEventListener('resize', this.scheduleNameGradientUpdate);
|
|
||||||
if (this.scrollAnimationFrameId !== null) {
|
|
||||||
window.cancelAnimationFrame(this.scrollAnimationFrameId);
|
|
||||||
this.scrollAnimationFrameId = null;
|
|
||||||
}
|
|
||||||
document.documentElement.style.removeProperty('--name-pride-progress');
|
|
||||||
}
|
|
||||||
private clearPortraitSwitchTimeouts(): void {
|
private clearPortraitSwitchTimeouts(): void {
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
if (this.portraitSwitchTimeoutId !== null) {
|
if (this.portraitSwitchTimeoutId !== null) {
|
||||||
window.clearTimeout(this.portraitSwitchTimeoutId);
|
window.clearTimeout(this.portraitSwitchTimeoutId);
|
||||||
this.portraitSwitchTimeoutId = null;
|
this.portraitSwitchTimeoutId = null;
|
||||||
@@ -305,7 +221,6 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
private updateNameGradientProgress(): void {
|
private updateNameGradientProgress(): void {
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
const root: HTMLElement = document.documentElement;
|
const root: HTMLElement = document.documentElement;
|
||||||
const portfolioEyebrow: HTMLElement | null = document.getElementById('portfolio-eyebrow');
|
const portfolioEyebrow: HTMLElement | null = document.getElementById('portfolio-eyebrow');
|
||||||
const portfolioTriggerY: number =
|
const portfolioTriggerY: number =
|
||||||
@@ -326,7 +241,6 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
root.style.setProperty('--name-pride-progress', progress.toFixed(4));
|
root.style.setProperty('--name-pride-progress', progress.toFixed(4));
|
||||||
}
|
}
|
||||||
private updateAboutSectionScrollProgress(): void {
|
private updateAboutSectionScrollProgress(): void {
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
const aboutSection: HTMLElement | null = document.getElementById('about');
|
const aboutSection: HTMLElement | null = document.getElementById('about');
|
||||||
if (aboutSection === null) return;
|
if (aboutSection === null) return;
|
||||||
const sectionRect: DOMRect = aboutSection.getBoundingClientRect();
|
const sectionRect: DOMRect = aboutSection.getBoundingClientRect();
|
||||||
@@ -339,7 +253,6 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
aboutSection.style.setProperty('--about-scroll-progress', clampedProgress.toFixed(4));
|
aboutSection.style.setProperty('--about-scroll-progress', clampedProgress.toFixed(4));
|
||||||
}
|
}
|
||||||
private updateProjectEntryRevealProgress(): void {
|
private updateProjectEntryRevealProgress(): void {
|
||||||
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
|
||||||
const projectEntries: NodeListOf<HTMLElement> = document.querySelectorAll('.project-entry');
|
const projectEntries: NodeListOf<HTMLElement> = document.querySelectorAll('.project-entry');
|
||||||
const viewportHeight: number = window.innerHeight || 1;
|
const viewportHeight: number = window.innerHeight || 1;
|
||||||
const startY: number = viewportHeight * 0.98;
|
const startY: number = viewportHeight * 0.98;
|
||||||
|
|||||||
Reference in new issue
Block a user