extract carousel to component
All checks were successful
publish.yml / publish (push) Successful in 1m3s

This commit is contained in:
2026-04-16 19:50:40 +02:00
parent 3072668e05
commit 6f30fa2260
7 changed files with 381 additions and 327 deletions

View File

@@ -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<HTMLElement>;
private autoScrollTimer: ReturnType<typeof setInterval> | 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);
}
}