mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-16 01:03:42 +08:00
feat: implement SourceBadgeList and SourceBadgeItem components for improved video source filtering
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* SourceBadgeItem - Individual source badge component
|
||||
* Displays a single video source badge with count, supports selection state
|
||||
*/
|
||||
|
||||
interface SourceBadgeItemProps {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
isSelected: boolean;
|
||||
onToggle: () => void;
|
||||
isFocused?: boolean;
|
||||
onFocus?: () => void;
|
||||
innerRef?: (el: HTMLButtonElement | null) => void;
|
||||
}
|
||||
|
||||
export function SourceBadgeItem({
|
||||
id,
|
||||
name,
|
||||
count,
|
||||
isSelected,
|
||||
onToggle,
|
||||
isFocused = false,
|
||||
onFocus,
|
||||
innerRef,
|
||||
}: SourceBadgeItemProps) {
|
||||
const handleClick = () => {
|
||||
// Immediate visual feedback before state update
|
||||
onToggle();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={innerRef}
|
||||
onClick={handleClick}
|
||||
onFocus={onFocus}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
aria-pressed={isSelected}
|
||||
aria-label={`${name} 视频源,${count} 个视频${isSelected ? ',已选中' : ''}`}
|
||||
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 snap-start
|
||||
focus:outline-none
|
||||
${isSelected
|
||||
? 'bg-[var(--accent-color)] text-white border-[var(--accent-color)] shadow-md'
|
||||
: 'bg-[var(--glass-bg)] text-[var(--text-color)] border-[var(--glass-border)] hover:border-[var(--accent-color)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span>{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)]'
|
||||
}
|
||||
`}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* SourceBadgeList - Badge list container with responsive layout
|
||||
* Desktop: Expandable grid with show more/less
|
||||
* Mobile: Horizontal scroll with snap
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { SourceBadgeItem } from './SourceBadgeItem';
|
||||
import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation';
|
||||
|
||||
interface Source {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface SourceBadgeListProps {
|
||||
sources: Source[];
|
||||
selectedSources: Set<string>;
|
||||
onToggleSource: (sourceId: string) => void;
|
||||
}
|
||||
|
||||
export function SourceBadgeList({ sources, selectedSources, onToggleSource }: SourceBadgeListProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const badgeRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
// Keyboard navigation
|
||||
useKeyboardNavigation({
|
||||
enabled: true,
|
||||
containerRef: containerRef,
|
||||
currentIndex: focusedIndex,
|
||||
itemCount: sources.length,
|
||||
orientation: 'horizontal',
|
||||
onNavigate: useCallback((index: number) => {
|
||||
setFocusedIndex(index);
|
||||
badgeRefs.current[index]?.focus();
|
||||
// Scroll into view for mobile
|
||||
badgeRefs.current[index]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'center',
|
||||
});
|
||||
}, []),
|
||||
onSelect: useCallback((index: number) => {
|
||||
onToggleSource(sources[index].id);
|
||||
}, [sources, onToggleSource]),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop: Expandable Grid */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
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-[3rem] overflow-hidden' : ''
|
||||
}`}>
|
||||
{sources.map((source, index) => (
|
||||
<SourceBadgeItem
|
||||
key={source.id}
|
||||
id={source.id}
|
||||
name={source.name}
|
||||
count={source.count}
|
||||
isSelected={selectedSources.has(source.id)}
|
||||
onToggle={() => onToggleSource(source.id)}
|
||||
isFocused={focusedIndex === index}
|
||||
onFocus={() => setFocusedIndex(index)}
|
||||
innerRef={(el: HTMLButtonElement | null) => { badgeRefs.current[index] = el; }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sources.length > 5 && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="mt-2 text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)]
|
||||
flex items-center gap-1 transition-colors self-start"
|
||||
>
|
||||
<span>{isExpanded ? '收起' : '展开更多'}</span>
|
||||
<Icons.ChevronDown
|
||||
size={14}
|
||||
className={`transition-transform duration-300 ${isExpanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile & Tablet: Horizontal Scroll */}
|
||||
<div
|
||||
className="flex md:hidden flex-1 -mx-1 px-1 overflow-hidden"
|
||||
role="group"
|
||||
aria-label="视频源筛选"
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex items-center gap-2 overflow-x-auto pb-2 scrollbar-hide snap-x snap-mandatory"
|
||||
>
|
||||
{sources.map((source, index) => (
|
||||
<SourceBadgeItem
|
||||
key={source.id}
|
||||
id={source.id}
|
||||
name={source.name}
|
||||
count={source.count}
|
||||
isSelected={selectedSources.has(source.id)}
|
||||
onToggle={() => onToggleSource(source.id)}
|
||||
isFocused={focusedIndex === index}
|
||||
onFocus={() => setFocusedIndex(index)}
|
||||
innerRef={(el: HTMLButtonElement | null) => { badgeRefs.current[index] = el; }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,15 +2,15 @@
|
||||
* SourceBadges - Clickable video source badges with filtering
|
||||
* Shows available video sources with counts
|
||||
* Click to filter videos by source
|
||||
* Similar functionality to TypeBadges
|
||||
* Responsive: Desktop shows expand/collapse, Mobile shows horizontal scroll
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { SourceBadgeList } from './SourceBadgeList';
|
||||
|
||||
interface Source {
|
||||
id: string;
|
||||
@@ -52,43 +52,11 @@ export const SourceBadges = memo(function SourceBadges({
|
||||
</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)] 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>
|
||||
<SourceBadgeList
|
||||
sources={sources}
|
||||
selectedSources={selectedSources}
|
||||
onToggleSource={onToggleSource}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedSources.size > 0 && (
|
||||
|
||||
@@ -93,13 +93,13 @@ export function TypeBadgeList({ badges, selectedTypes, onToggleType }: TypeBadge
|
||||
|
||||
{/* Mobile & Tablet: Horizontal Scroll */}
|
||||
<div
|
||||
className="flex md:hidden flex-1 -mx-1 px-1"
|
||||
className="flex md:hidden flex-1 -mx-1 px-1 overflow-hidden"
|
||||
role="group"
|
||||
aria-label="类型筛选"
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex items-center gap-2 overflow-x-auto overflow-y-visible pb-2 scrollbar-hide snap-x snap-mandatory"
|
||||
className="flex items-center gap-2 overflow-x-auto pb-2 scrollbar-hide snap-x snap-mandatory"
|
||||
>
|
||||
{badges.map((badge, index) => (
|
||||
<TypeBadgeItem
|
||||
|
||||
Reference in New Issue
Block a user