feat: style

This commit is contained in:
2026-04-20 22:59:35 +02:00
parent 7a967e4d55
commit 38f391d525
6 changed files with 353 additions and 37 deletions
+27 -10
View File
@@ -13,7 +13,7 @@
</div> </div>
<div class="hero-actions"> <div class="hero-actions">
<div class="contact-area hero-contact-area"> <div id="contact-links" class="contact-area hero-contact-area">
<a [href]="contactSafeMail" aria-label="Send mail"> <a [href]="contactSafeMail" aria-label="Send mail">
<fa-icon class="fab" [icon]="faEnvelope" size="lg"></fa-icon> <fa-icon class="fab" [icon]="faEnvelope" size="lg"></fa-icon>
</a> </a>
@@ -78,7 +78,12 @@
</div> </div>
</section> </section>
<section id="about" class="story-section panel"> <section
id="about"
class="story-section panel about-scroll-anim-enabled"
[class.about-scroll-in-view]="isAboutSectionInView"
[class.about-scroll-anim-ready]="isAboutSectionAnimationReady"
>
<div class="section-heading"> <div class="section-heading">
<p class="eyebrow">About me</p> <p class="eyebrow">About me</p>
</div> </div>
@@ -101,8 +106,8 @@
<fa-icon [icon]="isLongBioShown ? faArrowDown : faArrowRight"></fa-icon> <fa-icon [icon]="isLongBioShown ? faArrowDown : faArrowRight"></fa-icon>
</button> </button>
@if (isLongBioShown) { @if (isLongBioMounted) {
<div class="detail-panel detail-panel-enter"> <div class="detail-panel" [class.detail-panel-enter]="isLongBioShown" [class.detail-panel-leave]="!isLongBioShown">
<p> <p>
My biggest hobby apart from software development is scuba diving. Floating in deep water and only thinking about the here and My biggest hobby apart from software development is scuba diving. Floating in deep water and only thinking about the here and
now always brings me back to a calmer pace. now always brings me back to a calmer pace.
@@ -111,7 +116,7 @@
I currently work mainly for the Viennese company SobIT GmbH and also take on freelance projects for companies, associations, I currently work mainly for the Viennese company SobIT GmbH and also take on freelance projects for companies, associations,
and private clients. The common thread is always the same: practical software, well delivered. and private clients. The common thread is always the same: practical software, well delivered.
</p> </p>
<a class="inline-link" [href]="contactSafeMail">Get in touch</a> <a class="inline-link" href="#contact-links">Get in touch</a>
</div> </div>
} }
</div> </div>
@@ -125,7 +130,15 @@
<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) {
<article class="panel project-entry" [class.project-entry-reverse]="i % 2 === 1"> <a
class="panel project-entry"
[class.project-entry-reverse]="i % 2 === 1"
[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.data-project-index]="i"
>
@if (project.icon) { @if (project.icon) {
<img <img
class="project-icon" class="project-icon"
@@ -137,15 +150,19 @@
<div class="project-copy"> <div class="project-copy">
<div class="project-header"> <div class="project-header">
<div> <div class="project-title-wrap">
<a class="project-link" href="{{ project.link }}">{{ project.title }}</a> <span class="project-link">{{ project.title }}</span>
</div>
<div class="project-languages" aria-label="Project languages">
@for (language of project.languages; track language + '-' + $index) {
<span class="project-language-chip">{{ language }}</span>
}
</div> </div>
<div class="project-languages">{{ project.languages.join(' · ') }}</div>
</div> </div>
<p class="project-description">{{ project.description }}</p> <p class="project-description">{{ project.description }}</p>
</div> </div>
</article> </a>
} }
</div> </div>
</section> </section>
+144 -3
View File
@@ -1,4 +1,4 @@
import type { OnDestroy, OnInit } from '@angular/core'; import type { AfterViewInit, OnDestroy, OnInit } from '@angular/core';
import { Component, inject, NgZone } from '@angular/core'; import { Component, inject, NgZone } from '@angular/core';
import { faArrowRight, faArrowDown, faEnvelope, faPhone } from '@fortawesome/free-solid-svg-icons'; import { faArrowRight, faArrowDown, faEnvelope, faPhone } from '@fortawesome/free-solid-svg-icons';
import { faDiscord, faGithub, faLinkedin } from '@fortawesome/free-brands-svg-icons'; import { faDiscord, faGithub, faLinkedin } from '@fortawesome/free-brands-svg-icons';
@@ -19,12 +19,14 @@ import { FooterComponent } from '../footer/footer.component';
imports: [FooterComponent, FaIconComponent], imports: [FooterComponent, FaIconComponent],
templateUrl: './home.component.html', templateUrl: './home.component.html',
}) })
export class HomeComponent implements OnInit, OnDestroy { export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly sanitizer = inject(DomSanitizer); private readonly sanitizer = inject(DomSanitizer);
private readonly zone = inject(NgZone); private readonly zone = inject(NgZone);
private preloadedImages: HTMLImageElement[] = []; private preloadedImages: HTMLImageElement[] = [];
private tallestPortraitAspectRatio: number | null = null; private tallestPortraitAspectRatio: number | null = null;
private lockedHeroSectionMinHeight: number = 0; private lockedHeroSectionMinHeight: number = 0;
private projectEntryObserver: IntersectionObserver | null = null;
private aboutSectionObserver: IntersectionObserver | null = null;
private scrollAnimationFrameId: number | null = null; private scrollAnimationFrameId: number | null = null;
private heroHeightAnimationFrameId: number | null = null; private heroHeightAnimationFrameId: number | null = null;
private readonly scheduleNameGradientUpdate: () => void = (): void => { private readonly scheduleNameGradientUpdate: () => void = (): void => {
@@ -53,6 +55,9 @@ export class HomeComponent implements OnInit, OnDestroy {
protected readonly age: number | string = this.calculateAge(global.birthdate); protected readonly age: number | string = this.calculateAge(global.birthdate);
protected isLongBioShown: boolean = false; protected isLongBioShown: boolean = false;
protected isLongBioMounted: boolean = false;
protected isAboutSectionInView: boolean = true;
protected isAboutSectionAnimationReady: boolean = false;
protected readonly faArrowRight: IconDefinition = faArrowRight; protected readonly faArrowRight: IconDefinition = faArrowRight;
protected readonly faArrowDown: IconDefinition = faArrowDown; protected readonly faArrowDown: IconDefinition = faArrowDown;
protected readonly faEnvelope: IconDefinition = faEnvelope; protected readonly faEnvelope: IconDefinition = faEnvelope;
@@ -70,6 +75,7 @@ export class HomeComponent implements OnInit, OnDestroy {
protected readonly projects: PortfolioProject[] = global.projects; protected readonly projects: PortfolioProject[] = global.projects;
protected readonly fallbackBioEntry: BioTextEntry = { label: 'my stack', value: '' }; protected readonly fallbackBioEntry: BioTextEntry = { label: 'my stack', value: '' };
private sub!: Subscription; private sub!: Subscription;
private bioHideTimeoutId: number | null = null;
constructor() { constructor() {
this.contactSafeMail = this.sanitizer.bypassSecurityTrustUrl(`mailto:${global.contactMail}`); this.contactSafeMail = this.sanitizer.bypassSecurityTrustUrl(`mailto:${global.contactMail}`);
@@ -88,13 +94,54 @@ export class HomeComponent implements OnInit, OnDestroy {
}); });
} }
ngAfterViewInit(): void {
this.initProjectEntryRevealObserver();
this.initAboutSectionScrollAnimation();
}
ngOnDestroy(): void { ngOnDestroy(): void {
this.sub.unsubscribe(); this.sub.unsubscribe();
if (this.bioHideTimeoutId !== null) {
window.clearTimeout(this.bioHideTimeoutId);
this.bioHideTimeoutId = null;
}
this.destroyNameGradientScrollAnimation(); this.destroyNameGradientScrollAnimation();
this.destroyProjectEntryRevealObserver();
this.destroyAboutSectionScrollAnimation();
} }
protected toggleBio(): void { protected toggleBio(): void {
this.isLongBioShown = !this.isLongBioShown; if (this.isLongBioShown) {
this.isLongBioShown = false;
if (typeof window !== 'undefined') {
if (this.bioHideTimeoutId !== null) {
window.clearTimeout(this.bioHideTimeoutId);
}
this.bioHideTimeoutId = window.setTimeout((): void => {
this.isLongBioMounted = false;
this.bioHideTimeoutId = null;
}, 300);
} else {
this.isLongBioMounted = false;
}
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 => {
window.requestAnimationFrame((): void => {
this.isLongBioShown = true;
});
});
} else {
this.isLongBioShown = true;
}
} }
protected get currentPortraitHighlight(): PortraitHighlight | null { protected get currentPortraitHighlight(): PortraitHighlight | null {
@@ -219,6 +266,100 @@ export class HomeComponent implements OnInit, OnDestroy {
this.scheduleHeroSectionHeightUpdate(); this.scheduleHeroSectionHeightUpdate();
} }
private initProjectEntryRevealObserver(): void {
if (typeof window === 'undefined') {
return;
}
const projectEntries: NodeListOf<Element> = document.querySelectorAll('.project-entry');
projectEntries.forEach((entry: Element): void => {
entry.classList.add('project-entry-reveal-pending');
});
if (typeof IntersectionObserver === 'undefined') {
projectEntries.forEach((entry: Element): void => {
entry.classList.add('project-entry-visible');
entry.classList.remove('project-entry-reveal-pending');
});
return;
}
this.projectEntryObserver = new IntersectionObserver(
(entries: IntersectionObserverEntry[]): void => {
for (const entry of entries) {
const element: Element = entry.target;
if (entry.isIntersecting) {
window.requestAnimationFrame((): void => {
element.classList.add('project-entry-visible');
element.classList.remove('project-entry-reveal-pending');
});
continue;
}
element.classList.add('project-entry-reveal-pending');
element.classList.remove('project-entry-visible');
}
},
{
root: null,
threshold: 0.18,
rootMargin: '0px 0px -8% 0px',
},
);
projectEntries.forEach((entry: Element): void => {
this.projectEntryObserver?.observe(entry);
});
}
private destroyProjectEntryRevealObserver(): void {
this.projectEntryObserver?.disconnect();
this.projectEntryObserver = null;
}
private initAboutSectionScrollAnimation(): void {
if (typeof window === 'undefined') {
return;
}
const aboutSection: HTMLElement | null = document.getElementById('about');
if (aboutSection === null) {
return;
}
const initialRect: DOMRect = aboutSection.getBoundingClientRect();
const viewportHeight: number = window.innerHeight || document.documentElement.clientHeight;
this.isAboutSectionInView =
initialRect.top < viewportHeight * 0.95 && initialRect.bottom > viewportHeight * 0.1;
this.isAboutSectionAnimationReady = true;
if (typeof IntersectionObserver === 'undefined') {
return;
}
this.aboutSectionObserver = new IntersectionObserver(
(entries: IntersectionObserverEntry[]): void => {
for (const entry of entries) {
this.zone.run((): void => {
this.isAboutSectionInView = entry.isIntersecting;
});
}
},
{
root: null,
threshold: 0.05,
rootMargin: '0px 0px -6% 0px',
},
);
this.aboutSectionObserver.observe(aboutSection);
}
private destroyAboutSectionScrollAnimation(): void {
this.aboutSectionObserver?.disconnect();
this.aboutSectionObserver = null;
}
private destroyNameGradientScrollAnimation(): void { private destroyNameGradientScrollAnimation(): void {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return; return;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

+2 -2
View File
@@ -101,8 +101,8 @@ export const global: Global = {
link: 'https://www.synradio.de/', link: 'https://www.synradio.de/',
description: 'An internet radio station that is available on various media.', description: 'An internet radio station that is available on various media.',
languages: ['TypeScript', 'JavaScript', 'HTML', 'CSS'], languages: ['TypeScript', 'JavaScript', 'HTML', 'CSS'],
icon: 'https://gerlach-systems.de/IMAGES/SYNHOST/SYNHOST_DARK.png', icon: 'assets/portrait/me.png',
CircleIcon: false, CircleIcon: true,
}, },
{ {
title: 'SynHost', title: 'SynHost',
+1 -1
View File
@@ -5,7 +5,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<base href="/" /> <base href="/" />
<link rel="icon" type="image/x-icon" href="favicon.ico" /> <link rel="icon" type="image/png" href="assets/portrait/me.png" />
<title>Lechner Julian</title> <title>Lechner Julian</title>
</head> </head>
<body> <body>
+179 -21
View File
@@ -223,6 +223,10 @@ hr {
gap: 0.6rem; gap: 0.6rem;
} }
#contact-links {
scroll-margin-top: 0.65rem;
}
.hero-contact-area a { .hero-contact-area a {
width: 2.8rem; width: 2.8rem;
height: 2.8rem; height: 2.8rem;
@@ -463,6 +467,41 @@ hr {
margin-top: var(--space-5); margin-top: var(--space-5);
} }
.about-scroll-anim-enabled .story-copy,
.about-scroll-anim-enabled .story-detail {
opacity: 1;
transform: translate3d(0, 0, 0);
}
.about-scroll-anim-enabled:not(.about-scroll-anim-ready) .story-copy,
.about-scroll-anim-enabled:not(.about-scroll-anim-ready) .story-detail {
opacity: 1;
transform: translate3d(0, 0, 0);
}
.about-scroll-anim-enabled.about-scroll-anim-ready .story-copy,
.about-scroll-anim-enabled.about-scroll-anim-ready .story-detail {
opacity: 1;
transition:
opacity 520ms ease,
transform 620ms cubic-bezier(0.2, 0.8, 0.2, 1);
will-change: opacity, transform;
}
.about-scroll-anim-enabled.about-scroll-anim-ready .story-copy {
transform: translate3d(0, 10px, 0);
}
.about-scroll-anim-enabled.about-scroll-anim-ready .story-detail {
transform: translate3d(0, 10px, 0);
}
.about-scroll-anim-enabled.about-scroll-anim-ready.about-scroll-in-view .story-copy,
.about-scroll-anim-enabled.about-scroll-anim-ready.about-scroll-in-view .story-detail {
opacity: 1;
transform: translate3d(0, 0, 0);
}
.story-copy p:first-child, .story-copy p:first-child,
.detail-panel p:first-child, .detail-panel p:first-child,
.legal-copy p:first-child { .legal-copy p:first-child {
@@ -511,19 +550,21 @@ hr {
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border-soft); border: 1px solid var(--border-soft);
opacity: 0;
transform: translateY(10px);
transition:
opacity 300ms ease,
transform 300ms ease;
} }
.detail-panel-enter { .detail-panel-enter {
animation: detail-panel-fade-in 300ms ease-in; opacity: 1;
transform: translateY(0);
} }
@keyframes detail-panel-fade-in { .detail-panel-leave {
from { opacity: 0;
opacity: 0; transform: translateY(10px);
}
to {
opacity: 1;
}
} }
@keyframes portrait-switch { @keyframes portrait-switch {
@@ -598,6 +639,40 @@ hr {
align-items: center; align-items: center;
gap: var(--space-5); gap: var(--space-5);
padding: clamp(1.4rem, 3vw, 2rem); padding: clamp(1.4rem, 3vw, 2rem);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-lg);
opacity: 1;
transform: translate3d(0, 0, 0);
transition:
opacity 520ms ease,
transform 620ms cubic-bezier(0.2, 0.8, 0.2, 1),
border-color var(--transition-fast),
background var(--transition-fast),
box-shadow var(--transition-fast);
}
.project-entry-reveal-pending.project-entry-from-right {
opacity: 0;
transform: translate3d(54px, 16px, 0);
}
.project-entry-reveal-pending.project-entry-from-left {
opacity: 0;
transform: translate3d(-54px, 16px, 0);
}
.project-entry-visible {
opacity: 1;
transform: translate3d(0, 0, 0);
}
.project-entry:hover {
transform: translateY(-2px);
border-color: rgba(255, 255, 255, 0.24);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.04));
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.26),
var(--shadow-soft);
} }
.project-entry-reverse { .project-entry-reverse {
@@ -612,6 +687,11 @@ hr {
order: 1; order: 1;
} }
.project-copy {
display: grid;
gap: 0.35rem;
}
.project-header { .project-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -619,6 +699,10 @@ hr {
gap: var(--space-4); gap: var(--space-4);
} }
.project-title-wrap {
min-width: 0;
}
.project-kicker { .project-kicker {
margin: 0 0 var(--space-1); margin: 0 0 var(--space-1);
color: var(--accent-strong); color: var(--accent-strong);
@@ -631,25 +715,50 @@ hr {
font-size: clamp(1.6rem, 2vw, 2.15rem); font-size: clamp(1.6rem, 2vw, 2.15rem);
font-weight: 700; font-weight: 700;
letter-spacing: -0.04em; letter-spacing: -0.04em;
line-height: 1.08;
text-wrap: balance;
transition: color var(--transition-fast);
}
.project-link:hover {
color: var(--accent-strong);
} }
.project-languages { .project-languages {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.45rem;
max-width: 22rem;
}
.project-language-chip {
display: inline-flex;
align-items: center;
min-height: 1.9rem;
padding: 0.15rem 0.65rem;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.04);
color: var(--text-muted); color: var(--text-muted);
font-size: 0.95rem; font-size: 0.82rem;
text-align: right; font-weight: 600;
line-height: 1;
letter-spacing: 0.01em;
} }
.project-description { .project-description {
max-width: 46rem; max-width: 46rem;
margin: var(--space-3) 0 0; margin: var(--space-3) 0 0;
text-wrap: pretty;
} }
.project-icon { .project-icon {
width: 100px; width: clamp(84px, 12vw, 108px);
min-width: 100px; min-width: clamp(84px, 12vw, 108px);
height: 100px; height: clamp(84px, 12vw, 108px);
object-fit: cover; object-fit: cover;
box-shadow: var(--shadow-soft); box-shadow: none;
} }
.project-icon-circle { .project-icon-circle {
@@ -794,17 +903,20 @@ hr {
text-align: left; text-align: left;
} }
.project-entry-reverse .project-icon, .project-entry {
.project-entry-reverse .project-copy { align-items: start;
order: initial; gap: var(--space-4);
padding: clamp(1.1rem, 3.8vw, 1.5rem);
} }
.project-header { .project-header {
flex-direction: column; flex-direction: column;
gap: var(--space-3);
} }
.project-languages { .project-languages {
text-align: left; justify-content: flex-start;
max-width: none;
} }
} }
@@ -852,9 +964,55 @@ hr {
} }
.project-icon { .project-icon {
width: 84px; width: 64px;
min-width: 84px; min-width: 64px;
height: 84px; height: 64px;
align-self: start;
}
.project-entry {
grid-template-columns: 64px minmax(0, 1fr);
align-items: start;
padding: 0.95rem;
gap: 0.9rem;
border-radius: var(--radius-lg);
}
.project-entry-reverse {
grid-template-columns: minmax(0, 1fr) 64px;
}
.project-entry-reverse .project-copy {
order: 1;
}
.project-entry-reverse .project-icon {
order: 2;
justify-self: end;
}
.project-copy {
gap: 0.25rem;
}
.project-header {
gap: 0.55rem;
}
.project-link {
font-size: clamp(1.12rem, 5.9vw, 1.35rem);
}
.project-description {
margin-top: 0.45rem;
line-height: 1.58;
font-size: 0.93rem;
}
.project-language-chip {
min-height: 1.6rem;
padding: 0.08rem 0.52rem;
font-size: 0.74rem;
} }
.hero-actions, .hero-actions,