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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-tractstack",
3
- "version": "2.0.0-rc.44",
3
+ "version": "2.0.0-rc.46",
4
4
  "description": "Astro integration for TractStack - redeeming the web from boring experiences",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -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
- ...(enableBunny && { 'bunny-video': true }),
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' && enableBunny ? (
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
- ...(enableBunny && { 'bunny-video': true }),
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' && enableBunny ? (
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 { FullContentMapItem } from '@/types/tractstack';
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
- isLoading,
31
- error,
32
- totalResults,
33
- executeSearch,
34
- clearResults,
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
- clearResults();
54
+ setSelectedTerms([]);
55
+ clearAll();
47
56
  }
48
- }, [isOpen, clearResults]);
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
- executeSearch(query.trim());
53
- } else {
54
- clearResults();
62
+ discoverTerms(query);
55
63
  }
56
- }, [query, executeSearch, clearResults]);
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
- clearResults();
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-5xl p-2 pt-16 md:p-4">
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
- <input
89
- ref={inputRef}
90
- type="text"
91
- value={query}
92
- onChange={handleInputChange}
93
- onKeyDown={handleKeyDown}
94
- placeholder="Search content..."
95
- className="text-mydarkgrey w-full border-none bg-transparent px-6 py-2 text-xl placeholder-gray-500 outline-none"
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
- {query.length < 3 && (
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
- Type at least 3 characters to search pages, context, and
117
- resources
246
+ Start typing to discover content suggestions
118
247
  </p>
119
248
  </div>
120
249
  )}
121
250
 
122
- {query.length >= 3 && isLoading && (
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">Searching...</p>
265
+ <p className="text-mydarkgrey mt-4">Discovering...</p>
126
266
  </div>
127
267
  )}
128
268
 
