videofolxtv/client/src/components/netflix-grid.tsx
sebastjanartic 6db85a98cf Improve visibility of scrolling arrows in video carousels
Update the CSS class for the left and right scrolling buttons in the Netflix grid component to use an opacity of 0.8, making them consistently visible.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 2eb1084e-b728-4449-9231-f1665924c8d5
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/2eb1084e-b728-4449-9231-f1665924c8d5/IgpyBVt
2025-08-29 16:23:56 +00:00

366 lines
12 KiB
TypeScript

import { useState, useRef, useEffect } from "react";
import { type Video } from "@shared/schema";
import VideoCard from "./video-card";
import BunnyVideoModal from "./bunny-video-modal";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
interface VideoCategory {
title: string;
videos: Video[];
}
interface NetflixGridProps {
videos: Video[];
isLoading: boolean;
}
export default function NetflixGrid({ videos, isLoading }: NetflixGridProps) {
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const handleVideoClick = (video: Video) => {
setSelectedVideo(video);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setSelectedVideo(null);
};
const handleVideoChange = (video: Video) => {
setSelectedVideo(video);
};
// Organize videos into categories
const getCategories = (): VideoCategory[] => {
if (!videos.length) return [];
// Sort by views for top content
const sortedByViews = [...videos].sort((a, b) => (b.views || 0) - (a.views || 0));
// Sort by date for recently added
const sortedByDate = [...videos].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
return [
{
title: "Top 10 Videos Today",
videos: sortedByViews.slice(0, 10)
},
{
title: "Popular Videos",
videos: sortedByViews.slice(10, 20)
},
{
title: "Recently Added",
videos: sortedByDate.slice(0, 15)
},
{
title: "Trending Now",
videos: videos.slice(0, 12)
}
];
};
if (isLoading && videos.length === 0) {
return (
<div className="space-y-8">
{Array.from({ length: 3 }).map((_, categoryIndex) => (
<div key={categoryIndex} className="space-y-4">
<div className="h-6 bg-bunny-gray rounded w-48 animate-pulse"></div>
<div className="flex space-x-4 overflow-hidden">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="flex-shrink-0 w-56 md:w-80 animate-pulse">
<div className="bg-bunny-gray aspect-[9/16] md:aspect-[16/9] rounded-xl mb-3"></div>
<div className="space-y-2">
<div className="h-4 bg-bunny-gray rounded w-3/4"></div>
<div className="h-3 bg-bunny-gray rounded w-1/2"></div>
</div>
</div>
))}
</div>
</div>
))}
</div>
);
}
if (videos.length === 0) {
return (
<div className="text-center py-12">
<div className="text-bunny-muted text-lg mb-4">
No videos found
</div>
<p className="text-sm text-bunny-muted">
Try adjusting your search or filter criteria
</p>
</div>
);
}
const categories = getCategories();
return (
<>
<div className="space-y-12">
{categories.map((category, categoryIndex) => (
<CategoryRow
key={categoryIndex}
category={category}
onVideoClick={handleVideoClick}
/>
))}
</div>
<BunnyVideoModal
video={selectedVideo}
isOpen={isModalOpen}
onClose={handleCloseModal}
videos={videos}
onVideoChange={handleVideoChange}
/>
</>
);
}
interface CategoryRowProps {
category: VideoCategory;
onVideoClick: (video: Video) => void;
}
function CategoryRow({ category, onVideoClick }: CategoryRowProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const scrollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const [showLeftButton, setShowLeftButton] = useState(false);
const [showRightButton, setShowRightButton] = useState(true);
const [translateX, setTranslateX] = useState(0);
const [isScrolling, setIsScrolling] = useState(false);
const [speedMode, setSpeedMode] = useState<'normal' | 'fast'>('normal');
const videosToShow = 5; // Show 5 videos at a time
const videoWidth = 120; // Width including spacing
const scroll = (direction: 'left' | 'right') => {
// Only move one step when clicked (no speed change here)
const step = direction === 'right' ? -videoWidth : videoWidth;
setTranslateX(prev => prev + step);
};
const toggleSpeed = (direction: 'left' | 'right') => {
// Toggle speed mode and restart animation with new speed
const newSpeed = speedMode === 'normal' ? 'fast' : 'normal';
setSpeedMode(newSpeed);
// Restart the animation with new speed
if (isScrolling && scrollIntervalRef.current) {
clearInterval(scrollIntervalRef.current);
// Start new interval with updated speed immediately
const baseSpeed = newSpeed === 'fast' ? 2.5 : 0.8;
const interval = 16; // Fixed interval - speed controlled by pixel movement only
scrollIntervalRef.current = setInterval(() => {
setTranslateX(prev => {
// Use the NEW speed that was just set
const currentSpeed = newSpeed === 'fast' ? 3.5 : 2.0;
const speed = direction === 'right' ? -currentSpeed : currentSpeed;
const newX = prev + speed;
const totalWidth = category.videos.length * videoWidth;
// Pure continuous flow - NO RESETS AT ALL
// Let it move freely, browser will handle infinite scroll
return newX;
});
}, interval);
}
};
const startAutoScroll = (direction: 'left' | 'right') => {
// Clear any existing interval
if (scrollIntervalRef.current) {
clearInterval(scrollIntervalRef.current);
}
setIsScrolling(true);
// Start continuous smooth scrolling with variable speed
scrollIntervalRef.current = setInterval(() => {
setTranslateX(prev => {
// Faster speed for better movement
const baseSpeed = 2.0; // Faster speed on hover
const speed = direction === 'right' ? -baseSpeed : baseSpeed;
const newX = prev + speed;
const totalWidth = category.videos.length * videoWidth;
// Pure continuous flow - NO RESETS AT ALL
// Let it move freely, browser will handle infinite scroll
return newX;
});
}, 16); // Fixed interval - speed controlled by pixel movement only
};
// Initialize with first video on the left side
useEffect(() => {
if (category.videos.length > 0) {
// Start with video 1 visible on the left side
setTranslateX(0);
}
}, [category.videos.length]);
// Always show both buttons
useEffect(() => {
setShowLeftButton(true);
setShowRightButton(true);
}, []);
const stopAutoScroll = () => {
setIsScrolling(false);
if (scrollIntervalRef.current) {
clearInterval(scrollIntervalRef.current);
scrollIntervalRef.current = null;
}
};
return (
<div className="relative group mb-8">
<h2 className="text-2xl font-bold text-bunny-light mb-6 px-4">
{category.title}
</h2>
<div className="relative">
{/* Left scroll button - positioned at video thumbnail center */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// If already scrolling, just toggle speed, otherwise do single scroll
if (isScrolling) {
toggleSpeed('left');
} else {
scroll('left');
}
}}
onMouseEnter={(e) => {
e.stopPropagation();
startAutoScroll('left');
}}
onMouseLeave={(e) => {
e.stopPropagation();
stopAutoScroll();
}}
className="flex absolute left-2 top-[45%] -translate-y-1/2 w-12 h-12 z-30 bg-black/80 hover:bg-black/95 rounded-full items-center justify-center transition-all duration-300 cursor-pointer border border-white/30 shadow-lg opacity-80 hover:!opacity-100"
data-testid="button-scroll-left"
>
<ChevronLeft className="w-6 h-6 text-white" />
</button>
{/* Right scroll button - positioned at video thumbnail center */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// If already scrolling, just toggle speed, otherwise do single scroll
if (isScrolling) {
toggleSpeed('right');
} else {
scroll('right');
}
}}
onMouseEnter={(e) => {
e.stopPropagation();
startAutoScroll('right');
}}
onMouseLeave={(e) => {
e.stopPropagation();
stopAutoScroll();
}}
className="flex absolute right-2 top-[45%] -translate-y-1/2 w-12 h-12 z-30 bg-black/80 hover:bg-black/95 rounded-full items-center justify-center transition-all duration-300 cursor-pointer border border-white/30 shadow-lg opacity-80 hover:!opacity-100"
data-testid="button-scroll-right"
>
<ChevronRight className="w-6 h-6 text-white" />
</button>
{/* Mobile scroll buttons */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
scroll('left');
}}
onTouchStart={(e) => {
e.stopPropagation();
startAutoScroll('left');
}}
onTouchEnd={(e) => {
e.stopPropagation();
stopAutoScroll();
}}
className="md:hidden absolute left-1 top-[45%] -translate-y-1/2 w-10 h-10 z-40 bg-black/80 rounded-full flex items-center justify-center border border-white/30 shadow-lg"
data-testid="button-mobile-scroll-left"
>
<ChevronLeft className="w-5 h-5 text-white" />
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
scroll('right');
}}
onTouchStart={(e) => {
e.stopPropagation();
startAutoScroll('right');
}}
onTouchEnd={(e) => {
e.stopPropagation();
stopAutoScroll();
}}
className="md:hidden absolute right-1 top-[45%] -translate-y-1/2 w-10 h-10 z-40 bg-black/80 rounded-full flex items-center justify-center border border-white/30 shadow-lg"
data-testid="button-mobile-scroll-right"
>
<ChevronRight className="w-5 h-5 text-white" />
</button>
{/* Continuous flowing carousel - videos flow across entire width */}
<div className="overflow-hidden">
<div
className="flex space-x-2 md:space-x-3 pb-4 px-4 md:px-0"
style={{
transform: `translateX(${translateX}px)`,
willChange: 'transform',
transition: isScrolling ? 'none' : 'transform 0.3s ease'
}}
>
{/* Many copies for true infinite scroll */}
{[...Array(10)].flatMap(() => category.videos).map((video, index) => {
const actualIndex = index % category.videos.length;
return (
<div key={`${video.id}-${Math.floor(index / category.videos.length)}-${actualIndex}`} className="flex-shrink-0 w-28 md:w-52 relative group">
{/* Top 10 Number overlay for first category */}
{category.title.includes("Top 10") && (
<div className="absolute top-1 left-1 z-30 text-white font-black text-3xl md:text-5xl drop-shadow-2xl pointer-events-none"
style={{
textShadow: '4px 4px 8px rgba(0,0,0,0.8), -2px -2px 4px rgba(0,0,0,0.6)',
WebkitTextStroke: '2px rgba(0,0,0,0.8)',
transform: 'none',
transition: 'none'
}}>
{actualIndex + 1}
</div>
)}
<VideoCard
video={video}
onClick={onVideoClick}
className="w-full hover:scale-105 hover:z-10 transition-all duration-300 group-hover:shadow-xl rounded-md overflow-hidden"
/>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}