diff --git a/src/app/home/home.component.html b/src/app/home/home.component.html index 2374d6c..2a6fd76 100644 --- a/src/app/home/home.component.html +++ b/src/app/home/home.component.html @@ -13,7 +13,7 @@
-
+ -
+

About me

@@ -101,8 +106,8 @@ - @if (isLongBioShown) { -
+ @if (isLongBioMounted) { +

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. @@ -111,7 +116,7 @@ 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.

- Get in touch + Get in touch
}
@@ -125,7 +130,15 @@
@for (project of projects; track project.link + '-' + $index; let i = $index) { -
+ @if (project.icon) {
-
- {{ project.title }} +
+ {{ project.title }} +
+
+ @for (language of project.languages; track language + '-' + $index) { + {{ language }} + }
-
{{ project.languages.join(' ยท ') }}

{{ project.description }}

-
+ }
diff --git a/src/app/home/home.component.ts b/src/app/home/home.component.ts index 18e4f05..efe7ec7 100644 --- a/src/app/home/home.component.ts +++ b/src/app/home/home.component.ts @@ -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 { faArrowRight, faArrowDown, faEnvelope, faPhone } from '@fortawesome/free-solid-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], templateUrl: './home.component.html', }) -export class HomeComponent implements OnInit, OnDestroy { +export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { private readonly sanitizer = inject(DomSanitizer); private readonly zone = inject(NgZone); private preloadedImages: HTMLImageElement[] = []; private tallestPortraitAspectRatio: number | null = null; private lockedHeroSectionMinHeight: number = 0; + private projectEntryObserver: IntersectionObserver | null = null; + private aboutSectionObserver: IntersectionObserver | null = null; private scrollAnimationFrameId: number | null = null; private heroHeightAnimationFrameId: number | null = null; 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 isLongBioShown: boolean = false; + protected isLongBioMounted: boolean = false; + protected isAboutSectionInView: boolean = true; + protected isAboutSectionAnimationReady: boolean = false; protected readonly faArrowRight: IconDefinition = faArrowRight; protected readonly faArrowDown: IconDefinition = faArrowDown; protected readonly faEnvelope: IconDefinition = faEnvelope; @@ -70,6 +75,7 @@ export class HomeComponent implements OnInit, OnDestroy { protected readonly projects: PortfolioProject[] = global.projects; protected readonly fallbackBioEntry: BioTextEntry = { label: 'my stack', value: '' }; private sub!: Subscription; + private bioHideTimeoutId: number | null = null; constructor() { 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 { this.sub.unsubscribe(); + if (this.bioHideTimeoutId !== null) { + window.clearTimeout(this.bioHideTimeoutId); + this.bioHideTimeoutId = null; + } this.destroyNameGradientScrollAnimation(); + this.destroyProjectEntryRevealObserver(); + this.destroyAboutSectionScrollAnimation(); } 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 { @@ -219,6 +266,100 @@ export class HomeComponent implements OnInit, OnDestroy { this.scheduleHeroSectionHeightUpdate(); } + private initProjectEntryRevealObserver(): void { + if (typeof window === 'undefined') { + return; + } + + const projectEntries: NodeListOf = 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 { if (typeof window === 'undefined') { return; diff --git a/src/assets/favicon.ico b/src/assets/favicon.ico deleted file mode 100644 index 431aa41..0000000 Binary files a/src/assets/favicon.ico and /dev/null differ diff --git a/src/global.ts b/src/global.ts index 6ebe553..1ae52b0 100644 --- a/src/global.ts +++ b/src/global.ts @@ -101,8 +101,8 @@ export const global: Global = { link: 'https://www.synradio.de/', description: 'An internet radio station that is available on various media.', languages: ['TypeScript', 'JavaScript', 'HTML', 'CSS'], - icon: 'https://gerlach-systems.de/IMAGES/SYNHOST/SYNHOST_DARK.png', - CircleIcon: false, + icon: 'assets/portrait/me.png', + CircleIcon: true, }, { title: 'SynHost', diff --git a/src/index.html b/src/index.html index 5de9749..54d5a22 100644 --- a/src/index.html +++ b/src/index.html @@ -5,7 +5,7 @@ - + Lechner Julian diff --git a/src/styles.scss b/src/styles.scss index c71e10c..5303e8f 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -223,6 +223,10 @@ hr { gap: 0.6rem; } +#contact-links { + scroll-margin-top: 0.65rem; +} + .hero-contact-area a { width: 2.8rem; height: 2.8rem; @@ -463,6 +467,41 @@ hr { 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, .detail-panel p:first-child, .legal-copy p:first-child { @@ -511,19 +550,21 @@ hr { border-radius: var(--radius-lg); background: rgba(255, 255, 255, 0.04); border: 1px solid var(--border-soft); + opacity: 0; + transform: translateY(10px); + transition: + opacity 300ms ease, + transform 300ms ease; } .detail-panel-enter { - animation: detail-panel-fade-in 300ms ease-in; + opacity: 1; + transform: translateY(0); } -@keyframes detail-panel-fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } +.detail-panel-leave { + opacity: 0; + transform: translateY(10px); } @keyframes portrait-switch { @@ -598,6 +639,40 @@ hr { align-items: center; gap: var(--space-5); 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 { @@ -612,6 +687,11 @@ hr { order: 1; } +.project-copy { + display: grid; + gap: 0.35rem; +} + .project-header { display: flex; justify-content: space-between; @@ -619,6 +699,10 @@ hr { gap: var(--space-4); } +.project-title-wrap { + min-width: 0; +} + .project-kicker { margin: 0 0 var(--space-1); color: var(--accent-strong); @@ -631,25 +715,50 @@ hr { font-size: clamp(1.6rem, 2vw, 2.15rem); font-weight: 700; 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 { + 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); - font-size: 0.95rem; - text-align: right; + font-size: 0.82rem; + font-weight: 600; + line-height: 1; + letter-spacing: 0.01em; } .project-description { max-width: 46rem; margin: var(--space-3) 0 0; + text-wrap: pretty; } .project-icon { - width: 100px; - min-width: 100px; - height: 100px; + width: clamp(84px, 12vw, 108px); + min-width: clamp(84px, 12vw, 108px); + height: clamp(84px, 12vw, 108px); object-fit: cover; - box-shadow: var(--shadow-soft); + box-shadow: none; } .project-icon-circle { @@ -794,17 +903,20 @@ hr { text-align: left; } - .project-entry-reverse .project-icon, - .project-entry-reverse .project-copy { - order: initial; + .project-entry { + align-items: start; + gap: var(--space-4); + padding: clamp(1.1rem, 3.8vw, 1.5rem); } .project-header { flex-direction: column; + gap: var(--space-3); } .project-languages { - text-align: left; + justify-content: flex-start; + max-width: none; } } @@ -852,9 +964,55 @@ hr { } .project-icon { - width: 84px; - min-width: 84px; - height: 84px; + width: 64px; + min-width: 64px; + 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,