@canopy-iiif/app 0.7.17 → 0.7.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,23 +1,34 @@
1
- import React, { useEffect, useMemo, useSyncExternalStore, useState } from 'react';
2
- import { createRoot } from 'react-dom/client';
3
- import { SearchResultsUI, SearchTabsUI } from '@canopy-iiif/app/ui';
1
+ import React, {useEffect, useMemo, useSyncExternalStore, useState} from "react";
2
+ import {createRoot} from "react-dom/client";
3
+ import {
4
+ SearchResultsUI,
5
+ SearchTabsUI,
6
+ SearchFiltersDialog,
7
+ } from "@canopy-iiif/app/ui";
4
8
 
5
9
  // Lightweight IndexedDB utilities (no deps) with defensive guards
6
10
  function hasIDB() {
7
- try { return typeof indexedDB !== 'undefined'; } catch (_) { return false; }
11
+ try {
12
+ return typeof indexedDB !== "undefined";
13
+ } catch (_) {
14
+ return false;
15
+ }
8
16
  }
9
17
  function idbOpen() {
10
18
  return new Promise((resolve, reject) => {
11
19
  if (!hasIDB()) return resolve(null);
12
20
  try {
13
- const req = indexedDB.open('canopy-search', 1);
21
+ const req = indexedDB.open("canopy-search", 1);
14
22
  req.onupgradeneeded = () => {
15
23
  const db = req.result;
16
- if (!db.objectStoreNames.contains('indexes')) db.createObjectStore('indexes', { keyPath: 'version' });
24
+ if (!db.objectStoreNames.contains("indexes"))
25
+ db.createObjectStore("indexes", {keyPath: "version"});
17
26
  };
18
27
  req.onsuccess = () => resolve(req.result);
19
28
  req.onerror = () => resolve(null);
20
- } catch (_) { resolve(null); }
29
+ } catch (_) {
30
+ resolve(null);
31
+ }
21
32
  });
22
33
  }
23
34
  async function idbGet(store, key) {
@@ -25,12 +36,14 @@ async function idbGet(store, key) {
25
36
  if (!db) return null;
26
37
  return new Promise((resolve) => {
27
38
  try {
28
- const tx = db.transaction(store, 'readonly');
39
+ const tx = db.transaction(store, "readonly");
29
40
  const st = tx.objectStore(store);
30
41
  const req = st.get(key);
31
42
  req.onsuccess = () => resolve(req.result || null);
32
43
  req.onerror = () => resolve(null);
33
- } catch (_) { resolve(null); }
44
+ } catch (_) {
45
+ resolve(null);
46
+ }
34
47
  });
35
48
  }
36
49
  async function idbPut(store, value) {
@@ -38,12 +51,14 @@ async function idbPut(store, value) {
38
51
  if (!db) return false;
39
52
  return new Promise((resolve) => {
40
53
  try {
41
- const tx = db.transaction(store, 'readwrite');
54
+ const tx = db.transaction(store, "readwrite");
42
55
  const st = tx.objectStore(store);
43
56
  st.put(value);
44
57
  tx.oncomplete = () => resolve(true);
45
58
  tx.onerror = () => resolve(false);
46
- } catch (_) { resolve(false); }
59
+ } catch (_) {
60
+ resolve(false);
61
+ }
47
62
  });
48
63
  }
49
64
  async function idbPruneOld(store, keepKey) {
@@ -51,48 +66,69 @@ async function idbPruneOld(store, keepKey) {
51
66
  if (!db) return false;
52
67
  return new Promise((resolve) => {
53
68
  try {
54
- const tx = db.transaction(store, 'readwrite');
69
+ const tx = db.transaction(store, "readwrite");
55
70
  const st = tx.objectStore(store);
56
71
  const req = st.getAllKeys();
57
72
  req.onsuccess = () => {
58
- try { (req.result || []).forEach((k) => { if (k !== keepKey) st.delete(k); }); } catch (_) {}
73
+ try {
74
+ (req.result || []).forEach((k) => {
75
+ if (k !== keepKey) st.delete(k);
76
+ });
77
+ } catch (_) {}
59
78
  resolve(true);
60
79
  };
61
80
  req.onerror = () => resolve(false);
62
- } catch (_) { resolve(false); }
81
+ } catch (_) {
82
+ resolve(false);
83
+ }
63
84
  });
64
85
  }
