diff --git a/package.json b/package.json index 961eb17..4cebcc6 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,6 @@ "react-dom": "^18.3.1", "react-icons": "^5.4.0", "react-markdown": "^10.1.0", - "react-window": "^2.2.3", "redis": "^4.6.7", "remark-gfm": "^3.0.1", "server-only": "^0.0.1", @@ -96,7 +95,6 @@ "@types/pg": "^8.16.0", "@types/react": "^18.3.18", "@types/react-dom": "^19.1.6", - "@types/react-window": "^2.0.0", "@types/testing-library__jest-dom": "^5.14.9", "@types/xml2js": "^0.4.14", "@typescript-eslint/eslint-plugin": "^5.62.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 341d082..191b654 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,9 +128,6 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@18.3.27)(react@18.3.1) - react-window: - specifier: ^2.2.3 - version: 2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) redis: specifier: ^4.6.7 version: 4.7.1 @@ -213,9 +210,6 @@ importers: '@types/react-dom': specifier: ^19.1.6 version: 19.2.3(@types/react@18.3.27) - '@types/react-window': - specifier: ^2.0.0 - version: 2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/testing-library__jest-dom': specifier: ^5.14.9 version: 5.14.9 @@ -3318,10 +3312,6 @@ packages: peerDependencies: '@types/react': ^19.2.0 - '@types/react-window@2.0.0': - resolution: {integrity: sha512-E8hMDtImEpMk1SjswSvqoSmYvk7GEtyVaTa/GJV++FdDNuMVVEzpAClyJ0nqeKYBrMkGiyH6M1+rPLM0Nu1exQ==} - deprecated: This is a stub types definition. react-window provides its own type definitions, so you do not need this installed. - '@types/react@18.3.27': resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==} @@ -7323,12 +7313,6 @@ packages: '@types/react': '>=18' react: '>=18' - react-window@2.2.4: - resolution: {integrity: sha512-FiZsQHvt2qbnTz6cN+/FXvX62v2xukQ+AajUivkm/Ivdp9rnU3bp0B1eDcCNpQXNaDBdqkEVGNYHlvIUGU9yBw==} - peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -13168,13 +13152,6 @@ snapshots: dependencies: '@types/react': 18.3.27 - '@types/react-window@2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - react-window: 2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - transitivePeerDependencies: - - react - - react-dom - '@types/react@18.3.27': dependencies: '@types/prop-types': 15.7.15 @@ -18137,11 +18114,6 @@ snapshots: transitivePeerDependencies: - supports-color - react-window@2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react@18.3.1: dependencies: loose-envify: 1.4.0 diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 221d304..5f05588 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -22,6 +22,7 @@ import PansouSearch from '@/components/PansouSearch'; import SearchResultFilter, { SearchFilterCategory } from '@/components/SearchResultFilter'; import SearchSuggestions from '@/components/SearchSuggestions'; import VideoCard, { VideoCardHandle } from '@/components/VideoCard'; +import VirtualScrollableGrid from '@/components/VirtualScrollableGrid'; function SearchPageClient() { // 搜索历史 @@ -372,11 +373,11 @@ function SearchPageClient() { const yearsSet = new Set(); searchResults.forEach((item) => { - if (item.source && item.source_name) { + if (item.source && item.source_name && item.source.trim() !== '' && item.source_name.trim() !== '') { sourcesSet.set(item.source, item.source_name); } - if (item.title) titlesSet.add(item.title); - if (item.year) yearsSet.add(item.year); + if (item.title && item.title.trim() !== '') titlesSet.add(item.title); + if (item.year && item.year.trim() !== '') yearsSet.add(item.year); }); const sourceOptions: { label: string; value: string }[] = [ @@ -518,6 +519,11 @@ function SearchPageClient() { }); }, [aggregatedResults, filterAgg, searchQuery]); + const useVirtualGrid = useMemo(() => { + const cardCount = viewMode === 'agg' ? filteredAggResults.length : filteredAllResults.length; + return cardCount >= 100; + }, [viewMode, filteredAggResults.length, filteredAllResults.length]); + // 监听选项卡切换,自动执行搜索 useEffect(() => { // 如果切换到网盘搜索选项卡,且有搜索关键词,且已显示结果,则触发搜索 @@ -1194,77 +1200,98 @@ function SearchPageClient() { ) ) : ( -
- {viewMode === 'agg' - ? filteredAggResults.map(([mapKey, group]) => { - const title = group[0]?.title || ''; - const poster = group[0]?.poster || ''; - const year = group[0]?.year || 'unknown'; - const { episodes, source_names, douban_id } = computeGroupStats(group); + (() => { + const gridClassName = + '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'; - // 从 mapKey 中提取类型(mapKey 格式:normalizedTitle-type-year) - // 找到最后一个 '-' 之前的部分,再找倒数第二个 '-' - const lastDashIndex = mapKey.lastIndexOf('-'); - const secondLastDashIndex = mapKey.lastIndexOf('-', lastDashIndex - 1); - const type = secondLastDashIndex > 0 - ? mapKey.substring(secondLastDashIndex + 1, lastDashIndex) as 'movie' | 'tv' - : (episodes === 1 ? 'movie' : 'tv'); // 兜底 + const gridChildren = + viewMode === 'agg' + ? filteredAggResults.map(([mapKey, group]) => { + const title = group[0]?.title || ''; + const poster = group[0]?.poster || ''; + const year = group[0]?.year || 'unknown'; + const { episodes, source_names, douban_id } = computeGroupStats(group); - // 如果该聚合第一次出现,写入初始统计 - if (!groupStatsRef.current.has(mapKey)) { - groupStatsRef.current.set(mapKey, { episodes, source_names, douban_id }); - } + // 从 mapKey 中提取类型(mapKey 格式:normalizedTitle-type-year) + // 找到最后一个 '-' 之前的部分,再找倒数第二个 '-' + const lastDashIndex = mapKey.lastIndexOf('-'); + const secondLastDashIndex = mapKey.lastIndexOf('-', lastDashIndex - 1); + const type = secondLastDashIndex > 0 + ? mapKey.substring(secondLastDashIndex + 1, lastDashIndex) as 'movie' | 'tv' + : (episodes === 1 ? 'movie' : 'tv'); // 兜底 - return ( -
+ // 如果该聚合第一次出现,写入初始统计 + if (!groupStatsRef.current.has(mapKey)) { + groupStatsRef.current.set(mapKey, { episodes, source_names, douban_id }); + } + + return ( +
+ +
+ ); + }) + : filteredAllResults.map((item) => ( +
1 ? 'tv' : 'movie'} />
- ); - }) - : filteredAllResults.map((item) => ( -
- 1 ? 'tv' : 'movie'} - /> -
- ))} -
+ {gridChildren} + + ); + } + + return ( +
+ {gridChildren} +
+ ); + })() )} ) : activeTab === 'pansou' ? ( diff --git a/src/components/VirtualScrollableGrid.tsx b/src/components/VirtualScrollableGrid.tsx new file mode 100644 index 0000000..dd0ab2c --- /dev/null +++ b/src/components/VirtualScrollableGrid.tsx @@ -0,0 +1,154 @@ +'use client'; + +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +interface VirtualScrollableGridProps { + children: React.ReactNode[]; + gridClassName: string; + /** extra rows rendered above/below viewport */ + overscanRows?: number; + /** < 640px columns */ + mobileColumns?: number; + /** >= 640px min card width (px) to derive columns */ + minItemWidth?: number; + /** >= 640px max content width (px) to derive columns */ + maxContentWidth?: number; +} + +const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n)); + +export default function VirtualScrollableGrid({ + children, + gridClassName, + overscanRows = 3, + mobileColumns = 3, + minItemWidth = 176, + maxContentWidth = 1400, +}: VirtualScrollableGridProps) { + const containerRef = useRef(null); + const measureRef = useRef(null); + + const columnsRef = useRef(mobileColumns); + const rowHeightRef = useRef(320); + const totalRowsRef = useRef(0); + const rafRef = useRef(null); + + const [range, setRange] = useState({ startRow: 0, endRow: 0 }); + + const computeColumns = () => { + if (typeof window === 'undefined') return mobileColumns; + const width = window.innerWidth; + if (width < 640) return mobileColumns; + const containerWidth = Math.min(width - 32, maxContentWidth); + return Math.max(mobileColumns, Math.floor(containerWidth / minItemWidth)); + }; + + const updateLayout = () => { + const gapY = window.innerWidth >= 640 ? 80 : 56; // gap-y-14 / sm:gap-y-20 + const columns = computeColumns(); + columnsRef.current = columns; + totalRowsRef.current = Math.ceil(children.length / Math.max(1, columns)); + + // Measure a single item height (wrapper div around VideoCard) and add vertical gap. + const measureEl = measureRef.current; + const firstItem = measureEl?.querySelector('[data-virtual-measure-item]'); + const itemH = firstItem?.getBoundingClientRect().height; + if (itemH && Number.isFinite(itemH) && itemH > 0) { + rowHeightRef.current = Math.max(120, Math.round(itemH + gapY)); + } + }; + + const updateRange = () => { + const el = containerRef.current; + if (!el) return; + + const totalRows = totalRowsRef.current; + if (totalRows <= 0) return; + + const rowHeight = rowHeightRef.current; + if (!rowHeight || rowHeight <= 0) return; + + // This app uses `document.body` as the actual scroll container (see search page back-to-top logic). + const scrollTop = document.body.scrollTop || 0; + const viewportBottom = scrollTop + window.innerHeight; + const containerTop = el.getBoundingClientRect().top + scrollTop; + + const startRow = Math.floor((scrollTop - containerTop) / rowHeight) - overscanRows; + const endRow = Math.ceil((viewportBottom - containerTop) / rowHeight) + overscanRows; + + const clampedStart = clamp(startRow, 0, Math.max(0, totalRows - 1)); + const clampedEnd = clamp(endRow, clampedStart, Math.max(0, totalRows - 1)); + + setRange((prev) => { + if (prev.startRow === clampedStart && prev.endRow === clampedEnd) return prev; + return { startRow: clampedStart, endRow: clampedEnd }; + }); + }; + + const scheduleUpdate = () => { + if (rafRef.current != null) return; + rafRef.current = window.requestAnimationFrame(() => { + rafRef.current = null; + updateLayout(); + updateRange(); + }); + }; + + useEffect(() => { + updateLayout(); + updateRange(); + + let isRunning = true; + const rafLoop = () => { + if (!isRunning) return; + scheduleUpdate(); + window.requestAnimationFrame(rafLoop); + }; + rafLoop(); + + document.body.addEventListener('scroll', scheduleUpdate, { passive: true }); + window.addEventListener('resize', scheduleUpdate); + + return () => { + isRunning = false; + document.body.removeEventListener('scroll', scheduleUpdate); + window.removeEventListener('resize', scheduleUpdate); + if (rafRef.current != null) window.cancelAnimationFrame(rafRef.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [children.length, overscanRows, mobileColumns, minItemWidth, maxContentWidth]); + + const columns = columnsRef.current; + const totalRows = totalRowsRef.current; + const rowHeight = rowHeightRef.current; + + const startIndex = range.startRow * columns; + const endIndexExclusive = Math.min(children.length, (range.endRow + 1) * columns); + const visibleChildren = children.slice(startIndex, endIndexExclusive); + + const topSpacerHeight = range.startRow * rowHeight; + const bottomSpacerHeight = Math.max(0, (totalRows - range.endRow - 1) * rowHeight); + + return ( +
+ {/* hidden measuring row (first visible row) */} + + +
+
{visibleChildren}
+
+
+ ); +} diff --git a/src/lib/db.client.ts b/src/lib/db.client.ts index 9adf9c4..5fcfcde 100644 --- a/src/lib/db.client.ts +++ b/src/lib/db.client.ts @@ -860,13 +860,15 @@ export async function getSearchHistory(): Promise { // 返回缓存数据,同时后台异步更新 fetchFromApi(`/api/searchhistory`) .then((freshData) => { + // 去重处理 + const uniqueData = Array.from(new Set(freshData)); // 只有数据真正不同时才更新缓存 - if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) { - cacheManager.cacheSearchHistory(freshData); + if (JSON.stringify(cachedData) !== JSON.stringify(uniqueData)) { + cacheManager.cacheSearchHistory(uniqueData); // 触发数据更新事件 window.dispatchEvent( new CustomEvent('searchHistoryUpdated', { - detail: freshData, + detail: uniqueData, }) ); } @@ -881,8 +883,10 @@ export async function getSearchHistory(): Promise { // 缓存为空,直接从 API 获取并缓存 try { const freshData = await fetchFromApi(`/api/searchhistory`); - cacheManager.cacheSearchHistory(freshData); - return freshData; + // 去重处理 + const uniqueData = Array.from(new Set(freshData)); + cacheManager.cacheSearchHistory(uniqueData); + return uniqueData; } catch (err) { console.error('获取搜索历史失败:', err); triggerGlobalError('获取搜索历史失败'); @@ -896,8 +900,9 @@ export async function getSearchHistory(): Promise { const raw = localStorage.getItem(SEARCH_HISTORY_KEY); if (!raw) return []; const arr = JSON.parse(raw) as string[]; - // 仅返回字符串数组 - return Array.isArray(arr) ? arr : []; + // 仅返回字符串数组,并去重 + const validArray = Array.isArray(arr) ? arr : []; + return Array.from(new Set(validArray)); } catch (err) { console.error('读取搜索历史失败:', err); triggerGlobalError('读取搜索历史失败');