A project tracker for decentralized social media platforms, clients, and tools
1import { useState, useRef, useEffect } from 'react'; 2import type { FilterState } from '../types/project'; 3 4interface FilterToolbarProps { 5 query: string; 6 selectedTags: string[]; 7 availableTags: string[]; 8 sort: FilterState['sort']; 9 projectCount: number; 10 onSearchChange: (query: string) => void; 11 onTagsChange: (tags: string[]) => void; 12 onSortChange: (sort: FilterState['sort']) => void; 13} 14 15const sortOptions: { value: FilterState['sort']; label: string }[] = [ 16 { value: 'stars', label: 'Most Stars' }, 17 { value: 'alphabetical', label: 'Alphabetical' } 18]; 19 20export default function FilterToolbar({ 21 query, 22 selectedTags, 23 availableTags, 24 sort, 25 projectCount, 26 onSearchChange, 27 onTagsChange, 28 onSortChange 29}: FilterToolbarProps) { 30 const [showTagDropdown, setShowTagDropdown] = useState(false); 31 const searchRef = useRef<HTMLInputElement>(null); 32 33 useEffect(() => { 34 const handleKeyDown = (e: KeyboardEvent) => { 35 if (e.key === '/' && document.activeElement?.tagName !== 'INPUT') { 36 e.preventDefault(); 37 searchRef.current?.focus(); 38 } 39 if (e.key === 'Escape') { 40 setShowTagDropdown(false); 41 } 42 }; 43 44 const handleClickOutside = (e: MouseEvent) => { 45 // Check if click is outside the dropdown 46 const target = e.target as HTMLElement; 47 if (!target.closest('.tag-dropdown-container')) { 48 setShowTagDropdown(false); 49 } 50 }; 51 52 window.addEventListener('keydown', handleKeyDown); 53 if (showTagDropdown) { 54 document.addEventListener('click', handleClickOutside); 55 } 56 57 return () => { 58 window.removeEventListener('keydown', handleKeyDown); 59 document.removeEventListener('click', handleClickOutside); 60 }; 61 }, [showTagDropdown]); 62 63 const toggleTag = (tag: string) => { 64 if (selectedTags.includes(tag)) { 65 onTagsChange(selectedTags.filter(t => t !== tag)); 66 } else { 67 onTagsChange([...selectedTags, tag]); 68 } 69 }; 70 71 return ( 72 <div className="mb-6 space-y-4"> 73 <div className="flex flex-wrap gap-3 items-center"> 74 <div className="relative flex-1 min-w-[200px] max-w-md"> 75 <input 76 ref={searchRef} 77 type="text" 78 value={query} 79 onChange={(e) => onSearchChange(e.target.value)} 80 placeholder="Search projects or tags" 81 className="w-full pl-10 pr-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-gray-100 placeholder-gray-500 focus:outline-none focus:border-blue-500" 82 /> 83 <svg className="absolute left-3 top-2.5 w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 84 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> 85 </svg> 86 </div> 87 88 <select 89 value={sort} 90 onChange={(e) => onSortChange(e.target.value as FilterState['sort'])} 91 className="px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-gray-100 focus:outline-none focus:border-blue-500" 92 > 93 {sortOptions.map(option => ( 94 <option key={option.value} value={option.value}> 95 {option.label} 96 </option> 97 ))} 98 </select> 99 100 <div className="relative tag-dropdown-container"> 101 <button 102 onClick={() => setShowTagDropdown(!showTagDropdown)} 103 className="px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-gray-100 hover:bg-gray-700 focus:outline-none focus:border-blue-500 flex items-center gap-2" 104 > 105 <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 106 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" /> 107 </svg> 108 Tags {selectedTags.length > 0 && `(${selectedTags.length})`} 109 <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 110 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> 111 </svg> 112 </button> 113 114 {showTagDropdown && ( 115 <div className="absolute top-full mt-2 w-64 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-20 max-h-80 overflow-y-auto"> 116 <div className="p-2"> 117 {availableTags.map(tag => ( 118 <label key={tag} className="flex items-center px-2 py-1.5 hover:bg-gray-700 rounded cursor-pointer"> 119 <input 120 type="checkbox" 121 checked={selectedTags.includes(tag)} 122 onChange={() => toggleTag(tag)} 123 className="mr-2 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500" 124 /> 125 <span className="text-sm text-gray-200">{tag}</span> 126 </label> 127 ))} 128 </div> 129 </div> 130 )} 131 </div> 132 133 <span className="text-sm text-gray-400"> 134 Showing {projectCount} projects 135 </span> 136 </div> 137 138 {selectedTags.length > 0 && ( 139 <div className="flex flex-wrap gap-2"> 140 {selectedTags.map(tag => ( 141 <span 142 key={tag} 143 className="inline-flex items-center gap-1 px-3 py-1 bg-blue-900 text-blue-200 rounded-full text-sm" 144 > 145 {tag} 146 <button 147 onClick={() => toggleTag(tag)} 148 className="hover:text-white" 149 > 150 <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 151 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> 152 </svg> 153 </button> 154 </span> 155 ))} 156 <button 157 onClick={() => onTagsChange([])} 158 className="text-sm text-gray-400 hover:text-gray-200" 159 > 160 Clear all 161 </button> 162 </div> 163 )} 164 </div> 165 ); 166}