65
86
  async function sha256Hex(str) {
66
87
  try {
67
- if (typeof crypto !== 'undefined' && crypto.subtle) {
88
+ if (typeof crypto !== "undefined" && crypto.subtle) {
68
89
  const data = new TextEncoder().encode(str);
69
- const digest = await crypto.subtle.digest('SHA-256', data);
70
- return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('');
90
+ const digest = await crypto.subtle.digest("SHA-256", data);
91
+ return Array.from(new Uint8Array(digest))
92
+ .map((b) => b.toString(16).padStart(2, "0"))
93
+ .join("");
71
94
  }
72
95
  } catch (_) {}
73
96
  // Defensive: simple non-crypto hash when Web Crypto is unavailable
74
97
  try {
75
- let h = 5381; for (let i = 0; i < str.length; i++) h = ((h << 5) + h) ^ str.charCodeAt(i);
98
+ let h = 5381;
99
+ for (let i = 0; i < str.length; i++) h = ((h << 5) + h) ^ str.charCodeAt(i);
76
100
  return (h >>> 0).toString(16);
77
- } catch (_) { return String(str && str.length ? str.length : 0); }
101
+ } catch (_) {
102
+ return String(str && str.length ? str.length : 0);
103
+ }
78
104
  }
79
105
 
80
106
  function createSearchStore() {
81
107
  let state = {
82
- query: new URLSearchParams(location.search).get('q') || '',
83
- type: new URLSearchParams(location.search).get('type') || 'all',
108
+ query: new URLSearchParams(location.search).get("q") || "",
109
+ type: new URLSearchParams(location.search).get("type") || "all",
84
110
  loading: true,
85
111
  records: [],
86
112
  types: [],
87
113
  index: null,
88
114
  counts: {},
115
+ facets: [],
116
+ facetsDocsMap: {},
117
+ filters: {},
118
+ activeFilterCount: 0,
89
119
  };
90
120
  const listeners = new Set();
91
- function notify() { listeners.forEach((fn) => { try { fn(); } catch (_) {} }); }
121
+ function notify() {
122
+ listeners.forEach((fn) => {
123
+ try {
124
+ fn();
125
+ } catch (_) {}
126
+ });
127
+ }
92
128
  // Keep a memoized snapshot so getSnapshot returns stable references
93
129
  let snapshot = null;
94
130
  function recomputeSnapshot() {
95
- const { index, records, query, type } = state;
131
+ const {index, records, query, type, filters, facetsDocsMap} = state;
96
132
  let base = [];
97
133
  let results = [];
98
134
  let totalForType = Array.isArray(records) ? records.length : 0;
@@ -101,89 +137,363 @@ function createSearchStore() {
101
137
  if (!query) {
102
138
  base = records;
103
139
  } else {
104
- try { const ids = index && index.search(query, { limit: 200 }) || []; base = ids.map((i) => records[i]).filter(Boolean); } catch (_) { base = []; }
140
+ try {
141
+ const ids = (index && index.search(query, {limit: 200})) || [];
142
+ base = ids.map((i) => records[i]).filter(Boolean);
143
+ } catch (_) {
144
+ base = [];
145
+ }
146
+ }
147
+ const normalizedFilters =
148
+ filters && typeof filters === "object" ? filters : {};
149
+ const totalActiveFilters = Object.keys(normalizedFilters).reduce(
150
+ (sum, slug) => {
151
+ const arr = Array.isArray(normalizedFilters[slug])
152
+ ? normalizedFilters[slug]
153
+ : [];
154
+ return sum + arr.filter(Boolean).length;
155
+ },
156
+ 0
157
+ );
158
+ state.activeFilterCount = totalActiveFilters;
159
+
160
+ const shouldFilterWorks =
161
+ totalActiveFilters > 0 && String(type).toLowerCase() === "work";
162
+ let allowed = null; // Set of record indices that satisfy filters
163
+ if (shouldFilterWorks) {
164
+ Object.keys(normalizedFilters).forEach((facetSlug) => {
165
+ const values = Array.isArray(normalizedFilters[facetSlug])
166
+ ? normalizedFilters[facetSlug]
167
+ : [];
168
+ if (!values.length) return;
169
+ const docMap =
170
+ facetsDocsMap && facetsDocsMap[facetSlug]
171
+ ? facetsDocsMap[facetSlug]
172
+ : null;
173
+ if (!docMap) {
174
+ allowed = new Set();
175
+ return;
176
+ }
177
+ const union = new Set();
178
+ values.forEach((valueSlugRaw) => {
179
+ const valueSlug = String(valueSlugRaw);
180
+ const docsSet = docMap[valueSlug];
181
+ if (docsSet && docsSet.size) {
182
+ docsSet.forEach((idx) => union.add(idx));
183
+ }
184
+ });
185
+ if (union.size === 0) {
186
+ allowed = new Set();
187
+ return;
188
+ }
189
+ if (!allowed) {
190
+ allowed = new Set(union);
191
+ } else {
192
+ allowed = new Set(
193
+ Array.from(allowed).filter((idx) => union.has(idx))
194
+ );
195
+ }
196
+ });
105
197
  }
106
- // Build per-type counts from base (query-filtered or all)
198
+
107
199
  try {
108
200
  counts = base.reduce((acc, r) => {
109
- const t = String((r && r.type) || 'page').toLowerCase();
201
+ const t = String((r && r.type) || "page").toLowerCase();
202
+ if (shouldFilterWorks && t === "work") {
203
+ if (!allowed || !allowed.has(r && r.__docIndex)) return acc;
204
+ }
110
205
  acc[t] = (acc[t] || 0) + 1;
111
206
  return acc;
112
207
  }, {});
113
- } catch (_) { counts = {}; }
114
- // Derive results for current tab
115
- results = type === 'all' ? base : base.filter((r) => String(r.type).toLowerCase() === String(type).toLowerCase());
116
- // Compute total relative to active tab (unfiltered by query)
117
- if (type !== 'all') {
118
- try { totalForType = records.filter((r) => String(r.type).toLowerCase() === String(type).toLowerCase()).length; } catch (_) {}
208
+ } catch (_) {
209
+ counts = {};
210
+ }
211
+
212
+ results =
213
+ type === "all"
214
+ ? base
215
+ : base.filter(
216
+ (r) => String(r.type).toLowerCase() === String(type).toLowerCase()
217
+ );
218
+ if (shouldFilterWorks && allowed) {
219
+ results = results.filter((r) => allowed.has(r && r.__docIndex));
220
+ }
221
+
222
+ if (type !== "all") {
223
+ try {
224
+ totalForType = records.filter(
225
+ (r) => String(r.type).toLowerCase() === String(type).toLowerCase()
226
+ ).length;
227
+ } catch (_) {}
119
228
  }
120
229
  }
121
- snapshot = { ...state, results, total: totalForType, shown: results.length, counts };
230
+ const {facetsDocsMap: _fMap, ...publicState} = state;
231
+ snapshot = {
232
+ ...publicState,
233
+ results,
234
+ total: totalForType,
235
+ shown: results.length,
236
+ counts,
237
+ };
238
+ }
239
+ function set(partial) {
240
+ state = {...state, ...partial};
241
+ recomputeSnapshot();
242
+ notify();
243
+ }
244
+
245
+ function syncFiltersToUrl(nextFilters) {
246
+ try {
247
+ const url = new URL(location.href);
248
+ const facetSlugs = new Set();
249
+ if (Array.isArray(state.facets)) {
250
+ state.facets.forEach((facet) => {
251
+ if (facet && facet.slug) facetSlugs.add(String(facet.slug));
252
+ });
253
+ }
254
+ Object.keys(nextFilters || {}).forEach((slug) => {
255
+ if (slug) facetSlugs.add(String(slug));
256
+ });
257
+ facetSlugs.forEach((slug) => url.searchParams.delete(slug));
258
+ Object.keys(nextFilters || {}).forEach((slug) => {
259
+ const vals = Array.isArray(nextFilters[slug])
260
+ ? nextFilters[slug].filter(Boolean)
261
+ : [];
262
+ if (vals.length)
263
+ url.searchParams.set(slug, Array.from(new Set(vals)).join(","));
264
+ });
265
+ const qs = url.searchParams.toString();
266
+ history.replaceState(
267
+ null,
268
+ "",
269
+ qs ? `${url.pathname}?${qs}` : url.pathname
270
+ );
271
+ } catch (_) {}
272
+ }
273
+
274
+ function toggleFilter(facetSlug, valueSlug, nextChecked) {
275
+ const slug = String(facetSlug || "");
276
+ const value = String(valueSlug || "");
277
+ if (!slug || !value) return;
278
+ const current = Array.isArray(state.filters && state.filters[slug])
279
+ ? [...state.filters[slug]]
280
+ : [];
281
+ const setValues = new Set(current);
282
+ const shouldCheck =
283
+ typeof nextChecked === "boolean" ? nextChecked : !setValues.has(value);
284
+ if (shouldCheck) setValues.add(value);
285
+ else setValues.delete(value);
286
+ const nextFilters = {...state.filters};
287
+ if (setValues.size) nextFilters[slug] = Array.from(setValues);
288
+ else delete nextFilters[slug];
289
+ syncFiltersToUrl(nextFilters);
290
+ set({filters: nextFilters});
291
+ }
292
+
293
+ function clearFilters() {
294
+ syncFiltersToUrl({});
295
+ set({filters: {}});
296
+ }
297
+
298
+ async function hydrateFacets() {
299
+ try {
300
+ const res = await fetch("./api/search/facets.json");
301
+ if (!res || !res.ok) return;
302
+ const json = await res.json().catch(() => null);
303
+ if (!Array.isArray(json)) return;
304
+ const sanitized = [];
305
+ const docsMap = {};
306
+ json.forEach((facet) => {
307
+ if (
308
+ !facet ||
309
+ !facet.label ||
310
+ !facet.slug ||
311
+ !Array.isArray(facet.values)
312
+ )
313
+ return;
314
+ const slug = String(facet.slug);
315
+ const values = [];
316
+ const valueMap = {};
317
+ facet.values.forEach((valueEntry) => {
318
+ if (!valueEntry || !valueEntry.slug) return;
319
+ const vSlug = String(valueEntry.slug);
320
+ const vLabel =
321
+ valueEntry.value != null ? String(valueEntry.value) : "";
322
+ const docCount = Number.isFinite(Number(valueEntry.doc_count))
323
+ ? Number(valueEntry.doc_count)
324
+ : undefined;
325
+ const docs = Array.isArray(valueEntry.docs)
326
+ ? valueEntry.docs.map((idx) => Number(idx))
327
+ : [];
328
+ if (!valueMap[vSlug]) valueMap[vSlug] = new Set();
329
+ docs.forEach((idx) => {
330
+ if (Number.isInteger(idx)) valueMap[vSlug].add(idx);
331
+ });
332
+ values.push({value: vLabel, slug: vSlug, doc_count: docCount});
333
+ });
334
+ docsMap[slug] = valueMap;
335
+ sanitized.push({label: String(facet.label), slug, values});
336
+ });
337
+
338
+ // Pre-populate filters from query params once facets are known
339
+ const params = new URLSearchParams(location.search);
340
+ const nextFilters = {};
341
+ sanitized.forEach((facet) => {
342
+ const slug = facet.slug;
343
+ const valid = new Set(facet.values.map((item) => item.slug));
344
+ const requested = [];
345
+ params.getAll(slug).forEach((raw) => {
346
+ const parts = String(raw || "").split(",");
347
+ parts.forEach((part) => {
348
+ const trimmed = part && part.trim ? part.trim() : part;
349
+ if (trimmed && valid.has(trimmed)) requested.push(trimmed);
350
+ });
351
+ });
352
+ if (requested.length)
353
+ nextFilters[slug] = Array.from(new Set(requested));
354
+ });
355
+
356
+ set({facets: sanitized, facetsDocsMap: docsMap, filters: nextFilters});
357
+ syncFiltersToUrl(nextFilters);
358
+ } catch (_) {}
359
+ }
360
+ function subscribe(fn) {
361
+ listeners.add(fn);
362
+ return () => listeners.delete(fn);
363
+ }
364
+ function getSnapshot() {
365
+ return snapshot;
122
366
  }
123
- function set(partial) { state = { ...state, ...partial }; recomputeSnapshot(); notify(); }
124
- function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }
125
- function getSnapshot() { return snapshot; }
126
367
  // Initialize snapshot
