doccupine 0.0.64 → 0.0.66
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/structures.js +6 -4
- package/dist/templates/app/api/rag/route.d.ts +1 -1
- package/dist/templates/app/api/rag/route.js +11 -4
- package/dist/templates/components/Chat.d.ts +1 -1
- package/dist/templates/components/Chat.js +124 -20
- package/dist/templates/components/DocsSideBar.d.ts +1 -1
- package/dist/templates/components/DocsSideBar.js +15 -4
- package/dist/templates/components/LockBodyScroll.d.ts +1 -0
- package/dist/templates/components/LockBodyScroll.js +17 -0
- package/dist/templates/components/SideBar.d.ts +1 -1
- package/dist/templates/components/SideBar.js +3 -0
- package/dist/templates/components/layout/ActionBar.d.ts +1 -1
- package/dist/templates/components/layout/ActionBar.js +9 -34
- package/dist/templates/components/layout/Code.d.ts +1 -1
- package/dist/templates/components/layout/Code.js +1 -1
- package/dist/templates/components/layout/DocsComponents.d.ts +1 -1
- package/dist/templates/components/layout/DocsComponents.js +5 -5
- package/dist/templates/mdx/deployment-and-hosting.mdx.d.ts +1 -0
- package/dist/templates/mdx/deployment-and-hosting.mdx.js +61 -0
- package/dist/templates/mdx/index.mdx.d.ts +1 -1
- package/dist/templates/mdx/index.mdx.js +1 -1
- package/dist/templates/mdx/model-context-protocol.mdx.d.ts +1 -1
- package/dist/templates/mdx/model-context-protocol.mdx.js +3 -3
- package/dist/templates/mdx/platform/ai-assistant.mdx.d.ts +1 -1
- package/dist/templates/mdx/platform/ai-assistant.mdx.js +20 -0
- package/dist/templates/mdx/platform/build-and-deploy.mdx.d.ts +1 -0
- package/dist/templates/mdx/platform/build-and-deploy.mdx.js +36 -0
- package/dist/templates/package.js +1 -1
- package/package.json +1 -1
package/dist/lib/structures.js
CHANGED
|
@@ -14,6 +14,7 @@ import { notFoundTemplate } from "../templates/app/not-found.js";
|
|
|
14
14
|
import { themeTemplate } from "../templates/app/theme.js";
|
|
15
15
|
import { chatTemplate } from "../templates/components/Chat.js";
|
|
16
16
|
import { clickOutsideTemplate } from "../templates/components/ClickOutside.js";
|
|
17
|
+
import { lockBodyScrollTemplate } from "../templates/components/LockBodyScroll.js";
|
|
17
18
|
import { docsTemplate } from "../templates/components/Docs.js";
|
|
18
19
|
import { docsSideBarTemplate } from "../templates/components/DocsSideBar.js";
|
|
19
20
|
import { mdxComponentsTemplate } from "../templates/components/MDXComponents.js";
|
|
@@ -67,7 +68,7 @@ import { codeMdxTemplate } from "../templates/mdx/code.mdx.js";
|
|
|
67
68
|
import { columnsMdxTemplate } from "../templates/mdx/columns.mdx.js";
|
|
68
69
|
import { commandsMdxTemplate } from "../templates/mdx/commands.mdx.js";
|
|
69
70
|
import { componentsMdxTemplate } from "../templates/mdx/components.mdx.js";
|
|
70
|
-
import {
|
|
71
|
+
import { deploymentAndHostingMdxTemplate } from "../templates/mdx/deployment-and-hosting.mdx.js";
|
|
71
72
|
import { fieldsMdxTemplate } from "../templates/mdx/fields.mdx.js";
|
|
72
73
|
import { fontsMdxTemplate } from "../templates/mdx/fonts.mdx.js";
|
|
73
74
|
import { globalsMdxTemplate } from "../templates/mdx/globals.mdx.js";
|
|
@@ -97,7 +98,7 @@ import { platformFontsSettingsMdxTemplate } from "../templates/mdx/platform/font
|
|
|
97
98
|
import { platformExternalLinksMdxTemplate } from "../templates/mdx/platform/external-links.mdx.js";
|
|
98
99
|
import { platformAiAssistantMdxTemplate } from "../templates/mdx/platform/ai-assistant.mdx.js";
|
|
99
100
|
import { platformCustomDomainsMdxTemplate } from "../templates/mdx/platform/custom-domains.mdx.js";
|
|
100
|
-
import {
|
|
101
|
+
import { platformBuildAndDeployMdxTemplate } from "../templates/mdx/platform/build-and-deploy.mdx.js";
|
|
101
102
|
import { platformTeamMembersMdxTemplate } from "../templates/mdx/platform/team-members.mdx.js";
|
|
102
103
|
import { platformBillingMdxTemplate } from "../templates/mdx/platform/billing.mdx.js";
|
|
103
104
|
import { platformProjectSettingsMdxTemplate } from "../templates/mdx/platform/project-settings.mdx.js";
|
|
@@ -131,6 +132,7 @@ export const appStructure = {
|
|
|
131
132
|
"utils/config.ts": configTemplate,
|
|
132
133
|
"components/Chat.tsx": chatTemplate,
|
|
133
134
|
"components/ClickOutside.ts": clickOutsideTemplate,
|
|
135
|
+
"components/LockBodyScroll.ts": lockBodyScrollTemplate,
|
|
134
136
|
"components/Docs.tsx": docsTemplate,
|
|
135
137
|
"components/DocsSideBar.tsx": docsSideBarTemplate,
|
|
136
138
|
"components/MDXComponents.tsx": mdxComponentsTemplate,
|
|
@@ -173,7 +175,7 @@ export const startingDocsStructure = {
|
|
|
173
175
|
"columns.mdx": columnsMdxTemplate,
|
|
174
176
|
"commands.mdx": commandsMdxTemplate,
|
|
175
177
|
"components.mdx": componentsMdxTemplate,
|
|
176
|
-
"deployment.mdx":
|
|
178
|
+
"deployment-and-hosting.mdx": deploymentAndHostingMdxTemplate,
|
|
177
179
|
"fields.mdx": fieldsMdxTemplate,
|
|
178
180
|
"fonts.mdx": fontsMdxTemplate,
|
|
179
181
|
"globals.mdx": globalsMdxTemplate,
|
|
@@ -203,7 +205,7 @@ export const startingDocsStructure = {
|
|
|
203
205
|
"platform/external-links.mdx": platformExternalLinksMdxTemplate,
|
|
204
206
|
"platform/ai-assistant.mdx": platformAiAssistantMdxTemplate,
|
|
205
207
|
"platform/custom-domains.mdx": platformCustomDomainsMdxTemplate,
|
|
206
|
-
"platform/
|
|
208
|
+
"platform/build-and-deploy.mdx": platformBuildAndDeployMdxTemplate,
|
|
207
209
|
"platform/team-members.mdx": platformTeamMembersMdxTemplate,
|
|
208
210
|
"platform/billing.mdx": platformBillingMdxTemplate,
|
|
209
211
|
"platform/project-settings.mdx": platformProjectSettingsMdxTemplate,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const ragRoutesTemplate = "import { NextResponse } from \"next/server\";\nimport { z } from \"zod\";\nimport { getLLMConfig, createChatModel } from \"@/services/llm\";\nimport {\n searchDocs,\n ensureDocsIndex,\n getIndexStatus,\n} from \"@/services/mcp/server\";\nimport { rateLimit } from \"@/utils/rateLimit\";\nimport { config } from \"@/utils/config\";\n\nconst messageSchema = z.object({\n role: z.enum([\"user\", \"assistant\"]),\n content: z.string().max(4000),\n});\n\nconst ragSchema = z.object({\n question: z.string().min(1).max(2000),\n history: z.array(messageSchema).max(20).optional(),\n refresh: z.boolean().optional(),\n});\n\nconst projectName = config.name || \"Doccupine\";\n\nconst systemContext = `You are AI Assistant, a documentation assistant for ${projectName}, Your name is ${projectName} AI Assistant.\n\n## Core Rules\n1. Answer ONLY from the provided context. Never fabricate information.\n2. If the answer isn't in the context, say so clearly and suggest relevant sections or pages the user might check.\n3. If the question is ambiguous, ask a brief clarifying question before answering.\n\n## Response Style\n- Be concise and direct. Lead with the answer, then provide details if needed.\n- Use code examples from the context when relevant.\n- Match the technical level of the user's question.\n\n## MDX/Code Formatting\nWhen including code blocks in your response:\n- Never nest fenced code blocks (triple backticks) inside other fenced code blocks.\n- If you need to show MDX source that itself contains code blocks, use indented code blocks or escape the inner backticks.\n- All output must be valid MDX that renders correctly.\n\n## Greetings & Small Talk\nIf the user sends a greeting or non-documentation question, respond briefly and ask how you can help with the documentation.`;\n\nexport async function POST(req: Request) {\n // Rate limit by IP\n const ip =\n req.headers.get(\"x-forwarded-for\")?.split(\",\")[0]?.trim() ?? \"unknown\";\n const { allowed, retryAfter } = rateLimit(ip);\n if (!allowed) {\n return NextResponse.json(\n { error: \"Too many requests\" },\n { status: 429, headers: { \"Retry-After\": String(retryAfter) } },\n );\n }\n\n try {\n const body = await req.json();\n const parsed = ragSchema.safeParse(body);\n if (!parsed.success) {\n return NextResponse.json(\n { error: \"Invalid input\", details: parsed.error.issues },\n { status: 400 },\n );\n }\n const { question, history, refresh } = parsed.data;\n\n let llmConfig;\n try {\n llmConfig = getLLMConfig();\n } catch (error: unknown) {\n const message =\n error instanceof Error ? error.message : \"LLM configuration error\";\n return NextResponse.json({ error: message }, { status: 500 });\n }\n\n // Use MCP service to ensure docs are indexed\n await ensureDocsIndex(Boolean(refresh));\n\n // Use MCP search_docs tool to find relevant documentation\n const searchResults = await searchDocs(question, 6);\n\n // Build context from search results\n const context = searchResults\n .map(
|
|
1
|
+
export declare const ragRoutesTemplate = "import { NextResponse } from \"next/server\";\nimport { z } from \"zod\";\nimport { getLLMConfig, createChatModel } from \"@/services/llm\";\nimport {\n searchDocs,\n ensureDocsIndex,\n getIndexStatus,\n} from \"@/services/mcp/server\";\nimport { rateLimit } from \"@/utils/rateLimit\";\nimport { config } from \"@/utils/config\";\n\nconst messageSchema = z.object({\n role: z.enum([\"user\", \"assistant\"]),\n content: z.string().max(4000),\n});\n\nconst ragSchema = z.object({\n question: z.string().min(1).max(2000),\n history: z.array(messageSchema).max(20).optional(),\n refresh: z.boolean().optional(),\n});\n\nconst projectName = config.name || \"Doccupine\";\n\nconst systemContext = `You are AI Assistant, a documentation assistant for ${projectName}, Your name is ${projectName} AI Assistant.\n\n## Core Rules\n1. Answer ONLY from the provided context. Never fabricate information.\n2. If the answer isn't in the context, say so clearly and suggest relevant sections or pages the user might check.\n3. If the question is ambiguous, ask a brief clarifying question before answering.\n\n## Response Style\n- Be concise and direct. Lead with the answer, then provide details if needed.\n- Use code examples from the context when relevant.\n- Match the technical level of the user's question.\n\n## MDX/Code Formatting\nWhen including code blocks in your response:\n- Never nest fenced code blocks (triple backticks) inside other fenced code blocks.\n- If you need to show MDX source that itself contains code blocks, use indented code blocks or escape the inner backticks.\n- All output must be valid MDX that renders correctly.\n\n## Internal Links\nEach context chunk includes a \"URL:\" line with the pre-computed page URL. Use it directly when linking:\n- Format links as markdown: [Page Title](/slug/).\n- Never expose raw file paths like \"/app/.../page.tsx\" to the user.\n- Do NOT add a \"Related Pages\" section at the end - sources are shown separately by the UI.\n\n## Greetings & Small Talk\nIf the user sends a greeting or non-documentation question, respond briefly and ask how you can help with the documentation.`;\n\nexport async function POST(req: Request) {\n // Rate limit by IP\n const ip =\n req.headers.get(\"x-forwarded-for\")?.split(\",\")[0]?.trim() ?? \"unknown\";\n const { allowed, retryAfter } = rateLimit(ip);\n if (!allowed) {\n return NextResponse.json(\n { error: \"Too many requests\" },\n { status: 429, headers: { \"Retry-After\": String(retryAfter) } },\n );\n }\n\n try {\n const body = await req.json();\n const parsed = ragSchema.safeParse(body);\n if (!parsed.success) {\n return NextResponse.json(\n { error: \"Invalid input\", details: parsed.error.issues },\n { status: 400 },\n );\n }\n const { question, history, refresh } = parsed.data;\n\n let llmConfig;\n try {\n llmConfig = getLLMConfig();\n } catch (error: unknown) {\n const message =\n error instanceof Error ? error.message : \"LLM configuration error\";\n return NextResponse.json({ error: message }, { status: 500 });\n }\n\n // Use MCP service to ensure docs are indexed\n await ensureDocsIndex(Boolean(refresh));\n\n // Use MCP search_docs tool to find relevant documentation\n const searchResults = await searchDocs(question, 6);\n\n // Build context from search results\n const context = searchResults\n .map(({ chunk, score }) => {\n const slug = chunk.uri.replace(\"docs://\", \"\").replace(/^\\/+/, \"\");\n const url = slug ? `/${slug}/` : \"/\";\n return `File: ${chunk.path}\\nURL: ${url}\\nScore: ${score.toFixed(3)}\\n----\\n${chunk.text}`;\n })\n .join(\"\\n\\n================\\n\\n\");\n\n // Create chat model and stream response\n const llm = createChatModel(llmConfig);\n const prompt: { role: \"system\" | \"user\" | \"assistant\"; content: string }[] =\n [\n {\n role: \"system\" as const,\n content: systemContext,\n },\n ];\n\n // Include conversation history for multi-turn context\n if (history && history.length > 0) {\n for (const msg of history) {\n prompt.push({\n role: msg.role,\n content: msg.content,\n });\n }\n }\n\n prompt.push({\n role: \"user\" as const,\n content: `Question: ${question}\\n\\nContext:\\n${context}`,\n });\n\n const stream = await llm.stream(prompt);\n\n // Build metadata from MCP search results\n const indexStatus = getIndexStatus();\n const metadata = {\n sources: searchResults.map(({ chunk, score }) => ({\n id: chunk.id,\n path: chunk.path,\n uri: chunk.uri,\n score,\n })),\n chunkCount: indexStatus.chunkCount,\n };\n\n const encoder = new TextEncoder();\n const readableStream = new ReadableStream({\n async start(controller) {\n try {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"metadata\", data: metadata })}\\n\\n`,\n ),\n );\n\n for await (const chunk of stream) {\n const content = chunk?.content || \"\";\n if (content) {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"content\", data: content })}\\n\\n`,\n ),\n );\n }\n }\n\n controller.enqueue(\n encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`),\n );\n controller.close();\n } catch (error: unknown) {\n const message =\n error instanceof Error ? error.message : \"Stream error\";\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", data: message })}\\n\\n`,\n ),\n );\n controller.close();\n }\n },\n });\n\n return new Response(readableStream, {\n headers: {\n \"Content-Type\": \"text/event-stream\",\n \"Cache-Control\": \"no-cache\",\n Connection: \"keep-alive\",\n },\n });\n } catch (e: unknown) {\n const message = e instanceof Error ? e.message : \"Unknown error\";\n return NextResponse.json({ error: message }, { status: 500 });\n }\n}\n\nexport async function GET() {\n const status = getIndexStatus();\n return NextResponse.json({\n ready: status.ready,\n chunks: status.chunkCount,\n });\n}\n";
|
|
@@ -40,6 +40,12 @@ When including code blocks in your response:
|
|
|
40
40
|
- If you need to show MDX source that itself contains code blocks, use indented code blocks or escape the inner backticks.
|
|
41
41
|
- All output must be valid MDX that renders correctly.
|
|
42
42
|
|
|
43
|
+
## Internal Links
|
|
44
|
+
Each context chunk includes a "URL:" line with the pre-computed page URL. Use it directly when linking:
|
|
45
|
+
- Format links as markdown: [Page Title](/slug/).
|
|
46
|
+
- Never expose raw file paths like "/app/.../page.tsx" to the user.
|
|
47
|
+
- Do NOT add a "Related Pages" section at the end - sources are shown separately by the UI.
|
|
48
|
+
|
|
43
49
|
## Greetings & Small Talk
|
|
44
50
|
If the user sends a greeting or non-documentation question, respond briefly and ask how you can help with the documentation.\`;
|
|
45
51
|
|
|
@@ -83,10 +89,11 @@ export async function POST(req: Request) {
|
|
|
83
89
|
|
|
84
90
|
// Build context from search results
|
|
85
91
|
const context = searchResults
|
|
86
|
-
.map(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
92
|
+
.map(({ chunk, score }) => {
|
|
93
|
+
const slug = chunk.uri.replace("docs://", "").replace(/^\\/+/, "");
|
|
94
|
+
const url = slug ? \`/\${slug}/\` : "/";
|
|
95
|
+
return \`File: \${chunk.path}\\nURL: \${url}\\nScore: \${score.toFixed(3)}\\n----\\n\${chunk.text}\`;
|
|
96
|
+
})
|
|
90
97
|
.join("\\n\\n================\\n\\n");
|
|
91
98
|
|
|
92
99
|
// Create chat model and stream response
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const chatTemplate = "\"use client\";\nimport React, {\n createContext,\n useContext,\n useEffect,\n useRef,\n useState,\n} from \"react\";\nimport styled, { css, keyframes } from \"styled-components\";\nimport { rgba } from \"polished\";\nimport { Button } from \"cherry-styled-components\";\nimport { ArrowUp, LoaderPinwheel, Sparkles, X } from \"lucide-react\";\nimport remarkGfm from \"remark-gfm\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport { MDXRemote, MDXRemoteSerializeResult } from \"next-mdx-remote\";\nimport { serialize } from \"next-mdx-remote/serialize\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { useMDXComponents as getMDXComponents } from \"@/components/MDXComponents\";\nimport {\n styledAnchor,\n styledTable,\n stylesLists,\n StyledSmallButton,\n} from \"@/components/layout/SharedStyled\";\n\nconst mdxComponents = getMDXComponents({});\n\nconst styledText = css<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.text.xs};\n line-height: ${({ theme }) => theme.lineHeights.text.xs};\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n line-height: ${({ theme }) => theme.lineHeights.small.lg};\n }\n`;\n\nconst StyledChat = styled.div<{ theme: Theme; $isVisible: boolean }>`\n margin: 0;\n position: fixed;\n top: 0;\n right: 0;\n width: 100%;\n height: calc(100vh - 90px);\n overflow-y: scroll;\n overflow-x: hidden;\n z-index: 1000;\n padding: 0 20px;\n transition: all 0.3s ease;\n transform: translateX(0);\n background: ${({ theme }) => theme.colors.light};\n -webkit-overflow-scrolling: touch;\n opacity: 1;\n\n &::-webkit-scrollbar {\n display: none;\n }\n\n ${({ $isVisible }) =>\n !$isVisible &&\n css`\n transform: translateX(100%);\n opacity: 0;\n `}\n\n ${mq(\"lg\")} {\n width: 420px;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n }\n`;\n\nconst loadingAnimation = keyframes`\n 0% {\n transform: rotate(0deg);\n }\n 100% {\n transform: rotate(360deg);\n }\n`;\n\nconst rotateGradient = keyframes`\n 0% {\n --gradient-angle: 0deg;\n }\n 100% {\n --gradient-angle: 360deg;\n }\n`;\n\nconst pulseGlow = keyframes`\n 0%, 100% {\n opacity: 0.5;\n filter: blur(16px);\n }\n 50% {\n opacity: 1;\n filter: blur(22px);\n }\n`;\n\nconst sparkleFloat = keyframes`\n 0%, 100% {\n opacity: 0;\n transform: translateY(0) scale(0);\n }\n 50% {\n opacity: 0.9;\n transform: translateY(-20px) scale(1);\n }\n`;\n\nconst shimmer = keyframes`\n 0% {\n background-position: 0% center;\n }\n 50% {\n background-position: 100% center;\n }\n 100% {\n background-position: 0% center;\n }\n`;\n\nconst StyledRainbowInputWrapper = styled.div<{\n theme: Theme;\n $isActive: boolean;\n}>`\n @property --gradient-angle {\n syntax: \"<angle>\";\n initial-value: 0deg;\n inherits: false;\n }\n\n position: relative;\n flex: 1;\n\n &::before {\n content: \"\";\n position: absolute;\n inset: -2px;\n border-radius: 14px;\n background: conic-gradient(\n from var(--gradient-angle),\n #cc5555,\n #d9a745,\n #3ab0cc,\n #cc7fc2,\n #4380cc,\n #4c1fa3,\n #cc5555\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation: ${rotateGradient} 3s linear infinite;\n z-index: 0;\n }\n\n &::after {\n content: \"\";\n position: absolute;\n inset: -10px;\n border-radius: 20px;\n background: conic-gradient(\n from var(--gradient-angle),\n ${rgba(\"#ff6b6b\", 0.4)},\n ${rgba(\"#feca57\", 0.4)},\n ${rgba(\"#48dbfb\", 0.4)},\n ${rgba(\"#ff9ff3\", 0.4)},\n ${rgba(\"#54a0ff\", 0.4)},\n ${rgba(\"#5f27cd\", 0.4)},\n ${rgba(\"#ff6b6b\", 0.4)}\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation:\n ${rotateGradient} 3s linear infinite,\n ${pulseGlow} 2s ease-in-out infinite;\n z-index: -1;\n pointer-events: none;\n }\n\n &:hover::before,\n &:focus-within::before {\n opacity: 1;\n }\n\n &:hover::after,\n &:focus-within::after {\n opacity: 1;\n }\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n &::before {\n opacity: 1;\n }\n &::after {\n opacity: 1;\n }\n `}\n`;\n\nconst StyledSparkleContainer = styled.div<{ $isActive: boolean }>`\n position: absolute;\n inset: -30px;\n pointer-events: none;\n overflow: hidden;\n border-radius: 30px;\n z-index: -2;\n opacity: 0;\n transition: opacity 0.4s ease;\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n opacity: 1;\n `}\n`;\n\nconst StyledSparkle = styled.div<{\n $color: string;\n $left: number;\n $top: number;\n $delay: number;\n}>`\n position: absolute;\n width: 4px;\n height: 4px;\n border-radius: 50%;\n background: ${({ $color }) => $color};\n box-shadow: 0 0 6px ${({ $color }) => $color};\n left: ${({ $left }) => $left}%;\n top: ${({ $top }) => $top}%;\n animation: ${sparkleFloat} 2s ease-in-out infinite;\n animation-delay: ${({ $delay }) => $delay}s;\n`;\n\nconst StyledRainbowInput = styled.input<{ theme: Theme }>`\n position: relative;\n z-index: 1;\n width: 100%;\n background: ${({ theme }) => theme.colors.light};\n border: 1px solid ${({ theme }) => theme.colors.grayLight};\n border-radius: 12px;\n padding: 14px 18px;\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n font-family: inherit;\n color: ${({ theme }) => theme.colors.dark};\n outline: none;\n transition:\n border-color 0.3s ease,\n box-shadow 0.3s ease;\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n }\n\n &::placeholder {\n color: ${({ theme }) => rgba(theme.colors.dark, 0.4)};\n transition: color 0.3s ease;\n }\n\n &:focus::placeholder {\n color: ${({ theme }) => rgba(theme.colors.dark, 0.6)};\n }\n\n &:focus {\n border-color: transparent;\n }\n`;\n\nconst StyledRainbowButton = styled(Button)<{\n theme: Theme;\n $hasContent: boolean;\n}>`\n padding-top: 10px;\n padding-bottom: 10px;\n position: relative;\n overflow: hidden;\n transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\n\n &::before {\n content: \"\";\n position: absolute;\n inset: 0;\n background: linear-gradient(\n 135deg,\n #ff6b6b,\n #feca57,\n #48dbfb,\n #ff9ff3,\n #54a0ff\n );\n background-size: 300% 300%;\n opacity: 0;\n transition: opacity 0.3s ease;\n z-index: 0;\n animation: ${shimmer} 3s linear infinite;\n width: 200%;\n }\n\n ${({ $hasContent }) =>\n $hasContent &&\n css`\n &::before {\n opacity: 1;\n }\n `}\n\n &:hover::before {\n opacity: 1;\n }\n\n &:hover {\n transform: scale(1.05);\n }\n\n &:active {\n transform: scale(0.95);\n }\n\n & svg {\n position: relative;\n z-index: 1;\n transition: transform 0.3s ease;\n }\n\n &:disabled,\n &:disabled:hover {\n background: ${({ theme }) => theme.colors.primaryDark};\n transform: none;\n box-shadow: none;\n\n &::before {\n opacity: 0;\n }\n }\n`;\n\nconst StyledChatForm = styled.form<{ theme: Theme; $isVisible: boolean }>`\n display: flex;\n gap: 10px;\n justify-content: center;\n align-items: center;\n background: ${({ theme }) => theme.colors.light};\n padding: 20px;\n position: fixed;\n bottom: 0;\n right: 0;\n z-index: 1000;\n width: 100%;\n border-top: solid 1px ${({ theme }) => theme.colors.grayLight};\n transition: all 0.3s ease;\n transform: translateX(100%);\n opacity: 0;\n\n ${mq(\"lg\")} {\n width: 420px;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n }\n\n ${({ $isVisible }) =>\n $isVisible &&\n css`\n opacity: 1;\n transform: translateX(0);\n `}\n\n & .loading {\n animation: ${loadingAnimation} 1s linear infinite;\n }\n`;\n\nconst StyledGlowSmallButton = styled(StyledSmallButton)<{\n theme: Theme;\n $hasContent: boolean;\n}>`\n @property --gradient-angle {\n syntax: \"<angle>\";\n initial-value: 0deg;\n inherits: false;\n }\n\n position: relative;\n isolation: isolate;\n margin-right: 0;\n background: ${({ theme }) => theme.colors.light};\n padding: 0;\n\n &::before {\n content: \"\";\n inset: -2px;\n border-radius: 8px;\n background: conic-gradient(\n from var(--gradient-angle),\n #cc5555,\n #d9a745,\n #3ab0cc,\n #cc7fc2,\n #4380cc,\n #4c1fa3,\n #cc5555\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation: ${rotateGradient} 3s linear infinite;\n z-index: -1;\n position: absolute;\n top: -2px;\n left: -2px;\n width: calc(100% + 4px);\n height: calc(100% + 4px);\n }\n\n &::after {\n content: \"\";\n position: absolute;\n inset: -8px;\n border-radius: 14px;\n background: conic-gradient(\n from var(--gradient-angle),\n ${rgba(\"#ff6b6b\", 0.4)},\n ${rgba(\"#feca57\", 0.4)},\n ${rgba(\"#48dbfb\", 0.4)},\n ${rgba(\"#ff9ff3\", 0.4)},\n ${rgba(\"#54a0ff\", 0.4)},\n ${rgba(\"#5f27cd\", 0.4)},\n ${rgba(\"#ff6b6b\", 0.4)}\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation:\n ${rotateGradient} 3s linear infinite,\n ${pulseGlow} 2s ease-in-out infinite;\n z-index: -2;\n pointer-events: none;\n }\n\n &:hover::before,\n &:hover::after {\n opacity: 1;\n }\n\n & span {\n padding: 6px 8px;\n display: flex;\n background: ${({ theme }) => theme.colors.light};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n gap: 6px;\n }\n\n ${({ $hasContent }) =>\n $hasContent &&\n css`\n &::before {\n opacity: 1;\n }\n &::after {\n opacity: 1;\n }\n `}\n`;\n\nconst StyledError = styled.div<{ theme: Theme }>`\n overflow-x: auto;\n background: ${({ theme }) => theme.colors.error};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\n padding: 10px;\n border-radius: 8px;\n margin: 20px 0;\n width: 100%;\n ${styledText};\n`;\n\nconst loadingDotAnimation = keyframes`\n 0% {\n opacity: 0;\n }\n 50% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n`;\n\nconst StyledLoading = styled.div<{ theme: Theme }>`\n overflow-x: auto;\n margin: 20px 0;\n width: 100%;\n font-weight: 600;\n ${styledText};\n color: ${({ theme }) => theme.colors.dark};\n\n & span {\n &:nth-child(1) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n }\n &:nth-child(2) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n animation-delay: 0.2s;\n }\n &:nth-child(3) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n animation-delay: 0.4s;\n }\n }\n`;\n\nconst StyledAnswer = styled.div<{ theme: Theme; $isAnswer: boolean }>`\n overflow-x: auto;\n background: ${({ theme }) => theme.colors.primary};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\n padding: 10px;\n border-radius: 8px;\n margin: 20px 0;\n width: 100%;\n ${styledText};\n\n & p {\n ${styledText};\n }\n\n ${({ $isAnswer }) =>\n $isAnswer &&\n css`\n background: transparent;\n color: ${({ theme }) => theme.colors.dark};\n padding: 0;\n `}\n\n & code:not([class]) {\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.2)};\n color: ${({ theme }) => theme.colors.dark};\n padding: 2px 4px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n white-space: pre;\n }\n\n ${styledAnchor};\n ${stylesLists};\n ${styledTable};\n\n & pre,\n & .hljs {\n margin: 10px 0;\n }\n\n & .code-wrapper pre {\n margin: 0;\n ${styledText};\n }\n\n & > *:first-child {\n margin-top: 0;\n }\n\n & > *:last-child {\n margin-bottom: 0;\n\n & > *:last-child {\n margin-bottom: 0;\n }\n }\n\n & ul,\n & ol {\n & li {\n ${styledText};\n }\n }\n\n & ol {\n & > li {\n padding-left: 20px;\n\n &::before {\n position: absolute;\n top: 0;\n left: 0;\n }\n }\n }\n\n & img,\n & video,\n & iframe {\n max-width: 100%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n margin: 10px 0;\n display: block;\n }\n\n & h1,\n & h2,\n & h3,\n & h4,\n & h5,\n & h6 {\n margin: 10px 0;\n padding: 0;\n }\n`;\n\nconst StyledChatTitle = styled.div<{ theme: Theme }>`\n display: flex;\n flex-wrap: nowrap;\n justify-content: space-between;\n position: sticky;\n margin: 0 -20px;\n padding: 16px 20px;\n height: 62px;\n top: 0;\n background: ${({ theme }) => theme.colors.light};\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n z-index: 1000;\n`;\n\nconst StyledChatTitleIconWrapper = styled.span<{ theme: Theme }>`\n display: flex;\n align-items: center;\n gap: 5px;\n color: ${({ theme }) => theme.colors.dark};\n`;\n\nconst StyledChatCloseButton = styled.button<{ theme: Theme }>`\n background: transparent;\n border: none;\n cursor: pointer;\n padding: 0;\n margin: 0;\n color: ${({ theme }) => theme.colors.primary};\n\n &:hover {\n color: ${({ theme }) => theme.colors.primaryDark};\n transform: scale(1.05);\n }\n\n &:active {\n transform: scale(0.95);\n }\n`;\n\ntype Answer = {\n text: string;\n answer?: boolean;\n mdx?: MDXRemoteSerializeResult;\n};\n\nconst SPARKLE_COLORS = [\n \"#ff6b6b\",\n \"#feca57\",\n \"#48dbfb\",\n \"#ff9ff3\",\n \"#54a0ff\",\n \"#5f27cd\",\n];\n\n// Deterministic sparkle positions to avoid hydration mismatch\nconst SPARKLE_POSITIONS = [\n { left: 8, top: 35 },\n { left: 17, top: 55 },\n { left: 26, top: 28 },\n { left: 35, top: 68 },\n { left: 44, top: 42 },\n { left: 53, top: 75 },\n { left: 62, top: 32 },\n { left: 71, top: 58 },\n { left: 80, top: 45 },\n { left: 89, top: 65 },\n];\n\ninterface RainbowInputProps {\n id?: string;\n value: string;\n onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;\n placeholder?: string;\n autoComplete?: string;\n \"aria-label\"?: string;\n inputRef?: React.Ref<HTMLInputElement>;\n}\n\nfunction RainbowInput({\n id,\n value,\n onChange,\n placeholder,\n autoComplete,\n \"aria-label\": ariaLabel,\n inputRef,\n}: RainbowInputProps) {\n const [isFocused, setIsFocused] = useState(false);\n const [isHovered, setIsHovered] = useState(false);\n const isActive = isFocused || isHovered;\n\n const sparkles = SPARKLE_POSITIONS.map((pos, i) => ({\n color: SPARKLE_COLORS[i % SPARKLE_COLORS.length],\n left: pos.left,\n top: pos.top,\n delay: i * 0.12,\n }));\n\n return (\n <StyledRainbowInputWrapper\n $isActive={isActive}\n onMouseEnter={() => setIsHovered(true)}\n onMouseLeave={() => setIsHovered(false)}\n >\n <StyledSparkleContainer $isActive={isActive}>\n {sparkles.map((sparkle, i) => (\n <StyledSparkle\n key={i}\n $color={sparkle.color}\n $left={sparkle.left}\n $top={sparkle.top}\n $delay={sparkle.delay}\n />\n ))}\n </StyledSparkleContainer>\n <StyledRainbowInput\n ref={inputRef}\n id={id}\n value={value}\n onChange={onChange}\n placeholder={placeholder}\n autoComplete={autoComplete}\n aria-label={ariaLabel}\n onFocus={() => setIsFocused(true)}\n onBlur={() => setIsFocused(false)}\n />\n </StyledRainbowInputWrapper>\n );\n}\n\nfunction ChatButtonCTA() {\n const { setIsOpen, isOpen, answer, setAnswer, chatInputRef } =\n useContext(ChatContext);\n\n return (\n <StyledGlowSmallButton\n onClick={() => {\n const next = !isOpen;\n setIsOpen(next);\n if (next) {\n if (answer.length === 0) {\n setAnswer([\n { text: \"Hey there, how can I assist you?\", answer: true },\n ]);\n }\n setTimeout(() => {\n chatInputRef.current?.focus();\n }, 350);\n }\n }}\n aria-label=\"Ask AI Assistant\"\n $hasContent={isOpen}\n type=\"button\"\n >\n <span>\n <Sparkles size={16} />\n Ask AI\n </span>\n </StyledGlowSmallButton>\n );\n}\n\nfunction Chat() {\n const {\n isOpen,\n question,\n setQuestion,\n loading,\n error,\n answer,\n ask,\n clearChat,\n chatInputRef,\n } = useContext(ChatContext);\n const endRef = useRef<HTMLDivElement | null>(null);\n\n useEffect(() => {\n endRef.current?.scrollIntoView({ behavior: \"smooth\", block: \"end\" });\n }, [answer]);\n\n useEffect(() => {\n if (answer?.length > 0) {\n chatInputRef.current?.focus();\n }\n }, [answer, chatInputRef]);\n\n return (\n <>\n <StyledChat $isVisible={isOpen}>\n <StyledChatTitle>\n <StyledChatTitleIconWrapper>\n <Sparkles />\n <h3>AI Assistant</h3>\n </StyledChatTitleIconWrapper>\n <StyledChatCloseButton onClick={clearChat} aria-label=\"Close chat\">\n <X />\n </StyledChatCloseButton>\n </StyledChatTitle>\n {answer &&\n answer.map((a, i) => (\n <StyledAnswer key={i} $isAnswer={a.answer ?? false}>\n {a.answer && a.mdx ? (\n <MDXRemote {...a.mdx} components={mdxComponents} />\n ) : (\n a.text\n )}\n </StyledAnswer>\n ))}\n {loading && (\n <StyledLoading>\n Answering<span>.</span>\n <span>.</span>\n <span>.</span>\n </StyledLoading>\n )}\n {error && (\n <StyledError>\n <strong>Error:</strong> {error}\n </StyledError>\n )}\n <div ref={endRef} />\n </StyledChat>\n\n <StyledChatForm onSubmit={ask} $isVisible={isOpen}>\n <RainbowInput\n id=\"chat-bottom-input\"\n inputRef={chatInputRef}\n value={question}\n onChange={(e) => setQuestion(e.target.value)}\n placeholder=\"Ask AI Assistant...\"\n autoComplete=\"off\"\n aria-label=\"Ask a follow-up question\"\n />\n <StyledRainbowButton\n type=\"submit\"\n disabled={loading || question.trim() === \"\"}\n $hasContent={question.trim().length > 0}\n aria-label={loading ? \"Loading response\" : \"Submit question\"}\n >\n {loading ? <LoaderPinwheel className=\"loading\" /> : <ArrowUp />}\n </StyledRainbowButton>\n </StyledChatForm>\n </>\n );\n}\n\nconst ChatContext = createContext<{\n isOpen: boolean;\n setIsOpen: (isOpen: boolean) => void;\n isChatActive: boolean;\n question: string;\n setQuestion: (q: string) => void;\n loading: boolean;\n error: string | null;\n answer: Answer[];\n setAnswer: (answers: Answer[]) => void;\n ask: (e: React.FormEvent) => void;\n clearChat: () => void;\n chatInputRef: React.RefObject<HTMLInputElement | null>;\n}>({\n isOpen: false,\n setIsOpen: () => {},\n isChatActive: false,\n question: \"\",\n setQuestion: () => {},\n loading: false,\n error: null,\n answer: [],\n setAnswer: () => {},\n ask: () => {},\n clearChat: () => {},\n chatInputRef: { current: null },\n});\n\ninterface ChatContextProviderProps {\n children: React.ReactNode;\n isChatActive: boolean;\n}\n\nconst ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {\n const [isOpen, setIsOpen] = useState(false);\n const [question, setQuestion] = useState(\"\");\n const [loading, setLoading] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const [answer, setAnswer] = useState<Answer[]>([]);\n const abortRef = useRef<AbortController | null>(null);\n const chatInputRef = useRef<HTMLInputElement | null>(null);\n\n async function ask(e: React.FormEvent) {\n e.preventDefault();\n if (loading || question.trim() === \"\") return;\n const currentQuestion = question;\n setQuestion(\"\");\n setIsOpen(true);\n setLoading(true);\n setError(null);\n\n const mergedQuestions =\n answer.length > 0\n ? [...answer, { text: currentQuestion, answer: false }]\n : [{ text: currentQuestion, answer: false }];\n\n setAnswer(mergedQuestions);\n\n const controller = new AbortController();\n abortRef.current = controller;\n\n try {\n const history = answer\n .filter((a) => a.text.trim() !== \"\")\n .map((a) => ({\n role: a.answer ? (\"assistant\" as const) : (\"user\" as const),\n content: a.text,\n }));\n\n const res = await fetch(\"/api/rag\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ question: currentQuestion, history }),\n signal: controller.signal,\n });\n\n if (!res.ok) {\n const errorData = await res.json();\n throw new Error(errorData.error || \"Request failed\");\n }\n\n const reader = res.body?.getReader();\n const decoder = new TextDecoder();\n const contentParts: string[] = [];\n if (!reader) {\n throw new Error(\"Failed to get response reader\");\n }\n\n const streamingAnswerIndex = mergedQuestions.length;\n setAnswer([...mergedQuestions, { text: \"\", answer: true }]);\n\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n\n buffer += decoder.decode(value, { stream: true });\n const parts = buffer.split(\"\\n\");\n buffer = parts.pop() ?? \"\";\n\n for (const line of parts) {\n if (line.startsWith(\"data: \")) {\n try {\n const data = JSON.parse(line.slice(6));\n\n if (data.type === \"content\") {\n contentParts.push(data.data);\n const streamedContent = contentParts.join(\"\");\n\n setAnswer((prev) => {\n const newAnswers = [...prev];\n newAnswers[streamingAnswerIndex] = {\n text: streamedContent,\n answer: true,\n };\n return newAnswers;\n });\n } else if (data.type === \"error\") {\n throw new Error(data.data);\n } else if (data.type === \"done\") {\n const streamedContent = contentParts.join(\"\");\n let mdxSource: MDXRemoteSerializeResult | null = null;\n try {\n mdxSource = await serialize(streamedContent, {\n parseFrontmatter: false,\n mdxOptions: {\n remarkPlugins: [remarkGfm],\n rehypePlugins: [rehypeHighlight],\n format: \"md\",\n development: false,\n },\n });\n } catch (mdxError: unknown) {\n console.error(\"MDX serialization error:\", mdxError);\n }\n\n setAnswer((prev) => {\n const newAnswers = [...prev];\n newAnswers[streamingAnswerIndex] = {\n text: streamedContent,\n answer: true,\n mdx: mdxSource || undefined,\n };\n return newAnswers;\n });\n }\n } catch (parseError) {\n if (\n parseError instanceof Error &&\n parseError.message !== \"Unknown error\"\n ) {\n console.error(\"Failed to parse SSE data:\", parseError);\n }\n }\n }\n }\n }\n } catch (err: unknown) {\n if (err instanceof DOMException && err.name === \"AbortError\") return;\n setError(err instanceof Error ? err.message : \"Unknown error\");\n } finally {\n abortRef.current = null;\n setLoading(false);\n }\n }\n\n function clearChat() {\n abortRef.current?.abort();\n setAnswer([]);\n setIsOpen(false);\n }\n\n return (\n <ChatContext.Provider\n value={{\n isOpen,\n setIsOpen,\n isChatActive,\n question,\n setQuestion,\n loading,\n error,\n answer,\n setAnswer,\n ask,\n clearChat,\n chatInputRef,\n }}\n >\n {children}\n </ChatContext.Provider>\n );\n};\n\nexport { Chat, ChtProvider, ChatContext, ChatButtonCTA };\n";
|
|
1
|
+
export declare const chatTemplate = "\"use client\";\nimport React, {\n createContext,\n useContext,\n useEffect,\n useRef,\n useState,\n} from \"react\";\nimport styled, { css, keyframes } from \"styled-components\";\nimport { rgba } from \"polished\";\nimport { Button } from \"cherry-styled-components\";\nimport { ArrowUp, LoaderPinwheel, RotateCcw, Sparkles, X } from \"lucide-react\";\nimport remarkGfm from \"remark-gfm\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport { MDXRemote, MDXRemoteSerializeResult } from \"next-mdx-remote\";\nimport { serialize } from \"next-mdx-remote/serialize\";\nimport Link from \"next/link\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { useLockBodyScroll } from \"@/components/LockBodyScroll\";\nimport { useMDXComponents as getMDXComponents } from \"@/components/MDXComponents\";\nimport {\n styledAnchor,\n styledTable,\n stylesLists,\n StyledSmallButton,\n interactiveStyles,\n} from \"@/components/layout/SharedStyled\";\n\nconst mdxComponents = getMDXComponents({});\n\nconst styledText = css<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.text.xs};\n line-height: ${({ theme }) => theme.lineHeights.text.xs};\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n line-height: ${({ theme }) => theme.lineHeights.small.lg};\n }\n`;\n\nconst StyledChat = styled.div<{ theme: Theme; $isVisible: boolean }>`\n margin: 0;\n position: fixed;\n top: 0;\n right: 0;\n width: 100%;\n height: calc(100dvh - 90px);\n overflow-y: scroll;\n overflow-x: hidden;\n z-index: 1000;\n padding: 0 20px;\n transition: all 0.3s ease;\n transform: translateX(0);\n background: ${({ theme }) => theme.colors.light};\n -webkit-overflow-scrolling: touch;\n opacity: 1;\n\n &::-webkit-scrollbar {\n display: none;\n }\n\n ${({ $isVisible }) =>\n !$isVisible &&\n css`\n transform: translateX(100%);\n opacity: 0;\n `}\n\n ${mq(\"lg\")} {\n width: 420px;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n }\n`;\n\nconst loadingAnimation = keyframes`\n 0% {\n transform: rotate(0deg);\n }\n 100% {\n transform: rotate(360deg);\n }\n`;\n\nconst rotateGradient = keyframes`\n 0% {\n --gradient-angle: 0deg;\n }\n 100% {\n --gradient-angle: 360deg;\n }\n`;\n\nconst pulseGlow = keyframes`\n 0%, 100% {\n opacity: 0.5;\n filter: blur(16px);\n }\n 50% {\n opacity: 1;\n filter: blur(22px);\n }\n`;\n\nconst sparkleFloat = keyframes`\n 0%, 100% {\n opacity: 0;\n transform: translateY(0) scale(0);\n }\n 50% {\n opacity: 0.9;\n transform: translateY(-20px) scale(1);\n }\n`;\n\nconst shimmer = keyframes`\n 0% {\n background-position: 0% center;\n }\n 50% {\n background-position: 100% center;\n }\n 100% {\n background-position: 0% center;\n }\n`;\n\nconst StyledRainbowInputWrapper = styled.div<{\n theme: Theme;\n $isActive: boolean;\n}>`\n @property --gradient-angle {\n syntax: \"<angle>\";\n initial-value: 0deg;\n inherits: false;\n }\n\n position: relative;\n flex: 1;\n\n &::before {\n content: \"\";\n position: absolute;\n inset: -2px;\n border-radius: 14px;\n background: conic-gradient(\n from var(--gradient-angle),\n #cc5555,\n #d9a745,\n #3ab0cc,\n #cc7fc2,\n #4380cc,\n #4c1fa3,\n #cc5555\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation: ${rotateGradient} 3s linear infinite;\n z-index: 0;\n }\n\n &::after {\n content: \"\";\n position: absolute;\n inset: -10px;\n border-radius: 20px;\n background: conic-gradient(\n from var(--gradient-angle),\n ${rgba(\"#ff6b6b\", 0.4)},\n ${rgba(\"#feca57\", 0.4)},\n ${rgba(\"#48dbfb\", 0.4)},\n ${rgba(\"#ff9ff3\", 0.4)},\n ${rgba(\"#54a0ff\", 0.4)},\n ${rgba(\"#5f27cd\", 0.4)},\n ${rgba(\"#ff6b6b\", 0.4)}\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation:\n ${rotateGradient} 3s linear infinite,\n ${pulseGlow} 2s ease-in-out infinite;\n z-index: -1;\n pointer-events: none;\n }\n\n &:hover::before,\n &:focus-within::before {\n opacity: 1;\n }\n\n &:hover::after,\n &:focus-within::after {\n opacity: 1;\n }\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n &::before {\n opacity: 1;\n }\n &::after {\n opacity: 1;\n }\n `}\n`;\n\nconst StyledSparkleContainer = styled.div<{ $isActive: boolean }>`\n position: absolute;\n inset: -30px;\n pointer-events: none;\n overflow: hidden;\n border-radius: 30px;\n z-index: -2;\n opacity: 0;\n transition: opacity 0.4s ease;\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n opacity: 1;\n `}\n`;\n\nconst StyledSparkle = styled.div<{\n $color: string;\n $left: number;\n $top: number;\n $delay: number;\n}>`\n position: absolute;\n width: 4px;\n height: 4px;\n border-radius: 50%;\n background: ${({ $color }) => $color};\n box-shadow: 0 0 6px ${({ $color }) => $color};\n left: ${({ $left }) => $left}%;\n top: ${({ $top }) => $top}%;\n animation: ${sparkleFloat} 2s ease-in-out infinite;\n animation-delay: ${({ $delay }) => $delay}s;\n`;\n\nconst StyledRainbowInput = styled.input<{ theme: Theme }>`\n position: relative;\n z-index: 1;\n width: 100%;\n background: ${({ theme }) => theme.colors.light};\n border: 1px solid ${({ theme }) => theme.colors.grayLight};\n border-radius: 12px;\n padding: 14px 18px;\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n font-family: inherit;\n color: ${({ theme }) => theme.colors.dark};\n outline: none;\n transition:\n border-color 0.3s ease,\n box-shadow 0.3s ease;\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n }\n\n &::placeholder {\n color: ${({ theme }) => rgba(theme.colors.dark, 0.4)};\n transition: color 0.3s ease;\n }\n\n &:focus::placeholder {\n color: ${({ theme }) => rgba(theme.colors.dark, 0.6)};\n }\n\n &:focus {\n border-color: transparent;\n }\n`;\n\nconst StyledRainbowButton = styled(Button)<{\n theme: Theme;\n $hasContent: boolean;\n}>`\n padding-top: 10px;\n padding-bottom: 10px;\n position: relative;\n overflow: hidden;\n transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\n\n &::before {\n content: \"\";\n position: absolute;\n inset: 0;\n background: linear-gradient(\n 135deg,\n #ff6b6b,\n #feca57,\n #48dbfb,\n #ff9ff3,\n #54a0ff\n );\n background-size: 300% 300%;\n opacity: 0;\n transition: opacity 0.3s ease;\n z-index: 0;\n animation: ${shimmer} 3s linear infinite;\n width: 200%;\n }\n\n ${({ $hasContent }) =>\n $hasContent &&\n css`\n &::before {\n opacity: 1;\n }\n `}\n\n &:hover::before {\n opacity: 1;\n }\n\n &:hover {\n transform: scale(1.05);\n }\n\n &:active {\n transform: scale(0.95);\n }\n\n & svg {\n position: relative;\n z-index: 1;\n transition: transform 0.3s ease;\n }\n\n &:disabled,\n &:disabled:hover {\n background: ${({ theme }) => theme.colors.primaryDark};\n transform: none;\n box-shadow: none;\n\n &::before {\n opacity: 0;\n }\n }\n`;\n\nconst StyledChatForm = styled.form<{ theme: Theme; $isVisible: boolean }>`\n display: flex;\n gap: 10px;\n justify-content: center;\n align-items: center;\n background: ${({ theme }) => theme.colors.light};\n padding: 20px;\n position: fixed;\n bottom: 0;\n right: 0;\n z-index: 1000;\n width: 100%;\n border-top: solid 1px ${({ theme }) => theme.colors.grayLight};\n transition: all 0.3s ease;\n transform: translateX(100%);\n opacity: 0;\n\n ${mq(\"lg\")} {\n width: 420px;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n }\n\n ${({ $isVisible }) =>\n $isVisible &&\n css`\n opacity: 1;\n transform: translateX(0);\n `}\n\n & .loading {\n animation: ${loadingAnimation} 1s linear infinite;\n }\n`;\n\nconst StyledGlowSmallButton = styled(StyledSmallButton)<{\n theme: Theme;\n $hasContent: boolean;\n}>`\n @property --gradient-angle {\n syntax: \"<angle>\";\n initial-value: 0deg;\n inherits: false;\n }\n\n position: relative;\n isolation: isolate;\n margin-right: 0;\n background: ${({ theme }) => theme.colors.light};\n padding: 0;\n\n &::before {\n content: \"\";\n inset: -2px;\n border-radius: 8px;\n background: conic-gradient(\n from var(--gradient-angle),\n #cc5555,\n #d9a745,\n #3ab0cc,\n #cc7fc2,\n #4380cc,\n #4c1fa3,\n #cc5555\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation: ${rotateGradient} 3s linear infinite;\n z-index: -1;\n position: absolute;\n top: -2px;\n left: -2px;\n width: calc(100% + 4px);\n height: calc(100% + 4px);\n }\n\n &::after {\n content: \"\";\n position: absolute;\n inset: -8px;\n border-radius: 14px;\n background: conic-gradient(\n from var(--gradient-angle),\n ${rgba(\"#ff6b6b\", 0.4)},\n ${rgba(\"#feca57\", 0.4)},\n ${rgba(\"#48dbfb\", 0.4)},\n ${rgba(\"#ff9ff3\", 0.4)},\n ${rgba(\"#54a0ff\", 0.4)},\n ${rgba(\"#5f27cd\", 0.4)},\n ${rgba(\"#ff6b6b\", 0.4)}\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation:\n ${rotateGradient} 3s linear infinite,\n ${pulseGlow} 2s ease-in-out infinite;\n z-index: -2;\n pointer-events: none;\n }\n\n &:hover::before,\n &:hover::after {\n opacity: 1;\n }\n\n & span {\n padding: 6px 8px;\n display: flex;\n background: ${({ theme }) => theme.colors.light};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n gap: 6px;\n }\n\n ${({ $hasContent }) =>\n $hasContent &&\n css`\n &::before {\n opacity: 1;\n }\n &::after {\n opacity: 1;\n }\n `}\n`;\n\nconst StyledError = styled.div<{ theme: Theme }>`\n overflow-x: auto;\n background: ${({ theme }) => theme.colors.error};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\n padding: 10px;\n border-radius: 8px;\n margin: 20px 0;\n width: 100%;\n ${styledText};\n`;\n\nconst loadingDotAnimation = keyframes`\n 0% {\n opacity: 0;\n }\n 50% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n`;\n\nconst StyledLoading = styled.div<{ theme: Theme }>`\n overflow-x: auto;\n margin: 20px 0;\n width: 100%;\n font-weight: 600;\n ${styledText};\n color: ${({ theme }) => theme.colors.dark};\n\n & span {\n &:nth-child(1) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n }\n &:nth-child(2) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n animation-delay: 0.2s;\n }\n &:nth-child(3) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n animation-delay: 0.4s;\n }\n }\n`;\n\nconst StyledAnswer = styled.div<{ theme: Theme; $isAnswer: boolean }>`\n overflow-x: auto;\n background: ${({ theme }) => theme.colors.primary};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\n padding: 10px;\n border-radius: 8px;\n margin: 20px 0;\n width: 100%;\n ${styledText};\n\n & p {\n ${styledText};\n }\n\n ${({ $isAnswer }) =>\n $isAnswer &&\n css`\n background: transparent;\n color: ${({ theme }) => theme.colors.dark};\n padding: 0;\n `}\n\n & code:not([class]) {\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.2)};\n color: ${({ theme }) => theme.colors.dark};\n padding: 2px 4px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n white-space: pre;\n }\n\n ${styledAnchor};\n ${stylesLists};\n ${styledTable};\n\n & pre,\n & .hljs {\n margin: 10px 0;\n }\n\n & .code-wrapper pre {\n margin: 0;\n ${styledText};\n }\n\n & > *:first-child {\n margin-top: 0;\n }\n\n & > *:last-child {\n margin-bottom: 0;\n\n & > *:last-child {\n margin-bottom: 0;\n }\n }\n\n & ul,\n & ol {\n & li {\n ${styledText};\n }\n }\n\n & ol {\n & > li {\n padding-left: 20px;\n\n &::before {\n position: absolute;\n top: 0;\n left: 0;\n }\n }\n }\n\n & img,\n & video,\n & iframe {\n max-width: 100%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n margin: 10px 0;\n display: block;\n }\n\n & h1,\n & h2,\n & h3,\n & h4,\n & h5,\n & h6 {\n margin: 10px 0;\n padding: 0;\n }\n`;\n\nconst StyledSources = styled.div`\n display: flex;\n gap: 16px;\n flex-wrap: wrap;\n margin: -5px 0 20px;\n`;\n\nconst StyledSourceLink = styled(Link)<{ theme: Theme }>`\n position: relative;\n text-decoration: none;\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n line-height: 1;\n color: ${({ theme }) => theme.colors.primary};\n display: flex;\n gap: 6px;\n transition: all 0.3s ease;\n font-weight: 600;\n white-space: nowrap;\n min-width: fit-content;\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.1)};\n padding: 6px 8px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n ${interactiveStyles};\n\n & * {\n margin: auto 0;\n }\n\n &:hover {\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};\n }\n`;\n\nconst StyledChatTitle = styled.div<{ theme: Theme }>`\n display: flex;\n flex-wrap: nowrap;\n justify-content: space-between;\n position: sticky;\n margin: 0 -20px;\n padding: 16px 20px;\n height: 62px;\n top: 0;\n background: ${({ theme }) => theme.colors.light};\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n z-index: 1000;\n`;\n\nconst StyledChatTitleIconWrapper = styled.span<{ theme: Theme }>`\n display: flex;\n align-items: center;\n gap: 12px;\n color: ${({ theme }) => theme.colors.dark};\n`;\n\nconst StyledChatCloseButton = styled.button<{ theme: Theme }>`\n background: transparent;\n border: none;\n cursor: pointer;\n padding: 0;\n margin: 0;\n color: ${({ theme }) => theme.colors.primary};\n\n &:hover {\n color: ${({ theme }) => theme.colors.primaryDark};\n transform: scale(1.05);\n }\n\n &:active {\n transform: scale(0.95);\n }\n`;\n\ntype Source = {\n id: string;\n path: string;\n uri: string;\n score: number;\n};\n\ntype Answer = {\n text: string;\n answer?: boolean;\n mdx?: MDXRemoteSerializeResult;\n sources?: Source[];\n};\n\nconst SPARKLE_COLORS = [\n \"#ff6b6b\",\n \"#feca57\",\n \"#48dbfb\",\n \"#ff9ff3\",\n \"#54a0ff\",\n \"#5f27cd\",\n];\n\n// Deterministic sparkle positions to avoid hydration mismatch\nconst SPARKLE_POSITIONS = [\n { left: 8, top: 35 },\n { left: 17, top: 55 },\n { left: 26, top: 28 },\n { left: 35, top: 68 },\n { left: 44, top: 42 },\n { left: 53, top: 75 },\n { left: 62, top: 32 },\n { left: 71, top: 58 },\n { left: 80, top: 45 },\n { left: 89, top: 65 },\n];\n\ninterface RainbowInputProps {\n id?: string;\n value: string;\n onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;\n placeholder?: string;\n autoComplete?: string;\n \"aria-label\"?: string;\n inputRef?: React.Ref<HTMLInputElement>;\n}\n\nfunction RainbowInput({\n id,\n value,\n onChange,\n placeholder,\n autoComplete,\n \"aria-label\": ariaLabel,\n inputRef,\n}: RainbowInputProps) {\n const [isFocused, setIsFocused] = useState(false);\n const [isHovered, setIsHovered] = useState(false);\n const isActive = isFocused || isHovered;\n\n const sparkles = SPARKLE_POSITIONS.map((pos, i) => ({\n color: SPARKLE_COLORS[i % SPARKLE_COLORS.length],\n left: pos.left,\n top: pos.top,\n delay: i * 0.12,\n }));\n\n return (\n <StyledRainbowInputWrapper\n $isActive={isActive}\n onMouseEnter={() => setIsHovered(true)}\n onMouseLeave={() => setIsHovered(false)}\n >\n <StyledSparkleContainer $isActive={isActive}>\n {sparkles.map((sparkle, i) => (\n <StyledSparkle\n key={i}\n $color={sparkle.color}\n $left={sparkle.left}\n $top={sparkle.top}\n $delay={sparkle.delay}\n />\n ))}\n </StyledSparkleContainer>\n <StyledRainbowInput\n ref={inputRef}\n id={id}\n value={value}\n onChange={onChange}\n placeholder={placeholder}\n autoComplete={autoComplete}\n aria-label={ariaLabel}\n onFocus={() => setIsFocused(true)}\n onBlur={() => setIsFocused(false)}\n />\n </StyledRainbowInputWrapper>\n );\n}\n\nfunction ChatButtonCTA() {\n const { setIsOpen, isOpen, answer, setAnswer, chatInputRef } =\n useContext(ChatContext);\n\n return (\n <StyledGlowSmallButton\n onClick={() => {\n const next = !isOpen;\n setIsOpen(next);\n if (next) {\n if (answer.length === 0) {\n setAnswer([\n { text: \"Hey there, how can I assist you?\", answer: true },\n ]);\n }\n setTimeout(() => {\n chatInputRef.current?.focus();\n }, 350);\n }\n }}\n aria-label=\"Ask AI Assistant\"\n $hasContent={isOpen}\n type=\"button\"\n >\n <span>\n <Sparkles size={16} />\n Ask AI\n </span>\n </StyledGlowSmallButton>\n );\n}\n\nfunction Chat() {\n const {\n isOpen,\n question,\n setQuestion,\n loading,\n error,\n answer,\n ask,\n closeChat,\n resetChat,\n chatInputRef,\n } = useContext(ChatContext);\n const endRef = useRef<HTMLDivElement | null>(null);\n\n useLockBodyScroll(isOpen);\n\n useEffect(() => {\n endRef.current?.scrollIntoView({ behavior: \"smooth\", block: \"end\" });\n }, [answer]);\n\n useEffect(() => {\n if (answer?.length > 0) {\n chatInputRef.current?.focus();\n }\n }, [answer, chatInputRef]);\n\n return (\n <>\n <StyledChat $isVisible={isOpen}>\n <StyledChatTitle>\n <StyledChatTitleIconWrapper>\n <Sparkles />\n <h3>AI Assistant</h3>\n </StyledChatTitleIconWrapper>\n <StyledChatTitleIconWrapper>\n <StyledChatCloseButton\n onClick={resetChat}\n aria-label=\"Reset chat history\"\n title=\"Reset chat history\"\n >\n <RotateCcw size={18} />\n </StyledChatCloseButton>\n <StyledChatCloseButton\n onClick={closeChat}\n aria-label=\"Close chat\"\n title=\"Close chat\"\n >\n <X />\n </StyledChatCloseButton>\n </StyledChatTitleIconWrapper>\n </StyledChatTitle>\n {answer &&\n answer.map((a, i) => (\n <React.Fragment key={i}>\n <StyledAnswer $isAnswer={a.answer ?? false}>\n {a.answer && a.mdx ? (\n <MDXRemote {...a.mdx} components={mdxComponents} />\n ) : (\n a.text\n )}\n </StyledAnswer>\n {a.answer && a.sources && a.sources.length > 0 && (\n <StyledSources>\n {a.sources.map((src) => {\n const slug = src.uri\n .replace(\"docs://\", \"\")\n .replace(/^\\/+/, \"\");\n const href = slug ? `/${slug}/` : \"/\";\n const label = slug\n ? slug\n .split(\"/\")\n .pop()!\n .replace(/-/g, \" \")\n .replace(/\\b\\w/g, (c: string) => c.toUpperCase())\n : \"Home\";\n return (\n <StyledSourceLink key={src.id} href={href}>\n {label}\n </StyledSourceLink>\n );\n })}\n </StyledSources>\n )}\n </React.Fragment>\n ))}\n {loading && (\n <StyledLoading>\n Answering<span>.</span>\n <span>.</span>\n <span>.</span>\n </StyledLoading>\n )}\n {error && (\n <StyledError>\n <strong>Error:</strong> {error}\n </StyledError>\n )}\n <div ref={endRef} />\n </StyledChat>\n\n <StyledChatForm onSubmit={ask} $isVisible={isOpen}>\n <RainbowInput\n id=\"chat-bottom-input\"\n inputRef={chatInputRef}\n value={question}\n onChange={(e) => setQuestion(e.target.value)}\n placeholder=\"Ask AI Assistant...\"\n autoComplete=\"off\"\n aria-label=\"Ask a follow-up question\"\n />\n <StyledRainbowButton\n type=\"submit\"\n disabled={loading || question.trim() === \"\"}\n $hasContent={question.trim().length > 0}\n aria-label={loading ? \"Loading response\" : \"Submit question\"}\n >\n {loading ? <LoaderPinwheel className=\"loading\" /> : <ArrowUp />}\n </StyledRainbowButton>\n </StyledChatForm>\n </>\n );\n}\n\nconst ChatContext = createContext<{\n isOpen: boolean;\n setIsOpen: (isOpen: boolean) => void;\n isChatActive: boolean;\n question: string;\n setQuestion: (q: string) => void;\n loading: boolean;\n error: string | null;\n answer: Answer[];\n setAnswer: (answers: Answer[]) => void;\n ask: (e: React.FormEvent) => void;\n closeChat: () => void;\n resetChat: () => void;\n chatInputRef: React.RefObject<HTMLInputElement | null>;\n}>({\n isOpen: false,\n setIsOpen: () => {},\n isChatActive: false,\n question: \"\",\n setQuestion: () => {},\n loading: false,\n error: null,\n answer: [],\n setAnswer: () => {},\n ask: () => {},\n closeChat: () => {},\n resetChat: () => {},\n chatInputRef: { current: null },\n});\n\ninterface ChatContextProviderProps {\n children: React.ReactNode;\n isChatActive: boolean;\n}\n\nconst ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {\n const [isOpen, setIsOpen] = useState(false);\n const [question, setQuestion] = useState(\"\");\n const [loading, setLoading] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const [answer, setAnswer] = useState<Answer[]>([]);\n const abortRef = useRef<AbortController | null>(null);\n const chatInputRef = useRef<HTMLInputElement | null>(null);\n\n async function ask(e: React.FormEvent) {\n e.preventDefault();\n if (loading || question.trim() === \"\") return;\n const currentQuestion = question;\n setQuestion(\"\");\n setIsOpen(true);\n setLoading(true);\n setError(null);\n\n const mergedQuestions =\n answer.length > 0\n ? [...answer, { text: currentQuestion, answer: false }]\n : [{ text: currentQuestion, answer: false }];\n\n setAnswer(mergedQuestions);\n\n const controller = new AbortController();\n abortRef.current = controller;\n\n try {\n const history = answer\n .filter((a) => a.text.trim() !== \"\")\n .map((a) => ({\n role: a.answer ? (\"assistant\" as const) : (\"user\" as const),\n content: a.text,\n }));\n\n const res = await fetch(\"/api/rag\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ question: currentQuestion, history }),\n signal: controller.signal,\n });\n\n if (!res.ok) {\n const errorData = await res.json();\n throw new Error(errorData.error || \"Request failed\");\n }\n\n const reader = res.body?.getReader();\n const decoder = new TextDecoder();\n const contentParts: string[] = [];\n let sources: Source[] = [];\n if (!reader) {\n throw new Error(\"Failed to get response reader\");\n }\n\n const streamingAnswerIndex = mergedQuestions.length;\n setAnswer([...mergedQuestions, { text: \"\", answer: true }]);\n\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n\n buffer += decoder.decode(value, { stream: true });\n const parts = buffer.split(\"\\n\");\n buffer = parts.pop() ?? \"\";\n\n for (const line of parts) {\n if (line.startsWith(\"data: \")) {\n try {\n const data = JSON.parse(line.slice(6));\n\n if (data.type === \"metadata\") {\n const allSources: Source[] = data.data?.sources ?? [];\n const seen = new Set<string>();\n sources = allSources.filter((s: Source) => {\n if (s.score < 0.4 || seen.has(s.uri)) return false;\n seen.add(s.uri);\n return true;\n });\n } else if (data.type === \"content\") {\n contentParts.push(data.data);\n const streamedContent = contentParts.join(\"\");\n\n setAnswer((prev) => {\n const newAnswers = [...prev];\n newAnswers[streamingAnswerIndex] = {\n text: streamedContent,\n answer: true,\n sources,\n };\n return newAnswers;\n });\n } else if (data.type === \"error\") {\n throw new Error(data.data);\n } else if (data.type === \"done\") {\n const streamedContent = contentParts.join(\"\");\n let mdxSource: MDXRemoteSerializeResult | null = null;\n try {\n mdxSource = await serialize(streamedContent, {\n parseFrontmatter: false,\n mdxOptions: {\n remarkPlugins: [remarkGfm],\n rehypePlugins: [rehypeHighlight],\n format: \"md\",\n development: false,\n },\n });\n } catch (mdxError: unknown) {\n console.error(\"MDX serialization error:\", mdxError);\n }\n\n setAnswer((prev) => {\n const newAnswers = [...prev];\n newAnswers[streamingAnswerIndex] = {\n text: streamedContent,\n answer: true,\n mdx: mdxSource || undefined,\n sources,\n };\n return newAnswers;\n });\n }\n } catch (parseError) {\n if (\n parseError instanceof Error &&\n parseError.message !== \"Unknown error\"\n ) {\n console.error(\"Failed to parse SSE data:\", parseError);\n }\n }\n }\n }\n }\n } catch (err: unknown) {\n if (err instanceof DOMException && err.name === \"AbortError\") return;\n setError(err instanceof Error ? err.message : \"Unknown error\");\n } finally {\n abortRef.current = null;\n setLoading(false);\n }\n }\n\n function closeChat() {\n setIsOpen(false);\n }\n\n function resetChat() {\n abortRef.current?.abort();\n setLoading(false);\n setError(null);\n setAnswer([{ text: \"Hey there, how can I assist you?\", answer: true }]);\n }\n\n return (\n <ChatContext.Provider\n value={{\n isOpen,\n setIsOpen,\n isChatActive,\n question,\n setQuestion,\n loading,\n error,\n answer,\n setAnswer,\n ask,\n closeChat,\n resetChat,\n chatInputRef,\n }}\n >\n {children}\n </ChatContext.Provider>\n );\n};\n\nexport { Chat, ChtProvider, ChatContext, ChatButtonCTA };\n";
|
|
@@ -10,18 +10,21 @@ import React, {
|
|
|
10
10
|
import styled, { css, keyframes } from "styled-components";
|
|
11
11
|
import { rgba } from "polished";
|
|
12
12
|
import { Button } from "cherry-styled-components";
|
|
13
|
-
import { ArrowUp, LoaderPinwheel, Sparkles, X } from "lucide-react";
|
|
13
|
+
import { ArrowUp, LoaderPinwheel, RotateCcw, Sparkles, X } from "lucide-react";
|
|
14
14
|
import remarkGfm from "remark-gfm";
|
|
15
15
|
import rehypeHighlight from "rehype-highlight";
|
|
16
16
|
import { MDXRemote, MDXRemoteSerializeResult } from "next-mdx-remote";
|
|
17
17
|
import { serialize } from "next-mdx-remote/serialize";
|
|
18
|
+
import Link from "next/link";
|
|
18
19
|
import { mq, Theme } from "@/app/theme";
|
|
20
|
+
import { useLockBodyScroll } from "@/components/LockBodyScroll";
|
|
19
21
|
import { useMDXComponents as getMDXComponents } from "@/components/MDXComponents";
|
|
20
22
|
import {
|
|
21
23
|
styledAnchor,
|
|
22
24
|
styledTable,
|
|
23
25
|
stylesLists,
|
|
24
26
|
StyledSmallButton,
|
|
27
|
+
interactiveStyles,
|
|
25
28
|
} from "@/components/layout/SharedStyled";
|
|
26
29
|
|
|
27
30
|
const mdxComponents = getMDXComponents({});
|
|
@@ -42,7 +45,7 @@ const StyledChat = styled.div<{ theme: Theme; $isVisible: boolean }>\`
|
|
|
42
45
|
top: 0;
|
|
43
46
|
right: 0;
|
|
44
47
|
width: 100%;
|
|
45
|
-
height: calc(
|
|
48
|
+
height: calc(100dvh - 90px);
|
|
46
49
|
overflow-y: scroll;
|
|
47
50
|
overflow-x: hidden;
|
|
48
51
|
z-index: 1000;
|
|
@@ -608,6 +611,40 @@ const StyledAnswer = styled.div<{ theme: Theme; $isAnswer: boolean }>\`
|
|
|
608
611
|
}
|
|
609
612
|
\`;
|
|
610
613
|
|
|
614
|
+
const StyledSources = styled.div\`
|
|
615
|
+
display: flex;
|
|
616
|
+
gap: 16px;
|
|
617
|
+
flex-wrap: wrap;
|
|
618
|
+
margin: -5px 0 20px;
|
|
619
|
+
\`;
|
|
620
|
+
|
|
621
|
+
const StyledSourceLink = styled(Link)<{ theme: Theme }>\`
|
|
622
|
+
position: relative;
|
|
623
|
+
text-decoration: none;
|
|
624
|
+
font-size: \${({ theme }) => theme.fontSizes.small.lg};
|
|
625
|
+
line-height: 1;
|
|
626
|
+
color: \${({ theme }) => theme.colors.primary};
|
|
627
|
+
display: flex;
|
|
628
|
+
gap: 6px;
|
|
629
|
+
transition: all 0.3s ease;
|
|
630
|
+
font-weight: 600;
|
|
631
|
+
white-space: nowrap;
|
|
632
|
+
min-width: fit-content;
|
|
633
|
+
background: \${({ theme }) => rgba(theme.colors.primaryLight, 0.1)};
|
|
634
|
+
padding: 6px 8px;
|
|
635
|
+
border-radius: \${({ theme }) => theme.spacing.radius.xs};
|
|
636
|
+
\${interactiveStyles};
|
|
637
|
+
|
|
638
|
+
& * {
|
|
639
|
+
margin: auto 0;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
&:hover {
|
|
643
|
+
color: \${({ theme }) =>
|
|
644
|
+
theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};
|
|
645
|
+
}
|
|
646
|
+
\`;
|
|
647
|
+
|
|
611
648
|
const StyledChatTitle = styled.div<{ theme: Theme }>\`
|
|
612
649
|
display: flex;
|
|
613
650
|
flex-wrap: nowrap;
|
|
@@ -625,7 +662,7 @@ const StyledChatTitle = styled.div<{ theme: Theme }>\`
|
|
|
625
662
|
const StyledChatTitleIconWrapper = styled.span<{ theme: Theme }>\`
|
|
626
663
|
display: flex;
|
|
627
664
|
align-items: center;
|
|
628
|
-
gap:
|
|
665
|
+
gap: 12px;
|
|
629
666
|
color: \${({ theme }) => theme.colors.dark};
|
|
630
667
|
\`;
|
|
631
668
|
|
|
@@ -647,10 +684,18 @@ const StyledChatCloseButton = styled.button<{ theme: Theme }>\`
|
|
|
647
684
|
}
|
|
648
685
|
\`;
|
|
649
686
|
|
|
687
|
+
type Source = {
|
|
688
|
+
id: string;
|
|
689
|
+
path: string;
|
|
690
|
+
uri: string;
|
|
691
|
+
score: number;
|
|
692
|
+
};
|
|
693
|
+
|
|
650
694
|
type Answer = {
|
|
651
695
|
text: string;
|
|
652
696
|
answer?: boolean;
|
|
653
697
|
mdx?: MDXRemoteSerializeResult;
|
|
698
|
+
sources?: Source[];
|
|
654
699
|
};
|
|
655
700
|
|
|
656
701
|
const SPARKLE_COLORS = [
|
|
@@ -779,11 +824,14 @@ function Chat() {
|
|
|
779
824
|
error,
|
|
780
825
|
answer,
|
|
781
826
|
ask,
|
|
782
|
-
|
|
827
|
+
closeChat,
|
|
828
|
+
resetChat,
|
|
783
829
|
chatInputRef,
|
|
784
830
|
} = useContext(ChatContext);
|
|
785
831
|
const endRef = useRef<HTMLDivElement | null>(null);
|
|
786
832
|
|
|
833
|
+
useLockBodyScroll(isOpen);
|
|
834
|
+
|
|
787
835
|
useEffect(() => {
|
|
788
836
|
endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
|
789
837
|
}, [answer]);
|
|
@@ -802,19 +850,56 @@ function Chat() {
|
|
|
802
850
|
<Sparkles />
|
|
803
851
|
<h3>AI Assistant</h3>
|
|
804
852
|
</StyledChatTitleIconWrapper>
|
|
805
|
-
<
|
|
806
|
-
<
|
|
807
|
-
|
|
853
|
+
<StyledChatTitleIconWrapper>
|
|
854
|
+
<StyledChatCloseButton
|
|
855
|
+
onClick={resetChat}
|
|
856
|
+
aria-label="Reset chat history"
|
|
857
|
+
title="Reset chat history"
|
|
858
|
+
>
|
|
859
|
+
<RotateCcw size={18} />
|
|
860
|
+
</StyledChatCloseButton>
|
|
861
|
+
<StyledChatCloseButton
|
|
862
|
+
onClick={closeChat}
|
|
863
|
+
aria-label="Close chat"
|
|
864
|
+
title="Close chat"
|
|
865
|
+
>
|
|
866
|
+
<X />
|
|
867
|
+
</StyledChatCloseButton>
|
|
868
|
+
</StyledChatTitleIconWrapper>
|
|
808
869
|
</StyledChatTitle>
|
|
809
870
|
{answer &&
|
|
810
871
|
answer.map((a, i) => (
|
|
811
|
-
<
|
|
812
|
-
{a.answer
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
872
|
+
<React.Fragment key={i}>
|
|
873
|
+
<StyledAnswer $isAnswer={a.answer ?? false}>
|
|
874
|
+
{a.answer && a.mdx ? (
|
|
875
|
+
<MDXRemote {...a.mdx} components={mdxComponents} />
|
|
876
|
+
) : (
|
|
877
|
+
a.text
|
|
878
|
+
)}
|
|
879
|
+
</StyledAnswer>
|
|
880
|
+
{a.answer && a.sources && a.sources.length > 0 && (
|
|
881
|
+
<StyledSources>
|
|
882
|
+
{a.sources.map((src) => {
|
|
883
|
+
const slug = src.uri
|
|
884
|
+
.replace("docs://", "")
|
|
885
|
+
.replace(/^\\/+/, "");
|
|
886
|
+
const href = slug ? \`/\${slug}/\` : "/";
|
|
887
|
+
const label = slug
|
|
888
|
+
? slug
|
|
889
|
+
.split("/")
|
|
890
|
+
.pop()!
|
|
891
|
+
.replace(/-/g, " ")
|
|
892
|
+
.replace(/\\b\\w/g, (c: string) => c.toUpperCase())
|
|
893
|
+
: "Home";
|
|
894
|
+
return (
|
|
895
|
+
<StyledSourceLink key={src.id} href={href}>
|
|
896
|
+
{label}
|
|
897
|
+
</StyledSourceLink>
|
|
898
|
+
);
|
|
899
|
+
})}
|
|
900
|
+
</StyledSources>
|
|
816
901
|
)}
|
|
817
|
-
</
|
|
902
|
+
</React.Fragment>
|
|
818
903
|
))}
|
|
819
904
|
{loading && (
|
|
820
905
|
<StyledLoading>
|
|
@@ -865,7 +950,8 @@ const ChatContext = createContext<{
|
|
|
865
950
|
answer: Answer[];
|
|
866
951
|
setAnswer: (answers: Answer[]) => void;
|
|
867
952
|
ask: (e: React.FormEvent) => void;
|
|
868
|
-
|
|
953
|
+
closeChat: () => void;
|
|
954
|
+
resetChat: () => void;
|
|
869
955
|
chatInputRef: React.RefObject<HTMLInputElement | null>;
|
|
870
956
|
}>({
|
|
871
957
|
isOpen: false,
|
|
@@ -878,7 +964,8 @@ const ChatContext = createContext<{
|
|
|
878
964
|
answer: [],
|
|
879
965
|
setAnswer: () => {},
|
|
880
966
|
ask: () => {},
|
|
881
|
-
|
|
967
|
+
closeChat: () => {},
|
|
968
|
+
resetChat: () => {},
|
|
882
969
|
chatInputRef: { current: null },
|
|
883
970
|
});
|
|
884
971
|
|
|
@@ -938,6 +1025,7 @@ const ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {
|
|
|
938
1025
|
const reader = res.body?.getReader();
|
|
939
1026
|
const decoder = new TextDecoder();
|
|
940
1027
|
const contentParts: string[] = [];
|
|
1028
|
+
let sources: Source[] = [];
|
|
941
1029
|
if (!reader) {
|
|
942
1030
|
throw new Error("Failed to get response reader");
|
|
943
1031
|
}
|
|
@@ -959,7 +1047,15 @@ const ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {
|
|
|
959
1047
|
try {
|
|
960
1048
|
const data = JSON.parse(line.slice(6));
|
|
961
1049
|
|
|
962
|
-
if (data.type === "
|
|
1050
|
+
if (data.type === "metadata") {
|
|
1051
|
+
const allSources: Source[] = data.data?.sources ?? [];
|
|
1052
|
+
const seen = new Set<string>();
|
|
1053
|
+
sources = allSources.filter((s: Source) => {
|
|
1054
|
+
if (s.score < 0.4 || seen.has(s.uri)) return false;
|
|
1055
|
+
seen.add(s.uri);
|
|
1056
|
+
return true;
|
|
1057
|
+
});
|
|
1058
|
+
} else if (data.type === "content") {
|
|
963
1059
|
contentParts.push(data.data);
|
|
964
1060
|
const streamedContent = contentParts.join("");
|
|
965
1061
|
|
|
@@ -968,6 +1064,7 @@ const ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {
|
|
|
968
1064
|
newAnswers[streamingAnswerIndex] = {
|
|
969
1065
|
text: streamedContent,
|
|
970
1066
|
answer: true,
|
|
1067
|
+
sources,
|
|
971
1068
|
};
|
|
972
1069
|
return newAnswers;
|
|
973
1070
|
});
|
|
@@ -996,6 +1093,7 @@ const ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {
|
|
|
996
1093
|
text: streamedContent,
|
|
997
1094
|
answer: true,
|
|
998
1095
|
mdx: mdxSource || undefined,
|
|
1096
|
+
sources,
|
|
999
1097
|
};
|
|
1000
1098
|
return newAnswers;
|
|
1001
1099
|
});
|
|
@@ -1020,12 +1118,17 @@ const ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {
|
|
|
1020
1118
|
}
|
|
1021
1119
|
}
|
|
1022
1120
|
|
|
1023
|
-
function
|
|
1024
|
-
abortRef.current?.abort();
|
|
1025
|
-
setAnswer([]);
|
|
1121
|
+
function closeChat() {
|
|
1026
1122
|
setIsOpen(false);
|
|
1027
1123
|
}
|
|
1028
1124
|
|
|
1125
|
+
function resetChat() {
|
|
1126
|
+
abortRef.current?.abort();
|
|
1127
|
+
setLoading(false);
|
|
1128
|
+
setError(null);
|
|
1129
|
+
setAnswer([{ text: "Hey there, how can I assist you?", answer: true }]);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1029
1132
|
return (
|
|
1030
1133
|
<ChatContext.Provider
|
|
1031
1134
|
value={{
|
|
@@ -1039,7 +1142,8 @@ const ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {
|
|
|
1039
1142
|
answer,
|
|
1040
1143
|
setAnswer,
|
|
1041
1144
|
ask,
|
|
1042
|
-
|
|
1145
|
+
closeChat,
|
|
1146
|
+
resetChat,
|
|
1043
1147
|
chatInputRef,
|
|
1044
1148
|
}}
|
|
1045
1149
|
>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const docsSideBarTemplate = "\"use client\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { Space } from \"cherry-styled-components\";\nimport {\n StyledIndexSidebar,\n StyledIndexSidebarLink,\n StyledIndexSidebarLabel,\n StyledIndexSidebarLi,\n} from \"@/components/layout/DocsComponents\";\n\ninterface Heading {\n id: string;\n text: string;\n level: number;\n}\n\nconst FALLBACK_OFFSET = 60;\n\nfunction getOffset() {\n const header = document.getElementById(\"header\");\n return (header ? header.offsetHeight : FALLBACK_OFFSET) + 20;\n}\n\nexport function DocsSideBar({ headings }: { headings: Heading[] }) {\n const [activeId, setActiveId] = useState<string>(\"\");\n const activeRef = useRef<HTMLLIElement>(null);\n\n const handleScroll = useCallback(() => {\n if (headings.length === 0) return;\n\n const headingElements = headings\n .map((heading) => document.getElementById(heading.id))\n .filter((el): el is HTMLElement => el !== null);\n\n if (headingElements.length === 0) return;\n\n const windowHeight = window.innerHeight;\n\n const visibleHeadings = headingElements.filter((element) => {\n const rect = element.getBoundingClientRect();\n const elementTop = rect.top;\n const elementBottom = rect.bottom;\n return elementTop < windowHeight && elementBottom > -50;\n });\n\n if (visibleHeadings.length > 0) {\n let closestHeading = visibleHeadings[0];\n let closestDistance = Math.abs(\n closestHeading.getBoundingClientRect().top - getOffset(),\n );\n for (const heading of visibleHeadings) {\n const distance = Math.abs(heading.getBoundingClientRect().top - getOffset());\n if (\n distance < closestDistance &&\n heading.getBoundingClientRect().top <= windowHeight * 0.3\n ) {\n closestDistance = distance;\n closestHeading = heading;\n }\n }\n setActiveId(closestHeading.id);\n return;\n }\n\n let currentActiveId = headings[0].id;\n for (const element of headingElements) {\n const rect = element.getBoundingClientRect();\n if (rect.top <= getOffset()) {\n currentActiveId = element.id;\n } else {\n break;\n }\n }\n setActiveId(currentActiveId);\n }, [headings]);\n\n useEffect(() => {\n if (headings.length === 0) return;\n // Set active heading from URL hash immediately on mount\n if (window.location.hash) {\n setActiveId(window.location.hash.slice(1));\n }\n // Run initial scroll check on next frame to avoid synchronous setState in effect\n const rafId = requestAnimationFrame(handleScroll);\n // Re-check after browser finishes scrolling to hash target on new tab/page load\n const delayedId = setTimeout(handleScroll, 300);\n let timeoutId: NodeJS.Timeout;\n const throttledHandleScroll = () => {\n clearTimeout(timeoutId);\n timeoutId = setTimeout(handleScroll, 50);\n };\n window.addEventListener(\"scroll\", throttledHandleScroll);\n window.addEventListener(\"resize\", handleScroll);\n return () => {\n window.removeEventListener(\"scroll\", throttledHandleScroll);\n window.removeEventListener(\"resize\", handleScroll);\n cancelAnimationFrame(rafId);\n clearTimeout(delayedId);\n clearTimeout(timeoutId);\n };\n }, [handleScroll, headings]);\n\n useEffect(() => {\n const el = activeRef.current;\n const container = el?.closest(\"[data-sidebar]\") as HTMLElement | null;\n if (!el || !container) return;\n const elRect = el.getBoundingClientRect();\n const cRect = container.getBoundingClientRect();\n const pad = 140;\n if (elRect.bottom + pad > cRect.bottom) {\n container.scrollBy({
|
|
1
|
+
export declare const docsSideBarTemplate = "\"use client\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { Space } from \"cherry-styled-components\";\nimport {\n StyledIndexSidebar,\n StyledIndexSidebarLink,\n StyledIndexSidebarLabel,\n StyledIndexSidebarLi,\n} from \"@/components/layout/DocsComponents\";\n\ninterface Heading {\n id: string;\n text: string;\n level: number;\n}\n\nconst FALLBACK_OFFSET = 60;\n\nfunction getOffset() {\n const header = document.getElementById(\"header\");\n return (header ? header.offsetHeight : FALLBACK_OFFSET) + 20;\n}\n\nexport function DocsSideBar({ headings }: { headings: Heading[] }) {\n const [activeId, setActiveId] = useState<string>(\"\");\n const activeRef = useRef<HTMLLIElement>(null);\n\n const handleScroll = useCallback(() => {\n if (headings.length === 0) return;\n\n const headingElements = headings\n .map((heading) => document.getElementById(heading.id))\n .filter((el): el is HTMLElement => el !== null);\n\n if (headingElements.length === 0) return;\n\n const windowHeight = window.innerHeight;\n\n const visibleHeadings = headingElements.filter((element) => {\n const rect = element.getBoundingClientRect();\n const elementTop = rect.top;\n const elementBottom = rect.bottom;\n return elementTop < windowHeight && elementBottom > -50;\n });\n\n if (visibleHeadings.length > 0) {\n let closestHeading = visibleHeadings[0];\n let closestDistance = Math.abs(\n closestHeading.getBoundingClientRect().top - getOffset(),\n );\n for (const heading of visibleHeadings) {\n const distance = Math.abs(\n heading.getBoundingClientRect().top - getOffset(),\n );\n if (\n distance < closestDistance &&\n heading.getBoundingClientRect().top <= windowHeight * 0.3\n ) {\n closestDistance = distance;\n closestHeading = heading;\n }\n }\n setActiveId(closestHeading.id);\n return;\n }\n\n let currentActiveId = headings[0].id;\n for (const element of headingElements) {\n const rect = element.getBoundingClientRect();\n if (rect.top <= getOffset()) {\n currentActiveId = element.id;\n } else {\n break;\n }\n }\n setActiveId(currentActiveId);\n }, [headings]);\n\n useEffect(() => {\n if (headings.length === 0) return;\n // Set active heading from URL hash immediately on mount\n if (window.location.hash) {\n setActiveId(window.location.hash.slice(1));\n }\n // Run initial scroll check on next frame to avoid synchronous setState in effect\n const rafId = requestAnimationFrame(handleScroll);\n // Re-check after browser finishes scrolling to hash target on new tab/page load\n const delayedId = setTimeout(handleScroll, 300);\n let timeoutId: NodeJS.Timeout;\n const throttledHandleScroll = () => {\n clearTimeout(timeoutId);\n timeoutId = setTimeout(handleScroll, 50);\n };\n window.addEventListener(\"scroll\", throttledHandleScroll);\n window.addEventListener(\"resize\", handleScroll);\n return () => {\n window.removeEventListener(\"scroll\", throttledHandleScroll);\n window.removeEventListener(\"resize\", handleScroll);\n cancelAnimationFrame(rafId);\n clearTimeout(delayedId);\n clearTimeout(timeoutId);\n };\n }, [handleScroll, headings]);\n\n useEffect(() => {\n const el = activeRef.current;\n const container = el?.closest(\"[data-sidebar]\") as HTMLElement | null;\n if (!el || !container) return;\n const elRect = el.getBoundingClientRect();\n const cRect = container.getBoundingClientRect();\n const pad = 140;\n if (elRect.bottom + pad > cRect.bottom) {\n container.scrollBy({\n top: elRect.bottom - cRect.bottom + pad,\n behavior: \"smooth\",\n });\n } else if (elRect.top - pad < cRect.top) {\n container.scrollBy({\n top: elRect.top - cRect.top - pad,\n behavior: \"smooth\",\n });\n }\n }, [activeId]);\n\n const handleHeadingClick = (headingId: string) => {\n const element = document.getElementById(headingId);\n if (element) {\n const elementPosition =\n element.getBoundingClientRect().top + window.scrollY;\n window.scrollTo({\n top: elementPosition - getOffset(),\n behavior: \"smooth\",\n });\n }\n };\n\n return (\n <StyledIndexSidebar data-sidebar>\n {headings?.length > 0 && (\n <>\n <StyledIndexSidebarLabel>On this page</StyledIndexSidebarLabel>\n <Space $size={15} />\n </>\n )}\n {headings.map((heading, index) => (\n <StyledIndexSidebarLi\n key={index}\n ref={activeId === heading.id ? activeRef : null}\n $isActive={activeId === heading.id}\n style={{ paddingLeft: `${(heading.level - 1) * 16}px` }}\n >\n <StyledIndexSidebarLink\n href={`#${heading.id}`}\n onClick={(e) => {\n e.preventDefault();\n handleHeadingClick(heading.id);\n }}\n $isActive={activeId === heading.id}\n >\n {heading.text}\n </StyledIndexSidebarLink>\n </StyledIndexSidebarLi>\n ))}\n </StyledIndexSidebar>\n );\n}\n";
|
|
@@ -49,7 +49,9 @@ export function DocsSideBar({ headings }: { headings: Heading[] }) {
|
|
|
49
49
|
closestHeading.getBoundingClientRect().top - getOffset(),
|
|
50
50
|
);
|
|
51
51
|
for (const heading of visibleHeadings) {
|
|
52
|
-
const distance = Math.abs(
|
|
52
|
+
const distance = Math.abs(
|
|
53
|
+
heading.getBoundingClientRect().top - getOffset(),
|
|
54
|
+
);
|
|
53
55
|
if (
|
|
54
56
|
distance < closestDistance &&
|
|
55
57
|
heading.getBoundingClientRect().top <= windowHeight * 0.3
|
|
@@ -108,9 +110,15 @@ export function DocsSideBar({ headings }: { headings: Heading[] }) {
|
|
|
108
110
|
const cRect = container.getBoundingClientRect();
|
|
109
111
|
const pad = 140;
|
|
110
112
|
if (elRect.bottom + pad > cRect.bottom) {
|
|
111
|
-
container.scrollBy({
|
|
113
|
+
container.scrollBy({
|
|
114
|
+
top: elRect.bottom - cRect.bottom + pad,
|
|
115
|
+
behavior: "smooth",
|
|
116
|
+
});
|
|
112
117
|
} else if (elRect.top - pad < cRect.top) {
|
|
113
|
-
container.scrollBy({
|
|
118
|
+
container.scrollBy({
|
|
119
|
+
top: elRect.top - cRect.top - pad,
|
|
120
|
+
behavior: "smooth",
|
|
121
|
+
});
|
|
114
122
|
}
|
|
115
123
|
}, [activeId]);
|
|
116
124
|
|
|
@@ -119,7 +127,10 @@ export function DocsSideBar({ headings }: { headings: Heading[] }) {
|
|
|
119
127
|
if (element) {
|
|
120
128
|
const elementPosition =
|
|
121
129
|
element.getBoundingClientRect().top + window.scrollY;
|
|
122
|
-
window.scrollTo({
|
|
130
|
+
window.scrollTo({
|
|
131
|
+
top: elementPosition - getOffset(),
|
|
132
|
+
behavior: "smooth",
|
|
133
|
+
});
|
|
123
134
|
}
|
|
124
135
|
};
|
|
125
136
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const lockBodyScrollTemplate = "import { useEffect } from \"react\";\n\nexport function useLockBodyScroll(isActive: boolean) {\n useEffect(() => {\n const mql = window.matchMedia(\"(max-width: 992px)\");\n function update() {\n document.body.style.overflow = isActive && mql.matches ? \"hidden\" : \"\";\n }\n update();\n mql.addEventListener(\"change\", update);\n return () => {\n mql.removeEventListener(\"change\", update);\n document.body.style.overflow = \"\";\n };\n }, [isActive]);\n}\n";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const lockBodyScrollTemplate = `import { useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
export function useLockBodyScroll(isActive: boolean) {
|
|
4
|
+
useEffect(() => {
|
|
5
|
+
const mql = window.matchMedia("(max-width: 992px)");
|
|
6
|
+
function update() {
|
|
7
|
+
document.body.style.overflow = isActive && mql.matches ? "hidden" : "";
|
|
8
|
+
}
|
|
9
|
+
update();
|
|
10
|
+
mql.addEventListener("change", update);
|
|
11
|
+
return () => {
|
|
12
|
+
mql.removeEventListener("change", update);
|
|
13
|
+
document.body.style.overflow = "";
|
|
14
|
+
};
|
|
15
|
+
}, [isActive]);
|
|
16
|
+
}
|
|
17
|
+
`;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const sideBarTemplate = "\"use client\";\nimport { useContext, useState, Suspense } from \"react\";\nimport { usePathname } from \"next/navigation\";\nimport { Flex, Space } from \"cherry-styled-components\";\nimport {\n DocsSidebar,\n SectionBarContext,\n StyledSidebar,\n StyledSidebarList,\n StyledSidebarListItem,\n StyledStrong,\n StyledSidebarListItemLink,\n StyleMobileBar,\n StyledMobileBurger,\n} from \"@/components/layout/DocsComponents\";\nimport {\n ToggleTheme,\n ToggleThemeLoading,\n} from \"@/components/layout/ThemeToggle\";\n\ntype NavItem = {\n label: string;\n links: NavItemLink[];\n};\n\ntype NavItemLink = {\n slug: string;\n title: string;\n};\n\ninterface SideBarProps {\n result: NavItem[];\n}\n\nfunction SideBar({ result }: SideBarProps) {\n const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);\n const hasSectionBar = useContext(SectionBarContext);\n const pathname = usePathname();\n\n return (\n <DocsSidebar>\n <StyleMobileBar\n onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}\n $isActive={isMobileMenuOpen}\n >\n <StyledMobileBurger $isActive={isMobileMenuOpen} />\n </StyleMobileBar>\n\n <StyledSidebar\n $isActive={isMobileMenuOpen}\n $hasSectionBar={hasSectionBar}\n >\n {result &&\n result.map((item: NavItem, index: number) => {\n return (\n <StyledSidebarList key={index}>\n <StyledSidebarListItem>\n <StyledStrong>{item.label}</StyledStrong>{\" \"}\n </StyledSidebarListItem>\n <li>\n <Space $size={20} />\n </li>\n {item.links &&\n item.links.map((link: NavItemLink, indexChild: number) => {\n return (\n <StyledSidebarListItem key={indexChild}>\n <StyledSidebarListItemLink\n href={`/${link.slug}`}\n $isActive={pathname === `/${link.slug}`}\n onClick={() => setIsMobileMenuOpen(false)}\n >\n {link.title}\n </StyledSidebarListItemLink>\n </StyledSidebarListItem>\n );\n })}\n <Space $size={20} />\n </StyledSidebarList>\n );\n })}\n <Space $xs={40} $lg={20} />\n <Flex $xsJustifyContent=\"flex-start\" $lgJustifyContent=\"flex-end\">\n <Suspense fallback={<ToggleThemeLoading />}>\n <ToggleTheme />\n </Suspense>\n </Flex>\n </StyledSidebar>\n </DocsSidebar>\n );\n}\n\nexport { SideBar };\n";
|
|
1
|
+
export declare const sideBarTemplate = "\"use client\";\nimport { useContext, useState, Suspense } from \"react\";\nimport { usePathname } from \"next/navigation\";\nimport { Flex, Space } from \"cherry-styled-components\";\nimport {\n DocsSidebar,\n SectionBarContext,\n StyledSidebar,\n StyledSidebarList,\n StyledSidebarListItem,\n StyledStrong,\n StyledSidebarListItemLink,\n StyleMobileBar,\n StyledMobileBurger,\n} from \"@/components/layout/DocsComponents\";\nimport {\n ToggleTheme,\n ToggleThemeLoading,\n} from \"@/components/layout/ThemeToggle\";\nimport { useLockBodyScroll } from \"@/components/LockBodyScroll\";\n\ntype NavItem = {\n label: string;\n links: NavItemLink[];\n};\n\ntype NavItemLink = {\n slug: string;\n title: string;\n};\n\ninterface SideBarProps {\n result: NavItem[];\n}\n\nfunction SideBar({ result }: SideBarProps) {\n const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);\n const hasSectionBar = useContext(SectionBarContext);\n const pathname = usePathname();\n\n useLockBodyScroll(isMobileMenuOpen);\n\n return (\n <DocsSidebar>\n <StyleMobileBar\n onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}\n $isActive={isMobileMenuOpen}\n >\n <StyledMobileBurger $isActive={isMobileMenuOpen} />\n </StyleMobileBar>\n\n <StyledSidebar\n $isActive={isMobileMenuOpen}\n $hasSectionBar={hasSectionBar}\n >\n {result &&\n result.map((item: NavItem, index: number) => {\n return (\n <StyledSidebarList key={index}>\n <StyledSidebarListItem>\n <StyledStrong>{item.label}</StyledStrong>{\" \"}\n </StyledSidebarListItem>\n <li>\n <Space $size={20} />\n </li>\n {item.links &&\n item.links.map((link: NavItemLink, indexChild: number) => {\n return (\n <StyledSidebarListItem key={indexChild}>\n <StyledSidebarListItemLink\n href={`/${link.slug}`}\n $isActive={pathname === `/${link.slug}`}\n onClick={() => setIsMobileMenuOpen(false)}\n >\n {link.title}\n </StyledSidebarListItemLink>\n </StyledSidebarListItem>\n );\n })}\n <Space $size={20} />\n </StyledSidebarList>\n );\n })}\n <Space $xs={40} $lg={20} />\n <Flex $xsJustifyContent=\"flex-start\" $lgJustifyContent=\"flex-end\">\n <Suspense fallback={<ToggleThemeLoading />}>\n <ToggleTheme />\n </Suspense>\n </Flex>\n </StyledSidebar>\n </DocsSidebar>\n );\n}\n\nexport { SideBar };\n";
|