feat: added languages

This commit is contained in:
2026-04-28 13:52:38 +02:00
parent d5d995c50d
commit 735b5fc206
19 changed files with 1300 additions and 592 deletions
+39 -71
View File
@@ -2,53 +2,44 @@
<section class="hero-section panel">
<div class="hero-copy">
<div class="hero-copy-main">
<p id="portfolio-eyebrow" class="eyebrow">Portfolio</p>
<p id="portfolio-eyebrow" class="eyebrow">{{ content().home.portfolioEyebrow }}</p>
<h1>
<span class="hero-name-part" [attr.data-name]="global.firstname">{{ global.firstname }}</span>
<span class="hero-name-part" [attr.data-name]="global.lastname">{{ global.lastname }}</span>
</h1>
<p class="hero-intro">
Building reliable systems, clean interfaces, and practical digital solutions that actually hold up in production.
</p>
<p class="hero-intro">{{ content().home.heroIntro }}</p>
</div>
<div class="hero-actions">
<div class="hero-contact-block">
<p class="hero-contact-title">Contact me</p>
<p class="hero-contact-title">{{ content().home.contactTitle }}</p>
<div id="contact-links" class="contact-area hero-contact-area">
<a [href]="contactSafeMail" aria-label="Send mail">
<fa-icon class="fab" [icon]="faEnvelope" size="lg"></fa-icon>
</a>
<a href="tel:{{ global.hrefContactPhone }}" aria-label="Call phone number">
<fa-icon class="fab" [icon]="faPhone" size="lg"></fa-icon>
</a>
<a href="https://discord.com/users/860206216893693973" aria-label="Discord profile">
<fa-icon class="fab" [icon]="faDiscord" size="lg"></fa-icon>
</a>
<a href="https://github.com/fraujulian" aria-label="GitHub profile">
<fa-icon class="fab" [icon]="faGithub" size="lg"></fa-icon>
</a>
<a href="https://www.linkedin.com/in/julian-lechner-98b377356/" aria-label="LinkedIn profile">
<fa-icon class="fab" [icon]="faLinkedin" size="lg"></fa-icon>
</a>
<a [href]="contactMailHref" aria-label="Send mail"><fa-icon class="fab" [icon]="faEnvelope" size="lg"></fa-icon></a>
<a href="tel:{{ global.hrefContactPhone }}" aria-label="Call phone number"
><fa-icon class="fab" [icon]="faPhone" size="lg"></fa-icon
></a>
<a href="https://discord.com/users/860206216893693973" aria-label="Discord profile"
><fa-icon class="fab" [icon]="faDiscord" size="lg"></fa-icon
></a>
<a href="https://github.com/fraujulian" aria-label="GitHub profile"
><fa-icon class="fab" [icon]="faGithub" size="lg"></fa-icon
></a>
<a href="https://www.linkedin.com/in/julian-lechner-98b377356/" aria-label="LinkedIn profile"
><fa-icon class="fab" [icon]="faLinkedin" size="lg"></fa-icon
></a>
</div>
</div>
<div class="hero-action-links">
<button class="action-link" type="button" (click)="scrollToProjectsSection()">View projects</button>
<button class="action-link" type="button" (click)="scrollToAboutSection()">About me</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>
</div>
</div>
<div class="hero-meta">
<div>
<span class="meta-label">{{ currentBioEntry.label }}</span>
<strong>{{ currentBioEntry.value }}</strong>
<span class="meta-label">{{ currentBioEntry.label }}</span
><strong>{{ currentBioEntry.value }}</strong>
</div>
</div>
</div>
@@ -60,8 +51,8 @@
type="button"
class="portrait-highlight-button"
(click)="showNextPortraitHighlight()"
[attr.aria-label]="'Show next highlight image'"
[title]="portraitHighlights.length > 1 ? 'Show next image' : 'Single image'"
[attr.aria-label]="content().home.nextHighlightAriaLabel"
[title]="portraitHighlights.length > 1 ? content().home.nextHighlightTitle : content().home.singleHighlightTitle"
>
<div class="portrait-media" [class.portrait-media-switching]="isPortraitSwitching">
<img
@@ -74,10 +65,10 @@
disableOptimizedSrcset
priority
decoding="async"
[alt]="currentPortraitHighlight.text + ' Image'"
[alt]="currentPortraitHighlight.text + ' ' + content().home.portraitAltSuffix"
/>
@if (currentPortraitHighlightIndex === 0) {
<span class="portrait-click-hint">Click me!</span>
<span class="portrait-click-hint">{{ content().home.clickHint }}</span>
}
<span class="portrait-caption">{{ currentPortraitHighlight.text }}</span>
</div>
@@ -89,47 +80,27 @@
<section id="about" class="story-section panel about-scroll-anim-enabled">
<div class="section-heading">
<p class="eyebrow">About me</p>
<p class="eyebrow">{{ content().home.aboutEyebrow }}</p>
</div>
<div class="story-grid">
<div class="story-copy">
<p>
My name is {{ global.firstname }}, though most people call me Julie. Im {{ age }} and based in Austria. A large part of my day
happens on the move — usually on trains, going between meetings, offices, and time with my family.
</p>
<p>
I care deeply about digital security and privacy. I design systems with minimal exposure, sensible defaults, and a strong focus on
data protection. Anonymity, secure communication, and reducing unnecessary data collection arent extras for me — theyre baseline
requirements for my personal infrastructure.
</p>
<p>
I work with modern web technologies and on business-critical systems. I dont care much for unnecessary complexity — I focus on
clear, stable solutions that actually hold up in day-to-day use and deliver real value.
</p>
<p>{{ interpolate(content().home.aboutParagraphs[0]) }}</p>
<p>{{ content().home.aboutParagraphs[1] }}</p>
<p>{{ content().home.aboutParagraphs[2] }}</p>
</div>
<div class="story-detail">
<button class="detail-toggle" type="button" (click)="toggleBio()">
<span>{{ isLongBioShown ? 'Hide extended profile' : 'Read extended profile' }}</span>
<fa-icon [icon]="isLongBioShown ? faArrowDown : faArrowRight"></fa-icon>
<span>{{ isLongBioShown ? content().home.detailToggleClose : content().home.detailToggleOpen }}</span
><fa-icon [icon]="isLongBioShown ? faArrowDown : faArrowRight"></fa-icon>
</button>
@if (isLongBioMounted) {
<div class="detail-panel" [class.detail-panel-enter]="isLongBioShown" [class.detail-panel-leave]="!isLongBioShown">
<p>
What really interests me is modern code — well-structured, maintainable, and built with performance in mind. No unnecessary
bloat, just clean, readable solutions that reliably work under real-world conditions… in theory 😁
</p>
<p>
Outside of software, my main hobby is scuba diving. The silence underwater and the focus on the present moment keep pulling me
back into Austrias dark, cold lakes.
</p>
<p>
Right now, I mainly work for SobIT GmbH in Vienna, alongside freelance projects for companies, organizations, and private
clients. No gimmicks — just software that does what its supposed to do, built properly.
</p>
<button class="inline-link inline-link-button" type="button" (click)="scrollToContactLinks()">Get in touch</button>
<p>{{ content().home.detailParagraphs[0] }}</p>
<p>{{ content().home.detailParagraphs[1] }}</p>
<p>{{ content().home.detailParagraphs[2] }}</p>
<button class="inline-link inline-link-button" type="button" (click)="scrollToContactLinks()">
{{ content().home.getInTouch }}
</button>
</div>
}
</div>
@@ -138,9 +109,8 @@
<section id="projects" class="projects-section">
<div class="section-heading section-heading-centered">
<p class="eyebrow">current Jobs & Projects</p>
<p class="eyebrow">{{ content().home.projectsEyebrow }}</p>
</div>
<div class="project-list">
@for (project of projects; track project.link + '-' + $index; let i = $index) {
<a
@@ -149,7 +119,7 @@
[class.project-entry-from-right]="i % 2 === 0"
[class.project-entry-from-left]="i % 2 === 1"
[href]="project.link"
[attr.aria-label]="'Open project ' + project.title"
[attr.aria-label]="content().home.openProjectPrefix + ' ' + project.title"
[attr.data-project-index]="i"
>
@if (project.icon) {
@@ -165,19 +135,17 @@
[alt]="project.title + ' icon'"
/>
}
<div class="project-copy">
<div class="project-header">
<div class="project-title-wrap">
<span class="project-link">{{ project.title }}</span>
</div>
<div class="project-languages" aria-label="Project languages">
<div class="project-languages" [attr.aria-label]="content().home.projectLanguagesAriaLabel">
@for (language of project.skills; track language + '-' + $index) {
<span class="project-language-chip">{{ language }}</span>
}
</div>
</div>
<p class="project-description">{{ project.description }}</p>
</div>
</a>
+19 -15
View File
@@ -8,7 +8,11 @@ import type { Subscription } from 'rxjs';
import { HomeComponent } from './home.component';
import { global } from '../../global';
import type { BioTextEntry, PortraitHighlight } from '../../global';
import { enLanguage } from '../../languages/en';
import type {
LanguageBioTextEntry,
LanguagePortraitHighlight,
} from '../../languages/language.types';
describe('HomeComponent', (): void => {
let fixture: ComponentFixture<HomeComponent>;
@@ -34,8 +38,8 @@ describe('HomeComponent', (): void => {
scrollToAboutSection(): void;
scrollToProjectsSection(): void;
scrollToContactLinks(): void;
currentBioEntry: BioTextEntry;
currentPortraitHighlight: PortraitHighlight | null;
currentBioEntry: LanguageBioTextEntry;
currentPortraitHighlight: LanguagePortraitHighlight | null;
}
let comp: ComponentAccess;
@@ -93,12 +97,12 @@ describe('HomeComponent', (): void => {
describe('currentBioEntry getter', (): void => {
it('should return the first bioTextsList entry at index 0', (): void => {
comp.currentIndex = 0;
expect(comp.currentBioEntry).toEqual(global.bioTextsList[0]);
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[0]);
});
it('should return the correct entry when index changes', (): void => {
comp.currentIndex = 3;
expect(comp.currentBioEntry).toEqual(global.bioTextsList[3]);
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[3]);
});
it('should return the fallback entry when index is out of range', (): void => {
@@ -119,12 +123,12 @@ describe('HomeComponent', (): void => {
});
it('should correctly wrap from the last entry back to 0 using modulo', (): void => {
const last = global.bioTextsList.length - 1;
const last = enLanguage.bioTextsList.length - 1;
comp.currentIndex = last;
expect(comp.currentBioEntry).toEqual(global.bioTextsList[last]);
comp.currentIndex = (last + 1) % global.bioTextsList.length;
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[last]);
comp.currentIndex = (last + 1) % enLanguage.bioTextsList.length;
expect(comp.currentIndex).toBe(0);
expect(comp.currentBioEntry).toEqual(global.bioTextsList[0]);
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[0]);
});
});
@@ -133,7 +137,7 @@ describe('HomeComponent', (): void => {
describe('currentPortraitHighlight getter', (): void => {
it('should return the highlight at the current index', (): void => {
comp.currentPortraitHighlightIndex = 0;
expect(comp.currentPortraitHighlight).toEqual(global.portraitHighlights[0]);
expect(comp.currentPortraitHighlight).toEqual(enLanguage.portraitHighlights[0]);
});
it('should return null when index is out of range', (): void => {
@@ -159,7 +163,7 @@ describe('HomeComponent', (): void => {
});
it('should not call setTimeout when only one portrait exists', (): void => {
const highlights = (comp as unknown as { portraitHighlights: PortraitHighlight[] })
const highlights = (comp as unknown as { portraitHighlights: LanguagePortraitHighlight[] })
.portraitHighlights;
const saved = [...highlights]; // snapshot before mutation
highlights.splice(1, saved.length - 1); // reduce to a single entry
@@ -193,7 +197,7 @@ describe('HomeComponent', (): void => {
}));
it('should wrap portrait index from the last position back to 0', fakeAsync((): void => {
comp.currentPortraitHighlightIndex = global.portraitHighlights.length - 1;
comp.currentPortraitHighlightIndex = enLanguage.portraitHighlights.length - 1;
const zone = TestBed.inject(NgZone);
zone.runOutsideAngular((): void => {
comp.showNextPortraitHighlight();
@@ -400,15 +404,15 @@ describe('HomeComponent', (): void => {
expect(el.querySelector('#projects')).not.toBeNull();
});
it('should render one project entry per global.projects entry', (): void => {
it('should render one project entry per localized project entry', (): void => {
const el = fixture.nativeElement as HTMLElement;
const entries = el.querySelectorAll('.project-entry');
expect(entries.length).toBe(global.projects.length);
expect(entries.length).toBe(enLanguage.projects.length);
});
it('should render all project titles', (): void => {
const el = fixture.nativeElement as HTMLElement;
global.projects.forEach((project): void => {
enLanguage.projects.forEach((project): void => {
expect(el.textContent).toContain(project.title);
});
});
+65 -144
View File
@@ -1,67 +1,64 @@
import type { AfterViewInit, OnDestroy, OnInit } from '@angular/core';
import { Component, inject, NgZone } from '@angular/core';
import { ChangeDetectionStrategy, Component, 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';
import type { IconDefinition } from '@fortawesome/angular-fontawesome';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { DomSanitizer } from '@angular/platform-browser';
import type { SafeUrl } from '@angular/platform-browser';
import type { Subscription } from 'rxjs';
import { interval } from 'rxjs';
import type { BioTextEntry, Global, PortraitHighlight, PortfolioProject } from '../../global';
import { global } from '../../global';
import type {
LanguageBioTextEntry,
LanguagePortraitHighlight,
LanguageProject,
} from '../../languages/language.types';
import { FooterComponent } from '../footer/footer.component';
import { LanguageService } from '../services/language.service';
@Component({
selector: 'app-home',
standalone: true,
imports: [FooterComponent, FaIconComponent, NgOptimizedImage],
templateUrl: './home.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly sanitizer = inject(DomSanitizer);
private readonly zone = inject(NgZone);
private readonly languageService = inject(LanguageService);
protected readonly content = this.languageService.content;
private scrollAnimationFrameId: number | null = null;
private projectEntryAnimationFrameId: number | null = null;
private aboutSectionAnimationFrameId: number | null = null;
private readonly scheduleNameGradientUpdate: () => void = (): void => {
if (this.scrollAnimationFrameId !== null || typeof window === 'undefined') {
return;
}
if (this.scrollAnimationFrameId !== null || typeof window === 'undefined') return;
this.scrollAnimationFrameId = window.requestAnimationFrame((): void => {
this.scrollAnimationFrameId = null;
this.updateNameGradientProgress();
});
};
private readonly scheduleProjectEntryRevealUpdate: () => void = (): void => {
if (this.projectEntryAnimationFrameId !== null || typeof window === 'undefined') {
return;
}
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;
}
if (this.aboutSectionAnimationFrameId !== null || typeof window === 'undefined') return;
this.aboutSectionAnimationFrameId = window.requestAnimationFrame((): void => {
this.aboutSectionAnimationFrameId = null;
this.updateAboutSectionScrollProgress();
});
};
protected readonly global: Global = global;
protected readonly age: number = this.calculateAge(global.birthdate);
protected isLongBioShown: boolean = false;
protected isLongBioMounted: boolean = false;
protected readonly global = global;
protected readonly age = this.calculateAge(global.birthdate);
protected readonly contactMailHref = `mailto:${global.contactMail}`;
protected isLongBioShown = false;
protected isLongBioMounted = false;
protected readonly faArrowRight: IconDefinition = faArrowRight;
protected readonly faArrowDown: IconDefinition = faArrowDown;
protected readonly faEnvelope: IconDefinition = faEnvelope;
@@ -70,28 +67,44 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
readonly faGithub: IconDefinition = faGithub;
readonly faLinkedin: IconDefinition = faLinkedin;
protected readonly contactSafeMail: SafeUrl;
protected readonly portraitHighlights: PortraitHighlight[] = global.portraitHighlights;
protected currentPortraitHighlightIndex: number = 0;
protected isPortraitSwitching: boolean = false;
protected currentPortraitHighlightIndex = 0;
protected isPortraitSwitching = false;
protected currentIndex = 0;
protected readonly projects: PortfolioProject[] = global.projects;
protected readonly fallbackBioEntry: BioTextEntry = { label: 'my stack', value: '' };
protected readonly fallbackBioEntry: LanguageBioTextEntry = { label: 'my stack', value: '' };
private sub!: Subscription;
private bioHideTimeoutId: number | null = null;
constructor() {
this.contactSafeMail = this.sanitizer.bypassSecurityTrustUrl(`mailto:${global.contactMail}`);
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.currentPortraitHighlightIndex] ?? null;
}
protected get currentBioEntry(): LanguageBioTextEntry {
return (
this.content().bioTextsList[this.currentIndex] ?? {
label: this.content().home.fallbackBioLabel,
value: '',
}
);
}
protected interpolate(text: string): string {
return text
.replaceAll('{{firstname}}', this.global.firstname)
.replaceAll('{{age}}', String(this.age));
}
ngOnInit(): void {
this.initNameGradientScrollAnimation();
this.zone.runOutsideAngular((): void => {
this.sub = interval(1000).subscribe((): void => {
this.zone.run((): void => {
this.currentIndex = (this.currentIndex + 1) % this.global.bioTextsList.length;
const len = this.content().bioTextsList.length;
this.currentIndex = len > 0 ? (this.currentIndex + 1) % len : 0;
});
});
});
@@ -101,7 +114,6 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
this.initProjectEntryRevealObserver();
this.initAboutSectionScrollAnimation();
}
ngOnDestroy(): void {
this.sub.unsubscribe();
if (this.bioHideTimeoutId !== null) {
@@ -117,9 +129,7 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
if (this.isLongBioShown) {
this.isLongBioShown = 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.isLongBioMounted = false;
this.bioHideTimeoutId = null;
@@ -129,12 +139,10 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
}
return;
}
if (this.bioHideTimeoutId !== null && typeof window !== 'undefined') {
window.clearTimeout(this.bioHideTimeoutId);
this.bioHideTimeoutId = null;
}
this.isLongBioMounted = true;
if (typeof window !== 'undefined') {
window.requestAnimationFrame((): void => {
@@ -146,88 +154,52 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
this.isLongBioShown = true;
}
}
protected get currentPortraitHighlight(): PortraitHighlight | null {
return this.portraitHighlights[this.currentPortraitHighlightIndex] ?? null;
}
protected get currentBioEntry(): BioTextEntry {
return this.global.bioTextsList[this.currentIndex] ?? this.fallbackBioEntry;
}
protected showNextPortraitHighlight(): void {
if (this.portraitHighlights.length <= 1 || this.isPortraitSwitching) {
return;
}
if (this.portraitHighlights.length <= 1 || this.isPortraitSwitching) return;
this.isPortraitSwitching = true;
setTimeout((): void => {
this.currentPortraitHighlightIndex =
(this.currentPortraitHighlightIndex + 1) % this.portraitHighlights.length;
}, 125);
setTimeout((): void => {
this.isPortraitSwitching = false;
}, 250);
}
protected scrollToAboutSection(): void {
this.scrollToElementById('about', this.getResponsiveScrollOffset(0.035));
}
protected scrollToProjectsSection(): void {
this.scrollToElementById('projects', this.getResponsiveScrollOffset(0.035));
}
protected scrollToContactLinks(): void {
this.scrollToElementById('contact-links', this.getResponsiveScrollOffset(0.05));
}
private initNameGradientScrollAnimation(): void {
if (typeof window === 'undefined') {
return;
}
if (typeof window === 'undefined') 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 (typeof window === 'undefined') return;
window.addEventListener('scroll', this.scheduleProjectEntryRevealUpdate, { passive: true });
window.addEventListener('resize', this.scheduleProjectEntryRevealUpdate, { passive: true });
this.scheduleProjectEntryRevealUpdate();
}
private destroyProjectEntryRevealObserver(): void {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
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;
}
const projectEntries: NodeListOf<HTMLElement> = document.querySelectorAll('.project-entry');
projectEntries.forEach((entry: HTMLElement): void => {
document.querySelectorAll<HTMLElement>('.project-entry').forEach((entry: HTMLElement): void => {
entry.style.removeProperty('--project-entry-progress');
});
}
private initAboutSectionScrollAnimation(): void {
if (typeof window === 'undefined') {
return;
}
if (typeof window === 'undefined') return;
window.addEventListener('scroll', this.scheduleAboutSectionScrollAnimationUpdate, {
passive: true,
});
@@ -236,45 +208,28 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
});
this.scheduleAboutSectionScrollAnimationUpdate();
}
private destroyAboutSectionScrollAnimation(): void {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
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;
}
const aboutSection: HTMLElement | null = document.getElementById('about');
aboutSection?.style.removeProperty('--about-scroll-progress');
document.getElementById('about')?.style.removeProperty('--about-scroll-progress');
}
private destroyNameGradientScrollAnimation(): void {
if (typeof window === 'undefined') {
return;
}
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 updateNameGradientProgress(): void {
if (typeof window === 'undefined') {
return;
}
if (typeof window === 'undefined') return;
const root: HTMLElement = document.documentElement;
const portfolioEyebrow: HTMLElement | null = document.getElementById('portfolio-eyebrow');
const portfolioTriggerY: number =
@@ -292,20 +247,12 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
window.scrollY > 0
? Math.max(acceleratedProgress, isMobileViewport ? 0.18 : 0.05)
: acceleratedProgress;
root.style.setProperty('--name-pride-progress', progress.toFixed(4));
}
private updateAboutSectionScrollProgress(): void {
if (typeof window === 'undefined') {
return;
}
if (typeof window === 'undefined') return;
const aboutSection: HTMLElement | null = document.getElementById('about');
if (aboutSection === null) {
return;
}
if (aboutSection === null) return;
const sectionRect: DOMRect = aboutSection.getBoundingClientRect();
const viewportHeight: number = window.innerHeight || 1;
const startY: number = viewportHeight * 0.96;
@@ -313,21 +260,15 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
const totalDistance: number = Math.max(startY - endY, 1);
const rawProgress: number = (startY - sectionRect.top) / totalDistance;
const clampedProgress: number = Math.min(Math.max(rawProgress, 0), 1);
aboutSection.style.setProperty('--about-scroll-progress', clampedProgress.toFixed(4));
}
private updateProjectEntryRevealProgress(): void {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
if (typeof window === 'undefined' || typeof document === 'undefined') return;
const projectEntries: NodeListOf<HTMLElement> = document.querySelectorAll('.project-entry');
const viewportHeight: number = window.innerHeight || 1;
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;
@@ -335,7 +276,6 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
entry.style.setProperty('--project-entry-progress', clampedProgress.toFixed(4));
});
}
private calculateAge(birthDateString: string): number {
const birthDate: Date = new Date(birthDateString);
const now: Date = new Date();
@@ -343,40 +283,21 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
const hasBirthdayPassedThisYear: boolean =
now.getMonth() > birthDate.getMonth() ||
(now.getMonth() === birthDate.getMonth() && now.getDate() >= birthDate.getDate());
if (!hasBirthdayPassedThisYear) {
age -= 1;
}
if (!hasBirthdayPassedThisYear) age -= 1;
return age;
}
private scrollToElementById(elementId: string, offsetPx: number = 0): void {
if (typeof document === 'undefined' || typeof window === 'undefined') {
return;
}
if (typeof document === 'undefined' || typeof window === 'undefined') return;
const element: HTMLElement | null = document.getElementById(elementId);
if (element === null) {
return;
}
if (element === null) return;
const targetTop: number = Math.max(
element.getBoundingClientRect().top + window.scrollY - offsetPx,
0,
);
window.scrollTo({
top: targetTop,
behavior: 'smooth',
});
window.scrollTo({ top: targetTop, behavior: 'smooth' });
}
private getResponsiveScrollOffset(viewportRatio: number = 0.05): number {
if (typeof window === 'undefined') {
return 0;
}
if (typeof window === 'undefined') return 0;
const minOffsetPx: number = 20;
const maxOffsetPx: number = 48;
const responsiveOffsetPx: number = window.innerHeight * viewportRatio;