127
368
  recomputeSnapshot();
128
369
  // init
129
370
  (async () => {
130
371
  try {
131
- const DEBUG = (() => { try { const p = new URLSearchParams(location.search); return p.has('searchDebug') || localStorage.CANOPY_SEARCH_DEBUG === '1'; } catch (_) { return false; } })();
132
- const Flex = (window && window.FlexSearch) || (await import('flexsearch')).default;
372
+ const DEBUG = (() => {
373
+ try {
374
+ const p = new URLSearchParams(location.search);
375
+ return (
376
+ p.has("searchDebug") || localStorage.CANOPY_SEARCH_DEBUG === "1"
377
+ );
378
+ } catch (_) {
379
+ return false;
380
+ }
381
+ })();
382
+ const Flex =
383
+ (window && window.FlexSearch) || (await import("flexsearch")).default;
133
384
  // Broadcast new index installs to other tabs
134
385
  let bc = null;
135
- try { if (typeof BroadcastChannel !== 'undefined') bc = new BroadcastChannel('canopy-search'); } catch (_) {}
386
+ try {
387
+ if (typeof BroadcastChannel !== "undefined")
388
+ bc = new BroadcastChannel("canopy-search");
389
+ } catch (_) {}
136
390
  // Try to load meta for cache-busting and tab order; fall back to hash of JSON
137
- let version = '';
391
+ let version = "";
138
392
  let tabsOrder = [];
139
393
  try {
140
- const meta = await fetch('./api/index.json').then((r) => (r && r.ok ? r.json() : null)).catch(() => null);
141
- if (meta && typeof meta.version === 'string') version = meta.version;
142
- const ord = meta && meta.search && meta.search.tabs && Array.isArray(meta.search.tabs.order) ? meta.search.tabs.order : [];
394
+ const meta = await fetch("./api/index.json")
395
+ .then((r) => (r && r.ok ? r.json() : null))
396
+ .catch(() => null);
397
+ if (meta && typeof meta.version === "string") version = meta.version;
398
+ const ord =
399
+ meta &&
400
+ meta.search &&
401
+ meta.search.tabs &&
402
+ Array.isArray(meta.search.tabs.order)
403
+ ? meta.search.tabs.order
404
+ : [];
143
405
  tabsOrder = ord.map((s) => String(s)).filter(Boolean);
144
406
  } catch (_) {}
145
- const res = await fetch('./api/search-index.json' + (version ? `?v=${encodeURIComponent(version)}` : ''));
407
+ const res = await fetch(
408
+ "./api/search-index.json" +
409
+ (version ? `?v=${encodeURIComponent(version)}` : "")
410
+ );
146
411
  const text = await res.text();
147
- const parsed = (() => { try { return JSON.parse(text); } catch { return []; } })();
148
- const data = Array.isArray(parsed) ? parsed : (parsed && parsed.records ? parsed.records : []);
149
- if (!version) version = (parsed && parsed.version) || (await sha256Hex(text));
412
+ const parsed = (() => {
413
+ try {
414
+ return JSON.parse(text);
415
+ } catch {
416
+ return [];
417
+ }
418
+ })();
419
+ const rawData = Array.isArray(parsed)
420
+ ? parsed
421
+ : parsed && parsed.records
422
+ ? parsed.records
423
+ : [];
424
+ const data = rawData.map((rec, i) => ({...(rec || {}), __docIndex: i}));
425
+ if (!version)
426
+ version = (parsed && parsed.version) || (await sha256Hex(text));
150
427
 
151
- const idx = new Flex.Index({ tokenize: 'forward' });
428
+ const idx = new Flex.Index({tokenize: "forward"});
152
429
  let hydrated = false;
153
- const t0 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
430
+ const t0 =
431
+ typeof performance !== "undefined" && performance.now
432
+ ? performance.now()
433
+ : Date.now();
154
434
  try {
155
- const cached = await idbGet('indexes', version);
435
+ const cached = await idbGet("indexes", version);
156
436
  if (cached && cached.exportData) {
157
437
  try {
158
438
  const dataObj = cached.exportData || {};
159
439
  for (const k in dataObj) {
160
440
  if (Object.prototype.hasOwnProperty.call(dataObj, k)) {
161
- try { idx.import(k, dataObj[k]); } catch (_) {}
441
+ try {
442
+ idx.import(k, dataObj[k]);
443
+ } catch (_) {}
162
444
  }
163
445
  }
164
446
  hydrated = true;
165
- } catch (_) { hydrated = false; }
447
+ } catch (_) {
448
+ hydrated = false;
449
+ }
166
450
  }
167
- } catch (_) { /* no-op */ }
451
+ } catch (_) {
452
+ /* no-op */
453
+ }
168
454
 
169
455
  if (!hydrated) {
170
- data.forEach((rec, i) => { try { idx.add(i, rec && rec.title ? String(rec.title) : ''); } catch (_) {} });
456
+ data.forEach((rec, i) => {
457
+ try {
458
+ idx.add(i, rec && rec.title ? String(rec.title) : "");
459
+ } catch (_) {}
460
+ });
171
461
  try {
172
462
  const dump = {};
173
- try { await idx.export((key, val) => { dump[key] = val; }); } catch (_) {}
174
- await idbPut('indexes', { version, exportData: dump, ts: Date.now() });
175
- await idbPruneOld('indexes', version);
176
- try { if (bc) bc.postMessage({ type: 'search-index-installed', version }); } catch (_) {}
463
+ try {
464
+ await idx.export((key, val) => {
465
+ dump[key] = val;
466
+ });
467
+ } catch (_) {}
468
+ await idbPut("indexes", {version, exportData: dump, ts: Date.now()});
469
+ await idbPruneOld("indexes", version);
470
+ try {
471
+ if (bc) bc.postMessage({type: "search-index-installed", version});
472
+ } catch (_) {}
177
473
  } catch (_) {}
178
474
  if (DEBUG) {
179
- const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
475
+ const t1 =
476
+ typeof performance !== "undefined" && performance.now
477
+ ? performance.now()
478
+ : Date.now();
180
479
  // eslint-disable-next-line no-console
181
- console.info(`[Search] Index built in ${Math.round(t1 - t0)}ms (records=${data.length}) v=${String(version).slice(0,8)}`);
480
+ console.info(
481
+ `[Search] Index built in ${Math.round(t1 - t0)}ms (records=${
482
+ data.length
483
+ }) v=${String(version).slice(0, 8)}`
484
+ );
182
485
  }
183
486
  } else if (DEBUG) {
184
- const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
487
+ const t1 =
488
+ typeof performance !== "undefined" && performance.now
489
+ ? performance.now()
490
+ : Date.now();
185
491
  // eslint-disable-next-line no-console
186
- console.info(`[Search] Index imported from IndexedDB in ${Math.round(t1 - t0)}ms v=${String(version).slice(0,8)}`);
492
+ console.info(
493
+ `[Search] Index imported from IndexedDB in ${Math.round(
494
+ t1 - t0
495
+ )}ms v=${String(version).slice(0, 8)}`
496
+ );
187
497
  }
188
498
  // Optional: debug-listen for install events from other tabs
189
499
  try {
@@ -191,63 +501,171 @@ function createSearchStore() {
191
501
  bc.onmessage = (ev) => {
192
502
  try {
193
503
  const msg = ev && ev.data;
194
- if (msg && msg.type === 'search-index-installed' && msg.version && msg.version !== version) {
504
+ if (
505
+ msg &&
506
+ msg.type === "search-index-installed" &&
507
+ msg.version &&
508
+ msg.version !== version
509
+ ) {
195
510
  // eslint-disable-next-line no-console
196
- console.info('[Search] Another tab installed version', String(msg.version).slice(0,8));
511
+ console.info(
512
+ "[Search] Another tab installed version",
513
+ String(msg.version).slice(0, 8)
514
+ );
197
515
  }
198
516
  } catch (_) {}
199
517
  };
200
518
  }
201
519
  } catch (_) {}
202
520
 
203
- const ts = Array.from(new Set(data.map((r) => String((r && r.type) || 'page'))));
204
- const order = Array.isArray(tabsOrder) && tabsOrder.length ? tabsOrder : ['work', 'docs', 'page'];
205
- ts.sort((a, b) => { const ia = order.indexOf(a); const ib = order.indexOf(b); return (ia<0?99:ia)-(ib<0?99:ib) || a.localeCompare(b); });
521
+ const ts = Array.from(
522
+ new Set(data.map((r) => String((r && r.type) || "page")))
523
+ );
524
+ const order =
525
+ Array.isArray(tabsOrder) && tabsOrder.length
526
+ ? tabsOrder
527
+ : ["work", "docs", "page"];
528
+ ts.sort((a, b) => {
529
+ const ia = order.indexOf(a);
530
+ const ib = order.indexOf(b);
531
+ return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) || a.localeCompare(b);
532
+ });
206
533
  // Default to configured first tab if no type param present
207
534
  try {
208
535
  const p = new URLSearchParams(location.search);
209
- const hasType = p.has('type');
536
+ const hasType = p.has("type");
210
537
  if (!hasType) {
211
- let def = (order && order.length ? order[0] : 'all');
212
- if (!ts.includes(def)) def = ts[0] || 'all';
213
- if (def && def !== 'all') {
214
- p.set('type', def);
215
- history.replaceState(null, '', `${location.pathname}?${p.toString()}`);
538
+ let def = order && order.length ? order[0] : "all";
539
+ if (!ts.includes(def)) def = ts[0] || "all";
540
+ if (def && def !== "all") {
541
+ p.set("type", def);
542
+ history.replaceState(
543
+ null,
544
+ "",
545
+ `${location.pathname}?${p.toString()}`
546
+ );
216
547
  }
217
- set({ type: def });
548
+ set({type: def});
218
549
  }
219
550
  } catch (_) {}
220
- set({ index: idx, records: data, types: ts, loading: false });
221
- } catch (_) { set({ loading: false }); }
551
+ set({index: idx, records: data, types: ts, loading: false});
552
+ await hydrateFacets();
553
+ } catch (_) {
554
+ set({loading: false});
555
+ }
222
556
  })();
