@canopy-iiif/app 0.6.28

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.
@@ -0,0 +1,44 @@
1
+ import React from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { CommandPaletteApp } from '@canopy-iiif/app/ui';
4
+
5
+ function ready(fn) {
6
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once: true });
7
+ else fn();
8
+ }
9
+ function parseProps(el) {
10
+ try { const s = el.querySelector('script[type="application/json"]'); if (s) return JSON.parse(s.textContent || '{}'); } catch {}
11
+ return {};
12
+ }
13
+ function withBase(href) {
14
+ try {
15
+ const bp = (window && window.CANOPY_BASE_PATH) ? String(window.CANOPY_BASE_PATH) : '';
16
+ if (!bp) return href;
17
+ if (/^https?:/i.test(href)) return href;
18
+ const clean = String(href || '').replace(/^\/+/, '');
19
+ return (bp.endsWith('/') ? bp.slice(0, -1) : bp) + '/' + clean;
20
+ } catch { return href; }
21
+ }
22
+ function rootBase() { try { const bp = (window && window.CANOPY_BASE_PATH) ? String(window.CANOPY_BASE_PATH) : ''; return bp && bp.endsWith('/') ? bp.slice(0, -1) : bp; } catch { return ''; } }
23
+
24
+ ready(async function () {
25
+ const host = document.querySelector('[data-canopy-command]');
26
+ if (!host) return;
27
+ const cfg = parseProps(host) || {};
28
+ let records = [];
29
+ let loading = true;
30
+ try {
31
+ let v = '';
32
+ try { const m = await fetch(rootBase() + '/api/index.json').then((r) => r && r.ok ? r.json() : null).catch(() => null); v = (m && m.version) || ''; } catch {}
33
+ const res = await fetch(rootBase() + '/api/search-index.json' + (v ? ('?v=' + encodeURIComponent(v)) : '')).catch(() => null);
34
+ const j = res && res.ok ? await res.json().catch(() => []) : [];
35
+ records = Array.isArray(j) ? j : ((j && j.records) || []);
36
+ loading = false;
37
+ } catch {}
38
+ const ReactObj = (window && window.React) || null;
39
+ const RDC = (window && window.ReactDOMClient) || null;
40
+ if (!ReactObj || !RDC || !RDC.createRoot) return;
41
+ const root = RDC.createRoot(host);
42
+ const onSelect = (href) => { try { window.location.href = withBase(String(href || '')); } catch {} };
43
+ root.render(React.createElement(CommandPaletteApp, { records, loading, config: cfg, onSelect }));
44
+ });
@@ -0,0 +1,273 @@
1
+ import React, { useEffect, useMemo, useSyncExternalStore, useState } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { SearchFormUI, SearchResultsUI } from '@canopy-iiif/app/ui';
4
+
5
+ // Lightweight IndexedDB utilities (no deps) with graceful fallback
6
+ function hasIDB() {
7
+ try { return typeof indexedDB !== 'undefined'; } catch (_) { return false; }
8
+ }
9
+ function idbOpen() {
10
+ return new Promise((resolve, reject) => {
11
+ if (!hasIDB()) return resolve(null);
12
+ try {
13
+ const req = indexedDB.open('canopy-search', 1);
14
+ req.onupgradeneeded = () => {
15
+ const db = req.result;
16
+ if (!db.objectStoreNames.contains('indexes')) db.createObjectStore('indexes', { keyPath: 'version' });
17
+ };
18
+ req.onsuccess = () => resolve(req.result);
19
+ req.onerror = () => resolve(null);
20
+ } catch (_) { resolve(null); }
21
+ });
22
+ }
23
+ async function idbGet(store, key) {
24
+ const db = await idbOpen();
25
+ if (!db) return null;
26
+ return new Promise((resolve) => {
27
+ try {
28
+ const tx = db.transaction(store, 'readonly');
29
+ const st = tx.objectStore(store);
30
+ const req = st.get(key);
31
+ req.onsuccess = () => resolve(req.result || null);
32
+ req.onerror = () => resolve(null);
33
+ } catch (_) { resolve(null); }
34
+ });
35
+ }
36
+ async function idbPut(store, value) {
37
+ const db = await idbOpen();
38
+ if (!db) return false;
39
+ return new Promise((resolve) => {
40
+ try {
41
+ const tx = db.transaction(store, 'readwrite');
42
+ const st = tx.objectStore(store);
43
+ st.put(value);
44
+ tx.oncomplete = () => resolve(true);
45
+ tx.onerror = () => resolve(false);
46
+ } catch (_) { resolve(false); }
47
+ });
48
+ }
49
+ async function idbPruneOld(store, keepKey) {
50
+ const db = await idbOpen();
51
+ if (!db) return false;
52
+ return new Promise((resolve) => {
53
+ try {
54
+ const tx = db.transaction(store, 'readwrite');
55
+ const st = tx.objectStore(store);
56
+ const req = st.getAllKeys();
57
+ req.onsuccess = () => {
58
+ try { (req.result || []).forEach((k) => { if (k !== keepKey) st.delete(k); }); } catch (_) {}
59
+ resolve(true);
60
+ };
61
+ req.onerror = () => resolve(false);
62
+ } catch (_) { resolve(false); }
63
+ });
64
+ }
65
+ async function sha256Hex(str) {
66
+ try {
67
+ if (typeof crypto !== 'undefined' && crypto.subtle) {
68
+ 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('');
71
+ }
72
+ } catch (_) {}
73
+ // Fallback: simple non-crypto hash
74
+ try {
75
+ let h = 5381; for (let i = 0; i < str.length; i++) h = ((h << 5) + h) ^ str.charCodeAt(i);
76
+ return (h >>> 0).toString(16);
77
+ } catch (_) { return String(str && str.length ? str.length : 0); }
78
+ }
79
+
80
+ function createSearchStore() {
81
+ let state = {
82
+ query: new URLSearchParams(location.search).get('q') || '',
83
+ type: new URLSearchParams(location.search).get('type') || 'all',
84
+ loading: true,
85
+ records: [],
86
+ types: [],
87
+ index: null,
88
+ counts: {},
89
+ };
90
+ const listeners = new Set();
91
+ function notify() { listeners.forEach((fn) => { try { fn(); } catch (_) {} }); }
92
+ // Keep a memoized snapshot so getSnapshot returns stable references
93
+ let snapshot = null;
94
+ function recomputeSnapshot() {
95
+ const { index, records, query, type } = state;
96
+ let base = [];
97
+ let results = [];
98
+ let totalForType = Array.isArray(records) ? records.length : 0;
99
+ let counts = {};
100
+ if (records && records.length) {
101
+ if (!query) {
102
+ base = records;
103
+ } else {
104
+ try { const ids = index && index.search(query, { limit: 200 }) || []; base = ids.map((i) => records[i]).filter(Boolean); } catch (_) { base = []; }
105
+ }
106
+ // Build per-type counts from base (query-filtered or all)
107
+ try {
108
+ counts = base.reduce((acc, r) => {
109
+ const t = String((r && r.type) || 'page').toLowerCase();
110
+ acc[t] = (acc[t] || 0) + 1;
111
+ return acc;
112
+ }, {});
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 (_) {}
119
+ }
120
+ }
121
+ snapshot = { ...state, results, total: totalForType, shown: results.length, counts };
122
+ }
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
+ // Initialize snapshot
127
+ recomputeSnapshot();
128
+ // init
129
+ (async () => {
130
+ 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;
133
+ // Broadcast new index installs to other tabs
134
+ let bc = null;
135
+ try { if (typeof BroadcastChannel !== 'undefined') bc = new BroadcastChannel('canopy-search'); } catch (_) {}
136
+ // Try to load meta version for cache-busting; fall back to hash of JSON
137
+ let version = '';
138
+ try {
139
+ const meta = await fetch('./api/index.json').then((r) => (r && r.ok ? r.json() : null)).catch(() => null);
140
+ if (meta && typeof meta.version === 'string') version = meta.version;
141
+ } catch (_) {}
142
+ const res = await fetch('./api/search-index.json' + (version ? `?v=${encodeURIComponent(version)}` : ''));
143
+ const text = await res.text();
144
+ const parsed = (() => { try { return JSON.parse(text); } catch { return []; } })();
145
+ const data = Array.isArray(parsed) ? parsed : (parsed && parsed.records ? parsed.records : []);
146
+ if (!version) version = (parsed && parsed.version) || (await sha256Hex(text));
147
+
148
+ const idx = new Flex.Index({ tokenize: 'forward' });
149
+ let hydrated = false;
150
+ const t0 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
151
+ try {
152
+ const cached = await idbGet('indexes', version);
153
+ if (cached && cached.exportData) {
154
+ try {
155
+ const dataObj = cached.exportData || {};
156
+ for (const k in dataObj) {
157
+ if (Object.prototype.hasOwnProperty.call(dataObj, k)) {
158
+ try { idx.import(k, dataObj[k]); } catch (_) {}
159
+ }
160
+ }
161
+ hydrated = true;
162
+ } catch (_) { hydrated = false; }
163
+ }
164
+ } catch (_) { /* no-op */ }
165
+
166
+ if (!hydrated) {
167
+ data.forEach((rec, i) => { try { idx.add(i, rec && rec.title ? String(rec.title) : ''); } catch (_) {} });
168
+ try {
169
+ const dump = {};
170
+ try { await idx.export((key, val) => { dump[key] = val; }); } catch (_) {}
171
+ await idbPut('indexes', { version, exportData: dump, ts: Date.now() });
172
+ await idbPruneOld('indexes', version);
173
+ try { if (bc) bc.postMessage({ type: 'search-index-installed', version }); } catch (_) {}
174
+ } catch (_) {}
175
+ if (DEBUG) {
176
+ const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
177
+ // eslint-disable-next-line no-console
178
+ console.info(`[Search] Index built in ${Math.round(t1 - t0)}ms (records=${data.length}) v=${String(version).slice(0,8)}`);
179
+ }
180
+ } else if (DEBUG) {
181
+ const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
182
+ // eslint-disable-next-line no-console
183
+ console.info(`[Search] Index imported from IndexedDB in ${Math.round(t1 - t0)}ms v=${String(version).slice(0,8)}`);
184
+ }
185
+ // Optional: debug-listen for install events from other tabs
186
+ try {
187
+ if (bc && DEBUG) {
188
+ bc.onmessage = (ev) => {
189
+ try {
190
+ const msg = ev && ev.data;
191
+ if (msg && msg.type === 'search-index-installed' && msg.version && msg.version !== version) {
192
+ // eslint-disable-next-line no-console
193
+ console.info('[Search] Another tab installed version', String(msg.version).slice(0,8));
194
+ }
195
+ } catch (_) {}
196
+ };
197
+ }
198
+ } catch (_) {}
199
+
200
+ const ts = Array.from(new Set(data.map((r) => String((r && r.type) || 'page'))));
201
+ const order = ['work', 'docs', 'page'];
202
+ 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); });
203
+ set({ index: idx, records: data, types: ts, loading: false });
204
+ } catch (_) { set({ loading: false }); }
205
+ })();
206
+ // API
207
+ function setQuery(q) { set({ query: q }); const u = new URL(location.href); u.searchParams.set('q', q); history.replaceState(null, '', u); }
208
+ function setType(t) { set({ type: t }); const u = new URL(location.href); u.searchParams.set('type', t); history.replaceState(null, '', u); }
209
+ return { subscribe, getSnapshot, setQuery, setType };
210
+ }
211
+
212
+ const store = typeof window !== 'undefined' ? createSearchStore() : null;
213
+
214
+ function useStore() {
215
+ const snap = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
216
+ return { ...snap, setQuery: store.setQuery, setType: store.setType };
217
+ }
218
+
219
+ function FormMount() {
220
+ const { query, setQuery, type, setType, types, counts } = useStore();
221
+ return <SearchFormUI query={query} onQueryChange={setQuery} type={type} onTypeChange={setType} types={types} counts={counts} />;
222
+ }
223
+ function ResultsMount(props = {}) {
224
+ const { results, type, loading } = useStore();
225
+ if (loading) return <div className="text-slate-600">Loading…</div>;
226
+ const layout = (props && props.layout) || 'grid';
227
+ return <SearchResultsUI results={results} type={type} layout={layout} />;
228
+ }
229
+ function SummaryMount() {
230
+ const { query, type, shown, total } = useStore();
231
+ const text = useMemo(() => {
232
+ if (!query) return `Showing ${shown} of ${total} items`;
233
+ return `Found ${shown} of ${total} in ${type === 'all' ? 'all types' : type} for "${query}"`;
234
+ }, [query, type, shown, total]);
235
+ return <div className="text-sm text-slate-600">{text}</div>;
236
+ }
237
+ function TotalMount() {
238
+ const { shown } = useStore();
239
+ return <span>{shown}</span>;
240
+ }
241
+
242
+ function parseProps(el) {
243
+ try {
244
+ const s = el.querySelector('script[type="application/json"]');
245
+ if (s) return JSON.parse(s.textContent || '{}');
246
+ } catch (_) {}
247
+ return {};
248
+ }
249
+
250
+ function mountAt(selector, Comp) {
251
+ const nodes = document.querySelectorAll(selector);
252
+ nodes.forEach((n) => {
253
+ try {
254
+ const root = createRoot(n);
255
+ const props = parseProps(n);
256
+ root.render(<Comp {...props} />);
257
+ } catch (e) {
258
+ // Surface helpful diagnostics in dev
259
+ try { console.error('[Search] mount error at', selector, e && e.message ? e.message : e, e && e.stack ? e.stack : ''); } catch (_) {}
260
+ }
261
+ });
262
+ }
263
+
264
+ if (typeof document !== 'undefined') {
265
+ const run = () => {
266
+ mountAt('[data-canopy-search-form]', FormMount);
267
+ mountAt('[data-canopy-search-results]', ResultsMount);
268
+ mountAt('[data-canopy-search-summary]', SummaryMount);
269
+ mountAt('[data-canopy-search-total]', TotalMount);
270
+ };
271
+ if (document.readyState !== 'loading') run();
272
+ else document.addEventListener('DOMContentLoaded', run, { once: true });
273
+ }