diff --git a/docs/src/app/(home)/page.tsx b/docs/src/app/(home)/page.tsx index 7e5b832c6..e0af3b40c 100644 --- a/docs/src/app/(home)/page.tsx +++ b/docs/src/app/(home)/page.tsx @@ -1,4 +1,3 @@ -import AIChat from '@/components/chat/ai-chat'; import DocsSelector from '@/components/homepage/iconHover'; export default function HomePage() { @@ -35,11 +34,6 @@ export default function HomePage() {
Documentation assistant
++ Ask me about Stack Auth while you browse the docs. +
+Welcome to Stack Auth!
-- Ask me anything about authentication, documentation, or how to get started. -
-+ setHighlightedCode(``); + } + } finally { + if (!isCancelled) { + setIsLoading(false); + } + } + }; + + highlightCode().catch((error) => { + console.error('Error highlighting code:', error); + }); + + return () => { + isCancelled = true; + }; + }, [code, language]); + + const handleCopy = () => { + void navigator.clipboard.writeText(code).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }).catch((error) => { + console.error('Failed to copy:', error); + }); + }; + + if (isLoading) { + return ( +${code}++ ); + } + + return ( ++ {language} + +++++ + +++ {/* Header */} ++ ); +} diff --git a/docs/src/components/chat/message-formatter.tsx b/docs/src/components/chat/message-formatter.tsx new file mode 100644 index 000000000..1dfbc224b --- /dev/null +++ b/docs/src/components/chat/message-formatter.tsx @@ -0,0 +1,286 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { remark } from 'remark'; +import remarkGfm from 'remark-gfm'; +import { CompactCodeblock } from './compact-codeblock'; + +type MessageNode = { + type: 'text' | 'code' | 'heading' | 'list' | 'listItem' | 'paragraph' | 'strong' | 'emphasis' | 'break', + content?: string, + language?: string, + level?: number, + children?: MessageNode[], +}; + +// Parse markdown text into a structured format +async function parseMarkdown(text: string): Promise+ {language} ++ + {/* Code content */} ++ + +++++ ++ + {/* Fade overlay when collapsed and content overflows */} + {!isExpanded && ( + + )} +{ + const processor = remark().use(remarkGfm); + const tree = processor.parse(text); + + function processNode(node: unknown): MessageNode[] { + const nodes: MessageNode[] = []; + + switch ((node as { type: string }).type) { + case 'root': { + (node as { children?: unknown[] }).children?.forEach((child: unknown) => { + nodes.push(...processNode(child)); + }); + break; + } + + case 'paragraph': { + const paragraphChildren: MessageNode[] = []; + (node as { children?: unknown[] }).children?.forEach((child: unknown) => { + paragraphChildren.push(...processNode(child)); + }); + nodes.push({ + type: 'paragraph', + children: paragraphChildren + }); + break; + } + + case 'code': { + nodes.push({ + type: 'code', + content: (node as { value: string }).value, + language: (node as { lang?: string }).lang || 'text' + }); + break; + } + + case 'heading': { + const headingChildren: MessageNode[] = []; + (node as { children?: unknown[] }).children?.forEach((child: unknown) => { + headingChildren.push(...processNode(child)); + }); + nodes.push({ + type: 'heading', + level: (node as { depth: number }).depth, + children: headingChildren + }); + break; + } + + case 'list': { + const listChildren: MessageNode[] = []; + (node as { children?: unknown[] }).children?.forEach((child: unknown) => { + listChildren.push(...processNode(child)); + }); + nodes.push({ + type: 'list', + children: listChildren + }); + break; + } + + case 'listItem': { + const listItemChildren: MessageNode[] = []; + (node as { children?: unknown[] }).children?.forEach((child: unknown) => { + listItemChildren.push(...processNode(child)); + }); + nodes.push({ + type: 'listItem', + children: listItemChildren + }); + break; + } + + case 'strong': { + const strongChildren: MessageNode[] = []; + (node as { children?: unknown[] }).children?.forEach((child: unknown) => { + strongChildren.push(...processNode(child)); + }); + nodes.push({ + type: 'strong', + children: strongChildren + }); + break; + } + + case 'emphasis': { + const emphasisChildren: MessageNode[] = []; + (node as { children?: unknown[] }).children?.forEach((child: unknown) => { + emphasisChildren.push(...processNode(child)); + }); + nodes.push({ + type: 'emphasis', + children: emphasisChildren + }); + break; + } + + case 'break': { + nodes.push({ + type: 'break' + }); + break; + } + + case 'text': { + nodes.push({ + type: 'text', + content: (node as { value: string }).value + }); + break; + } + + case 'inlineCode': { + nodes.push({ + type: 'text', + content: `\`${(node as { value: string }).value}\`` + }); + break; + } + + default: { + // For any unhandled node types, try to extract text content + if ((node as { value?: string }).value) { + nodes.push({ + type: 'text', + content: (node as { value: string }).value + }); + } + break; + } + } + + return nodes; + } + + return processNode(tree); +} + +// Render a single message node +function renderNode(node: MessageNode, index: number): React.ReactNode { + switch (node.type) { + case 'text': { + return node.content; + } + + case 'code': { + return ( + ++ ); + } + + case 'heading': { + const HeadingTag = `h${Math.min(node.level || 1, 6)}` as keyof JSX.IntrinsicElements; + return ( ++ + {node.children?.map(renderNode)} + + ); + } + + case 'paragraph': { + return ( ++ {node.children?.map(renderNode)} +
+ ); + } + + case 'list': { + return ( ++ {node.children?.map(renderNode)} +
+ ); + } + + case 'listItem': { + return ( ++ {node.children?.map(renderNode)} + + ); + } + + case 'strong': { + return ( + + {node.children?.map(renderNode)} + + ); + } + + case 'emphasis': { + return ( + + {node.children?.map(renderNode)} + + ); + } + + case 'break': { + return
; + } + + default: { + return null; + } + } +} + +type MessageFormatterProps = { + content: string, + className?: string, +}; + +export function MessageFormatter({ content, className = '' }: MessageFormatterProps) { + const [parsedNodes, setParsedNodes] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + let isCancelled = false; + + const parseContent = async () => { + setIsLoading(true); + try { + const nodes = await parseMarkdown(content); + if (!isCancelled) { + setParsedNodes(nodes); + } + } catch (error) { + console.error('Error parsing markdown:', error); + if (!isCancelled) { + // Fallback to plain text + setParsedNodes([{ type: 'text', content }]); + } + } finally { + if (!isCancelled) { + setIsLoading(false); + } + } + }; + + parseContent().catch((error) => { + console.error('Error parsing markdown:', error); + }); + + return () => { + isCancelled = true; + }; + }, [content]); + + if (isLoading) { + return ( + + {content} ++ ); + } + + return ( ++ {parsedNodes.map((node, index) => renderNode(node, index))} ++ ); +} diff --git a/docs/src/components/layout/toc.tsx b/docs/src/components/layout/toc.tsx index c3af26559..06646d579 100644 --- a/docs/src/components/layout/toc.tsx +++ b/docs/src/components/layout/toc.tsx @@ -10,6 +10,7 @@ import { useRef, } from 'react'; import { cn } from '../../lib/cn'; +import { useChatContext } from '../chat/ai-chat'; import { useTOC } from '../layouts/toc-context'; import { TocThumb } from './toc-thumb'; @@ -30,8 +31,10 @@ export type TOCProps = { export function Toc(props: HTMLAttributes) { const { toc } = usePageStyles(); const { isTocOpen } = useTOC(); + const { isOpen: isChatOpen } = useChatContext(); - if (!isTocOpen) return null; + // Hide TOC if not open or if chat is open + if (!isTocOpen || isChatOpen) return null; return ( , @@ -472,6 +475,7 @@ export function DocsLayout({ }: DocsLayoutProps): ReactNode { const { isTocOpen } = useTOC(); const [searchOpen, setSearchOpen] = useState(false); + const { isOpen: isChatOpen } = useChatContext(); const tabs = useMemo( () => getSidebarTabsFromOptions(sidebar.tabs, props.tree) ?? [], @@ -564,7 +568,7 @@ export function DocsLayout({ )}@@ -227,6 +254,34 @@ function HomeNavbar() { // Main Home Layout Component export function HomeLayout({ children }: { children: ReactNode }) { + // Add home-page class to body to exclude from chat content shifting + useEffect(() => { + document.body.classList.add('home-page'); + return () => { + document.body.classList.remove('home-page'); + }; + }, []); + + // Add scroll detection for homepage + useEffect(() => { + const handleScroll = () => { + const scrollY = window.scrollY; + const isScrolled = scrollY > 50; + + if (isScrolled) { + document.body.classList.add('scrolled'); + } else { + document.body.classList.remove('scrolled'); + } + }; + + window.addEventListener('scroll', handleScroll, { passive: true }); + return () => { + window.removeEventListener('scroll', handleScroll); + document.body.classList.remove('scrolled'); + }; + }, []); + return (diff --git a/docs/src/components/layouts/home-layout.tsx b/docs/src/components/layouts/home-layout.tsx index f1442a17e..ee3f6752e 100644 --- a/docs/src/components/layouts/home-layout.tsx +++ b/docs/src/components/layouts/home-layout.tsx @@ -1,8 +1,9 @@ 'use client'; -import { Github, Menu, X } from 'lucide-react'; +import { Github, Menu, Sparkles, X } from 'lucide-react'; import Link from 'next/link'; import { type ReactNode, useEffect, useState } from 'react'; +import { useChatContext } from '../chat/ai-chat'; import { CustomSearchDialog } from '../layout/custom-search-dialog'; import { SearchInputToggle } from '../layout/custom-search-toggle'; import { ThemeToggle } from '../layout/theme-toggle'; @@ -41,12 +42,32 @@ function StackAuthLogo() { ); } +// AI Chat Toggle Button for Home Layout +function HomeAIChatToggleButton({ compact = false }: { compact?: boolean }) { + const { isOpen, toggleChat } = useChatContext(); + + return ( + + ); +} + // Home Navbar Component function HomeNavbar() { const [searchOpen, setSearchOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [isScrolled, setIsScrolled] = useState(false); + // Check if chat is open + const { isOpen: isChatOpen, isExpanded: isChatExpanded } = useChatContext(); + // Scroll detection useEffect(() => { const handleScroll = () => { @@ -68,8 +89,8 @@ function HomeNavbar() { return ( <> {/* Full Navbar */} -{children} - ++ {/* Left - Logo + Social Links */}+ {/* AI Chat Toggle */} +@@ -107,6 +128,9 @@ function HomeNavbar() { /> + {/* Theme Toggle */} @@ -212,6 +236,9 @@ function HomeNavbar() { {/* Compact Theme Toggle */} + + {/* Compact AI Chat Toggle */} + + {/* AI Chat Toggle Button */} +diff --git a/docs/src/components/layouts/shared-header.tsx b/docs/src/components/layouts/shared-header.tsx index 9eaf85855..3c2b5f245 100644 --- a/docs/src/components/layouts/shared-header.tsx +++ b/docs/src/components/layouts/shared-header.tsx @@ -4,11 +4,12 @@ import { SearchInputToggle } from '@/components/layout/custom-search-toggle'; import Waves from '@/components/layouts/api/waves'; import { isInApiSection, isInComponentsSection, isInSdkSection } from '@/components/layouts/shared/section-utils'; import { type NavLink } from '@/lib/navigation-utils'; -import { List, Menu, X } from 'lucide-react'; +import { List, Menu, Sparkles, X } from 'lucide-react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import type { ReactNode } from 'react'; import { useEffect, useState } from 'react'; +import { useChatContext } from '../chat/ai-chat'; import { useTOC } from './toc-context'; type SharedHeaderProps = { @@ -68,25 +69,50 @@ function isNavLinkActive(pathname: string, navLink: NavLink): boolean { return false; } +/** + * AI Chat Toggle Button + */ +function AIChatToggleButton() { + const { isOpen, toggleChat } = useChatContext(); + + return ( + + ); +} + /** * Inner TOC Toggle Button that uses the context */ function TOCToggleButtonInner() { const { isTocOpen, toggleToc } = useTOC(); + const { isOpen: isChatOpen } = useChatContext(); + + // TOC is effectively visible only if it's open AND chat is not open + const isTocEffectivelyVisible = isTocOpen && !isChatOpen; return ( ); @@ -254,6 +280,11 @@ export function SharedHeader({ ++ {/* Mobile Hamburger Menu - Shown on mobile */}+