129
- {query.length >= 3 && error && (
269
+ {/* Discovery Error */}
270
+ {query.trim().length >= 3 && discoverError && (
130
271
  <div className="w-full p-8 text-center text-red-600">
131
- <p>Search failed: {error}</p>
272
+ <p>Discovery failed: {discoverError}</p>
132
273
  <button
133
- onClick={() => executeSearch(query.trim())}
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
- {query.length >= 3 &&
142
- !isLoading &&
143
- !error &&
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 for "{query}"</p>
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
- {query.length >= 3 &&
154
- !isLoading &&
155
- !error &&
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 type { SearchResults as SearchResultsType } from '@/hooks/useSearch';
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: SearchResultsType;
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
- results.storyFragmentIds.forEach((id) => {
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 === id && item.type === 'StoryFragment'
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
- results.contextPaneIds.forEach((id) => {
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 === id && item.type === 'Pane'
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
- results.resourceIds.forEach((id) => {
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 === id && item.type === 'Resource'
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 totalPages = Math.ceil(allResultItems.length / ITEMS_PER_PAGE);
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 = (details: { page: number }) => {
127
- setCurrentPage(details.page);
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 (allResultItems.length === 0) {
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
- {allResultItems.length} result{allResultItems.length !== 1 ? 's' : ''}{' '}
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, allResultItems.length)} of{' '}
169
- {allResultItems.length}
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} onClick={onResultClick} className="group block">
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 flex-shrink-0 overflow-hidden rounded-lg p-1 md:hidden"
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 rounded object-contain"
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 p-1 md:block"
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 rounded object-contain"
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
- <div className="mb-2">
215
- <h3 className="text-mydarkgrey group-hover:text-myblue line-clamp-2 font-bold transition-colors">
216
- {item.title}
217
- </h3>
218
- </div>
219
-
220
- {(item.type === 'StoryFragment' ||
221
- item.type === 'Resource') &&
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={topic}
233
- className="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs text-gray-700"
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-xs text-gray-500">
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="mt-8 flex justify-center">
309
+ <div className="flex justify-center">
271
310
  <Pagination.Root
272
- count={allResultItems.length}
311
+ count={totalResults}
273
312
  pageSize={ITEMS_PER_PAGE}
274
313
  page={currentPage}
275
- siblingCount={1}
276
- onPageChange={handlePageChange}
277
- className="flex flex-wrap items-center gap-2"
314
+ onPageChange={(details) => handlePageChange(details.page)}
278
315
  >
279
- <Pagination.Context>
280
- {(pagination) =>
281
- pagination.pages.map((page, index) =>
282
- page.type === 'page' ? (
283
- <Pagination.Item
284
- key={index}
285
- {...page}
286
- className={`cursor-pointer rounded-md px-3 py-2 text-sm ${
287
- page.value === currentPage
288
- ? 'bg-gray-900 text-white'
289
- : 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
290
- }`}
291
- >
292
- {page.value}
293
- </Pagination.Item>
294
- ) : (
295
- <Pagination.Ellipsis
296
- key={index}
297
- index={index}
298
- className="px-3 py-2 text-sm text-gray-500"
299
- >
300
-
301
- </Pagination.Ellipsis>
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
- </Pagination.Context>
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
- export interface SearchResults {
5
- storyFragmentIds: string[];
6
- contextPaneIds: string[];
7
- resourceIds: string[];
8
- }
3
+ import type {
4
+ DiscoverySuggestion,
5
+ CategorizedResults,
6
+ } from '@/types/tractstack';
9
7
 
10
8
  interface UseSearchReturn {
11
- searchResults: SearchResults;
12
- isLoading: boolean;
13
- error: string | null;
14
- totalResults: number;
15
- executeSearch: (query: string) => void;
16
- clearResults: () => void;
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 = 1000;
27
+ const BACKEND_THROTTLE_MS = 1200;
21
28
 
22
29
  export function useSearch(): UseSearchReturn {
23
- const [searchResults, setSearchResults] = useState<SearchResults>({
24
- storyFragmentIds: [],
25
- contextPaneIds: [],
26
- resourceIds: [],
27
- });
28
- const [isLoading, setIsLoading] = useState(false);
29
- const [error, setError] = useState<string | null>(null);
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 queuedQueryRef = useRef<string | null>(null);
34
- const queueTimeoutRef = useRef<NodeJS.Timeout>();
35
- const isFirstSearchRef = useRef<boolean>(true);
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 performSearch = useCallback(
50
+ const performDiscovery = useCallback(
40
51
  async (query: string) => {
41
- if (!query.trim() || query.trim().length < 3) {
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
- setIsLoading(true);
46
- setError(null);
62
+ // Mark this query as inflight
63
+ inflightQueryRef.current = query.trim();
47
64
 
48
- try {
49
- const response = await api.search(query.trim());
65
+ setIsDiscovering(true);
66
+ setDiscoverError(null);
50
67
 
51
- if (response.success && response.data) {
52
- setSearchResults(response.data);
53
- lastSearchTimeRef.current = Date.now();
54
- } else {
55
- // Handle 429 silently - backend is protecting itself
56
- if (response.error?.includes('too frequently')) {
57
- // Don't set error for rate limiting, just maintain loading state
58
- return;
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
- setError(err instanceof Error ? err.message : 'Search failed');
69
- setSearchResults({
70
- storyFragmentIds: [],
71
- contextPaneIds: [],
72
- resourceIds: [],
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
- setIsLoading(false);
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 processQueue = useCallback(() => {
82
- if (queuedQueryRef.current) {
83
- const queryToProcess = queuedQueryRef.current;
84
- queuedQueryRef.current = null;
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
- const executeSearchWithThrottling = useCallback(
90
- (query: string) => {
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
- // Schedule execution for when the throttle window expires
111
- const remainingTime = BACKEND_THROTTLE_MS - timeSinceLastSearch;
112
- queueTimeoutRef.current = setTimeout(processQueue, remainingTime);
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
- [performSearch, processQueue]
122
+ [api]
116
123
  );
117
124
 
118
- const executeSearch = useCallback(
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
- // Handle empty or short queries immediately
126
- if (!query.trim() || query.trim().length < 3) {
127
- setSearchResults({
128
- storyFragmentIds: [],
129
- contextPaneIds: [],
130
- resourceIds: [],
131
- });
132
- setError(null);
133
- setIsLoading(false);
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
- // Debounce the actual search execution
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
- executeSearchWithThrottling(query);
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
- [executeSearchWithThrottling]
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 clearResults = useCallback(() => {
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 (queueTimeoutRef.current) {
151
- clearTimeout(queueTimeoutRef.current);
222
+ if (throttleTimeoutRef.current) {
223
+ clearTimeout(throttleTimeoutRef.current);
152
224
  }
153
225
 
154
- // Reset state
155
- setSearchResults({
156
- storyFragmentIds: [],
157
- contextPaneIds: [],
158
- resourceIds: [],
159
- });
160
- setIsLoading(false);
161
- setError(null);
162
- queuedQueryRef.current = null;
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
- isLoading,
174
- error,
175
- totalResults,
176
- executeSearch,
177
- clearResults,
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 z-50 h-1 w-full scale-x-0 transform transition-transform duration-300 ease-out"
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 search(query: string): Promise<
142
- APIResponse<{
143
- storyFragmentIds: string[];
144
- contextPaneIds: string[];
145
- resourceIds: string[];
146
- }>
147
- > {
148
- return this.post('/api/v1/nodes/panes/search', { query });
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> {