Files
LunaTV/src/app/search/page.tsx
2025-08-12 21:50:58 +08:00

469 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable react-hooks/exhaustive-deps, @typescript-eslint/no-explicit-any */
'use client';
import { ChevronUp, Search, X } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useMemo, useState } from 'react';
import {
addSearchHistory,
clearSearchHistory,
deleteSearchHistory,
getSearchHistory,
subscribeToDataUpdates,
} from '@/lib/db.client';
import { SearchResult } from '@/lib/types';
import { yellowWords } from '@/lib/yellow';
import PageLayout from '@/components/PageLayout';
import SearchSuggestions from '@/components/SearchSuggestions';
import VideoCard from '@/components/VideoCard';
function SearchPageClient() {
// 搜索历史
const [searchHistory, setSearchHistory] = useState<string[]>([]);
// 返回顶部按钮显示状态
const [showBackToTop, setShowBackToTop] = useState(false);
const router = useRouter();
const searchParams = useSearchParams();
const [searchQuery, setSearchQuery] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showResults, setShowResults] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
// 获取默认聚合设置:只读取用户本地设置,默认为 true
const getDefaultAggregate = () => {
if (typeof window !== 'undefined') {
const userSetting = localStorage.getItem('defaultAggregateSearch');
if (userSetting !== null) {
return JSON.parse(userSetting);
}
}
return true; // 默认启用聚合
};
const [viewMode, setViewMode] = useState<'agg' | 'all'>(() => {
return getDefaultAggregate() ? 'agg' : 'all';
});
// 聚合后的结果(按标题和年份分组)
const aggregatedResults = useMemo(() => {
const map = new Map<string, SearchResult[]>();
searchResults.forEach((item) => {
// 使用 title + year + type 作为键year 必然存在,但依然兜底 'unknown'
const key = `${item.title.replaceAll(' ', '')}-${
item.year || 'unknown'
}-${item.episodes.length === 1 ? 'movie' : 'tv'}`;
const arr = map.get(key) || [];
arr.push(item);
map.set(key, arr);
});
return Array.from(map.entries()).sort((a, b) => {
// 优先排序:标题与搜索词完全一致的排在前面
const aExactMatch = a[1][0].title
.replaceAll(' ', '')
.includes(searchQuery.trim().replaceAll(' ', ''));
const bExactMatch = b[1][0].title
.replaceAll(' ', '')
.includes(searchQuery.trim().replaceAll(' ', ''));
if (aExactMatch && !bExactMatch) return -1;
if (!aExactMatch && bExactMatch) return 1;
// 年份排序
if (a[1][0].year === b[1][0].year) {
return a[0].localeCompare(b[0]);
} else {
// 处理 unknown 的情况
const aYear = a[1][0].year;
const bYear = b[1][0].year;
if (aYear === 'unknown' && bYear === 'unknown') {
return 0;
} else if (aYear === 'unknown') {
return 1; // a 排在后面
} else if (bYear === 'unknown') {
return -1; // b 排在后面
} else {
// 都是数字年份,按数字大小排序(大的在前面)
return aYear > bYear ? -1 : 1;
}
}
});
}, [searchResults]);
useEffect(() => {
// 无搜索参数时聚焦搜索框
!searchParams.get('q') && document.getElementById('searchInput')?.focus();
// 初始加载搜索历史
getSearchHistory().then(setSearchHistory);
// 监听搜索历史更新事件
const unsubscribe = subscribeToDataUpdates(
'searchHistoryUpdated',
(newHistory: string[]) => {
setSearchHistory(newHistory);
}
);
// 获取滚动位置的函数 - 专门针对 body 滚动
const getScrollTop = () => {
return document.body.scrollTop || 0;
};
// 使用 requestAnimationFrame 持续检测滚动位置
let isRunning = false;
const checkScrollPosition = () => {
if (!isRunning) return;
const scrollTop = getScrollTop();
const shouldShow = scrollTop > 300;
setShowBackToTop(shouldShow);
requestAnimationFrame(checkScrollPosition);
};
// 启动持续检测
isRunning = true;
checkScrollPosition();
// 监听 body 元素的滚动事件
const handleScroll = () => {
const scrollTop = getScrollTop();
setShowBackToTop(scrollTop > 300);
};
document.body.addEventListener('scroll', handleScroll, { passive: true });
return () => {
unsubscribe();
isRunning = false; // 停止 requestAnimationFrame 循环
// 移除 body 滚动事件监听器
document.body.removeEventListener('scroll', handleScroll);
};
}, []);
useEffect(() => {
// 当搜索参数变化时更新搜索状态
const query = searchParams.get('q');
if (query) {
setSearchQuery(query);
fetchSearchResults(query);
setShowSuggestions(false);
// 保存到搜索历史 (事件监听会自动更新界面)
addSearchHistory(query);
} else {
setShowResults(false);
setShowSuggestions(false);
}
}, [searchParams]);
const fetchSearchResults = async (query: string) => {
try {
setIsLoading(true);
const response = await fetch(
`/api/search?q=${encodeURIComponent(query.trim())}`
);
const data = await response.json();
let results = data.results;
if (
typeof window !== 'undefined' &&
!(window as any).RUNTIME_CONFIG?.DISABLE_YELLOW_FILTER
) {
results = results.filter((result: SearchResult) => {
const typeName = result.type_name || '';
return !yellowWords.some((word: string) => typeName.includes(word));
});
}
setSearchResults(
results.sort((a: SearchResult, b: SearchResult) => {
// 优先排序:标题与搜索词完全一致的排在前面
const aExactMatch = a.title === query.trim();
const bExactMatch = b.title === query.trim();
if (aExactMatch && !bExactMatch) return -1;
if (!aExactMatch && bExactMatch) return 1;
// 如果都匹配或都不匹配,则按原来的逻辑排序
if (a.year === b.year) {
return a.title.localeCompare(b.title);
} else {
// 处理 unknown 的情况
if (a.year === 'unknown' && b.year === 'unknown') {
return 0;
} else if (a.year === 'unknown') {
return 1; // a 排在后面
} else if (b.year === 'unknown') {
return -1; // b 排在后面
} else {
// 都是数字年份,按数字大小排序(大的在前面)
return parseInt(a.year) > parseInt(b.year) ? -1 : 1;
}
}
})
);
setShowResults(true);
} catch (error) {
setSearchResults([]);
} finally {
setIsLoading(false);
}
};
// 输入框内容变化时触发,显示搜索建议
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setSearchQuery(value);
if (value.trim()) {
setShowSuggestions(true);
} else {
setShowSuggestions(false);
}
};
// 搜索框聚焦时触发,显示搜索建议
const handleInputFocus = () => {
if (searchQuery.trim()) {
setShowSuggestions(true);
}
};
// 搜索表单提交时触发,处理搜索逻辑
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = searchQuery.trim().replace(/\s+/g, ' ');
if (!trimmed) return;
// 回显搜索框
setSearchQuery(trimmed);
setIsLoading(true);
setShowResults(true);
setShowSuggestions(false);
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
// 直接发请求
fetchSearchResults(trimmed);
// 保存到搜索历史 (事件监听会自动更新界面)
addSearchHistory(trimmed);
};
const handleSuggestionSelect = (suggestion: string) => {
setSearchQuery(suggestion);
setShowSuggestions(false);
// 自动执行搜索
setIsLoading(true);
setShowResults(true);
router.push(`/search?q=${encodeURIComponent(suggestion)}`);
fetchSearchResults(suggestion);
addSearchHistory(suggestion);
};
// 返回顶部功能
const scrollToTop = () => {
try {
// 根据调试结果,真正的滚动容器是 document.body
document.body.scrollTo({
top: 0,
behavior: 'smooth',
});
} catch (error) {
// 如果平滑滚动完全失败,使用立即滚动
document.body.scrollTop = 0;
}
};
return (
<PageLayout activePath='/search'>
<div className='px-4 sm:px-10 py-4 sm:py-8 overflow-visible mb-10'>
{/* 搜索框 */}
<div className='mb-8'>
<form onSubmit={handleSearch} className='max-w-2xl mx-auto'>
<div className='relative'>
<Search className='absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400 dark:text-gray-500' />
<input
id='searchInput'
type='text'
value={searchQuery}
onChange={handleInputChange}
onFocus={handleInputFocus}
placeholder='搜索电影、电视剧...'
className='w-full h-12 rounded-lg bg-gray-50/80 py-3 pl-10 pr-4 text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-400 focus:bg-white border border-gray-200/50 shadow-sm dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500 dark:focus:bg-gray-700 dark:border-gray-700'
/>
{/* 搜索建议 */}
<SearchSuggestions
query={searchQuery}
isVisible={showSuggestions}
onSelect={handleSuggestionSelect}
onClose={() => setShowSuggestions(false)}
/>
</div>
</form>
</div>
{/* 搜索结果或搜索历史 */}
<div className='max-w-[95%] mx-auto mt-12 overflow-visible'>
{isLoading ? (
<div className='flex justify-center items-center h-40'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
</div>
) : showResults ? (
<section className='mb-12'>
{/* 标题 + 聚合开关 */}
<div className='mb-8 flex items-center justify-between'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h2>
{/* 聚合开关 */}
<label className='flex items-center gap-2 cursor-pointer select-none'>
<span className='text-sm text-gray-700 dark:text-gray-300'>
</span>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={viewMode === 'agg'}
onChange={() =>
setViewMode(viewMode === 'agg' ? 'all' : 'agg')
}
/>
<div className='w-9 h-5 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4'></div>
</div>
</label>
</div>
<div
key={`search-results-${viewMode}`}
className='justify-start 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'
>
{viewMode === 'agg'
? aggregatedResults.map(([mapKey, group]) => {
return (
<div key={`agg-${mapKey}`} className='w-full'>
<VideoCard
from='search'
items={group}
query={
searchQuery.trim() !== group[0].title
? searchQuery.trim()
: ''
}
/>
</div>
);
})
: searchResults.map((item) => (
<div
key={`all-${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}
query={
searchQuery.trim() !== item.title
? searchQuery.trim()
: ''
}
year={item.year}
from='search'
type={item.episodes.length > 1 ? 'tv' : 'movie'}
/>
</div>
))}
{searchResults.length === 0 && (
<div className='col-span-full text-center text-gray-500 py-8 dark:text-gray-400'>
</div>
)}
</div>
</section>
) : searchHistory.length > 0 ? (
// 搜索历史
<section className='mb-12'>
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left dark:text-gray-200'>
{searchHistory.length > 0 && (
<button
onClick={() => {
clearSearchHistory(); // 事件监听会自动更新界面
}}
className='ml-3 text-sm text-gray-500 hover:text-red-500 transition-colors dark:text-gray-400 dark:hover:text-red-500'
>
</button>
)}
</h2>
<div className='flex flex-wrap gap-2'>
{searchHistory.map((item) => (
<div key={item} className='relative group'>
<button
onClick={() => {
setSearchQuery(item);
router.push(
`/search?q=${encodeURIComponent(item.trim())}`
);
}}
className='px-4 py-2 bg-gray-500/10 hover:bg-gray-300 rounded-full text-sm text-gray-700 transition-colors duration-200 dark:bg-gray-700/50 dark:hover:bg-gray-600 dark:text-gray-300'
>
{item}
</button>
{/* 删除按钮 */}
<button
aria-label='删除搜索历史'
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
deleteSearchHistory(item); // 事件监听会自动更新界面
}}
className='absolute -top-1 -right-1 w-4 h-4 opacity-0 group-hover:opacity-100 bg-gray-400 hover:bg-red-500 text-white rounded-full flex items-center justify-center text-[10px] transition-colors'
>
<X className='w-3 h-3' />
</button>
</div>
))}
</div>
</section>
) : null}
</div>
</div>
{/* 返回顶部悬浮按钮 */}
<button
onClick={scrollToTop}
className={`fixed bottom-20 md:bottom-6 right-6 z-[500] w-12 h-12 bg-green-500/90 hover:bg-green-500 text-white rounded-full shadow-lg backdrop-blur-sm transition-all duration-300 ease-in-out flex items-center justify-center group ${
showBackToTop
? 'opacity-100 translate-y-0 pointer-events-auto'
: 'opacity-0 translate-y-4 pointer-events-none'
}`}
aria-label='返回顶部'
>
<ChevronUp className='w-6 h-6 transition-transform group-hover:scale-110' />
</button>
</PageLayout>
);
}
export default function SearchPage() {
return (
<Suspense>
<SearchPageClient />
</Suspense>
);
}