223
557
  // API
224
- function setQuery(q) { set({ query: q }); const u = new URL(location.href); u.searchParams.set('q', q); history.replaceState(null, '', u); }
225
- function setType(t) { set({ type: t }); const u = new URL(location.href); u.searchParams.set('type', t); history.replaceState(null, '', u); }
226
- return { subscribe, getSnapshot, setQuery, setType };
558
+ function setQuery(q) {
559
+ set({query: q});
560
+ const u = new URL(location.href);
561
+ if (q) u.searchParams.set("q", q);
562
+ else u.searchParams.delete("q");
563
+ history.replaceState(
564
+ null,
565
+ "",
566
+ u.searchParams.toString()
567
+ ? `${u.pathname}?${u.searchParams.toString()}`
568
+ : u.pathname
569
+ );
570
+ }
571
+ function setType(t) {
572
+ set({type: t});
573
+ const u = new URL(location.href);
574
+ if (t && t !== "all") u.searchParams.set("type", t);
575
+ else u.searchParams.delete("type");
576
+ history.replaceState(
577
+ null,
578
+ "",
579
+ u.searchParams.toString()
580
+ ? `${u.pathname}?${u.searchParams.toString()}`
581
+ : u.pathname
582
+ );
583
+ }
584
+ return {
585
+ subscribe,
586
+ getSnapshot,
587
+ setQuery,
588
+ setType,
589
+ toggleFilter,
590
+ clearFilters,
591
+ };
227
592
  }
