kv-tube/frontend/app/components/InfiniteVideoGrid.tsx
2026-03-26 13:11:20 +07:00

115 lines
3.9 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, useCallback } from 'react';
import VideoCard from './VideoCard';
import { fetchMoreVideos } from '../actions';
import { VideoData } from '../constants';
import LoadingSpinner from './LoadingSpinner';
interface Props {
initialVideos: VideoData[];
currentCategory: string;
regionLabel: string;
contextVideoId?: string;
}
export default function InfiniteVideoGrid({ initialVideos, currentCategory, regionLabel, contextVideoId }: Props) {
const [videos, setVideos] = useState<VideoData[]>(initialVideos);
const [page, setPage] = useState(2);
const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const observerTarget = useRef<HTMLDivElement>(null);
// Reset state if category or region changes, or initialVideos changes
useEffect(() => {
setVideos(initialVideos);
setPage(2);
setHasMore(true);
}, [initialVideos, currentCategory, regionLabel]);
const loadMore = useCallback(async () => {
if (isLoading || !hasMore) return;
setIsLoading(true);
try {
const newVideos = await fetchMoreVideos(currentCategory, regionLabel, page, contextVideoId);
if (newVideos.length === 0) {
setHasMore(false);
} else {
setVideos(prev => {
// Deduplicate IDs
const existingIds = new Set(prev.map(v => v.id));
const uniqueNewVideos = newVideos.filter(v => !existingIds.has(v.id));
if (uniqueNewVideos.length === 0) {
return prev;
}
return [...prev, ...uniqueNewVideos];
});
setPage(p => p + 1);
// If we get an extremely small yield, consider it the end
if (newVideos.length < 5) {
setHasMore(false);
}
}
} catch (e) {
console.error('Failed to load more videos:', e);
} finally {
setIsLoading(false);
}
}, [currentCategory, regionLabel, page, isLoading, hasMore, contextVideoId]);
useEffect(() => {
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting) {
loadMore();
}
},
{ threshold: 0.1, rootMargin: '200px' }
);
const currentTarget = observerTarget.current;
if (currentTarget) {
observer.observe(currentTarget);
}
return () => {
if (currentTarget) {
observer.unobserve(currentTarget);
}
};
}, [loadMore]);
return (
<div>
<div className="fade-in video-grid-mobile" style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '16px',
paddingBottom: '24px'
}}>
{videos.map((v, i) => {
const staggerClass = i < 12 ? `stagger-${Math.min((i % 12) + 1, 6)}` : '';
return (
<div key={`${v.id}-${i}`} className={i < 12 ? `fade-in-up ${staggerClass}` : 'fade-in'}>
<VideoCard video={v} />
</div>
);
})}
</div>
{hasMore && (
<div ref={observerTarget} style={{ padding: '24px 0', display: 'flex', justifyContent: 'center' }}>
{isLoading && <LoadingSpinner />}
</div>
)}
{!hasMore && videos.length > 0 && (
<div style={{ textAlign: 'center', padding: '24px 0', color: 'var(--yt-text-secondary)', fontSize: '14px' }}>
No more results
</div>
)}
</div>
);
}