diff --git a/AGENTS.md b/AGENTS.md index 85a7924..7f99b8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This repository is an Angular 21 SSR application. Main app code lives in `src/`, with standalone components under `src/app/` such as `home/`, `footer/`, and -`imprint/`. Shared constants are kept in `src/global.fields.ts`. Component tests +`imprint/`. Shared constants are kept in `src/global.ts`. Component tests sit next to their source files as `*.spec.ts`. Automation and project maintenance scripts live in `scripts/`. Generated build output goes to `dist/` and should not be edited manually. @@ -42,7 +42,7 @@ tests for changed behavior. ## Commit & Pull Request Guidelines -Recent history mixes short imperative messages (`Update src/global.fields.ts`) +Recent history mixes short imperative messages (`Update src/global.ts`) with lightweight conventional commits (`feat: pre-render routes and restore scroll`). Prefer concise, imperative commit subjects and include a scope or prefix when it adds clarity. PRs should diff --git a/src/app/footer/footer.component.ts b/src/app/footer/footer.component.ts index 493f3cf..52a3582 100644 --- a/src/app/footer/footer.component.ts +++ b/src/app/footer/footer.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { RouterLink } from '@angular/router'; -import type { Global } from '../../global.fields'; -import { global } from '../../global.fields'; +import type { Global } from '../../global'; +import { global } from '../../global'; @Component({ selector: 'app-footer', diff --git a/src/app/home/home.component.html b/src/app/home/home.component.html index cd84239..2374d6c 100644 --- a/src/app/home/home.component.html +++ b/src/app/home/home.component.html @@ -2,10 +2,10 @@
-

Portfolio

+

Portfolio

- {{ global.firstname }} - {{ global.lastname }} + {{ global.firstname }} + {{ global.lastname }}

Full-stack development, thoughtful interfaces, and tailored digital products for companies, associations, and private clients. @@ -43,8 +43,8 @@

- my stack - {{ global.bioTextsList[currentIndex] }} + {{ currentBioEntry.label }} + {{ currentBioEntry.value }}
@@ -81,7 +81,6 @@

About me

-

Direct communication, solid implementation, and software that stays understandable.

@@ -120,9 +119,8 @@
-
-

Selected Projects

-

A compact look at products, platforms, and systems I have helped shape.

+
+

Projects