228
593
 
229
- const store = typeof window !== 'undefined' ? createSearchStore() : null;
594
+ const store = typeof window !== "undefined" ? createSearchStore() : null;
230
595
 
231
596
  function useStore() {
232
- const snap = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
233
- return { ...snap, setQuery: store.setQuery, setType: store.setType };
597
+ const snap = useSyncExternalStore(
598
+ store.subscribe,
599
+ store.getSnapshot,
600
+ store.getSnapshot
601
+ );
602
+ return {
603
+ ...snap,
604
+ setQuery: store.setQuery,
605
+ setType: store.setType,
606
+ toggleFilter: store.toggleFilter,
607
+ clearFilters: store.clearFilters,
608
+ };
234
609
  }
235
610
 
236
611
  function ResultsMount(props = {}) {
237
- const { results, type, loading } = useStore();
612
+ const {results, type, loading} = useStore();
238
613
  if (loading) return <div className="text-slate-600">Loading…</div>;
239
- const layout = (props && props.layout) || 'grid';
614
+ const layout = (props && props.layout) || "grid";
240
615
  return <SearchResultsUI results={results} type={type} layout={layout} />;
241
616
  }
242
617
  function TabsMount() {
243
- const { type, setType, types, counts } = useStore();
244
- return <SearchTabsUI type={type} onTypeChange={setType} types={types} counts={counts} />;
618
+ const {
619
+ type,
620
+ setType,
621
+ types,
622
+ counts,
623
+ facets,
624
+ filters,
625
+ activeFilterCount,
626
+ toggleFilter,
627
+ clearFilters,
628
+ } = useStore();
629
+ const [open, setOpen] = useState(false);
630
+ const hasFilters = Array.isArray(facets) && facets.length > 0;
631
+ const allowFilters = hasFilters && String(type).toLowerCase() === "work";
632
+ useEffect(() => {
633
+ if (!allowFilters && open) setOpen(false);
634
+ }, [allowFilters, open]);
635
+ const handleToggle = (facetSlug, valueSlug, checked) => {
636
+ if (toggleFilter) toggleFilter(facetSlug, valueSlug, checked);
637
+ };
638
+ return (
639
+ <>
640
+ <SearchTabsUI
641
+ type={type}
642
+ onTypeChange={setType}
643
+ types={types}
644
+ counts={counts}
645
+ onOpenFilters={allowFilters ? () => setOpen(true) : undefined}
646
+ activeFilterCount={activeFilterCount || 0}
647
+ filtersOpen={allowFilters ? open : false}
648
+ />
649
+ {allowFilters ? (
650
+ <SearchFiltersDialog
651
+ open={open}
652
+ onOpenChange={setOpen}
653
+ facets={facets}
654
+ selected={filters}
655
+ onToggle={handleToggle}
656
+ onClear={() => clearFilters && clearFilters()}
657
+ />
658
+ ) : null}
659
+ </>
660
+ );
245
661
  }
