mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-16 17:23:43 +08:00
feat: Add auto-collecting type badges with filtering functionality
This commit is contained in:
@@ -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
|
||||
|
||||
+23
-1
@@ -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}
|
||||
/>
|
||||
<VideoGrid videos={results} />
|
||||
|
||||
{/* Type Badges - Auto-collected from search results */}
|
||||
{typeBadges.length > 0 && (
|
||||
<TypeBadges
|
||||
badges={typeBadges}
|
||||
selectedTypes={selectedTypes}
|
||||
onToggleType={toggleType}
|
||||
className="mb-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Display filtered or all videos */}
|
||||
<VideoGrid videos={filteredVideos} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<string>;
|
||||
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 (
|
||||
<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.Tag size={16} className="text-[var(--accent-color)]" />
|
||||
<span className="text-sm font-semibold text-[var(--text-color)]">
|
||||
分类标签 ({badges.length}):
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{badges.map((badge) => {
|
||||
const isSelected = selectedTypes.has(badge.type);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={badge.type}
|
||||
onClick={() => onToggleType(badge.type)}
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 px-3 py-1.5
|
||||
border border-[var(--glass-border)]
|
||||
text-xs font-medium
|
||||
transition-all duration-[var(--transition-fluid)]
|
||||
hover:scale-105 hover:shadow-[var(--shadow-sm)]
|
||||
active:scale-95
|
||||
${isSelected
|
||||
? 'bg-[var(--accent-color)] text-white border-[var(--accent-color)]'
|
||||
: 'bg-[var(--glass-bg)] text-[var(--text-color)] backdrop-blur-[10px]'
|
||||
}
|
||||
`}
|
||||
style={{ borderRadius: 'var(--radius-full)' }}
|
||||
>
|
||||
<span>{badge.type}</span>
|
||||
<span className={`
|
||||
px-1.5 py-0.5 rounded-full text-[10px] font-semibold
|
||||
${isSelected
|
||||
? 'bg-white/20 text-white'
|
||||
: 'bg-[var(--accent-color)]/10 text-[var(--accent-color)]'
|
||||
}
|
||||
`}>
|
||||
{badge.count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedTypes.size > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-[var(--glass-border)]">
|
||||
<button
|
||||
onClick={() => selectedTypes.forEach(type => onToggleType(type))}
|
||||
className="text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)]
|
||||
flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<Icons.X size={12} />
|
||||
清除筛选 ({selectedTypes.size})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -276,4 +276,38 @@ export const Icons = {
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
Tag: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/>
|
||||
<line x1="7" y1="7" x2="7.01" y2="7"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
X: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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<T extends { type_name?: string }>(videos: T[]) {
|
||||
const [selectedTypes, setSelectedTypes] = useState<Set<string>>(new Set());
|
||||
|
||||
// Collect and count type badges from videos
|
||||
const typeBadges = useMemo<TypeBadge[]>(() => {
|
||||
const typeMap = new Map<string, number>();
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user