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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-tractstack",
3
- "version": "2.0.0-rc.43",
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}
@@ -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
- <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,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 { SearchResults as SearchResultsType } from '@/hooks/useSearch';
4
+ import type { CategorizedResults, FTSResult } from '@/types/tractstack';
5
5
  import type { FullContentMapItem } from '@/types/tractstack';
6
- import { getResourceUrl, getResourceImage } from '@/utils/customHelpers';
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: SearchResultsType;
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
- 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);
41
57
  const item = contentMap.find(
42
- (item) => item.id === id && item.type === 'StoryFragment'
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
- results.contextPaneIds.forEach((id) => {
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 === id && item.type === 'Pane'
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
- results.resourceIds.forEach((id) => {
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 === id && item.type === 'Resource'
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 totalPages = Math.ceil(allResultItems.length / ITEMS_PER_PAGE);
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 (allResultItems.length === 0) {
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
- {allResultItems.length} result{allResultItems.length !== 1 ? 's' : ''}{' '}
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, allResultItems.length)} of{' '}
160
- {allResultItems.length}
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 hidden flex-shrink-0 overflow-hidden rounded-lg md:block"
174
- style={{ width: '120px', height: '67.5px' }}
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
- <div className="min-w-0 flex-1">
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
- <div className="mb-2">
188
- <h3 className="text-mydarkgrey group-hover:text-myblue line-clamp-2 font-bold transition-colors">
189
- {item.title}
190
- </h3>
191
- </div>
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={topic}
204
- 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"
205
290
  >
206
291
  {topic}
207
292
  </span>
208
293
  ))}
209
294
  {item.topics.length > 3 && (
210
- <span className="text-xs text-gray-500">
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 items-center space-x-1">
311
+ <div className="flex justify-center">
237
312
  <Pagination.Root
238
- count={allResultItems.length}
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 rounded-md border border-gray-300 px-3 py-2 text-sm hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50">
244
- <ChevronLeftIcon className="mr-1 h-4 w-4" />
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
- <Pagination.Context>
249
- {(pagination) =>
250
- pagination.pages.map((page, index) =>
251
- page.type === 'page' ? (
252
- <Pagination.Item
253
- key={index}
254
- value={page.value}
255
- type="page"
256
- className={`cursor-pointer rounded-md px-3 py-2 text-sm ${
257
- page.value === currentPage
258
- ? 'bg-myblue text-white'
259
- : 'text-mydarkgrey hover:bg-gray-50'
260
- }`}
261
- >
262
- {page.value}
263
- </Pagination.Item>
264
- ) : (
265
- <span key={index} className="px-2 text-gray-400">
266
- ...
267
- </span>
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
- </Pagination.Context>
349
+ }
350
+ </Pagination.Context>
351
+ </div>
272
352
 
273
- <Pagination.NextTrigger className="text-mydarkgrey flex items-center rounded-md border border-gray-300 px-3 py-2 text-sm hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50">
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="ml-1 h-4 w-4" />
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
- 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> {
@@ -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