doccupine 0.0.83 → 0.0.84
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 +40 -270
- package/dist/templates/components/SearchModalContent.d.ts +1 -0
- package/dist/templates/components/SearchModalContent.js +326 -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/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/theme.mdx.d.ts +1 -1
- package/dist/templates/mdx/theme.mdx.js +1 -1
- package/dist/templates/package.js +10 -10
- 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 +3 -3
|
@@ -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,
|
|
@@ -284,7 +95,7 @@ function SearchProvider({
|
|
|
284
95
|
const [isSearching, setIsSearching] = useState(false);
|
|
285
96
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
286
97
|
const resultsRef = useRef<HTMLUListElement>(null);
|
|
287
|
-
const
|
|
98
|
+
const closingRef = useRef(false);
|
|
288
99
|
const abortRef = useRef<AbortController | null>(null);
|
|
289
100
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
|
290
101
|
const router = useRouter();
|
|
@@ -298,23 +109,27 @@ function SearchProvider({
|
|
|
298
109
|
}, [sections]);
|
|
299
110
|
|
|
300
111
|
const openSearch = useCallback(() => {
|
|
301
|
-
|
|
112
|
+
closingRef.current = false;
|
|
302
113
|
setIsClosing(false);
|
|
303
114
|
setIsVisible(true);
|
|
304
115
|
}, []);
|
|
305
116
|
|
|
306
117
|
const closeSearch = useCallback(() => {
|
|
118
|
+
closingRef.current = true;
|
|
307
119
|
setIsClosing(true);
|
|
308
120
|
if (abortRef.current) abortRef.current.abort();
|
|
309
121
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
124
|
+
const handleCloseAnimationEnd = useCallback(() => {
|
|
125
|
+
if (!closingRef.current) return;
|
|
126
|
+
closingRef.current = false;
|
|
127
|
+
setIsVisible(false);
|
|
128
|
+
setIsClosing(false);
|
|
129
|
+
setQuery("");
|
|
130
|
+
setActiveIndex(0);
|
|
131
|
+
setContentResults([]);
|
|
132
|
+
setIsSearching(false);
|
|
318
133
|
}, []);
|
|
319
134
|
|
|
320
135
|
// Instant title/description filtering
|
|
@@ -423,13 +238,6 @@ function SearchProvider({
|
|
|
423
238
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
424
239
|
}, [closeSearch, openSearch]);
|
|
425
240
|
|
|
426
|
-
// Focus input on open
|
|
427
|
-
useEffect(() => {
|
|
428
|
-
if (isVisible && !isClosing) {
|
|
429
|
-
setTimeout(() => inputRef.current?.focus(), 10);
|
|
430
|
-
}
|
|
431
|
-
}, [isVisible, isClosing]);
|
|
432
|
-
|
|
433
241
|
// Scroll active item into view
|
|
434
242
|
useEffect(() => {
|
|
435
243
|
if (!resultsRef.current) return;
|
|
@@ -458,60 +266,22 @@ function SearchProvider({
|
|
|
458
266
|
<SearchContext.Provider value={{ openSearch }}>
|
|
459
267
|
{children}
|
|
460
268
|
{isVisible && (
|
|
461
|
-
<
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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>
|
|
269
|
+
<SearchModalContent
|
|
270
|
+
isClosing={isClosing}
|
|
271
|
+
closeSearch={closeSearch}
|
|
272
|
+
onCloseAnimationEnd={handleCloseAnimationEnd}
|
|
273
|
+
query={query}
|
|
274
|
+
setQuery={setQuery}
|
|
275
|
+
activeIndex={activeIndex}
|
|
276
|
+
setActiveIndex={setActiveIndex}
|
|
277
|
+
inputRef={inputRef}
|
|
278
|
+
resultsRef={resultsRef}
|
|
279
|
+
onKeyDown={handleKeyDown}
|
|
280
|
+
merged={merged}
|
|
281
|
+
sectionLabels={sectionLabels}
|
|
282
|
+
isSearching={isSearching}
|
|
283
|
+
navigate={navigate}
|
|
284
|
+
/>
|
|
515
285
|
)}
|
|
516
286
|
</SearchContext.Provider>
|
|
517
287
|
);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const searchModalContentTemplate = "\"use client\";\nimport React, { useCallback } 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 inputRef: React.RefObject<HTMLInputElement | null>;\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 inputRef,\n resultsRef,\n onKeyDown,\n merged,\n sectionLabels,\n isSearching,\n navigate,\n}: SearchModalContentProps) {\n const setInputRef = useCallback(\n (node: HTMLInputElement | null) => {\n // Sync with parent ref\n if (inputRef) {\n (inputRef as React.RefObject<HTMLInputElement | null>).current = node;\n }\n // Auto-focus on mount\n if (node && !isClosing) {\n node.focus();\n }\n },\n [inputRef, isClosing],\n );\n\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 ref={setInputRef}\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";
|