204 lines
7.5 KiB
TypeScript
204 lines
7.5 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
|
|
import LeagueCard from '@/components/leagues/LeagueCard';
|
|
|
|
interface LeagueSliderProps {
|
|
title: string;
|
|
icon: React.ElementType;
|
|
description: string;
|
|
leagues: LeagueSummaryViewModel[];
|
|
onLeagueClick: (id: string) => void;
|
|
autoScroll?: boolean;
|
|
iconColor?: string;
|
|
scrollSpeedMultiplier?: number;
|
|
scrollDirection?: 'left' | 'right';
|
|
}
|
|
|
|
export const LeagueSlider = ({
|
|
title,
|
|
icon: Icon,
|
|
description,
|
|
leagues,
|
|
onLeagueClick,
|
|
autoScroll = true,
|
|
iconColor = 'text-primary-blue',
|
|
scrollSpeedMultiplier = 1,
|
|
scrollDirection = 'right',
|
|
}: LeagueSliderProps) => {
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
|
const [canScrollRight, setCanScrollRight] = useState(true);
|
|
const [isHovering, setIsHovering] = useState(false);
|
|
const animationRef = useRef<number | null>(null);
|
|
const scrollPositionRef = useRef(0);
|
|
|
|
const checkScrollButtons = useCallback(() => {
|
|
if (scrollRef.current) {
|
|
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
|
setCanScrollLeft(scrollLeft > 0);
|
|
setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 10);
|
|
}
|
|
}, []);
|
|
|
|
const scroll = useCallback((direction: 'left' | 'right') => {
|
|
if (scrollRef.current) {
|
|
const cardWidth = 340;
|
|
const scrollAmount = direction === 'left' ? -cardWidth : cardWidth;
|
|
// Update the ref so auto-scroll continues from new position
|
|
scrollPositionRef.current = scrollRef.current.scrollLeft + scrollAmount;
|
|
scrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
|
|
}
|
|
}, []);
|
|
|
|
// Initialize scroll position for left-scrolling sliders
|
|
useEffect(() => {
|
|
if (scrollDirection === 'left' && scrollRef.current) {
|
|
const { scrollWidth, clientWidth } = scrollRef.current;
|
|
scrollPositionRef.current = scrollWidth - clientWidth;
|
|
scrollRef.current.scrollLeft = scrollPositionRef.current;
|
|
}
|
|
}, [scrollDirection, leagues.length]);
|
|
|
|
// Smooth continuous auto-scroll using requestAnimationFrame with variable speed and direction
|
|
useEffect(() => {
|
|
// Allow scroll even with just 2 leagues (minimum threshold = 1)
|
|
if (!autoScroll || leagues.length <= 1) return;
|
|
|
|
const scrollContainer = scrollRef.current;
|
|
if (!scrollContainer) return;
|
|
|
|
let lastTimestamp = 0;
|
|
// Base speed with multiplier for variation between sliders
|
|
const baseSpeed = 0.025;
|
|
const scrollSpeed = baseSpeed * scrollSpeedMultiplier;
|
|
const directionMultiplier = scrollDirection === 'left' ? -1 : 1;
|
|
|
|
const animate = (timestamp: number) => {
|
|
if (!isHovering && scrollContainer) {
|
|
const delta = lastTimestamp ? timestamp - lastTimestamp : 0;
|
|
lastTimestamp = timestamp;
|
|
|
|
scrollPositionRef.current += scrollSpeed * delta * directionMultiplier;
|
|
|
|
const { scrollWidth, clientWidth } = scrollContainer;
|
|
const maxScroll = scrollWidth - clientWidth;
|
|
|
|
// Handle wrap-around for both directions
|
|
if (scrollDirection === 'right' && scrollPositionRef.current >= maxScroll) {
|
|
scrollPositionRef.current = 0;
|
|
} else if (scrollDirection === 'left' && scrollPositionRef.current <= 0) {
|
|
scrollPositionRef.current = maxScroll;
|
|
}
|
|
|
|
scrollContainer.scrollLeft = scrollPositionRef.current;
|
|
} else {
|
|
lastTimestamp = timestamp;
|
|
}
|
|
|
|
animationRef.current = requestAnimationFrame(animate);
|
|
};
|
|
|
|
animationRef.current = requestAnimationFrame(animate);
|
|
|
|
return () => {
|
|
if (animationRef.current) {
|
|
cancelAnimationFrame(animationRef.current);
|
|
}
|
|
};
|
|
}, [autoScroll, leagues.length, isHovering, scrollSpeedMultiplier, scrollDirection]);
|
|
|
|
// Sync scroll position when user manually scrolls
|
|
useEffect(() => {
|
|
const scrollContainer = scrollRef.current;
|
|
if (!scrollContainer) return;
|
|
|
|
const handleScroll = () => {
|
|
scrollPositionRef.current = scrollContainer.scrollLeft;
|
|
checkScrollButtons();
|
|
};
|
|
|
|
scrollContainer.addEventListener('scroll', handleScroll);
|
|
return () => scrollContainer.removeEventListener('scroll', handleScroll);
|
|
}, [checkScrollButtons]);
|
|
|
|
if (leagues.length === 0) return null;
|
|
|
|
return (
|
|
<div className="mb-10">
|
|
{/* Section header */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`flex h-10 w-10 items-center justify-center rounded-xl bg-iron-gray border border-charcoal-outline`}>
|
|
<Icon className={`w-5 h-5 ${iconColor}`} />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-white">{title}</h2>
|
|
<p className="text-xs text-gray-500">{description}</p>
|
|
</div>
|
|
<span className="ml-2 px-2 py-0.5 rounded-full text-xs bg-charcoal-outline/50 text-gray-400">
|
|
{leagues.length}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Navigation arrows */}
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => scroll('left')}
|
|
disabled={!canScrollLeft}
|
|
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-all ${
|
|
canScrollLeft
|
|
? 'bg-iron-gray border border-charcoal-outline text-white hover:border-primary-blue hover:text-primary-blue'
|
|
: 'bg-iron-gray/30 border border-charcoal-outline/30 text-gray-600 cursor-not-allowed'
|
|
}`}
|
|
>
|
|
<ChevronLeft className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => scroll('right')}
|
|
disabled={!canScrollRight}
|
|
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-all ${
|
|
canScrollRight
|
|
? 'bg-iron-gray border border-charcoal-outline text-white hover:border-primary-blue hover:text-primary-blue'
|
|
: 'bg-iron-gray/30 border border-charcoal-outline/30 text-gray-600 cursor-not-allowed'
|
|
}`}
|
|
>
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Scrollable container with fade edges */}
|
|
<div className="relative">
|
|
{/* Left fade gradient */}
|
|
<div className="absolute left-0 top-0 bottom-4 w-12 bg-gradient-to-r from-deep-graphite to-transparent z-10 pointer-events-none" />
|
|
{/* Right fade gradient */}
|
|
<div className="absolute right-0 top-0 bottom-4 w-12 bg-gradient-to-l from-deep-graphite to-transparent z-10 pointer-events-none" />
|
|
|
|
<div
|
|
ref={scrollRef}
|
|
onMouseEnter={() => setIsHovering(true)}
|
|
onMouseLeave={() => setIsHovering(false)}
|
|
className="league-slider__scroll flex gap-4 overflow-x-auto pb-4 px-4"
|
|
style={{
|
|
scrollbarWidth: 'none',
|
|
msOverflowStyle: 'none',
|
|
}}
|
|
>
|
|
<style>{`
|
|
.league-slider__scroll::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
`}</style>
|
|
{leagues.map((league) => (
|
|
<div key={league.id} className="flex-shrink-0 w-[320px] h-full">
|
|
<LeagueCard league={league} onClick={() => onLeagueClick(league.id)} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}; |