astro-tractstack 2.0.0-rc.44 → 2.0.0-rc.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json 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.45",
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,57 @@ export default function SearchModal({
78
176
  >
79
177
  <Portal>
80
178
  <Dialog.Backdrop className="fixed inset-0 z-50 bg-black bg-opacity-50 backdrop-blur-sm" />
81
- <Dialog.Positioner className="fixed inset-0 z-50 mx-auto max-w-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">
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
- {query.length < 3 && (
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
- Type at least 3 characters to search pages, context, and
117
- resources
250
+ Start typing to discover content suggestions
118
251
  </p>
119
252
  </div>
120
253
  )}
121
254
 
122
- {query.length >= 3 && isLoading && (
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">Searching...</p>
269
+ <p className="text-mydarkgrey mt-4">Discovering...</p>
126
270
  </div>
127
271
  )}
128
272
 
129
- {query.length >= 3 && error && (
273
+ {/* Discovery Error */}
274
+ {query.trim().length >= 3 && discoverError && (
130
275
  <div className="w-full p-8 text-center text-red-600">
131
- <p>Search failed: {error}</p>
276
+ <p>Discovery failed: {discoverError}</p>
132
277
  <button
133
- onClick={() => executeSearch(query.trim())}
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
- {query.length >= 3 &&
142
- !isLoading &&
143
- !error &&
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 for "{query}"</p>
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
- {query.length >= 3 &&
154
- !isLoading &&
155
- !error &&
337
+ {/* Search Results */}
338
+ {!isRetrieving &&
339
+ !retrieveError &&
340
+ showResults &&
156
341
  totalResults > 0 && (
157
342
  <SearchResults
158
343
  results={searchResults}
@@ -1,6 +1,7 @@
1
1
  import { useState, useMemo } from 'react';
2
2
  import { Pagination } from '@ark-ui/react/pagination';
3
- import 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,8 +9,10 @@ import {
8
9
  getResourceDescription,
9
10
  } from '@/utils/customHelpers';
10
11
 
12
+ const VERBOSE = false;
13
+
11
14
  interface SearchResultsProps {
12
- results: SearchResultsType;
15
+ results: CategorizedResults;
13
16
  contentMap: FullContentMapItem[];
14
17
  onResultClick: () => void;
15
18
  }
@@ -40,11 +43,26 @@ export default function SearchResults({
40
43
  const allResultItems = useMemo(() => {
41
44
  const items: ResultItem[] = [];
42
45
 
43
- results.storyFragmentIds.forEach((id) => {
46
+ if (VERBOSE)
47
+ console.log('DEBUG SearchResults: Processing results', {
48
+ storyFragmentResults: results.storyFragmentResults.length,
49
+ contextPaneResults: results.contextPaneResults.length,
50
+ resourceResults: results.resourceResults.length,
51
+ contentMapSize: contentMap.length,
52
+ });
53
+
54
+ // Process StoryFragment results
55
+ results.storyFragmentResults.forEach((ftsResult: FTSResult, index) => {
56
+ if (VERBOSE) console.log(`DEBUG StoryFragment ${index}:`, ftsResult);
44
57
  const item = contentMap.find(
45
- (item) => item.id === id && item.type === 'StoryFragment'
58
+ (item) => item.id === ftsResult.ID && item.type === 'StoryFragment'
46
59
  );
47
60
  if (item) {
61
+ if (VERBOSE)
62
+ console.log(
63
+ `DEBUG StoryFragment ${index}: Found in contentMap`,
64
+ item
65
+ );
48
66
  items.push({
49
67
  id: item.id,
50
68
  type: 'StoryFragment',
@@ -57,14 +75,23 @@ export default function SearchResults({
57
75
  url: `/${item.slug}`,
58
76
  imageSrc: item.thumbSrc || '/static.jpg',
59
77
  });
78
+ } else {
79
+ if (VERBOSE)
80
+ console.log(
81
+ `DEBUG StoryFragment ${index}: NOT found in contentMap for ID ${ftsResult.ID}`
82
+ );
60
83
  }
61
84
  });
62
85
 
63
- results.contextPaneIds.forEach((id) => {
86
+ // Process ContextPane results
87
+ results.contextPaneResults.forEach((ftsResult: FTSResult, index) => {
88
+ if (VERBOSE) console.log(`DEBUG ContextPane ${index}:`, ftsResult);
64
89
  const item = contentMap.find(
65
- (item) => item.id === id && item.type === 'Pane'
90
+ (item) => item.id === ftsResult.ID && item.type === 'Pane'
66
91
  );
67
92
  if (item) {
93
+ if (VERBOSE)
94
+ console.log(`DEBUG ContextPane ${index}: Found in contentMap`, item);
68
95
  items.push({
69
96
  id: item.id,
70
97
  type: 'ContextPane',
@@ -73,14 +100,24 @@ export default function SearchResults({
73
100
  url: `/context/${item.slug}`,
74
101
  imageSrc: '/static.jpg',
75
102
  });
103
+ } else {
104
+ if (VERBOSE)
105
+ console.log(
106
+ `DEBUG ContextPane ${index}: NOT found in contentMap for ID ${ftsResult.ID}`
107
+ );
76
108
  }
77
109
  });
78
110
 
79
- results.resourceIds.forEach((id) => {
111
+ // Process Resource results
112
+ results.resourceResults.forEach((ftsResult: FTSResult, index) => {
113
+ if (VERBOSE) console.log(`DEBUG Resource ${index}:`, ftsResult);
80
114
  const item = contentMap.find(
81
- (item) => item.id === id && item.type === 'Resource'
115
+ (item) => item.id === ftsResult.ID && item.type === 'Resource'
82
116
  );
83
117
  if (item) {
118
+ if (VERBOSE)
119
+ console.log(`DEBUG Resource ${index}: Found in contentMap`, item);
120
+
84
121
  const resourceUrl = getResourceUrl(item.categorySlug || '', item.slug);
85
122
  const resourceImage = getResourceImage(
86
123
  item.id,
@@ -93,6 +130,16 @@ export default function SearchResults({
93
130
  item.categorySlug || ''
94
131
  );
95
132
 
133
+ if (VERBOSE)
134
+ console.log(`DEBUG Resource ${index}: Helper results`, {
135
+ resourceUrl,
136
+ resourceImage,
137
+ description,
138
+ categorySlug: item.categorySlug,
139
+ slug: item.slug,
140
+ id: item.id,
141
+ });
142
+
96
143
  items.push({
97
144
  id: item.id,
98
145
  type: 'Resource',
@@ -103,9 +150,24 @@ export default function SearchResults({
103
150
  url: resourceUrl,
104
151
  imageSrc: resourceImage,
105
152
  });
153
+ } else {
154
+ if (VERBOSE)
155
+ console.log(
156
+ `DEBUG Resource ${index}: NOT found in contentMap for ID ${ftsResult.ID}`
157
+ );
158
+ if (VERBOSE)
159
+ console.log(
160
+ 'DEBUG: Available resource IDs in contentMap:',
161
+ contentMap
162
+ .filter((item) => item.type === 'Resource')
163
+ .map((item) => ({ id: item.id, title: item.title }))
164
+ );
106
165
  }
107
166
  });
108
167
 
168
+ if (VERBOSE) console.log('DEBUG SearchResults: Final items', items);
169
+
170
+ // Sort by whether they have real images
109
171
  return items.sort((a, b) => {
110
172
  const aHasRealImage = a.imageSrc !== '/static.jpg';
111
173
  const bHasRealImage = b.imageSrc !== '/static.jpg';
@@ -114,17 +176,18 @@ export default function SearchResults({
114
176
  if (!aHasRealImage && bHasRealImage) return 1;
115
177
  return 0;
116
178
  });
117
- }, [results]);
179
+ }, [results, contentMap]);
118
180
 
119
- const totalPages = Math.ceil(allResultItems.length / ITEMS_PER_PAGE);
181
+ const totalResults = allResultItems.length;
182
+ const totalPages = Math.ceil(totalResults / ITEMS_PER_PAGE);
120
183
  const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
121
184
  const paginatedItems = allResultItems.slice(
122
185
  startIndex,
123
186
  startIndex + ITEMS_PER_PAGE
124
187
  );
125
188
 
126
- const handlePageChange = (details: { page: number }) => {
127
- setCurrentPage(details.page);
189
+ const handlePageChange = (page: number) => {
190
+ setCurrentPage(page);
128
191
  };
129
192
 
130
193
  const getResultBadge = (type: string, categorySlug?: string) => {
@@ -152,7 +215,7 @@ export default function SearchResults({
152
215
  }
153
216
  };
154
217
 
155
- if (allResultItems.length === 0) {
218
+ if (totalResults === 0) {
156
219
  return null;
157
220
  }
158
221
 
@@ -160,13 +223,12 @@ export default function SearchResults({
160
223
  <div className="p-6">
161
224
  <div className="mb-6">
162
225
  <h2 className="text-mydarkgrey text-lg font-bold">
163
- {allResultItems.length} result{allResultItems.length !== 1 ? 's' : ''}{' '}
164
- found
226
+ {totalResults} result{totalResults !== 1 ? 's' : ''} found
165
227
  </h2>
166
228
  <p className="mt-1 text-sm text-gray-600">
167
229
  Showing {startIndex + 1}-
168
- {Math.min(startIndex + ITEMS_PER_PAGE, allResultItems.length)} of{' '}
169
- {allResultItems.length}
230
+ {Math.min(startIndex + ITEMS_PER_PAGE, totalResults)} of{' '}
231
+ {totalResults}
170
232
  </p>
171
233
  </div>
172
234
 
@@ -177,86 +239,65 @@ export default function SearchResults({
177
239
  className="rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-100"
178
240
  >
179
241
  <a href={item.url} onClick={onResultClick} className="group block">
180
- <div className="flex items-start gap-4">
242
+ <div className="flex flex-col md:flex-row md:items-start md:gap-4">
243
+ {/* Mobile: Full width image with overlay badge */}
181
244
  <div
182
- className="bg-mydarkgrey 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"
245
+ className="bg-mydarkgrey relative w-full overflow-hidden rounded-lg md:hidden"
246
+ style={{ aspectRatio: '1200/630' }}
188
247
  >
189
248
  <img
190
249
  src={item.imageSrc}
191
250
  alt={item.title}
192
- className="h-full w-full rounded object-contain"
251
+ className="h-full w-full object-contain"
193
252
  />
253
+ <div className="absolute left-2 top-2">
254
+ {getResultBadge(item.type, item.categorySlug)}
255
+ </div>
194
256
  </div>
195
257
 
258
+ {/* Desktop: Side image with overlay badge */}
196
259
  <div
197
- className="bg-mydarkgrey hidden flex-shrink-0 overflow-hidden rounded-lg p-1 md:block"
198
- style={{
199
- width: '240px',
200
- height: '135px',
201
- }}
202
- data-desktop-size="240x135"
260
+ className="bg-mydarkgrey relative hidden flex-shrink-0 overflow-hidden rounded-lg md:block"
261
+ style={{ width: '240px', height: '135px' }}
203
262
  >
204
263
  <img
205
264
  src={item.imageSrc}
206
265
  alt={item.title}
207
- className="h-full w-full rounded object-contain"
266
+ className="h-full w-full object-contain"
208
267
  />
268
+ <div className="absolute left-2 top-2">
269
+ {getResultBadge(item.type, item.categorySlug)}
270
+ </div>
209
271
  </div>
210
272
 
211
- <div className="min-w-0 flex-1">
273
+ <div className="mt-3 min-w-0 flex-1 md:mt-0">
212
274
  <div className="flex items-start justify-between gap-4">
213
275
  <div className="flex-1">
214
- <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
-
276
+ <h3 className="text-mydarkgrey group-hover:text-myblue mb-2 text-lg font-semibold transition-colors">
277
+ {item.title}
278
+ </h3>
279
+ {item.description && (
280
+ <p className="text-mydarkgrey mb-2 text-sm">
281
+ {item.description}
282
+ </p>
283
+ )}
228
284
  {item.topics && item.topics.length > 0 && (
229
285
  <div className="mb-2 flex flex-wrap gap-1">
230
- {item.topics.slice(0, 3).map((topic) => (
286
+ {item.topics.slice(0, 3).map((topic, idx) => (
231
287
  <span
232
- key={topic}
233
- className="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs text-gray-700"
288
+ key={idx}
289
+ className="bg-myoffwhite text-mydarkgrey rounded px-2 py-1 text-xs"
234
290
  >
235
291
  {topic}
236
292
  </span>
237
293
  ))}
238
294
  {item.topics.length > 3 && (
239
- <span className="text-xs text-gray-500">
295
+ <span className="text-mydarkgrey text-xs">
240
296
  +{item.topics.length - 3} more
241
297
  </span>
242
298
  )}
243
299
  </div>
244
300
  )}
245
-
246
- <p className="truncate text-xs text-gray-500">
247
- {item.url}
248
- </p>
249
-
250
- {/* Mobile badge row */}
251
- <div className="mt-2 block md:hidden">
252
- {getResultBadge(item.type, item.categorySlug)}
253
- </div>
254
- </div>
255
-
256
- <div className="hidden flex-shrink-0 text-right md:block">
257
- <div className="mb-2">
258
- {getResultBadge(item.type, item.categorySlug)}
259
- </div>
260
301
  </div>
261
302
  </div>
262
303
  </div>
@@ -267,42 +308,52 @@ export default function SearchResults({
267
308
  </div>
268
309
 
269
310
  {totalPages > 1 && (
270
- <div className="mt-8 flex justify-center">
311
+ <div className="flex justify-center">
271
312
  <Pagination.Root
272
- count={allResultItems.length}
313
+ count={totalResults}
273
314
  pageSize={ITEMS_PER_PAGE}
274
315
  page={currentPage}
275
- siblingCount={1}
276
- onPageChange={handlePageChange}
277
- className="flex flex-wrap items-center gap-2"
316
+ onPageChange={(details) => handlePageChange(details.page)}
278
317
  >
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>
318
+ <Pagination.PrevTrigger className="text-mydarkgrey hover:text-myblue mr-2 flex items-center gap-1 rounded px-3 py-2 text-sm font-medium transition-colors disabled:opacity-50">
319
+ <ChevronLeftIcon className="h-4 w-4" />
320
+ Previous
321
+ </Pagination.PrevTrigger>
322
+
323
+ <div className="flex items-center gap-1">
324
+ <Pagination.Context>
325
+ {(pagination) =>
326
+ pagination.pages.map((page, index) =>
327
+ page.type === 'page' ? (
328
+ <Pagination.Item
329
+ key={index}
330
+ type="page"
331
+ value={page.value}
332
+ className={`rounded px-3 py-2 text-sm font-medium transition-colors ${
333
+ page.value === currentPage
334
+ ? 'bg-myblue text-white'
335
+ : 'text-mydarkgrey hover:text-myblue'
336
+ }`}
337
+ >
338
+ {page.value}
339
+ </Pagination.Item>
340
+ ) : (
341
+ <span
342
+ key={index}
343
+ className="text-mydarkgrey px-2 text-sm"
344
+ >
345
+ {page.type === 'ellipsis' ? '...' : ''}
346
+ </span>
347
+ )
302
348
  )
303
- )
304
- }
305
- </Pagination.Context>
349
+ }
350
+ </Pagination.Context>
351
+ </div>
352
+
353
+ <Pagination.NextTrigger className="text-mydarkgrey hover:text-myblue ml-2 flex items-center gap-1 rounded px-3 py-2 text-sm font-medium transition-colors disabled:opacity-50">
354
+ Next
355
+ <ChevronRightIcon className="h-4 w-6" />
356
+ </Pagination.NextTrigger>
306
357
  </Pagination.Root>
307
358
  </div>
308
359
  )}
@@ -1,179 +1,224 @@
1
1
  import { useState, useCallback, useRef, useMemo } from 'react';
2
2
  import { TractStackAPI } from '@/utils/api';
3
-
4
- 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
 
31
42
  const debounceRef = useRef<NodeJS.Timeout>();
32
43
  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
-
44
+ const pendingQueryRef = useRef<string | null>(null);
45
+ const throttleTimeoutRef = useRef<NodeJS.Timeout>();
37
46
  const api = useMemo(() => new TractStackAPI(), []);
38
47
 
39
- const performSearch = useCallback(
48
+ const performDiscovery = useCallback(
40
49
  async (query: string) => {
41
- if (!query.trim() || query.trim().length < 3) {
50
+ if (!query.trim()) {
51
+ setSuggestions([]);
42
52
  return;
43
53
  }
44
54
 
45
- setIsLoading(true);
46
- setError(null);
55
+ setIsDiscovering(true);
56
+ setDiscoverError(null);
57
+ lastSearchTimeRef.current = Date.now();
47
58
 
48
59
  try {
49
- const response = await api.search(query.trim());
60
+ const response = await api.discover(query.trim());
50
61
 
51
62
  if (response.success && response.data) {
52
- setSearchResults(response.data);
53
- lastSearchTimeRef.current = Date.now();
63
+ setSuggestions(response.data.suggestions);
54
64
  } 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;
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
- setError(err instanceof Error ? err.message : 'Search failed');
69
- setSearchResults({
70
- storyFragmentIds: [],
71
- contextPaneIds: [],
72
- resourceIds: [],
73
- });
69
+ setDiscoverError(
70
+ err instanceof Error ? err.message : 'Discovery failed'
71
+ );
72
+ setSuggestions([]);
74
73
  } finally {
75
- setIsLoading(false);
74
+ setIsDiscovering(false);
76
75
  }
77
76
  },
78
77
  [api]
79
78
  );
80
79
 
81
- const processQueue = useCallback(() => {
82
- if (queuedQueryRef.current) {
83
- const queryToProcess = queuedQueryRef.current;
84
- queuedQueryRef.current = null;
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
- 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
- }
85
+ try {
86
+ const response = await api.retrieve(term, isTopic);
109
87
 
110
- // Schedule execution for when the throttle window expires
111
- const remainingTime = BACKEND_THROTTLE_MS - timeSinceLastSearch;
112
- queueTimeoutRef.current = setTimeout(processQueue, remainingTime);
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
- [performSearch, processQueue]
103
+ [api]
116
104
  );
117
105
 
118
- const executeSearch = useCallback(
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
- // 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);
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
- // Debounce the actual search execution
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
- executeSearchWithThrottling(query);
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
- [executeSearchWithThrottling]
160
+ [executePendingSearch]
143
161
  );
144
162
 
145
- const clearResults = useCallback(() => {
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 (queueTimeoutRef.current) {
151
- clearTimeout(queueTimeoutRef.current);
198
+ if (throttleTimeoutRef.current) {
199
+ clearTimeout(throttleTimeoutRef.current);
152
200
  }
153
201
 
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;
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
- isLoading,
174
- error,
175
- totalResults,
176
- executeSearch,
177
- clearResults,
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 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> {