mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-21 11:43:41 +08:00
refactor: implement source badge filtering; add useSourceBadges hook and integrate with HomePage and VideoGrid components
This commit is contained in:
+24
-4
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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)]'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user