新增源站寻片

This commit is contained in:
mtvpls
2025-12-31 17:38:06 +08:00
parent caf8f28df3
commit 8e9eb76d58
9 changed files with 984 additions and 54 deletions
+397
View File
@@ -0,0 +1,397 @@
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
'use client';
import { Loader2, Search } from 'lucide-react';
import { Suspense, useEffect, useRef, useState } from 'react';
import { ApiSite } from '@/lib/config';
import { SearchResult } from '@/lib/types';
import CapsuleSwitch from '@/components/CapsuleSwitch';
import PageLayout from '@/components/PageLayout';
import VideoCard from '@/components/VideoCard';
interface Category {
id: string;
name: string;
}
type ViewMode = 'browse' | 'search';
function SourceSearchPageClient() {
const [apiSites, setApiSites] = useState<ApiSite[]>([]);
const [selectedSource, setSelectedSource] = useState<string>('');
const [categories, setCategories] = useState<Category[]>([]);
const [selectedCategory, setSelectedCategory] = useState<string>('');
const [videos, setVideos] = useState<SearchResult[]>([]);
const [isLoadingSources, setIsLoadingSources] = useState(true);
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
const [isLoadingVideos, setIsLoadingVideos] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [viewMode, setViewMode] = useState<ViewMode>('browse');
const [searchKeyword, setSearchKeyword] = useState<string>('');
const [searchInputValue, setSearchInputValue] = useState<string>('');
const loadMoreRef = useRef<HTMLDivElement>(null);
// 加载用户可用的视频源
useEffect(() => {
const fetchApiSites = async () => {
setIsLoadingSources(true);
try {
const response = await fetch('/api/source-search/sources');
const data = await response.json();
if (data.sources && Array.isArray(data.sources)) {
setApiSites(data.sources);
// 默认选择第一个源
if (data.sources.length > 0) {
setSelectedSource(data.sources[0].key);
}
}
} catch (error) {
console.error('Failed to load API sources:', error);
} finally {
setIsLoadingSources(false);
}
};
fetchApiSites();
}, []);
// 当选择的源变化时,加载分类列表
useEffect(() => {
if (!selectedSource) return;
const fetchCategories = async () => {
setIsLoadingCategories(true);
setCategories([]);
setSelectedCategory('');
setVideos([]);
setCurrentPage(1);
setHasMore(true);
try {
const response = await fetch(
`/api/source-search/categories?source=${encodeURIComponent(selectedSource)}`
);
const data = await response.json();
if (data.categories && Array.isArray(data.categories)) {
setCategories(data.categories);
// 默认选择第一个分类
if (data.categories.length > 0) {
setSelectedCategory(data.categories[0].id);
}
}
} catch (error) {
console.error('Failed to load categories:', error);
} finally {
setIsLoadingCategories(false);
}
};
fetchCategories();
}, [selectedSource]);
// 当选择的分类或页码变化时,加载视频列表(浏览模式)
useEffect(() => {
if (viewMode !== 'browse' || !selectedSource || !selectedCategory) return;
const fetchVideos = async () => {
setIsLoadingVideos(true);
try {
const response = await fetch(
`/api/source-search/videos?source=${encodeURIComponent(selectedSource)}&categoryId=${encodeURIComponent(selectedCategory)}&page=${currentPage}`
);
const data = await response.json();
if (data.results && Array.isArray(data.results)) {
if (currentPage === 1) {
setVideos(data.results);
} else {
setVideos((prev) => [...prev, ...data.results]);
}
setHasMore(data.page < data.pageCount);
}
} catch (error) {
console.error('Failed to load videos:', error);
} finally {
setIsLoadingVideos(false);
}
};
fetchVideos();
}, [selectedSource, selectedCategory, currentPage, viewMode]);
// 当搜索关键词或页码变化时,执行搜索(搜索模式)
useEffect(() => {
if (viewMode !== 'search' || !selectedSource || !searchKeyword) return;
const searchVideos = async () => {
setIsLoadingVideos(true);
try {
const response = await fetch(
`/api/source-search/search?source=${encodeURIComponent(selectedSource)}&keyword=${encodeURIComponent(searchKeyword)}&page=${currentPage}`
);
const data = await response.json();
if (data.results && Array.isArray(data.results)) {
if (currentPage === 1) {
setVideos(data.results);
} else {
setVideos((prev) => [...prev, ...data.results]);
}
setHasMore(data.page < data.pageCount);
}
} catch (error) {
console.error('Failed to search videos:', error);
} finally {
setIsLoadingVideos(false);
}
};
searchVideos();
}, [selectedSource, searchKeyword, currentPage, viewMode]);
// 当分类变化时,重置到第一页
useEffect(() => {
setCurrentPage(1);
setVideos([]);
setHasMore(true);
}, [selectedCategory]);
// 处理搜索提交
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (searchInputValue.trim()) {
setSearchKeyword(searchInputValue.trim());
setViewMode('search');
setCurrentPage(1);
setVideos([]);
setHasMore(true);
}
};
// 切换回浏览模式
const handleBackToBrowse = () => {
setViewMode('browse');
setSearchKeyword('');
setSearchInputValue('');
setCurrentPage(1);
setVideos([]);
setHasMore(true);
};
// Intersection Observer for infinite scroll
useEffect(() => {
if (!loadMoreRef.current) return;
const observer = new IntersectionObserver(
(entries) => {
const target = entries[0];
if (target.isIntersecting && hasMore && !isLoadingVideos) {
setCurrentPage((prev) => prev + 1);
}
},
{ threshold: 0.1 }
);
observer.observe(loadMoreRef.current);
return () => {
observer.disconnect();
};
}, [hasMore, isLoadingVideos]);
return (
<PageLayout activePath='/source-search'>
<div className='px-4 sm:px-10 py-4 sm:py-8 overflow-visible mb-10'>
{/* 页面标题 */}
<div className='mb-6'>
<h1 className='text-2xl font-bold text-gray-800 dark:text-gray-200'>
</h1>
<p className='text-sm text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
{/* 源选择和分类选择 */}
<div className='max-w-4xl mx-auto mb-8 space-y-6'>
{/* 源选择 CapsuleSwitch */}
<div className='relative'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3'>
</label>
{isLoadingSources ? (
<div className='flex items-center justify-center h-12 bg-gray-50/80 rounded-lg border border-gray-200/50 dark:bg-gray-800 dark:border-gray-700'>
<Loader2 className='h-5 w-5 animate-spin text-gray-400' />
<span className='ml-2 text-sm text-gray-500 dark:text-gray-400'>
...
</span>
</div>
) : apiSites.length === 0 ? (
<div className='flex items-center justify-center h-12 bg-gray-50/80 rounded-lg border border-gray-200/50 dark:bg-gray-800 dark:border-gray-700'>
<span className='text-sm text-gray-500 dark:text-gray-400'>
</span>
</div>
) : (
<div className='flex justify-center'>
<CapsuleSwitch
options={apiSites.map((site) => ({
label: site.name,
value: site.key,
}))}
active={selectedSource}
onChange={(value) => {
setSelectedSource(value);
handleBackToBrowse();
}}
/>
</div>
)}
</div>
{/* 搜索框 */}
{selectedSource && (
<div className='relative'>
<form onSubmit={handleSearch}>
<div className='relative'>
<input
type='text'
value={searchInputValue}
onChange={(e) => setSearchInputValue(e.target.value)}
placeholder='搜索视频...'
className='w-full h-12 rounded-lg bg-gray-50/80 py-3 pl-4 pr-12 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:bg-white border border-gray-200/50 shadow-sm dark:bg-gray-800 dark:text-gray-300 dark:focus:bg-gray-700 dark:border-gray-700'
/>
<button
type='submit'
className='absolute right-2 top-1/2 -translate-y-1/2 p-2 rounded-lg text-blue-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-gray-600 transition-colors'
>
<Search size={20} />
</button>
</div>
</form>
</div>
)}
{/* 搜索结果提示和返回按钮 */}
{viewMode === 'search' && searchKeyword && (
<div className='flex items-center justify-between bg-blue-50/80 dark:bg-blue-900/20 border border-blue-200/50 dark:border-blue-800/50 rounded-lg px-4 py-3'>
<span className='text-sm text-gray-700 dark:text-gray-300'>
: <span className='font-medium'>{searchKeyword}</span>
</span>
<button
onClick={handleBackToBrowse}
className='text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 font-medium'
>
</button>
</div>
)}
{/* 分类选择 CapsuleSwitch */}
{selectedSource && viewMode === 'browse' && (
<div className='relative'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3'>
</label>
{isLoadingCategories ? (
<div className='flex items-center justify-center h-12 bg-gray-50/80 rounded-lg border border-gray-200/50 dark:bg-gray-800 dark:border-gray-700'>
<Loader2 className='h-5 w-5 animate-spin text-gray-400' />
<span className='ml-2 text-sm text-gray-500 dark:text-gray-400'>
...
</span>
</div>
) : categories.length === 0 ? (
<div className='flex items-center justify-center h-12 bg-gray-50/80 rounded-lg border border-gray-200/50 dark:bg-gray-800 dark:border-gray-700'>
<span className='text-sm text-gray-500 dark:text-gray-400'>
</span>
</div>
) : (
<div className='flex justify-center'>
<CapsuleSwitch
options={categories.map((category) => ({
label: category.name,
value: category.id,
}))}
active={selectedCategory}
onChange={setSelectedCategory}
/>
</div>
)}
</div>
)}
</div>
{/* 视频列表 */}
{selectedSource && (viewMode === 'search' ? searchKeyword : selectedCategory) && (
<div className='max-w-[95%] mx-auto mt-8'>
<div className='mb-4'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h2>
</div>
{isLoadingVideos && currentPage === 1 ? (
<div className='flex justify-center items-center h-40'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500'></div>
</div>
) : videos.length === 0 ? (
<div className='text-center text-gray-500 py-8 dark:text-gray-400'>
</div>
) : (
<>
<div className='grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8'>
{videos.map((item) => (
<div
key={`${item.source}-${item.id}`}
className='w-full'
>
<VideoCard
id={item.id}
title={item.title}
poster={item.poster}
episodes={item.episodes.length}
source={item.source}
source_name={item.source_name}
douban_id={item.douban_id}
year={item.year}
from='source-search'
type={item.episodes.length > 1 ? 'tv' : 'movie'}
cmsData={{
desc: item.desc,
episodes: item.episodes,
episodes_titles: item.episodes_titles,
}}
/>
</div>
))}
</div>
{/* Infinite scroll trigger */}
<div ref={loadMoreRef} className='flex justify-center items-center py-8'>
{isLoadingVideos && (
<div className='animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500'></div>
)}
{!hasMore && videos.length > 0 && (
<span className='text-sm text-gray-500 dark:text-gray-400'>
</span>
)}
</div>
</>
)}
</div>
)}
</div>
</PageLayout>
);
}
export default function SourceSearchPage() {
return (
<Suspense>
<SourceSearchPageClient />
</Suspense>
);
}