@eightyfourthousand/lib-search 2026.3.1 → 2026.4.0
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.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eightyfourthousand/lib-search",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.4.0",
|
|
4
4
|
"description": "Shared translation search UI and server helpers for 84000 applications.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"repository": {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"access": "public"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
+
"@eightyfourthousand/client-graphql": "^2026.3.1",
|
|
20
21
|
"@eightyfourthousand/data-access": "^2026.3.1",
|
|
21
22
|
"@eightyfourthousand/design-system": "^2026.3.1",
|
|
22
23
|
"@eightyfourthousand/lib-utils": "^2026.3.1"
|
package/src/lib/types/search.ts
CHANGED
|
@@ -1,20 +1,31 @@
|
|
|
1
|
+
import { GlossaryTermInstance, GlossaryTermInstanceDTO, glossaryTermInstanceFromDTO } from "@eightyfourthousand/data-access";
|
|
2
|
+
|
|
1
3
|
export const RESULTS_ENTITIES = [
|
|
2
|
-
'passages',
|
|
3
4
|
'alignments',
|
|
5
|
+
'passages',
|
|
4
6
|
'bibliographies',
|
|
5
7
|
'glossaries',
|
|
6
8
|
] as const;
|
|
7
9
|
|
|
8
10
|
export type ResultsEntity = (typeof RESULTS_ENTITIES)[number];
|
|
9
11
|
|
|
12
|
+
export const RESULTS_ENTITY_LABELS: Record<ResultsEntity, string> = {
|
|
13
|
+
alignments: 'Translation Passages',
|
|
14
|
+
passages: 'Other Passages',
|
|
15
|
+
bibliographies: 'Bibliographies',
|
|
16
|
+
glossaries: 'Glossaries',
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
10
19
|
export type BibliographySearchMatchDTO = {
|
|
11
20
|
bibliography_uuid: string;
|
|
12
21
|
content: string;
|
|
13
22
|
};
|
|
14
23
|
|
|
15
24
|
export type GlossarySearchMatchDTO = {
|
|
25
|
+
authority_uuid: string;
|
|
16
26
|
glossary_uuid: string;
|
|
17
27
|
content: string;
|
|
28
|
+
entry: GlossaryTermInstanceDTO;
|
|
18
29
|
};
|
|
19
30
|
|
|
20
31
|
export type PassageSearchMatchDTO = {
|
|
@@ -60,6 +71,7 @@ export type BibliographyMatch = SearchMatch & {
|
|
|
60
71
|
|
|
61
72
|
export type GlossaryMatch = SearchMatch & {
|
|
62
73
|
type: 'glossary';
|
|
74
|
+
entry: GlossaryTermInstance;
|
|
63
75
|
};
|
|
64
76
|
|
|
65
77
|
export type SearchResultsDTO = {
|
|
@@ -98,8 +110,9 @@ export const glossaryMatchFromDTO = (
|
|
|
98
110
|
): GlossaryMatch => {
|
|
99
111
|
return {
|
|
100
112
|
type: 'glossary',
|
|
101
|
-
uuid: dto.
|
|
113
|
+
uuid: dto.authority_uuid,
|
|
102
114
|
content: dto.content,
|
|
115
|
+
entry: glossaryTermInstanceFromDTO(dto.entry),
|
|
103
116
|
};
|
|
104
117
|
};
|
|
105
118
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import type { PassageOccurrence } from '@eightyfourthousand/data-access';
|
|
4
|
+
import { getPassageOccurrences } from '@eightyfourthousand/data-access';
|
|
3
5
|
import {
|
|
4
6
|
Button,
|
|
5
7
|
Dialog,
|
|
@@ -12,71 +14,349 @@ import {
|
|
|
12
14
|
TabsContent,
|
|
13
15
|
} from '@eightyfourthousand/design-system';
|
|
14
16
|
import { Loader2Icon, SearchIcon, XIcon } from 'lucide-react';
|
|
15
|
-
import {
|
|
17
|
+
import type { ReactNode } from 'react';
|
|
18
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
16
19
|
import { search } from '../data';
|
|
17
|
-
import {
|
|
20
|
+
import type {
|
|
21
|
+
PassageMatch,
|
|
22
|
+
ResultsEntity,
|
|
23
|
+
SearchResult,
|
|
24
|
+
SearchResults,
|
|
25
|
+
} from '../types';
|
|
26
|
+
import { RESULTS_ENTITIES } from '../types';
|
|
18
27
|
import { SearchResultsList } from './SearchResultsList';
|
|
19
28
|
import { SearchResultTabs } from './SearchResultTab';
|
|
20
29
|
|
|
30
|
+
export type SearchPendingSelection =
|
|
31
|
+
| { kind: 'index'; index: number }
|
|
32
|
+
| { kind: 'cursor'; start: number; passageUuid: string };
|
|
33
|
+
|
|
34
|
+
export interface SearchActionContext {
|
|
35
|
+
activeOccurrence?: PassageOccurrence;
|
|
36
|
+
activeOccurrenceIndex: number;
|
|
37
|
+
activePassageLabel?: string;
|
|
38
|
+
activePassageUuid?: string;
|
|
39
|
+
moveActiveOccurrence: (direction: 'next' | 'previous') => void;
|
|
40
|
+
passageOccurrences: PassageOccurrence[];
|
|
41
|
+
passages: PassageMatch[];
|
|
42
|
+
refreshSearch: (options?: {
|
|
43
|
+
nextSelection?: SearchPendingSelection | null;
|
|
44
|
+
}) => Promise<void>;
|
|
45
|
+
searchQuery: string;
|
|
46
|
+
searching: boolean;
|
|
47
|
+
scrollActiveOccurrenceIntoView: () => void;
|
|
48
|
+
setActiveOccurrenceIndex: (index: number) => void;
|
|
49
|
+
setShouldScrollActiveOccurrence: (shouldScroll: boolean) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SearchButtonProps {
|
|
53
|
+
onResultSelected: (result: SearchResult) => void;
|
|
54
|
+
renderActions?: (context: SearchActionContext) => ReactNode;
|
|
55
|
+
toh?: string;
|
|
56
|
+
workUuid?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const DEFAULT_RESULTS_TAB: ResultsEntity = 'alignments';
|
|
60
|
+
|
|
61
|
+
const getFirstResultsTab = (results?: SearchResults): ResultsEntity => {
|
|
62
|
+
return (
|
|
63
|
+
RESULTS_ENTITIES.find((tab) => (results?.[tab].length ?? 0) > 0) ??
|
|
64
|
+
DEFAULT_RESULTS_TAB
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const getOccurrenceResultsTab = (
|
|
69
|
+
occurrence?: PassageOccurrence,
|
|
70
|
+
): ResultsEntity | undefined => {
|
|
71
|
+
if (!occurrence) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return occurrence.type === 'alignment' ? 'alignments' : 'passages';
|
|
76
|
+
};
|
|
77
|
+
|
|
21
78
|
export const SearchButton = ({
|
|
79
|
+
renderActions,
|
|
22
80
|
workUuid,
|
|
23
81
|
toh,
|
|
24
82
|
onResultSelected,
|
|
25
|
-
}: {
|
|
26
|
-
workUuid?: string;
|
|
27
|
-
toh?: string;
|
|
28
|
-
onResultSelected: (result: SearchResult) => void;
|
|
29
|
-
}) => {
|
|
83
|
+
}: SearchButtonProps) => {
|
|
30
84
|
const [open, setOpen] = useState(false);
|
|
31
85
|
const [searching, setSearching] = useState(false);
|
|
32
86
|
const [hasResults, setHasResults] = useState(false);
|
|
33
87
|
const [searchQuery, setSearchQuery] = useState('');
|
|
34
88
|
const [results, setResults] = useState<SearchResults>();
|
|
89
|
+
const [activeOccurrenceIndex, setActiveOccurrenceIndexState] = useState(0);
|
|
90
|
+
const [activeResultsTab, setActiveResultsTab] =
|
|
91
|
+
useState<ResultsEntity>(DEFAULT_RESULTS_TAB);
|
|
92
|
+
const [shouldScrollActiveOccurrence, setShouldScrollActiveOccurrence] =
|
|
93
|
+
useState(false);
|
|
94
|
+
const pendingOccurrenceSelectionRef = useRef<SearchPendingSelection | null>(
|
|
95
|
+
null,
|
|
96
|
+
);
|
|
35
97
|
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
98
|
+
const passageOccurrences = useMemo(
|
|
99
|
+
() =>
|
|
100
|
+
getPassageOccurrences(
|
|
101
|
+
[...(results?.alignments || []), ...(results?.passages || [])],
|
|
102
|
+
searchQuery,
|
|
103
|
+
),
|
|
104
|
+
[results?.alignments, results?.passages, searchQuery],
|
|
105
|
+
);
|
|
106
|
+
const activeOccurrence = passageOccurrences[activeOccurrenceIndex];
|
|
107
|
+
const activeOccurrenceStart = activeOccurrence?.start ?? null;
|
|
108
|
+
const activePassageUuid = activeOccurrence?.passageUuid;
|
|
109
|
+
const activePassageLabel = results?.passages.find(
|
|
110
|
+
(passage) => passage.uuid === activePassageUuid,
|
|
111
|
+
)?.label;
|
|
112
|
+
const passageOrder = useMemo(
|
|
113
|
+
() =>
|
|
114
|
+
new Map(
|
|
115
|
+
(results?.passages || []).map((passage, index) => [
|
|
116
|
+
passage.uuid,
|
|
117
|
+
index,
|
|
118
|
+
]),
|
|
119
|
+
),
|
|
120
|
+
[results?.passages],
|
|
121
|
+
);
|
|
40
122
|
|
|
41
|
-
|
|
42
|
-
|
|
123
|
+
const scrollActiveOccurrenceIntoView = useCallback(() => {
|
|
124
|
+
requestAnimationFrame(() => {
|
|
125
|
+
document
|
|
126
|
+
.querySelector<HTMLElement>('[data-search-result-active="true"]')
|
|
127
|
+
?.scrollIntoView({
|
|
128
|
+
behavior: 'smooth',
|
|
129
|
+
block: 'start',
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}, []);
|
|
133
|
+
|
|
134
|
+
const refreshSearch = useCallback(
|
|
135
|
+
async (options: { nextSelection?: SearchPendingSelection | null } = {}) => {
|
|
43
136
|
if (!searchQuery || !workUuid || !toh) {
|
|
44
137
|
setResults(undefined);
|
|
138
|
+
setHasResults(false);
|
|
45
139
|
return;
|
|
46
140
|
}
|
|
47
141
|
|
|
48
142
|
setSearching(true);
|
|
49
|
-
|
|
50
|
-
|
|
143
|
+
const nextResults = await search({
|
|
144
|
+
text: searchQuery,
|
|
145
|
+
uuid: workUuid,
|
|
146
|
+
toh,
|
|
147
|
+
});
|
|
51
148
|
setHasResults(
|
|
52
|
-
!!
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
149
|
+
!!nextResults &&
|
|
150
|
+
(nextResults.passages.length > 0 ||
|
|
151
|
+
nextResults.alignments.length > 0 ||
|
|
152
|
+
nextResults.bibliographies.length > 0 ||
|
|
153
|
+
nextResults.glossaries.length > 0),
|
|
57
154
|
);
|
|
58
|
-
setResults(
|
|
155
|
+
setResults(nextResults);
|
|
156
|
+
|
|
157
|
+
if ('nextSelection' in options) {
|
|
158
|
+
pendingOccurrenceSelectionRef.current = options.nextSelection ?? null;
|
|
159
|
+
}
|
|
160
|
+
|
|
59
161
|
setSearching(false);
|
|
60
|
-
}
|
|
162
|
+
},
|
|
163
|
+
[searchQuery, toh, workUuid],
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const setActiveOccurrenceIndex = useCallback(
|
|
167
|
+
(nextIndex: number) => {
|
|
168
|
+
pendingOccurrenceSelectionRef.current = null;
|
|
169
|
+
setActiveOccurrenceIndexState(
|
|
170
|
+
Math.max(
|
|
171
|
+
0,
|
|
172
|
+
Math.min(nextIndex, Math.max(passageOccurrences.length - 1, 0)),
|
|
173
|
+
),
|
|
174
|
+
);
|
|
175
|
+
},
|
|
176
|
+
[passageOccurrences.length],
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const moveActiveOccurrence = useCallback(
|
|
180
|
+
(direction: 'next' | 'previous') => {
|
|
181
|
+
if (passageOccurrences.length === 0) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
pendingOccurrenceSelectionRef.current = null;
|
|
186
|
+
setActiveOccurrenceIndexState((current) => {
|
|
187
|
+
if (direction === 'previous') {
|
|
188
|
+
return Math.max(current - 1, 0);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return Math.min(current + 1, passageOccurrences.length - 1);
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
[passageOccurrences.length],
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
const debounce = setTimeout(() => {
|
|
199
|
+
void refreshSearch();
|
|
200
|
+
}, 300);
|
|
61
201
|
|
|
62
|
-
const debounce = setTimeout(performSearch, 300);
|
|
63
202
|
return () => clearTimeout(debounce);
|
|
64
|
-
}, [
|
|
203
|
+
}, [refreshSearch]);
|
|
65
204
|
|
|
66
205
|
useEffect(() => {
|
|
67
206
|
setResults(undefined);
|
|
68
207
|
setSearchQuery('');
|
|
208
|
+
setActiveOccurrenceIndexState(0);
|
|
209
|
+
setShouldScrollActiveOccurrence(false);
|
|
210
|
+
pendingOccurrenceSelectionRef.current = null;
|
|
69
211
|
}, [open]);
|
|
70
212
|
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
if (!results) {
|
|
215
|
+
setActiveResultsTab(DEFAULT_RESULTS_TAB);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
setActiveResultsTab((currentTab) =>
|
|
220
|
+
results[currentTab].length > 0 ? currentTab : getFirstResultsTab(results),
|
|
221
|
+
);
|
|
222
|
+
}, [results]);
|
|
223
|
+
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
if (passageOccurrences.length === 0) {
|
|
226
|
+
setActiveOccurrenceIndexState(0);
|
|
227
|
+
pendingOccurrenceSelectionRef.current = null;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const pendingSelection = pendingOccurrenceSelectionRef.current;
|
|
232
|
+
if (pendingSelection) {
|
|
233
|
+
if (pendingSelection.kind === 'index') {
|
|
234
|
+
setActiveOccurrenceIndexState(
|
|
235
|
+
Math.min(pendingSelection.index, passageOccurrences.length - 1),
|
|
236
|
+
);
|
|
237
|
+
pendingOccurrenceSelectionRef.current = null;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const nextIndex = passageOccurrences.findIndex((occurrence) => {
|
|
242
|
+
if (
|
|
243
|
+
pendingSelection.kind === 'cursor' &&
|
|
244
|
+
occurrence.passageUuid === pendingSelection.passageUuid &&
|
|
245
|
+
occurrence.start >= pendingSelection.start
|
|
246
|
+
) {
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return false;
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
if (nextIndex >= 0) {
|
|
254
|
+
setActiveOccurrenceIndexState(nextIndex);
|
|
255
|
+
pendingOccurrenceSelectionRef.current = null;
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const currentPassageOrder =
|
|
260
|
+
pendingSelection.kind === 'cursor'
|
|
261
|
+
? passageOrder.get(pendingSelection.passageUuid)
|
|
262
|
+
: undefined;
|
|
263
|
+
const nextPassageIndex =
|
|
264
|
+
pendingSelection.kind === 'cursor' && currentPassageOrder != null
|
|
265
|
+
? passageOccurrences.findIndex((occurrence) => {
|
|
266
|
+
const occurrenceOrder = passageOrder.get(occurrence.passageUuid);
|
|
267
|
+
return (
|
|
268
|
+
occurrenceOrder != null && occurrenceOrder > currentPassageOrder
|
|
269
|
+
);
|
|
270
|
+
})
|
|
271
|
+
: -1;
|
|
272
|
+
|
|
273
|
+
setActiveOccurrenceIndexState(
|
|
274
|
+
nextPassageIndex >= 0 ? nextPassageIndex : 0,
|
|
275
|
+
);
|
|
276
|
+
pendingOccurrenceSelectionRef.current = null;
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
setActiveOccurrenceIndexState((current) =>
|
|
281
|
+
Math.min(current, passageOccurrences.length - 1),
|
|
282
|
+
);
|
|
283
|
+
}, [passageOccurrences, passageOrder]);
|
|
284
|
+
|
|
285
|
+
useEffect(() => {
|
|
286
|
+
if (!shouldScrollActiveOccurrence || !activeOccurrence || !results) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const occurrenceTab = getOccurrenceResultsTab(activeOccurrence);
|
|
291
|
+
if (occurrenceTab && results[occurrenceTab].length > 0) {
|
|
292
|
+
setActiveResultsTab(occurrenceTab);
|
|
293
|
+
}
|
|
294
|
+
}, [activeOccurrence, results, shouldScrollActiveOccurrence]);
|
|
295
|
+
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
if (
|
|
298
|
+
!shouldScrollActiveOccurrence ||
|
|
299
|
+
!activePassageUuid ||
|
|
300
|
+
activeOccurrenceStart == null
|
|
301
|
+
) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
scrollActiveOccurrenceIntoView();
|
|
306
|
+
}, [
|
|
307
|
+
activeOccurrenceStart,
|
|
308
|
+
activeResultsTab,
|
|
309
|
+
activePassageUuid,
|
|
310
|
+
scrollActiveOccurrenceIntoView,
|
|
311
|
+
shouldScrollActiveOccurrence,
|
|
312
|
+
]);
|
|
313
|
+
|
|
314
|
+
const onCardClick = (result: SearchResult) => {
|
|
315
|
+
setOpen(false);
|
|
316
|
+
onResultSelected(result);
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const actionContext = useMemo<SearchActionContext>(
|
|
320
|
+
() => ({
|
|
321
|
+
activeOccurrence,
|
|
322
|
+
activeOccurrenceIndex,
|
|
323
|
+
activePassageLabel,
|
|
324
|
+
activePassageUuid,
|
|
325
|
+
moveActiveOccurrence,
|
|
326
|
+
passageOccurrences,
|
|
327
|
+
passages: results?.passages || [],
|
|
328
|
+
refreshSearch,
|
|
329
|
+
searchQuery,
|
|
330
|
+
searching,
|
|
331
|
+
scrollActiveOccurrenceIntoView,
|
|
332
|
+
setActiveOccurrenceIndex,
|
|
333
|
+
setShouldScrollActiveOccurrence,
|
|
334
|
+
}),
|
|
335
|
+
[
|
|
336
|
+
activeOccurrence,
|
|
337
|
+
activeOccurrenceIndex,
|
|
338
|
+
activePassageLabel,
|
|
339
|
+
activePassageUuid,
|
|
340
|
+
moveActiveOccurrence,
|
|
341
|
+
passageOccurrences,
|
|
342
|
+
refreshSearch,
|
|
343
|
+
results?.passages,
|
|
344
|
+
scrollActiveOccurrenceIntoView,
|
|
345
|
+
searchQuery,
|
|
346
|
+
searching,
|
|
347
|
+
setActiveOccurrenceIndex,
|
|
348
|
+
],
|
|
349
|
+
);
|
|
350
|
+
|
|
71
351
|
return (
|
|
72
352
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
73
353
|
<DialogTrigger asChild>
|
|
74
354
|
<Button
|
|
75
355
|
variant="ghost"
|
|
356
|
+
size="icon"
|
|
76
357
|
className="bg-background my-auto [&_svg]:size-6 [&_svg]:stroke-1 hover:bg-background cursor-pointer text-base text-accent hover:text-accent/80"
|
|
77
358
|
>
|
|
78
359
|
<SearchIcon />
|
|
79
|
-
<span className="hidden md:flex">Search</span>
|
|
80
360
|
</Button>
|
|
81
361
|
</DialogTrigger>
|
|
82
362
|
<DialogContent
|
|
@@ -87,8 +367,8 @@ export const SearchButton = ({
|
|
|
87
367
|
<DialogDescription className="hidden">
|
|
88
368
|
Search this translation
|
|
89
369
|
</DialogDescription>
|
|
90
|
-
<div className="flex flex-col justify-start gap-2 h-[calc(
|
|
91
|
-
<div className="w-full flex flex-col gap-2 text-foreground
|
|
370
|
+
<div className="flex flex-col justify-start gap-2 h-[calc(100vh-2.5rem)]">
|
|
371
|
+
<div className="w-full flex flex-col gap-2 text-foreground shrink-0">
|
|
92
372
|
<div className="flex justify-end">
|
|
93
373
|
<Button
|
|
94
374
|
className="text-secondary-foreground -me-3"
|
|
@@ -102,21 +382,36 @@ export const SearchButton = ({
|
|
|
102
382
|
<Input
|
|
103
383
|
autoFocus
|
|
104
384
|
placeholder="Type to search..."
|
|
385
|
+
value={searchQuery}
|
|
105
386
|
className="w-full text-foreground px-4 py-6"
|
|
106
|
-
onChange={(e) =>
|
|
387
|
+
onChange={(e) => {
|
|
388
|
+
const nextValue = e.target.value;
|
|
389
|
+
if (nextValue === searchQuery) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
setActiveOccurrenceIndexState(0);
|
|
393
|
+
pendingOccurrenceSelectionRef.current = null;
|
|
394
|
+
setSearchQuery(nextValue);
|
|
395
|
+
}}
|
|
107
396
|
/>
|
|
397
|
+
{searchQuery && renderActions?.(actionContext)}
|
|
108
398
|
</div>
|
|
109
399
|
{searchQuery && (
|
|
110
400
|
<>
|
|
111
|
-
<div className="text-sm text-secondary-foreground">
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
401
|
+
<div className="flex flex-col gap-3 text-sm text-secondary-foreground">
|
|
402
|
+
<div>
|
|
403
|
+
Showing results for "<strong>{searchQuery}</strong>"
|
|
404
|
+
{searching && (
|
|
405
|
+
<Loader2Icon className="size-4 ml-2 animate-spin inline-block" />
|
|
406
|
+
)}
|
|
407
|
+
</div>
|
|
116
408
|
</div>
|
|
117
409
|
{results && hasResults ? (
|
|
118
410
|
<Tabs
|
|
119
|
-
|
|
411
|
+
value={activeResultsTab}
|
|
412
|
+
onValueChange={(tab) => {
|
|
413
|
+
setActiveResultsTab(tab as ResultsEntity);
|
|
414
|
+
}}
|
|
120
415
|
className="flex flex-col grow min-h-0 pt-2"
|
|
121
416
|
>
|
|
122
417
|
<SearchResultTabs results={results} />
|
|
@@ -129,6 +424,13 @@ export const SearchButton = ({
|
|
|
129
424
|
value={tab}
|
|
130
425
|
>
|
|
131
426
|
<SearchResultsList
|
|
427
|
+
activeOccurrenceStart={
|
|
428
|
+
activeOccurrenceStart != null &&
|
|
429
|
+
shouldScrollActiveOccurrence
|
|
430
|
+
? activeOccurrenceStart
|
|
431
|
+
: undefined
|
|
432
|
+
}
|
|
433
|
+
activePassageUuid={activePassageUuid}
|
|
132
434
|
query={searchQuery}
|
|
133
435
|
results={results[tab]}
|
|
134
436
|
onCardClick={onCardClick}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { highlightText, removeHtmlTags, useIsMobile } from '@eightyfourthousand/lib-utils';
|
|
3
|
+
import { highlightText, removeDiacritics, removeHtmlTags, useIsMobile } from '@eightyfourthousand/lib-utils';
|
|
4
4
|
import {
|
|
5
5
|
AlignmentMatch,
|
|
6
6
|
BibliographyMatch,
|
|
@@ -10,26 +10,87 @@ import {
|
|
|
10
10
|
} from '../types';
|
|
11
11
|
import { Separator } from '@eightyfourthousand/design-system';
|
|
12
12
|
|
|
13
|
+
const renderPassageHighlight = ({
|
|
14
|
+
activeOccurrenceStart,
|
|
15
|
+
query,
|
|
16
|
+
text,
|
|
17
|
+
}: {
|
|
18
|
+
activeOccurrenceStart?: number;
|
|
19
|
+
query: string;
|
|
20
|
+
text: string;
|
|
21
|
+
}) => {
|
|
22
|
+
if (!query.trim()) {
|
|
23
|
+
return text;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
27
|
+
const parts = text.split(new RegExp(`(${escapedQuery})`, 'gi'));
|
|
28
|
+
let offset = 0;
|
|
29
|
+
|
|
30
|
+
return parts.map((part, index) => {
|
|
31
|
+
const isMatch = part.toLowerCase() === query.toLowerCase();
|
|
32
|
+
|
|
33
|
+
if (!isMatch) {
|
|
34
|
+
offset += part.length;
|
|
35
|
+
return part;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const start = offset;
|
|
39
|
+
offset += part.length;
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<mark
|
|
43
|
+
key={index}
|
|
44
|
+
className={
|
|
45
|
+
start === activeOccurrenceStart
|
|
46
|
+
? 'bg-accent/20 text-foreground font-semibold ring-2 ring-accent ring-offset-1 ring-offset-background rounded-sm'
|
|
47
|
+
: 'bg-highlight text-foreground font-semibold'
|
|
48
|
+
}
|
|
49
|
+
>
|
|
50
|
+
{part}
|
|
51
|
+
</mark>
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
|
|
13
56
|
export const PassageResult = ({
|
|
57
|
+
activeOccurrenceStart,
|
|
14
58
|
match,
|
|
15
59
|
query,
|
|
16
60
|
}: {
|
|
61
|
+
activeOccurrenceStart?: number;
|
|
17
62
|
match: PassageMatch;
|
|
18
63
|
query: string;
|
|
19
64
|
}) => {
|
|
20
|
-
return
|
|
65
|
+
return (
|
|
66
|
+
<div>
|
|
67
|
+
{renderPassageHighlight({
|
|
68
|
+
activeOccurrenceStart,
|
|
69
|
+
query,
|
|
70
|
+
text: match.content,
|
|
71
|
+
})}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
21
74
|
};
|
|
22
75
|
|
|
23
76
|
export const AlignmentResult = ({
|
|
77
|
+
activeOccurrenceStart,
|
|
24
78
|
match,
|
|
25
79
|
query,
|
|
26
80
|
}: {
|
|
81
|
+
activeOccurrenceStart?: number;
|
|
27
82
|
match: AlignmentMatch;
|
|
28
83
|
query: string;
|
|
29
84
|
}) => {
|
|
30
85
|
return (
|
|
31
86
|
<div className="grid md:grid-cols-2 gap-4">
|
|
32
|
-
<div>
|
|
87
|
+
<div>
|
|
88
|
+
{renderPassageHighlight({
|
|
89
|
+
activeOccurrenceStart,
|
|
90
|
+
query,
|
|
91
|
+
text: match.content,
|
|
92
|
+
})}
|
|
93
|
+
</div>
|
|
33
94
|
<div className="text-lg">{highlightText(match.source, query)}</div>
|
|
34
95
|
</div>
|
|
35
96
|
);
|
|
@@ -52,14 +113,31 @@ export const GlossaryResult = ({
|
|
|
52
113
|
match: GlossaryMatch;
|
|
53
114
|
query: string;
|
|
54
115
|
}) => {
|
|
55
|
-
return
|
|
116
|
+
return (
|
|
117
|
+
<div className="flex flex-col gap-2">
|
|
118
|
+
<div className='text-primary font-bold'>{
|
|
119
|
+
highlightText(
|
|
120
|
+
removeDiacritics(match.content),
|
|
121
|
+
removeDiacritics(query)
|
|
122
|
+
)
|
|
123
|
+
}</div>
|
|
124
|
+
{match.entry.definition && <div
|
|
125
|
+
className="glossary-instance-definition"
|
|
126
|
+
dangerouslySetInnerHTML={{ __html: match.entry.definition }}
|
|
127
|
+
/>}
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
56
130
|
};
|
|
57
131
|
|
|
58
132
|
export const SearchResultCard = ({
|
|
133
|
+
isActive = false,
|
|
134
|
+
activeOccurrenceStart,
|
|
59
135
|
match,
|
|
60
136
|
query,
|
|
61
137
|
onClick,
|
|
62
138
|
}: {
|
|
139
|
+
isActive?: boolean;
|
|
140
|
+
activeOccurrenceStart?: number;
|
|
63
141
|
match: SearchResult;
|
|
64
142
|
query: string;
|
|
65
143
|
onClick: () => void;
|
|
@@ -72,10 +150,20 @@ export const SearchResultCard = ({
|
|
|
72
150
|
const renderInner = (match: SearchResult) => {
|
|
73
151
|
switch (match.type) {
|
|
74
152
|
case 'passage':
|
|
75
|
-
return
|
|
153
|
+
return (
|
|
154
|
+
<PassageResult
|
|
155
|
+
activeOccurrenceStart={isActive ? activeOccurrenceStart : undefined}
|
|
156
|
+
match={match as PassageMatch}
|
|
157
|
+
query={query}
|
|
158
|
+
/>
|
|
159
|
+
);
|
|
76
160
|
case 'alignment':
|
|
77
161
|
return (
|
|
78
|
-
<AlignmentResult
|
|
162
|
+
<AlignmentResult
|
|
163
|
+
activeOccurrenceStart={isActive ? activeOccurrenceStart : undefined}
|
|
164
|
+
match={match as AlignmentMatch}
|
|
165
|
+
query={query}
|
|
166
|
+
/>
|
|
79
167
|
);
|
|
80
168
|
case 'bibliography':
|
|
81
169
|
return (
|
|
@@ -94,9 +182,13 @@ export const SearchResultCard = ({
|
|
|
94
182
|
return (
|
|
95
183
|
<div
|
|
96
184
|
onClick={onClick}
|
|
97
|
-
|
|
185
|
+
data-search-result-active={isActive ? 'true' : undefined}
|
|
186
|
+
className={`flex flex-col md:flex-row gap-4 py-6 px-4 md:px-6 font-serif text-sm text-foreground rounded-lg bg-background cursor-pointer border transition-colors ${isActive
|
|
187
|
+
? 'border-accent border-2 scroll-mt-4'
|
|
188
|
+
: 'border-transparent hover:border-secondary border-2'
|
|
189
|
+
}`}
|
|
98
190
|
>
|
|
99
|
-
<div className="md:w-
|
|
191
|
+
<div className="md:w-28 shrink-0 flex md:flex-col gap-1 md:gap-2 md:text-right text-xs md:text-sm">
|
|
100
192
|
<div className="flex flex-col gap-1">
|
|
101
193
|
<span className="text-secondary capitalize">{passage.type}</span>
|
|
102
194
|
{passage.section && (
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import { TabsList, TabsTrigger } from '@eightyfourthousand/design-system';
|
|
2
|
-
import { RESULTS_ENTITIES, SearchResults } from '../types';
|
|
2
|
+
import { RESULTS_ENTITIES, RESULTS_ENTITY_LABELS, SearchResults } from '../types';
|
|
3
3
|
|
|
4
4
|
export const SearchResultTabs = ({ results }: { results: SearchResults }) => {
|
|
5
5
|
return (
|
|
6
|
-
<TabsList className="h-24 gap-4 flex-shrink-0 rounded-lg p-0 w-full bg-transparent">
|
|
6
|
+
<TabsList className="h-24 gap-4 flex-shrink-0 rounded-lg p-0 w-full bg-transparent sm:p-0">
|
|
7
7
|
{RESULTS_ENTITIES.map(
|
|
8
8
|
(tab) =>
|
|
9
9
|
results[tab].length > 0 && (
|
|
10
10
|
<TabsTrigger
|
|
11
11
|
key={tab}
|
|
12
12
|
value={tab}
|
|
13
|
-
className="h-full border border-border rounded-xl flex-grow data-[state=inactive]:bg-muted data-[state=active]:border-
|
|
13
|
+
className="h-full border border-border rounded-xl flex-grow data-[state=inactive]:bg-muted data-[state=active]:border-secondary data-[state=active]:border-3 border-2"
|
|
14
14
|
>
|
|
15
15
|
<div className="flex flex-col gap-1 items-center">
|
|
16
|
-
<span className="
|
|
17
|
-
{tab}
|
|
16
|
+
<span className="text-secondary font-bold">
|
|
17
|
+
{RESULTS_ENTITY_LABELS[tab]}
|
|
18
18
|
</span>
|
|
19
19
|
<span className="text-primary font-bold">
|
|
20
20
|
{results[tab].length} matches
|
|
@@ -2,24 +2,34 @@ import { SearchResult } from '../types';
|
|
|
2
2
|
import { SearchResultCard } from './SearchResultCard';
|
|
3
3
|
|
|
4
4
|
export const SearchResultsList = ({
|
|
5
|
+
activeOccurrenceStart,
|
|
6
|
+
activePassageUuid,
|
|
5
7
|
query,
|
|
6
8
|
results,
|
|
7
9
|
onCardClick,
|
|
8
10
|
}: {
|
|
11
|
+
activeOccurrenceStart?: number;
|
|
12
|
+
activePassageUuid?: string;
|
|
9
13
|
query: string;
|
|
10
14
|
results: SearchResult[];
|
|
11
15
|
onCardClick: (result: SearchResult) => void;
|
|
12
16
|
}) => {
|
|
13
17
|
return (
|
|
14
18
|
<div className="h-full flex flex-col gap-3 pb-4 overflow-y-auto min-h-0">
|
|
15
|
-
{results.map((result, index) =>
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
{results.map((result, index) => {
|
|
20
|
+
const start = result.uuid === activePassageUuid ? activeOccurrenceStart : undefined;
|
|
21
|
+
const isActive = !!start;
|
|
22
|
+
return (
|
|
23
|
+
<SearchResultCard
|
|
24
|
+
key={index}
|
|
25
|
+
activeOccurrenceStart={start}
|
|
26
|
+
isActive={isActive}
|
|
27
|
+
match={result}
|
|
28
|
+
query={query}
|
|
29
|
+
onClick={() => onCardClick(result)}
|
|
30
|
+
/>
|
|
31
|
+
)
|
|
32
|
+
})}
|
|
23
33
|
</div>
|
|
24
34
|
);
|
|
25
35
|
};
|