Refactors video routing and client-side components to generate and utilize shortened, 8-character IDs derived from the full UUIDs. This change enhances the usability of shared video links by removing dashes and truncating the UUID. The backend now supports fetching videos by either the short or full ID, ensuring backward compatibility. Additionally, ad retrieval and view count updates correctly use the full video ID to maintain data integrity with Bunny.net and the database. 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/HCAS0JG
471 lines
17 KiB
TypeScript
471 lines
17 KiB
TypeScript
import { useState, useRef, useEffect, useMemo, useCallback } 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) => {
|
|
// Generate short ID for cleaner URLs (first 8 chars without dashes)
|
|
const shortId = video.id.replace(/-/g, '').substring(0, 8);
|
|
// Navigate to individual video page with short ID
|
|
setLocation(`/video/${shortId}`);
|
|
};
|
|
|
|
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: (() => {
|
|
// Shuffle FOLX STADL videos randomly
|
|
const shuffled = [...folxStadlVideos].sort(() => Math.random() - 0.5);
|
|
return shuffled.slice(0, 10);
|
|
})()
|
|
}] : []),
|
|
{
|
|
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, 10);
|
|
}
|
|
|
|
// Shuffle the Geschichte des Liedes VIDEO videos randomly
|
|
const shuffled = [...videoVideos].sort(() => Math.random() - 0.5);
|
|
|
|
// Return 10 random videos from the VIDEO collection
|
|
return shuffled.slice(0, 10);
|
|
})()
|
|
},
|
|
{
|
|
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 [scrollTimer, setScrollTimer] = useState<NodeJS.Timeout | null>(null);
|
|
|
|
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) {
|
|
// Calculate card width with gap for precise scrolling
|
|
const containerWidth = scrollRef.current.clientWidth;
|
|
const isMobile = window.innerWidth < 768;
|
|
const cardWidth = isMobile ? containerWidth - 24 : 330; // 24px for mobile margins (3rem - 1.5rem each side)
|
|
const gap = 12; // 3 * 0.25rem = 12px gap
|
|
const scrollAmount = cardWidth + gap;
|
|
|
|
const currentScroll = scrollRef.current.scrollLeft;
|
|
const targetScroll = direction === 'left'
|
|
? Math.max(0, 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]);
|
|
|
|
// Cleanup timer on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (scrollTimer) {
|
|
clearTimeout(scrollTimer);
|
|
}
|
|
};
|
|
}, [scrollTimer]);
|
|
|
|
const handleScroll = () => {
|
|
checkScrollButtons();
|
|
|
|
// Clear existing timer
|
|
if (scrollTimer) {
|
|
clearTimeout(scrollTimer);
|
|
}
|
|
|
|
// Calculate current index for mobile dots with delay
|
|
if (scrollRef.current) {
|
|
const containerWidth = scrollRef.current.clientWidth;
|
|
const scrollLeft = scrollRef.current.scrollLeft;
|
|
const isMobile = window.innerWidth < 768;
|
|
|
|
if (isMobile) {
|
|
// Debounce the dot update to prevent flickering during scroll
|
|
const timer = setTimeout(() => {
|
|
// Card width should match CSS: calc(100vw - 1.5rem) = window width - 24px
|
|
const cardWidth = window.innerWidth - 24;
|
|
const scrollProgress = scrollLeft / cardWidth;
|
|
const newIndex = Math.round(scrollProgress);
|
|
const clampedIndex = Math.min(Math.max(newIndex, 0), 9); // Ensure 0-9 range
|
|
|
|
// Only update if index actually changed
|
|
if (clampedIndex !== currentIndex) {
|
|
setCurrentIndex(clampedIndex);
|
|
}
|
|
}, 100); // 100ms delay for stable positioning
|
|
|
|
setScrollTimer(timer);
|
|
}
|
|
}
|
|
};
|
|
|
|
// 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 < 9) { // Max index 9 for 10 cards
|
|
// Navigate to next card - snap to left edge of screen
|
|
const nextIndex = currentIndex + 1;
|
|
if (scrollRef.current) {
|
|
// Card width should match CSS: calc(100vw - 1.5rem) = window width - 24px
|
|
const cardWidth = window.innerWidth - 24;
|
|
// Scroll so only one full card is visible
|
|
scrollRef.current.scrollTo({
|
|
left: nextIndex * cardWidth,
|
|
behavior: 'smooth'
|
|
});
|
|
setCurrentIndex(nextIndex);
|
|
}
|
|
}
|
|
|
|
if (isRightSwipe && currentIndex > 0) {
|
|
// Navigate to previous card - snap to left edge of screen
|
|
const prevIndex = currentIndex - 1;
|
|
if (scrollRef.current) {
|
|
// Card width should match CSS: calc(100vw - 1.5rem) = window width - 24px
|
|
const cardWidth = window.innerWidth - 24;
|
|
// Scroll so only one full card is visible
|
|
scrollRef.current.scrollTo({
|
|
left: prevIndex * cardWidth,
|
|
behavior: 'smooth'
|
|
});
|
|
setCurrentIndex(prevIndex);
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="relative"
|
|
onMouseLeave={() => setClickedVideoId(null)}
|
|
>
|
|
<h2 className="text-lg font-medium text-bunny-light mb-2 md: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-0 md:gap-3 overflow-x-auto scrollbar-hide py-2 md:py-4 px-3 md:px-2"
|
|
style={{
|
|
scrollbarWidth: 'none',
|
|
msOverflowStyle: 'none',
|
|
scrollSnapType: window.innerWidth < 768 ? 'x mandatory' : '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-1.5rem)] sm:w-[330px] relative hover:z-50"
|
|
style={{
|
|
scrollSnapAlign: window.innerWidth < 768 ? 'start' : 'none'
|
|
}}
|
|
onMouseEnter={() => setClickedVideoId(video.id)}
|
|
>
|
|
{/* Top 10 Number overlay for first category - only on desktop */}
|
|
{category.title.includes("Meist Angesehen") && index < 10 && window.innerWidth >= 768 && clickedVideoId !== video.id && (
|
|
<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={`dot-${category.title}-${index}`}
|
|
onClick={() => {
|
|
if (scrollRef.current) {
|
|
// Card width should match CSS: calc(100vw - 1.5rem) = window width - 24px
|
|
const cardWidth = window.innerWidth - 24;
|
|
scrollRef.current.scrollTo({
|
|
left: index * cardWidth,
|
|
behavior: 'smooth'
|
|
});
|
|
setCurrentIndex(index);
|
|
}
|
|
}}
|
|
className={`rounded-full transition-colors duration-300 ease-in-out ${
|
|
index === currentIndex
|
|
? 'bg-gradient-to-r from-purple-500 to-blue-500'
|
|
: 'bg-white/25 hover:bg-white/40'
|
|
}`}
|
|
style={{
|
|
width: '8px',
|
|
height: '8px',
|
|
minWidth: '8px',
|
|
minHeight: '8px'
|
|
}}
|
|
aria-label={`Go to card ${index + 1}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |