@tomehq/theme 0.1.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 +21 -0
- package/dist/chunk-BZGWSKT2.js +573 -0
- package/dist/chunk-FWBTK5TL.js +1444 -0
- package/dist/chunk-JZRT4WNC.js +1441 -0
- package/dist/chunk-LIMYFTPC.js +1468 -0
- package/dist/chunk-MEP7P6A7.js +1500 -0
- package/dist/chunk-QCWZYABW.js +1468 -0
- package/dist/chunk-RKTT3ZEX.js +1500 -0
- package/dist/chunk-UKYFJSUA.js +509 -0
- package/dist/entry.d.ts +5 -0
- package/dist/entry.js +6 -0
- package/dist/index.d.ts +200 -0
- package/dist/index.js +12 -0
- package/package.json +52 -0
- package/src/AiChat.test.tsx +308 -0
- package/src/AiChat.tsx +439 -0
- package/src/Shell.test.tsx +565 -0
- package/src/Shell.tsx +827 -0
- package/src/entry.tsx +191 -0
- package/src/global.d.ts +22 -0
- package/src/index.tsx +6 -0
- package/src/presets.test.ts +59 -0
- package/src/presets.ts +51 -0
- package/src/test-setup.ts +5 -0
- package/src/virtual.d.ts +14 -0
- package/tsconfig.json +6 -0
- package/vitest.config.ts +21 -0
package/src/Shell.tsx
ADDED
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef, useCallback } from "react";
|
|
2
|
+
import { THEME_PRESETS, type PresetName } from "./presets.js";
|
|
3
|
+
import { AiChat } from "./AiChat.js";
|
|
4
|
+
|
|
5
|
+
// ── ACCENT PALETTE GENERATION (TOM-12) ───────────────────
|
|
6
|
+
function hexToRgb(hex: string): [number, number, number] | null {
|
|
7
|
+
const m = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
|
8
|
+
if (!m) return null;
|
|
9
|
+
const n = parseInt(m[1], 16);
|
|
10
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function buildAccentOverride(hex: string, isDark: boolean) {
|
|
14
|
+
const rgb = hexToRgb(hex);
|
|
15
|
+
if (!rgb) return null;
|
|
16
|
+
const [r, g, b] = rgb;
|
|
17
|
+
// Dim variant: low-opacity fill
|
|
18
|
+
const acD = `rgba(${r},${g},${b},${isDark ? 0.12 : 0.08})`;
|
|
19
|
+
// Hover variant: slightly lighter in dark, darker in light
|
|
20
|
+
const factor = isDark ? 1.15 : 0.85;
|
|
21
|
+
const tr = Math.min(255, Math.round(r * factor));
|
|
22
|
+
const tg = Math.min(255, Math.round(g * factor));
|
|
23
|
+
const tb = Math.min(255, Math.round(b * factor));
|
|
24
|
+
const acT = `rgb(${tr},${tg},${tb})`;
|
|
25
|
+
return { ac: hex, acD, acT };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── ICONS ────────────────────────────────────────────────
|
|
29
|
+
const Icon = ({ d, size = 16 }: { d: string; size?: number }) => (
|
|
30
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
|
|
31
|
+
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
32
|
+
<path d={d} />
|
|
33
|
+
</svg>
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const SearchIcon = () => <Icon d="M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16ZM21 21l-4.3-4.3" />;
|
|
37
|
+
const ChevRight = () => <Icon d="M9 18l6-6-6-6" size={14} />;
|
|
38
|
+
const ChevDown = () => <Icon d="M6 9l6 6 6-6" size={14} />;
|
|
39
|
+
const CopyIcon = () => <Icon d="M9 9h13v13H9zM5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" size={14} />;
|
|
40
|
+
const CheckIcon = () => <Icon d="M20 6L9 17l-5-5" size={14} />;
|
|
41
|
+
const MenuIcon = () => <Icon d="M3 12h18M3 6h18M3 18h18" size={20} />;
|
|
42
|
+
const XIcon = () => <Icon d="M18 6L6 18M6 6l12 12" size={18} />;
|
|
43
|
+
const MoonIcon = () => <Icon d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />;
|
|
44
|
+
const SunIcon = () => <Icon d="M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10Z" />;
|
|
45
|
+
const ArrowLeft = () => <Icon d="M19 12H5M12 19l-7-7 7-7" size={14} />;
|
|
46
|
+
const ArrowRight = () => <Icon d="M5 12h14M12 5l7 7-7 7" size={14} />;
|
|
47
|
+
|
|
48
|
+
// ── PAGEFIND CLIENT (TOM-15) ─────────────────────────────
|
|
49
|
+
let pagefindInstance: any = null;
|
|
50
|
+
const PAGEFIND_PATH = "/_pagefind/pagefind.js";
|
|
51
|
+
async function initPagefind(): Promise<any> {
|
|
52
|
+
if (pagefindInstance) return pagefindInstance;
|
|
53
|
+
try {
|
|
54
|
+
// Dynamic path variable prevents Vite from resolving at build time
|
|
55
|
+
pagefindInstance = await import(/* @vite-ignore */ PAGEFIND_PATH);
|
|
56
|
+
await pagefindInstance.init();
|
|
57
|
+
return pagefindInstance;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── ALGOLIA DOCSEARCH (TOM-16) ──────────────────────────
|
|
64
|
+
let docSearchLoaded: Promise<any> | null = null;
|
|
65
|
+
function loadDocSearch(): Promise<any> {
|
|
66
|
+
if (docSearchLoaded) return docSearchLoaded;
|
|
67
|
+
docSearchLoaded = import("@docsearch/react").catch(() => null);
|
|
68
|
+
return docSearchLoaded;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function AlgoliaSearchModal({
|
|
72
|
+
appId,
|
|
73
|
+
apiKey,
|
|
74
|
+
indexName,
|
|
75
|
+
onNavigate,
|
|
76
|
+
onClose,
|
|
77
|
+
}: {
|
|
78
|
+
appId: string;
|
|
79
|
+
apiKey: string;
|
|
80
|
+
indexName: string;
|
|
81
|
+
onNavigate: (id: string) => void;
|
|
82
|
+
onClose: () => void;
|
|
83
|
+
}) {
|
|
84
|
+
const [DocSearchComponent, setDocSearchComponent] = useState<React.ComponentType<any> | null>(null);
|
|
85
|
+
const [loadFailed, setLoadFailed] = useState(false);
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
loadDocSearch().then((mod) => {
|
|
89
|
+
if (mod && mod.DocSearch) {
|
|
90
|
+
setDocSearchComponent(() => mod.DocSearch);
|
|
91
|
+
} else if (mod && mod.default) {
|
|
92
|
+
setDocSearchComponent(() => mod.default);
|
|
93
|
+
} else {
|
|
94
|
+
setLoadFailed(true);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
// Extract page ID from a DocSearch result URL
|
|
100
|
+
const extractPageId = useCallback((url: string): string => {
|
|
101
|
+
try {
|
|
102
|
+
const parsed = new URL(url, "http://localhost");
|
|
103
|
+
const pathname = parsed.pathname;
|
|
104
|
+
return pathname
|
|
105
|
+
.replace(/^\//, "")
|
|
106
|
+
.replace(/\/index\.html$/, "")
|
|
107
|
+
.replace(/\.html$/, "")
|
|
108
|
+
|| "index";
|
|
109
|
+
} catch {
|
|
110
|
+
return "index";
|
|
111
|
+
}
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
if (loadFailed) {
|
|
115
|
+
return (
|
|
116
|
+
<div onClick={onClose} style={{
|
|
117
|
+
position: "fixed", inset: 0, zIndex: 1000, background: "rgba(0,0,0,0.55)",
|
|
118
|
+
backdropFilter: "blur(6px)", display: "flex", alignItems: "flex-start",
|
|
119
|
+
justifyContent: "center", paddingTop: "12vh",
|
|
120
|
+
}}>
|
|
121
|
+
<div onClick={e => e.stopPropagation()} style={{
|
|
122
|
+
background: "var(--sf)", border: "1px solid var(--bd)", borderRadius: 12,
|
|
123
|
+
width: "100%", maxWidth: 520, boxShadow: "0 24px 80px rgba(0,0,0,0.4)",
|
|
124
|
+
padding: "32px 18px", textAlign: "center", color: "var(--txM)", fontSize: 14,
|
|
125
|
+
}}>
|
|
126
|
+
Algolia DocSearch is not available. Install @docsearch/react to enable it.
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!DocSearchComponent) {
|
|
133
|
+
// Loading state
|
|
134
|
+
return (
|
|
135
|
+
<div onClick={onClose} style={{
|
|
136
|
+
position: "fixed", inset: 0, zIndex: 1000, background: "rgba(0,0,0,0.55)",
|
|
137
|
+
backdropFilter: "blur(6px)", display: "flex", alignItems: "flex-start",
|
|
138
|
+
justifyContent: "center", paddingTop: "12vh",
|
|
139
|
+
}}>
|
|
140
|
+
<div style={{
|
|
141
|
+
background: "var(--sf)", border: "1px solid var(--bd)", borderRadius: 12,
|
|
142
|
+
width: "100%", maxWidth: 520, boxShadow: "0 24px 80px rgba(0,0,0,0.4)",
|
|
143
|
+
padding: "32px 18px", textAlign: "center", color: "var(--txM)", fontSize: 14,
|
|
144
|
+
}}>
|
|
145
|
+
Loading search...
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div data-testid="algolia-search-modal">
|
|
153
|
+
<DocSearchComponent
|
|
154
|
+
appId={appId}
|
|
155
|
+
apiKey={apiKey}
|
|
156
|
+
indexName={indexName}
|
|
157
|
+
navigator={{
|
|
158
|
+
navigate({ itemUrl }: { itemUrl: string }) {
|
|
159
|
+
const pageId = extractPageId(itemUrl);
|
|
160
|
+
onNavigate(pageId);
|
|
161
|
+
},
|
|
162
|
+
}}
|
|
163
|
+
hitComponent={({ hit, children }: { hit: { url: string }; children: React.ReactNode }) => (
|
|
164
|
+
<a href={hit.url} onClick={(e: React.MouseEvent) => {
|
|
165
|
+
e.preventDefault();
|
|
166
|
+
const pageId = extractPageId(hit.url);
|
|
167
|
+
onNavigate(pageId);
|
|
168
|
+
}}>
|
|
169
|
+
{children}
|
|
170
|
+
</a>
|
|
171
|
+
)}
|
|
172
|
+
/>
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── VERSION SWITCHER ICON (TOM-30) ───────────────────────
|
|
178
|
+
const VersionIcon = () => <Icon d="M12 8v4l3 3m6-3a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" size={14} />;
|
|
179
|
+
|
|
180
|
+
// ── LANGUAGE SWITCHER ICON (TOM-34) ──────────────────────
|
|
181
|
+
const GlobeIcon = () => <Icon d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM3.6 9h16.8M3.6 15h16.8M12 3a15 15 0 0 1 4 9 15 15 0 0 1-4 9 15 15 0 0 1-4-9 15 15 0 0 1 4-9Z" size={14} />;
|
|
182
|
+
|
|
183
|
+
// ── TOP NAV EXTERNAL LINK ICON ────────────────────────────
|
|
184
|
+
const ExtLinkIcon = () => <Icon d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3" size={11} />;
|
|
185
|
+
|
|
186
|
+
// ── SHELL COMPONENT ──────────────────────────────────────
|
|
187
|
+
export interface VersioningInfo {
|
|
188
|
+
current: string;
|
|
189
|
+
versions: string[];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface I18nInfo {
|
|
193
|
+
defaultLocale: string;
|
|
194
|
+
locales: string[];
|
|
195
|
+
localeNames?: Record<string, string>;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
interface ShellProps {
|
|
199
|
+
config: {
|
|
200
|
+
name: string;
|
|
201
|
+
theme?: { preset?: string; mode?: string; accent?: string; fonts?: { heading?: string; body?: string; code?: string } };
|
|
202
|
+
search?: { provider?: string; appId?: string; apiKey?: string; indexName?: string };
|
|
203
|
+
ai?: { enabled?: boolean; provider?: "openai" | "anthropic" | "custom"; model?: string; apiKeyEnv?: string };
|
|
204
|
+
topNav?: Array<{ label: string; href: string }>;
|
|
205
|
+
[key: string]: unknown;
|
|
206
|
+
};
|
|
207
|
+
navigation: Array<{
|
|
208
|
+
section: string;
|
|
209
|
+
pages: Array<{ title: string; id: string; urlPath: string; icon?: string }>;
|
|
210
|
+
}>;
|
|
211
|
+
currentPageId: string;
|
|
212
|
+
pageHtml?: string;
|
|
213
|
+
pageComponent?: React.ComponentType<{ components?: Record<string, React.ComponentType> }>;
|
|
214
|
+
mdxComponents?: Record<string, React.ComponentType>;
|
|
215
|
+
pageTitle: string;
|
|
216
|
+
pageDescription?: string;
|
|
217
|
+
headings: Array<{ depth: number; text: string; id: string }>;
|
|
218
|
+
onNavigate: (id: string) => void;
|
|
219
|
+
allPages: Array<{ id: string; title: string; description?: string }>;
|
|
220
|
+
versioning?: VersioningInfo;
|
|
221
|
+
currentVersion?: string;
|
|
222
|
+
i18n?: I18nInfo;
|
|
223
|
+
currentLocale?: string;
|
|
224
|
+
docContext?: Array<{ id: string; title: string; content: string }>;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function Shell({
|
|
228
|
+
config, navigation, currentPageId, pageHtml, pageComponent, mdxComponents,
|
|
229
|
+
pageTitle, pageDescription, headings, onNavigate, allPages,
|
|
230
|
+
versioning, currentVersion, i18n, currentLocale, docContext,
|
|
231
|
+
}: ShellProps) {
|
|
232
|
+
const themeMode = config.theme?.mode || "auto";
|
|
233
|
+
|
|
234
|
+
// TOM-12: Initialize dark mode from config.theme.mode + system preference
|
|
235
|
+
const [isDark, setDark] = useState(() => {
|
|
236
|
+
if (themeMode === "dark") return true;
|
|
237
|
+
if (themeMode === "light") return false;
|
|
238
|
+
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? true;
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const [sbOpen, setSb] = useState(true);
|
|
242
|
+
const [searchOpen, setSearch] = useState(false);
|
|
243
|
+
const [versionDropdownOpen, setVersionDropdown] = useState(false);
|
|
244
|
+
const [localeDropdownOpen, setLocaleDropdown] = useState(false);
|
|
245
|
+
|
|
246
|
+
// TOM-30: Determine if viewing an old version
|
|
247
|
+
const isOldVersion = versioning && currentVersion && currentVersion !== versioning.current;
|
|
248
|
+
const [expanded, setExpanded] = useState<string[]>(navigation.map(n => n.section));
|
|
249
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
250
|
+
const [wide, setWide] = useState(true);
|
|
251
|
+
|
|
252
|
+
const preset = (config.theme?.preset || "amber") as PresetName;
|
|
253
|
+
const baseTokens = THEME_PRESETS[preset]?.[isDark ? "dark" : "light"] || THEME_PRESETS.amber.dark;
|
|
254
|
+
|
|
255
|
+
// TOM-12: Custom accent color override
|
|
256
|
+
const accentOverride = config.theme?.accent
|
|
257
|
+
? buildAccentOverride(config.theme.accent, isDark)
|
|
258
|
+
: null;
|
|
259
|
+
|
|
260
|
+
const t = accentOverride
|
|
261
|
+
? { ...baseTokens, ...accentOverride }
|
|
262
|
+
: baseTokens;
|
|
263
|
+
|
|
264
|
+
// TOM-12: Custom font override
|
|
265
|
+
const presetFonts = THEME_PRESETS[preset]?.fonts || THEME_PRESETS.amber.fonts;
|
|
266
|
+
const fonts = {
|
|
267
|
+
heading: config.theme?.fonts?.heading || presetFonts.heading,
|
|
268
|
+
body: config.theme?.fonts?.body || presetFonts.body,
|
|
269
|
+
code: config.theme?.fonts?.code || presetFonts.code,
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// TOM-12: Listen to system preference changes when mode is "auto"
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
if (themeMode !== "auto") return;
|
|
275
|
+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
276
|
+
const handler = (e: MediaQueryListEvent) => setDark(e.matches);
|
|
277
|
+
mq.addEventListener("change", handler);
|
|
278
|
+
return () => mq.removeEventListener("change", handler);
|
|
279
|
+
}, [themeMode]);
|
|
280
|
+
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
const c = () => setWide(window.innerWidth > 1100);
|
|
283
|
+
c(); window.addEventListener("resize", c);
|
|
284
|
+
return () => window.removeEventListener("resize", c);
|
|
285
|
+
}, []);
|
|
286
|
+
|
|
287
|
+
useEffect(() => { contentRef.current?.scrollTo(0, 0); }, [currentPageId]);
|
|
288
|
+
|
|
289
|
+
useEffect(() => {
|
|
290
|
+
const h = (e: KeyboardEvent) => {
|
|
291
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); setSearch(true); }
|
|
292
|
+
if (e.key === "Escape") setSearch(false);
|
|
293
|
+
};
|
|
294
|
+
window.addEventListener("keydown", h);
|
|
295
|
+
return () => window.removeEventListener("keydown", h);
|
|
296
|
+
}, []);
|
|
297
|
+
|
|
298
|
+
// Prev / Next
|
|
299
|
+
const allNavPages = navigation.flatMap(g => g.pages);
|
|
300
|
+
const idx = allNavPages.findIndex(p => p.id === currentPageId);
|
|
301
|
+
const prev = idx > 0 ? allNavPages[idx - 1] : null;
|
|
302
|
+
const next = idx < allNavPages.length - 1 ? allNavPages[idx + 1] : null;
|
|
303
|
+
|
|
304
|
+
const togSec = (s: string) => setExpanded(p => p.includes(s) ? p.filter(x => x !== s) : [...p, s]);
|
|
305
|
+
|
|
306
|
+
const cssVars: Record<string, string> = {
|
|
307
|
+
"--bg": t.bg, "--sf": t.sf, "--sfH": t.sfH, "--bd": t.bd,
|
|
308
|
+
"--tx": t.tx, "--tx2": t.tx2, "--txM": t.txM,
|
|
309
|
+
"--ac": t.ac, "--acD": t.acD, "--acT": t.acT,
|
|
310
|
+
"--cdBg": t.cdBg, "--cdTx": t.cdTx, "--sbBg": t.sbBg, "--hdBg": t.hdBg,
|
|
311
|
+
"--font-heading": `"${fonts.heading}", serif`,
|
|
312
|
+
"--font-body": `"${fonts.body}", sans-serif`,
|
|
313
|
+
"--font-code": `"${fonts.code}", monospace`,
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const PageComponent = pageComponent;
|
|
317
|
+
|
|
318
|
+
return (
|
|
319
|
+
<div className="tome-grain" style={{ ...cssVars as React.CSSProperties, color: "var(--tx)", background: "var(--bg)", fontFamily: "var(--font-body)", minHeight: "100vh" }}>
|
|
320
|
+
{/* Search Modal (TOM-16: branch on provider) */}
|
|
321
|
+
{searchOpen && config.search?.provider === "algolia" && config.search.appId && config.search.apiKey && config.search.indexName ? (
|
|
322
|
+
<AlgoliaSearchModal
|
|
323
|
+
appId={config.search.appId}
|
|
324
|
+
apiKey={config.search.apiKey}
|
|
325
|
+
indexName={config.search.indexName}
|
|
326
|
+
onNavigate={(id) => { onNavigate(id); setSearch(false); }}
|
|
327
|
+
onClose={() => setSearch(false)}
|
|
328
|
+
/>
|
|
329
|
+
) : searchOpen ? (
|
|
330
|
+
<SearchModal
|
|
331
|
+
allPages={allPages}
|
|
332
|
+
onNavigate={(id) => { onNavigate(id); setSearch(false); }}
|
|
333
|
+
onClose={() => setSearch(false)}
|
|
334
|
+
/>
|
|
335
|
+
) : null}
|
|
336
|
+
|
|
337
|
+
<div style={{ display: "flex", height: "100vh" }}>
|
|
338
|
+
{/* Sidebar */}
|
|
339
|
+
<aside style={{
|
|
340
|
+
width: sbOpen ? 270 : 0, minWidth: sbOpen ? 270 : 0,
|
|
341
|
+
background: "var(--sbBg)", borderRight: "1px solid var(--bd)",
|
|
342
|
+
display: "flex", flexDirection: "column",
|
|
343
|
+
transition: "width .2s, min-width .2s", overflow: "hidden",
|
|
344
|
+
}}>
|
|
345
|
+
<div style={{ padding: "18px 20px", display: "flex", alignItems: "baseline", gap: 6, borderBottom: "1px solid var(--bd)" }}>
|
|
346
|
+
<span style={{ fontFamily: "var(--font-heading)", fontSize: 22, fontWeight: 700, fontStyle: "italic" }}>
|
|
347
|
+
{config.name}
|
|
348
|
+
</span>
|
|
349
|
+
<span style={{ width: 5, height: 5, borderRadius: "50%", background: "var(--ac)", display: "inline-block" }} />
|
|
350
|
+
</div>
|
|
351
|
+
|
|
352
|
+
<div style={{ padding: "12px 14px" }}>
|
|
353
|
+
<button onClick={() => setSearch(true)} style={{
|
|
354
|
+
display: "flex", alignItems: "center", gap: 8, width: "100%",
|
|
355
|
+
background: "var(--cdBg)", border: "1px solid var(--bd)", borderRadius: 2,
|
|
356
|
+
padding: "8px 12px", cursor: "pointer", color: "var(--txM)", fontSize: 12.5,
|
|
357
|
+
fontFamily: "var(--font-body)",
|
|
358
|
+
}}>
|
|
359
|
+
<SearchIcon /><span style={{ flex: 1, textAlign: "left" }}>Search...</span>
|
|
360
|
+
<kbd style={{ fontFamily: "var(--font-code)", fontSize: 9, background: "var(--sf)", border: "1px solid var(--bd)", borderRadius: 2, padding: "2px 6px" }}>{"\u2318K"}</kbd>
|
|
361
|
+
</button>
|
|
362
|
+
</div>
|
|
363
|
+
|
|
364
|
+
<nav style={{ flex: 1, overflow: "auto", padding: "4px 10px 20px" }}>
|
|
365
|
+
{navigation.map(sec => (
|
|
366
|
+
<div key={sec.section} style={{ marginBottom: 8 }}>
|
|
367
|
+
<button onClick={() => togSec(sec.section)} style={{
|
|
368
|
+
display: "flex", alignItems: "center", gap: 6, width: "100%",
|
|
369
|
+
background: "none", border: "none", padding: "8px 10px", cursor: "pointer",
|
|
370
|
+
borderRadius: 2, color: "var(--txM)", fontSize: 10, fontWeight: 600,
|
|
371
|
+
textTransform: "uppercase", letterSpacing: ".1em", fontFamily: "var(--font-code)",
|
|
372
|
+
}}>
|
|
373
|
+
{expanded.includes(sec.section) ? <ChevDown /> : <ChevRight />}{sec.section}
|
|
374
|
+
</button>
|
|
375
|
+
{expanded.includes(sec.section) && <div style={{ marginLeft: 8, borderLeft: "1px solid var(--bd)", paddingLeft: 0 }}>
|
|
376
|
+
{sec.pages.map(p => {
|
|
377
|
+
const active = currentPageId === p.id;
|
|
378
|
+
return (
|
|
379
|
+
<button key={p.id} onClick={() => onNavigate(p.id)} style={{
|
|
380
|
+
display: "flex", alignItems: "center", gap: 10, width: "100%",
|
|
381
|
+
textAlign: "left", background: "none",
|
|
382
|
+
border: "none", borderRadius: 0,
|
|
383
|
+
borderLeft: active ? "2px solid var(--ac)" : "2px solid transparent",
|
|
384
|
+
padding: "7px 14px", cursor: "pointer",
|
|
385
|
+
color: active ? "var(--ac)" : "var(--tx2)", fontSize: 13,
|
|
386
|
+
fontWeight: active ? 500 : 400, fontFamily: "var(--font-body)",
|
|
387
|
+
transition: "all .12s",
|
|
388
|
+
}}>
|
|
389
|
+
{p.title}
|
|
390
|
+
</button>
|
|
391
|
+
);
|
|
392
|
+
})}
|
|
393
|
+
</div>}
|
|
394
|
+
</div>
|
|
395
|
+
))}
|
|
396
|
+
</nav>
|
|
397
|
+
|
|
398
|
+
<div style={{ padding: "12px 16px", borderTop: "1px solid var(--bd)", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
|
399
|
+
{/* TOM-12: Only show toggle when mode is "auto" */}
|
|
400
|
+
{themeMode === "auto" ? (
|
|
401
|
+
<button onClick={() => setDark(d => !d)} style={{ background: "none", border: "none", color: "var(--txM)", cursor: "pointer", display: "flex" }}>
|
|
402
|
+
{isDark ? <SunIcon /> : <MoonIcon />}
|
|
403
|
+
</button>
|
|
404
|
+
) : <div />}
|
|
405
|
+
<span style={{ fontFamily: "var(--font-code)", fontSize: 10, color: "var(--txM)" }}>v0.1.0</span>
|
|
406
|
+
</div>
|
|
407
|
+
</aside>
|
|
408
|
+
|
|
409
|
+
{/* Main area */}
|
|
410
|
+
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
|
411
|
+
{/* Header */}
|
|
412
|
+
<header style={{
|
|
413
|
+
display: "flex", alignItems: "center", gap: 12, padding: "10px 24px",
|
|
414
|
+
borderBottom: "1px solid var(--bd)", background: "var(--hdBg)", backdropFilter: "blur(12px)",
|
|
415
|
+
}}>
|
|
416
|
+
<button onClick={() => setSb(!sbOpen)} style={{ background: "none", border: "none", color: "var(--txM)", cursor: "pointer", display: "flex" }}>
|
|
417
|
+
{sbOpen ? <XIcon /> : <MenuIcon />}
|
|
418
|
+
</button>
|
|
419
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--font-code)", fontSize: 11, color: "var(--txM)", letterSpacing: ".03em", flex: 1 }}>
|
|
420
|
+
{navigation.map(s => {
|
|
421
|
+
const f = s.pages.find(p => p.id === currentPageId);
|
|
422
|
+
if (!f) return null;
|
|
423
|
+
return <span key={s.section} style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
424
|
+
<span>{s.section}</span><ChevRight /><span style={{ color: "var(--ac)" }}>{f.title}</span>
|
|
425
|
+
</span>;
|
|
426
|
+
})}
|
|
427
|
+
</div>
|
|
428
|
+
|
|
429
|
+
{/* Top Nav Links */}
|
|
430
|
+
{config.topNav && config.topNav.length > 0 && (
|
|
431
|
+
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
432
|
+
{config.topNav.map((link) => {
|
|
433
|
+
const isExternal = link.href.startsWith("http") || !link.href.startsWith("#");
|
|
434
|
+
return (
|
|
435
|
+
<a
|
|
436
|
+
key={link.label}
|
|
437
|
+
href={link.href}
|
|
438
|
+
{...(isExternal ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
439
|
+
style={{
|
|
440
|
+
display: "flex", alignItems: "center", gap: 4,
|
|
441
|
+
color: "var(--txM)", textDecoration: "none", fontSize: 12,
|
|
442
|
+
fontFamily: "var(--font-body)", fontWeight: 500,
|
|
443
|
+
transition: "color .15s",
|
|
444
|
+
}}
|
|
445
|
+
onMouseOver={(e) => (e.currentTarget.style.color = "var(--ac)")}
|
|
446
|
+
onMouseOut={(e) => (e.currentTarget.style.color = "var(--txM)")}
|
|
447
|
+
>
|
|
448
|
+
{link.label}
|
|
449
|
+
{isExternal && <ExtLinkIcon />}
|
|
450
|
+
</a>
|
|
451
|
+
);
|
|
452
|
+
})}
|
|
453
|
+
<span style={{ width: 1, height: 16, background: "var(--bd)" }} />
|
|
454
|
+
</div>
|
|
455
|
+
)}
|
|
456
|
+
|
|
457
|
+
{/* TOM-30: Version Switcher */}
|
|
458
|
+
{versioning && (
|
|
459
|
+
<div style={{ position: "relative" }}>
|
|
460
|
+
<button
|
|
461
|
+
data-testid="version-switcher"
|
|
462
|
+
onClick={() => setVersionDropdown(o => !o)}
|
|
463
|
+
style={{
|
|
464
|
+
display: "flex", alignItems: "center", gap: 6,
|
|
465
|
+
background: "var(--sf)", border: "1px solid var(--bd)", borderRadius: 2,
|
|
466
|
+
padding: "5px 10px", cursor: "pointer", color: "var(--tx2)", fontSize: 12,
|
|
467
|
+
fontFamily: "var(--font-code)",
|
|
468
|
+
}}
|
|
469
|
+
>
|
|
470
|
+
<VersionIcon />
|
|
471
|
+
{currentVersion || versioning.current}
|
|
472
|
+
<ChevDown />
|
|
473
|
+
</button>
|
|
474
|
+
{versionDropdownOpen && (
|
|
475
|
+
<div
|
|
476
|
+
data-testid="version-dropdown"
|
|
477
|
+
style={{
|
|
478
|
+
position: "absolute", top: "100%", right: 0, marginTop: 4,
|
|
479
|
+
background: "var(--sf)", border: "1px solid var(--bd)", borderRadius: 2,
|
|
480
|
+
boxShadow: "0 8px 32px rgba(0,0,0,0.2)", overflow: "hidden", zIndex: 100,
|
|
481
|
+
minWidth: 120,
|
|
482
|
+
}}
|
|
483
|
+
>
|
|
484
|
+
{versioning.versions.map(v => (
|
|
485
|
+
<button
|
|
486
|
+
key={v}
|
|
487
|
+
onClick={() => {
|
|
488
|
+
setVersionDropdown(false);
|
|
489
|
+
// Navigate to the version's root page
|
|
490
|
+
const targetUrl = v === versioning.current ? "/" : `/${v}`;
|
|
491
|
+
window.location.href = targetUrl;
|
|
492
|
+
}}
|
|
493
|
+
style={{
|
|
494
|
+
display: "block", width: "100%", textAlign: "left",
|
|
495
|
+
background: v === (currentVersion || versioning.current) ? "var(--acD)" : "none",
|
|
496
|
+
border: "none", padding: "8px 14px", cursor: "pointer",
|
|
497
|
+
color: v === (currentVersion || versioning.current) ? "var(--ac)" : "var(--tx2)",
|
|
498
|
+
fontSize: 12, fontFamily: "var(--font-code)",
|
|
499
|
+
fontWeight: v === versioning.current ? 600 : 400,
|
|
500
|
+
}}
|
|
501
|
+
>
|
|
502
|
+
{v}{v === versioning.current ? " (latest)" : ""}
|
|
503
|
+
</button>
|
|
504
|
+
))}
|
|
505
|
+
</div>
|
|
506
|
+
)}
|
|
507
|
+
</div>
|
|
508
|
+
)}
|
|
509
|
+
|
|
510
|
+
{/* TOM-34: Language Switcher */}
|
|
511
|
+
{i18n && i18n.locales.length > 1 && (
|
|
512
|
+
<div style={{ position: "relative" }}>
|
|
513
|
+
<button
|
|
514
|
+
data-testid="language-switcher"
|
|
515
|
+
onClick={() => setLocaleDropdown(o => !o)}
|
|
516
|
+
style={{
|
|
517
|
+
display: "flex", alignItems: "center", gap: 6,
|
|
518
|
+
background: "var(--sf)", border: "1px solid var(--bd)", borderRadius: 2,
|
|
519
|
+
padding: "5px 10px", cursor: "pointer", color: "var(--tx2)", fontSize: 12,
|
|
520
|
+
fontFamily: "var(--font-body)",
|
|
521
|
+
}}
|
|
522
|
+
>
|
|
523
|
+
<GlobeIcon />
|
|
524
|
+
{i18n.localeNames?.[currentLocale || i18n.defaultLocale] || currentLocale || i18n.defaultLocale}
|
|
525
|
+
<ChevDown />
|
|
526
|
+
</button>
|
|
527
|
+
{localeDropdownOpen && (
|
|
528
|
+
<div
|
|
529
|
+
data-testid="language-dropdown"
|
|
530
|
+
style={{
|
|
531
|
+
position: "absolute", top: "100%", right: 0, marginTop: 4,
|
|
532
|
+
background: "var(--sf)", border: "1px solid var(--bd)", borderRadius: 2,
|
|
533
|
+
boxShadow: "0 8px 32px rgba(0,0,0,0.2)", overflow: "hidden", zIndex: 100,
|
|
534
|
+
minWidth: 120,
|
|
535
|
+
}}
|
|
536
|
+
>
|
|
537
|
+
{i18n.locales.map(locale => {
|
|
538
|
+
const isActive = locale === (currentLocale || i18n.defaultLocale);
|
|
539
|
+
const displayName = i18n.localeNames?.[locale] || locale;
|
|
540
|
+
|
|
541
|
+
// Compute the target URL: switch locale but keep the same page
|
|
542
|
+
const activeLocale = currentLocale || i18n.defaultLocale;
|
|
543
|
+
// Get the base page path (strip locale prefix from current page ID)
|
|
544
|
+
let basePageId = currentPageId;
|
|
545
|
+
if (activeLocale !== i18n.defaultLocale && currentPageId.startsWith(`${activeLocale}/`)) {
|
|
546
|
+
basePageId = currentPageId.slice(activeLocale.length + 1);
|
|
547
|
+
}
|
|
548
|
+
const targetId = locale === i18n.defaultLocale
|
|
549
|
+
? basePageId
|
|
550
|
+
: `${locale}/${basePageId}`;
|
|
551
|
+
|
|
552
|
+
return (
|
|
553
|
+
<button
|
|
554
|
+
key={locale}
|
|
555
|
+
onClick={() => {
|
|
556
|
+
setLocaleDropdown(false);
|
|
557
|
+
onNavigate(targetId);
|
|
558
|
+
}}
|
|
559
|
+
style={{
|
|
560
|
+
display: "block", width: "100%", textAlign: "left",
|
|
561
|
+
background: isActive ? "var(--acD)" : "none",
|
|
562
|
+
border: "none", padding: "8px 14px", cursor: "pointer",
|
|
563
|
+
color: isActive ? "var(--ac)" : "var(--tx2)",
|
|
564
|
+
fontSize: 12, fontFamily: "var(--font-body)",
|
|
565
|
+
fontWeight: isActive ? 600 : 400,
|
|
566
|
+
}}
|
|
567
|
+
>
|
|
568
|
+
{displayName}
|
|
569
|
+
</button>
|
|
570
|
+
);
|
|
571
|
+
})}
|
|
572
|
+
</div>
|
|
573
|
+
)}
|
|
574
|
+
</div>
|
|
575
|
+
)}
|
|
576
|
+
</header>
|
|
577
|
+
|
|
578
|
+
{/* TOM-30: Old version banner */}
|
|
579
|
+
{isOldVersion && (
|
|
580
|
+
<div
|
|
581
|
+
data-testid="old-version-banner"
|
|
582
|
+
style={{
|
|
583
|
+
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
|
|
584
|
+
background: "var(--acD)", borderBottom: "1px solid var(--bd)",
|
|
585
|
+
padding: "8px 24px", fontSize: 13, color: "var(--tx2)",
|
|
586
|
+
}}
|
|
587
|
+
>
|
|
588
|
+
<span>You're viewing docs for {currentVersion}.</span>
|
|
589
|
+
<button
|
|
590
|
+
onClick={() => { window.location.href = "/"; }}
|
|
591
|
+
style={{
|
|
592
|
+
background: "none", border: "none", color: "var(--ac)",
|
|
593
|
+
cursor: "pointer", fontWeight: 600, fontSize: 13,
|
|
594
|
+
fontFamily: "var(--font-body)", textDecoration: "underline",
|
|
595
|
+
}}
|
|
596
|
+
>
|
|
597
|
+
Switch to latest.
|
|
598
|
+
</button>
|
|
599
|
+
</div>
|
|
600
|
+
)}
|
|
601
|
+
|
|
602
|
+
{/* Content + TOC */}
|
|
603
|
+
<div ref={contentRef} style={{ flex: 1, overflow: "auto", display: "flex" }}>
|
|
604
|
+
<main style={{ flex: 1, maxWidth: 760, padding: "40px 48px 80px", margin: "0 auto" }}>
|
|
605
|
+
<h1 style={{ fontFamily: "var(--font-heading)", fontSize: 38, fontWeight: 400, fontStyle: "italic", lineHeight: 1.15, marginBottom: 8 }}>
|
|
606
|
+
{pageTitle}
|
|
607
|
+
</h1>
|
|
608
|
+
{pageDescription && <p style={{ fontSize: 16, color: "var(--tx2)", lineHeight: 1.6, marginBottom: 32 }}>{pageDescription}</p>}
|
|
609
|
+
<div style={{ borderTop: "1px solid var(--bd)", paddingTop: 28 }}>
|
|
610
|
+
{/* TOM-8: Render MDX component or raw HTML */}
|
|
611
|
+
{PageComponent ? (
|
|
612
|
+
<div className="tome-content">
|
|
613
|
+
<PageComponent components={mdxComponents || {}} />
|
|
614
|
+
</div>
|
|
615
|
+
) : (
|
|
616
|
+
<div
|
|
617
|
+
className="tome-content"
|
|
618
|
+
dangerouslySetInnerHTML={{ __html: pageHtml || "" }}
|
|
619
|
+
/>
|
|
620
|
+
)}
|
|
621
|
+
</div>
|
|
622
|
+
|
|
623
|
+
{/* Prev / Next */}
|
|
624
|
+
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 48, paddingTop: 24, borderTop: "1px solid var(--bd)", gap: 16 }}>
|
|
625
|
+
{prev ? (
|
|
626
|
+
<button onClick={() => onNavigate(prev.id)} style={{
|
|
627
|
+
display: "flex", alignItems: "center", gap: 8, background: "none",
|
|
628
|
+
border: "1px solid var(--bd)", borderRadius: 2, padding: "10px 16px",
|
|
629
|
+
cursor: "pointer", color: "var(--tx2)", fontSize: 13, fontFamily: "var(--font-body)",
|
|
630
|
+
transition: "border-color .15s, color .15s",
|
|
631
|
+
}}><ArrowLeft /> {prev.title}</button>
|
|
632
|
+
) : <div />}
|
|
633
|
+
{next ? (
|
|
634
|
+
<button onClick={() => onNavigate(next.id)} style={{
|
|
635
|
+
display: "flex", alignItems: "center", gap: 8, background: "none",
|
|
636
|
+
border: "1px solid var(--bd)", borderRadius: 2, padding: "10px 16px",
|
|
637
|
+
cursor: "pointer", color: "var(--tx2)", fontSize: 13, fontFamily: "var(--font-body)",
|
|
638
|
+
transition: "border-color .15s, color .15s",
|
|
639
|
+
}}>{next.title} <ArrowRight /></button>
|
|
640
|
+
) : <div />}
|
|
641
|
+
</div>
|
|
642
|
+
</main>
|
|
643
|
+
|
|
644
|
+
{/* TOC */}
|
|
645
|
+
{headings.length > 0 && wide && (
|
|
646
|
+
<aside style={{ width: 200, padding: "40px 16px 40px 0", position: "sticky", top: 0, alignSelf: "flex-start", flexShrink: 0 }}>
|
|
647
|
+
<div style={{ fontSize: 10, fontWeight: 600, textTransform: "uppercase", letterSpacing: ".1em", color: "var(--txM)", marginBottom: 12, fontFamily: "var(--font-code)" }}>On this page</div>
|
|
648
|
+
<div style={{ borderLeft: "1px solid var(--bd)", paddingLeft: 0 }}>
|
|
649
|
+
{headings.map((h, i) => (
|
|
650
|
+
<a key={i} href={`#${h.id}`} style={{
|
|
651
|
+
display: "block", fontSize: 12, color: "var(--txM)",
|
|
652
|
+
textDecoration: "none", padding: "4px 12px", paddingLeft: 12 + (h.depth - 2) * 12,
|
|
653
|
+
lineHeight: 1.4, transition: "color .12s",
|
|
654
|
+
}}>{h.text}</a>
|
|
655
|
+
))}
|
|
656
|
+
</div>
|
|
657
|
+
</aside>
|
|
658
|
+
)}
|
|
659
|
+
</div>
|
|
660
|
+
</div>
|
|
661
|
+
</div>
|
|
662
|
+
|
|
663
|
+
{/* TOM-32: AI Chat Widget (BYOK) */}
|
|
664
|
+
{config.ai?.enabled && (
|
|
665
|
+
<AiChat
|
|
666
|
+
provider={config.ai.provider || "anthropic"}
|
|
667
|
+
model={config.ai.model}
|
|
668
|
+
apiKey={typeof __TOME_AI_API_KEY__ !== "undefined" && __TOME_AI_API_KEY__ ? __TOME_AI_API_KEY__ : undefined}
|
|
669
|
+
context={docContext?.map((d) => `## ${d.title}\n${d.content}`).join("\n\n") ?? allPages.map((p) => `- ${p.title}${p.description ? ": " + p.description : ""}`).join("\n")}
|
|
670
|
+
/>
|
|
671
|
+
)}
|
|
672
|
+
</div>
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ── SEARCH RESULT TYPE (TOM-15) ──────────────────────────
|
|
677
|
+
interface SearchResult {
|
|
678
|
+
id: string;
|
|
679
|
+
title: string;
|
|
680
|
+
excerpt?: string;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ── SEARCH MODAL (TOM-15) ────────────────────────────────
|
|
684
|
+
function SearchModal({ allPages, onNavigate, onClose }: {
|
|
685
|
+
allPages: Array<{ id: string; title: string; description?: string }>;
|
|
686
|
+
onNavigate: (id: string) => void;
|
|
687
|
+
onClose: () => void;
|
|
688
|
+
}) {
|
|
689
|
+
const [q, setQ] = useState("");
|
|
690
|
+
const [results, setResults] = useState<SearchResult[]>([]);
|
|
691
|
+
const [selected, setSelected] = useState(0);
|
|
692
|
+
const [pagefindReady, setPagefindReady] = useState<boolean | null>(null);
|
|
693
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
694
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
695
|
+
|
|
696
|
+
// Try to initialize Pagefind on mount
|
|
697
|
+
useEffect(() => {
|
|
698
|
+
initPagefind().then((pf) => setPagefindReady(!!pf));
|
|
699
|
+
setTimeout(() => inputRef.current?.focus(), 50);
|
|
700
|
+
}, []);
|
|
701
|
+
|
|
702
|
+
// Fallback search: filter allPages by title/description
|
|
703
|
+
const fallbackSearch = useCallback((query: string): SearchResult[] => {
|
|
704
|
+
if (!query.trim()) return [];
|
|
705
|
+
const ql = query.toLowerCase();
|
|
706
|
+
return allPages
|
|
707
|
+
.filter(p => p.title.toLowerCase().includes(ql) || (p.description || "").toLowerCase().includes(ql))
|
|
708
|
+
.slice(0, 8)
|
|
709
|
+
.map(p => ({ id: p.id, title: p.title, excerpt: p.description }));
|
|
710
|
+
}, [allPages]);
|
|
711
|
+
|
|
712
|
+
// Search handler: use Pagefind if available, otherwise fallback
|
|
713
|
+
const doSearch = useCallback(async (query: string) => {
|
|
714
|
+
if (!query.trim()) {
|
|
715
|
+
setResults([]);
|
|
716
|
+
setSelected(0);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Try Pagefind first
|
|
721
|
+
const pf = pagefindInstance;
|
|
722
|
+
if (pf) {
|
|
723
|
+
try {
|
|
724
|
+
const search = await pf.search(query);
|
|
725
|
+
const items: SearchResult[] = [];
|
|
726
|
+
for (const result of search.results.slice(0, 8)) {
|
|
727
|
+
const data = await result.data();
|
|
728
|
+
// Pagefind URLs look like "/index.html" or "/quickstart/index.html"
|
|
729
|
+
// Extract the page ID from the URL path
|
|
730
|
+
const url: string = data.url || "";
|
|
731
|
+
const id = url
|
|
732
|
+
.replace(/^\//, "")
|
|
733
|
+
.replace(/\/index\.html$/, "")
|
|
734
|
+
.replace(/\.html$/, "")
|
|
735
|
+
|| "index";
|
|
736
|
+
items.push({
|
|
737
|
+
id,
|
|
738
|
+
title: data.meta?.title || id,
|
|
739
|
+
excerpt: data.excerpt || undefined,
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
setResults(items);
|
|
743
|
+
setSelected(0);
|
|
744
|
+
return;
|
|
745
|
+
} catch {
|
|
746
|
+
// Pagefind search failed, fall through to fallback
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Fallback: client-side filtering
|
|
751
|
+
setResults(fallbackSearch(query));
|
|
752
|
+
setSelected(0);
|
|
753
|
+
}, [fallbackSearch]);
|
|
754
|
+
|
|
755
|
+
// Debounced search on query change
|
|
756
|
+
useEffect(() => {
|
|
757
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
758
|
+
debounceRef.current = setTimeout(() => doSearch(q), 120);
|
|
759
|
+
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
|
760
|
+
}, [q, doSearch]);
|
|
761
|
+
|
|
762
|
+
// Keyboard navigation
|
|
763
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
764
|
+
if (e.key === "ArrowDown") {
|
|
765
|
+
e.preventDefault();
|
|
766
|
+
setSelected(i => Math.min(i + 1, results.length - 1));
|
|
767
|
+
} else if (e.key === "ArrowUp") {
|
|
768
|
+
e.preventDefault();
|
|
769
|
+
setSelected(i => Math.max(i - 1, 0));
|
|
770
|
+
} else if (e.key === "Enter" && results.length > 0) {
|
|
771
|
+
e.preventDefault();
|
|
772
|
+
onNavigate(results[selected].id);
|
|
773
|
+
}
|
|
774
|
+
}, [results, selected, onNavigate]);
|
|
775
|
+
|
|
776
|
+
return (
|
|
777
|
+
<div onClick={onClose} style={{
|
|
778
|
+
position: "fixed", inset: 0, zIndex: 1000, background: "rgba(0,0,0,0.55)",
|
|
779
|
+
backdropFilter: "blur(6px)", display: "flex", alignItems: "flex-start",
|
|
780
|
+
justifyContent: "center", paddingTop: "12vh",
|
|
781
|
+
}}>
|
|
782
|
+
<div onClick={e => e.stopPropagation()} style={{
|
|
783
|
+
background: "var(--sf)", border: "1px solid var(--bd)", borderRadius: 2,
|
|
784
|
+
width: "100%", maxWidth: 520, boxShadow: "0 24px 80px rgba(0,0,0,0.4)", overflow: "hidden",
|
|
785
|
+
}}>
|
|
786
|
+
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "14px 18px", borderBottom: "1px solid var(--bd)" }}>
|
|
787
|
+
<SearchIcon />
|
|
788
|
+
<input ref={inputRef} value={q} onChange={e => setQ(e.target.value)}
|
|
789
|
+
onKeyDown={handleKeyDown}
|
|
790
|
+
placeholder="Search documentation..."
|
|
791
|
+
style={{ flex: 1, background: "none", border: "none", outline: "none", color: "var(--tx)", fontSize: 15, fontFamily: "var(--font-body)" }}
|
|
792
|
+
/>
|
|
793
|
+
<kbd style={{ fontFamily: "var(--font-code)", fontSize: 10, color: "var(--txM)", background: "var(--cdBg)", padding: "2px 6px", borderRadius: 2, border: "1px solid var(--bd)" }}>ESC</kbd>
|
|
794
|
+
</div>
|
|
795
|
+
{results.length > 0 && <div style={{ padding: 6, maxHeight: 360, overflow: "auto" }}>
|
|
796
|
+
{results.map((r, i) => (
|
|
797
|
+
<button key={r.id + i} onClick={() => onNavigate(r.id)} style={{
|
|
798
|
+
display: "block", width: "100%", textAlign: "left",
|
|
799
|
+
background: i === selected ? "var(--acD)" : "none",
|
|
800
|
+
border: "none", borderRadius: 2, padding: "10px 14px", cursor: "pointer", color: "var(--tx)",
|
|
801
|
+
fontFamily: "var(--font-body)",
|
|
802
|
+
}}
|
|
803
|
+
onMouseEnter={() => setSelected(i)}
|
|
804
|
+
>
|
|
805
|
+
<div style={{ fontWeight: 500, fontSize: 14, marginBottom: 2 }}>{r.title}</div>
|
|
806
|
+
{r.excerpt && <div style={{
|
|
807
|
+
fontSize: 12, color: "var(--txM)", lineHeight: 1.3,
|
|
808
|
+
}} dangerouslySetInnerHTML={{ __html: r.excerpt }} />}
|
|
809
|
+
</button>
|
|
810
|
+
))}
|
|
811
|
+
</div>}
|
|
812
|
+
{q && !results.length && (
|
|
813
|
+
<div style={{ padding: "32px 18px", textAlign: "center", color: "var(--txM)", fontSize: 14 }}>
|
|
814
|
+
No results found
|
|
815
|
+
</div>
|
|
816
|
+
)}
|
|
817
|
+
{pagefindReady === false && q && results.length > 0 && (
|
|
818
|
+
<div style={{ padding: "6px 18px 10px", fontSize: 11, color: "var(--txM)", textAlign: "center" }}>
|
|
819
|
+
Showing title matches. Build your site for full-text search.
|
|
820
|
+
</div>
|
|
821
|
+
)}
|
|
822
|
+
</div>
|
|
823
|
+
</div>
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
export default Shell;
|