246
662
  function SummaryMount() {
247
- const { query, type, shown, total } = useStore();
663
+ const {query, type, shown, total} = useStore();
248
664
  const text = useMemo(() => {
249
665
  if (!query) return `Showing ${shown} of ${total} items`;
250
- return `Found ${shown} of ${total} in ${type === 'all' ? 'all types' : type} for "${query}"`;
666
+ return `Found ${shown} of ${total} in ${
667
+ type === "all" ? "all types" : type
668
+ } for "${query}"`;
251
669
  }, [query, type, shown, total]);
252
670
  return <div className="text-sm text-slate-600">{text}</div>;
253
671
  }
@@ -255,35 +673,39 @@ function SummaryMount() {
255
673
  function parseProps(el) {
256
674
  try {
257
675
  const s = el.querySelector('script[type="application/json"]');
258
- if (s) return JSON.parse(s.textContent || '{}');
676
+ if (s) return JSON.parse(s.textContent || "{}");
259
677
  } catch (_) {}
260
678
  return {};
261
679
  }
262
680
 
263
681
  function bindSearchInputToStore() {
264
- if (!store || typeof document === 'undefined') return;
682
+ if (!store || typeof document === "undefined") return;
265
683
  try {
266
- const input = document.querySelector('[data-canopy-command-input]');
267
- if (!input || input.dataset.canopySearchSync === '1') return;
268
- input.dataset.canopySearchSync = '1';
684
+ const input = document.querySelector("[data-canopy-command-input]");
685
+ if (!input || input.dataset.canopySearchSync === "1") return;
686
+ input.dataset.canopySearchSync = "1";
269
687
 
270
688
  const syncFromStore = () => {
271
689
  try {
272
690
  const snap = store.getSnapshot();
273
- const nextVal = (snap && typeof snap.query === 'string') ? snap.query : '';
691
+ const nextVal =
692
+ snap && typeof snap.query === "string" ? snap.query : "";
274
693
  if (input.value !== nextVal) input.value = nextVal;
275
694
  } catch (_) {}
276
695
  };
277
696
 
278
697
  const onInput = (event) => {
279
698
  try {
280
- const val = event && event.target && typeof event.target.value === 'string' ? event.target.value : '';
699
+ const val =
700
+ event && event.target && typeof event.target.value === "string"
701
+ ? event.target.value
702
+ : "";
281
703
  const current = (() => {
282
704
  try {
283
705
  const snap = store.getSnapshot();
284
- return snap && typeof snap.query === 'string' ? snap.query : '';
706
+ return snap && typeof snap.query === "string" ? snap.query : "";
285
707
  } catch (_) {
286
- return '';
708
+ return "";
287
709
  }
288
710
  })();
289
711
  if (val === current) return;
@@ -291,15 +713,19 @@ function bindSearchInputToStore() {
291
713
  } catch (_) {}
292
714
  };
293
715
 
294
- input.addEventListener('input', onInput);
716
+ input.addEventListener("input", onInput);
295
717
  const unsubscribe = store.subscribe(syncFromStore);
296
718
  syncFromStore();
297
719
 
298
720
  const cleanup = () => {
299
- try { input.removeEventListener('input', onInput); } catch (_) {}
300
- try { if (typeof unsubscribe === 'function') unsubscribe(); } catch (_) {}
721
+ try {
722
+ input.removeEventListener("input", onInput);
723
+ } catch (_) {}
724
+ try {
725
+ if (typeof unsubscribe === "function") unsubscribe();
726
+ } catch (_) {}
301
727
  };
302
- window.addEventListener('beforeunload', cleanup, { once: true });
728
+ window.addEventListener("beforeunload", cleanup, {once: true});
303
729
  } catch (_) {}
304
730
  }
305
731
 
@@ -312,28 +738,39 @@ function mountAt(selector, Comp) {
312
738
  root.render(<Comp {...props} />);
313
739
  } catch (e) {
314
740
  // Surface helpful diagnostics in dev
315
- try { console.error('[Search] mount error at', selector, e && e.message ? e.message : e, e && e.stack ? e.stack : ''); } catch (_) {}
741
+ try {
742
+ console.error(
743
+ "[Search] mount error at",
744
+ selector,
745
+ e && e.message ? e.message : e,
746
+ e && e.stack ? e.stack : ""
747
+ );
748
+ } catch (_) {}
316
749
  }
317
750
  });
