/** * 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, useEffect, useMemo } from 'react'; import { Icons } from '@/components/ui/Icon'; import { SourceBadgeItem } from './SourceBadgeItem'; import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation'; const EXPAND_KEY = 'kvideo_source_badges_expanded'; interface Source { id: string; name: string; count: number; typeName?: string; } interface SourceBadgeListProps { sources: Source[]; selectedSources: Set; onToggleSource: (sourceId: string) => void; } export function SourceBadgeList({ sources, selectedSources, onToggleSource }: SourceBadgeListProps) { const [isExpanded, setIsExpanded] = useState(() => { if (typeof window === 'undefined') return true; const saved = localStorage.getItem(EXPAND_KEY); return saved !== 'false'; // default to expanded }); const [focusedIndex, setFocusedIndex] = useState(-1); const [hasOverflow, setHasOverflow] = useState(false); const containerRef = useRef(null); const badgeContainerRef = useRef(null); const badgeRefs = useRef<(HTMLButtonElement | null)[]>([]); // Group sources by typeName if type info is available const typeGroups = useMemo(() => { const groups = new Map(); for (const source of sources) { const type = source.typeName || ''; if (!groups.has(type)) groups.set(type, []); groups.get(type)!.push(source); } // Only use grouping if there are meaningful type names const hasTypes = groups.size > 1 || (groups.size === 1 && !groups.has('')); return hasTypes ? groups : null; }, [sources]); const toggleExpanded = useCallback(() => { setIsExpanded(prev => { const next = !prev; localStorage.setItem(EXPAND_KEY, String(next)); return next; }); }, []); // 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]), }); // Check if content has overflow on mount and when source count changes const hasCheckedOverflow = useRef(false); useEffect(() => { const checkOverflow = () => { if (badgeContainerRef.current) { const maxHeight = 50; // 50px to fit one row (44px) + padding but hide second row (starts at 52px) setHasOverflow(badgeContainerRef.current.scrollHeight > maxHeight); } }; checkOverflow(); // Only do delayed recheck on first measurement if (!hasCheckedOverflow.current) { hasCheckedOverflow.current = true; const timeout = setTimeout(checkOverflow, 100); return () => clearTimeout(timeout); } }, [sources.length]); return ( <> {/* Desktop: Expandable Grid */}
{typeGroups ? ( Array.from(typeGroups.entries()).map(([typeName, typeSources]) => (
{typeName && ( {typeName}: )} {typeSources.map((source) => { const globalIndex = sources.indexOf(source); return ( onToggleSource(source.id)} isFocused={focusedIndex === globalIndex} onFocus={() => setFocusedIndex(globalIndex)} innerRef={(el: HTMLButtonElement | null) => { badgeRefs.current[globalIndex] = el; }} /> ); })}
)) ) : ( sources.map((source, index) => ( onToggleSource(source.id)} isFocused={focusedIndex === index} onFocus={() => setFocusedIndex(index)} innerRef={(el: HTMLButtonElement | null) => { badgeRefs.current[index] = el; }} /> )) )}
{hasOverflow && ( )}
{/* Mobile & Tablet: Horizontal Scroll */}
{sources.map((source, index) => ( onToggleSource(source.id)} isFocused={focusedIndex === index} onFocus={() => setFocusedIndex(index)} innerRef={(el: HTMLButtonElement | null) => { badgeRefs.current[index] = el; }} /> ))}
); }