astro-tractstack 2.0.0-rc.35 → 2.0.0-rc.37
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/dist/index.js +22 -0
- package/package.json +1 -1
- package/templates/src/components/Header.astro +8 -8
- package/templates/src/components/search/SearchModal.tsx +164 -0
- package/templates/src/components/search/SearchResults.tsx +275 -0
- package/templates/src/components/search/SearchWrapper.tsx +41 -0
- package/templates/src/hooks/useSearch.ts +179 -0
- package/templates/src/pages/storykeep/advanced.astro +1 -1
- package/templates/src/pages/storykeep/branding.astro +1 -1
- package/templates/src/pages/storykeep/content.astro +1 -1
- package/templates/src/utils/api.ts +15 -0
- package/templates/src/utils/customHelpers.ts +23 -0
- package/utils/inject-files.ts +22 -1
package/dist/index.js
CHANGED
|
@@ -893,6 +893,23 @@ async function w(t, e, c) {
|
|
|
893
893
|
src: t("../templates/src/components/Fragment.astro"),
|
|
894
894
|
dest: "src/components/Fragment.astro"
|
|
895
895
|
},
|
|
896
|
+
// Search Components
|
|
897
|
+
{
|
|
898
|
+
src: t("../templates/src/components/search/SearchWrapper.tsx"),
|
|
899
|
+
dest: "src/components/search/SearchWrapper.tsx"
|
|
900
|
+
},
|
|
901
|
+
{
|
|
902
|
+
src: t("../templates/src/components/search/SearchModal.tsx"),
|
|
903
|
+
dest: "src/components/search/SearchModal.tsx"
|
|
904
|
+
},
|
|
905
|
+
{
|
|
906
|
+
src: t("../templates/src/components/search/SearchResults.tsx"),
|
|
907
|
+
dest: "src/components/search/SearchResults.tsx"
|
|
908
|
+
},
|
|
909
|
+
{
|
|
910
|
+
src: t("../templates/src/hooks/useSearch.ts"),
|
|
911
|
+
dest: "src/hooks/useSearch.ts"
|
|
912
|
+
},
|
|
896
913
|
// Profile Components
|
|
897
914
|
{
|
|
898
915
|
src: t("../templates/src/components/profile/ProfileConsent.tsx"),
|
|
@@ -2015,6 +2032,11 @@ async function w(t, e, c) {
|
|
|
2015
2032
|
dest: "src/custom/CustomRoutes.astro",
|
|
2016
2033
|
protected: !0
|
|
2017
2034
|
},
|
|
2035
|
+
{
|
|
2036
|
+
src: t("../templates/src/utils/customHelpers.ts"),
|
|
2037
|
+
dest: "src/utils/customHelpers.ts",
|
|
2038
|
+
protected: !0
|
|
2039
|
+
},
|
|
2018
2040
|
// Example Components (Conditional)
|
|
2019
2041
|
...c?.includeExamples ? [
|
|
2020
2042
|
{
|
package/package.json
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Menu from './Menu';
|
|
3
|
+
import SearchWrapper from '@/components/search/SearchWrapper';
|
|
4
|
+
import { getFullContentMap } from '@/stores/analytics';
|
|
3
5
|
import { isAuthenticated, isAdmin, getUserRole } from '@/utils/auth';
|
|
4
6
|
import ImpressionWrapper from '@/components/widgets/ImpressionWrapper';
|
|
5
7
|
import type { MenuNode } from '@/types/tractstack';
|
|
@@ -33,6 +35,10 @@ const {
|
|
|
33
35
|
|
|
34
36
|
const isHome = slug === brandConfig?.HOME_SLUG;
|
|
35
37
|
|
|
38
|
+
const tenantId =
|
|
39
|
+
Astro.locals.tenant?.id || import.meta.env.PUBLIC_TENANTID || 'default';
|
|
40
|
+
const fullContentMap = await getFullContentMap(tenantId);
|
|
41
|
+
|
|
36
42
|
const getAssetPath = (configPath: string, fallback: string) => {
|
|
37
43
|
// Always prioritize brandConfig values when they exist
|
|
38
44
|
if (configPath && configPath !== '') {
|
|
@@ -40,11 +46,8 @@ const getAssetPath = (configPath: string, fallback: string) => {
|
|
|
40
46
|
}
|
|
41
47
|
return fallback;
|
|
42
48
|
};
|
|
43
|
-
|
|
44
49
|
const logo = getAssetPath(brandConfig?.LOGO, '/brand/logo.svg');
|
|
45
50
|
const wordmark = getAssetPath(brandConfig?.WORDMARK, '/brand/wordmark.svg');
|
|
46
|
-
|
|
47
|
-
// Handle empty WORDMARK_MODE by defaulting to "default"
|
|
48
51
|
const wordmarkMode =
|
|
49
52
|
brandConfig?.WORDMARK_MODE && brandConfig.WORDMARK_MODE !== ''
|
|
50
53
|
? brandConfig.WORDMARK_MODE
|
|
@@ -108,7 +111,6 @@ const authStatus = {
|
|
|
108
111
|
>
|
|
109
112
|
<h1 class="text-mydarkgrey truncate text-xl">{title}</h1>
|
|
110
113
|
<div class="flex flex-row flex-nowrap items-center gap-x-2">
|
|
111
|
-
{/* Home Icon */}
|
|
112
114
|
{
|
|
113
115
|
!isHome ? (
|
|
114
116
|
<a
|
|
@@ -133,7 +135,6 @@ const authStatus = {
|
|
|
133
135
|
) : null
|
|
134
136
|
}
|
|
135
137
|
|
|
136
|
-
{/* StoryKeep Dashboard Icon */}
|
|
137
138
|
{
|
|
138
139
|
authStatus.isAdmin || authStatus.isAuthenticated ? (
|
|
139
140
|
<a
|
|
@@ -158,7 +159,6 @@ const authStatus = {
|
|
|
158
159
|
) : null
|
|
159
160
|
}
|
|
160
161
|
|
|
161
|
-
{/* Edit Icon */}
|
|
162
162
|
{
|
|
163
163
|
isEditable &&
|
|
164
164
|
(authStatus.isAdmin || authStatus.userRole === 'editor') ? (
|
|
@@ -185,7 +185,8 @@ const authStatus = {
|
|
|
185
185
|
) : null
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
-
{
|
|
188
|
+
<SearchWrapper contentMap={fullContentMap} client:load />
|
|
189
|
+
|
|
189
190
|
<script is:inline define:vars={{ sessionId }}>
|
|
190
191
|
function initRememberMe() {
|
|
191
192
|
const consent = localStorage.getItem('tractstack_consent') === '1';
|
|
@@ -247,7 +248,6 @@ const authStatus = {
|
|
|
247
248
|
) : null
|
|
248
249
|
}
|
|
249
250
|
|
|
250
|
-
{/* Logout Icon - Admin/Editor Only */}
|
|
251
251
|
{
|
|
252
252
|
authStatus.isAdmin || authStatus.userRole === 'editor' ? (
|
|
253
253
|
<a
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { Dialog } from '@ark-ui/react/dialog';
|
|
3
|
+
import { Portal } from '@ark-ui/react/portal';
|
|
4
|
+
import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
|
5
|
+
import { useSearch } from '@/hooks/useSearch';
|
|
6
|
+
import SearchResults from './SearchResults';
|
|
7
|
+
import type { FullContentMapItem } from '@/types/tractstack';
|
|
8
|
+
|
|
9
|
+
interface SearchModalProps {
|
|
10
|
+
isOpen: boolean;
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
contentMap: FullContentMapItem[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function SearchModal({
|
|
16
|
+
isOpen,
|
|
17
|
+
onClose,
|
|
18
|
+
contentMap,
|
|
19
|
+
}: SearchModalProps) {
|
|
20
|
+
const [query, setQuery] = useState('');
|
|
21
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
22
|
+
const {
|
|
23
|
+
searchResults,
|
|
24
|
+
isLoading,
|
|
25
|
+
error,
|
|
26
|
+
totalResults,
|
|
27
|
+
executeSearch,
|
|
28
|
+
clearResults,
|
|
29
|
+
} = useSearch();
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (isOpen && inputRef.current) {
|
|
33
|
+
inputRef.current.focus();
|
|
34
|
+
}
|
|
35
|
+
}, [isOpen]);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!isOpen) {
|
|
39
|
+
setQuery('');
|
|
40
|
+
clearResults();
|
|
41
|
+
}
|
|
42
|
+
}, [isOpen, clearResults]);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (query.trim().length >= 3) {
|
|
46
|
+
executeSearch(query.trim());
|
|
47
|
+
} else {
|
|
48
|
+
clearResults();
|
|
49
|
+
}
|
|
50
|
+
}, [query, executeSearch, clearResults]);
|
|
51
|
+
|
|
52
|
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
53
|
+
setQuery(e.target.value);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const handleClose = () => {
|
|
57
|
+
setQuery('');
|
|
58
|
+
clearResults();
|
|
59
|
+
onClose();
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
63
|
+
if (e.key === 'Escape') {
|
|
64
|
+
handleClose();
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Dialog.Root
|
|
70
|
+
open={isOpen}
|
|
71
|
+
onOpenChange={(details) => !details.open && handleClose()}
|
|
72
|
+
>
|
|
73
|
+
<Portal>
|
|
74
|
+
<Dialog.Backdrop className="fixed inset-0 z-50 bg-black bg-opacity-50 backdrop-blur-sm" />
|
|
75
|
+
<Dialog.Positioner className="fixed inset-0 z-50 flex items-start justify-center p-4 pt-16">
|
|
76
|
+
<Dialog.Content
|
|
77
|
+
className="bg-mywhite w-full max-w-4xl overflow-hidden rounded-lg shadow-2xl"
|
|
78
|
+
style={{ height: '80vh', display: 'flex', flexDirection: 'column' }}
|
|
79
|
+
>
|
|
80
|
+
{/* Fixed Header */}
|
|
81
|
+
<div
|
|
82
|
+
className="flex items-center gap-4 border-b border-gray-200 p-6"
|
|
83
|
+
style={{ flexShrink: 0 }}
|
|
84
|
+
>
|
|
85
|
+
<MagnifyingGlassIcon className="text-mydarkgrey h-6 w-6" />
|
|
86
|
+
<input
|
|
87
|
+
ref={inputRef}
|
|
88
|
+
type="text"
|
|
89
|
+
value={query}
|
|
90
|
+
onChange={handleInputChange}
|
|
91
|
+
onKeyDown={handleKeyDown}
|
|
92
|
+
placeholder="Search content..."
|
|
93
|
+
className="text-mydarkgrey flex-1 border-none bg-transparent px-4 text-xl placeholder-gray-500 outline-none"
|
|
94
|
+
/>
|
|
95
|
+
<button
|
|
96
|
+
onClick={handleClose}
|
|
97
|
+
className="text-mydarkgrey hover:text-myblue rounded-lg p-2 transition-colors hover:bg-gray-100"
|
|
98
|
+
aria-label="Close search"
|
|
99
|
+
>
|
|
100
|
+
<XMarkIcon className="h-6 w-6" />
|
|
101
|
+
</button>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
{/* Scrollable Content Area */}
|
|
105
|
+
<div className="overflow-y-auto" style={{ flex: 1 }}>
|
|
106
|
+
{query.length < 3 && (
|
|
107
|
+
<div className="p-8 text-center text-gray-500">
|
|
108
|
+
<MagnifyingGlassIcon className="mx-auto mb-4 h-16 w-16 text-gray-300" />
|
|
109
|
+
<p className="text-lg">Search across all content</p>
|
|
110
|
+
<p className="mt-2 text-sm">
|
|
111
|
+
Type at least 3 characters to search pages, context, and
|
|
112
|
+
resources
|
|
113
|
+
</p>
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
{query.length >= 3 && isLoading && (
|
|
118
|
+
<div className="p-8 text-center">
|
|
119
|
+
<div className="border-myblue inline-block h-8 w-8 animate-spin rounded-full border-b-2"></div>
|
|
120
|
+
<p className="text-mydarkgrey mt-4">Searching...</p>
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
{query.length >= 3 && error && (
|
|
125
|
+
<div className="p-8 text-center text-red-600">
|
|
126
|
+
<p>Search failed: {error}</p>
|
|
127
|
+
<button
|
|
128
|
+
onClick={() => executeSearch(query.trim())}
|
|
129
|
+
className="text-myblue mt-2 hover:underline"
|
|
130
|
+
>
|
|
131
|
+
Try again
|
|
132
|
+
</button>
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
|
|
136
|
+
{query.length >= 3 &&
|
|
137
|
+
!isLoading &&
|
|
138
|
+
!error &&
|
|
139
|
+
totalResults === 0 && (
|
|
140
|
+
<div className="p-8 text-center text-gray-500">
|
|
141
|
+
<p className="text-lg">No results found for "{query}"</p>
|
|
142
|
+
<p className="mt-2 text-sm">
|
|
143
|
+
Try different keywords or check your spelling
|
|
144
|
+
</p>
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
|
|
148
|
+
{query.length >= 3 &&
|
|
149
|
+
!isLoading &&
|
|
150
|
+
!error &&
|
|
151
|
+
totalResults > 0 && (
|
|
152
|
+
<SearchResults
|
|
153
|
+
results={searchResults}
|
|
154
|
+
contentMap={contentMap}
|
|
155
|
+
onResultClick={handleClose}
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
</Dialog.Content>
|
|
160
|
+
</Dialog.Positioner>
|
|
161
|
+
</Portal>
|
|
162
|
+
</Dialog.Root>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { useState, useMemo } from 'react';
|
|
2
|
+
import { Pagination } from '@ark-ui/react/pagination';
|
|
3
|
+
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
|
4
|
+
import type { SearchResults as SearchResultsType } from '@/hooks/useSearch';
|
|
5
|
+
import type { FullContentMapItem } from '@/types/tractstack';
|
|
6
|
+
import { getResourceUrl, getResourceImage } from '@/utils/customHelpers';
|
|
7
|
+
|
|
8
|
+
interface SearchResultsProps {
|
|
9
|
+
results: SearchResultsType;
|
|
10
|
+
contentMap: FullContentMapItem[];
|
|
11
|
+
onResultClick: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ResultItem {
|
|
15
|
+
id: string;
|
|
16
|
+
type: 'StoryFragment' | 'ContextPane' | 'Resource';
|
|
17
|
+
title: string;
|
|
18
|
+
slug: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
topics?: string[];
|
|
21
|
+
changed?: string;
|
|
22
|
+
thumbSrc?: string;
|
|
23
|
+
categorySlug?: string;
|
|
24
|
+
url: string;
|
|
25
|
+
imageSrc: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ITEMS_PER_PAGE = 10;
|
|
29
|
+
|
|
30
|
+
export default function SearchResults({
|
|
31
|
+
results,
|
|
32
|
+
contentMap,
|
|
33
|
+
onResultClick,
|
|
34
|
+
}: SearchResultsProps) {
|
|
35
|
+
const [currentPage, setCurrentPage] = useState(1);
|
|
36
|
+
|
|
37
|
+
const allResultItems = useMemo(() => {
|
|
38
|
+
const items: ResultItem[] = [];
|
|
39
|
+
|
|
40
|
+
results.storyFragmentIds.forEach((id) => {
|
|
41
|
+
const item = contentMap.find(
|
|
42
|
+
(item) => item.id === id && item.type === 'StoryFragment'
|
|
43
|
+
);
|
|
44
|
+
if (item) {
|
|
45
|
+
items.push({
|
|
46
|
+
id: item.id,
|
|
47
|
+
type: 'StoryFragment',
|
|
48
|
+
title: item.title,
|
|
49
|
+
slug: item.slug,
|
|
50
|
+
description: item.description || undefined,
|
|
51
|
+
topics: item.topics || undefined,
|
|
52
|
+
changed: item.changed || undefined,
|
|
53
|
+
thumbSrc: item.thumbSrc || undefined,
|
|
54
|
+
url: `/${item.slug}`,
|
|
55
|
+
imageSrc: item.thumbSrc || '/static.jpg',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
results.contextPaneIds.forEach((id) => {
|
|
61
|
+
const item = contentMap.find(
|
|
62
|
+
(item) => item.id === id && item.type === 'Pane'
|
|
63
|
+
);
|
|
64
|
+
if (item) {
|
|
65
|
+
items.push({
|
|
66
|
+
id: item.id,
|
|
67
|
+
type: 'ContextPane',
|
|
68
|
+
title: item.title,
|
|
69
|
+
slug: item.slug,
|
|
70
|
+
url: `/context/${item.slug}`,
|
|
71
|
+
imageSrc: '/static.jpg',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
results.resourceIds.forEach((id) => {
|
|
77
|
+
const item = contentMap.find(
|
|
78
|
+
(item) => item.id === id && item.type === 'Resource'
|
|
79
|
+
);
|
|
80
|
+
if (item) {
|
|
81
|
+
const resourceUrl = getResourceUrl(item.categorySlug || '', item.slug);
|
|
82
|
+
const resourceImage = getResourceImage(
|
|
83
|
+
item.id,
|
|
84
|
+
item.slug,
|
|
85
|
+
item.categorySlug || ''
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
items.push({
|
|
89
|
+
id: item.id,
|
|
90
|
+
type: 'Resource',
|
|
91
|
+
title: item.title,
|
|
92
|
+
slug: item.slug,
|
|
93
|
+
categorySlug: item.categorySlug || undefined,
|
|
94
|
+
url: resourceUrl,
|
|
95
|
+
imageSrc: resourceImage,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return items;
|
|
101
|
+
}, [results, contentMap]);
|
|
102
|
+
|
|
103
|
+
const totalPages = Math.ceil(allResultItems.length / ITEMS_PER_PAGE);
|
|
104
|
+
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
|
105
|
+
const paginatedItems = allResultItems.slice(
|
|
106
|
+
startIndex,
|
|
107
|
+
startIndex + ITEMS_PER_PAGE
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const handlePageChange = (page: number) => {
|
|
111
|
+
setCurrentPage(page);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const getResultBadge = (type: string, categorySlug?: string) => {
|
|
115
|
+
switch (type) {
|
|
116
|
+
case 'StoryFragment':
|
|
117
|
+
return (
|
|
118
|
+
<span className="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800">
|
|
119
|
+
Page
|
|
120
|
+
</span>
|
|
121
|
+
);
|
|
122
|
+
case 'ContextPane':
|
|
123
|
+
return (
|
|
124
|
+
<span className="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-800">
|
|
125
|
+
Context
|
|
126
|
+
</span>
|
|
127
|
+
);
|
|
128
|
+
case 'Resource':
|
|
129
|
+
return (
|
|
130
|
+
<span className="inline-flex items-center rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-800">
|
|
131
|
+
{categorySlug || 'Resource'}
|
|
132
|
+
</span>
|
|
133
|
+
);
|
|
134
|
+
default:
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
if (allResultItems.length === 0) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<div className="p-6">
|
|
145
|
+
<div className="mb-6">
|
|
146
|
+
<h2 className="text-mydarkgrey text-lg font-semibold">
|
|
147
|
+
{allResultItems.length} result{allResultItems.length !== 1 ? 's' : ''}{' '}
|
|
148
|
+
found
|
|
149
|
+
</h2>
|
|
150
|
+
<p className="mt-1 text-sm text-gray-600">
|
|
151
|
+
Showing {startIndex + 1}-
|
|
152
|
+
{Math.min(startIndex + ITEMS_PER_PAGE, allResultItems.length)} of{' '}
|
|
153
|
+
{allResultItems.length}
|
|
154
|
+
</p>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<div className="mb-8 space-y-4">
|
|
158
|
+
{paginatedItems.map((item) => (
|
|
159
|
+
<div
|
|
160
|
+
key={item.id}
|
|
161
|
+
className="hover:border-myblue rounded-lg border border-gray-200 p-4 transition-colors"
|
|
162
|
+
>
|
|
163
|
+
<a href={item.url} onClick={onResultClick} className="group block">
|
|
164
|
+
<div className="flex items-start gap-4">
|
|
165
|
+
<div
|
|
166
|
+
className="flex-shrink-0 overflow-hidden rounded-lg bg-gray-100"
|
|
167
|
+
style={{ width: '120px', height: '67.5px' }}
|
|
168
|
+
>
|
|
169
|
+
<img
|
|
170
|
+
src={item.imageSrc}
|
|
171
|
+
alt={item.title}
|
|
172
|
+
className="h-full w-full object-cover"
|
|
173
|
+
style={{ width: '100%', height: '100%' }}
|
|
174
|
+
/>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<div className="min-w-0 flex-1">
|
|
178
|
+
<div className="flex items-start justify-between gap-4">
|
|
179
|
+
<div className="flex-1">
|
|
180
|
+
<div className="mb-2">
|
|
181
|
+
<h3 className="text-mydarkgrey group-hover:text-myblue line-clamp-2 font-semibold transition-colors">
|
|
182
|
+
{item.title}
|
|
183
|
+
</h3>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
{item.type === 'StoryFragment' && item.description && (
|
|
187
|
+
<p className="mb-2 line-clamp-2 text-sm text-gray-600">
|
|
188
|
+
{item.description}
|
|
189
|
+
</p>
|
|
190
|
+
)}
|
|
191
|
+
|
|
192
|
+
{item.topics && item.topics.length > 0 && (
|
|
193
|
+
<div className="mb-2 flex flex-wrap gap-1">
|
|
194
|
+
{item.topics.slice(0, 3).map((topic) => (
|
|
195
|
+
<span
|
|
196
|
+
key={topic}
|
|
197
|
+
className="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs text-gray-700"
|
|
198
|
+
>
|
|
199
|
+
{topic}
|
|
200
|
+
</span>
|
|
201
|
+
))}
|
|
202
|
+
{item.topics.length > 3 && (
|
|
203
|
+
<span className="text-xs text-gray-500">
|
|
204
|
+
+{item.topics.length - 3} more
|
|
205
|
+
</span>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
|
|
210
|
+
<p className="truncate text-xs text-gray-500">
|
|
211
|
+
{item.url}
|
|
212
|
+
</p>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<div className="flex-shrink-0 text-right">
|
|
216
|
+
<div className="mb-2">
|
|
217
|
+
{getResultBadge(item.type, item.categorySlug)}
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
</a>
|
|
224
|
+
</div>
|
|
225
|
+
))}
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
{totalPages > 1 && (
|
|
229
|
+
<div className="flex items-center space-x-1">
|
|
230
|
+
<Pagination.Root
|
|
231
|
+
count={allResultItems.length}
|
|
232
|
+
pageSize={ITEMS_PER_PAGE}
|
|
233
|
+
page={currentPage}
|
|
234
|
+
onPageChange={(details) => handlePageChange(details.page)}
|
|
235
|
+
>
|
|
236
|
+
<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">
|
|
237
|
+
<ChevronLeftIcon className="mr-1 h-4 w-4" />
|
|
238
|
+
Previous
|
|
239
|
+
</Pagination.PrevTrigger>
|
|
240
|
+
|
|
241
|
+
<Pagination.Context>
|
|
242
|
+
{(pagination) =>
|
|
243
|
+
pagination.pages.map((page, index) =>
|
|
244
|
+
page.type === 'page' ? (
|
|
245
|
+
<Pagination.Item
|
|
246
|
+
key={index}
|
|
247
|
+
value={page.value}
|
|
248
|
+
type="page"
|
|
249
|
+
className={`cursor-pointer rounded-md px-3 py-2 text-sm ${
|
|
250
|
+
page.value === currentPage
|
|
251
|
+
? 'bg-myblue text-white'
|
|
252
|
+
: 'text-mydarkgrey hover:bg-gray-50'
|
|
253
|
+
}`}
|
|
254
|
+
>
|
|
255
|
+
{page.value}
|
|
256
|
+
</Pagination.Item>
|
|
257
|
+
) : (
|
|
258
|
+
<span key={index} className="px-2 text-gray-400">
|
|
259
|
+
...
|
|
260
|
+
</span>
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
</Pagination.Context>
|
|
265
|
+
|
|
266
|
+
<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">
|
|
267
|
+
Next
|
|
268
|
+
<ChevronRightIcon className="ml-1 h-4 w-4" />
|
|
269
|
+
</Pagination.NextTrigger>
|
|
270
|
+
</Pagination.Root>
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
|
3
|
+
import SearchModal from './SearchModal';
|
|
4
|
+
import type { FullContentMapItem } from '@/types/tractstack';
|
|
5
|
+
|
|
6
|
+
interface SearchWrapperProps {
|
|
7
|
+
contentMap: FullContentMapItem[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default function SearchWrapper({ contentMap }: SearchWrapperProps) {
|
|
11
|
+
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
|
12
|
+
|
|
13
|
+
const handleSearchOpen = () => {
|
|
14
|
+
setIsSearchOpen(true);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const handleSearchClose = () => {
|
|
18
|
+
setIsSearchOpen(false);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
<button
|
|
24
|
+
onClick={handleSearchOpen}
|
|
25
|
+
className="text-myblue/80 hover:text-myblue hover:rotate-6"
|
|
26
|
+
title="Search content"
|
|
27
|
+
aria-label="Search content"
|
|
28
|
+
>
|
|
29
|
+
<MagnifyingGlassIcon className="h-6 w-6" />
|
|
30
|
+
</button>
|
|
31
|
+
|
|
32
|
+
{isSearchOpen && (
|
|
33
|
+
<SearchModal
|
|
34
|
+
isOpen={isSearchOpen}
|
|
35
|
+
onClose={handleSearchClose}
|
|
36
|
+
contentMap={contentMap}
|
|
37
|
+
/>
|
|
38
|
+
)}
|
|
39
|
+
</>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useMemo } from 'react';
|
|
2
|
+
import { TractStackAPI } from '@/utils/api';
|
|
3
|
+
|
|
4
|
+
export interface SearchResults {
|
|
5
|
+
storyFragmentIds: string[];
|
|
6
|
+
contextPaneIds: string[];
|
|
7
|
+
resourceIds: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface UseSearchReturn {
|
|
11
|
+
searchResults: SearchResults;
|
|
12
|
+
isLoading: boolean;
|
|
13
|
+
error: string | null;
|
|
14
|
+
totalResults: number;
|
|
15
|
+
executeSearch: (query: string) => void;
|
|
16
|
+
clearResults: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DEBOUNCE_MS = 100;
|
|
20
|
+
const BACKEND_THROTTLE_MS = 1000;
|
|
21
|
+
|
|
22
|
+
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
|
+
|
|
31
|
+
const debounceRef = useRef<NodeJS.Timeout>();
|
|
32
|
+
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
|
+
|
|
37
|
+
const api = useMemo(() => new TractStackAPI(), []);
|
|
38
|
+
|
|
39
|
+
const performSearch = useCallback(
|
|
40
|
+
async (query: string) => {
|
|
41
|
+
if (!query.trim() || query.trim().length < 3) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
setIsLoading(true);
|
|
46
|
+
setError(null);
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const response = await api.search(query.trim());
|
|
50
|
+
|
|
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;
|
|
59
|
+
}
|
|
60
|
+
setError(response.error || 'Search failed');
|
|
61
|
+
setSearchResults({
|
|
62
|
+
storyFragmentIds: [],
|
|
63
|
+
contextPaneIds: [],
|
|
64
|
+
resourceIds: [],
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
} catch (err) {
|
|
68
|
+
setError(err instanceof Error ? err.message : 'Search failed');
|
|
69
|
+
setSearchResults({
|
|
70
|
+
storyFragmentIds: [],
|
|
71
|
+
contextPaneIds: [],
|
|
72
|
+
resourceIds: [],
|
|
73
|
+
});
|
|
74
|
+
} finally {
|
|
75
|
+
setIsLoading(false);
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
[api]
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const processQueue = useCallback(() => {
|
|
82
|
+
if (queuedQueryRef.current) {
|
|
83
|
+
const queryToProcess = queuedQueryRef.current;
|
|
84
|
+
queuedQueryRef.current = null;
|
|
85
|
+
performSearch(queryToProcess);
|
|
86
|
+
}
|
|
87
|
+
}, [performSearch]);
|
|
88
|
+
|
|
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
|
+
}
|
|
109
|
+
|
|
110
|
+
// Schedule execution for when the throttle window expires
|
|
111
|
+
const remainingTime = BACKEND_THROTTLE_MS - timeSinceLastSearch;
|
|
112
|
+
queueTimeoutRef.current = setTimeout(processQueue, remainingTime);
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
[performSearch, processQueue]
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const executeSearch = useCallback(
|
|
119
|
+
(query: string) => {
|
|
120
|
+
// Clear existing debounce
|
|
121
|
+
if (debounceRef.current) {
|
|
122
|
+
clearTimeout(debounceRef.current);
|
|
123
|
+
}
|
|
124
|
+
|
|
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);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Debounce the actual search execution
|
|
138
|
+
debounceRef.current = setTimeout(() => {
|
|
139
|
+
executeSearchWithThrottling(query);
|
|
140
|
+
}, DEBOUNCE_MS);
|
|
141
|
+
},
|
|
142
|
+
[executeSearchWithThrottling]
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const clearResults = useCallback(() => {
|
|
146
|
+
// Clear all timeouts
|
|
147
|
+
if (debounceRef.current) {
|
|
148
|
+
clearTimeout(debounceRef.current);
|
|
149
|
+
}
|
|
150
|
+
if (queueTimeoutRef.current) {
|
|
151
|
+
clearTimeout(queueTimeoutRef.current);
|
|
152
|
+
}
|
|
153
|
+
|
|
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;
|
|
164
|
+
}, []);
|
|
165
|
+
|
|
166
|
+
const totalResults =
|
|
167
|
+
searchResults.storyFragmentIds.length +
|
|
168
|
+
searchResults.contextPaneIds.length +
|
|
169
|
+
searchResults.resourceIds.length;
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
searchResults,
|
|
173
|
+
isLoading,
|
|
174
|
+
error,
|
|
175
|
+
totalResults,
|
|
176
|
+
executeSearch,
|
|
177
|
+
clearResults,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -53,7 +53,7 @@ try {
|
|
|
53
53
|
---
|
|
54
54
|
|
|
55
55
|
<Layout title={title} slug="storykeep" isStoryKeep={true}>
|
|
56
|
-
<main id="main-content" class="min-h-screen w-full">
|
|
56
|
+
<main id="main-content" class="relative min-h-screen w-full">
|
|
57
57
|
<StoryKeepBackdrop brandConfig={brandConfig} />
|
|
58
58
|
<div class="max-w-5xl p-3.5 md:p-8">
|
|
59
59
|
<StoryKeepDashboard
|
|
@@ -42,7 +42,7 @@ try {
|
|
|
42
42
|
---
|
|
43
43
|
|
|
44
44
|
<Layout title={title} slug="storykeep" isStoryKeep={true}>
|
|
45
|
-
<main id="main-content" class="min-h-screen w-full">
|
|
45
|
+
<main id="main-content" class="relative min-h-screen w-full">
|
|
46
46
|
<StoryKeepBackdrop brandConfig={brandConfig} />
|
|
47
47
|
<div class="max-w-5xl p-3.5 md:p-8">
|
|
48
48
|
<BrandingPageWrapper
|
|
@@ -49,7 +49,7 @@ try {
|
|
|
49
49
|
---
|
|
50
50
|
|
|
51
51
|
<Layout title={title} slug="storykeep" isStoryKeep={true}>
|
|
52
|
-
<main id="main-content" class="min-h-screen w-full">
|
|
52
|
+
<main id="main-content" class="relative min-h-screen w-full">
|
|
53
53
|
<StoryKeepBackdrop brandConfig={brandConfig} />
|
|
54
54
|
<div class="max-w-5xl p-3.5 md:p-8">
|
|
55
55
|
<StoryKeepDashboard
|
|
@@ -73,6 +73,11 @@ export class TractStackAPI {
|
|
|
73
73
|
const defaultHeaders = {
|
|
74
74
|
'Content-Type': 'application/json',
|
|
75
75
|
'X-Tenant-ID': this.tenantId,
|
|
76
|
+
...(typeof window !== 'undefined' &&
|
|
77
|
+
(window as any).TRACTSTACK_CONFIG?.sessionId && {
|
|
78
|
+
'X-TractStack-Session-ID': (window as any).TRACTSTACK_CONFIG
|
|
79
|
+
.sessionId,
|
|
80
|
+
}),
|
|
76
81
|
};
|
|
77
82
|
|
|
78
83
|
try {
|
|
@@ -133,6 +138,16 @@ export class TractStackAPI {
|
|
|
133
138
|
});
|
|
134
139
|
}
|
|
135
140
|
|
|
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 });
|
|
149
|
+
}
|
|
150
|
+
|
|
136
151
|
async getContent(slug: string): Promise<APIResponse> {
|
|
137
152
|
return this.get(`/api/v1/content/${slug}`);
|
|
138
153
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// URL Helper: Strip category prefix from slug
|
|
2
|
+
// e.g., "people-bleako" -> "bleako"
|
|
3
|
+
export function getCleanSlug(categorySlug: string, fullSlug: string): string {
|
|
4
|
+
const prefix = `${categorySlug}-`;
|
|
5
|
+
return fullSlug.startsWith(prefix) ? fullSlug.slice(prefix.length) : fullSlug;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Build proper URL for resource
|
|
9
|
+
// e.g., category="people", slug="people-bleako" -> "/people/bleako"
|
|
10
|
+
export function getResourceUrl(categorySlug: string, fullSlug: string): string {
|
|
11
|
+
const cleanSlug = getCleanSlug(categorySlug, fullSlug);
|
|
12
|
+
return `/${categorySlug}/${cleanSlug}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Image Helper: Placeholder implementation
|
|
16
|
+
export function getResourceImage(
|
|
17
|
+
id: string,
|
|
18
|
+
slug: string,
|
|
19
|
+
category: string
|
|
20
|
+
): string {
|
|
21
|
+
console.log(`please define getResourceImage`, id, slug, category);
|
|
22
|
+
return '/static.jpg';
|
|
23
|
+
}
|
package/utils/inject-files.ts
CHANGED
|
@@ -906,7 +906,23 @@ export async function injectTemplateFiles(
|
|
|
906
906
|
src: resolve('../templates/src/components/Fragment.astro'),
|
|
907
907
|
dest: 'src/components/Fragment.astro',
|
|
908
908
|
},
|
|
909
|
-
|
|
909
|
+
// Search Components
|
|
910
|
+
{
|
|
911
|
+
src: resolve('../templates/src/components/search/SearchWrapper.tsx'),
|
|
912
|
+
dest: 'src/components/search/SearchWrapper.tsx',
|
|
913
|
+
},
|
|
914
|
+
{
|
|
915
|
+
src: resolve('../templates/src/components/search/SearchModal.tsx'),
|
|
916
|
+
dest: 'src/components/search/SearchModal.tsx',
|
|
917
|
+
},
|
|
918
|
+
{
|
|
919
|
+
src: resolve('../templates/src/components/search/SearchResults.tsx'),
|
|
920
|
+
dest: 'src/components/search/SearchResults.tsx',
|
|
921
|
+
},
|
|
922
|
+
{
|
|
923
|
+
src: resolve('../templates/src/hooks/useSearch.ts'),
|
|
924
|
+
dest: 'src/hooks/useSearch.ts',
|
|
925
|
+
},
|
|
910
926
|
// Profile Components
|
|
911
927
|
{
|
|
912
928
|
src: resolve('../templates/src/components/profile/ProfileConsent.tsx'),
|
|
@@ -2058,6 +2074,11 @@ export async function injectTemplateFiles(
|
|
|
2058
2074
|
dest: 'src/custom/CustomRoutes.astro',
|
|
2059
2075
|
protected: true,
|
|
2060
2076
|
},
|
|
2077
|
+
{
|
|
2078
|
+
src: resolve('../templates/src/utils/customHelpers.ts'),
|
|
2079
|
+
dest: 'src/utils/customHelpers.ts',
|
|
2080
|
+
protected: true,
|
|
2081
|
+
},
|
|
2061
2082
|
|
|
2062
2083
|
// Example Components (Conditional)
|
|
2063
2084
|
...(config?.includeExamples
|