diff --git a/src/app/home/home.component.ts b/src/app/home/home.component.ts index 23f0f80..13e3488 100644 --- a/src/app/home/home.component.ts +++ b/src/app/home/home.component.ts @@ -9,8 +9,8 @@ import type { SafeUrl } from '@angular/platform-browser'; import type { Subscription } from 'rxjs'; import { interval } from 'rxjs'; -import type { Global, PortraitHighlight, PortfolioProject } from '../../global.fields'; -import { global } from '../../global.fields'; +import type { BioTextEntry, Global, PortraitHighlight, PortfolioProject } from '../../global'; +import { global } from '../../global'; import { FooterComponent } from '../footer/footer.component'; @Component({ @@ -23,6 +23,31 @@ export class HomeComponent implements OnInit, OnDestroy { private readonly sanitizer = inject(DomSanitizer); private readonly zone = inject(NgZone); private preloadedImages: HTMLImageElement[] = []; + private tallestPortraitAspectRatio: number | null = null; + private lockedHeroSectionMinHeight: number = 0; + private scrollAnimationFrameId: number | null = null; + private heroHeightAnimationFrameId: number | null = null; + private readonly scheduleNameGradientUpdate: () => void = (): void => { + if (this.scrollAnimationFrameId !== null || typeof window === 'undefined') { + return; + } + + this.scrollAnimationFrameId = window.requestAnimationFrame((): void => { + this.scrollAnimationFrameId = null; + this.updateNameGradientProgress(); + }); + }; + private readonly scheduleHeroSectionHeightUpdate: () => void = (): void => { + if (this.heroHeightAnimationFrameId !== null || typeof window === 'undefined') { + return; + } + + this.heroHeightAnimationFrameId = window.requestAnimationFrame((): void => { + this.heroHeightAnimationFrameId = null; + this.lockedHeroSectionMinHeight = 0; + this.updateHeroSectionMinHeight(); + }); + }; protected readonly global: Global = global; protected readonly age: number | string = this.calculateAge(global.birthdate); @@ -43,6 +68,7 @@ export class HomeComponent implements OnInit, OnDestroy { protected currentIndex = 0; protected readonly projects: PortfolioProject[] = global.projects; + protected readonly fallbackBioEntry: BioTextEntry = { label: 'my stack', value: '' }; private sub!: Subscription; constructor() { @@ -51,6 +77,7 @@ export class HomeComponent implements OnInit, OnDestroy { ngOnInit(): void { this.preloadImageAssets(); + this.initNameGradientScrollAnimation(); this.zone.runOutsideAngular((): void => { this.sub = interval(1000).subscribe((): void => { @@ -63,6 +90,7 @@ export class HomeComponent implements OnInit, OnDestroy { ngOnDestroy(): void { this.sub.unsubscribe(); + this.destroyNameGradientScrollAnimation(); } protected toggleBio(): void { @@ -73,6 +101,10 @@ export class HomeComponent implements OnInit, OnDestroy { 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; @@ -113,6 +145,158 @@ export class HomeComponent implements OnInit, OnDestroy { image.src = url; return image; }); + + this.syncHeroHeightToLargestPortraitImage(); + } + + private syncHeroHeightToLargestPortraitImage(): void { + if (typeof window === 'undefined') { + return; + } + + const portraitImageUrls: Set = new Set( + 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 }); + } + } + + private initNameGradientScrollAnimation(): void { + if (typeof window === 'undefined') { + return; + } + + window.addEventListener('scroll', this.scheduleNameGradientUpdate, { passive: true }); + window.addEventListener('resize', this.scheduleNameGradientUpdate, { passive: true }); + window.addEventListener('resize', this.scheduleHeroSectionHeightUpdate, { passive: true }); + this.scheduleNameGradientUpdate(); + this.scheduleHeroSectionHeightUpdate(); + } + + private destroyNameGradientScrollAnimation(): void { + if (typeof window === 'undefined') { + return; + } + + window.removeEventListener('scroll', this.scheduleNameGradientUpdate); + window.removeEventListener('resize', this.scheduleNameGradientUpdate); + window.removeEventListener('resize', this.scheduleHeroSectionHeightUpdate); + + if (this.scrollAnimationFrameId !== null) { + window.cancelAnimationFrame(this.scrollAnimationFrameId); + this.scrollAnimationFrameId = null; + } + if (this.heroHeightAnimationFrameId !== null) { + window.cancelAnimationFrame(this.heroHeightAnimationFrameId); + this.heroHeightAnimationFrameId = null; + } + + document.documentElement.style.removeProperty('--name-pride-progress'); + document.documentElement.style.removeProperty('--hero-section-min-height'); + this.lockedHeroSectionMinHeight = 0; + } + + private updateNameGradientProgress(): void { + if (typeof window === 'undefined') { + return; + } + + const root: HTMLElement = document.documentElement; + const portfolioEyebrow: HTMLElement | null = document.getElementById('portfolio-eyebrow'); + const portfolioTriggerY: number = + portfolioEyebrow === null + ? Number.POSITIVE_INFINITY + : portfolioEyebrow.getBoundingClientRect().top + window.scrollY; + + const triggerDistance: number = Math.max(portfolioTriggerY, 1); + const rawProgress: number = Math.min(window.scrollY / triggerDistance, 1); + const acceleratedProgress: number = Math.pow(rawProgress, 0.66); + const progress: number = + window.scrollY > 0 ? Math.max(acceleratedProgress, 0.05) : acceleratedProgress; + + root.style.setProperty('--name-pride-progress', progress.toFixed(4)); + } + + private updateHeroSectionMinHeight(): void { + if (typeof window === 'undefined' || this.tallestPortraitAspectRatio === null) { + return; + } + + const heroSection: HTMLElement | null = document.querySelector('.hero-section'); + const heroCopy: HTMLElement | null = document.querySelector('.hero-copy'); + const portraitHighlight: HTMLElement | null = document.querySelector('.portrait-highlight'); + const portraitButton: HTMLElement | null = document.querySelector('.portrait-highlight-button'); + + if ( + heroSection === null || + heroCopy === null || + portraitHighlight === null || + portraitButton === null + ) { + return; + } + + const buttonWidth: number = portraitButton.getBoundingClientRect().width; + if (buttonWidth <= 0) { + return; + } + + const maxMediaHeight: number = buttonWidth / this.tallestPortraitAspectRatio; + const currentButtonHeight: number = portraitButton.getBoundingClientRect().height; + const currentHighlightHeight: number = portraitHighlight.getBoundingClientRect().height; + const staticHighlightOverhead: number = Math.max(currentHighlightHeight - currentButtonHeight, 0); + const requiredHighlightHeight: number = maxMediaHeight + staticHighlightOverhead; + const requiredContentHeight: number = Math.max(heroCopy.getBoundingClientRect().height, requiredHighlightHeight); + const heroSectionStyles: CSSStyleDeclaration = window.getComputedStyle(heroSection); + const paddingTop: number = Number.parseFloat(heroSectionStyles.paddingTop) || 0; + const paddingBottom: number = Number.parseFloat(heroSectionStyles.paddingBottom) || 0; + const requiredSectionMinHeight: number = requiredContentHeight + paddingTop + paddingBottom; + this.lockedHeroSectionMinHeight = Math.max(this.lockedHeroSectionMinHeight, requiredSectionMinHeight); + + document.documentElement.style.setProperty( + '--hero-section-min-height', + `${Math.ceil(this.lockedHeroSectionMinHeight)}px`, + ); } private calculateAge(birthDateString: string): number | string { diff --git a/src/app/imprint/imprint.component.ts b/src/app/imprint/imprint.component.ts index 7008eae..86fbf10 100644 --- a/src/app/imprint/imprint.component.ts +++ b/src/app/imprint/imprint.component.ts @@ -7,8 +7,8 @@ import type { IconDefinition } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'; -import type { Global } from '../../global.fields'; -import { global } from '../../global.fields'; +import type { Global } from '../../global'; +import { global } from '../../global'; import { FooterComponent } from '../footer/footer.component'; @Component({ diff --git a/src/global.fields.ts b/src/global.ts similarity index 74% rename from src/global.fields.ts rename to src/global.ts index 190a871..6ebe553 100644 --- a/src/global.fields.ts +++ b/src/global.ts @@ -28,12 +28,17 @@ export interface Global { contactPhone: string; hrefContactPhone: string; birthdate: string; - bioTextsList: string[]; + bioTextsList: BioTextEntry[]; portraitHighlights: PortraitHighlight[]; projects: PortfolioProject[]; address: ImprintData; } +export interface BioTextEntry { + label: string; + value: string; +} + export const global: Global = { firstname: 'Julian', lastname: 'Lechner', @@ -43,27 +48,26 @@ export const global: Global = { hrefContactPhone: '+436609254001', birthdate: '2009-03-03', bioTextsList: [ - 'C#', - '.NET', - '.NET Framework', - 'WPF', - 'Avalonia', - 'NodeJS', - 'Angular', - 'TypeScript', - 'JavaScript', - 'Java', - 'HTML', - '(S)CSS', - 'Github', - 'Gitlab', - 'Azure DevOps', - 'Ubuntu', - 'Debian', - 'SSMS', - 'MariaDB', - 'MySQL', - 'Grandle', + { label: 'Languages', value: 'C#' }, + { label: 'Languages', value: 'TypeScript' }, + { label: 'Languages', value: 'Bash' }, + { label: 'Frontend', value: 'Angular' }, + { label: 'Frontend', value: 'WPF' }, + { label: 'Frontend', value: 'Avalonia' }, + { label: 'Backend', value: '.NET' }, + { label: 'Backend', value: 'ASP.NET Core' }, + { label: 'Backend', value: 'Entity Framework Core' }, + { label: 'Backend', value: 'Node.js' }, + { label: 'Backend', value: 'REST' }, + { label: 'Backend', value: 'Swagger' }, + { label: 'Database', value: 'Microsoft SQL' }, + { label: 'Database', value: 'MariaDB' }, + { label: 'DevOps', value: 'Git' }, + { label: 'DevOps', value: 'GitHub & GitLab' }, + { label: 'DevOps', value: 'Azure DevOps' }, + { label: 'DevOps', value: 'Docker' }, + { label: 'DevOps', value: 'Ubuntu' }, + { label: 'DevOps', value: 'Arch' }, ], portraitHighlights: [ { diff --git a/src/styles.scss b/src/styles.scss index 45e4dfb..c71e10c 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -1,5 +1,5 @@ :root { - --page-max-width: 1160px; + --page-max-width: 1000px; --content-max-width: 920px; --surface: rgba(20, 18, 22, 0.62); --surface-strong: rgba(15, 14, 18, 0.78); @@ -24,6 +24,7 @@ --space-7: 4.5rem; --transition-fast: 180ms ease; --transition-medium: 320ms ease; + --name-pride-progress: 0; } * { @@ -68,7 +69,7 @@ hr { } .page-shell { - width: min(calc(100% - 2rem), var(--page-max-width)); + width: min(calc(100% - 6rem), var(--page-max-width)); margin: 0 auto; padding: var(--space-6) 0 var(--space-7); } @@ -94,6 +95,11 @@ hr { max-width: 44rem; } +.section-heading-centered { + margin-inline: auto; + text-align: center; +} + .section-heading.compact { max-width: 52rem; } @@ -111,7 +117,7 @@ hr { grid-template-columns: minmax(0, 1.15fr) minmax(280px, 0.85fr); gap: var(--space-6); align-items: start; - min-height: min(86svh, 780px); + min-height: var(--hero-section-min-height, auto); padding: clamp(2rem, 4vw, 4rem); } @@ -122,8 +128,27 @@ hr { } .hero-name-part { + position: relative; display: inline-block; margin-right: 0.28em; + color: var(--text-primary); +} + +.hero-name-part::after { + content: attr(data-name); + position: absolute; + left: 0; + top: 0; + white-space: nowrap; + pointer-events: none; + color: transparent; +} + +.hero-name-part::after { + --name-pride-reveal: clamp(0, var(--name-pride-progress), 1); + width: calc(var(--name-pride-reveal) * (100% + 0.14em)); + overflow: hidden; + color: transparent; background: linear-gradient( to right, #e40303 0%, @@ -141,9 +166,10 @@ hr { ); background-repeat: no-repeat; background-size: 100% 100%; + background-position: 0 50%; -webkit-background-clip: text; background-clip: text; - color: transparent; + -webkit-text-fill-color: transparent; } .hero-name-part:last-child { @@ -623,7 +649,6 @@ hr { min-width: 100px; height: 100px; object-fit: cover; - background: rgba(255, 255, 255, 0.06); box-shadow: var(--shadow-soft); } @@ -728,7 +753,7 @@ hr { } .footer-inner { - width: min(calc(100% - 2rem), var(--page-max-width)); + width: min(calc(100% - 6rem), var(--page-max-width)); margin: 0 auto; padding-top: var(--space-4); border-top: 1px solid var(--border-soft); @@ -785,7 +810,7 @@ hr { @media (max-width: 700px) { .page-shell { - width: min(calc(100% - 2rem), var(--page-max-width)); + width: min(calc(100% - 4rem), var(--page-max-width)); padding: var(--space-4) 0 var(--space-6); }