feat: style
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
<section class="hero-section panel">
|
||||
<div class="hero-copy">
|
||||
<div class="hero-copy-main">
|
||||
<p class="eyebrow">Portfolio</p>
|
||||
<p id="portfolio-eyebrow" class="eyebrow">Portfolio</p>
|
||||
<h1>
|
||||
<span class="hero-name-part">{{ global.firstname }}</span>
|
||||
<span class="hero-name-part">{{ global.lastname }}</span>
|
||||
<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">
|
||||
Full-stack development, thoughtful interfaces, and tailored digital products for companies, associations, and private clients.
|
||||
@@ -43,8 +43,8 @@
|
||||
|
||||
<div class="hero-meta">
|
||||
<div>
|
||||
<span class="meta-label">my stack</span>
|
||||
<strong>{{ global.bioTextsList[currentIndex] }}</strong>
|
||||
<span class="meta-label">{{ currentBioEntry.label }}</span>
|
||||
<strong>{{ currentBioEntry.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,7 +81,6 @@
|
||||
<section id="about" class="story-section panel">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">About me</p>
|
||||
<h2>Direct communication, solid implementation, and software that stays understandable.</h2>
|
||||
</div>
|
||||
|
||||
<div class="story-grid">
|
||||
@@ -120,9 +119,8 @@
|
||||
</section>
|
||||
|
||||
<section id="projects" class="projects-section">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">Selected Projects</p>
|
||||
<h2>A compact look at products, platforms, and systems I have helped shape.</h2>
|
||||
<div class="section-heading section-heading-centered">
|
||||
<p class="eyebrow">Projects</p>
|
||||
</div>
|
||||
|
||||
<div class="project-list">
|
||||
|
||||
@@ -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<string> = new Set<string>(
|
||||
this.portraitHighlights.map((highlight: PortraitHighlight): string => new URL(highlight.image, window.location.href).href),
|
||||
);
|
||||
|
||||
const portraitImages: HTMLImageElement[] = this.preloadedImages.filter((image: HTMLImageElement): boolean =>
|
||||
portraitImageUrls.has(image.src),
|
||||
);
|
||||
|
||||
if (portraitImages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const finalizeAspectRatio = (): void => {
|
||||
const minAspectRatio: number = portraitImages.reduce((minRatio: number, image: HTMLImageElement): number => {
|
||||
if (image.naturalWidth === 0 || image.naturalHeight === 0) {
|
||||
return minRatio;
|
||||
}
|
||||
|
||||
const currentRatio: number = image.naturalWidth / image.naturalHeight;
|
||||
return minRatio === 0 ? currentRatio : Math.min(minRatio, currentRatio);
|
||||
}, 0);
|
||||
|
||||
if (minAspectRatio > 0) {
|
||||
this.tallestPortraitAspectRatio = minAspectRatio;
|
||||
this.scheduleHeroSectionHeightUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
let pendingLoads: number = portraitImages.length;
|
||||
const onImageSettled = (): void => {
|
||||
pendingLoads -= 1;
|
||||
if (pendingLoads === 0) {
|
||||
finalizeAspectRatio();
|
||||
}
|
||||
};
|
||||
|
||||
for (const image of portraitImages) {
|
||||
if (image.complete) {
|
||||
onImageSettled();
|
||||
continue;
|
||||
}
|
||||
|
||||
image.addEventListener('load', onImageSettled, { once: true });
|
||||
image.addEventListener('error', onImageSettled, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
+32
-7
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user