From 694a015a1e579005f19126347737955151de72e0 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Tue, 18 Nov 2025 18:01:33 +0800 Subject: [PATCH] refactor: implement source badge filtering; add useSourceBadges hook and integrate with HomePage and VideoGrid components --- app/page.tsx | 28 +++++++-- components/search/ResultsHeader.tsx | 3 - components/search/SourceBadges.tsx | 96 +++++++++++++++++++++++++---- components/search/TypeBadgeItem.tsx | 19 +++--- components/search/TypeBadgeList.tsx | 8 +-- lib/hooks/useSourceBadges.ts | 78 +++++++++++++++++++++++ 6 files changed, 197 insertions(+), 35 deletions(-) create mode 100644 lib/hooks/useSourceBadges.ts diff --git a/app/page.tsx b/app/page.tsx index e3fbbff..393a59e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -10,11 +10,13 @@ import { VideoGrid } from '@/components/search/VideoGrid'; import { NoResults } from '@/components/search/NoResults'; import { ResultsHeader } from '@/components/search/ResultsHeader'; import { TypeBadges } from '@/components/search/TypeBadges'; +import { SourceBadges } from '@/components/search/SourceBadges'; import { PopularFeatures } from '@/components/home/PopularFeatures'; import { WatchHistorySidebar } from '@/components/history/WatchHistorySidebar'; import { useSearchCache } from '@/lib/hooks/useSearchCache'; import { useParallelSearch } from '@/lib/hooks/useParallelSearch'; import { useTypeBadges } from '@/lib/hooks/useTypeBadges'; +import { useSourceBadges } from '@/lib/hooks/useSourceBadges'; function HomePage() { const router = useRouter(); @@ -41,13 +43,21 @@ function HomePage() { (q: string) => router.replace(`/?q=${encodeURIComponent(q)}`, { scroll: false }) ); + // Source badges hook - filters by video source + const { + selectedSources, + filteredVideos: sourceFilteredVideos, + toggleSource, + } = useSourceBadges(results, availableSources); + // Type badges hook - auto-collects and filters by type_name + // Apply on source-filtered results for combined filtering const { typeBadges, selectedTypes, - filteredVideos, + filteredVideos: finalFilteredVideos, toggleType, - } = useTypeBadges(results); + } = useTypeBadges(sourceFilteredVideos); // Load cached results on mount useEffect(() => { @@ -139,6 +149,16 @@ function HomePage() { availableSources={availableSources} /> + {/* Source Badges - Clickable video source filtering */} + {availableSources.length > 0 && ( + + )} + {/* Type Badges - Auto-collected from search results */} {typeBadges.length > 0 && ( )} - {/* Display filtered or all videos */} - + {/* Display filtered videos (both source and type filters applied) */} + )} diff --git a/components/search/ResultsHeader.tsx b/components/search/ResultsHeader.tsx index 934af57..fe22b42 100644 --- a/components/search/ResultsHeader.tsx +++ b/components/search/ResultsHeader.tsx @@ -2,7 +2,6 @@ import { Badge } from '@/components/ui/Badge'; import { Icons } from '@/components/ui/Icon'; -import { SourceBadges } from './SourceBadges'; interface ResultsHeaderProps { loading: boolean; @@ -35,8 +34,6 @@ export function ResultsHeader({ )} - - ); } diff --git a/components/search/SourceBadges.tsx b/components/search/SourceBadges.tsx index d9ea51a..585fd59 100644 --- a/components/search/SourceBadges.tsx +++ b/components/search/SourceBadges.tsx @@ -1,5 +1,13 @@ +/** + * SourceBadges - Clickable video source badges with filtering + * Shows available video sources with counts + * Click to filter videos by source + * Similar functionality to TypeBadges + */ + 'use client'; +import { memo } from 'react'; import { Card } from '@/components/ui/Card'; import { Badge } from '@/components/ui/Badge'; import { Icons } from '@/components/ui/Icon'; @@ -12,27 +20,89 @@ interface Source { interface SourceBadgesProps { sources: Source[]; + selectedSources: Set; + onToggleSource: (sourceId: string) => void; className?: string; } -export function SourceBadges({ sources, className = '' }: SourceBadgesProps) { +export const SourceBadges = memo(function SourceBadges({ + sources, + selectedSources, + onToggleSource, + className = '' +}: SourceBadgesProps) { if (sources.length === 0) { return null; } + const handleClearAll = () => { + selectedSources.forEach(sourceId => onToggleSource(sourceId)); + }; + return ( - -
- {sources.map((source) => ( - - {source.name} ({source.count}) - - ))} + +
+
+ + + 视频源 ({sources.length}): + +
+ +
+ {sources.map((source) => { + const isSelected = selectedSources.has(source.id); + + return ( + + ); + })} +
+ + {selectedSources.size > 0 && ( +
+ +
+ )}
); -} +}); diff --git a/components/search/TypeBadgeItem.tsx b/components/search/TypeBadgeItem.tsx index 77f95c7..8c2dd75 100644 --- a/components/search/TypeBadgeItem.tsx +++ b/components/search/TypeBadgeItem.tsx @@ -42,26 +42,23 @@ export function TypeBadgeItem({ aria-pressed={isSelected} aria-label={`${type} 类型,${count} 个视频${isSelected ? ',已选中' : ''}`} className={` - inline-flex items-center gap-1.5 px-3 py-1.5 - border border-[var(--glass-border)] - text-xs font-medium whitespace-nowrap + inline-flex items-center gap-1.5 px-4 py-2 + rounded-full + border-2 + text-sm font-medium whitespace-nowrap transition-all duration-200 ease-out hover:scale-105 hover:shadow-[var(--shadow-sm)] active:scale-95 snap-start + focus:outline-none ${isSelected - ? 'bg-[var(--accent-color)] text-white border-[var(--accent-color)]' - : 'bg-[var(--glass-bg)] text-[var(--text-color)] backdrop-blur-[10px]' + ? 'bg-[var(--accent-color)] text-white border-[var(--accent-color)] shadow-md' + : 'bg-[var(--glass-bg)] text-[var(--text-color)] backdrop-blur-[10px] border-[var(--glass-border)] hover:border-[var(--accent-color)]' } - ${isFocused - ? 'ring-2 ring-[var(--accent-color)] ring-offset-2' - : '' - } - rounded-[var(--radius-full)] `} > {type}
{badges.map((badge, index) => (
{badges.map((badge, index) => ( ( + videos: T[], + availableSources: SourceBadge[] +) { + const [selectedSources, setSelectedSources] = useState>(new Set()); + + // Filter videos by selected sources + const filteredVideos = useMemo(() => { + if (selectedSources.size === 0) { + return videos; + } + + return videos.filter(video => + video.source && selectedSources.has(video.source) + ); + }, [videos, selectedSources]); + + // Toggle source selection + const toggleSource = useCallback((sourceId: string) => { + setSelectedSources(prev => { + const newSet = new Set(prev); + if (newSet.has(sourceId)) { + newSet.delete(sourceId); + } else { + newSet.add(sourceId); + } + return newSet; + }); + }, []); + + // Clear all selections + const clearSelection = useCallback(() => { + setSelectedSources(new Set()); + }, []); + + // Auto-cleanup: remove selected sources that no longer exist + useEffect(() => { + const availableSourceIds = new Set(availableSources.map(s => s.id)); + + setSelectedSources(prev => { + const filtered = new Set( + Array.from(prev).filter(sourceId => availableSourceIds.has(sourceId)) + ); + + // Only update if changed + if (filtered.size !== prev.size) { + return filtered; + } + return prev; + }); + }, [availableSources]); + + return { + selectedSources, + filteredVideos, + toggleSource, + clearSelection, + hasFilters: selectedSources.size > 0, + }; +}