diff --git a/README.md b/README.md index 6c1677a..8dcb1ab 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,12 @@ KVideo is built using cutting-edge web technologies: - Search result caching (10-minute duration) - Loading animations with progress indicators - Source availability badges +- **🏷️ Auto-collected type badges with filtering** (NEW!) + - Automatically collects category badges from search results + - Interactive filtering by video type/category + - Real-time badge count updates + - Smart badge removal when videos are deleted + - Beautiful Liquid Glass design integration #### 📱 **Fully Responsive** - Mobile-first design approach diff --git a/app/page.tsx b/app/page.tsx index 686269e..25cad77 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -10,8 +10,10 @@ import { VideoGrid } from '@/components/search/VideoGrid'; import { EmptyState } from '@/components/search/EmptyState'; import { NoResults } from '@/components/search/NoResults'; import { ResultsHeader } from '@/components/search/ResultsHeader'; +import { TypeBadges } from '@/components/search/TypeBadges'; import { useSearchCache } from '@/lib/hooks/useSearchCache'; import { useParallelSearch } from '@/lib/hooks/useParallelSearch'; +import { useTypeBadges } from '@/lib/hooks/useTypeBadges'; function HomePage() { const router = useRouter(); @@ -38,6 +40,14 @@ function HomePage() { (q: string) => router.replace(`/?q=${encodeURIComponent(q)}`, { scroll: false }) ); + // Type badges hook - auto-collects and filters by type_name + const { + typeBadges, + selectedTypes, + filteredVideos, + toggleType, + } = useTypeBadges(results); + // Load cached results on mount useEffect(() => { if (hasLoadedCache.current) return; @@ -136,7 +146,19 @@ function HomePage() { totalVideos={totalVideosFound} availableSources={availableSources} /> - + + {/* Type Badges - Auto-collected from search results */} + {typeBadges.length > 0 && ( + + )} + + {/* Display filtered or all videos */} + )} diff --git a/components/search/TypeBadges.tsx b/components/search/TypeBadges.tsx new file mode 100644 index 0000000..7716d90 --- /dev/null +++ b/components/search/TypeBadges.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { Card } from '@/components/ui/Card'; +import { Badge } from '@/components/ui/Badge'; +import { Icons } from '@/components/ui/Icon'; + +interface TypeBadge { + type: string; + count: number; +} + +interface TypeBadgesProps { + badges: TypeBadge[]; + selectedTypes: Set; + onToggleType: (type: string) => void; + className?: string; +} + +/** + * TypeBadges - Displays collected type badges from search results + * Auto-collects unique type_name values and shows counts + * Badges disappear when all videos of that type are removed + */ +export function TypeBadges({ + badges, + selectedTypes, + onToggleType, + className = '' +}: TypeBadgesProps) { + if (badges.length === 0) { + return null; + } + + return ( + +
+
+ + + 分类标签 ({badges.length}): + +
+ +
+ {badges.map((badge) => { + const isSelected = selectedTypes.has(badge.type); + + return ( + + ); + })} +
+
+ + {selectedTypes.size > 0 && ( +
+ +
+ )} +
+ ); +} diff --git a/components/ui/Icon.tsx b/components/ui/Icon.tsx index b5bc202..5f7f532 100644 --- a/components/ui/Icon.tsx +++ b/components/ui/Icon.tsx @@ -276,4 +276,38 @@ export const Icons = { ), + + Tag: ({ className = "", size = 24 }: IconProps) => ( + + + + + ), + + X: ({ className = "", size = 24 }: IconProps) => ( + + + + + ), }; diff --git a/lib/hooks/useTypeBadges.ts b/lib/hooks/useTypeBadges.ts new file mode 100644 index 0000000..2bbcf21 --- /dev/null +++ b/lib/hooks/useTypeBadges.ts @@ -0,0 +1,94 @@ +'use client'; + +import { useState, useEffect, useMemo } from 'react'; + +interface TypeBadge { + type: string; + count: number; +} + +/** + * Custom hook to automatically collect and track type badges from video results + * + * Features: + * - Auto-collects unique type_name values + * - Tracks count per type + * - Updates dynamically as videos are added/removed + * - Removes badges when count reaches 0 + * - Supports filtering by selected types + */ +export function useTypeBadges(videos: T[]) { + const [selectedTypes, setSelectedTypes] = useState>(new Set()); + + // Collect and count type badges from videos + const typeBadges = useMemo(() => { + const typeMap = new Map(); + + videos.forEach(video => { + if (video.type_name && video.type_name.trim()) { + const type = video.type_name.trim(); + typeMap.set(type, (typeMap.get(type) || 0) + 1); + } + }); + + // Convert to array and sort by count (descending) + return Array.from(typeMap.entries()) + .map(([type, count]) => ({ type, count })) + .sort((a, b) => b.count - a.count); + }, [videos]); + + // Filter videos by selected types + const filteredVideos = useMemo(() => { + if (selectedTypes.size === 0) { + return videos; + } + + return videos.filter(video => + video.type_name && selectedTypes.has(video.type_name.trim()) + ); + }, [videos, selectedTypes]); + + // Toggle type selection + const toggleType = (type: string) => { + setSelectedTypes(prev => { + const newSet = new Set(prev); + if (newSet.has(type)) { + newSet.delete(type); + } else { + newSet.add(type); + } + return newSet; + }); + }; + + // Clear all selections + const clearSelection = () => { + setSelectedTypes(new Set()); + }; + + // Auto-cleanup: remove selected types that no longer exist in badges + useEffect(() => { + const availableTypes = new Set(typeBadges.map(b => b.type)); + + setSelectedTypes(prev => { + const filtered = new Set( + Array.from(prev).filter(type => availableTypes.has(type)) + ); + + // Only update if changed + if (filtered.size !== prev.size) { + return filtered; + } + return prev; + }); + }, [typeBadges]); + + return { + typeBadges, + selectedTypes, + filteredVideos, + toggleType, + clearSelection, + hasFilters: selectedTypes.size > 0, + }; +}