diff --git a/src/components/tech-stack-carousel/tech-stack-carousel.css b/src/components/tech-stack-carousel/tech-stack-carousel.css new file mode 100644 index 0000000..d20294d --- /dev/null +++ b/src/components/tech-stack-carousel/tech-stack-carousel.css @@ -0,0 +1,201 @@ +:host { + display: block; +} + +.section { + margin-bottom: 4rem; + scroll-margin-top: 5.5rem; +} + +h2 { + margin: 0; + font-size: clamp(1.5rem, 2.2vw, 2rem); +} + +.section-title-row { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: baseline; + gap: 1rem; + margin-bottom: 1.3rem; +} + +.section-title-row p { + margin: 0; + color: #8b949e; +} + +.stack-carousel-shell { + --carousel-nav-space: 4.25rem; + position: relative; + width: calc(100% + (var(--carousel-nav-space) * 2)); + margin-inline: calc(var(--carousel-nav-space) * -1); + overflow: visible; +} + +.stack-carousel-column { + width: calc(100% - (var(--carousel-nav-space) * 2)); + margin-inline: auto; +} + +.stack-carousel { + display: flex; + gap: 1rem; + overflow-x: auto; + width: 100%; + padding: 0.2rem; + scroll-snap-type: x mandatory; + scrollbar-width: thin; + scrollbar-color: rgba(88, 166, 255, 0.45) rgba(33, 38, 45, 0.65); +} + +.stack-slide { + flex: 0 0 min(320px, calc(100% - 0.2rem)); + scroll-snap-align: start; +} + +.skill-heading { + display: flex; + align-items: center; + gap: 0.65rem; +} + +.skill-heading h3 { + margin: 0; + font-size: 1.08rem; +} + +.skill-icon { + display: inline-flex; + justify-content: center; + align-items: center; + width: 2rem; + height: 2rem; + border-radius: 10px; + border: 1px solid rgba(88, 166, 255, 0.4); + background: radial-gradient(circle at 30% 20%, rgba(88, 166, 255, 0.2), rgba(22, 27, 34, 0.95)); + box-shadow: inset 0 1px 0 rgba(240, 246, 252, 0.08); + overflow: hidden; + transition: border-color 140ms ease, box-shadow 140ms ease, background-color 140ms ease; +} + +.skill-logo { + width: 1.2rem; + height: 1.2rem; + object-fit: contain; + opacity: 0.92; + filter: grayscale(1) brightness(1.25) contrast(0.92); + transition: transform 140ms ease, opacity 140ms ease, filter 140ms ease; +} + +.skill-fallback { + display: none; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + color: #9ecbff; +} + +.skill-icon.is-fallback .skill-logo { + display: none; +} + +.skill-icon.is-fallback .skill-fallback { + display: inline; +} + +.skill-card { + transition: border-color 140ms ease, box-shadow 140ms ease; +} + +.skill-card .meta { + margin: 0.9rem 0 0; + color: #8b949e; + font-size: 0.92rem; +} + +.stack-slide:hover .skill-icon, +.stack-slide:focus-within .skill-icon { + border-color: rgba(121, 192, 255, 0.75); + box-shadow: 0 0 0 3px rgba(56, 139, 253, 0.18), inset 0 1px 0 rgba(240, 246, 252, 0.14); + background: radial-gradient(circle at 30% 20%, rgba(121, 192, 255, 0.28), rgba(22, 27, 34, 0.95)); +} + +.stack-slide:hover .skill-logo, +.stack-slide:focus-within .skill-logo { + opacity: 1; + transform: scale(1.04); + filter: grayscale(1) brightness(1.4) contrast(1); +} + +.carousel-btn { + position: absolute; + top: 50%; + width: 2.5rem; + height: 2.5rem; + border: 1px solid #3d444d; + border-radius: 999px; + background: rgba(33, 38, 45, 0.8); + color: #e6edf3; + cursor: pointer; + opacity: 0; + visibility: hidden; + pointer-events: none; + transform: translateY(calc(-50% + 2px)); + transition: border-color 120ms ease, background-color 120ms ease, transform 120ms ease, opacity 120ms ease; +} + +.stack-carousel-shell > .carousel-btn:first-of-type { + left: 0.85rem; +} + +.stack-carousel-shell > .carousel-btn:last-of-type { + right: 0.85rem; +} + +.stack-carousel-shell:hover .carousel-btn, +.stack-carousel-shell:focus-within .carousel-btn { + opacity: 1; + visibility: visible; + pointer-events: auto; + transform: translateY(-50%); +} + +.carousel-btn:hover { + border-color: #58a6ff; + background: rgba(56, 139, 253, 0.2); + transform: translateY(calc(-50% - 1px)); +} + +.carousel-btn:focus-visible { + outline: 2px solid #58a6ff; + outline-offset: 2px; +} + +@media (max-width: 760px) { + .stack-carousel-shell { + --carousel-nav-space: 0; + width: 100%; + margin-inline: 0; + } + + .stack-carousel-column { + width: 100%; + } + + .carousel-btn { + display: none; + } + + .stack-slide { + flex-basis: min(280px, 85%); + } +} + +@media (min-width: 1000px) { + .stack-slide { + flex-basis: calc((100% - 2rem) / 3); + } +} + diff --git a/src/components/tech-stack-carousel/tech-stack-carousel.html b/src/components/tech-stack-carousel/tech-stack-carousel.html new file mode 100644 index 0000000..db5e4e4 --- /dev/null +++ b/src/components/tech-stack-carousel/tech-stack-carousel.html @@ -0,0 +1,49 @@ + + + Tech Stack + Primary technologies I use to deliver production-ready software. + + + + ← + + + + + @for (skill of skills; track skill.name) { + + + + + {{ skill.fallbackLabel }} + + {{ skill.name }} + + {{ skill.category }} - {{ skill.level }} + + } + + + + + → + + + + diff --git a/src/components/tech-stack-carousel/tech-stack-carousel.ts b/src/components/tech-stack-carousel/tech-stack-carousel.ts new file mode 100644 index 0000000..3c5a62a --- /dev/null +++ b/src/components/tech-stack-carousel/tech-stack-carousel.ts @@ -0,0 +1,118 @@ +import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from '@angular/core'; +import { CardComponent } from '../card/card'; + +interface Skill { + name: string; + category: 'Frontend' | 'Backend' | 'Mobile' | 'Cloud' | 'Frontend / Backend' | 'Deployment'; + level: string; + logoUrl: string; + fallbackLabel: string; +} + +@Component({ + selector: 'app-tech-stack-carousel', + imports: [CardComponent], + templateUrl: './tech-stack-carousel.html', + styleUrl: './tech-stack-carousel.css', +}) +export class TechStackCarousel implements AfterViewInit, OnDestroy { + @ViewChild('stackCarousel') private readonly stackCarousel?: ElementRef; + private autoScrollTimer: ReturnType | null = null; + + readonly skills: Skill[] = [ + { name: 'Angular', category: 'Frontend', level: 'Advanced', logoUrl: '/tech-logos/angular.svg', fallbackLabel: 'NG' }, + { name: 'React', category: 'Frontend', level: 'Advanced', logoUrl: '/tech-logos/react.svg', fallbackLabel: 'RE' }, + { name: 'Ionic', category: 'Frontend', level: 'Advanced', logoUrl: '/tech-logos/ionic.svg', fallbackLabel: 'IO' }, + { name: 'TypeScript', category: 'Frontend / Backend', level: 'Advanced', logoUrl: '/tech-logos/typescript.svg', fallbackLabel: 'TS' }, + { name: 'Javascript', category: 'Frontend / Backend', level: 'Advanced', logoUrl: '/tech-logos/javascript.svg', fallbackLabel: 'JS' }, + { name: 'Docker', category: 'Deployment', level: 'Advanced', logoUrl: '/tech-logos/docker.svg', fallbackLabel: 'DK' }, + { name: 'C# / .NET', category: 'Frontend / Backend', level: 'Advanced', logoUrl: '/tech-logos/csharp.svg', fallbackLabel: 'CS' }, + { name: 'ASP.NET Core', category: 'Backend', level: 'Advanced', logoUrl: '/tech-logos/dotnet.svg', fallbackLabel: 'AS' }, + { name: 'WPF', category: 'Frontend', level: 'Advanced', logoUrl: '/tech-logos/wpf.svg', fallbackLabel: 'WP' }, + { name: 'Java', category: 'Backend', level: 'Advanced', logoUrl: '/tech-logos/java.svg', fallbackLabel: 'JV' }, + { name: 'Spring Boot', category: 'Backend', level: 'Advanced', logoUrl: '/tech-logos/spring.svg', fallbackLabel: 'SB' }, + { name: 'Flutter', category: 'Mobile', level: 'Advanced', logoUrl: '/tech-logos/flutter.svg', fallbackLabel: 'FL' }, + ]; + + ngAfterViewInit(): void { + const prefersReducedMotion = + typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + if (prefersReducedMotion) { + return; + } + + this.resumeAutoScroll(); + } + + ngOnDestroy(): void { + this.pauseAutoScroll(); + } + + scrollStack(direction: -1 | 1): void { + const carousel = this.stackCarousel?.nativeElement; + if (!carousel) { + return; + } + + carousel.scrollBy({ left: this.getScrollStep(carousel) * direction, behavior: 'smooth' }); + } + + pauseAutoScroll(): void { + if (!this.autoScrollTimer) { + return; + } + + clearInterval(this.autoScrollTimer); + this.autoScrollTimer = null; + } + + resumeAutoScroll(): void { + if (this.autoScrollTimer) { + return; + } + + this.autoScrollTimer = setInterval(() => { + this.autoAdvanceStack(); + }, 3200); + } + + onCarouselFocusOut(event: FocusEvent): void { + const next = event.relatedTarget as Node | null; + const shell = event.currentTarget as HTMLElement | null; + + if (!shell?.contains(next)) { + this.resumeAutoScroll(); + } + } + + onLogoError(event: Event): void { + const image = event.target as HTMLImageElement | null; + const wrapper = image?.closest('.skill-icon'); + wrapper?.classList.add('is-fallback'); + } + + private autoAdvanceStack(): void { + const carousel = this.stackCarousel?.nativeElement; + if (!carousel) { + return; + } + + const maxScroll = carousel.scrollWidth - carousel.clientWidth; + if (maxScroll <= 0) { + return; + } + + if (carousel.scrollLeft >= maxScroll - 4) { + carousel.scrollTo({ left: 0, behavior: 'smooth' }); + return; + } + + carousel.scrollBy({ left: this.getScrollStep(carousel), behavior: 'smooth' }); + } + + private getScrollStep(carousel: HTMLElement): number { + return Math.max(carousel.clientWidth * 0.82, 260); + } +} + diff --git a/src/pages/home/home.css b/src/pages/home/home.css index 4dbb64e..e73de7a 100644 --- a/src/pages/home/home.css +++ b/src/pages/home/home.css @@ -140,146 +140,6 @@ h2 { outline: none; } -.stack-carousel-shell { - --carousel-nav-space: 4.25rem; - position: relative; - width: calc(100% + (var(--carousel-nav-space) * 2)); - margin-inline: calc(var(--carousel-nav-space) * -1); - overflow: visible; -} - -.stack-carousel-column { - width: calc(100% - (var(--carousel-nav-space) * 2)); - margin-inline: auto; -} - -.stack-carousel { - display: flex; - gap: 1rem; - overflow-x: auto; - width: 100%; - padding: 0.2rem; - scroll-snap-type: x mandatory; - scrollbar-width: thin; - scrollbar-color: rgba(88, 166, 255, 0.45) rgba(33, 38, 45, 0.65); -} - -.stack-slide { - flex: 0 0 min(320px, calc(100% - 0.2rem)); - scroll-snap-align: start; -} - -.skill-heading { - display: flex; - align-items: center; - gap: 0.65rem; -} - -.skill-icon { - display: inline-flex; - justify-content: center; - align-items: center; - width: 2rem; - height: 2rem; - border-radius: 10px; - border: 1px solid rgba(88, 166, 255, 0.4); - background: radial-gradient(circle at 30% 20%, rgba(88, 166, 255, 0.2), rgba(22, 27, 34, 0.95)); - box-shadow: inset 0 1px 0 rgba(240, 246, 252, 0.08); - overflow: hidden; - transition: border-color 140ms ease, box-shadow 140ms ease, background-color 140ms ease; -} - -.skill-logo { - width: 1.2rem; - height: 1.2rem; - object-fit: contain; - opacity: 0.92; - filter: grayscale(1) brightness(1.25) contrast(0.92); - transition: transform 140ms ease, opacity 140ms ease, filter 140ms ease; -} - -.skill-fallback { - display: none; - font-size: 0.72rem; - font-weight: 700; - letter-spacing: 0.04em; - color: #9ecbff; -} - -.skill-icon.is-fallback .skill-logo { - display: none; -} - -.skill-icon.is-fallback .skill-fallback { - display: inline; -} - -.skill-card .meta { - margin-top: 0.9rem; -} - -.skill-card { - transition: border-color 140ms ease, box-shadow 140ms ease; -} - -.stack-slide:hover .skill-icon, -.stack-slide:focus-within .skill-icon { - border-color: rgba(121, 192, 255, 0.75); - box-shadow: 0 0 0 3px rgba(56, 139, 253, 0.18), inset 0 1px 0 rgba(240, 246, 252, 0.14); - background: radial-gradient(circle at 30% 20%, rgba(121, 192, 255, 0.28), rgba(22, 27, 34, 0.95)); -} - -.stack-slide:hover .skill-logo, -.stack-slide:focus-within .skill-logo { - opacity: 1; - transform: scale(1.04); - filter: grayscale(1) brightness(1.4) contrast(1); -} - -.carousel-btn { - position: absolute; - top: 50%; - width: 2.5rem; - height: 2.5rem; - border: 1px solid #3d444d; - border-radius: 999px; - background: rgba(33, 38, 45, 0.8); - color: #e6edf3; - cursor: pointer; - opacity: 0; - visibility: hidden; - pointer-events: none; - transform: translateY(calc(-50% + 2px)); - transition: border-color 120ms ease, background-color 120ms ease, transform 120ms ease, opacity 120ms ease; -} - -.stack-carousel-shell > .carousel-btn:first-of-type { - left: 0.85rem; -} - -.stack-carousel-shell > .carousel-btn:last-of-type { - right: 0.85rem; -} - -.stack-carousel-shell:hover .carousel-btn, -.stack-carousel-shell:focus-within .carousel-btn { - opacity: 1; - visibility: visible; - pointer-events: auto; - transform: translateY(-50%); -} - -.carousel-btn:hover { - border-color: #58a6ff; - background: rgba(56, 139, 253, 0.2); - transform: translateY(calc(-50% - 1px)); -} - -.carousel-btn:focus-visible { - outline: 2px solid #58a6ff; - outline-offset: 2px; -} - .card h3 { margin: 0; font-size: 1.08rem; @@ -308,27 +168,4 @@ h2 { padding: 2rem 1.2rem; } - .stack-carousel-shell { - --carousel-nav-space: 0; - width: 100%; - margin-inline: 0; - } - - .stack-carousel-column { - width: 100%; - } - - .carousel-btn { - display: none; - } - - .stack-slide { - flex-basis: min(280px, 85%); - } -} - -@media (min-width: 1000px) { - .stack-slide { - flex-basis: calc((100% - 2rem) / 3); - } } diff --git a/src/pages/home/home.html b/src/pages/home/home.html index 4590bae..8aee0df 100644 --- a/src/pages/home/home.html +++ b/src/pages/home/home.html @@ -29,53 +29,6 @@ - - - Tech Stack - Primary technologies I use to deliver production-ready software. - - - - ← - - - - - @for (skill of skills; track skill.name) { - - - - - {{ skill.fallbackLabel }} - - {{ skill.name }} - - {{ skill.category }} - {{ skill.level }} - - } - - - - - → - - - + diff --git a/src/pages/home/home.spec.ts b/src/pages/home/home.spec.ts index c95018c..45662ef 100644 --- a/src/pages/home/home.spec.ts +++ b/src/pages/home/home.spec.ts @@ -40,14 +40,16 @@ describe('Home', () => { it('should render stack carousel with icon badges', () => { const compiled = fixture.nativeElement as HTMLElement; - const carousel = compiled.querySelector('#stack .stack-carousel'); - const icons = compiled.querySelectorAll('#stack .skill-icon'); - const logos = compiled.querySelectorAll('#stack .skill-logo'); - const controls = compiled.querySelectorAll('#stack .carousel-btn'); + const stackSection = compiled.querySelector('app-tech-stack-carousel #stack'); + const carousel = compiled.querySelector('app-tech-stack-carousel .stack-carousel'); + const icons = compiled.querySelectorAll('app-tech-stack-carousel .skill-icon'); + const logos = compiled.querySelectorAll('app-tech-stack-carousel .skill-logo'); + const controls = compiled.querySelectorAll('app-tech-stack-carousel .carousel-btn'); + expect(stackSection).toBeTruthy(); expect(carousel).toBeTruthy(); - expect(icons.length).toBe(component.skills.length); - expect(logos.length).toBe(component.skills.length); + expect(icons.length).toBeGreaterThan(0); + expect(logos.length).toBe(icons.length); expect(logos[0]?.getAttribute('src')).toContain('/tech-logos/'); expect(controls.length).toBe(2); }); diff --git a/src/pages/home/home.ts b/src/pages/home/home.ts index 85dd1c4..ae69b0b 100644 --- a/src/pages/home/home.ts +++ b/src/pages/home/home.ts @@ -1,26 +1,16 @@ -import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from '@angular/core'; +import { Component } from '@angular/core'; import { RouterLink } from '@angular/router'; import { CardComponent } from '../../components/card/card'; +import { TechStackCarousel } from '../../components/tech-stack-carousel/tech-stack-carousel'; import { PROJECTS, Project } from '../projects/project-data'; -interface Skill { - name: string; - category: 'Frontend' | 'Backend' | 'Mobile' | 'Cloud' | 'Frontend / Backend' | 'Deployment'; - level: string; - logoUrl: string; - fallbackLabel: string; -} - @Component({ selector: 'app-home', - imports: [RouterLink, CardComponent], + imports: [RouterLink, CardComponent, TechStackCarousel], templateUrl: './home.html', styleUrl: './home.css', }) -export class Home implements AfterViewInit, OnDestroy { - @ViewChild('stackCarousel') private readonly stackCarousel?: ElementRef; - private autoScrollTimer: ReturnType | null = null; - +export class Home { readonly hero = { name: 'Tim Kainz', role: 'Fullstack Developer from Austria', @@ -28,101 +18,5 @@ export class Home implements AfterViewInit, OnDestroy { focus: 'Focused on performance, clean architecture, and product-minded delivery.', }; - readonly skills: Skill[] = [ - { name: 'Angular', category: 'Frontend', level: 'Advanced', logoUrl: '/tech-logos/angular.svg', fallbackLabel: 'NG' }, - { name: 'React', category: 'Frontend', level: 'Advanced', logoUrl: '/tech-logos/react.svg', fallbackLabel: 'RE' }, - { name: 'Ionic', category: 'Frontend', level: 'Advanced', logoUrl: '/tech-logos/ionic.svg', fallbackLabel: 'IO' }, - { name: 'TypeScript', category: 'Frontend / Backend', level: 'Advanced', logoUrl: '/tech-logos/typescript.svg', fallbackLabel: 'TS' }, - { name: 'Javascript', category: 'Frontend / Backend', level: 'Advanced', logoUrl: '/tech-logos/javascript.svg', fallbackLabel: 'JS' }, - { name: 'Docker', category: 'Deployment', level: 'Advanced', logoUrl: '/tech-logos/docker.svg', fallbackLabel: 'DK' }, - { name: 'C# / .NET', category: 'Frontend / Backend', level: 'Advanced', logoUrl: '/tech-logos/csharp.svg', fallbackLabel: 'CS' }, - { name: 'ASP.NET Core', category: 'Backend', level: 'Advanced', logoUrl: '/tech-logos/dotnet.svg', fallbackLabel: 'AS' }, - { name: 'WPF', category: 'Frontend', level: 'Advanced', logoUrl: '/tech-logos/wpf.svg', fallbackLabel: 'WP' }, - { name: 'Java', category: 'Backend', level: 'Advanced', logoUrl: '/tech-logos/java.svg', fallbackLabel: 'JV' }, - { name: 'Spring Boot', category: 'Backend', level: 'Advanced', logoUrl: '/tech-logos/spring.svg', fallbackLabel: 'SB' }, - { name: 'Flutter', category: 'Mobile', level: 'Advanced', logoUrl: '/tech-logos/flutter.svg', fallbackLabel: 'FL' }, - ]; - readonly projects: Project[] = PROJECTS; - - ngAfterViewInit(): void { - const prefersReducedMotion = - typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; - - if (prefersReducedMotion) { - return; - } - - this.resumeAutoScroll(); - } - - ngOnDestroy(): void { - this.pauseAutoScroll(); - } - - scrollStack(direction: -1 | 1): void { - const carousel = this.stackCarousel?.nativeElement; - if (!carousel) { - return; - } - - carousel.scrollBy({ left: this.getScrollStep(carousel) * direction, behavior: 'smooth' }); - } - - pauseAutoScroll(): void { - if (!this.autoScrollTimer) { - return; - } - - clearInterval(this.autoScrollTimer); - this.autoScrollTimer = null; - } - - resumeAutoScroll(): void { - if (this.autoScrollTimer) { - return; - } - - this.autoScrollTimer = setInterval(() => { - this.autoAdvanceStack(); - }, 3200); - } - - onCarouselFocusOut(event: FocusEvent): void { - const next = event.relatedTarget as Node | null; - const shell = event.currentTarget as HTMLElement | null; - - if (!shell?.contains(next)) { - this.resumeAutoScroll(); - } - } - - onLogoError(event: Event): void { - const image = event.target as HTMLImageElement | null; - const wrapper = image?.closest('.skill-icon'); - wrapper?.classList.add('is-fallback'); - } - - private autoAdvanceStack(): void { - const carousel = this.stackCarousel?.nativeElement; - if (!carousel) { - return; - } - - const maxScroll = carousel.scrollWidth - carousel.clientWidth; - if (maxScroll <= 0) { - return; - } - - if (carousel.scrollLeft >= maxScroll - 4) { - carousel.scrollTo({ left: 0, behavior: 'smooth' }); - return; - } - - carousel.scrollBy({ left: this.getScrollStep(carousel), behavior: 'smooth' }); - } - - private getScrollStep(carousel: HTMLElement): number { - return Math.max(carousel.clientWidth * 0.82, 260); - } }
Primary technologies I use to deliver production-ready software.
{{ skill.category }} - {{ skill.level }}