@opendocsdev/cli 0.2.0
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/LICENSE +661 -0
- package/README.md +300 -0
- package/dist/bin/opendocs.js +712 -0
- package/dist/bin/opendocs.js.map +1 -0
- package/dist/templates/api-reference.mdx +308 -0
- package/dist/templates/components.mdx +286 -0
- package/dist/templates/configuration.mdx +190 -0
- package/dist/templates/docs.json +27 -0
- package/dist/templates/introduction.mdx +25 -0
- package/dist/templates/logo.svg +4 -0
- package/dist/templates/quickstart.mdx +59 -0
- package/dist/templates/writing-content.mdx +236 -0
- package/package.json +92 -0
- package/src/engine/astro.config.ts +75 -0
- package/src/engine/src/components/Analytics.astro +57 -0
- package/src/engine/src/components/ApiPlayground.astro +24 -0
- package/src/engine/src/components/Callout.astro +66 -0
- package/src/engine/src/components/Card.astro +75 -0
- package/src/engine/src/components/CardGroup.astro +29 -0
- package/src/engine/src/components/CodeGroup.astro +231 -0
- package/src/engine/src/components/CopyButton.astro +179 -0
- package/src/engine/src/components/Steps.astro +27 -0
- package/src/engine/src/components/Tab.astro +21 -0
- package/src/engine/src/components/TableOfContents.astro +119 -0
- package/src/engine/src/components/Tabs.astro +135 -0
- package/src/engine/src/components/index.ts +107 -0
- package/src/engine/src/components/react/ApiPlayground/AuthSection.tsx +91 -0
- package/src/engine/src/components/react/ApiPlayground/CodeBlock.tsx +66 -0
- package/src/engine/src/components/react/ApiPlayground/CodeSnippets.tsx +66 -0
- package/src/engine/src/components/react/ApiPlayground/CollapsibleSection.tsx +26 -0
- package/src/engine/src/components/react/ApiPlayground/KeyValueEditor.tsx +58 -0
- package/src/engine/src/components/react/ApiPlayground/ResponseDisplay.tsx +109 -0
- package/src/engine/src/components/react/ApiPlayground/Spinner.tsx +6 -0
- package/src/engine/src/components/react/ApiPlayground/constants.ts +16 -0
- package/src/engine/src/components/react/ApiPlayground/generators.test.ts +130 -0
- package/src/engine/src/components/react/ApiPlayground/generators.ts +75 -0
- package/src/engine/src/components/react/ApiPlayground/index.tsx +490 -0
- package/src/engine/src/components/react/ApiPlayground/types.ts +35 -0
- package/src/engine/src/components/react/Callout.tsx +54 -0
- package/src/engine/src/components/react/Card.tsx +48 -0
- package/src/engine/src/components/react/CardGroup.tsx +24 -0
- package/src/engine/src/components/react/FeedbackWidget.tsx +166 -0
- package/src/engine/src/components/react/GitHubLink.tsx +28 -0
- package/src/engine/src/components/react/NavigationCard.tsx +53 -0
- package/src/engine/src/components/react/PageActions.tsx +124 -0
- package/src/engine/src/components/react/PageFooter.tsx +91 -0
- package/src/engine/src/components/react/SearchModal.tsx +358 -0
- package/src/engine/src/components/react/SearchProvider.tsx +37 -0
- package/src/engine/src/components/react/Sidebar.tsx +369 -0
- package/src/engine/src/components/react/SidebarSearchTrigger.tsx +57 -0
- package/src/engine/src/components/react/Steps.tsx +25 -0
- package/src/engine/src/components/react/ThemeToggle.tsx +72 -0
- package/src/engine/src/components/react/index.ts +14 -0
- package/src/engine/src/env.d.ts +10 -0
- package/src/engine/src/layouts/DocsLayout.astro +357 -0
- package/src/engine/src/lib/__tests__/markdown.test.ts +124 -0
- package/src/engine/src/lib/__tests__/mdx-loader.test.ts +205 -0
- package/src/engine/src/lib/config.ts +79 -0
- package/src/engine/src/lib/markdown.ts +54 -0
- package/src/engine/src/lib/mdx-loader.ts +143 -0
- package/src/engine/src/lib/mdx-utils.ts +72 -0
- package/src/engine/src/lib/remark-opendocs.ts +195 -0
- package/src/engine/src/lib/utils.ts +221 -0
- package/src/engine/src/pages/[...slug].astro +115 -0
- package/src/engine/src/pages/index.astro +71 -0
- package/src/engine/src/scripts/mobile-sidebar.ts +56 -0
- package/src/engine/src/scripts/theme-init.ts +25 -0
- package/src/engine/src/styles/global.css +703 -0
- package/src/engine/tailwind.config.mjs +60 -0
- package/src/engine/tsconfig.json +15 -0
- package/src/templates/api-reference.mdx +308 -0
- package/src/templates/components.mdx +286 -0
- package/src/templates/configuration.mdx +190 -0
- package/src/templates/docs.json +27 -0
- package/src/templates/introduction.mdx +25 -0
- package/src/templates/logo.svg +4 -0
- package/src/templates/quickstart.mdx +59 -0
- package/src/templates/writing-content.mdx +236 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { useEffect, useRef, useCallback, useReducer } from "react";
|
|
2
|
+
import { Search } from "lucide-react";
|
|
3
|
+
import { cn } from "../../lib/utils";
|
|
4
|
+
|
|
5
|
+
interface SearchResult {
|
|
6
|
+
url: string;
|
|
7
|
+
title: string;
|
|
8
|
+
excerpt: string;
|
|
9
|
+
breadcrumb?: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface SearchModalProps {
|
|
13
|
+
isOpen: boolean;
|
|
14
|
+
onClose: () => void;
|
|
15
|
+
backend?: string;
|
|
16
|
+
siteId?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Consolidated state with useReducer to avoid useState hell
|
|
20
|
+
interface SearchState {
|
|
21
|
+
query: string;
|
|
22
|
+
results: SearchResult[];
|
|
23
|
+
status: "idle" | "loading" | "error";
|
|
24
|
+
error: string | null;
|
|
25
|
+
selectedIndex: number;
|
|
26
|
+
pagefindLoaded: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type SearchAction =
|
|
30
|
+
| { type: "SET_QUERY"; payload: string }
|
|
31
|
+
| { type: "SEARCH_START" }
|
|
32
|
+
| { type: "SEARCH_SUCCESS"; payload: SearchResult[] }
|
|
33
|
+
| { type: "SEARCH_ERROR"; payload: string }
|
|
34
|
+
| { type: "CLEAR_RESULTS" }
|
|
35
|
+
| { type: "SET_SELECTED_INDEX"; payload: number }
|
|
36
|
+
| { type: "PAGEFIND_LOADED" }
|
|
37
|
+
| { type: "PAGEFIND_ERROR"; payload: string }
|
|
38
|
+
| { type: "RESET" };
|
|
39
|
+
|
|
40
|
+
const initialState: SearchState = {
|
|
41
|
+
query: "",
|
|
42
|
+
results: [],
|
|
43
|
+
status: "idle",
|
|
44
|
+
error: null,
|
|
45
|
+
selectedIndex: 0,
|
|
46
|
+
pagefindLoaded: false,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function searchReducer(state: SearchState, action: SearchAction): SearchState {
|
|
50
|
+
switch (action.type) {
|
|
51
|
+
case "SET_QUERY":
|
|
52
|
+
return { ...state, query: action.payload };
|
|
53
|
+
case "SEARCH_START":
|
|
54
|
+
return { ...state, status: "loading" };
|
|
55
|
+
case "SEARCH_SUCCESS":
|
|
56
|
+
// Reset selectedIndex when results change - no useEffect needed
|
|
57
|
+
return { ...state, status: "idle", results: action.payload, selectedIndex: 0 };
|
|
58
|
+
case "SEARCH_ERROR":
|
|
59
|
+
return { ...state, status: "error", error: action.payload };
|
|
60
|
+
case "CLEAR_RESULTS":
|
|
61
|
+
return { ...state, results: [], selectedIndex: 0 };
|
|
62
|
+
case "SET_SELECTED_INDEX":
|
|
63
|
+
return { ...state, selectedIndex: action.payload };
|
|
64
|
+
case "PAGEFIND_LOADED":
|
|
65
|
+
return { ...state, pagefindLoaded: true, error: null };
|
|
66
|
+
case "PAGEFIND_ERROR":
|
|
67
|
+
return { ...state, error: action.payload };
|
|
68
|
+
case "RESET":
|
|
69
|
+
return { ...initialState, pagefindLoaded: state.pagefindLoaded };
|
|
70
|
+
default:
|
|
71
|
+
return state;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function SearchModal({ isOpen, onClose, backend, siteId }: SearchModalProps) {
|
|
76
|
+
const [state, dispatch] = useReducer(searchReducer, initialState);
|
|
77
|
+
const { query, results, status, error, selectedIndex, pagefindLoaded } = state;
|
|
78
|
+
|
|
79
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
80
|
+
const resultsRef = useRef<HTMLDivElement>(null);
|
|
81
|
+
const pagefindRef = useRef<any>(null);
|
|
82
|
+
|
|
83
|
+
// Track the last search for analytics (only sent on meaningful actions)
|
|
84
|
+
const lastSearchRef = useRef<{ query: string; resultCount: number } | null>(null);
|
|
85
|
+
const hasTrackedRef = useRef(false);
|
|
86
|
+
|
|
87
|
+
const loadPagefind = async () => {
|
|
88
|
+
try {
|
|
89
|
+
// Use Function constructor to create a truly dynamic import that Vite won't analyze
|
|
90
|
+
const importPagefind = new Function(
|
|
91
|
+
'return import("/pagefind/pagefind.js")'
|
|
92
|
+
) as () => Promise<any>;
|
|
93
|
+
|
|
94
|
+
const pagefind = await importPagefind();
|
|
95
|
+
await pagefind.init();
|
|
96
|
+
pagefindRef.current = pagefind;
|
|
97
|
+
dispatch({ type: "PAGEFIND_LOADED" });
|
|
98
|
+
} catch {
|
|
99
|
+
dispatch({ type: "PAGEFIND_ERROR", payload: "Search not available. Run build to enable search." });
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Track search query for analytics - only tracks once per search session
|
|
104
|
+
const trackSearch = useCallback(() => {
|
|
105
|
+
if (!backend || !siteId) return;
|
|
106
|
+
if (hasTrackedRef.current) return;
|
|
107
|
+
if (!lastSearchRef.current) return;
|
|
108
|
+
|
|
109
|
+
const { query: searchQuery, resultCount } = lastSearchRef.current;
|
|
110
|
+
if (!searchQuery.trim()) return;
|
|
111
|
+
|
|
112
|
+
hasTrackedRef.current = true;
|
|
113
|
+
|
|
114
|
+
fetch(`${backend}/api/analytics/sq`, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: { "Content-Type": "application/json" },
|
|
117
|
+
body: JSON.stringify({
|
|
118
|
+
siteId,
|
|
119
|
+
query: searchQuery,
|
|
120
|
+
resultCount,
|
|
121
|
+
}),
|
|
122
|
+
}).catch(() => {
|
|
123
|
+
// Silently fail - analytics should not break search
|
|
124
|
+
});
|
|
125
|
+
}, [backend, siteId]);
|
|
126
|
+
|
|
127
|
+
// Handle close with analytics tracking
|
|
128
|
+
const handleClose = useCallback(() => {
|
|
129
|
+
trackSearch();
|
|
130
|
+
onClose();
|
|
131
|
+
}, [onClose, trackSearch]);
|
|
132
|
+
|
|
133
|
+
// Handle result selection with analytics tracking
|
|
134
|
+
const handleResultSelect = useCallback((url: string) => {
|
|
135
|
+
trackSearch();
|
|
136
|
+
window.location.href = url;
|
|
137
|
+
}, [trackSearch]);
|
|
138
|
+
|
|
139
|
+
const performSearch = useCallback(async (searchQuery: string) => {
|
|
140
|
+
if (!searchQuery.trim()) {
|
|
141
|
+
dispatch({ type: "CLEAR_RESULTS" });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!pagefindRef.current) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
dispatch({ type: "SEARCH_START" });
|
|
150
|
+
try {
|
|
151
|
+
const searchResults = await pagefindRef.current.search(searchQuery);
|
|
152
|
+
const formattedResults: SearchResult[] = await Promise.all(
|
|
153
|
+
searchResults.results.slice(0, 10).map(async (result: any) => {
|
|
154
|
+
const data = await result.data();
|
|
155
|
+
return {
|
|
156
|
+
url: data.url,
|
|
157
|
+
title: data.meta?.title || data.url,
|
|
158
|
+
excerpt: data.excerpt || "",
|
|
159
|
+
breadcrumb: data.url.split("/").filter(Boolean).slice(0, -1),
|
|
160
|
+
};
|
|
161
|
+
})
|
|
162
|
+
);
|
|
163
|
+
dispatch({ type: "SEARCH_SUCCESS", payload: formattedResults });
|
|
164
|
+
lastSearchRef.current = { query: searchQuery, resultCount: searchResults.results.length };
|
|
165
|
+
} catch (e) {
|
|
166
|
+
console.error("Search error:", e);
|
|
167
|
+
dispatch({ type: "SEARCH_ERROR", payload: "Search failed" });
|
|
168
|
+
}
|
|
169
|
+
}, []);
|
|
170
|
+
|
|
171
|
+
// Load Pagefind on first open
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (isOpen && !pagefindLoaded && !pagefindRef.current) {
|
|
174
|
+
loadPagefind();
|
|
175
|
+
}
|
|
176
|
+
}, [isOpen, pagefindLoaded]);
|
|
177
|
+
|
|
178
|
+
// Reset state and focus input when modal opens
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (isOpen) {
|
|
181
|
+
dispatch({ type: "RESET" });
|
|
182
|
+
lastSearchRef.current = null;
|
|
183
|
+
hasTrackedRef.current = false;
|
|
184
|
+
// Small delay to ensure modal is rendered before focusing
|
|
185
|
+
const timerId = setTimeout(() => inputRef.current?.focus(), 50);
|
|
186
|
+
return () => clearTimeout(timerId);
|
|
187
|
+
}
|
|
188
|
+
}, [isOpen]);
|
|
189
|
+
|
|
190
|
+
// Scroll selected item into view
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
if (resultsRef.current && results.length > 0) {
|
|
193
|
+
const selectedEl = resultsRef.current.querySelector(`[data-index="${selectedIndex}"]`);
|
|
194
|
+
selectedEl?.scrollIntoView({ block: "nearest" });
|
|
195
|
+
}
|
|
196
|
+
}, [selectedIndex, results.length]);
|
|
197
|
+
|
|
198
|
+
// Debounced search - this is a valid useEffect for external system sync (pagefind)
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
const timer = setTimeout(() => {
|
|
201
|
+
performSearch(query);
|
|
202
|
+
}, 150);
|
|
203
|
+
return () => clearTimeout(timer);
|
|
204
|
+
}, [query, performSearch]);
|
|
205
|
+
|
|
206
|
+
// Keyboard navigation - all handled in a single event handler, no separate useEffect
|
|
207
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
208
|
+
switch (e.key) {
|
|
209
|
+
case "ArrowDown":
|
|
210
|
+
e.preventDefault();
|
|
211
|
+
dispatch({ type: "SET_SELECTED_INDEX", payload: Math.min(selectedIndex + 1, results.length - 1) });
|
|
212
|
+
break;
|
|
213
|
+
case "ArrowUp":
|
|
214
|
+
e.preventDefault();
|
|
215
|
+
dispatch({ type: "SET_SELECTED_INDEX", payload: Math.max(selectedIndex - 1, 0) });
|
|
216
|
+
break;
|
|
217
|
+
case "Enter":
|
|
218
|
+
e.preventDefault();
|
|
219
|
+
if (results[selectedIndex]) {
|
|
220
|
+
handleResultSelect(results[selectedIndex].url);
|
|
221
|
+
}
|
|
222
|
+
break;
|
|
223
|
+
case "Escape":
|
|
224
|
+
e.preventDefault();
|
|
225
|
+
handleClose();
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
if (!isOpen) return null;
|
|
231
|
+
|
|
232
|
+
const isLoading = status === "loading";
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<div
|
|
236
|
+
className="fixed inset-0 z-50"
|
|
237
|
+
role="dialog"
|
|
238
|
+
aria-modal="true"
|
|
239
|
+
onKeyDown={(e) => {
|
|
240
|
+
// Global escape handler for clicking outside input
|
|
241
|
+
if (e.key === "Escape") {
|
|
242
|
+
e.preventDefault();
|
|
243
|
+
handleClose();
|
|
244
|
+
}
|
|
245
|
+
}}
|
|
246
|
+
>
|
|
247
|
+
{/* Backdrop */}
|
|
248
|
+
<div
|
|
249
|
+
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
|
250
|
+
onClick={handleClose}
|
|
251
|
+
aria-hidden="true"
|
|
252
|
+
/>
|
|
253
|
+
|
|
254
|
+
{/* Modal */}
|
|
255
|
+
<div className="relative flex flex-col max-w-2xl w-[calc(100%-1rem)] sm:w-[calc(100%-2rem)] mx-auto mt-4 sm:mt-[10vh] max-h-[85vh] sm:max-h-[70vh] bg-[var(--color-surface)] rounded-xl shadow-2xl overflow-hidden border border-[var(--color-border)]">
|
|
256
|
+
{/* Search input */}
|
|
257
|
+
<div className="flex items-center gap-3 px-4 py-3 border-b border-[var(--color-border)]">
|
|
258
|
+
<Search className="w-5 h-5 text-[var(--color-muted)] flex-shrink-0" />
|
|
259
|
+
<input
|
|
260
|
+
ref={inputRef}
|
|
261
|
+
type="text"
|
|
262
|
+
value={query}
|
|
263
|
+
onChange={(e) => dispatch({ type: "SET_QUERY", payload: e.target.value })}
|
|
264
|
+
onKeyDown={handleKeyDown}
|
|
265
|
+
placeholder="Search documentation..."
|
|
266
|
+
className="flex-1 bg-transparent text-[var(--color-foreground)] text-base placeholder:text-[var(--color-muted)] focus:outline-none"
|
|
267
|
+
autoComplete="off"
|
|
268
|
+
autoCorrect="off"
|
|
269
|
+
autoCapitalize="off"
|
|
270
|
+
spellCheck="false"
|
|
271
|
+
/>
|
|
272
|
+
<kbd className="hidden sm:flex items-center px-2 py-1 text-xs font-medium text-[var(--color-muted-foreground)] bg-[var(--color-surface-sunken)] border border-[var(--color-border)] rounded">
|
|
273
|
+
ESC
|
|
274
|
+
</kbd>
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
{/* Results */}
|
|
278
|
+
<div ref={resultsRef} className="flex-1 overflow-y-auto">
|
|
279
|
+
{error ? (
|
|
280
|
+
<div className="p-8 text-center">
|
|
281
|
+
<p className="text-sm text-[var(--color-muted)]">{error}</p>
|
|
282
|
+
</div>
|
|
283
|
+
) : isLoading ? (
|
|
284
|
+
<div className="p-8 text-center">
|
|
285
|
+
<div className="inline-block w-5 h-5 border-2 border-[var(--color-primary)] border-t-transparent rounded-full animate-spin" />
|
|
286
|
+
</div>
|
|
287
|
+
) : results.length > 0 ? (
|
|
288
|
+
<ul className="py-2">
|
|
289
|
+
{results.map((result, index) => (
|
|
290
|
+
<li key={result.url} data-index={index}>
|
|
291
|
+
<a
|
|
292
|
+
href={result.url}
|
|
293
|
+
onClick={(e) => {
|
|
294
|
+
e.preventDefault();
|
|
295
|
+
handleResultSelect(result.url);
|
|
296
|
+
}}
|
|
297
|
+
className={cn(
|
|
298
|
+
"block px-4 py-3 transition-colors",
|
|
299
|
+
index === selectedIndex
|
|
300
|
+
? "bg-[var(--color-primary-light)]"
|
|
301
|
+
: "hover:bg-[var(--color-surface-sunken)]"
|
|
302
|
+
)}
|
|
303
|
+
>
|
|
304
|
+
{result.breadcrumb && result.breadcrumb.length > 0 && (
|
|
305
|
+
<div className="flex items-center gap-1 text-xs text-[var(--color-muted)] mb-1">
|
|
306
|
+
{result.breadcrumb.map((crumb, i) => (
|
|
307
|
+
<span key={i} className="flex items-center gap-1">
|
|
308
|
+
{i > 0 && <span className="text-[var(--color-border)]">›</span>}
|
|
309
|
+
<span className="capitalize">{crumb.replace(/-/g, " ")}</span>
|
|
310
|
+
</span>
|
|
311
|
+
))}
|
|
312
|
+
</div>
|
|
313
|
+
)}
|
|
314
|
+
<div className="font-medium text-[var(--color-foreground)]">
|
|
315
|
+
{result.title}
|
|
316
|
+
</div>
|
|
317
|
+
{result.excerpt && (
|
|
318
|
+
<p
|
|
319
|
+
className="mt-1 text-sm text-[var(--color-muted)] line-clamp-2 [&_mark]:bg-[var(--color-primary-light)] [&_mark]:text-[var(--color-primary)] [&_mark]:rounded [&_mark]:px-0.5"
|
|
320
|
+
dangerouslySetInnerHTML={{ __html: result.excerpt }}
|
|
321
|
+
/>
|
|
322
|
+
)}
|
|
323
|
+
</a>
|
|
324
|
+
</li>
|
|
325
|
+
))}
|
|
326
|
+
</ul>
|
|
327
|
+
) : query.trim() ? (
|
|
328
|
+
<div className="p-8 text-center">
|
|
329
|
+
<p className="text-sm text-[var(--color-muted)]">No results found for "{query}"</p>
|
|
330
|
+
</div>
|
|
331
|
+
) : (
|
|
332
|
+
<div className="p-8 text-center">
|
|
333
|
+
<p className="text-sm text-[var(--color-muted)]">Start typing to search...</p>
|
|
334
|
+
</div>
|
|
335
|
+
)}
|
|
336
|
+
</div>
|
|
337
|
+
|
|
338
|
+
{/* Footer */}
|
|
339
|
+
<div className="flex items-center justify-between px-4 py-2 border-t border-[var(--color-border)] text-xs text-[var(--color-muted-foreground)]">
|
|
340
|
+
<div className="flex items-center gap-3">
|
|
341
|
+
<span className="flex items-center gap-1">
|
|
342
|
+
<kbd className="px-1.5 py-0.5 bg-[var(--color-surface-sunken)] border border-[var(--color-border)] rounded text-[10px]">↑</kbd>
|
|
343
|
+
<kbd className="px-1.5 py-0.5 bg-[var(--color-surface-sunken)] border border-[var(--color-border)] rounded text-[10px]">↓</kbd>
|
|
344
|
+
<span className="ml-1">to navigate</span>
|
|
345
|
+
</span>
|
|
346
|
+
<span className="flex items-center gap-1">
|
|
347
|
+
<kbd className="px-1.5 py-0.5 bg-[var(--color-surface-sunken)] border border-[var(--color-border)] rounded text-[10px]">↵</kbd>
|
|
348
|
+
<span className="ml-1">to select</span>
|
|
349
|
+
</span>
|
|
350
|
+
</div>
|
|
351
|
+
<span>Powered by Pagefind</span>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export default SearchModal;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useEffect, useState, useCallback } from "react";
|
|
2
|
+
import { SearchModal } from "./SearchModal";
|
|
3
|
+
|
|
4
|
+
interface SearchProviderProps {
|
|
5
|
+
backend?: string;
|
|
6
|
+
siteId?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function SearchProvider({ backend, siteId }: SearchProviderProps) {
|
|
10
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
11
|
+
|
|
12
|
+
const openSearch = useCallback(() => setIsOpen(true), []);
|
|
13
|
+
const closeSearch = useCallback(() => setIsOpen(false), []);
|
|
14
|
+
|
|
15
|
+
// Global keyboard shortcut: Cmd+K / Ctrl+K
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
18
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
|
19
|
+
e.preventDefault();
|
|
20
|
+
setIsOpen((prev) => !prev);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
25
|
+
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
// Listen for custom event from search triggers
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
document.addEventListener("open-search", openSearch);
|
|
31
|
+
return () => document.removeEventListener("open-search", openSearch);
|
|
32
|
+
}, [openSearch]);
|
|
33
|
+
|
|
34
|
+
return <SearchModal isOpen={isOpen} onClose={closeSearch} backend={backend} siteId={siteId} />;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default SearchProvider;
|