videofolxtv/client/src/components/netflix-grid.tsx
sebastjanartic 258383ce36 Shorten video URLs for improved sharing and user experience
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
2025-09-03 11:03:27 +00:00

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>
);
}