doccupine 0.0.83 → 0.0.85
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/lib/layout.js +7 -2
- package/dist/lib/structures.js +6 -0
- package/dist/templates/app/robots.d.ts +1 -0
- package/dist/templates/app/robots.js +11 -0
- package/dist/templates/app/theme.d.ts +1 -1
- package/dist/templates/app/theme.js +1 -1
- package/dist/templates/components/DocsSideBar.d.ts +1 -1
- package/dist/templates/components/DocsSideBar.js +2 -2
- package/dist/templates/components/PostHogProvider.d.ts +1 -1
- package/dist/templates/components/PostHogProvider.js +9 -62
- package/dist/templates/components/PostHogProviderLazy.d.ts +1 -0
- package/dist/templates/components/PostHogProviderLazy.js +70 -0
- package/dist/templates/components/SearchDocs.d.ts +1 -1
- package/dist/templates/components/SearchDocs.js +39 -271
- package/dist/templates/components/SearchModalContent.d.ts +1 -0
- package/dist/templates/components/SearchModalContent.js +310 -0
- package/dist/templates/components/SideBar.d.ts +1 -1
- package/dist/templates/components/SideBar.js +5 -1
- package/dist/templates/components/layout/DocsComponents.d.ts +1 -1
- package/dist/templates/components/layout/DocsComponents.js +1 -1
- package/dist/templates/components/layout/DocsNavigation.d.ts +1 -1
- package/dist/templates/components/layout/DocsNavigation.js +1 -1
- package/dist/templates/components/layout/Field.d.ts +1 -1
- package/dist/templates/components/layout/Field.js +1 -0
- package/dist/templates/components/layout/Footer.d.ts +1 -1
- package/dist/templates/components/layout/Footer.js +8 -3
- package/dist/templates/components/layout/SharedStyles.d.ts +1 -1
- package/dist/templates/components/layout/SharedStyles.js +2 -1
- package/dist/templates/components/layout/StaticLinks.d.ts +1 -1
- package/dist/templates/components/layout/StaticLinks.js +1 -1
- package/dist/templates/mdx/footer-links.mdx.d.ts +1 -1
- package/dist/templates/mdx/footer-links.mdx.js +1 -1
- package/dist/templates/mdx/platform/external-links.mdx.d.ts +1 -1
- package/dist/templates/mdx/platform/external-links.mdx.js +6 -21
- package/dist/templates/mdx/theme.mdx.d.ts +1 -1
- package/dist/templates/mdx/theme.mdx.js +1 -1
- package/dist/templates/package.js +13 -13
- package/dist/templates/services/mcp/tools.d.ts +1 -1
- package/dist/templates/services/mcp/tools.js +9 -10
- package/dist/templates/tsconfig.d.ts +1 -1
- package/dist/templates/tsconfig.js +0 -1
- package/package.json +4 -4
|
@@ -8,20 +8,19 @@ import React, {
|
|
|
8
8
|
useState,
|
|
9
9
|
} from "react";
|
|
10
10
|
import { useRouter } from "next/navigation";
|
|
11
|
-
import
|
|
12
|
-
import
|
|
13
|
-
import { Search } from "lucide-react";
|
|
11
|
+
import dynamic from "next/dynamic";
|
|
12
|
+
import styled from "styled-components";
|
|
14
13
|
import { mq, Theme } from "@/app/theme";
|
|
15
14
|
import { interactiveStyles } from "@/components/layout/SharedStyled";
|
|
16
|
-
import {
|
|
15
|
+
import type { PageItem, MergedResult } from "@/components/SearchModalContent";
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
const SearchModalContent = dynamic(
|
|
18
|
+
() =>
|
|
19
|
+
import("@/components/SearchModalContent").then(
|
|
20
|
+
(mod) => mod.SearchModalContent,
|
|
21
|
+
),
|
|
22
|
+
{ ssr: false },
|
|
23
|
+
);
|
|
25
24
|
|
|
26
25
|
interface SectionItem {
|
|
27
26
|
label: string;
|
|
@@ -33,11 +32,6 @@ interface ContentHit {
|
|
|
33
32
|
snippet: string;
|
|
34
33
|
}
|
|
35
34
|
|
|
36
|
-
interface MergedResult {
|
|
37
|
-
page: PageItem;
|
|
38
|
-
snippet?: string;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
35
|
interface SearchContextValue {
|
|
42
36
|
openSearch: () => void;
|
|
43
37
|
}
|
|
@@ -46,170 +40,6 @@ const SearchContext = createContext<SearchContextValue>({
|
|
|
46
40
|
openSearch: () => {},
|
|
47
41
|
});
|
|
48
42
|
|
|
49
|
-
const ANIMATION_MS = 150;
|
|
50
|
-
|
|
51
|
-
const backdropIn = keyframes\`
|
|
52
|
-
from { opacity: 0; }
|
|
53
|
-
to { opacity: 1; }
|
|
54
|
-
\`;
|
|
55
|
-
|
|
56
|
-
const backdropOut = keyframes\`
|
|
57
|
-
from { opacity: 1; }
|
|
58
|
-
to { opacity: 0; }
|
|
59
|
-
\`;
|
|
60
|
-
|
|
61
|
-
const modalIn = keyframes\`
|
|
62
|
-
from { opacity: 0; transform: scale(0.96) translateY(-8px); }
|
|
63
|
-
to { opacity: 1; transform: scale(1) translateY(0); }
|
|
64
|
-
\`;
|
|
65
|
-
|
|
66
|
-
const modalOut = keyframes\`
|
|
67
|
-
from { opacity: 1; transform: scale(1) translateY(0); }
|
|
68
|
-
to { opacity: 0; transform: scale(0.96) translateY(-8px); }
|
|
69
|
-
\`;
|
|
70
|
-
|
|
71
|
-
const StyledBackdrop = styled.div<{ theme: Theme; $isClosing: boolean }>\`
|
|
72
|
-
position: fixed;
|
|
73
|
-
inset: 0;
|
|
74
|
-
z-index: 9999;
|
|
75
|
-
background: \${({ theme }) =>
|
|
76
|
-
rgba(theme.isDark ? theme.colors.light : theme.colors.dark, 0.5)};
|
|
77
|
-
backdrop-filter: blur(4px);
|
|
78
|
-
-webkit-backdrop-filter: blur(4px);
|
|
79
|
-
display: flex;
|
|
80
|
-
align-items: flex-start;
|
|
81
|
-
justify-content: center;
|
|
82
|
-
padding: 20px;
|
|
83
|
-
animation: \${({ $isClosing }) => ($isClosing ? backdropOut : backdropIn)}
|
|
84
|
-
\${ANIMATION_MS}ms ease forwards;
|
|
85
|
-
|
|
86
|
-
\${mq("lg")} {
|
|
87
|
-
padding: 120px 20px 20px 20px;
|
|
88
|
-
}
|
|
89
|
-
\`;
|
|
90
|
-
|
|
91
|
-
const StyledModal = styled.div<{ theme: Theme; $isClosing: boolean }>\`
|
|
92
|
-
background: \${({ theme }) => theme.colors.light};
|
|
93
|
-
border-radius: \${({ theme }) => theme.spacing.radius.lg};
|
|
94
|
-
box-shadow: \${({ theme }) => theme.shadows.xs};
|
|
95
|
-
width: 100%;
|
|
96
|
-
max-width: 560px;
|
|
97
|
-
max-height: calc(100dvh - 40px);
|
|
98
|
-
display: flex;
|
|
99
|
-
flex-direction: column;
|
|
100
|
-
border: solid 1px \${({ theme }) => theme.colors.grayLight};
|
|
101
|
-
padding-bottom: 8px;
|
|
102
|
-
animation: \${({ $isClosing }) => ($isClosing ? modalOut : modalIn)}
|
|
103
|
-
\${ANIMATION_MS}ms ease forwards;
|
|
104
|
-
|
|
105
|
-
\${mq("lg")} {
|
|
106
|
-
max-height: calc(100dvh - 240px);
|
|
107
|
-
}
|
|
108
|
-
\`;
|
|
109
|
-
|
|
110
|
-
const StyledInputWrapper = styled.div<{ theme: Theme }>\`
|
|
111
|
-
display: flex;
|
|
112
|
-
align-items: center;
|
|
113
|
-
gap: 12px;
|
|
114
|
-
padding: 16px;
|
|
115
|
-
flex-shrink: 0;
|
|
116
|
-
border-bottom: solid 1px \${({ theme }) => theme.colors.grayLight};
|
|
117
|
-
|
|
118
|
-
& svg.lucide {
|
|
119
|
-
color: \${({ theme }) => theme.colors.gray};
|
|
120
|
-
flex-shrink: 0;
|
|
121
|
-
}
|
|
122
|
-
\`;
|
|
123
|
-
|
|
124
|
-
const StyledInput = styled.input<{ theme: Theme }>\`
|
|
125
|
-
flex: 1;
|
|
126
|
-
border: none;
|
|
127
|
-
outline: none;
|
|
128
|
-
background: transparent;
|
|
129
|
-
font-size: \${({ theme }) => theme.fontSizes.text.lg};
|
|
130
|
-
line-height: \${({ theme }) => theme.lineHeights.text.lg};
|
|
131
|
-
color: \${({ theme }) => theme.colors.dark};
|
|
132
|
-
font-family: inherit;
|
|
133
|
-
|
|
134
|
-
&::placeholder {
|
|
135
|
-
color: \${({ theme }) => theme.colors.gray};
|
|
136
|
-
}
|
|
137
|
-
\`;
|
|
138
|
-
|
|
139
|
-
const StyledResults = styled.ul<{ theme: Theme }>\`
|
|
140
|
-
list-style: none;
|
|
141
|
-
margin: 8px 0 0 0;
|
|
142
|
-
padding: 0 8px;
|
|
143
|
-
overflow-y: auto;
|
|
144
|
-
flex: 1;
|
|
145
|
-
min-height: 0;
|
|
146
|
-
-webkit-overflow-scrolling: touch;
|
|
147
|
-
|
|
148
|
-
&::-webkit-scrollbar {
|
|
149
|
-
display: none;
|
|
150
|
-
}
|
|
151
|
-
\`;
|
|
152
|
-
|
|
153
|
-
const StyledResultItem = styled.li<{ theme: Theme; $isActive: boolean }>\`
|
|
154
|
-
padding: 10px 12px;
|
|
155
|
-
border-radius: \${({ theme }) => theme.spacing.radius.xs};
|
|
156
|
-
cursor: pointer;
|
|
157
|
-
transition: background 0.15s ease;
|
|
158
|
-
|
|
159
|
-
\${({ $isActive, theme }) =>
|
|
160
|
-
$isActive &&
|
|
161
|
-
css\`
|
|
162
|
-
background: \${rgba(theme.colors.primaryLight, 0.2)};
|
|
163
|
-
\`}
|
|
164
|
-
|
|
165
|
-
&:hover {
|
|
166
|
-
background: \${({ theme }) => rgba(theme.colors.primaryLight, 0.15)};
|
|
167
|
-
}
|
|
168
|
-
\`;
|
|
169
|
-
|
|
170
|
-
const StyledResultTitle = styled.span<{ theme: Theme }>\`
|
|
171
|
-
font-size: \${({ theme }) => theme.fontSizes.text.lg};
|
|
172
|
-
font-weight: 500;
|
|
173
|
-
color: \${({ theme }) => theme.colors.dark};
|
|
174
|
-
display: block;
|
|
175
|
-
\`;
|
|
176
|
-
|
|
177
|
-
const StyledResultMeta = styled.span<{ theme: Theme }>\`
|
|
178
|
-
font-size: \${({ theme }) => theme.fontSizes.small.lg};
|
|
179
|
-
color: \${({ theme }) => theme.colors.gray};
|
|
180
|
-
display: block;
|
|
181
|
-
margin-top: 2px;
|
|
182
|
-
\`;
|
|
183
|
-
|
|
184
|
-
const StyledSnippet = styled.span<{ theme: Theme }>\`
|
|
185
|
-
font-size: \${({ theme }) => theme.fontSizes.small.lg};
|
|
186
|
-
color: \${({ theme }) => theme.colors.grayDark};
|
|
187
|
-
display: block;
|
|
188
|
-
margin-top: 4px;
|
|
189
|
-
line-height: 1.4;
|
|
190
|
-
overflow: hidden;
|
|
191
|
-
text-overflow: ellipsis;
|
|
192
|
-
white-space: nowrap;
|
|
193
|
-
|
|
194
|
-
& mark {
|
|
195
|
-
background: \${({ theme }) => rgba(theme.colors.primaryLight, 0.35)};
|
|
196
|
-
color: inherit;
|
|
197
|
-
border-radius: 4px;
|
|
198
|
-
padding: 0 1px;
|
|
199
|
-
}
|
|
200
|
-
\`;
|
|
201
|
-
|
|
202
|
-
const StyledEmpty = styled.div<{ theme: Theme }>\`
|
|
203
|
-
padding: 20px 20px 12px;
|
|
204
|
-
min-height: 40px;
|
|
205
|
-
display: flex;
|
|
206
|
-
align-items: center;
|
|
207
|
-
justify-content: center;
|
|
208
|
-
text-align: center;
|
|
209
|
-
font-size: \${({ theme }) => theme.fontSizes.small.lg};
|
|
210
|
-
color: \${({ theme }) => theme.colors.gray};
|
|
211
|
-
\`;
|
|
212
|
-
|
|
213
43
|
const StyledKbd = styled.kbd<{ theme: Theme }>\`
|
|
214
44
|
font-size: 11px;
|
|
215
45
|
font-family: inherit;
|
|
@@ -248,25 +78,6 @@ const StyledSearchButton = styled.button<{ theme: Theme }>\`
|
|
|
248
78
|
}
|
|
249
79
|
\`;
|
|
250
80
|
|
|
251
|
-
function escapeHtml(str: string): string {
|
|
252
|
-
return str
|
|
253
|
-
.replace(/&/g, "&")
|
|
254
|
-
.replace(/</g, "<")
|
|
255
|
-
.replace(/>/g, ">")
|
|
256
|
-
.replace(/"/g, """);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
function highlightMatch(snippet: string, query: string): string {
|
|
260
|
-
const escaped = escapeHtml(snippet);
|
|
261
|
-
if (!query.trim()) return escaped;
|
|
262
|
-
const q = escapeHtml(query.trim());
|
|
263
|
-
const regex = new RegExp(
|
|
264
|
-
\`(\${q.replace(/[.*+?^\${}()|[\\]\\\\]/g, "\\\\$&")})\`,
|
|
265
|
-
"gi",
|
|
266
|
-
);
|
|
267
|
-
return escaped.replace(regex, "<mark>$1</mark>");
|
|
268
|
-
}
|
|
269
|
-
|
|
270
81
|
function SearchProvider({
|
|
271
82
|
pages,
|
|
272
83
|
sections,
|
|
@@ -282,9 +93,8 @@ function SearchProvider({
|
|
|
282
93
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
283
94
|
const [contentResults, setContentResults] = useState<ContentHit[]>([]);
|
|
284
95
|
const [isSearching, setIsSearching] = useState(false);
|
|
285
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
286
96
|
const resultsRef = useRef<HTMLUListElement>(null);
|
|
287
|
-
const
|
|
97
|
+
const closingRef = useRef(false);
|
|
288
98
|
const abortRef = useRef<AbortController | null>(null);
|
|
289
99
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
|
290
100
|
const router = useRouter();
|
|
@@ -298,23 +108,27 @@ function SearchProvider({
|
|
|
298
108
|
}, [sections]);
|
|
299
109
|
|
|
300
110
|
const openSearch = useCallback(() => {
|
|
301
|
-
|
|
111
|
+
closingRef.current = false;
|
|
302
112
|
setIsClosing(false);
|
|
303
113
|
setIsVisible(true);
|
|
304
114
|
}, []);
|
|
305
115
|
|
|
306
116
|
const closeSearch = useCallback(() => {
|
|
117
|
+
closingRef.current = true;
|
|
307
118
|
setIsClosing(true);
|
|
308
119
|
if (abortRef.current) abortRef.current.abort();
|
|
309
120
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
121
|
+
}, []);
|
|
122
|
+
|
|
123
|
+
const handleCloseAnimationEnd = useCallback(() => {
|
|
124
|
+
if (!closingRef.current) return;
|
|
125
|
+
closingRef.current = false;
|
|
126
|
+
setIsVisible(false);
|
|
127
|
+
setIsClosing(false);
|
|
128
|
+
setQuery("");
|
|
129
|
+
setActiveIndex(0);
|
|
130
|
+
setContentResults([]);
|
|
131
|
+
setIsSearching(false);
|
|
318
132
|
}, []);
|
|
319
133
|
|
|
320
134
|
// Instant title/description filtering
|
|
@@ -423,13 +237,6 @@ function SearchProvider({
|
|
|
423
237
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
424
238
|
}, [closeSearch, openSearch]);
|
|
425
239
|
|
|
426
|
-
// Focus input on open
|
|
427
|
-
useEffect(() => {
|
|
428
|
-
if (isVisible && !isClosing) {
|
|
429
|
-
setTimeout(() => inputRef.current?.focus(), 10);
|
|
430
|
-
}
|
|
431
|
-
}, [isVisible, isClosing]);
|
|
432
|
-
|
|
433
240
|
// Scroll active item into view
|
|
434
241
|
useEffect(() => {
|
|
435
242
|
if (!resultsRef.current) return;
|
|
@@ -458,60 +265,21 @@ function SearchProvider({
|
|
|
458
265
|
<SearchContext.Provider value={{ openSearch }}>
|
|
459
266
|
{children}
|
|
460
267
|
{isVisible && (
|
|
461
|
-
<
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
placeholder="Search docs..."
|
|
477
|
-
autoComplete="off"
|
|
478
|
-
spellCheck={false}
|
|
479
|
-
/>
|
|
480
|
-
<StyledKbd>Esc</StyledKbd>
|
|
481
|
-
</StyledInputWrapper>
|
|
482
|
-
{merged.length > 0 ? (
|
|
483
|
-
<StyledResults ref={resultsRef}>
|
|
484
|
-
{merged.map((result, index) => (
|
|
485
|
-
<StyledResultItem
|
|
486
|
-
key={result.page.slug + result.page.section}
|
|
487
|
-
$isActive={index === activeIndex}
|
|
488
|
-
onClick={() => navigate(result.page.slug)}
|
|
489
|
-
onMouseEnter={() => setActiveIndex(index)}
|
|
490
|
-
>
|
|
491
|
-
<StyledResultTitle>{result.page.title}</StyledResultTitle>
|
|
492
|
-
<StyledResultMeta>
|
|
493
|
-
{result.page.section
|
|
494
|
-
? \`\${sectionLabels[result.page.section] || result.page.section} / \`
|
|
495
|
-
: ""}
|
|
496
|
-
{result.page.category}
|
|
497
|
-
</StyledResultMeta>
|
|
498
|
-
{result.snippet && (
|
|
499
|
-
<StyledSnippet
|
|
500
|
-
dangerouslySetInnerHTML={{
|
|
501
|
-
__html: highlightMatch(result.snippet, query),
|
|
502
|
-
}}
|
|
503
|
-
/>
|
|
504
|
-
)}
|
|
505
|
-
</StyledResultItem>
|
|
506
|
-
))}
|
|
507
|
-
</StyledResults>
|
|
508
|
-
) : (
|
|
509
|
-
<StyledEmpty>
|
|
510
|
-
{isSearching ? <Spinner size={18} /> : "No results found"}
|
|
511
|
-
</StyledEmpty>
|
|
512
|
-
)}
|
|
513
|
-
</StyledModal>
|
|
514
|
-
</StyledBackdrop>
|
|
268
|
+
<SearchModalContent
|
|
269
|
+
isClosing={isClosing}
|
|
270
|
+
closeSearch={closeSearch}
|
|
271
|
+
onCloseAnimationEnd={handleCloseAnimationEnd}
|
|
272
|
+
query={query}
|
|
273
|
+
setQuery={setQuery}
|
|
274
|
+
activeIndex={activeIndex}
|
|
275
|
+
setActiveIndex={setActiveIndex}
|
|
276
|
+
resultsRef={resultsRef}
|
|
277
|
+
onKeyDown={handleKeyDown}
|
|
278
|
+
merged={merged}
|
|
279
|
+
sectionLabels={sectionLabels}
|
|
280
|
+
isSearching={isSearching}
|
|
281
|
+
navigate={navigate}
|
|
282
|
+
/>
|
|
515
283
|
)}
|
|
516
284
|
</SearchContext.Provider>
|
|
517
285
|
);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const searchModalContentTemplate = "\"use client\";\nimport React from \"react\";\nimport styled, { css, keyframes } from \"styled-components\";\nimport { rgba } from \"polished\";\nimport { Search } from \"lucide-react\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { Spinner } from \"@/components/Spinner\";\n\nexport interface PageItem {\n slug: string;\n title: string;\n description?: string;\n category: string;\n section?: string;\n}\n\nexport interface MergedResult {\n page: PageItem;\n snippet?: string;\n}\n\nexport interface SearchModalContentProps {\n isClosing: boolean;\n closeSearch: () => void;\n onCloseAnimationEnd: () => void;\n query: string;\n setQuery: (q: string) => void;\n activeIndex: number;\n setActiveIndex: (i: number | ((prev: number) => number)) => void;\n resultsRef: React.RefObject<HTMLUListElement | null>;\n onKeyDown: (e: React.KeyboardEvent) => void;\n merged: MergedResult[];\n sectionLabels: Record<string, string>;\n isSearching: boolean;\n navigate: (slug: string) => void;\n}\n\nconst ANIMATION_MS = 150;\n\nconst backdropIn = keyframes`\n from { opacity: 0; }\n to { opacity: 1; }\n`;\n\nconst backdropOut = keyframes`\n from { opacity: 1; }\n to { opacity: 0; }\n`;\n\nconst modalIn = keyframes`\n from { opacity: 0; transform: scale(0.96) translateY(-8px); }\n to { opacity: 1; transform: scale(1) translateY(0); }\n`;\n\nconst modalOut = keyframes`\n from { opacity: 1; transform: scale(1) translateY(0); }\n to { opacity: 0; transform: scale(0.96) translateY(-8px); }\n`;\n\nconst StyledBackdrop = styled.div<{ theme: Theme; $isClosing: boolean }>`\n position: fixed;\n inset: 0;\n z-index: 9999;\n background: ${({ theme }) =>\n rgba(theme.isDark ? theme.colors.light : theme.colors.dark, 0.5)};\n backdrop-filter: blur(4px);\n -webkit-backdrop-filter: blur(4px);\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding: 20px;\n animation: ${({ $isClosing }) => ($isClosing ? backdropOut : backdropIn)}\n ${ANIMATION_MS}ms ease forwards;\n\n ${mq(\"lg\")} {\n padding: 120px 20px 20px 20px;\n }\n`;\n\nconst StyledModal = styled.div<{ theme: Theme; $isClosing: boolean }>`\n background: ${({ theme }) => theme.colors.light};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n box-shadow: ${({ theme }) => theme.shadows.xs};\n width: 100%;\n max-width: 560px;\n max-height: calc(100dvh - 40px);\n display: flex;\n flex-direction: column;\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding-bottom: 8px;\n animation: ${({ $isClosing }) => ($isClosing ? modalOut : modalIn)}\n ${ANIMATION_MS}ms ease forwards;\n\n ${mq(\"lg\")} {\n max-height: calc(100dvh - 240px);\n }\n`;\n\nconst StyledInputWrapper = styled.div<{ theme: Theme }>`\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 16px;\n flex-shrink: 0;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n\n & svg.lucide {\n color: ${({ theme }) => theme.colors.gray};\n flex-shrink: 0;\n }\n`;\n\nconst StyledInput = styled.input<{ theme: Theme }>`\n flex: 1;\n border: none;\n outline: none;\n background: transparent;\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n line-height: ${({ theme }) => theme.lineHeights.text.lg};\n color: ${({ theme }) => theme.colors.dark};\n font-family: inherit;\n\n &::placeholder {\n color: ${({ theme }) => theme.colors.gray};\n }\n`;\n\nconst StyledResults = styled.ul<{ theme: Theme }>`\n list-style: none;\n margin: 8px 0 0 0;\n padding: 0 8px;\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n -webkit-overflow-scrolling: touch;\n\n &::-webkit-scrollbar {\n display: none;\n }\n`;\n\nconst StyledResultItem = styled.li<{ theme: Theme; $isActive: boolean }>`\n padding: 10px 12px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n cursor: pointer;\n transition: background 0.15s ease;\n\n ${({ $isActive, theme }) =>\n $isActive &&\n css`\n background: ${rgba(theme.colors.primaryLight, 0.2)};\n `}\n\n &:hover {\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.15)};\n }\n`;\n\nconst StyledResultTitle = styled.span<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n font-weight: 500;\n color: ${({ theme }) => theme.colors.dark};\n display: block;\n`;\n\nconst StyledResultMeta = styled.span<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n color: ${({ theme }) => theme.colors.gray};\n display: block;\n margin-top: 2px;\n`;\n\nconst StyledSnippet = styled.span<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n color: ${({ theme }) => theme.colors.grayDark};\n display: block;\n margin-top: 4px;\n line-height: 1.4;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n & mark {\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.35)};\n color: inherit;\n border-radius: 4px;\n padding: 0 1px;\n }\n`;\n\nconst StyledEmpty = styled.div<{ theme: Theme }>`\n padding: 20px 20px 12px;\n min-height: 40px;\n display: flex;\n align-items: center;\n justify-content: center;\n text-align: center;\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n color: ${({ theme }) => theme.colors.gray};\n`;\n\nconst StyledKbd = styled.kbd<{ theme: Theme }>`\n font-size: 11px;\n font-family: inherit;\n background: ${({ theme }) => theme.colors.grayLight};\n color: ${({ theme }) => theme.colors.grayDark};\n padding: 2px 6px;\n border-radius: 4px;\n margin-left: auto;\n font-weight: 600;\n display: none;\n\n ${mq(\"lg\")} {\n display: initial;\n }\n`;\n\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\");\n}\n\nfunction highlightMatch(snippet: string, query: string): string {\n const escaped = escapeHtml(snippet);\n if (!query.trim()) return escaped;\n const q = escapeHtml(query.trim());\n const regex = new RegExp(\n `(${q.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")})`,\n \"gi\",\n );\n return escaped.replace(regex, \"<mark>$1</mark>\");\n}\n\nexport function SearchModalContent({\n isClosing,\n closeSearch,\n onCloseAnimationEnd,\n query,\n setQuery,\n activeIndex,\n setActiveIndex,\n resultsRef,\n onKeyDown,\n merged,\n sectionLabels,\n isSearching,\n navigate,\n}: SearchModalContentProps) {\n return (\n <StyledBackdrop\n $isClosing={isClosing}\n onClick={closeSearch}\n onAnimationEnd={onCloseAnimationEnd}\n >\n <StyledModal $isClosing={isClosing} onClick={(e) => e.stopPropagation()}>\n <StyledInputWrapper>\n <Search size={18} />\n <StyledInput\n autoFocus\n value={query}\n onChange={(e) => {\n setQuery(e.target.value);\n setActiveIndex(0);\n }}\n onKeyDown={onKeyDown}\n placeholder=\"Search docs...\"\n autoComplete=\"off\"\n spellCheck={false}\n />\n <StyledKbd>Esc</StyledKbd>\n </StyledInputWrapper>\n {merged.length > 0 ? (\n <StyledResults ref={resultsRef}>\n {merged.map((result, index) => (\n <StyledResultItem\n key={result.page.slug + result.page.section}\n $isActive={index === activeIndex}\n onClick={() => navigate(result.page.slug)}\n onMouseEnter={() => setActiveIndex(index)}\n >\n <StyledResultTitle>{result.page.title}</StyledResultTitle>\n <StyledResultMeta>\n {result.page.section\n ? `${sectionLabels[result.page.section] || result.page.section} / `\n : \"\"}\n {result.page.category}\n </StyledResultMeta>\n {result.snippet && (\n <StyledSnippet\n dangerouslySetInnerHTML={{\n __html: highlightMatch(result.snippet, query),\n }}\n />\n )}\n </StyledResultItem>\n ))}\n </StyledResults>\n ) : (\n <StyledEmpty>\n {isSearching ? <Spinner size={18} /> : \"No results found\"}\n </StyledEmpty>\n )}\n </StyledModal>\n </StyledBackdrop>\n );\n}\n";
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
export const searchModalContentTemplate = `"use client";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import styled, { css, keyframes } from "styled-components";
|
|
4
|
+
import { rgba } from "polished";
|
|
5
|
+
import { Search } from "lucide-react";
|
|
6
|
+
import { mq, Theme } from "@/app/theme";
|
|
7
|
+
import { Spinner } from "@/components/Spinner";
|
|
8
|
+
|
|
9
|
+
export interface PageItem {
|
|
10
|
+
slug: string;
|
|
11
|
+
title: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
category: string;
|
|
14
|
+
section?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MergedResult {
|
|
18
|
+
page: PageItem;
|
|
19
|
+
snippet?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SearchModalContentProps {
|
|
23
|
+
isClosing: boolean;
|
|
24
|
+
closeSearch: () => void;
|
|
25
|
+
onCloseAnimationEnd: () => void;
|
|
26
|
+
query: string;
|
|
27
|
+
setQuery: (q: string) => void;
|
|
28
|
+
activeIndex: number;
|
|
29
|
+
setActiveIndex: (i: number | ((prev: number) => number)) => void;
|
|
30
|
+
resultsRef: React.RefObject<HTMLUListElement | null>;
|
|
31
|
+
onKeyDown: (e: React.KeyboardEvent) => void;
|
|
32
|
+
merged: MergedResult[];
|
|
33
|
+
sectionLabels: Record<string, string>;
|
|
34
|
+
isSearching: boolean;
|
|
35
|
+
navigate: (slug: string) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const ANIMATION_MS = 150;
|
|
39
|
+
|
|
40
|
+
const backdropIn = keyframes\`
|
|
41
|
+
from { opacity: 0; }
|
|
42
|
+
to { opacity: 1; }
|
|
43
|
+
\`;
|
|
44
|
+
|
|
45
|
+
const backdropOut = keyframes\`
|
|
46
|
+
from { opacity: 1; }
|
|
47
|
+
to { opacity: 0; }
|
|
48
|
+
\`;
|
|
49
|
+
|
|
50
|
+
const modalIn = keyframes\`
|
|
51
|
+
from { opacity: 0; transform: scale(0.96) translateY(-8px); }
|
|
52
|
+
to { opacity: 1; transform: scale(1) translateY(0); }
|
|
53
|
+
\`;
|
|
54
|
+
|
|
55
|
+
const modalOut = keyframes\`
|
|
56
|
+
from { opacity: 1; transform: scale(1) translateY(0); }
|
|
57
|
+
to { opacity: 0; transform: scale(0.96) translateY(-8px); }
|
|
58
|
+
\`;
|
|
59
|
+
|
|
60
|
+
const StyledBackdrop = styled.div<{ theme: Theme; $isClosing: boolean }>\`
|
|
61
|
+
position: fixed;
|
|
62
|
+
inset: 0;
|
|
63
|
+
z-index: 9999;
|
|
64
|
+
background: \${({ theme }) =>
|
|
65
|
+
rgba(theme.isDark ? theme.colors.light : theme.colors.dark, 0.5)};
|
|
66
|
+
backdrop-filter: blur(4px);
|
|
67
|
+
-webkit-backdrop-filter: blur(4px);
|
|
68
|
+
display: flex;
|
|
69
|
+
align-items: flex-start;
|
|
70
|
+
justify-content: center;
|
|
71
|
+
padding: 20px;
|
|
72
|
+
animation: \${({ $isClosing }) => ($isClosing ? backdropOut : backdropIn)}
|
|
73
|
+
\${ANIMATION_MS}ms ease forwards;
|
|
74
|
+
|
|
75
|
+
\${mq("lg")} {
|
|
76
|
+
padding: 120px 20px 20px 20px;
|
|
77
|
+
}
|
|
78
|
+
\`;
|
|
79
|
+
|
|
80
|
+
const StyledModal = styled.div<{ theme: Theme; $isClosing: boolean }>\`
|
|
81
|
+
background: \${({ theme }) => theme.colors.light};
|
|
82
|
+
border-radius: \${({ theme }) => theme.spacing.radius.lg};
|
|
83
|
+
box-shadow: \${({ theme }) => theme.shadows.xs};
|
|
84
|
+
width: 100%;
|
|
85
|
+
max-width: 560px;
|
|
86
|
+
max-height: calc(100dvh - 40px);
|
|
87
|
+
display: flex;
|
|
88
|
+
flex-direction: column;
|
|
89
|
+
border: solid 1px \${({ theme }) => theme.colors.grayLight};
|
|
90
|
+
padding-bottom: 8px;
|
|
91
|
+
animation: \${({ $isClosing }) => ($isClosing ? modalOut : modalIn)}
|
|
92
|
+
\${ANIMATION_MS}ms ease forwards;
|
|
93
|
+
|
|
94
|
+
\${mq("lg")} {
|
|
95
|
+
max-height: calc(100dvh - 240px);
|
|
96
|
+
}
|
|
97
|
+
\`;
|
|
98
|
+
|
|
99
|
+
const StyledInputWrapper = styled.div<{ theme: Theme }>\`
|
|
100
|
+
display: flex;
|
|
101
|
+
align-items: center;
|
|
102
|
+
gap: 12px;
|
|
103
|
+
padding: 16px;
|
|
104
|
+
flex-shrink: 0;
|
|
105
|
+
border-bottom: solid 1px \${({ theme }) => theme.colors.grayLight};
|
|
106
|
+
|
|
107
|
+
& svg.lucide {
|
|
108
|
+
color: \${({ theme }) => theme.colors.gray};
|
|
109
|
+
flex-shrink: 0;
|
|
110
|
+
}
|
|
111
|
+
\`;
|
|
112
|
+
|
|
113
|
+
const StyledInput = styled.input<{ theme: Theme }>\`
|
|
114
|
+
flex: 1;
|
|
115
|
+
border: none;
|
|
116
|
+
outline: none;
|
|
117
|
+
background: transparent;
|
|
118
|
+
font-size: \${({ theme }) => theme.fontSizes.text.lg};
|
|
119
|
+
line-height: \${({ theme }) => theme.lineHeights.text.lg};
|
|
120
|
+
color: \${({ theme }) => theme.colors.dark};
|
|
121
|
+
font-family: inherit;
|
|
122
|
+
|
|
123
|
+
&::placeholder {
|
|
124
|
+
color: \${({ theme }) => theme.colors.gray};
|
|
125
|
+
}
|
|
126
|
+
\`;
|
|
127
|
+
|
|
128
|
+
const StyledResults = styled.ul<{ theme: Theme }>\`
|
|
129
|
+
list-style: none;
|
|
130
|
+
margin: 8px 0 0 0;
|
|
131
|
+
padding: 0 8px;
|
|
132
|
+
overflow-y: auto;
|
|
133
|
+
flex: 1;
|
|
134
|
+
min-height: 0;
|
|
135
|
+
-webkit-overflow-scrolling: touch;
|
|
136
|
+
|
|
137
|
+
&::-webkit-scrollbar {
|
|
138
|
+
display: none;
|
|
139
|
+
}
|
|
140
|
+
\`;
|
|
141
|
+
|
|
142
|
+
const StyledResultItem = styled.li<{ theme: Theme; $isActive: boolean }>\`
|
|
143
|
+
padding: 10px 12px;
|
|
144
|
+
border-radius: \${({ theme }) => theme.spacing.radius.xs};
|
|
145
|
+
cursor: pointer;
|
|
146
|
+
transition: background 0.15s ease;
|
|
147
|
+
|
|
148
|
+
\${({ $isActive, theme }) =>
|
|
149
|
+
$isActive &&
|
|
150
|
+
css\`
|
|
151
|
+
background: \${rgba(theme.colors.primaryLight, 0.2)};
|
|
152
|
+
\`}
|
|
153
|
+
|
|
154
|
+
&:hover {
|
|
155
|
+
background: \${({ theme }) => rgba(theme.colors.primaryLight, 0.15)};
|
|
156
|
+
}
|
|
157
|
+
\`;
|
|
158
|
+
|
|
159
|
+
const StyledResultTitle = styled.span<{ theme: Theme }>\`
|
|
160
|
+
font-size: \${({ theme }) => theme.fontSizes.text.lg};
|
|
161
|
+
font-weight: 500;
|
|
162
|
+
color: \${({ theme }) => theme.colors.dark};
|
|
163
|
+
display: block;
|
|
164
|
+
\`;
|
|
165
|
+
|
|
166
|
+
const StyledResultMeta = styled.span<{ theme: Theme }>\`
|
|
167
|
+
font-size: \${({ theme }) => theme.fontSizes.small.lg};
|
|
168
|
+
color: \${({ theme }) => theme.colors.gray};
|
|
169
|
+
display: block;
|
|
170
|
+
margin-top: 2px;
|
|
171
|
+
\`;
|
|
172
|
+
|
|
173
|
+
const StyledSnippet = styled.span<{ theme: Theme }>\`
|
|
174
|
+
font-size: \${({ theme }) => theme.fontSizes.small.lg};
|
|
175
|
+
color: \${({ theme }) => theme.colors.grayDark};
|
|
176
|
+
display: block;
|
|
177
|
+
margin-top: 4px;
|
|
178
|
+
line-height: 1.4;
|
|
179
|
+
overflow: hidden;
|
|
180
|
+
text-overflow: ellipsis;
|
|
181
|
+
white-space: nowrap;
|
|
182
|
+
|
|
183
|
+
& mark {
|
|
184
|
+
background: \${({ theme }) => rgba(theme.colors.primaryLight, 0.35)};
|
|
185
|
+
color: inherit;
|
|
186
|
+
border-radius: 4px;
|
|
187
|
+
padding: 0 1px;
|
|
188
|
+
}
|
|
189
|
+
\`;
|
|
190
|
+
|
|
191
|
+
const StyledEmpty = styled.div<{ theme: Theme }>\`
|
|
192
|
+
padding: 20px 20px 12px;
|
|
193
|
+
min-height: 40px;
|
|
194
|
+
display: flex;
|
|
195
|
+
align-items: center;
|
|
196
|
+
justify-content: center;
|
|
197
|
+
text-align: center;
|
|
198
|
+
font-size: \${({ theme }) => theme.fontSizes.small.lg};
|
|
199
|
+
color: \${({ theme }) => theme.colors.gray};
|
|
200
|
+
\`;
|
|
201
|
+
|
|
202
|
+
const StyledKbd = styled.kbd<{ theme: Theme }>\`
|
|
203
|
+
font-size: 11px;
|
|
204
|
+
font-family: inherit;
|
|
205
|
+
background: \${({ theme }) => theme.colors.grayLight};
|
|
206
|
+
color: \${({ theme }) => theme.colors.grayDark};
|
|
207
|
+
padding: 2px 6px;
|
|
208
|
+
border-radius: 4px;
|
|
209
|
+
margin-left: auto;
|
|
210
|
+
font-weight: 600;
|
|
211
|
+
display: none;
|
|
212
|
+
|
|
213
|
+
\${mq("lg")} {
|
|
214
|
+
display: initial;
|
|
215
|
+
}
|
|
216
|
+
\`;
|
|
217
|
+
|
|
218
|
+
function escapeHtml(str: string): string {
|
|
219
|
+
return str
|
|
220
|
+
.replace(/&/g, "&")
|
|
221
|
+
.replace(/</g, "<")
|
|
222
|
+
.replace(/>/g, ">")
|
|
223
|
+
.replace(/"/g, """);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function highlightMatch(snippet: string, query: string): string {
|
|
227
|
+
const escaped = escapeHtml(snippet);
|
|
228
|
+
if (!query.trim()) return escaped;
|
|
229
|
+
const q = escapeHtml(query.trim());
|
|
230
|
+
const regex = new RegExp(
|
|
231
|
+
\`(\${q.replace(/[.*+?^\${}()|[\\]\\\\]/g, "\\\\$&")})\`,
|
|
232
|
+
"gi",
|
|
233
|
+
);
|
|
234
|
+
return escaped.replace(regex, "<mark>$1</mark>");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function SearchModalContent({
|
|
238
|
+
isClosing,
|
|
239
|
+
closeSearch,
|
|
240
|
+
onCloseAnimationEnd,
|
|
241
|
+
query,
|
|
242
|
+
setQuery,
|
|
243
|
+
activeIndex,
|
|
244
|
+
setActiveIndex,
|
|
245
|
+
resultsRef,
|
|
246
|
+
onKeyDown,
|
|
247
|
+
merged,
|
|
248
|
+
sectionLabels,
|
|
249
|
+
isSearching,
|
|
250
|
+
navigate,
|
|
251
|
+
}: SearchModalContentProps) {
|
|
252
|
+
return (
|
|
253
|
+
<StyledBackdrop
|
|
254
|
+
$isClosing={isClosing}
|
|
255
|
+
onClick={closeSearch}
|
|
256
|
+
onAnimationEnd={onCloseAnimationEnd}
|
|
257
|
+
>
|
|
258
|
+
<StyledModal $isClosing={isClosing} onClick={(e) => e.stopPropagation()}>
|
|
259
|
+
<StyledInputWrapper>
|
|
260
|
+
<Search size={18} />
|
|
261
|
+
<StyledInput
|
|
262
|
+
autoFocus
|
|
263
|
+
value={query}
|
|
264
|
+
onChange={(e) => {
|
|
265
|
+
setQuery(e.target.value);
|
|
266
|
+
setActiveIndex(0);
|
|
267
|
+
}}
|
|
268
|
+
onKeyDown={onKeyDown}
|
|
269
|
+
placeholder="Search docs..."
|
|
270
|
+
autoComplete="off"
|
|
271
|
+
spellCheck={false}
|
|
272
|
+
/>
|
|
273
|
+
<StyledKbd>Esc</StyledKbd>
|
|
274
|
+
</StyledInputWrapper>
|
|
275
|
+
{merged.length > 0 ? (
|
|
276
|
+
<StyledResults ref={resultsRef}>
|
|
277
|
+
{merged.map((result, index) => (
|
|
278
|
+
<StyledResultItem
|
|
279
|
+
key={result.page.slug + result.page.section}
|
|
280
|
+
$isActive={index === activeIndex}
|
|
281
|
+
onClick={() => navigate(result.page.slug)}
|
|
282
|
+
onMouseEnter={() => setActiveIndex(index)}
|
|
283
|
+
>
|
|
284
|
+
<StyledResultTitle>{result.page.title}</StyledResultTitle>
|
|
285
|
+
<StyledResultMeta>
|
|
286
|
+
{result.page.section
|
|
287
|
+
? \`\${sectionLabels[result.page.section] || result.page.section} / \`
|
|
288
|
+
: ""}
|
|
289
|
+
{result.page.category}
|
|
290
|
+
</StyledResultMeta>
|
|
291
|
+
{result.snippet && (
|
|
292
|
+
<StyledSnippet
|
|
293
|
+
dangerouslySetInnerHTML={{
|
|
294
|
+
__html: highlightMatch(result.snippet, query),
|
|
295
|
+
}}
|
|
296
|
+
/>
|
|
297
|
+
)}
|
|
298
|
+
</StyledResultItem>
|
|
299
|
+
))}
|
|
300
|
+
</StyledResults>
|
|
301
|
+
) : (
|
|
302
|
+
<StyledEmpty>
|
|
303
|
+
{isSearching ? <Spinner size={18} /> : "No results found"}
|
|
304
|
+
</StyledEmpty>
|
|
305
|
+
)}
|
|
306
|
+
</StyledModal>
|
|
307
|
+
</StyledBackdrop>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
`;
|