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

223 lines
8.7 KiB
TypeScript

'use client';
import { useEffect, useState, useRef, useCallback } from 'react';
import { useSearchParams } from 'next/navigation';
import { searchVideosClient } from '../clientActions';
import { VideoData } from '../constants';
import VideoCard from '../components/VideoCard';
import LoadingSpinner from '../components/LoadingSpinner';
function SearchSkeleton() {
return (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '16px',
}}>
{[...Array(12)].map((_, i) => (
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{
aspectRatio: '16/9',
backgroundColor: 'var(--yt-hover)',
borderRadius: '12px',
animation: 'pulse 1.5s ease-in-out infinite',
}} />
<div style={{ display: 'flex', gap: '12px', padding: '0' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ width: '90%', height: '16px', backgroundColor: 'var(--yt-hover)', borderRadius: '4px' }} />
<div style={{ width: '60%', height: '12px', backgroundColor: 'var(--yt-hover)', borderRadius: '4px' }} />
<div style={{ width: '40%', height: '12px', backgroundColor: 'var(--yt-hover)', borderRadius: '4px' }} />
</div>
</div>
</div>
))}
<style jsx>{`
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.6; }
}
`}</style>
</div>
);
}
export default function ClientSearchPage() {
const searchParams = useSearchParams();
const query = searchParams.get('q') || '';
const [videos, setVideos] = useState<VideoData[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [searchPage, setSearchPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const observerTarget = useRef<HTMLDivElement>(null);
const loadingMoreRef = useRef(false);
const hasMoreRef = useRef(true);
const searchPageRef = useRef(0);
useEffect(() => { loadingMoreRef.current = loadingMore; }, [loadingMore]);
useEffect(() => { hasMoreRef.current = hasMore; }, [hasMore]);
useEffect(() => { searchPageRef.current = searchPage; }, [searchPage]);
useEffect(() => {
if (query) {
performSearch(query);
}
}, [query]);
const performSearch = async (q: string) => {
try {
setLoading(true);
setSearchPage(0);
searchPageRef.current = 0;
setHasMore(true);
hasMoreRef.current = true;
const results = await searchVideosClient(q, 50);
const uniqueResults = results.filter((video, index, self) =>
index === self.findIndex(v => v.id === video.id)
);
setVideos(uniqueResults);
setHasMore(uniqueResults.length >= 40);
hasMoreRef.current = uniqueResults.length >= 40;
} catch (error) {
console.error('Search failed:', error);
} finally {
setLoading(false);
}
};
const loadMore = useCallback(async () => {
if (loadingMoreRef.current || !hasMoreRef.current || !query) return;
setLoadingMore(true);
const nextPage = searchPageRef.current + 1;
try {
// Use different search variations to get more results
const variations = [
`${query}`,
`${query} official`,
`${query} video`,
`${query} review`,
`${query} tutorial`,
`${query} 2026`,
`${query} new`,
`${query} best`,
];
const searchVariation = variations[nextPage % variations.length];
const results = await searchVideosClient(searchVariation, 50);
setVideos(prev => {
const existingIds = new Set(prev.map(v => v.id));
const uniqueNewVideos = results.filter(v => !existingIds.has(v.id));
// Stop loading if we get very few new videos
if (uniqueNewVideos.length < 3) {
setHasMore(false);
hasMoreRef.current = false;
}
return [...prev, ...uniqueNewVideos];
});
setSearchPage(nextPage);
searchPageRef.current = nextPage;
} catch (error) {
console.error('Failed to load more:', error);
} finally {
setLoadingMore(false);
}
}, [query]);
// Infinite scroll observer
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !loadingMoreRef.current && hasMoreRef.current) {
loadMore();
}
},
{ rootMargin: '500px', threshold: 0.1 }
);
const timer = setTimeout(() => {
if (observerTarget.current) {
observer.observe(observerTarget.current);
}
}, 100);
return () => {
clearTimeout(timer);
observer.disconnect();
};
}, [loadMore]);
return (
<div style={{
backgroundColor: 'var(--yt-background)',
color: 'var(--yt-text-primary)',
minHeight: '100vh',
padding: '0 24px 24px',
}}>
<div style={{ maxWidth: '1400px', margin: '0 auto' }}>
{/* Results Header */}
{query && !loading && (
<div style={{ marginBottom: '20px' }}>
<span style={{ fontSize: '14px', color: 'var(--yt-text-secondary)' }}>
{videos.length > 0 ? `${videos.length} results for "${query}"` : `No results for "${query}"`}
</span>
</div>
)}
{/* Results Grid */}
{loading ? (
<SearchSkeleton />
) : videos.length === 0 ? (
<div style={{
textAlign: 'center',
padding: '80px 24px',
color: 'var(--yt-text-secondary)',
}}>
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" style={{ marginBottom: '16px', opacity: 0.5 }}>
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
</svg>
<h3 style={{ fontSize: '18px', marginBottom: '8px', color: 'var(--yt-text-primary)' }}>
No results found
</h3>
<p style={{ fontSize: '14px' }}>Try different keywords or check your spelling</p>
</div>
) : (
<>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '16px',
}}>
{videos.map((video) => (
<VideoCard key={video.id} video={video} />
))}
</div>
{/* Infinite scroll sentinel */}
<div ref={observerTarget} style={{ padding: '24px 0', display: 'flex', justifyContent: 'center' }}>
{loadingMore && <LoadingSpinner />}
</div>
{/* End of results */}
{!hasMore && videos.length > 0 && (
<div style={{
textAlign: 'center',
padding: '24px 0',
color: 'var(--yt-text-secondary)',
fontSize: '14px',
}}>
End of results
</div>
)}
</>
)}
</div>
</div>
);
}