astro-tractstack 2.0.0-rc.43 → 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 +222 -37
- package/templates/src/components/search/SearchResults.tsx +153 -73
- 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/templates/src/utils/customHelpers.ts +9 -0
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}
|
|
@@ -85,15 +183,50 @@ export default function SearchModal({
|
|
|
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,12 +1,18 @@
|
|
|
1
1
|
import { useState, useMemo } from 'react';
|
|
2
2
|
import { Pagination } from '@ark-ui/react/pagination';
|
|
3
3
|
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
|
4
|
-
import type {
|
|
4
|
+
import type { CategorizedResults, FTSResult } from '@/types/tractstack';
|
|
5
5
|
import type { FullContentMapItem } from '@/types/tractstack';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
getResourceUrl,
|
|
8
|
+
getResourceImage,
|
|
9
|
+
getResourceDescription,
|
|
10
|
+
} from '@/utils/customHelpers';
|
|
11
|
+
|
|
12
|
+
const VERBOSE = false;
|
|
7
13
|
|
|
8
14
|
interface SearchResultsProps {
|
|
9
|
-
results:
|
|
15
|
+
results: CategorizedResults;
|
|
10
16
|
contentMap: FullContentMapItem[];
|
|
11
17
|
onResultClick: () => void;
|
|
12
18
|
}
|
|
@@ -37,11 +43,26 @@ export default function SearchResults({
|
|
|
37
43
|
const allResultItems = useMemo(() => {
|
|
38
44
|
const items: ResultItem[] = [];
|
|
39
45
|
|
|
40
|
-
|
|
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);
|
|
41
57
|
const item = contentMap.find(
|
|
42
|
-
(item) => item.id ===
|
|
58
|
+
(item) => item.id === ftsResult.ID && item.type === 'StoryFragment'
|
|
43
59
|
);
|
|
44
60
|
if (item) {
|
|
61
|
+
if (VERBOSE)
|
|
62
|
+
console.log(
|
|
63
|
+
`DEBUG StoryFragment ${index}: Found in contentMap`,
|
|
64
|
+
item
|
|
65
|
+
);
|
|
45
66
|
items.push({
|
|
46
67
|
id: item.id,
|
|
47
68
|
type: 'StoryFragment',
|
|
@@ -54,14 +75,23 @@ export default function SearchResults({
|
|
|
54
75
|
url: `/${item.slug}`,
|
|
55
76
|
imageSrc: item.thumbSrc || '/static.jpg',
|
|
56
77
|
});
|
|
78
|
+
} else {
|
|
79
|
+
if (VERBOSE)
|
|
80
|
+
console.log(
|
|
81
|
+
`DEBUG StoryFragment ${index}: NOT found in contentMap for ID ${ftsResult.ID}`
|
|
82
|
+
);
|
|
57
83
|
}
|
|
58
84
|
});
|
|
59
85
|
|
|
60
|
-
|
|
86
|
+
// Process ContextPane results
|
|
87
|
+
results.contextPaneResults.forEach((ftsResult: FTSResult, index) => {
|
|
88
|
+
if (VERBOSE) console.log(`DEBUG ContextPane ${index}:`, ftsResult);
|
|
61
89
|
const item = contentMap.find(
|
|
62
|
-
(item) => item.id ===
|
|
90
|
+
(item) => item.id === ftsResult.ID && item.type === 'Pane'
|
|
63
91
|
);
|
|
64
92
|
if (item) {
|
|
93
|
+
if (VERBOSE)
|
|
94
|
+
console.log(`DEBUG ContextPane ${index}: Found in contentMap`, item);
|
|
65
95
|
items.push({
|
|
66
96
|
id: item.id,
|
|
67
97
|
type: 'ContextPane',
|
|
@@ -70,33 +100,74 @@ export default function SearchResults({
|
|
|
70
100
|
url: `/context/${item.slug}`,
|
|
71
101
|
imageSrc: '/static.jpg',
|
|
72
102
|
});
|
|
103
|
+
} else {
|
|
104
|
+
if (VERBOSE)
|
|
105
|
+
console.log(
|
|
106
|
+
`DEBUG ContextPane ${index}: NOT found in contentMap for ID ${ftsResult.ID}`
|
|
107
|
+
);
|
|
73
108
|
}
|
|
74
109
|
});
|
|
75
110
|
|
|
76
|
-
|
|
111
|
+
// Process Resource results
|
|
112
|
+
results.resourceResults.forEach((ftsResult: FTSResult, index) => {
|
|
113
|
+
if (VERBOSE) console.log(`DEBUG Resource ${index}:`, ftsResult);
|
|
77
114
|
const item = contentMap.find(
|
|
78
|
-
(item) => item.id ===
|
|
115
|
+
(item) => item.id === ftsResult.ID && item.type === 'Resource'
|
|
79
116
|
);
|
|
80
117
|
if (item) {
|
|
118
|
+
if (VERBOSE)
|
|
119
|
+
console.log(`DEBUG Resource ${index}: Found in contentMap`, item);
|
|
120
|
+
|
|
81
121
|
const resourceUrl = getResourceUrl(item.categorySlug || '', item.slug);
|
|
82
122
|
const resourceImage = getResourceImage(
|
|
83
123
|
item.id,
|
|
84
124
|
item.slug,
|
|
85
125
|
item.categorySlug || ''
|
|
86
126
|
);
|
|
127
|
+
const description = getResourceDescription(
|
|
128
|
+
item.id,
|
|
129
|
+
item.slug,
|
|
130
|
+
item.categorySlug || ''
|
|
131
|
+
);
|
|
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
|
+
});
|
|
87
142
|
|
|
88
143
|
items.push({
|
|
89
144
|
id: item.id,
|
|
90
145
|
type: 'Resource',
|
|
91
146
|
title: item.title,
|
|
92
147
|
slug: item.slug,
|
|
148
|
+
description: description || undefined,
|
|
93
149
|
categorySlug: item.categorySlug || undefined,
|
|
94
150
|
url: resourceUrl,
|
|
95
151
|
imageSrc: resourceImage,
|
|
96
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
|
+
);
|
|
97
165
|
}
|
|
98
166
|
});
|
|
99
167
|
|
|
168
|
+
if (VERBOSE) console.log('DEBUG SearchResults: Final items', items);
|
|
169
|
+
|
|
170
|
+
// Sort by whether they have real images
|
|
100
171
|
return items.sort((a, b) => {
|
|
101
172
|
const aHasRealImage = a.imageSrc !== '/static.jpg';
|
|
102
173
|
const bHasRealImage = b.imageSrc !== '/static.jpg';
|
|
@@ -105,9 +176,10 @@ export default function SearchResults({
|
|
|
105
176
|
if (!aHasRealImage && bHasRealImage) return 1;
|
|
106
177
|
return 0;
|
|
107
178
|
});
|
|
108
|
-
}, [results]);
|
|
179
|
+
}, [results, contentMap]);
|
|
109
180
|
|
|
110
|
-
const
|
|
181
|
+
const totalResults = allResultItems.length;
|
|
182
|
+
const totalPages = Math.ceil(totalResults / ITEMS_PER_PAGE);
|
|
111
183
|
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
|
112
184
|
const paginatedItems = allResultItems.slice(
|
|
113
185
|
startIndex,
|
|
@@ -143,7 +215,7 @@ export default function SearchResults({
|
|
|
143
215
|
}
|
|
144
216
|
};
|
|
145
217
|
|
|
146
|
-
if (
|
|
218
|
+
if (totalResults === 0) {
|
|
147
219
|
return null;
|
|
148
220
|
}
|
|
149
221
|
|
|
@@ -151,13 +223,12 @@ export default function SearchResults({
|
|
|
151
223
|
<div className="p-6">
|
|
152
224
|
<div className="mb-6">
|
|
153
225
|
<h2 className="text-mydarkgrey text-lg font-bold">
|
|
154
|
-
{
|
|
155
|
-
found
|
|
226
|
+
{totalResults} result{totalResults !== 1 ? 's' : ''} found
|
|
156
227
|
</h2>
|
|
157
228
|
<p className="mt-1 text-sm text-gray-600">
|
|
158
229
|
Showing {startIndex + 1}-
|
|
159
|
-
{Math.min(startIndex + ITEMS_PER_PAGE,
|
|
160
|
-
{
|
|
230
|
+
{Math.min(startIndex + ITEMS_PER_PAGE, totalResults)} of{' '}
|
|
231
|
+
{totalResults}
|
|
161
232
|
</p>
|
|
162
233
|
</div>
|
|
163
234
|
|
|
@@ -168,61 +239,65 @@ export default function SearchResults({
|
|
|
168
239
|
className="rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-100"
|
|
169
240
|
>
|
|
170
241
|
<a href={item.url} onClick={onResultClick} className="group block">
|
|
171
|
-
<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 */}
|
|
172
244
|
<div
|
|
173
|
-
className="bg-mydarkgrey
|
|
174
|
-
style={{
|
|
245
|
+
className="bg-mydarkgrey relative w-full overflow-hidden rounded-lg md:hidden"
|
|
246
|
+
style={{ aspectRatio: '1200/630' }}
|
|
175
247
|
>
|
|
176
248
|
<img
|
|
177
249
|
src={item.imageSrc}
|
|
178
250
|
alt={item.title}
|
|
179
251
|
className="h-full w-full object-contain"
|
|
180
|
-
style={{ width: '100%', height: '100%' }}
|
|
181
252
|
/>
|
|
253
|
+
<div className="absolute left-2 top-2">
|
|
254
|
+
{getResultBadge(item.type, item.categorySlug)}
|
|
255
|
+
</div>
|
|
182
256
|
</div>
|
|
183
257
|
|
|
184
|
-
|
|
258
|
+
{/* Desktop: Side image with overlay badge */}
|
|
259
|
+
<div
|
|
260
|
+
className="bg-mydarkgrey relative hidden flex-shrink-0 overflow-hidden rounded-lg md:block"
|
|
261
|
+
style={{ width: '240px', height: '135px' }}
|
|
262
|
+
>
|
|
263
|
+
<img
|
|
264
|
+
src={item.imageSrc}
|
|
265
|
+
alt={item.title}
|
|
266
|
+
className="h-full w-full object-contain"
|
|
267
|
+
/>
|
|
268
|
+
<div className="absolute left-2 top-2">
|
|
269
|
+
{getResultBadge(item.type, item.categorySlug)}
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
<div className="mt-3 min-w-0 flex-1 md:mt-0">
|
|
185
274
|
<div className="flex items-start justify-between gap-4">
|
|
186
275
|
<div className="flex-1">
|
|
187
|
-
<
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
{item.type === 'StoryFragment' && item.description && (
|
|
194
|
-
<p className="mb-2 line-clamp-2 text-sm text-gray-600">
|
|
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">
|
|
195
281
|
{item.description}
|
|
196
282
|
</p>
|
|
197
283
|
)}
|
|
198
|
-
|
|
199
284
|
{item.topics && item.topics.length > 0 && (
|
|
200
285
|
<div className="mb-2 flex flex-wrap gap-1">
|
|
201
|
-
{item.topics.slice(0, 3).map((topic) => (
|
|
286
|
+
{item.topics.slice(0, 3).map((topic, idx) => (
|
|
202
287
|
<span
|
|
203
|
-
key={
|
|
204
|
-
className="
|
|
288
|
+
key={idx}
|
|
289
|
+
className="bg-myoffwhite text-mydarkgrey rounded px-2 py-1 text-xs"
|
|
205
290
|
>
|
|
206
291
|
{topic}
|
|
207
292
|
</span>
|
|
208
293
|
))}
|
|
209
294
|
{item.topics.length > 3 && (
|
|
210
|
-
<span className="text-
|
|
295
|
+
<span className="text-mydarkgrey text-xs">
|
|
211
296
|
+{item.topics.length - 3} more
|
|
212
297
|
</span>
|
|
213
298
|
)}
|
|
214
299
|
</div>
|
|
215
300
|
)}
|
|
216
|
-
|
|
217
|
-
<p className="truncate text-xs text-gray-500">
|
|
218
|
-
{item.url}
|
|
219
|
-
</p>
|
|
220
|
-
</div>
|
|
221
|
-
|
|
222
|
-
<div className="hidden flex-shrink-0 text-right md:block">
|
|
223
|
-
<div className="mb-2">
|
|
224
|
-
{getResultBadge(item.type, item.categorySlug)}
|
|
225
|
-
</div>
|
|
226
301
|
</div>
|
|
227
302
|
</div>
|
|
228
303
|
</div>
|
|
@@ -233,46 +308,51 @@ export default function SearchResults({
|
|
|
233
308
|
</div>
|
|
234
309
|
|
|
235
310
|
{totalPages > 1 && (
|
|
236
|
-
<div className="flex
|
|
311
|
+
<div className="flex justify-center">
|
|
237
312
|
<Pagination.Root
|
|
238
|
-
count={
|
|
313
|
+
count={totalResults}
|
|
239
314
|
pageSize={ITEMS_PER_PAGE}
|
|
240
315
|
page={currentPage}
|
|
241
316
|
onPageChange={(details) => handlePageChange(details.page)}
|
|
242
317
|
>
|
|
243
|
-
<Pagination.PrevTrigger className="text-mydarkgrey flex items-center
|
|
244
|
-
<ChevronLeftIcon className="
|
|
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" />
|
|
245
320
|
Previous
|
|
246
321
|
</Pagination.PrevTrigger>
|
|
247
322
|
|
|
248
|
-
<
|
|
249
|
-
|
|
250
|
-
pagination
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
+
)
|
|
268
348
|
)
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
</
|
|
349
|
+
}
|
|
350
|
+
</Pagination.Context>
|
|
351
|
+
</div>
|
|
272
352
|
|
|
273
|
-
<Pagination.NextTrigger className="text-mydarkgrey flex items-center
|
|
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">
|
|
274
354
|
Next
|
|
275
|
-
<ChevronRightIcon className="
|
|
355
|
+
<ChevronRightIcon className="h-4 w-6" />
|
|
276
356
|
</Pagination.NextTrigger>
|
|
277
357
|
</Pagination.Root>
|
|
278
358
|
</div>
|
|
@@ -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> {
|
|
@@ -22,6 +22,15 @@ export function getResourceImage(
|
|
|
22
22
|
return '/static.jpg';
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export function getResourceDescription(
|
|
26
|
+
id: string,
|
|
27
|
+
slug: string,
|
|
28
|
+
category: string
|
|
29
|
+
): string | null {
|
|
30
|
+
console.log(`please define getResourceDescription`, id, slug, category);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
// Initialize search data - override in custom implementation
|
|
26
35
|
export function initSearch(): void {
|
|
27
36
|
// Default implementation does nothing
|