diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index a7b4141174d..59484b5f8c1 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -89,7 +89,7 @@ "react-dropzone": "^14.3.8", "react-error-boundary": "^5.0.0", "react-hook-form": "7.55.0", - "react-markdown": "^9.0.1", + "react-markdown": "10.1.0", "react-table": "^7.8.0", "recharts": "2.15.3", "remark-gfm": "4.0.1", diff --git a/apps/portal/package.json b/apps/portal/package.json index e51165fa21e..7d302012687 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -20,14 +20,15 @@ }, "dependencies": { "@dirtycajunrice/klee": "^1.0.6", - "@mdx-js/loader": "^2.3.0", - "@mdx-js/react": "^2.3.0", + "@mdx-js/loader": "3.1.0", + "@mdx-js/react": "3.1.0", "@next/mdx": "15.3.2", "@radix-ui/react-dialog": "1.1.10", "@radix-ui/react-dropdown-menu": "^2.1.11", "@radix-ui/react-select": "^2.2.2", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.8", + "@radix-ui/react-tooltip": "1.2.3", "@tanstack/react-query": "5.74.4", "@tryghost/content-api": "^1.11.22", "class-variance-authority": "^0.7.1", @@ -45,9 +46,11 @@ "posthog-js": "1.67.1", "prettier": "3.5.3", "react": "19.1.0", + "react-children-utilities": "^2.10.0", "react-dom": "19.1.0", "react-html-parser": "2.0.2", - "remark-gfm": "3.0.1", + "react-markdown": "10.1.0", + "remark-gfm": "4.0.1", "server-only": "^0.0.1", "shiki": "1.27.0", "tailwind-merge": "^2.6.0", diff --git a/apps/portal/src/app/Header.tsx b/apps/portal/src/app/Header.tsx index e627b000b29..35f4b5f11ac 100644 --- a/apps/portal/src/app/Header.tsx +++ b/apps/portal/src/app/Header.tsx @@ -9,9 +9,14 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import clsx from "clsx"; -import { ChevronDownIcon, MenuIcon, TableOfContentsIcon } from "lucide-react"; +import { + BotIcon, + ChevronDownIcon, + MenuIcon, + TableOfContentsIcon, +} from "lucide-react"; import Link from "next/link"; -import { usePathname } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { useState } from "react"; import { GithubIcon } from "../components/Document/GithubButtonLink"; import { CustomAccordion } from "../components/others/CustomAccordion"; @@ -29,7 +34,6 @@ const links = [ { name: "Connect", href: "/connect", - icon: TableOfContentsIcon, }, { name: "Bridge", @@ -191,6 +195,7 @@ const supportLinks = [ export function Header() { const [showBurgerMenu, setShowBurgerMenu] = useState(false); + const router = useRouter(); return (
@@ -211,17 +216,27 @@ export function Header() {
-
- +
+
- +
-
+
- +
+ +
+
+ +
+ + +
+
+ setShowBurgerMenu(false)} + category="Tools" + /> +
+
- - - -
@@ -322,7 +343,6 @@ export function Header() { key={link.name} name={link.name} href={link.href} - icon={link.icon} onClick={() => setShowBurgerMenu(false)} /> ))} diff --git a/apps/portal/src/app/chat/page.tsx b/apps/portal/src/app/chat/page.tsx new file mode 100644 index 00000000000..1570158f924 --- /dev/null +++ b/apps/portal/src/app/chat/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { Chat } from "@/components/AI/chat"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const queryClient = new QueryClient(); + +export default function ChatPage() { + return ( + +
+ +
+
+ ); +} diff --git a/apps/portal/src/app/globals.css b/apps/portal/src/app/globals.css index fd60cbe43f9..d7d1770c992 100644 --- a/apps/portal/src/app/globals.css +++ b/apps/portal/src/app/globals.css @@ -2,23 +2,7 @@ @tailwind components; @tailwind utilities; -html { - /* scroll-behavior: smooth; */ - font-feature-settings: "cv02", "cv03", "cv04", "cv11"; - font-feature-settings: "rlig" 1, "calt" 0; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - font-variation-settings: normal; - -webkit-text-size-adjust: 100%; - -webkit-tap-highlight-color: transparent; -} - @layer base { - :root { - --radius: 0.5rem; - --sticky-top-height: 70px; - } - :root { /* bg - neutral */ --background: 0 0% 98%; @@ -27,7 +11,7 @@ html { --secondary: 0 0% 90%; --muted: 0 0% 93%; --accent: 0 0% 93%; - --inverted: 0 0 0%; + --inverted: 0 0% 4%; /* bg - colorful */ --primary: 221 83% 54%; @@ -46,13 +30,26 @@ html { --link-foreground: 221.21deg 83.19% 53.33%; --success-text: 142.09 70.56% 35.29%; --warning-text: 38 92% 40%; - --destructive-text: 357.15deg 100% 68.72%; + --destructive-text: 360 72% 60%; /* Borders */ --border: 0 0% 85%; --active-border: 0 0% 70%; --input: 0 0% 85%; --ring: 0 0% 80%; + + /* Others */ + --radius: 0.5rem; + + /* Sidebar */ + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 0 0% 4%; + --sidebar-primary: 221 83% 54%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 0 0% 93%; + --sidebar-accent-foreground: 0 0% 9%; + --sidebar-border: 0 0% 85%; + --sidebar-ring: 0 0% 80%; } .dark { @@ -89,31 +86,67 @@ html { --active-border: 0 0% 22%; --ring: 0 0% 30%; --input: 0 0% 15%; + + /* sidebar */ + --sidebar-background: 0 0% 0%; + --sidebar-foreground: 0 0% 98%; + --sidebar-primary: 221 83% 54%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 0 0% 11%; + --sidebar-accent-foreground: 0 0% 98%; + --sidebar-border: 0 0% 15%; + --sidebar-ring: 0 0% 30%; } } -.dark .light-only { - display: none; +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } } -html:not(.dark) .dark-only { - display: none; +.dark .shiki, +.dark .shiki span { + color: var(--shiki-dark) !important; + /* Optional, if you also want font styles */ + font-style: var(--shiki-dark-font-style) !important; + font-weight: var(--shiki-dark-font-weight) !important; + text-decoration: var(--shiki-dark-text-decoration) !important; } -code span { - color: var(--code-light-color); +.shiki, +.shiki span { + background-color: transparent !important; } -.dark code span { - color: var(--code-dark-color); +/* Fix colors on auto-filled inputs */ +input:-webkit-autofill, +input:-webkit-autofill:hover, +input:-webkit-autofill:focus, +input:-webkit-autofill:active { + /* Revert text color */ + -webkit-text-fill-color: hsl(var(--foreground)) !important; + color: hsl(var(--foreground)) !important; + caret-color: hsl(var(--foreground)) !important; + + /* Revert background color */ + transition: background-color 5000s ease-in-out 0s; } -@layer base { - * { - @apply border-border; +@layer utilities { + /* Hide scrollbar for Chrome, Safari and Opera */ + .no-scrollbar::-webkit-scrollbar, + .no-scrollbar *::-webkit-scrollbar { + display: none; } - body { - @apply bg-background text-foreground; + /* Hide scrollbar for IE, Edge and Firefox */ + .no-scrollbar, + .no-scrollbar * { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ } } @@ -121,41 +154,49 @@ code span { width: 0.5rem; height: 0.5rem; } - @media (max-width: 640px) { .styled-scrollbar::-webkit-scrollbar { width: 0; height: 0; } } - .styled-scrollbar::-webkit-scrollbar-thumb { border-radius: 0.5rem; transition: color 200ms ease; background: var(--border); } - .styled-scrollbar::-webkit-scrollbar-thumb:hover { background: hsl(var(--foreground)); } - .styled-scrollbar::-webkit-scrollbar-track { background-color: transparent; } - button { -webkit-tap-highlight-color: transparent; } - ::selection { background: hsl(var(--foreground)); color: hsl(var(--background)); } - .hide-scrollbar { scrollbar-width: none; /* Firefox */ } - .hide-scrollbar::-webkit-scrollbar { display: none; /* Safari and Chrome */ } + +.dark .light-only { + display: none; +} + +html:not(.dark) .dark-only { + display: none; +} + +code span { + color: var(--code-light-color); +} + +.dark code span { + color: var(--code-dark-color); +} diff --git a/apps/portal/src/app/references/components/TDoc/PageLayout.tsx b/apps/portal/src/app/references/components/TDoc/PageLayout.tsx index 210dabea524..d7106f678b1 100644 --- a/apps/portal/src/app/references/components/TDoc/PageLayout.tsx +++ b/apps/portal/src/app/references/components/TDoc/PageLayout.tsx @@ -117,15 +117,12 @@ export function getTDocPage(options: { const paths = await getDoc(version) .then((doc) => fetchAllSlugs(doc)) .then((slugs) => { - return [ - ...slugs.map((slug) => { - return { - slug: slug.split("/") as string[], - version: version, - }; - }), - { version, slug: [] }, - ]; + return Array.from(new Set(slugs)).map((slug) => { + return { + slug: slug.split("/") as string[], + version: version, + }; + }); }); return paths; }), diff --git a/apps/portal/src/app/references/components/TDoc/Summary.tsx b/apps/portal/src/app/references/components/TDoc/Summary.tsx index d037b195179..7fc3194cb2b 100644 --- a/apps/portal/src/app/references/components/TDoc/Summary.tsx +++ b/apps/portal/src/app/references/components/TDoc/Summary.tsx @@ -85,7 +85,7 @@ export function TypedocSummary(props: { diff --git a/apps/portal/src/app/references/components/TDoc/utils/getSidebarLinkgroups.ts b/apps/portal/src/app/references/components/TDoc/utils/getSidebarLinkgroups.ts index 3a8efb43d57..f1b86ccfe42 100644 --- a/apps/portal/src/app/references/components/TDoc/utils/getSidebarLinkgroups.ts +++ b/apps/portal/src/app/references/components/TDoc/utils/getSidebarLinkgroups.ts @@ -411,7 +411,7 @@ export function getExtensionName( ): string | undefined { try { const extensionNameString = - extensionBlockTag?.summary?.[0]?.children?.[0]?.value || "Common"; + extensionBlockTag?.summary?.[0]?.data?.hName || "Common"; if (typeof extensionNameString === "string" && extensionNameString) { return extensionNameString; diff --git a/apps/portal/src/app/references/typescript/[version]/[[...slug]]/page.tsx b/apps/portal/src/app/references/typescript/[version]/[[...slug]]/page.tsx index 75021bf39d0..be54c0f7cb7 100644 --- a/apps/portal/src/app/references/typescript/[version]/[[...slug]]/page.tsx +++ b/apps/portal/src/app/references/typescript/[version]/[[...slug]]/page.tsx @@ -12,5 +12,6 @@ const config = getTDocPage({ }); export default config.default; -export const generateStaticParams = config.generateStaticParams; +// TODO: fix duplicate slugs +// export const generateStaticParams = config.generateStaticParams; export const generateMetadata = config.generateMetadata; diff --git a/apps/portal/src/components/AI/api.ts b/apps/portal/src/components/AI/api.ts new file mode 100644 index 00000000000..247063ffdc0 --- /dev/null +++ b/apps/portal/src/components/AI/api.ts @@ -0,0 +1,43 @@ +"use server"; + +const serviceKey = process.env.SIWA_SERVICE_KEY as string; +const apiUrl = process.env.SIWA_URL; + +export const getChatResponse = async ( + userMessage: string, + sessionId: string | undefined, +) => { + try { + const payload = { + message: userMessage, + conversationId: sessionId, + }; + const response = await fetch(`${apiUrl}/v1/chat`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-service-api-key": serviceKey, + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error( + `Failed to get chat response: ${response.status} - ${error}`, + ); + } + + const data = (await response.json()) as { + data: string; + conversationId: string; + }; + return data; + } catch (error) { + console.error( + "Chat API error:", + error instanceof Error ? error.message : "Unknown error", + ); + return null; + } +}; diff --git a/apps/portal/src/components/AI/chat.tsx b/apps/portal/src/components/AI/chat.tsx new file mode 100644 index 00000000000..a72d8521b7a --- /dev/null +++ b/apps/portal/src/components/AI/chat.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { MarkdownRenderer } from "@/components/markdown/MarkdownRenderer"; +import { LoadingDots } from "@/components/ui/LoadingDots"; +import { Button } from "@/components/ui/button"; +import { AutoResizeTextarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import { ArrowUpIcon, BotIcon } from "lucide-react"; +import { usePostHog } from "posthog-js/react"; +import { + type ChangeEvent, + type KeyboardEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { getChatResponse } from "./api"; + +interface Message { + id: string; + role: "user" | "assistant"; + content: string; + isLoading?: boolean; +} + +const predefinedPrompts = [ + "How do I connect an in-app wallet with google in react?", + "How do I deploy a DropERC1155 contract in typescript?", + "How do I send a transaction in Unity?", +]; + +// Empty State Component +function ChatEmptyState({ + onPromptClick, +}: { onPromptClick: (prompt: string) => void }) { + return ( +
+ + +

+ How can I help you
+ build onchain today? +

+ +
+ {predefinedPrompts.map((prompt) => ( + + ))} +
+
+ ); +} + +export function Chat() { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const lastMessageRef = useRef(null); + const posthog = usePostHog(); + const [conversationId, setConversationId] = useState( + undefined, + ); + + const handleSendMessage = useCallback( + async (content: string) => { + if (!content.trim()) return; + + posthog?.capture("siwa.send-message", { + sessionId: conversationId, + message: content, + }); + + const userMessage: Message = { + id: Date.now().toString(), + role: "user", + content, + }; + + const loadingMessageId = (Date.now() + 1).toString(); + const assistantLoadingMessage: Message = { + id: loadingMessageId, + role: "assistant", + content: "", + isLoading: true, + }; + + setMessages((prevMessages) => [ + ...prevMessages, + userMessage, + assistantLoadingMessage, + ]); + + // Input clearing is handled by the callers (handleKeyDown, button onClick) + + try { + const response = await getChatResponse(content, conversationId); + + if (response?.conversationId) { + setConversationId(response.conversationId); + } + + setMessages((prevMessages) => + prevMessages.map((msg) => + msg.id === loadingMessageId + ? { ...msg, content: response?.data ?? "", isLoading: false } + : msg, + ), + ); + } catch (error) { + console.error("Failed to get chat response:", error); + setMessages((prevMessages) => + prevMessages.map((msg) => + msg.id === loadingMessageId + ? { + ...msg, + content: "Error: Could not load response.", + isLoading: false, + } + : msg, + ), + ); + } + }, + [conversationId, posthog], + ); + + useEffect(() => { + if (lastMessageRef.current && messages.length > 0) { + lastMessageRef.current.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + } + }, [messages.length]); + + const handleInputChange = (e: ChangeEvent) => { + setInput(e.target.value); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + const currentInput = input; + setInput(""); + handleSendMessage(currentInput); + } + }; + + return ( +
+
+ {messages.length === 0 ? ( + + ) : ( +
+ {messages.map((message, index) => ( +
+
+ {message.role === "assistant" && message.isLoading ? ( + + ) : ( + + )} +
+
+ ))} +
+ )} +
+
+
+ + +
+
+
+ ); +} + +function StyledMarkdownRenderer(props: { + text: string; + isMessagePending: boolean; + type: "assistant" | "user"; +}) { + return ( + + ); +} diff --git a/apps/portal/src/components/Document/Code.tsx b/apps/portal/src/components/Document/Code.tsx index a96f53168e8..a6e8185c83f 100644 --- a/apps/portal/src/components/Document/Code.tsx +++ b/apps/portal/src/components/Document/Code.tsx @@ -28,14 +28,14 @@ const jsOrTsLangs = new Set([ export async function CodeBlock(props: { code: string; - lang: BuiltinLanguage | SpecialLanguage; + lang: BuiltinLanguage | SpecialLanguage | string | undefined | null; tokenLinks?: Map; className?: string; containerClassName?: string; scrollContainerClassName?: string; }) { let code = props.code; - let lang = props.lang; + let lang = props.lang || "javascript"; const tokenLinks = props.tokenLinks; if (lang === "shell" || lang === "sh") { @@ -90,12 +90,11 @@ export async function CodeBlock(props: { async function RenderCode(props: { code: string; - lang: BuiltinLanguage | SpecialLanguage; + lang: BuiltinLanguage | SpecialLanguage | string | undefined | null; tokenLinks?: Map; }) { const { tokens } = await codeToTokens(props.code, { - // theme: "github-dark", - lang: props.lang, + lang: (props.lang || "javascript") as BuiltinLanguage | SpecialLanguage, themes: { light: "github-light", dark: "github-dark-dimmed", diff --git a/apps/portal/src/components/code/CodeBlockContainer.tsx b/apps/portal/src/components/code/CodeBlockContainer.tsx new file mode 100644 index 00000000000..1e9aac377ff --- /dev/null +++ b/apps/portal/src/components/code/CodeBlockContainer.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useClipboard } from "@/hooks/useClipboard"; // Adjusted path for portal +import { cn } from "@/lib/utils"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import { ScrollShadow } from "../ui/ScrollShadow/ScrollShadow"; // Adjusted path for portal +import { Button } from "../ui/button"; // Adjusted path for portal + +export function CodeBlockContainer(props: { + codeToCopy: string; + children: React.ReactNode; + className?: string; + scrollableClassName?: string; + scrollableContainerClassName?: string; + copyButtonClassName?: string; + shadowColor?: string; + onCopy?: (code: string) => void; +}) { + const { hasCopied, onCopy: onClipboardCopy } = useClipboard(props.codeToCopy); // Renamed onCopy to avoid conflict + + return ( +
+ + {props.children} + + + +
+ ); +} diff --git a/apps/portal/src/components/code/RenderCode.tsx b/apps/portal/src/components/code/RenderCode.tsx new file mode 100644 index 00000000000..8d4f04b9701 --- /dev/null +++ b/apps/portal/src/components/code/RenderCode.tsx @@ -0,0 +1,39 @@ +import { cn } from "../../lib/utils"; +import { CopyButton } from "../ui/CopyButton"; +import { ScrollShadow } from "../ui/ScrollShadow/ScrollShadow"; + +export function RenderCode(props: { + code: string; + html: string; + className?: string; + scrollableClassName?: string; + scrollableContainerClassName?: string; +}) { + return ( +
+ +
+ + +
+ ); +} diff --git a/apps/portal/src/components/code/code.client.tsx b/apps/portal/src/components/code/code.client.tsx new file mode 100644 index 00000000000..48cb042719d --- /dev/null +++ b/apps/portal/src/components/code/code.client.tsx @@ -0,0 +1,57 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import type { BundledLanguage } from "shiki"; +import { LoadingDots } from "../ui/LoadingDots"; +import { RenderCode } from "./RenderCode"; +import { getCodeHtml } from "./getCodeHtml"; + +// Use CodeClient where the code changes based user input +// Using RSC in that scenario feels too slow and unnecessary keep hitting the server + +type CodeProps = { + code: string; + lang: BundledLanguage | string | undefined | null; + loader: React.ReactNode; + className?: string; + scrollableClassName?: string; + scrollableContainerClassName?: string; +}; + +export function CodeLoading() { + return ( +
+ +
+ ); +} + +export const CodeClient: React.FC = ({ + code, + lang, + loader, + className, + scrollableClassName, + scrollableContainerClassName, +}) => { + const codeQuery = useQuery({ + queryKey: ["html", code], + queryFn: () => getCodeHtml(code, lang), + placeholderData: keepPreviousData, + }); + + if (!codeQuery.data) { + return loader; + } + + return ( + + ); +}; + +/** @alias */ +export default CodeClient; diff --git a/apps/portal/src/components/code/getCodeHtml.tsx b/apps/portal/src/components/code/getCodeHtml.tsx new file mode 100644 index 00000000000..ed9449df319 --- /dev/null +++ b/apps/portal/src/components/code/getCodeHtml.tsx @@ -0,0 +1,47 @@ +import * as parserBabel from "prettier/plugins/babel"; +import { format } from "prettier/standalone"; +import { type BundledLanguage, codeToHtml } from "shiki"; + +function isPrettierSupportedLang( + lang: BundledLanguage | string | undefined | null, +) { + if (!lang) { + return false; + } + + return ( + lang === "js" || + lang === "jsx" || + lang === "ts" || + lang === "tsx" || + lang === "javascript" || + lang === "typescript" + ); +} + +export async function getCodeHtml( + code: string, + lang: BundledLanguage | string | undefined | null, +) { + const estreePlugin = await import("prettier/plugins/estree"); + + const formattedCode = isPrettierSupportedLang(lang) + ? await format(code, { + parser: "babel-ts", + plugins: [parserBabel, estreePlugin.default], + printWidth: 80, + }).catch(() => { + return code; + }) + : code; + + const html = await codeToHtml(formattedCode, { + lang: (lang || "javascript") as BundledLanguage, + themes: { + light: "github-light", + dark: "github-dark-default", + }, + }); + + return { html, formattedCode }; +} diff --git a/apps/portal/src/components/code/plaintext-code.tsx b/apps/portal/src/components/code/plaintext-code.tsx new file mode 100644 index 00000000000..0722d92831f --- /dev/null +++ b/apps/portal/src/components/code/plaintext-code.tsx @@ -0,0 +1,29 @@ +import { cn } from "@/lib/utils"; // Adjusted path for portal +import { CodeBlockContainer } from "./CodeBlockContainer"; // Path within portal/components/code + +export function PlainTextCodeBlock(props: { + code: string; + copyButtonClassName?: string; + className?: string; + scrollableClassName?: string; + codeClassName?: string; + scrollableContainerClassName?: string; + shadowColor?: string; + onCopy?: (code: string) => void; +}) { + return ( + + + {props.code} + + + ); +} diff --git a/apps/portal/src/components/markdown/MarkdownRenderer.tsx b/apps/portal/src/components/markdown/MarkdownRenderer.tsx new file mode 100644 index 00000000000..287495b5b89 --- /dev/null +++ b/apps/portal/src/components/markdown/MarkdownRenderer.tsx @@ -0,0 +1,225 @@ +import { CodeClient, CodeLoading } from "@/components/code/code.client"; // Adjusted path for portal +import { PlainTextCodeBlock } from "@/components/code/plaintext-code"; // Adjusted path for portal +import { InlineCode } from "@/components/ui/inline-code"; // Adjusted path for portal +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; // Adjusted path for portal +import { cn } from "@/lib/utils"; // Adjusted path for portal +import Link from "next/link"; +import { onlyText } from "react-children-utilities"; // Assuming this dependency is available +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; // Assuming this dependency is available +import type { BundledLanguage } from "shiki"; // For CodeClient lang prop + +// Helper function to remove the 'node' prop before spreading +function cleanedProps( + props: T, +): Omit { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { node, ...rest } = props; + return rest; +} + +export const MarkdownRenderer: React.FC<{ + markdownText: string; + className?: string; + code?: { + disableCodeHighlight?: boolean; + ignoreFormattingErrors?: boolean; + className?: string; + }; + inlineCode?: { + className?: string; + }; + p?: { + className?: string; + }; + li?: { + className?: string; + }; + skipHtml?: boolean; +}> = (markdownProps) => { + const { markdownText, className, code } = markdownProps; + const commonHeadingClassName = "mb-2 font-semibold leading-5 tracking-tight"; // Updated to match portal styling if needed + + return ( +
+ ( +

+ ), + h2: (props) => ( +

+ ), + h3: (props) => ( +

+ ), + h4: (props) => ( +

+ ), + h5: (props) => ( +
+ ), + h6: (props) => ( +

+ ), + a: (props) => ( + + ), + hr: (props) => ( +


// Consider portal styles + ), + code: ({ className: inheritedClassName, children, ...props }) => { + const codeStr = onlyText(children); + + // Check if it's likely a block or inline code based on className or content length + if (inheritedClassName || codeStr.length > 80) { + // Heuristic for block code + if ( + code?.disableCodeHighlight || + !inheritedClassName?.includes("language-") + ) { + return ( +
+ { + navigator.clipboard.writeText(code); + }} + /> +
+ ); + } + const language = inheritedClassName.replace( + "language-", + "", + ) as BundledLanguage; + return ( +
+ } // Basic loader + /> +
+ ); + } + // Inline code + return ( + + ); + }, + p: (props) => ( +

+ ), + table: (props) => ( +

+ + + + + ), + th: ({ children: c, ...props }) => ( + + {c} + + ), + td: (props) => ( + + ), + thead: (props) => , + tbody: (props) => , + tr: (props) => , + ul: (props) => ( +
    + ), + ol: (props) => ( +
      + ), + li: ({ children: c, ...props }) => ( +
    1. p]:m-0", + markdownProps.li?.className, + )} + {...cleanedProps(props)} + > + {c} +
    2. + ), + strong(props) { + return ; + }, + }} + > + {markdownText} + + + ); +}; diff --git a/apps/portal/src/components/others/DocSearch.tsx b/apps/portal/src/components/others/DocSearch.tsx index 6003d1f41a7..872ba908f73 100644 --- a/apps/portal/src/components/others/DocSearch.tsx +++ b/apps/portal/src/components/others/DocSearch.tsx @@ -135,7 +135,7 @@ function SearchModalContent(props: { closeModal: () => void }) { e.target.blur(); } }} - placeholder="Search documentation" + placeholder="Search docs" className={cn( "h-auto flex-1 border-none bg-transparent p-4 px-0 text-base placeholder:text-base placeholder:text-muted-foreground", "focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-transparent", @@ -357,7 +357,7 @@ export function DocSearch(props: { variant: "icon" | "search" }) { + + ); +} diff --git a/apps/portal/src/components/ui/LoadingDots.tsx b/apps/portal/src/components/ui/LoadingDots.tsx new file mode 100644 index 00000000000..864f4c60027 --- /dev/null +++ b/apps/portal/src/components/ui/LoadingDots.tsx @@ -0,0 +1,10 @@ +export function LoadingDots() { + return ( +
      + Loading... +
      +
      +
      +
      + ); +} diff --git a/apps/portal/src/components/ui/ScrollShadow/ScrollShadow.module.css b/apps/portal/src/components/ui/ScrollShadow/ScrollShadow.module.css new file mode 100644 index 00000000000..29638d30d84 --- /dev/null +++ b/apps/portal/src/components/ui/ScrollShadow/ScrollShadow.module.css @@ -0,0 +1,36 @@ +.scrollShadowY { + position: absolute; + left: 0; + width: 100%; + height: 32px; + pointer-events: none; +} + +.scrollShadowX { + position: absolute; + top: 0; + width: 32px; + height: 100%; + pointer-events: none; +} + +.scrollShadowTop { + top: 0; + background: linear-gradient(to bottom, var(--shadow), transparent); + opacity: 0; +} + +.scrollShadowBottom { + bottom: 0; + background: linear-gradient(to top, var(--shadow), transparent); +} + +.scrollShadowLeft { + left: 0; + background: linear-gradient(to right, var(--shadow), transparent); +} + +.scrollShadowRight { + right: 0; + background: linear-gradient(to left, var(--shadow), transparent); +} diff --git a/apps/portal/src/components/ui/ScrollShadow/ScrollShadow.tsx b/apps/portal/src/components/ui/ScrollShadow/ScrollShadow.tsx new file mode 100644 index 00000000000..85f605ce09a --- /dev/null +++ b/apps/portal/src/components/ui/ScrollShadow/ScrollShadow.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useIsomorphicLayoutEffect } from "@/lib/useIsomorphicLayoutEffect"; +import { cn } from "@/lib/utils"; +import { useRef } from "react"; +import styles from "./ScrollShadow.module.css"; + +export function ScrollShadow(props: { + children: React.ReactNode; + className?: string; + scrollableClassName?: string; + disableTopShadow?: boolean; + shadowColor?: string; + shadowClassName?: string; +}) { + const scrollableEl = useRef(null); + const shadowTopEl = useRef(null); + const shadowBottomEl = useRef(null); + const shadowLeftEl = useRef(null); + const shadowRightEl = useRef(null); + const wrapperEl = useRef(null); + + useIsomorphicLayoutEffect(() => { + const content = scrollableEl.current; + const shadowTop = shadowTopEl.current; + const shadowBottom = shadowBottomEl.current; + const wrapper = wrapperEl.current; + const shadowLeft = shadowLeftEl.current; + const shadowRight = shadowRightEl.current; + + if (!content || !shadowTop || !shadowBottom || !wrapper) { + return; + } + + function handleScroll() { + if ( + !content || + !shadowTop || + !shadowBottom || + !shadowLeft || + !shadowRight || + !wrapper + ) { + return; + } + + const contentScrollHeight = content.scrollHeight - wrapper.offsetHeight; + const contentScrollWidth = content.scrollWidth - wrapper.offsetWidth; + + if (contentScrollHeight > 10) { + const currentScroll = content.scrollTop / contentScrollHeight; + shadowTop.style.opacity = `${currentScroll}`; + shadowBottom.style.opacity = `${1 - currentScroll}`; + } else { + shadowTop.style.opacity = "0"; + shadowBottom.style.opacity = "0"; + } + + if (contentScrollWidth > 10) { + const currentScrollX = content.scrollLeft / contentScrollWidth; + shadowLeft.style.opacity = `${currentScrollX}`; + shadowRight.style.opacity = `${1 - currentScrollX}`; + } else { + shadowLeft.style.opacity = "0"; + shadowRight.style.opacity = "0"; + } + } + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + handleScroll(); + }); + }); + content.addEventListener("scroll", handleScroll); + + const resizeObserver = new ResizeObserver(() => { + handleScroll(); + }); + + resizeObserver.observe(content); + return () => { + content.removeEventListener("scroll", handleScroll); + resizeObserver.disconnect(); + }; + }, []); + + return ( +
      +
      +
      +
      +
      +
      + {props.children} +
      +
      + ); +} diff --git a/apps/portal/src/components/ui/inline-code.tsx b/apps/portal/src/components/ui/inline-code.tsx new file mode 100644 index 00000000000..3a7f1888b34 --- /dev/null +++ b/apps/portal/src/components/ui/inline-code.tsx @@ -0,0 +1,17 @@ +import { cn } from "@/lib/utils"; // Adjusted path for portal + +export function InlineCode({ + code, + className, +}: { code: string; className?: string }) { + return ( + + {code} + + ); +} diff --git a/apps/portal/src/components/ui/table.tsx b/apps/portal/src/components/ui/table.tsx new file mode 100644 index 00000000000..f01616d3c45 --- /dev/null +++ b/apps/portal/src/components/ui/table.tsx @@ -0,0 +1,152 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; // Adjusted path for portal +import { ScrollShadow } from "./ScrollShadow/ScrollShadow"; // Path within portal/components/ui + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Table.displayName = "Table"; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); + +TableHeader.displayName = "TableHeader"; + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = "TableBody"; + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className, + )} + {...props} + /> +)); +TableFooter.displayName = "TableFooter"; + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes & { + linkBox?: boolean; + } +>(({ className, linkBox, ...props }, ref) => ( + +)); +TableRow.displayName = "TableRow"; + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableHead.displayName = "TableHead"; + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableCell.displayName = "TableCell"; + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableCaption.displayName = "TableCaption"; + +function TableContainer(props: { + children: React.ReactNode; + className?: string; + scrollableContainerClassName?: string; +}) { + return ( + + {props.children} + + ); +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, + TableContainer, +}; diff --git a/apps/portal/src/components/ui/textarea.tsx b/apps/portal/src/components/ui/textarea.tsx new file mode 100644 index 00000000000..b067dc5aa3f --- /dev/null +++ b/apps/portal/src/components/ui/textarea.tsx @@ -0,0 +1,48 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; // Adjusted path for portal + +export interface TextareaProps + extends React.TextareaHTMLAttributes {} + +const Textarea = React.forwardRef( + ({ className, ...props }, ref) => { + return ( +