1 2import { useState, useEffect, useRef } from 'react'; 3import { html } from '../html.js?v=3.8.36'; 4import { X, Search, XCircle, ChevronDown, ChevronUp } from 'lucide-react'; 5import { 6 findSearchSuggestions, 7 normalizeForSearch, 8} from '../utils/searchNormalize.js?v=3.8.36'; 9import { CONTENT_TYPE_OPTIONS } from '../constants.js?v=3.8.36'; 10import { createRapidRepeatCounter } from '../utils/easterEggs.js?v=3.8.36'; 11import SemanticSearchToggle from './SemanticSearchToggle.js?v=3.8.36'; 12 13const Sidebar = ({ 14 facets, 15 filters, 16 setFilters, 17 isOpen, 18 onClose, 19 resetFilters, 20 autocompleteIndex, 21 // Opt-in semantic search state owned by App (#279). Absent in shells that do 22 // not offer the toggle, so the search group renders without it. 23 semanticSearch = null, 24 variant = 'standard', 25 // Called when one category is toggled five times in quick succession 26 // (#754). Optional: shells that do not offer the extra simply omit it, and 27 // the filter itself behaves the same either way. 28 onCategoryStreak = null, 29}) => { 30 const [suggestions, setSuggestions] = useState([]); 31 const [showSuggestions, setShowSuggestions] = useState(false); 32 const [showAllCategories, setShowAllCategories] = useState(false); 33 const [isCompactViewport, setIsCompactViewport] = useState(() => ( 34 typeof window !== 'undefined' && window.matchMedia('(max-width: 1023px)').matches 35 )); 36 const searchRef = useRef(null); 37 const searchInputRef = useRef(null); 38 // Counts quick repeat clicks on one category. Held in a ref so counting 39 // never re-renders the filter panel, and built on the first click so no 40 // counter is made and thrown away on every render. 41 const categoryStreak = useRef(null); 42 43 const handleSearchChange = (e) => { 44 const val = e.target.value; 45 setFilters(prev => ({ ...prev, search: val })); 46 47 if (val.length > 1) { 48 const matched = findSearchSuggestions(autocompleteIndex, val, 8); 49 setSuggestions(matched); 50 setShowSuggestions(true); 51 } else { 52 setShowSuggestions(false); 53 } 54 }; 55 56 const handleSelectSuggestion = (term) => { 57 setFilters(prev => ({ ...prev, search: term })); 58 setShowSuggestions(false); 59 }; 60 61 // True on the click that completes a streak. Every click on the chip counts, 62 // whether it turns the filter on or off, so five clicks are five clicks. 63 const countCategoryClick = (cat) => { 64 if (!categoryStreak.current) categoryStreak.current = createRapidRepeatCounter(); 65 return categoryStreak.current.register(cat, Date.now()); 66 }; 67 68 const toggleCategory = (cat) => { 69 setFilters(prev => { 70 const exists = prev.categories.includes(cat); 71 return { 72 ...prev, 73 categories: exists ? prev.categories.filter(c => c !== cat) : [...prev.categories, cat] 74 }; 75 }); 76 if (onCategoryStreak && countCategoryClick(cat)) { 77 onCategoryStreak(cat); 78 } 79 }; 80 81 const handleEraChange = (era) => { 82 setFilters(prev => ({ 83 ...prev, 84 era: prev.era === era ? null : era, 85 year: null 86 })); 87 }; 88 89 const handleTypeChange = (type) => { 90 setFilters(prev => ({ ...prev, type })); 91 }; 92 93 const handleResetFilters = () => { 94 resetFilters(); 95 setSuggestions([]); 96 setShowSuggestions(false); 97 requestAnimationFrame(() => searchInputRef.current?.focus()); 98 }; 99 100 useEffect(() => { 101 const handleClickOutside = (event) => { 102 if (searchRef.current && !searchRef.current.contains(event.target)) { 103 setShowSuggestions(false); 104 } 105 }; 106 document.addEventListener("mousedown", handleClickOutside); 107 return () => document.removeEventListener("mousedown", handleClickOutside); 108 }, []); 109 110 useEffect(() => { 111 const media = window.matchMedia('(max-width: 1023px)'); 112 const syncViewport = () => setIsCompactViewport(media.matches); 113 syncViewport(); 114 media.addEventListener('change', syncViewport); 115 return () => media.removeEventListener('change', syncViewport); 116 }, []); 117 118 const visibleCategories = showAllCategories 119 ? facets.categories 120 : facets.categories.slice(0, 10); 121 122 // Count active filters for mobile summary 123 const activeFilterCount = (filters.search ? 1 : 0) + 124 filters.categories.length + 125 (filters.era ? 1 : 0) + 126 (filters.year ? 1 : 0) + 127 (filters.type ? 1 : 0) + 128 (filters.recordIds !== null ? 1 : 0); 129 const embedded = variant === 'desktop'; 130 const searchInputId = embedded ? 'desktop-archive-search' : 'archive-search'; 131 const FilterPanelTag = embedded ? 'div' : 'aside';
132 const drawerIsInert = !embedded && isCompactViewport && !isOpen; 133 134 return html` 135 <div className=${embedded ? 'desktop-filter-root archive-filter-sidebar-root' : 'archive-filter-sidebar-root'}> 136 <div 137 className=${embedded 138 ? `desktop-filter-backdrop ${isOpen ? 'is-open' : ''}` 139 : `fixed inset-0 bg-black/50 z-[55] lg:hidden backdrop-blur-sm transition-opacity duration-300 ${isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}`} 140 onClick=${onClose} 141 aria-hidden="true" 142 /> 143 144 <${FilterPanelTag} 145 id=${embedded ? 'desktop-archive-filters' : 'archive-filters'} 146 className=${embedded 147 ? `desktop-filter-panel archive-filter-sidebar ${isOpen ? 'is-open' : ''}` 148 : ` 149 archive-filter-sidebar 150 fixed inset-y-0 left-0 z-[60] lg:z-auto w-80 bg-[#fdfbf7] border-r border-stone-300 151 transform transition-transform duration-300 ease-out lg:translate-x-0 lg:static lg:w-64 lg:bg-transparent lg:border-none lg:block shrink-0 152 overflow-y-auto lg:overflow-visible shadow-2xl lg:shadow-none 153 ${isOpen ? 'translate-x-0' : '-translate-x-full'} 154 `} 155 aria-label=${embedded ? undefined : 'Archive filters'} 156 aria-hidden=${drawerIsInert ? 'true' : undefined} 157 inert=${drawerIsInert ? '' : undefined} 158 > 159 <div className="archive-filter-sidebar__inner p-5 lg:p-0"> 160 161 <div className="flex justify-between items-center lg:hidden mb-6"> 162 <h3 className="font-display text-xl">Filters and sort</h3> 163 <button type="button" onClick=${onClose} className="p-1 hover:bg-stone-200 rounded" aria-label="Close archive filters"> 164 <${X} className="w-6 h-6" aria-hidden="true" /> 165 </button> 166 </div> 167 168 ${activeFilterCount > 0 && html` 169 <div className="archive-filter-sidebar__active"> 170 <div> 171 <span className="font-bold">${activeFilterCount} filter${activeFilterCount > 1 ? 's' : ''} active</span> 172 ${filters.search && html` â searching "${filters.search}"`} 173 ${filters.categories.length > 0 && html` â ${filters.categories.join(', ')}`} 174 ${filters.era && html` â ${filters.era}`} 175 ${filters.type && html` â ${filters.type}`} 176 ${filters.recordIds !== null && html`, query results`} 177 </div> 178 <button type="button" onClick=${handleResetFilters}>Reset all</button> 179 </div> 180 `} 181 182 ${filters.recordIds !== null && html` 183 <div className="border border-amber-300 bg-amber-50 p-3 text-xs text-stone-700"> 184 <p className="font-bold">Limited to query results</p> 185 <button 186 type="button" 187 onClick=${() => setFilters(prev => ({ ...prev, recordIds: null }))} 188 className="mt-2 inline-flex items-center gap-1 font-bold text-amber-900 underline underline-offset-2 hover:text-stone-900" 189 > 190 <${XCircle} className="h-4 w-4" aria-hidden="true" /> 191 Clear query results 192 </button> 193 </div> 194 `} 195 196 <div className="archive-filter-sidebar__search-group space-y-2" ref=${searchRef}> 197 <label htmlFor=${searchInputId} className="archive-filter-sidebar__label">Search archive</label> 198 <div className="relative"> 199 <input 200 id=${searchInputId} 201 ref=${searchInputRef} 202 type="text" 203 value=${filters.search} 204 onChange=${handleSearchChange} 205 onFocus=${() => filters.search.length > 1 && setShowSuggestions(true)} 206 placeholder="Keywords, title..." 207 className="archive-control archive-filter-sidebar__search" 208 /> 209 <${Search} className="w-4 h-4 text-stone-400 absolute left-2.5 top-2.5" /> 210 ${filters.search && html` 211 <button 212 type="button" 213 onClick=${() => setFilters(prev => ({ ...prev, search: '' }))} 214 className="absolute right-2 top-2 text-stone-400 hover:text-stone-800" 215 aria-label="Clear archive search" 216 > 217 <${XCircle} className="w-4 h-4" aria-hidden="true" /> 218 </button> 219 `} 220 221 ${showSuggestions && suggestions.length > 0 && html` 222 <div className="absolute z-30 w-full bg-white border border-stone-300 shadow-lg mt-1 max-h-60 overflow-y-auto"> 223 <ul> 224 ${suggestions.map(term => html` 225 <li key=${normalizeForSearch(term)}> 226 <button 227 type="button" 228 onClick=${() => handleSelectSuggestion(term)} 229 className="block w-full px-3 py-2 text-left text-sm text-stone-700 hover:bg-stone-100 cursor-pointer truncate border-b border-stone-100 last:border-0" 230 > 231 ${term} 232 </button> 233 </li> 234 `)} 235 </ul> 236 </div> 237 `} 238 </div> 239 <p className="archive-filter-sidebar__hint">Searches metadata, article text, and social post text</p> 240 ${semanticSearch && html` 241 <${SemanticSearchToggle} 242 ...${semanticSearch} 243 inputId=${`${searchInputId}-semantic`} 244 /> 245 `} 246 </div> 247 248 <div className="archive-filter-group border-t border-stone-200 pt-4"> 249 <h4 className="archive-filter-sidebar__label">Thematic categories</h4> 250 <div className="space-y-2"> 251 ${visibleCategories.map(cat => html` 252 <label key=${cat} className="flex items-start gap-2 cursor-pointer group"> 253 <input 254 type="checkbox" 255 checked=${filters.categories.includes(cat)} 256 onChange=${() => toggleCategory(cat)} 257 className="mt-1 w-4 h-4 accent-stone-900 border-stone-300 rounded focus:ring-stone-900 transition-colors" 258 />
259 <span className="text-sm text-stone-600 group-hover:text-stone-900 transition-colors leading-tight pt-0.5">${cat}</span> 260 </label> 261 `)} 262 263 ${facets.categories.length > 10 && html` 264 <button 265 type="button" 266 onClick=${() => setShowAllCategories(!showAllCategories)} 267 className="archive-filter-more" 268 > 269 ${showAllCategories ? html`View less <${ChevronUp} className="w-3 h-3"/>` : html`View all (${facets.categories.length - 10} more) <${ChevronDown} className="w-3 h-3"/>`} 270 </button> 271 `} 272 </div> 273 </div> 274 275 <div className="archive-filter-group border-t border-stone-200 pt-4"> 276 <h4 className="archive-filter-sidebar__label">Timeline</h4> 277 <p className="archive-filter-sidebar__hint">Filter by era or choose a year in the timeline</p> 278 <div className="space-y-2"> 279 <label className="flex items-center gap-2 cursor-pointer"> 280 <input 281 type="radio" 282 name="era" 283 checked=${filters.era === null && filters.year === null} 284 onChange=${() => setFilters(prev => ({ ...prev, era: null, year: null }))} 285 className="accent-stone-900 focus:ring-stone-900" 286 /> 287 <span className="text-sm">All eras</span> 288 </label> 289 ${facets.eras.map(era => html` 290 <label key=${era} className="flex items-center gap-2 cursor-pointer"> 291 <input 292 type="radio" 293 name="era" 294 checked=${filters.era === era} 295 onChange=${() => handleEraChange(era)} 296 className="accent-stone-900 focus:ring-stone-900" 297 /> 298 <span className="text-sm">${era}</span> 299 </label> 300 `)} 301 </div> 302 </div> 303 304 <div className="archive-filter-group border-t border-stone-200 pt-4"> 305 <h4 className="archive-filter-sidebar__label">Content type</h4> 306 <div className="flex flex-wrap gap-4"> 307 ${CONTENT_TYPE_OPTIONS.map((opt) => html` 308 <label key=${opt.label} className="flex items-center gap-2 cursor-pointer"> 309 <input 310 type="radio" 311 name="ctype" 312 checked=${filters.type === opt.value} 313 onChange=${() => handleTypeChange(opt.value)} 314 className="accent-stone-900 focus:ring-stone-900" 315 /> 316 <span className="text-sm">${opt.label}</span> 317 </label> 318 `)} 319 </div> 320 ${/* Reply filtering is now handled during data export â short replies and thread members are excluded */''} 321 </div> 322 323 <div className="archive-filter-sidebar__actions"> 324 ${!embedded && html` 325 <button 326 type="button" 327 onClick=${onClose} 328 className="archive-action archive-filter-sidebar__apply lg:hidden" 329 > 330 Apply filters 331 </button> 332 `} 333 </div> 334 335 </div> 336 <//> 337 </div> 338 `; 339}; 340 341export default Sidebar;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.