videofolxtv/client/src/components/netflix-grid.tsx
sebastjanartic 98c5d91fac Adjust spacing for video category navigation on mobile devices
Update the styling for mobile navigation dots in the Netflix grid component by reducing top and bottom margin and padding.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 2cd2c0bc-434c-4bc9-ad3f-b99d3897a0d1
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/2cd2c0bc-434c-4bc9-ad3f-b99d3897a0d1/OdlP8Wj
2025-09-03 08:32:35 +00:00

420 lines
15 KiB
TypeScript

import { useState, useRef, useEffect, useMemo } from "react";
import { useLocation } from "wouter";
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 [, setLocation] = useLocation();
const handleVideoClick = (video: Video) => {
// Navigate to individual video page instead of modal
setLocation(`/video/${video.id}`);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setSelectedVideo(null);
};
const handleVideoChange = (video: Video) => {
setSelectedVideo(video);
};
// Memoize categories to avoid recalculation on every render
const categories = useMemo((): 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()
);
// FOLX STADL videos
const folxStadlVideos = videos.filter(video =>
video.title.includes("FOLX STADL") || video.title.includes("FOLXSTADL")
);
return [
{
title: "Meist Angesehen",
videos: sortedByViews.slice(0, 10)
},
...(folxStadlVideos.length > 0 ? [{
title: "FOLX STADL",
videos: folxStadlVideos.slice(0, 12)
}] : []),
{
title: "VIDEO",
videos: (() => {
// Filter videos that specifically contain "Geschichte des Liedes VIDEO" in title or description
const videoVideos = videos.filter(video =>
video.title.toLowerCase().includes("geschichte des liedes video") ||
video.description?.toLowerCase().includes("geschichte des liedes video")
);
// If no specific "Geschichte des Liedes VIDEO" videos found, fallback to general filter
if (videoVideos.length === 0) {
const fallbackVideos = videos.filter(video =>
!video.title.includes("FOLX STADL") &&
!video.title.includes("FOLXSTADL") &&
!video.title.includes("Gipfelstammtisch")
);
const shuffled = [...fallbackVideos].sort(() => Math.random() - 0.5);
return shuffled.slice(0, 15);
}
// Shuffle the Geschichte des Liedes VIDEO videos randomly
const shuffled = [...videoVideos].sort(() => Math.random() - 0.5);
// Return 15 random videos from the VIDEO collection
return shuffled.slice(0, 15);
})()
},
{
title: "DIE Geschichte des Liedes",
videos: (() => {
// Filter videos that specifically contain "Geschichte des Liedes" in title or description
const gdlVideos = videos.filter(video =>
video.title.toLowerCase().includes("geschichte des liedes") ||
video.description?.toLowerCase().includes("geschichte des liedes")
);
// If no specific "Geschichte des Liedes" videos found, fallback to general filter
if (gdlVideos.length === 0) {
const fallbackVideos = videos.filter(video =>
!video.title.includes("FOLX STADL") &&
!video.title.includes("FOLXSTADL") &&
!video.title.includes("Gipfelstammtisch")
);
const shuffled = [...fallbackVideos].sort(() => Math.random() - 0.5);
return shuffled.slice(0, 10);
}
// Shuffle the Geschichte des Liedes videos randomly
const shuffled = [...gdlVideos].sort(() => Math.random() - 0.5);
// Return 10 random videos from the collection
return shuffled.slice(0, 10);
})()
}
];
}, [videos]);
if (isLoading && videos.length === 0) {
return (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="w-16 h-16 gradient-primary rounded-lg flex items-center justify-center shadow-lg animate-pulse mb-4 mx-auto">
<div className="w-0 h-0 border-l-[12px] border-l-white border-y-[9px] border-y-transparent ml-1"></div>
</div>
<h3 className="text-white text-xl font-bold mb-2">go4.video</h3>
<p className="text-bunny-light">Loading videos...</p>
</div>
</div>
);
}
if (videos.length === 0) {
return (
<div className="text-center py-12">
<div className="w-12 h-12 gradient-primary rounded-lg flex items-center justify-center shadow-lg mb-4 mx-auto opacity-50">
<div className="w-0 h-0 border-l-[9px] border-l-white border-y-[7px] border-y-transparent ml-1"></div>
</div>
<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>
);
}
// Categories are now memoized above
return (
<>
<div className="space-y-8">
{categories.map((category, categoryIndex) => (
<div key={categoryIndex} className={`${categoryIndex === 0 ? 'mt-2 mb-6' : 'mb-6'}`}>
<CategoryRow
category={category}
onVideoClick={handleVideoClick}
hideScrollButtons={false}
/>
</div>
))}
</div>
<BunnyVideoModal
video={selectedVideo}
isOpen={isModalOpen}
onClose={handleCloseModal}
videos={videos}
onVideoChange={handleVideoChange}
/>
</>
);
}
interface CategoryRowProps {
category: VideoCategory;
onVideoClick: (video: Video) => void;
hideScrollButtons?: boolean;
}
function CategoryRow({ category, onVideoClick, hideScrollButtons = false }: CategoryRowProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [isScrolling, setIsScrolling] = useState(false);
const scrollIntervalRef = useRef<NodeJS.Timeout>();
const [clickedVideoId, setClickedVideoId] = useState<string | null>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(true);
const [currentIndex, setCurrentIndex] = useState(0);
const [touchStart, setTouchStart] = useState(0);
const [touchEnd, setTouchEnd] = useState(0);
const checkScrollButtons = () => {
if (scrollRef.current) {
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setCanScrollLeft(scrollLeft > 0);
setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 1);
}
};
const scroll = (direction: 'left' | 'right') => {
if (scrollRef.current) {
// Viewport-based scroll amount for full-width cards
const containerWidth = scrollRef.current.clientWidth;
const scrollAmount = containerWidth * 0.8; // Scroll 80% of visible area
const currentScroll = scrollRef.current.scrollLeft;
const targetScroll = direction === 'left'
? currentScroll - scrollAmount
: currentScroll + scrollAmount;
scrollRef.current.scrollTo({
left: targetScroll,
behavior: 'smooth'
});
// Check scroll buttons after animation completes
setTimeout(checkScrollButtons, 300);
}
};
const startAutoScroll = (direction: 'left' | 'right') => {
// Stop any existing scrolling first
if (scrollIntervalRef.current) {
clearInterval(scrollIntervalRef.current);
}
setIsScrolling(true);
scrollIntervalRef.current = setInterval(() => {
if (scrollRef.current) {
const scrollStep = direction === 'left' ? -3 : 3;
scrollRef.current.scrollLeft += scrollStep;
}
}, 16); // 60fps smooth scrolling
};
const stopAutoScroll = () => {
setIsScrolling(false);
if (scrollIntervalRef.current) {
clearInterval(scrollIntervalRef.current);
}
};
useEffect(() => {
checkScrollButtons();
return () => {
if (scrollIntervalRef.current) {
clearInterval(scrollIntervalRef.current);
}
};
}, [category.videos]);
const handleScroll = () => {
checkScrollButtons();
// Calculate current index for mobile dots
if (scrollRef.current) {
const containerWidth = scrollRef.current.clientWidth;
const scrollLeft = scrollRef.current.scrollLeft;
const cardWidth = containerWidth; // Full width cards on mobile
const newIndex = Math.round(scrollLeft / cardWidth);
setCurrentIndex(newIndex);
}
};
// Touch handlers for mobile swipe
const handleTouchStart = (e: React.TouchEvent) => {
setTouchEnd(0);
setTouchStart(e.targetTouches[0].clientX);
};
const handleTouchMove = (e: React.TouchEvent) => {
setTouchEnd(e.targetTouches[0].clientX);
};
const handleTouchEnd = () => {
if (!touchStart || !touchEnd) return;
const distance = touchStart - touchEnd;
const isLeftSwipe = distance > 50; // Swipe left (next)
const isRightSwipe = distance < -50; // Swipe right (previous)
if (isLeftSwipe && currentIndex < Math.min(10, category.videos.length) - 1) {
// Navigate to next card
const nextIndex = currentIndex + 1;
if (scrollRef.current) {
const containerWidth = scrollRef.current.clientWidth;
scrollRef.current.scrollTo({
left: nextIndex * containerWidth,
behavior: 'smooth'
});
setCurrentIndex(nextIndex);
}
}
if (isRightSwipe && currentIndex > 0) {
// Navigate to previous card
const prevIndex = currentIndex - 1;
if (scrollRef.current) {
const containerWidth = scrollRef.current.clientWidth;
scrollRef.current.scrollTo({
left: prevIndex * containerWidth,
behavior: 'smooth'
});
setCurrentIndex(prevIndex);
}
}
};
return (
<div
className="relative"
onMouseLeave={() => setClickedVideoId(null)}
>
<h2 className="text-lg font-medium text-bunny-light mb-1 mx-2 leading-tight uppercase">
{category.title}
</h2>
<div className="relative overflow-hidden">
{/* Left scroll button - small circular on videos */}
{!hideScrollButtons && canScrollLeft && (
<Button
onClick={() => scroll('left')}
onMouseEnter={() => startAutoScroll('left')}
onMouseLeave={stopAutoScroll}
className="absolute left-2 top-1/2 -translate-y-1/2 z-[60] bg-gradient-to-r from-purple-600 to-blue-500 hover:from-purple-700 hover:to-blue-600 text-white border-none w-8 h-8 rounded-full transition-all duration-300 hidden md:flex items-center justify-center shadow-xl opacity-75"
size="sm"
>
<ChevronLeft className="w-4 h-4" />
</Button>
)}
{/* Right scroll button - small circular on videos */}
{!hideScrollButtons && canScrollRight && (
<Button
onClick={() => scroll('right')}
onMouseEnter={() => startAutoScroll('right')}
onMouseLeave={stopAutoScroll}
className="absolute right-2 top-1/2 -translate-y-1/2 z-[60] bg-gradient-to-r from-purple-600 to-blue-500 hover:from-purple-700 hover:to-blue-600 text-white border-none w-8 h-8 rounded-full transition-all duration-300 hidden md:flex items-center justify-center shadow-xl opacity-75"
size="sm"
>
<ChevronRight className="w-4 h-4" />
</Button>
)}
{/* Scrollable video row - in container */}
<div
ref={scrollRef}
className="flex gap-3 overflow-x-auto scrollbar-hide py-4 px-2"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
onScroll={handleScroll}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{category.videos.map((video, index) => (
<div
key={video.id}
className="flex-shrink-0 w-[calc(100vw-3rem)] sm:w-[330px] relative hover:z-50"
onMouseEnter={() => setClickedVideoId(video.id)}
>
{/* Top 10 Number overlay for first category */}
{category.title.includes("Meist Angesehen") && index < 10 && (window.innerWidth >= 768 ? clickedVideoId !== video.id : true) && (
<div className="absolute top-0 left-2 z-20 text-white font-black text-5xl sm:text-6xl md:text-7xl drop-shadow-2xl transition-opacity duration-300"
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)'
}}>
{index + 1}
</div>
)}
<VideoCard
video={video}
onClick={(video) => {
setClickedVideoId(video.id);
onVideoClick(video);
}}
className="w-full hover:scale-102 md:hover:scale-105 hover:z-50 transition-all duration-300 md:duration-500 hover:shadow-2xl rounded-lg overflow-hidden"
/>
</div>
))}
</div>
{/* Mobile navigation dots - only visible on mobile, under all video rows */}
<div className="md:hidden flex justify-center mt-2 mb-2 gap-1 py-1">
{Array.from({ length: Math.min(10, category.videos.length) }, (_, index) => (
<button
key={index}
onClick={() => {
if (scrollRef.current) {
const containerWidth = scrollRef.current.clientWidth;
scrollRef.current.scrollTo({
left: index * containerWidth,
behavior: 'smooth'
});
setCurrentIndex(index);
}
}}
className={`rounded-full transition-all duration-300 ${
index === currentIndex
? 'bg-gradient-to-r from-purple-500 to-blue-500'
: 'bg-white/15 hover:bg-white/25'
}`}
style={{
width: '8px',
height: '8px',
minWidth: '8px',
minHeight: '8px',
transform: index === currentIndex ? 'scale(1.25)' : 'scale(1)'
}}
aria-label={`Go to card ${index + 1}`}
/>
))}
</div>
</div>
</div>
);
}