refactor: implement source badge filtering; add useSourceBadges hook and integrate with HomePage and VideoGrid components

This commit is contained in:
kuekhaoyang
2025-11-18 18:01:33 +08:00
parent 973bc9048d
commit 694a015a1e
6 changed files with 197 additions and 35 deletions
+24 -4
View File
@@ -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 && (
<SourceBadges
sources={availableSources}
selectedSources={selectedSources}
onToggleSource={toggleSource}
className="mb-6"
/>
)}
{/* Type Badges - Auto-collected from search results */}
{typeBadges.length > 0 && (
<TypeBadges
@@ -149,8 +169,8 @@ function HomePage() {
/>
)}
{/* Display filtered or all videos */}
<VideoGrid videos={filteredVideos} />
{/* Display filtered videos (both source and type filters applied) */}
<VideoGrid videos={finalFilteredVideos} />
</div>
)}
-3
View File
@@ -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({
)}
</div>
</div>
<SourceBadges sources={availableSources} />
</div>
);
}
+83 -13
View File
@@ -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<string>;
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 (
<Card hover={false} className={`p-4 ${className}`}>
<div className="flex items-center gap-2 flex-wrap">
{sources.map((source) => (
<Badge
key={source.id}
variant="secondary"
className="text-xs"
>
{source.name} ({source.count})
</Badge>
))}
<Card
hover={false}
className={`p-4 animate-fade-in ${className}`}
>
<div className="flex items-start gap-3">
<div className="flex items-center gap-2 shrink-0 pt-1">
<Icons.Globe size={16} className="text-[var(--accent-color)]" />
<span className="text-sm font-semibold text-[var(--text-color)]">
({sources.length}):
</span>
</div>
<div className="flex-1 flex items-center gap-2 flex-wrap">
{sources.map((source) => {
const isSelected = selectedSources.has(source.id);
return (
<button
key={source.id}
onClick={() => onToggleSource(source.id)}
className={`
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
focus:outline-none
${isSelected
? '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)]'
}
`}
>
<span>{source.name}</span>
<span className={`
px-2 py-0.5 rounded-full text-xs font-semibold
${isSelected
? 'bg-white/20 text-white'
: 'bg-[var(--accent-color)]/10 text-[var(--accent-color)]'
}
`}>
{source.count}
</span>
</button>
);
})}
</div>
</div>
{selectedSources.size > 0 && (
<div className="mt-3 pt-3 border-t border-[var(--glass-border)]">
<button
onClick={handleClearAll}
className="text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)]
flex items-center gap-1 transition-colors"
>
<Icons.X size={12} />
({selectedSources.size})
</button>
</div>
)}
</Card>
);
}
});
+8 -11
View File
@@ -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)]
`}
>
<span>{type}</span>
<span className={`
px-1.5 py-0.5 rounded-full text-[10px] font-semibold
px-2 py-0.5 rounded-full text-xs font-semibold
${isSelected
? 'bg-white/20 text-white'
: 'bg-[var(--accent-color)]/10 text-[var(--accent-color)]'
+4 -4
View File
@@ -55,12 +55,12 @@ export function TypeBadgeList({ badges, selectedTypes, onToggleType }: TypeBadge
{/* Desktop: Expandable Grid */}
<div
ref={containerRef}
className="hidden md:flex md:flex-col md:flex-1"
className="hidden md:flex md:flex-col md:flex-1 -mx-1 px-1"
role="group"
aria-label="类型筛选"
>
<div className={`flex items-center gap-2 flex-wrap transition-all duration-300 ${
!isExpanded ? 'max-h-[2.5rem] overflow-hidden' : ''
!isExpanded ? 'max-h-[3rem] overflow-hidden' : ''
}`}>
{badges.map((badge, index) => (
<TypeBadgeItem
@@ -93,13 +93,13 @@ export function TypeBadgeList({ badges, selectedTypes, onToggleType }: TypeBadge
{/* Mobile & Tablet: Horizontal Scroll */}
<div
className="flex md:hidden flex-1 overflow-hidden"
className="flex md:hidden flex-1 -mx-1 px-1"
role="group"
aria-label="类型筛选"
>
<div
ref={containerRef}
className="flex items-center gap-2 overflow-x-auto pb-2 scrollbar-hide snap-x snap-mandatory"
className="flex items-center gap-2 overflow-x-auto overflow-y-visible pb-2 scrollbar-hide snap-x snap-mandatory"
>
{badges.map((badge, index) => (
<TypeBadgeItem
+78
View File
@@ -0,0 +1,78 @@
'use client';
import { useState, useEffect, useMemo, useCallback } from 'react';
interface SourceBadge {
id: string;
name: string;
count: number;
}
/**
* Custom hook to manage source badge filtering
*
* Features:
* - Tracks available video sources
* - Supports filtering by selected sources
* - Auto-cleanup when sources no longer exist
*/
export function useSourceBadges<T extends { source?: string; sourceName?: string }>(
videos: T[],
availableSources: SourceBadge[]
) {
const [selectedSources, setSelectedSources] = useState<Set<string>>(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,
};
}