astro-tractstack 2.0.0-rc.44 → 2.0.0-rc.45
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 +1 -1
- package/templates/custom/minimal/CodeHook.astro +2 -4
- package/templates/custom/with-examples/CodeHook.astro +2 -4
- package/templates/src/components/search/SearchModal.tsx +223 -38
- package/templates/src/components/search/SearchResults.tsx +148 -97
- package/templates/src/hooks/useSearch.ts +162 -117
- package/templates/src/types/tractstack.ts +17 -0
- package/templates/src/utils/api.ts +19 -8
package/package.json
CHANGED
|
@@ -20,12 +20,10 @@ export interface Props {
|
|
|
20
20
|
|
|
21
21
|
const { target, options, fullContentMap /*, resourcesPayload */ } = Astro.props;
|
|
22
22
|
|
|
23
|
-
const enableBunny = import.meta.env.PUBLIC_ENABLE_BUNNY === 'true';
|
|
24
|
-
|
|
25
23
|
export const components = {
|
|
26
24
|
'featured-content': true,
|
|
27
25
|
'list-content': true,
|
|
28
|
-
|
|
26
|
+
'bunny-video': import.meta.env.PUBLIC_ENABLE_BUNNY === 'true',
|
|
29
27
|
epinet: true,
|
|
30
28
|
// "custom-hero": true, // Uncomment when you create CustomHero.astro
|
|
31
29
|
};
|
|
@@ -36,7 +34,7 @@ export const components = {
|
|
|
36
34
|
<ListContent options={options} contentMap={fullContentMap} />
|
|
37
35
|
) : target === 'featured-content' ? (
|
|
38
36
|
<FeaturedContent options={options} contentMap={fullContentMap} />
|
|
39
|
-
) : target === 'bunny-video' &&
|
|
37
|
+
) : target === 'bunny-video' && import.meta.env.PUBLIC_ENABLE_BUNNY ? (
|
|
40
38
|
<BunnyVideoWrapper options={options} />
|
|
41
39
|
) : target === 'epinet' ? (
|
|
42
40
|
<EpinetWrapper fullContentMap={fullContentMap} client:only="react" /> /*
|
|
@@ -20,13 +20,11 @@ export interface Props {
|
|
|
20
20
|
|
|
21
21
|
const { target, options, fullContentMap /*, resourcesPayload */ } = Astro.props;
|
|
22
22
|
|
|
23
|
-
const enableBunny = import.meta.env.PUBLIC_ENABLE_BUNNY === 'true';
|
|
24
|
-
|
|
25
23
|
export const components = {
|
|
26
24
|
'custom-hero': true,
|
|
27
25
|
'featured-content': true,
|
|
28
26
|
'list-content': true,
|
|
29
|
-
|
|
27
|
+
'bunny-video': import.meta.env.PUBLIC_ENABLE_BUNNY === 'true',
|
|
30
28
|
epinet: true,
|
|
31
29
|
};
|
|
32
30
|
---
|
|
@@ -36,7 +34,7 @@ export const components = {
|
|
|
36
34
|
<ListContent options={options} contentMap={fullContentMap} />
|
|
37
35
|
) : target === 'featured-content' ? (
|
|
38
36
|
<FeaturedContent options={options} contentMap={fullContentMap} />
|
|
39
|
-
) : target === 'bunny-video' &&
|
|
37
|
+
) : target === 'bunny-video' && import.meta.env.PUBLIC_ENABLE_BUNNY ? (
|
|
40
38
|
<BunnyVideoWrapper options={options} />
|
|
41
39
|
) : target === 'custom-hero' ? (
|
|
42
40
|
<CustomHero />
|
|
@@ -10,7 +10,10 @@ import { Portal } from '@ark-ui/react/portal';
|
|
|
10
10
|
import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
|
11
11
|
import { useSearch } from '@/hooks/useSearch';
|
|
12
12
|
import SearchResults from './SearchResults';
|
|
13
|
-
import type {
|
|
13
|
+
import type {
|
|
14
|
+
FullContentMapItem,
|
|
15
|
+
DiscoverySuggestion,
|
|
16
|
+
} from '@/types/tractstack';
|
|
14
17
|
|
|
15
18
|
interface SearchModalProps {
|
|
16
19
|
isOpen: boolean;
|
|
@@ -24,14 +27,19 @@ export default function SearchModal({
|
|
|
24
27
|
contentMap,
|
|
25
28
|
}: SearchModalProps) {
|
|
26
29
|
const [query, setQuery] = useState('');
|
|
30
|
+
const [selectedTerms, setSelectedTerms] = useState<string[]>([]);
|
|
27
31
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
28
32
|
const {
|
|
33
|
+
suggestions,
|
|
34
|
+
isDiscovering,
|
|
35
|
+
discoverError,
|
|
29
36
|
searchResults,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
isRetrieving,
|
|
38
|
+
retrieveError,
|
|
39
|
+
discoverTerms,
|
|
40
|
+
selectSuggestion,
|
|
41
|
+
selectExactMatch,
|
|
42
|
+
clearAll,
|
|
35
43
|
} = useSearch();
|
|
36
44
|
|
|
37
45
|
useEffect(() => {
|
|
@@ -43,17 +51,17 @@ export default function SearchModal({
|
|
|
43
51
|
useEffect(() => {
|
|
44
52
|
if (!isOpen) {
|
|
45
53
|
setQuery('');
|
|
46
|
-
|
|
54
|
+
setSelectedTerms([]);
|
|
55
|
+
clearAll();
|
|
47
56
|
}
|
|
48
|
-
}, [isOpen,
|
|
57
|
+
}, [isOpen, clearAll]);
|
|
49
58
|
|
|
50
59
|
useEffect(() => {
|
|
60
|
+
// Only trigger discovery if query has 3+ characters
|
|
51
61
|
if (query.trim().length >= 3) {
|
|
52
|
-
|
|
53
|
-
} else {
|
|
54
|
-
clearResults();
|
|
62
|
+
discoverTerms(query);
|
|
55
63
|
}
|
|
56
|
-
}, [query,
|
|
64
|
+
}, [query, discoverTerms]);
|
|
57
65
|
|
|
58
66
|
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
59
67
|
setQuery(e.target.value);
|
|
@@ -61,16 +69,106 @@ export default function SearchModal({
|
|
|
61
69
|
|
|
62
70
|
const handleClose = () => {
|
|
63
71
|
setQuery('');
|
|
64
|
-
|
|
72
|
+
setSelectedTerms([]);
|
|
73
|
+
clearAll();
|
|
65
74
|
onClose();
|
|
66
75
|
};
|
|
67
76
|
|
|
68
77
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
69
78
|
if (e.key === 'Escape') {
|
|
70
79
|
handleClose();
|
|
80
|
+
} else if (e.key === 'Enter' && query.trim()) {
|
|
81
|
+
// Only proceed if we have suggestions or query is 3+ chars
|
|
82
|
+
if (query.trim().length < 3) return;
|
|
83
|
+
|
|
84
|
+
// If there's only one suggestion, select it
|
|
85
|
+
if (suggestions.length === 1) {
|
|
86
|
+
handleSuggestionSelect(suggestions[0]);
|
|
87
|
+
} else if (suggestions.length > 0) {
|
|
88
|
+
// Check for exact match first
|
|
89
|
+
const exactMatch = suggestions.find(
|
|
90
|
+
(s) => s.term.toLowerCase() === query.trim().toLowerCase()
|
|
91
|
+
);
|
|
92
|
+
if (exactMatch) {
|
|
93
|
+
handleSuggestionSelect(exactMatch);
|
|
94
|
+
} else {
|
|
95
|
+
// No exact match, select first suggestion
|
|
96
|
+
handleSuggestionSelect(suggestions[0]);
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
// No suggestions, do exact match search
|
|
100
|
+
handleExactMatch(query.trim());
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const handleSuggestionClick = (suggestion: DiscoverySuggestion) => {
|
|
106
|
+
handleSuggestionSelect(suggestion);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const handleSuggestionSelect = (suggestion: DiscoverySuggestion) => {
|
|
110
|
+
// Add to selected terms if not already present
|
|
111
|
+
if (!selectedTerms.includes(suggestion.term)) {
|
|
112
|
+
setSelectedTerms((prev) => [...prev, suggestion.term]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Clear input and perform search
|
|
116
|
+
setQuery('');
|
|
117
|
+
selectSuggestion(suggestion);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const handleExactMatch = (term: string) => {
|
|
121
|
+
// Add to selected terms if not already present
|
|
122
|
+
if (!selectedTerms.includes(term)) {
|
|
123
|
+
setSelectedTerms((prev) => [...prev, term]);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Clear input and perform search
|
|
127
|
+
setQuery('');
|
|
128
|
+
selectExactMatch(term);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const removeTerm = (indexToRemove: number) => {
|
|
132
|
+
setSelectedTerms((prev) =>
|
|
133
|
+
prev.filter((_, index) => index !== indexToRemove)
|
|
134
|
+
);
|
|
135
|
+
// Clear search results when removing terms
|
|
136
|
+
clearAll();
|
|
137
|
+
// Focus back on input
|
|
138
|
+
if (inputRef.current) {
|
|
139
|
+
inputRef.current.focus();
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const getTypeColor = (type: string) => {
|
|
144
|
+
switch (type) {
|
|
145
|
+
case 'TOPIC':
|
|
146
|
+
return 'bg-purple-100 text-purple-800 border-purple-200';
|
|
147
|
+
case 'TITLE':
|
|
148
|
+
return 'bg-blue-100 text-blue-800 border-blue-200';
|
|
149
|
+
case 'CONTENT':
|
|
150
|
+
return 'bg-green-100 text-green-800 border-green-200';
|
|
151
|
+
default:
|
|
152
|
+
return 'bg-gray-100 text-gray-800 border-gray-200';
|
|
71
153
|
}
|
|
72
154
|
};
|
|
73
155
|
|
|
156
|
+
// Autocomplete logic - only show when we have suggestions and query is 3+ chars
|
|
157
|
+
const bestCompletion =
|
|
158
|
+
suggestions.length > 0 && query.length >= 3 ? suggestions[0].term : '';
|
|
159
|
+
const showCompletion =
|
|
160
|
+
bestCompletion.toLowerCase().startsWith(query.toLowerCase()) &&
|
|
161
|
+
query.length >= 3;
|
|
162
|
+
|
|
163
|
+
const showSuggestions =
|
|
164
|
+
suggestions.length > 0 && !searchResults && query.length >= 3;
|
|
165
|
+
const showResults = searchResults !== null;
|
|
166
|
+
const totalResults = searchResults
|
|
167
|
+
? searchResults.storyFragmentResults.length +
|
|
168
|
+
searchResults.contextPaneResults.length +
|
|
169
|
+
searchResults.resourceResults.length
|
|
170
|
+
: 0;
|
|
171
|
+
|
|
74
172
|
return (
|
|
75
173
|
<Dialog.Root
|
|
76
174
|
open={isOpen}
|
|
@@ -78,22 +176,57 @@ export default function SearchModal({
|
|
|
78
176
|
>
|
|
79
177
|
<Portal>
|
|
80
178
|
<Dialog.Backdrop className="fixed inset-0 z-50 bg-black bg-opacity-50 backdrop-blur-sm" />
|
|
81
|
-
<Dialog.Positioner className="fixed inset-0 z-50 mx-auto max-w-
|
|
179
|
+
<Dialog.Positioner className="fixed inset-0 z-50 mx-auto max-w-3xl p-2 pt-16 md:p-4">
|
|
82
180
|
<Dialog.Content
|
|
83
181
|
className="bg-mywhite mx-auto w-full overflow-hidden rounded-lg shadow-2xl"
|
|
84
182
|
style={{ height: '80vh' }}
|
|
85
183
|
>
|
|
86
184
|
{/* Fixed Header */}
|
|
87
185
|
<div className="relative w-full border-b border-gray-200 p-4">
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
186
|
+
{/* Selected Terms Pills */}
|
|
187
|
+
{selectedTerms.length > 0 && (
|
|
188
|
+
<div className="mb-3 flex flex-wrap gap-2">
|
|
189
|
+
{selectedTerms.map((term, index) => (
|
|
190
|
+
<div
|
|
191
|
+
key={index}
|
|
192
|
+
className="flex items-center gap-1 rounded-full border border-blue-200 bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800"
|
|
193
|
+
>
|
|
194
|
+
<span>{term}</span>
|
|
195
|
+
<button
|
|
196
|
+
onClick={() => removeTerm(index)}
|
|
197
|
+
className="ml-1 rounded-full p-0.5 text-blue-600 hover:bg-blue-200 hover:text-blue-800"
|
|
198
|
+
aria-label={`Remove ${term}`}
|
|
199
|
+
>
|
|
200
|
+
<XMarkIcon className="h-3 w-3" />
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
))}
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
{!showResults && (
|
|
208
|
+
<div className="relative">
|
|
209
|
+
<div className="relative w-full border-none bg-transparent px-6 py-2 text-xl">
|
|
210
|
+
{/* Background layer with completion text */}
|
|
211
|
+
{showCompletion && (
|
|
212
|
+
<div className="pointer-events-none absolute inset-0 px-6 py-2 text-xl text-gray-400">
|
|
213
|
+
{bestCompletion}
|
|
214
|
+
</div>
|
|
215
|
+
)}
|
|
216
|
+
{/* Foreground input */}
|
|
217
|
+
<input
|
|
218
|
+
ref={inputRef}
|
|
219
|
+
type="text"
|
|
220
|
+
value={query}
|
|
221
|
+
onChange={handleInputChange}
|
|
222
|
+
onKeyDown={handleKeyDown}
|
|
223
|
+
placeholder="Search content..."
|
|
224
|
+
className="text-mydarkgrey relative z-10 w-full border-none bg-transparent text-xl placeholder-gray-500 outline-none"
|
|
225
|
+
style={{ background: 'transparent' }}
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
97
230
|
<button
|
|
98
231
|
onClick={handleClose}
|
|
99
232
|
className="text-mydarkgrey hover:text-myblue absolute right-4 top-6 rounded-lg p-2 transition-colors hover:bg-gray-100"
|
|
@@ -108,29 +241,41 @@ export default function SearchModal({
|
|
|
108
241
|
className="w-full overflow-y-auto"
|
|
109
242
|
style={{ height: 'calc(80vh - 80px)' }}
|
|
110
243
|
>
|
|
111
|
-
{
|
|
244
|
+
{/* Initial State */}
|
|
245
|
+
{!query.trim() && selectedTerms.length === 0 && (
|
|
112
246
|
<div className="w-full p-8 text-center text-gray-500">
|
|
113
247
|
<MagnifyingGlassIcon className="mx-auto mb-4 h-16 w-16 text-gray-300" />
|
|
114
248
|
<p className="text-lg">Search across all content</p>
|
|
115
249
|
<p className="mt-2 text-sm">
|
|
116
|
-
|
|
117
|
-
resources
|
|
250
|
+
Start typing to discover content suggestions
|
|
118
251
|
</p>
|
|
119
252
|
</div>
|
|
120
253
|
)}
|
|
121
254
|
|
|
122
|
-
{
|
|
255
|
+
{/* Show message for less than 3 characters */}
|
|
256
|
+
{query.trim() && query.trim().length < 3 && (
|
|
257
|
+
<div className="w-full p-8 text-center text-gray-500">
|
|
258
|
+
<p className="text-lg">Keep typing...</p>
|
|
259
|
+
<p className="mt-2 text-sm">
|
|
260
|
+
Need at least 3 characters to search
|
|
261
|
+
</p>
|
|
262
|
+
</div>
|
|
263
|
+
)}
|
|
264
|
+
|
|
265
|
+
{/* Discovery Loading */}
|
|
266
|
+
{query.trim().length >= 3 && isDiscovering && (
|
|
123
267
|
<div className="w-full p-8 text-center">
|
|
124
268
|
<div className="border-myblue inline-block h-8 w-8 animate-spin rounded-full border-b-2"></div>
|
|
125
|
-
<p className="text-mydarkgrey mt-4">
|
|
269
|
+
<p className="text-mydarkgrey mt-4">Discovering...</p>
|
|
126
270
|
</div>
|
|
127
271
|
)}
|
|
128
272
|
|
|
129
|
-
{
|
|
273
|
+
{/* Discovery Error */}
|
|
274
|
+
{query.trim().length >= 3 && discoverError && (
|
|
130
275
|
<div className="w-full p-8 text-center text-red-600">
|
|
131
|
-
<p>
|
|
276
|
+
<p>Discovery failed: {discoverError}</p>
|
|
132
277
|
<button
|
|
133
|
-
onClick={() =>
|
|
278
|
+
onClick={() => discoverTerms(query.trim())}
|
|
134
279
|
className="text-myblue mt-2 hover:underline"
|
|
135
280
|
>
|
|
136
281
|
Try again
|
|
@@ -138,21 +283,61 @@ export default function SearchModal({
|
|
|
138
283
|
</div>
|
|
139
284
|
)}
|
|
140
285
|
|
|
141
|
-
{
|
|
142
|
-
|
|
143
|
-
|
|
286
|
+
{/* Suggestion Pills */}
|
|
287
|
+
{showSuggestions && (
|
|
288
|
+
<div className="w-full p-6">
|
|
289
|
+
<p className="text-mydarkgrey mb-4 text-sm font-medium">
|
|
290
|
+
Suggestions ({suggestions.length})
|
|
291
|
+
</p>
|
|
292
|
+
<div className="flex flex-wrap gap-2">
|
|
293
|
+
{suggestions.map((suggestion, index) => (
|
|
294
|
+
<button
|
|
295
|
+
key={index}
|
|
296
|
+
onClick={() => handleSuggestionClick(suggestion)}
|
|
297
|
+
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-sm font-medium transition-all hover:shadow-md ${getTypeColor(suggestion.type)}`}
|
|
298
|
+
>
|
|
299
|
+
<span>{suggestion.term}</span>
|
|
300
|
+
</button>
|
|
301
|
+
))}
|
|
302
|
+
</div>
|
|
303
|
+
<p className="text-mydarkgrey mt-4 text-xs">
|
|
304
|
+
Click a suggestion or press Enter to search
|
|
305
|
+
</p>
|
|
306
|
+
</div>
|
|
307
|
+
)}
|
|
308
|
+
|
|
309
|
+
{/* Retrieve Loading */}
|
|
310
|
+
{isRetrieving && (
|
|
311
|
+
<div className="w-full p-8 text-center">
|
|
312
|
+
<div className="border-myblue inline-block h-8 w-8 animate-spin rounded-full border-b-2"></div>
|
|
313
|
+
<p className="text-mydarkgrey mt-4">Searching...</p>
|
|
314
|
+
</div>
|
|
315
|
+
)}
|
|
316
|
+
|
|
317
|
+
{/* Retrieve Error */}
|
|
318
|
+
{retrieveError && (
|
|
319
|
+
<div className="w-full p-8 text-center text-red-600">
|
|
320
|
+
<p>Search failed: {retrieveError}</p>
|
|
321
|
+
</div>
|
|
322
|
+
)}
|
|
323
|
+
|
|
324
|
+
{/* No Results */}
|
|
325
|
+
{!isRetrieving &&
|
|
326
|
+
!retrieveError &&
|
|
327
|
+
showResults &&
|
|
144
328
|
totalResults === 0 && (
|
|
145
329
|
<div className="w-full p-8 text-center text-gray-500">
|
|
146
|
-
<p className="text-lg">No results found
|
|
330
|
+
<p className="text-lg">No results found</p>
|
|
147
331
|
<p className="mt-2 text-sm">
|
|
148
332
|
Try different keywords or check your spelling
|
|
149
333
|
</p>
|
|
150
334
|
</div>
|
|
151
335
|
)}
|
|
152
336
|
|
|
153
|
-
{
|
|
154
|
-
|
|
155
|
-
!
|
|
337
|
+
{/* Search Results */}
|
|
338
|
+
{!isRetrieving &&
|
|
339
|
+
!retrieveError &&
|
|
340
|
+
showResults &&
|
|
156
341
|
totalResults > 0 && (
|
|
157
342
|
<SearchResults
|
|
158
343
|
results={searchResults}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState, useMemo } from 'react';
|
|
2
2
|
import { Pagination } from '@ark-ui/react/pagination';
|
|
3
|
-
import
|
|
3
|
+
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
|
4
|
+
import type { CategorizedResults, FTSResult } from '@/types/tractstack';
|
|
4
5
|
import type { FullContentMapItem } from '@/types/tractstack';
|
|
5
6
|
import {
|
|
6
7
|
getResourceUrl,
|
|
@@ -8,8 +9,10 @@ import {
|
|
|
8
9
|
getResourceDescription,
|
|
9
10
|
} from '@/utils/customHelpers';
|
|
10
11
|
|
|
12
|
+
const VERBOSE = false;
|
|
13
|
+
|
|
11
14
|
interface SearchResultsProps {
|
|
12
|
-
results:
|
|
15
|
+
results: CategorizedResults;
|
|
13
16
|
contentMap: FullContentMapItem[];
|
|
14
17
|
onResultClick: () => void;
|
|
15
18
|
}
|
|
@@ -40,11 +43,26 @@ export default function SearchResults({
|
|
|
40
43
|
const allResultItems = useMemo(() => {
|
|
41
44
|
const items: ResultItem[] = [];
|
|
42
45
|
|
|
43
|
-
|
|
46
|
+
if (VERBOSE)
|
|
47
|
+
console.log('DEBUG SearchResults: Processing results', {
|
|
48
|
+
storyFragmentResults: results.storyFragmentResults.length,
|
|
49
|
+
contextPaneResults: results.contextPaneResults.length,
|
|
50
|
+
resourceResults: results.resourceResults.length,
|
|
51
|
+
contentMapSize: contentMap.length,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Process StoryFragment results
|
|
55
|
+
results.storyFragmentResults.forEach((ftsResult: FTSResult, index) => {
|
|
56
|
+
if (VERBOSE) console.log(`DEBUG StoryFragment ${index}:`, ftsResult);
|
|
44
57
|
const item = contentMap.find(
|
|
45
|
-
(item) => item.id ===
|
|
58
|
+
(item) => item.id === ftsResult.ID && item.type === 'StoryFragment'
|
|
46
59
|
);
|
|
47
60
|
if (item) {
|
|
61
|
+
if (VERBOSE)
|
|
62
|
+
console.log(
|
|
63
|
+
`DEBUG StoryFragment ${index}: Found in contentMap`,
|
|
64
|
+
item
|
|
65
|
+
);
|
|
48
66
|
items.push({
|
|
49
67
|
id: item.id,
|
|
50
68
|
type: 'StoryFragment',
|
|
@@ -57,14 +75,23 @@ export default function SearchResults({
|
|
|
57
75
|
url: `/${item.slug}`,
|
|
58
76
|
imageSrc: item.thumbSrc || '/static.jpg',
|
|
59
77
|
});
|
|
78
|
+
} else {
|
|
79
|
+
if (VERBOSE)
|
|
80
|
+
console.log(
|
|
81
|
+
`DEBUG StoryFragment ${index}: NOT found in contentMap for ID ${ftsResult.ID}`
|
|
82
|
+
);
|
|
60
83
|
}
|
|
61
84
|
});
|
|
62
85
|
|
|
63
|
-
|
|
86
|
+
// Process ContextPane results
|
|
87
|
+
results.contextPaneResults.forEach((ftsResult: FTSResult, index) => {
|
|
88
|
+
if (VERBOSE) console.log(`DEBUG ContextPane ${index}:`, ftsResult);
|
|
64
89
|
const item = contentMap.find(
|
|
65
|
-
(item) => item.id ===
|
|
90
|
+
(item) => item.id === ftsResult.ID && item.type === 'Pane'
|
|
66
91
|
);
|
|
67
92
|
if (item) {
|
|
93
|
+
if (VERBOSE)
|
|
94
|
+
console.log(`DEBUG ContextPane ${index}: Found in contentMap`, item);
|
|
68
95
|
items.push({
|
|
69
96
|
id: item.id,
|
|
70
97
|
type: 'ContextPane',
|
|
@@ -73,14 +100,24 @@ export default function SearchResults({
|
|
|
73
100
|
url: `/context/${item.slug}`,
|
|
74
101
|
imageSrc: '/static.jpg',
|
|
75
102
|
});
|
|
103
|
+
} else {
|
|
104
|
+
if (VERBOSE)
|
|
105
|
+
console.log(
|
|
106
|
+
`DEBUG ContextPane ${index}: NOT found in contentMap for ID ${ftsResult.ID}`
|
|
107
|
+
);
|
|
76
108
|
}
|
|
77
109
|
});
|
|
78
110
|
|
|
79
|
-
|
|
111
|
+
// Process Resource results
|
|
112
|
+
results.resourceResults.forEach((ftsResult: FTSResult, index) => {
|
|
113
|
+
if (VERBOSE) console.log(`DEBUG Resource ${index}:`, ftsResult);
|
|
80
114
|
const item = contentMap.find(
|
|
81
|
-
(item) => item.id ===
|
|
115
|
+
(item) => item.id === ftsResult.ID && item.type === 'Resource'
|
|
82
116
|
);
|
|
83
117
|
if (item) {
|
|
118
|
+
if (VERBOSE)
|
|
119
|
+
console.log(`DEBUG Resource ${index}: Found in contentMap`, item);
|
|
120
|
+
|
|
84
121
|
const resourceUrl = getResourceUrl(item.categorySlug || '', item.slug);
|
|
85
122
|
const resourceImage = getResourceImage(
|
|
86
123
|
item.id,
|
|
@@ -93,6 +130,16 @@ export default function SearchResults({
|
|
|
93
130
|
item.categorySlug || ''
|
|
94
131
|
);
|
|
95
132
|
|
|
133
|
+
if (VERBOSE)
|
|
134
|
+
console.log(`DEBUG Resource ${index}: Helper results`, {
|
|
135
|
+
resourceUrl,
|
|
136
|
+
resourceImage,
|
|
137
|
+
description,
|
|
138
|
+
categorySlug: item.categorySlug,
|
|
139
|
+
slug: item.slug,
|
|
140
|
+
id: item.id,
|
|
141
|
+
});
|
|
142
|
+
|
|
96
143
|
items.push({
|
|
97
144
|
id: item.id,
|
|
98
145
|
type: 'Resource',
|
|
@@ -103,9 +150,24 @@ export default function SearchResults({
|
|
|
103
150
|
url: resourceUrl,
|
|
104
151
|
imageSrc: resourceImage,
|
|
105
152
|
});
|
|
153
|
+
} else {
|
|
154
|
+
if (VERBOSE)
|
|
155
|
+
console.log(
|
|
156
|
+
`DEBUG Resource ${index}: NOT found in contentMap for ID ${ftsResult.ID}`
|
|
157
|
+
);
|
|
158
|
+
if (VERBOSE)
|
|
159
|
+
console.log(
|
|
160
|
+
'DEBUG: Available resource IDs in contentMap:',
|
|
161
|
+
contentMap
|
|
162
|
+
.filter((item) => item.type === 'Resource')
|
|
163
|
+
.map((item) => ({ id: item.id, title: item.title }))
|
|
164
|
+
);
|
|
106
165
|
}
|
|
107
166
|
});
|
|
108
167
|
|
|
168
|
+
if (VERBOSE) console.log('DEBUG SearchResults: Final items', items);
|
|
169
|
+
|
|
170
|
+
// Sort by whether they have real images
|
|
109
171
|
return items.sort((a, b) => {
|
|
110
172
|
const aHasRealImage = a.imageSrc !== '/static.jpg';
|
|
111
173
|
const bHasRealImage = b.imageSrc !== '/static.jpg';
|
|
@@ -114,17 +176,18 @@ export default function SearchResults({
|
|
|
114
176
|
if (!aHasRealImage && bHasRealImage) return 1;
|
|
115
177
|
return 0;
|
|
116
178
|
});
|
|
117
|
-
}, [results]);
|
|
179
|
+
}, [results, contentMap]);
|
|
118
180
|
|
|
119
|
-
const
|
|
181
|
+
const totalResults = allResultItems.length;
|
|
182
|
+
const totalPages = Math.ceil(totalResults / ITEMS_PER_PAGE);
|
|
120
183
|
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
|
121
184
|
const paginatedItems = allResultItems.slice(
|
|
122
185
|
startIndex,
|
|
123
186
|
startIndex + ITEMS_PER_PAGE
|
|
124
187
|
);
|
|
125
188
|
|
|
126
|
-
const handlePageChange = (
|
|
127
|
-
setCurrentPage(
|
|
189
|
+
const handlePageChange = (page: number) => {
|
|
190
|
+
setCurrentPage(page);
|
|
128
191
|
};
|
|
129
192
|
|
|
130
193
|
const getResultBadge = (type: string, categorySlug?: string) => {
|
|
@@ -152,7 +215,7 @@ export default function SearchResults({
|
|
|
152
215
|
}
|
|
153
216
|
};
|
|
154
217
|
|
|
155
|
-
if (
|
|
218
|
+
if (totalResults === 0) {
|
|
156
219
|
return null;
|
|
157
220
|
}
|
|
158
221
|
|
|
@@ -160,13 +223,12 @@ export default function SearchResults({
|
|
|
160
223
|
<div className="p-6">
|
|
161
224
|
<div className="mb-6">
|
|
162
225
|
<h2 className="text-mydarkgrey text-lg font-bold">
|
|
163
|
-
{
|
|
164
|
-
found
|
|
226
|
+
{totalResults} result{totalResults !== 1 ? 's' : ''} found
|
|
165
227
|
</h2>
|
|
166
228
|
<p className="mt-1 text-sm text-gray-600">
|
|
167
229
|
Showing {startIndex + 1}-
|
|
168
|
-
{Math.min(startIndex + ITEMS_PER_PAGE,
|
|
169
|
-
{
|
|
230
|
+
{Math.min(startIndex + ITEMS_PER_PAGE, totalResults)} of{' '}
|
|
231
|
+
{totalResults}
|
|
170
232
|
</p>
|
|
171
233
|
</div>
|
|
172
234
|
|
|
@@ -177,86 +239,65 @@ export default function SearchResults({
|
|
|
177
239
|
className="rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-100"
|
|
178
240
|
>
|
|
179
241
|
<a href={item.url} onClick={onResultClick} className="group block">
|
|
180
|
-
<div className="flex items-start gap-4">
|
|
242
|
+
<div className="flex flex-col md:flex-row md:items-start md:gap-4">
|
|
243
|
+
{/* Mobile: Full width image with overlay badge */}
|
|
181
244
|
<div
|
|
182
|
-
className="bg-mydarkgrey
|
|
183
|
-
style={{
|
|
184
|
-
width: '100px',
|
|
185
|
-
height: '56px',
|
|
186
|
-
}}
|
|
187
|
-
data-mobile-size="100x56"
|
|
245
|
+
className="bg-mydarkgrey relative w-full overflow-hidden rounded-lg md:hidden"
|
|
246
|
+
style={{ aspectRatio: '1200/630' }}
|
|
188
247
|
>
|
|
189
248
|
<img
|
|
190
249
|
src={item.imageSrc}
|
|
191
250
|
alt={item.title}
|
|
192
|
-
className="h-full w-full
|
|
251
|
+
className="h-full w-full object-contain"
|
|
193
252
|
/>
|
|
253
|
+
<div className="absolute left-2 top-2">
|
|
254
|
+
{getResultBadge(item.type, item.categorySlug)}
|
|
255
|
+
</div>
|
|
194
256
|
</div>
|
|
195
257
|
|
|
258
|
+
{/* Desktop: Side image with overlay badge */}
|
|
196
259
|
<div
|
|
197
|
-
className="bg-mydarkgrey hidden flex-shrink-0 overflow-hidden rounded-lg
|
|
198
|
-
style={{
|
|
199
|
-
width: '240px',
|
|
200
|
-
height: '135px',
|
|
201
|
-
}}
|
|
202
|
-
data-desktop-size="240x135"
|
|
260
|
+
className="bg-mydarkgrey relative hidden flex-shrink-0 overflow-hidden rounded-lg md:block"
|
|
261
|
+
style={{ width: '240px', height: '135px' }}
|
|
203
262
|
>
|
|
204
263
|
<img
|
|
205
264
|
src={item.imageSrc}
|
|
206
265
|
alt={item.title}
|
|
207
|
-
className="h-full w-full
|
|
266
|
+
className="h-full w-full object-contain"
|
|
208
267
|
/>
|
|
268
|
+
<div className="absolute left-2 top-2">
|
|
269
|
+
{getResultBadge(item.type, item.categorySlug)}
|
|
270
|
+
</div>
|
|
209
271
|
</div>
|
|
210
272
|
|
|
211
|
-
<div className="min-w-0 flex-1">
|
|
273
|
+
<div className="mt-3 min-w-0 flex-1 md:mt-0">
|
|
212
274
|
<div className="flex items-start justify-between gap-4">
|
|
213
275
|
<div className="flex-1">
|
|
214
|
-
<
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
item.description && (
|
|
223
|
-
<p className="mb-2 line-clamp-2 text-sm text-gray-600">
|
|
224
|
-
{item.description}
|
|
225
|
-
</p>
|
|
226
|
-
)}
|
|
227
|
-
|
|
276
|
+
<h3 className="text-mydarkgrey group-hover:text-myblue mb-2 text-lg font-semibold transition-colors">
|
|
277
|
+
{item.title}
|
|
278
|
+
</h3>
|
|
279
|
+
{item.description && (
|
|
280
|
+
<p className="text-mydarkgrey mb-2 text-sm">
|
|
281
|
+
{item.description}
|
|
282
|
+
</p>
|
|
283
|
+
)}
|
|
228
284
|
{item.topics && item.topics.length > 0 && (
|
|
229
285
|
<div className="mb-2 flex flex-wrap gap-1">
|
|
230
|
-
{item.topics.slice(0, 3).map((topic) => (
|
|
286
|
+
{item.topics.slice(0, 3).map((topic, idx) => (
|
|
231
287
|
<span
|
|
232
|
-
key={
|
|
233
|
-
className="
|
|
288
|
+
key={idx}
|
|
289
|
+
className="bg-myoffwhite text-mydarkgrey rounded px-2 py-1 text-xs"
|
|
234
290
|
>
|
|
235
291
|
{topic}
|
|
236
292
|
</span>
|
|
237
293
|
))}
|
|
238
294
|
{item.topics.length > 3 && (
|
|
239
|
-
<span className="text-
|
|
295
|
+
<span className="text-mydarkgrey text-xs">
|
|
240
296
|
+{item.topics.length - 3} more
|
|
241
297
|
</span>
|
|
242
298
|
)}
|
|
243
299
|
</div>
|
|
244
300
|
)}
|
|
245
|
-
|
|
246
|
-
<p className="truncate text-xs text-gray-500">
|
|
247
|
-
{item.url}
|
|
248
|
-
</p>
|
|
249
|
-
|
|
250
|
-
{/* Mobile badge row */}
|
|
251
|
-
<div className="mt-2 block md:hidden">
|
|
252
|
-
{getResultBadge(item.type, item.categorySlug)}
|
|
253
|
-
</div>
|
|
254
|
-
</div>
|
|
255
|
-
|
|
256
|
-
<div className="hidden flex-shrink-0 text-right md:block">
|
|
257
|
-
<div className="mb-2">
|
|
258
|
-
{getResultBadge(item.type, item.categorySlug)}
|
|
259
|
-
</div>
|
|
260
301
|
</div>
|
|
261
302
|
</div>
|
|
262
303
|
</div>
|
|
@@ -267,42 +308,52 @@ export default function SearchResults({
|
|
|
267
308
|
</div>
|
|
268
309
|
|
|
269
310
|
{totalPages > 1 && (
|
|
270
|
-
<div className="
|
|
311
|
+
<div className="flex justify-center">
|
|
271
312
|
<Pagination.Root
|
|
272
|
-
count={
|
|
313
|
+
count={totalResults}
|
|
273
314
|
pageSize={ITEMS_PER_PAGE}
|
|
274
315
|
page={currentPage}
|
|
275
|
-
|
|
276
|
-
onPageChange={handlePageChange}
|
|
277
|
-
className="flex flex-wrap items-center gap-2"
|
|
316
|
+
onPageChange={(details) => handlePageChange(details.page)}
|
|
278
317
|
>
|
|
279
|
-
<Pagination.
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
318
|
+
<Pagination.PrevTrigger className="text-mydarkgrey hover:text-myblue mr-2 flex items-center gap-1 rounded px-3 py-2 text-sm font-medium transition-colors disabled:opacity-50">
|
|
319
|
+
<ChevronLeftIcon className="h-4 w-4" />
|
|
320
|
+
Previous
|
|
321
|
+
</Pagination.PrevTrigger>
|
|
322
|
+
|
|
323
|
+
<div className="flex items-center gap-1">
|
|
324
|
+
<Pagination.Context>
|
|
325
|
+
{(pagination) =>
|
|
326
|
+
pagination.pages.map((page, index) =>
|
|
327
|
+
page.type === 'page' ? (
|
|
328
|
+
<Pagination.Item
|
|
329
|
+
key={index}
|
|
330
|
+
type="page"
|
|
331
|
+
value={page.value}
|
|
332
|
+
className={`rounded px-3 py-2 text-sm font-medium transition-colors ${
|
|
333
|
+
page.value === currentPage
|
|
334
|
+
? 'bg-myblue text-white'
|
|
335
|
+
: 'text-mydarkgrey hover:text-myblue'
|
|
336
|
+
}`}
|
|
337
|
+
>
|
|
338
|
+
{page.value}
|
|
339
|
+
</Pagination.Item>
|
|
340
|
+
) : (
|
|
341
|
+
<span
|
|
342
|
+
key={index}
|
|
343
|
+
className="text-mydarkgrey px-2 text-sm"
|
|
344
|
+
>
|
|
345
|
+
{page.type === 'ellipsis' ? '...' : ''}
|
|
346
|
+
</span>
|
|
347
|
+
)
|
|
302
348
|
)
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
</
|
|
349
|
+
}
|
|
350
|
+
</Pagination.Context>
|
|
351
|
+
</div>
|
|
352
|
+
|
|
353
|
+
<Pagination.NextTrigger className="text-mydarkgrey hover:text-myblue ml-2 flex items-center gap-1 rounded px-3 py-2 text-sm font-medium transition-colors disabled:opacity-50">
|
|
354
|
+
Next
|
|
355
|
+
<ChevronRightIcon className="h-4 w-6" />
|
|
356
|
+
</Pagination.NextTrigger>
|
|
306
357
|
</Pagination.Root>
|
|
307
358
|
</div>
|
|
308
359
|
)}
|
|
@@ -1,179 +1,224 @@
|
|
|
1
1
|
import { useState, useCallback, useRef, useMemo } from 'react';
|
|
2
2
|
import { TractStackAPI } from '@/utils/api';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
resourceIds: string[];
|
|
8
|
-
}
|
|
3
|
+
import type {
|
|
4
|
+
DiscoverySuggestion,
|
|
5
|
+
CategorizedResults,
|
|
6
|
+
} from '@/types/tractstack';
|
|
9
7
|
|
|
10
8
|
interface UseSearchReturn {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
9
|
+
// Discovery phase
|
|
10
|
+
suggestions: DiscoverySuggestion[];
|
|
11
|
+
isDiscovering: boolean;
|
|
12
|
+
discoverError: string | null;
|
|
13
|
+
|
|
14
|
+
// Retrieve phase
|
|
15
|
+
searchResults: CategorizedResults | null;
|
|
16
|
+
isRetrieving: boolean;
|
|
17
|
+
retrieveError: string | null;
|
|
18
|
+
|
|
19
|
+
// Actions
|
|
20
|
+
discoverTerms: (query: string) => void;
|
|
21
|
+
selectSuggestion: (suggestion: DiscoverySuggestion) => void;
|
|
22
|
+
selectExactMatch: (term: string) => void;
|
|
23
|
+
clearAll: () => void;
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
const DEBOUNCE_MS = 100;
|
|
20
|
-
const BACKEND_THROTTLE_MS =
|
|
27
|
+
const BACKEND_THROTTLE_MS = 1200;
|
|
21
28
|
|
|
22
29
|
export function useSearch(): UseSearchReturn {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const [
|
|
30
|
+
// Discovery state
|
|
31
|
+
const [suggestions, setSuggestions] = useState<DiscoverySuggestion[]>([]);
|
|
32
|
+
const [isDiscovering, setIsDiscovering] = useState(false);
|
|
33
|
+
const [discoverError, setDiscoverError] = useState<string | null>(null);
|
|
34
|
+
|
|
35
|
+
// Retrieve state
|
|
36
|
+
const [searchResults, setSearchResults] = useState<CategorizedResults | null>(
|
|
37
|
+
null
|
|
38
|
+
);
|
|
39
|
+
const [isRetrieving, setIsRetrieving] = useState(false);
|
|
40
|
+
const [retrieveError, setRetrieveError] = useState<string | null>(null);
|
|
30
41
|
|
|
31
42
|
const debounceRef = useRef<NodeJS.Timeout>();
|
|
32
43
|
const lastSearchTimeRef = useRef<number>(0);
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
const isFirstSearchRef = useRef<boolean>(true);
|
|
36
|
-
|
|
44
|
+
const pendingQueryRef = useRef<string | null>(null);
|
|
45
|
+
const throttleTimeoutRef = useRef<NodeJS.Timeout>();
|
|
37
46
|
const api = useMemo(() => new TractStackAPI(), []);
|
|
38
47
|
|
|
39
|
-
const
|
|
48
|
+
const performDiscovery = useCallback(
|
|
40
49
|
async (query: string) => {
|
|
41
|
-
if (!query.trim()
|
|
50
|
+
if (!query.trim()) {
|
|
51
|
+
setSuggestions([]);
|
|
42
52
|
return;
|
|
43
53
|
}
|
|
44
54
|
|
|
45
|
-
|
|
46
|
-
|
|
55
|
+
setIsDiscovering(true);
|
|
56
|
+
setDiscoverError(null);
|
|
57
|
+
lastSearchTimeRef.current = Date.now();
|
|
47
58
|
|
|
48
59
|
try {
|
|
49
|
-
const response = await api.
|
|
60
|
+
const response = await api.discover(query.trim());
|
|
50
61
|
|
|
51
62
|
if (response.success && response.data) {
|
|
52
|
-
|
|
53
|
-
lastSearchTimeRef.current = Date.now();
|
|
63
|
+
setSuggestions(response.data.suggestions);
|
|
54
64
|
} else {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
// Don't set error for rate limiting, just maintain loading state
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
setError(response.error || 'Search failed');
|
|
61
|
-
setSearchResults({
|
|
62
|
-
storyFragmentIds: [],
|
|
63
|
-
contextPaneIds: [],
|
|
64
|
-
resourceIds: [],
|
|
65
|
-
});
|
|
65
|
+
setDiscoverError(response.error || 'Discovery failed');
|
|
66
|
+
setSuggestions([]);
|
|
66
67
|
}
|
|
67
68
|
} catch (err) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
resourceIds: [],
|
|
73
|
-
});
|
|
69
|
+
setDiscoverError(
|
|
70
|
+
err instanceof Error ? err.message : 'Discovery failed'
|
|
71
|
+
);
|
|
72
|
+
setSuggestions([]);
|
|
74
73
|
} finally {
|
|
75
|
-
|
|
74
|
+
setIsDiscovering(false);
|
|
76
75
|
}
|
|
77
76
|
},
|
|
78
77
|
[api]
|
|
79
78
|
);
|
|
80
79
|
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
performSearch(queryToProcess);
|
|
86
|
-
}
|
|
87
|
-
}, [performSearch]);
|
|
80
|
+
const performRetrieve = useCallback(
|
|
81
|
+
async (term: string, isTopic: boolean = false) => {
|
|
82
|
+
setIsRetrieving(true);
|
|
83
|
+
setRetrieveError(null);
|
|
88
84
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const now = Date.now();
|
|
92
|
-
const timeSinceLastSearch = now - lastSearchTimeRef.current;
|
|
93
|
-
|
|
94
|
-
// First search or enough time has passed - execute immediately
|
|
95
|
-
if (
|
|
96
|
-
isFirstSearchRef.current ||
|
|
97
|
-
timeSinceLastSearch >= BACKEND_THROTTLE_MS
|
|
98
|
-
) {
|
|
99
|
-
isFirstSearchRef.current = false;
|
|
100
|
-
performSearch(query);
|
|
101
|
-
} else {
|
|
102
|
-
// Queue the search and schedule it to run when throttle window expires
|
|
103
|
-
queuedQueryRef.current = query;
|
|
104
|
-
|
|
105
|
-
// Clear any existing queue timeout
|
|
106
|
-
if (queueTimeoutRef.current) {
|
|
107
|
-
clearTimeout(queueTimeoutRef.current);
|
|
108
|
-
}
|
|
85
|
+
try {
|
|
86
|
+
const response = await api.retrieve(term, isTopic);
|
|
109
87
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
88
|
+
if (response.success && response.data) {
|
|
89
|
+
setSearchResults(response.data);
|
|
90
|
+
} else {
|
|
91
|
+
setRetrieveError(response.error || 'Retrieval failed');
|
|
92
|
+
setSearchResults(null);
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
setRetrieveError(
|
|
96
|
+
err instanceof Error ? err.message : 'Retrieval failed'
|
|
97
|
+
);
|
|
98
|
+
setSearchResults(null);
|
|
99
|
+
} finally {
|
|
100
|
+
setIsRetrieving(false);
|
|
113
101
|
}
|
|
114
102
|
},
|
|
115
|
-
[
|
|
103
|
+
[api]
|
|
116
104
|
);
|
|
117
105
|
|
|
118
|
-
const
|
|
106
|
+
const executePendingSearch = useCallback(() => {
|
|
107
|
+
if (pendingQueryRef.current) {
|
|
108
|
+
const queryToExecute = pendingQueryRef.current;
|
|
109
|
+
pendingQueryRef.current = null;
|
|
110
|
+
performDiscovery(queryToExecute);
|
|
111
|
+
}
|
|
112
|
+
}, [performDiscovery]);
|
|
113
|
+
|
|
114
|
+
const discoverTerms = useCallback(
|
|
119
115
|
(query: string) => {
|
|
120
|
-
// Clear existing debounce
|
|
116
|
+
// Clear existing debounce timer
|
|
121
117
|
if (debounceRef.current) {
|
|
122
118
|
clearTimeout(debounceRef.current);
|
|
123
119
|
}
|
|
124
120
|
|
|
125
|
-
//
|
|
126
|
-
if (
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
121
|
+
// Clear existing throttle timer
|
|
122
|
+
if (throttleTimeoutRef.current) {
|
|
123
|
+
clearTimeout(throttleTimeoutRef.current);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Clear results when starting new discovery
|
|
127
|
+
setSearchResults(null);
|
|
128
|
+
setRetrieveError(null);
|
|
129
|
+
|
|
130
|
+
// Handle empty queries immediately
|
|
131
|
+
if (!query.trim()) {
|
|
132
|
+
setSuggestions([]);
|
|
133
|
+
setDiscoverError(null);
|
|
134
|
+
setIsDiscovering(false);
|
|
135
|
+
pendingQueryRef.current = null;
|
|
134
136
|
return;
|
|
135
137
|
}
|
|
136
138
|
|
|
137
|
-
//
|
|
139
|
+
// Always store the latest query
|
|
140
|
+
pendingQueryRef.current = query;
|
|
141
|
+
|
|
142
|
+
// Debounce first - wait for user to stop typing
|
|
138
143
|
debounceRef.current = setTimeout(() => {
|
|
139
|
-
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
const timeSinceLastSearch = now - lastSearchTimeRef.current;
|
|
146
|
+
|
|
147
|
+
// If enough time has passed since last search, execute immediately
|
|
148
|
+
if (timeSinceLastSearch >= BACKEND_THROTTLE_MS) {
|
|
149
|
+
executePendingSearch();
|
|
150
|
+
} else {
|
|
151
|
+
// Need to wait for throttle - schedule execution
|
|
152
|
+
const remainingTime = BACKEND_THROTTLE_MS - timeSinceLastSearch;
|
|
153
|
+
throttleTimeoutRef.current = setTimeout(
|
|
154
|
+
executePendingSearch,
|
|
155
|
+
remainingTime
|
|
156
|
+
);
|
|
157
|
+
}
|
|
140
158
|
}, DEBOUNCE_MS);
|
|
141
159
|
},
|
|
142
|
-
[
|
|
160
|
+
[executePendingSearch]
|
|
143
161
|
);
|
|
144
162
|
|
|
145
|
-
const
|
|
163
|
+
const selectSuggestion = useCallback(
|
|
164
|
+
(suggestion: DiscoverySuggestion) => {
|
|
165
|
+
// Clear suggestions
|
|
166
|
+
setSuggestions([]);
|
|
167
|
+
setDiscoverError(null);
|
|
168
|
+
|
|
169
|
+
// Perform retrieve based on suggestion type
|
|
170
|
+
const isTopic = suggestion.type === 'TOPIC';
|
|
171
|
+
performRetrieve(suggestion.term, isTopic);
|
|
172
|
+
},
|
|
173
|
+
[performRetrieve]
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const selectExactMatch = useCallback(
|
|
177
|
+
(term: string) => {
|
|
178
|
+
// Clear suggestions
|
|
179
|
+
setSuggestions([]);
|
|
180
|
+
setDiscoverError(null);
|
|
181
|
+
|
|
182
|
+
// Check if term exists in current suggestions to determine if it's a topic
|
|
183
|
+
const matchingSuggestion = suggestions.find(
|
|
184
|
+
(s) => s.term.toLowerCase() === term.toLowerCase()
|
|
185
|
+
);
|
|
186
|
+
const isTopic = matchingSuggestion?.type === 'TOPIC';
|
|
187
|
+
|
|
188
|
+
performRetrieve(term, isTopic);
|
|
189
|
+
},
|
|
190
|
+
[suggestions, performRetrieve]
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const clearAll = useCallback(() => {
|
|
146
194
|
// Clear all timeouts
|
|
147
195
|
if (debounceRef.current) {
|
|
148
196
|
clearTimeout(debounceRef.current);
|
|
149
197
|
}
|
|
150
|
-
if (
|
|
151
|
-
clearTimeout(
|
|
198
|
+
if (throttleTimeoutRef.current) {
|
|
199
|
+
clearTimeout(throttleTimeoutRef.current);
|
|
152
200
|
}
|
|
153
201
|
|
|
154
|
-
// Reset state
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
queuedQueryRef.current = null;
|
|
163
|
-
isFirstSearchRef.current = true;
|
|
202
|
+
// Reset all state
|
|
203
|
+
setSuggestions([]);
|
|
204
|
+
setIsDiscovering(false);
|
|
205
|
+
setDiscoverError(null);
|
|
206
|
+
setSearchResults(null);
|
|
207
|
+
setIsRetrieving(false);
|
|
208
|
+
setRetrieveError(null);
|
|
209
|
+
pendingQueryRef.current = null;
|
|
164
210
|
}, []);
|
|
165
211
|
|
|
166
|
-
const totalResults =
|
|
167
|
-
searchResults.storyFragmentIds.length +
|
|
168
|
-
searchResults.contextPaneIds.length +
|
|
169
|
-
searchResults.resourceIds.length;
|
|
170
|
-
|
|
171
212
|
return {
|
|
213
|
+
suggestions,
|
|
214
|
+
isDiscovering,
|
|
215
|
+
discoverError,
|
|
172
216
|
searchResults,
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
217
|
+
isRetrieving,
|
|
218
|
+
retrieveError,
|
|
219
|
+
discoverTerms,
|
|
220
|
+
selectSuggestion,
|
|
221
|
+
selectExactMatch,
|
|
222
|
+
clearAll,
|
|
178
223
|
};
|
|
179
224
|
}
|
|
@@ -443,3 +443,20 @@ export interface BunnyPlayer {
|
|
|
443
443
|
export interface PlayerJS {
|
|
444
444
|
Player: new (elementId: string) => BunnyPlayer;
|
|
445
445
|
}
|
|
446
|
+
|
|
447
|
+
export interface DiscoverySuggestion {
|
|
448
|
+
term: string;
|
|
449
|
+
type: 'CONTENT' | 'TOPIC' | 'TITLE';
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export interface FTSResult {
|
|
453
|
+
ID: string;
|
|
454
|
+
Relevance: number;
|
|
455
|
+
Term: string;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export interface CategorizedResults {
|
|
459
|
+
storyFragmentResults: FTSResult[];
|
|
460
|
+
contextPaneResults: FTSResult[];
|
|
461
|
+
resourceResults: FTSResult[];
|
|
462
|
+
}
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DiscoverySuggestion,
|
|
3
|
+
FTSResult,
|
|
4
|
+
CategorizedResults,
|
|
5
|
+
} from '@/types/tractstack';
|
|
6
|
+
|
|
1
7
|
export interface APIResponse<T = any> {
|
|
2
8
|
success: boolean;
|
|
3
9
|
data?: T;
|
|
@@ -138,14 +144,19 @@ export class TractStackAPI {
|
|
|
138
144
|
});
|
|
139
145
|
}
|
|
140
146
|
|
|
141
|
-
async
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
147
|
+
async discover(
|
|
148
|
+
query: string
|
|
149
|
+
): Promise<APIResponse<{ suggestions: DiscoverySuggestion[] }>> {
|
|
150
|
+
return this.get(`/api/v1/search/discover?q=${encodeURIComponent(query)}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async retrieve(
|
|
154
|
+
term: string,
|
|
155
|
+
isTopic: boolean = false
|
|
156
|
+
): Promise<APIResponse<CategorizedResults>> {
|
|
157
|
+
return this.get(
|
|
158
|
+
`/api/v1/search/retrieve?term=${encodeURIComponent(term)}&topic=${isTopic}`
|
|
159
|
+
);
|
|
149
160
|
}
|
|
150
161
|
|
|
151
162
|
async getContent(slug: string): Promise<APIResponse> {
|