PageSourceSearch

https://pressthink.org/j/rosen-archive/frontend/components/Sidebar.js?v=3.8.36

js pressthink.org collected 2026-10-02 04:26:25 UTC 14,357 bytes, 341 lines download raw bytes

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.