astro-tractstack 2.0.0-rc.44 → 2.0.0-rc.46
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 +219 -39
- package/templates/src/components/search/SearchResults.tsx +149 -100
- package/templates/src/hooks/useSearch.ts +189 -119
- package/templates/src/layouts/Layout.astro +2 -2
- 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,53 @@ 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 w-full px-6 py-2">
|
|
209
|
+
{showCompletion && (
|
|
210
|
+
<div className="pointer-events-none absolute left-0 top-0 flex h-full w-full items-center px-6 py-2 text-xl text-gray-400">
|
|
211
|
+
{bestCompletion}
|
|
212
|
+
</div>
|
|
213
|
+
)}
|
|
214
|
+
<input
|
|
215
|
+
ref={inputRef}
|
|
216
|
+
type="text"
|
|
217
|
+
value={query}
|
|
218
|
+
onChange={handleInputChange}
|
|
219
|
+
onKeyDown={handleKeyDown}
|
|
220
|
+
placeholder="Search content..."
|
|
221
|
+
className="text-mydarkgrey relative z-10 w-full border-none bg-transparent text-xl placeholder-gray-500 outline-none"
|
|
222
|
+
style={{ background: 'transparent', padding: '0' }}
|
|
223
|
+
/>
|
|
224
|
+
</div>
|
|
225
|
+
)}
|
|
97
226
|
<button
|
|
98
227
|
onClick={handleClose}
|
|
99
228
|
className="text-mydarkgrey hover:text-myblue absolute right-4 top-6 rounded-lg p-2 transition-colors hover:bg-gray-100"
|
|
@@ -108,29 +237,41 @@ export default function SearchModal({
|
|
|
108
237
|
className="w-full overflow-y-auto"
|
|
109
238
|
style={{ height: 'calc(80vh - 80px)' }}
|
|
110
239
|
>
|
|
111
|
-
{
|
|
240
|
+
{/* Initial State */}
|
|
241
|
+
{!query.trim() && selectedTerms.length === 0 && (
|
|
112
242
|
<div className="w-full p-8 text-center text-gray-500">
|
|
113
243
|
<MagnifyingGlassIcon className="mx-auto mb-4 h-16 w-16 text-gray-300" />
|
|
114
244
|
<p className="text-lg">Search across all content</p>
|
|
115
245
|
<p className="mt-2 text-sm">
|
|
116
|
-
|
|
117
|
-
resources
|
|
246
|
+
Start typing to discover content suggestions
|
|
118
247
|
</p>
|
|
119
248
|
</div>
|
|
120
249
|
)}
|
|
121
250
|
|
|
122
|
-
{
|
|
251
|
+
{/* Show message for less than 3 characters */}
|
|
252
|
+
{query.trim() && query.trim().length < 3 && (
|
|
253
|
+
<div className="w-full p-8 text-center text-gray-500">
|
|
254
|
+
<p className="text-lg">Keep typing...</p>
|
|
255
|
+
<p className="mt-2 text-sm">
|
|
256
|
+
Need at least 3 characters to search
|
|
257
|
+
</p>
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
{/* Discovery Loading */}
|
|
262
|
+
{query.trim().length >= 3 && isDiscovering && (
|
|
123
263
|
<div className="w-full p-8 text-center">
|
|
124
264
|
<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">
|
|
265
|
+
<p className="text-mydarkgrey mt-4">Discovering...</p>
|
|
126
266
|
</div>
|
|
127
267
|
)}
|
|
128
268
|
|
|
129
|
-
{
|
|
269
|
+
{/* Discovery Error */}
|
|
270
|
+
{query.trim().length >= 3 && discoverError && (
|
|
130
271
|
<div className="w-full p-8 text-center text-red-600">
|
|
131
|
-
<p>
|
|
272
|
+
<p>Discovery failed: {discoverError}</p>
|
|
132
273
|
<button
|
|
133
|
-
onClick={() =>
|
|
274
|
+
onClick={() => discoverTerms(query.trim())}
|
|
134
275
|
className="text-myblue mt-2 hover:underline"
|
|
135
276
|
>
|
|
136
277
|
Try again
|
|
@@ -138,26 +279,65 @@ export default function SearchModal({
|
|
|
138
279
|
</div>
|
|
139
280
|
)}
|
|
140
281
|
|
|
141
|
-
{
|
|
142
|
-
|
|
143
|
-
|
|
282
|
+
{/* Suggestion Pills */}
|
|
283
|
+
{showSuggestions && (
|
|
284
|
+
<div className="w-full p-6">
|
|
285
|
+
<p className="text-mydarkgrey mb-4 text-sm font-medium">
|
|
286
|
+
Suggestions ({suggestions.length})
|
|
287
|
+
</p>
|
|
288
|
+
<div className="flex flex-wrap gap-2">
|
|
289
|
+
{suggestions.map((suggestion, index) => (
|
|
290
|
+
<button
|
|
291
|
+
key={index}
|
|
292
|
+
onClick={() => handleSuggestionClick(suggestion)}
|
|
293
|
+
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)}`}
|
|
294
|
+
>
|
|
295
|
+
<span>{suggestion.term}</span>
|
|
296
|
+
</button>
|
|
297
|
+
))}
|
|
298
|
+
</div>
|
|
299
|
+
<p className="text-mydarkgrey mt-4 text-xs">
|
|
300
|
+
Click a suggestion or press Enter to search
|
|
301
|
+
</p>
|
|
302
|
+
</div>
|
|
303
|
+
)}
|
|
304
|
+
|
|
305
|
+
{/* Retrieve Loading */}
|
|
306
|
+
{isRetrieving && (
|
|
307
|
+
<div className="w-full p-8 text-center">
|
|
308
|
+
<div className="border-myblue inline-block h-8 w-8 animate-spin rounded-full border-b-2"></div>
|
|
309
|
+
<p className="text-mydarkgrey mt-4">Searching...</p>
|
|
310
|
+
</div>
|
|
311
|
+
)}
|
|
312
|
+
|
|
313
|
+
{/* Retrieve Error */}
|
|
314
|
+
{retrieveError && (
|
|
315
|
+
<div className="w-full p-8 text-center text-red-600">
|
|
316
|
+
<p>Search failed: {retrieveError}</p>
|
|
317
|
+
</div>
|
|
318
|
+
)}
|
|
319
|
+
|
|
320
|
+
{/* No Results */}
|
|
321
|
+
{!isRetrieving &&
|
|
322
|
+
!retrieveError &&
|
|
323
|
+
showResults &&
|
|
144
324
|
totalResults === 0 && (
|
|
145
325
|
<div className="w-full p-8 text-center text-gray-500">
|
|
146
|
-
<p className="text-lg">No results found
|
|
326
|
+
<p className="text-lg">No results found</p>
|
|
147
327
|
<p className="mt-2 text-sm">
|
|
148
328
|
Try different keywords or check your spelling
|
|
149
329
|
</p>
|
|
150
330
|
</div>
|
|
151
331
|
)}
|
|
152
332
|
|
|
153
|
-
{
|
|
154
|
-
|
|
155
|
-
!
|
|
333
|
+
{/* Search Results */}
|
|
334
|
+
{!isRetrieving &&
|
|
335
|
+
!retrieveError &&
|
|
336
|
+
showResults &&
|
|
156
337
|
totalResults > 0 && (
|
|
157
338
|
<SearchResults
|
|
158
339
|
results={searchResults}
|
|
159
340
|
contentMap={contentMap}
|
|
160
|
-
onResultClick={handleClose}
|
|
161
341
|
/>
|
|
162
342
|
)}
|
|
163
343
|
</div>
|
|
@@ -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,10 +9,11 @@ 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
|
-
onResultClick: () => void;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
interface ResultItem {
|
|
@@ -33,18 +35,32 @@ const ITEMS_PER_PAGE = 10;
|
|
|
33
35
|
export default function SearchResults({
|
|
34
36
|
results,
|
|
35
37
|
contentMap,
|
|
36
|
-
onResultClick,
|
|
37
38
|
}: SearchResultsProps) {
|
|
38
39
|
const [currentPage, setCurrentPage] = useState(1);
|
|
39
40
|
|
|
40
41
|
const allResultItems = useMemo(() => {
|
|
41
42
|
const items: ResultItem[] = [];
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
if (VERBOSE)
|
|
45
|
+
console.log('DEBUG SearchResults: Processing results', {
|
|
46
|
+
storyFragmentResults: results.storyFragmentResults.length,
|
|
47
|
+
contextPaneResults: results.contextPaneResults.length,
|
|
48
|
+
resourceResults: results.resourceResults.length,
|
|
49
|
+
contentMapSize: contentMap.length,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Process StoryFragment results
|
|
53
|
+
results.storyFragmentResults.forEach((ftsResult: FTSResult, index) => {
|
|
54
|
+
if (VERBOSE) console.log(`DEBUG StoryFragment ${index}:`, ftsResult);
|
|
44
55
|
const item = contentMap.find(
|
|
45
|
-
(item) => item.id ===
|
|
56
|
+
(item) => item.id === ftsResult.ID && item.type === 'StoryFragment'
|
|
46
57
|
);
|
|
47
58
|
if (item) {
|
|
59
|
+
if (VERBOSE)
|
|
60
|
+
console.log(
|
|
61
|
+
`DEBUG StoryFragment ${index}: Found in contentMap`,
|
|
62
|
+
item
|
|
63
|
+
);
|
|
48
64
|
items.push({
|
|
49
65
|
id: item.id,
|
|
50
66
|
type: 'StoryFragment',
|
|
@@ -57,14 +73,23 @@ export default function SearchResults({
|
|
|
57
73
|
url: `/${item.slug}`,
|
|
58
74
|
imageSrc: item.thumbSrc || '/static.jpg',
|
|
59
75
|
});
|
|
76
|
+
} else {
|
|
77
|
+
if (VERBOSE)
|
|
78
|
+
console.log(
|
|
79
|
+
`DEBUG StoryFragment ${index}: NOT found in contentMap for ID ${ftsResult.ID}`
|
|
80
|
+
);
|
|
60
81
|
}
|
|
61
82
|
});
|
|
62
83
|
|
|
63
|
-
|
|
84
|
+
// Process ContextPane results
|
|
85
|
+
results.contextPaneResults.forEach((ftsResult: FTSResult, index) => {
|
|
86
|
+
if (VERBOSE) console.log(`DEBUG ContextPane ${index}:`, ftsResult);
|
|
64
87
|
const item = contentMap.find(
|
|
65
|
-
(item) => item.id ===
|
|
88
|
+
(item) => item.id === ftsResult.ID && item.type === 'Pane'
|
|
66
89
|
);
|
|
67
90
|
if (item) {
|
|
91
|
+
if (VERBOSE)
|
|
92
|
+
console.log(`DEBUG ContextPane ${index}: Found in contentMap`, item);
|
|
68
93
|
items.push({
|
|
69
94
|
id: item.id,
|
|
70
95
|
type: 'ContextPane',
|
|
@@ -73,14 +98,24 @@ export default function SearchResults({
|
|
|
73
98
|
url: `/context/${item.slug}`,
|
|
74
99
|
imageSrc: '/static.jpg',
|
|
75
100
|
});
|
|
101
|
+
} else {
|
|
102
|
+
if (VERBOSE)
|
|
103
|
+
console.log(
|
|
104
|
+
`DEBUG ContextPane ${index}: NOT found in contentMap for ID ${ftsResult.ID}`
|
|
105
|
+
);
|
|
76
106
|
}
|
|
77
107
|
});
|
|
78
108
|
|
|
79
|
-
|
|
109
|
+
// Process Resource results
|
|
110
|
+
results.resourceResults.forEach((ftsResult: FTSResult, index) => {
|
|
111
|
+
if (VERBOSE) console.log(`DEBUG Resource ${index}:`, ftsResult);
|
|
80
112
|
const item = contentMap.find(
|
|
81
|
-
(item) => item.id ===
|
|
113
|
+
(item) => item.id === ftsResult.ID && item.type === 'Resource'
|
|
82
114
|
);
|
|
83
115
|
if (item) {
|
|
116
|
+
if (VERBOSE)
|
|
117
|
+
console.log(`DEBUG Resource ${index}: Found in contentMap`, item);
|
|
118
|
+
|
|
84
119
|
const resourceUrl = getResourceUrl(item.categorySlug || '', item.slug);
|
|
85
120
|
const resourceImage = getResourceImage(
|
|
86
121
|
item.id,
|
|
@@ -93,6 +128,16 @@ export default function SearchResults({
|
|
|
93
128
|
item.categorySlug || ''
|
|
94
129
|
);
|
|
95
130
|
|
|
131
|
+
if (VERBOSE)
|
|
132
|
+
console.log(`DEBUG Resource ${index}: Helper results`, {
|
|
133
|
+
resourceUrl,
|
|
134
|
+
resourceImage,
|
|
135
|
+
description,
|
|
136
|
+
categorySlug: item.categorySlug,
|
|
137
|
+
slug: item.slug,
|
|
138
|
+
id: item.id,
|
|
139
|
+
});
|
|
140
|
+
|
|
96
141
|
items.push({
|
|
97
142
|
id: item.id,
|
|
98
143
|
type: 'Resource',
|
|
@@ -103,9 +148,24 @@ export default function SearchResults({
|
|
|
103
148
|
url: resourceUrl,
|
|
104
149
|
imageSrc: resourceImage,
|
|
105
150
|
});
|
|
151
|
+
} else {
|
|
152
|
+
if (VERBOSE)
|
|
153
|
+
console.log(
|
|
154
|
+
`DEBUG Resource ${index}: NOT found in contentMap for ID ${ftsResult.ID}`
|
|
155
|
+
);
|
|
156
|
+
if (VERBOSE)
|
|
157
|
+
console.log(
|
|
158
|
+
'DEBUG: Available resource IDs in contentMap:',
|
|
159
|
+
contentMap
|
|
160
|
+
.filter((item) => item.type === 'Resource')
|
|
161
|
+
.map((item) => ({ id: item.id, title: item.title }))
|
|
162
|
+
);
|
|
106
163
|
}
|
|
107
164
|
});
|
|
108
165
|
|
|
166
|
+
if (VERBOSE) console.log('DEBUG SearchResults: Final items', items);
|
|
167
|
+
|
|
168
|
+
// Sort by whether they have real images
|
|
109
169
|
return items.sort((a, b) => {
|
|
110
170
|
const aHasRealImage = a.imageSrc !== '/static.jpg';
|
|
111
171
|
const bHasRealImage = b.imageSrc !== '/static.jpg';
|
|
@@ -114,17 +174,18 @@ export default function SearchResults({
|
|
|
114
174
|
if (!aHasRealImage && bHasRealImage) return 1;
|
|
115
175
|
return 0;
|
|
116
176
|
});
|
|
117
|
-
}, [results]);
|
|
177
|
+
}, [results, contentMap]);
|
|
118
178
|
|
|
119
|
-
const
|
|
179
|
+
const totalResults = allResultItems.length;
|
|
180
|
+
const totalPages = Math.ceil(totalResults / ITEMS_PER_PAGE);
|
|
120
181
|
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
|
121
182
|
const paginatedItems = allResultItems.slice(
|
|
122
183
|
startIndex,
|
|
123
184
|
startIndex + ITEMS_PER_PAGE
|
|
124
185
|
);
|
|
125
186
|
|
|
126
|
-
const handlePageChange = (
|
|
127
|
-
setCurrentPage(
|
|
187
|
+
const handlePageChange = (page: number) => {
|
|
188
|
+
setCurrentPage(page);
|
|
128
189
|
};
|
|
129
190
|
|
|
130
191
|
const getResultBadge = (type: string, categorySlug?: string) => {
|
|
@@ -152,7 +213,7 @@ export default function SearchResults({
|
|
|
152
213
|
}
|
|
153
214
|
};
|
|
154
215
|
|
|
155
|
-
if (
|
|
216
|
+
if (totalResults === 0) {
|
|
156
217
|
return null;
|
|
157
218
|
}
|
|
158
219
|
|
|
@@ -160,13 +221,12 @@ export default function SearchResults({
|
|
|
160
221
|
<div className="p-6">
|
|
161
222
|
<div className="mb-6">
|
|
162
223
|
<h2 className="text-mydarkgrey text-lg font-bold">
|
|
163
|
-
{
|
|
164
|
-
found
|
|
224
|
+
{totalResults} result{totalResults !== 1 ? 's' : ''} found
|
|
165
225
|
</h2>
|
|
166
226
|
<p className="mt-1 text-sm text-gray-600">
|
|
167
227
|
Showing {startIndex + 1}-
|
|
168
|
-
{Math.min(startIndex + ITEMS_PER_PAGE,
|
|
169
|
-
{
|
|
228
|
+
{Math.min(startIndex + ITEMS_PER_PAGE, totalResults)} of{' '}
|
|
229
|
+
{totalResults}
|
|
170
230
|
</p>
|
|
171
231
|
</div>
|
|
172
232
|
|
|
@@ -176,87 +236,66 @@ export default function SearchResults({
|
|
|
176
236
|
key={item.id}
|
|
177
237
|
className="rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-100"
|
|
178
238
|
>
|
|
179
|
-
<a href={item.url}
|
|
180
|
-
<div className="flex items-start gap-4">
|
|
239
|
+
<a href={item.url} className="group block">
|
|
240
|
+
<div className="flex flex-col md:flex-row md:items-start md:gap-4">
|
|
241
|
+
{/* Mobile: Full width image with overlay badge */}
|
|
181
242
|
<div
|
|
182
|
-
className="bg-mydarkgrey
|
|
183
|
-
style={{
|
|
184
|
-
width: '100px',
|
|
185
|
-
height: '56px',
|
|
186
|
-
}}
|
|
187
|
-
data-mobile-size="100x56"
|
|
243
|
+
className="bg-mydarkgrey relative w-full overflow-hidden rounded-lg md:hidden"
|
|
244
|
+
style={{ aspectRatio: '1200/630' }}
|
|
188
245
|
>
|
|
189
246
|
<img
|
|
190
247
|
src={item.imageSrc}
|
|
191
248
|
alt={item.title}
|
|
192
|
-
className="h-full w-full
|
|
249
|
+
className="h-full w-full object-contain"
|
|
193
250
|
/>
|
|
251
|
+
<div className="absolute left-2 top-2">
|
|
252
|
+
{getResultBadge(item.type, item.categorySlug)}
|
|
253
|
+
</div>
|
|
194
254
|
</div>
|
|
195
255
|
|
|
256
|
+
{/* Desktop: Side image with overlay badge */}
|
|
196
257
|
<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"
|
|
258
|
+
className="bg-mydarkgrey relative hidden flex-shrink-0 overflow-hidden rounded-lg md:block"
|
|
259
|
+
style={{ width: '240px', height: '135px' }}
|
|
203
260
|
>
|
|
204
261
|
<img
|
|
205
262
|
src={item.imageSrc}
|
|
206
263
|
alt={item.title}
|
|
207
|
-
className="h-full w-full
|
|
264
|
+
className="h-full w-full object-contain"
|
|
208
265
|
/>
|
|
266
|
+
<div className="absolute left-2 top-2">
|
|
267
|
+
{getResultBadge(item.type, item.categorySlug)}
|
|
268
|
+
</div>
|
|
209
269
|
</div>
|
|
210
270
|
|
|
211
|
-
<div className="min-w-0 flex-1">
|
|
271
|
+
<div className="mt-3 min-w-0 flex-1 md:mt-0">
|
|
212
272
|
<div className="flex items-start justify-between gap-4">
|
|
213
273
|
<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
|
-
|
|
274
|
+
<h3 className="text-mydarkgrey group-hover:text-myblue mb-2 text-lg font-semibold transition-colors">
|
|
275
|
+
{item.title}
|
|
276
|
+
</h3>
|
|
277
|
+
{item.description && (
|
|
278
|
+
<p className="text-mydarkgrey mb-2 text-sm">
|
|
279
|
+
{item.description}
|
|
280
|
+
</p>
|
|
281
|
+
)}
|
|
228
282
|
{item.topics && item.topics.length > 0 && (
|
|
229
283
|
<div className="mb-2 flex flex-wrap gap-1">
|
|
230
|
-
{item.topics.slice(0, 3).map((topic) => (
|
|
284
|
+
{item.topics.slice(0, 3).map((topic, idx) => (
|
|
231
285
|
<span
|
|
232
|
-
key={
|
|
233
|
-
className="
|
|
286
|
+
key={idx}
|
|
287
|
+
className="bg-myoffwhite text-mydarkgrey rounded px-2 py-1 text-xs"
|
|
234
288
|
>
|
|
235
289
|
{topic}
|
|
236
290
|
</span>
|
|
237
291
|
))}
|
|
238
292
|
{item.topics.length > 3 && (
|
|
239
|
-
<span className="text-
|
|
293
|
+
<span className="text-mydarkgrey text-xs">
|
|
240
294
|
+{item.topics.length - 3} more
|
|
241
295
|
</span>
|
|
242
296
|
)}
|
|
243
297
|
</div>
|
|
244
298
|
)}
|
|
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
299
|
</div>
|
|
261
300
|
</div>
|
|
262
301
|
</div>
|
|
@@ -267,42 +306,52 @@ export default function SearchResults({
|
|
|
267
306
|
</div>
|
|
268
307
|
|
|
269
308
|
{totalPages > 1 && (
|
|
270
|
-
<div className="
|
|
309
|
+
<div className="flex justify-center">
|
|
271
310
|
<Pagination.Root
|
|
272
|
-
count={
|
|
311
|
+
count={totalResults}
|
|
273
312
|
pageSize={ITEMS_PER_PAGE}
|
|
274
313
|
page={currentPage}
|
|
275
|
-
|
|
276
|
-
onPageChange={handlePageChange}
|
|
277
|
-
className="flex flex-wrap items-center gap-2"
|
|
314
|
+
onPageChange={(details) => handlePageChange(details.page)}
|
|
278
315
|
>
|
|
279
|
-
<Pagination.
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
316
|
+
<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">
|
|
317
|
+
<ChevronLeftIcon className="h-4 w-4" />
|
|
318
|
+
Previous
|
|
319
|
+
</Pagination.PrevTrigger>
|
|
320
|
+
|
|
321
|
+
<div className="flex items-center gap-1">
|
|
322
|
+
<Pagination.Context>
|
|
323
|
+
{(pagination) =>
|
|
324
|
+
pagination.pages.map((page, index) =>
|
|
325
|
+
page.type === 'page' ? (
|
|
326
|
+
<Pagination.Item
|
|
327
|
+
key={index}
|
|
328
|
+
type="page"
|
|
329
|
+
value={page.value}
|
|
330
|
+
className={`rounded px-3 py-2 text-sm font-medium transition-colors ${
|
|
331
|
+
page.value === currentPage
|
|
332
|
+
? 'bg-myblue text-white'
|
|
333
|
+
: 'text-mydarkgrey hover:text-myblue'
|
|
334
|
+
}`}
|
|
335
|
+
>
|
|
336
|
+
{page.value}
|
|
337
|
+
</Pagination.Item>
|
|
338
|
+
) : (
|
|
339
|
+
<span
|
|
340
|
+
key={index}
|
|
341
|
+
className="text-mydarkgrey px-2 text-sm"
|
|
342
|
+
>
|
|
343
|
+
{page.type === 'ellipsis' ? '...' : ''}
|
|
344
|
+
</span>
|
|
345
|
+
)
|
|
302
346
|
)
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
</
|
|
347
|
+
}
|
|
348
|
+
</Pagination.Context>
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
<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">
|
|
352
|
+
Next
|
|
353
|
+
<ChevronRightIcon className="h-4 w-6" />
|
|
354
|
+
</Pagination.NextTrigger>
|
|
306
355
|
</Pagination.Root>
|
|
307
356
|
</div>
|
|
308
357
|
)}
|
|
@@ -1,179 +1,249 @@
|
|
|
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
|
|
|
42
|
+
// Race condition protection
|
|
31
43
|
const debounceRef = useRef<NodeJS.Timeout>();
|
|
32
44
|
const lastSearchTimeRef = useRef<number>(0);
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
45
|
+
const pendingQueryRef = useRef<string | null>(null);
|
|
46
|
+
const throttleTimeoutRef = useRef<NodeJS.Timeout>();
|
|
47
|
+
const inflightQueryRef = useRef<string | null>(null);
|
|
37
48
|
const api = useMemo(() => new TractStackAPI(), []);
|
|
38
49
|
|
|
39
|
-
const
|
|
50
|
+
const performDiscovery = useCallback(
|
|
40
51
|
async (query: string) => {
|
|
41
|
-
if (!query.trim()
|
|
52
|
+
if (!query.trim()) {
|
|
53
|
+
setSuggestions([]);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Check if this exact query is already inflight
|
|
58
|
+
if (inflightQueryRef.current === query.trim()) {
|
|
42
59
|
return;
|
|
43
60
|
}
|
|
44
61
|
|
|
45
|
-
|
|
46
|
-
|
|
62
|
+
// Mark this query as inflight
|
|
63
|
+
inflightQueryRef.current = query.trim();
|
|
47
64
|
|
|
48
|
-
|
|
49
|
-
|
|
65
|
+
setIsDiscovering(true);
|
|
66
|
+
setDiscoverError(null);
|
|
50
67
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (response.
|
|
57
|
-
|
|
58
|
-
|
|
68
|
+
try {
|
|
69
|
+
const response = await api.discover(query.trim());
|
|
70
|
+
|
|
71
|
+
// Only process response if this is still the current inflight query
|
|
72
|
+
if (inflightQueryRef.current === query.trim()) {
|
|
73
|
+
if (response.success && response.data) {
|
|
74
|
+
setSuggestions(response.data.suggestions);
|
|
75
|
+
} else {
|
|
76
|
+
setDiscoverError(response.error || 'Discovery failed');
|
|
77
|
+
setSuggestions([]);
|
|
59
78
|
}
|
|
60
|
-
setError(response.error || 'Search failed');
|
|
61
|
-
setSearchResults({
|
|
62
|
-
storyFragmentIds: [],
|
|
63
|
-
contextPaneIds: [],
|
|
64
|
-
resourceIds: [],
|
|
65
|
-
});
|
|
66
79
|
}
|
|
67
80
|
} catch (err) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
81
|
+
// Only process error if this is still the current inflight query
|
|
82
|
+
if (inflightQueryRef.current === query.trim()) {
|
|
83
|
+
setDiscoverError(
|
|
84
|
+
err instanceof Error ? err.message : 'Discovery failed'
|
|
85
|
+
);
|
|
86
|
+
setSuggestions([]);
|
|
87
|
+
}
|
|
74
88
|
} finally {
|
|
75
|
-
|
|
89
|
+
// Clear inflight tracking only if this is still the current query
|
|
90
|
+
if (inflightQueryRef.current === query.trim()) {
|
|
91
|
+
inflightQueryRef.current = null;
|
|
92
|
+
setIsDiscovering(false);
|
|
93
|
+
}
|
|
76
94
|
}
|
|
77
95
|
},
|
|
78
96
|
[api]
|
|
79
97
|
);
|
|
80
98
|
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
performSearch(queryToProcess);
|
|
86
|
-
}
|
|
87
|
-
}, [performSearch]);
|
|
99
|
+
const performRetrieve = useCallback(
|
|
100
|
+
async (term: string, isTopic: boolean = false) => {
|
|
101
|
+
setIsRetrieving(true);
|
|
102
|
+
setRetrieveError(null);
|
|
88
103
|
|
|
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
|
-
}
|
|
104
|
+
try {
|
|
105
|
+
const response = await api.retrieve(term, isTopic);
|
|
109
106
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
107
|
+
if (response.success && response.data) {
|
|
108
|
+
setSearchResults(response.data);
|
|
109
|
+
} else {
|
|
110
|
+
setRetrieveError(response.error || 'Retrieval failed');
|
|
111
|
+
setSearchResults(null);
|
|
112
|
+
}
|
|
113
|
+
} catch (err) {
|
|
114
|
+
setRetrieveError(
|
|
115
|
+
err instanceof Error ? err.message : 'Retrieval failed'
|
|
116
|
+
);
|
|
117
|
+
setSearchResults(null);
|
|
118
|
+
} finally {
|
|
119
|
+
setIsRetrieving(false);
|
|
113
120
|
}
|
|
114
121
|
},
|
|
115
|
-
[
|
|
122
|
+
[api]
|
|
116
123
|
);
|
|
117
124
|
|
|
118
|
-
const
|
|
125
|
+
const executePendingSearch = useCallback(() => {
|
|
126
|
+
if (pendingQueryRef.current) {
|
|
127
|
+
const queryToExecute = pendingQueryRef.current;
|
|
128
|
+
pendingQueryRef.current = null;
|
|
129
|
+
|
|
130
|
+
// Update timestamp immediately when scheduling request, not when executing
|
|
131
|
+
lastSearchTimeRef.current = Date.now();
|
|
132
|
+
|
|
133
|
+
performDiscovery(queryToExecute);
|
|
134
|
+
}
|
|
135
|
+
}, [performDiscovery]);
|
|
136
|
+
|
|
137
|
+
const discoverTerms = useCallback(
|
|
119
138
|
(query: string) => {
|
|
120
|
-
// Clear existing debounce
|
|
139
|
+
// Clear existing debounce timer
|
|
121
140
|
if (debounceRef.current) {
|
|
122
141
|
clearTimeout(debounceRef.current);
|
|
123
142
|
}
|
|
124
143
|
|
|
125
|
-
//
|
|
126
|
-
if (
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
144
|
+
// Clear existing throttle timer
|
|
145
|
+
if (throttleTimeoutRef.current) {
|
|
146
|
+
clearTimeout(throttleTimeoutRef.current);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Clear results when starting new discovery
|
|
150
|
+
setSearchResults(null);
|
|
151
|
+
setRetrieveError(null);
|
|
152
|
+
|
|
153
|
+
// Handle empty queries immediately
|
|
154
|
+
if (!query.trim()) {
|
|
155
|
+
setSuggestions([]);
|
|
156
|
+
setDiscoverError(null);
|
|
157
|
+
setIsDiscovering(false);
|
|
158
|
+
pendingQueryRef.current = null;
|
|
159
|
+
inflightQueryRef.current = null;
|
|
134
160
|
return;
|
|
135
161
|
}
|
|
136
162
|
|
|
137
|
-
//
|
|
163
|
+
// Always store the latest query
|
|
164
|
+
pendingQueryRef.current = query;
|
|
165
|
+
|
|
166
|
+
// Debounce first - wait for user to stop typing
|
|
138
167
|
debounceRef.current = setTimeout(() => {
|
|
139
|
-
|
|
168
|
+
const now = Date.now();
|
|
169
|
+
const timeSinceLastSearch = now - lastSearchTimeRef.current;
|
|
170
|
+
|
|
171
|
+
// If enough time has passed since last search, execute immediately
|
|
172
|
+
if (timeSinceLastSearch >= BACKEND_THROTTLE_MS) {
|
|
173
|
+
executePendingSearch();
|
|
174
|
+
} else {
|
|
175
|
+
// Need to wait for throttle - schedule execution
|
|
176
|
+
const remainingTime = BACKEND_THROTTLE_MS - timeSinceLastSearch;
|
|
177
|
+
throttleTimeoutRef.current = setTimeout(
|
|
178
|
+
executePendingSearch,
|
|
179
|
+
remainingTime
|
|
180
|
+
);
|
|
181
|
+
}
|
|
140
182
|
}, DEBOUNCE_MS);
|
|
141
183
|
},
|
|
142
|
-
[
|
|
184
|
+
[executePendingSearch]
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const selectSuggestion = useCallback(
|
|
188
|
+
(suggestion: DiscoverySuggestion) => {
|
|
189
|
+
// Clear suggestions
|
|
190
|
+
setSuggestions([]);
|
|
191
|
+
setDiscoverError(null);
|
|
192
|
+
|
|
193
|
+
// Perform retrieve based on suggestion type
|
|
194
|
+
const isTopic = suggestion.type === 'TOPIC';
|
|
195
|
+
performRetrieve(suggestion.term, isTopic);
|
|
196
|
+
},
|
|
197
|
+
[performRetrieve]
|
|
143
198
|
);
|
|
144
199
|
|
|
145
|
-
const
|
|
200
|
+
const selectExactMatch = useCallback(
|
|
201
|
+
(term: string) => {
|
|
202
|
+
// Clear suggestions
|
|
203
|
+
setSuggestions([]);
|
|
204
|
+
setDiscoverError(null);
|
|
205
|
+
|
|
206
|
+
// Check if term exists in current suggestions to determine if it's a topic
|
|
207
|
+
const matchingSuggestion = suggestions.find(
|
|
208
|
+
(s) => s.term.toLowerCase() === term.toLowerCase()
|
|
209
|
+
);
|
|
210
|
+
const isTopic = matchingSuggestion?.type === 'TOPIC';
|
|
211
|
+
|
|
212
|
+
performRetrieve(term, isTopic);
|
|
213
|
+
},
|
|
214
|
+
[suggestions, performRetrieve]
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const clearAll = useCallback(() => {
|
|
146
218
|
// Clear all timeouts
|
|
147
219
|
if (debounceRef.current) {
|
|
148
220
|
clearTimeout(debounceRef.current);
|
|
149
221
|
}
|
|
150
|
-
if (
|
|
151
|
-
clearTimeout(
|
|
222
|
+
if (throttleTimeoutRef.current) {
|
|
223
|
+
clearTimeout(throttleTimeoutRef.current);
|
|
152
224
|
}
|
|
153
225
|
|
|
154
|
-
// Reset state
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
isFirstSearchRef.current = true;
|
|
226
|
+
// Reset all state including race condition protection
|
|
227
|
+
setSuggestions([]);
|
|
228
|
+
setIsDiscovering(false);
|
|
229
|
+
setDiscoverError(null);
|
|
230
|
+
setSearchResults(null);
|
|
231
|
+
setIsRetrieving(false);
|
|
232
|
+
setRetrieveError(null);
|
|
233
|
+
pendingQueryRef.current = null;
|
|
234
|
+
inflightQueryRef.current = null;
|
|
164
235
|
}, []);
|
|
165
236
|
|
|
166
|
-
const totalResults =
|
|
167
|
-
searchResults.storyFragmentIds.length +
|
|
168
|
-
searchResults.contextPaneIds.length +
|
|
169
|
-
searchResults.resourceIds.length;
|
|
170
|
-
|
|
171
237
|
return {
|
|
238
|
+
suggestions,
|
|
239
|
+
isDiscovering,
|
|
240
|
+
discoverError,
|
|
172
241
|
searchResults,
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
242
|
+
isRetrieving,
|
|
243
|
+
retrieveError,
|
|
244
|
+
discoverTerms,
|
|
245
|
+
selectSuggestion,
|
|
246
|
+
selectExactMatch,
|
|
247
|
+
clearAll,
|
|
178
248
|
};
|
|
179
249
|
}
|
|
@@ -273,8 +273,8 @@ const enableBunny = import.meta.env.PUBLIC_ENABLE_BUNNY === 'true';
|
|
|
273
273
|
}
|
|
274
274
|
<div
|
|
275
275
|
id="loading-indicator"
|
|
276
|
-
class="bg-myorange fixed left-0 top-0
|
|
277
|
-
style="opacity: 0.5; filter: blur(0.5px);"
|
|
276
|
+
class="bg-myorange fixed left-0 top-0 h-1 w-full scale-x-0 transform transition-transform duration-300 ease-out"
|
|
277
|
+
style="opacity: 0.5; filter: blur(0.5px); z-index: 10070;"
|
|
278
278
|
>
|
|
279
279
|
</div>
|
|
280
280
|
|
|
@@ -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> {
|