318
751
  }
319
752
 
320
- if (typeof document !== 'undefined') {
753
+ if (typeof document !== "undefined") {
321
754
  const run = () => {
322
755
  // Mount tabs and other search UI pieces
323
- mountAt('[data-canopy-search-tabs]', TabsMount);
324
- mountAt('[data-canopy-search-results]', ResultsMount);
325
- mountAt('[data-canopy-search-summary]', SummaryMount);
756
+ mountAt("[data-canopy-search-tabs]", TabsMount);
757
+ mountAt("[data-canopy-search-results]", ResultsMount);
758
+ mountAt("[data-canopy-search-summary]", SummaryMount);
326
759
  bindSearchInputToStore();
327
760
  // Total mount removed
328
761
  try {
329
- window.addEventListener('canopy:search:setQuery', (ev) => {
762
+ window.addEventListener("canopy:search:setQuery", (ev) => {
330
763
  try {
331
- const q = ev && ev.detail && typeof ev.detail.query === 'string' ? ev.detail.query : (document.querySelector('[data-canopy-command-input]')?.value || '');
332
- if (typeof q === 'string') store.setQuery(q);
764
+ const q =
765
+ ev && ev.detail && typeof ev.detail.query === "string"
766
+ ? ev.detail.query
767
+ : document.querySelector("[data-canopy-command-input]")?.value ||
768
+ "";
769
+ if (typeof q === "string") store.setQuery(q);
333
770
  } catch (_) {}
334
771
  });
335
772
  } catch (_) {}
336
773
  };
337
- if (document.readyState !== 'loading') run();
338
- else document.addEventListener('DOMContentLoaded', run, { once: true });
774
+ if (document.readyState !== "loading") run();
775
+ else document.addEventListener("DOMContentLoaded", run, {once: true});
339
776
  }