mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
init AI UI implemented
This commit is contained in:
parent
1d94119100
commit
43cbda3c37
@ -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() {
|
||||
<div className="mb-6">
|
||||
<DocsSelector />
|
||||
</div>
|
||||
|
||||
{/* AI Chat Assistant */}
|
||||
<div className="mb-6">
|
||||
<AIChat />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@ -1,118 +1,97 @@
|
||||
import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
||||
import { streamText } from 'ai';
|
||||
import { streamText, tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Allow streaming responses up to 30 seconds
|
||||
export const maxDuration = 30;
|
||||
|
||||
// Configure Google Gemini with custom API key variable
|
||||
// Create Google AI instance
|
||||
const google = createGoogleGenerativeAI({
|
||||
apiKey: process.env.GOOGLE_AI_API_KEY,
|
||||
});
|
||||
|
||||
export function errorHandler(error: unknown) {
|
||||
if (error == null) {
|
||||
return 'unknown error';
|
||||
}
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
// Helper function to get error message
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return JSON.stringify(error);
|
||||
return String(error);
|
||||
}
|
||||
|
||||
async function getStackAuthDocs() {
|
||||
export async function POST(request: Request) {
|
||||
const { messages, docsContent } = await request.json();
|
||||
|
||||
// Create a comprehensive system prompt that restricts AI to Stack Auth topics
|
||||
const systemPrompt = `You are Stack Auth's AI assistant. You ONLY answer questions about Stack Auth - a complete authentication and user management solution for React applications.
|
||||
|
||||
DOCUMENTATION CONTEXT:
|
||||
${docsContent || 'Documentation not available'}
|
||||
|
||||
STRICT GUIDELINES:
|
||||
1. ONLY answer questions related to Stack Auth, its features, implementation, or usage
|
||||
2. If asked about non-Stack Auth topics, politely redirect: "I can only help with Stack Auth questions. Please ask about Stack Auth's features, setup, or implementation."
|
||||
3. Provide detailed, technical answers with code examples when relevant
|
||||
4. Reference specific Stack Auth features, components, or APIs
|
||||
5. When explaining concepts, always relate them to Stack Auth specifically
|
||||
6. Include relevant code snippets from the documentation when helpful
|
||||
7. If you're unsure about something Stack Auth-related, say so rather than guessing
|
||||
|
||||
RESPONSE FORMAT:
|
||||
- Use markdown formatting for better readability
|
||||
- Include code blocks with proper syntax highlighting
|
||||
- Use bullet points for lists
|
||||
- Bold important concepts
|
||||
- Provide practical examples when possible
|
||||
|
||||
Remember: You are Stack Auth's dedicated assistant. Stay focused on Stack Auth topics only.`;
|
||||
|
||||
try {
|
||||
// Get the base URL from the request or use localhost for development
|
||||
const baseUrl = process.env.VERCEL_URL
|
||||
? `https://${process.env.VERCEL_URL}`
|
||||
: 'http://localhost:8104';
|
||||
|
||||
console.log('Fetching docs from:', `${baseUrl}/llms.txt`);
|
||||
|
||||
const response = await fetch(`${baseUrl}/llms.txt`);
|
||||
console.log('Docs fetch response status:', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch Stack Auth docs:', response.status, response.statusText);
|
||||
return null;
|
||||
}
|
||||
|
||||
const docsContent = await response.text();
|
||||
console.log('Docs content length:', docsContent?.length || 0);
|
||||
console.log('Docs content preview:', docsContent?.substring(0, 200) + '...');
|
||||
|
||||
return docsContent;
|
||||
} catch (error) {
|
||||
console.error('Error fetching Stack Auth docs:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { messages } = await req.json();
|
||||
|
||||
console.log('Received messages:', messages);
|
||||
console.log('Google AI API Key configured:', !!process.env.GOOGLE_AI_API_KEY);
|
||||
|
||||
// Fetch Stack Auth documentation
|
||||
const stackAuthDocs = await getStackAuthDocs();
|
||||
|
||||
// Create system message with documentation context
|
||||
const systemMessage = {
|
||||
role: 'system' as const,
|
||||
content: `You are a technical AI assistant specializing in Stack Auth, a complete authentication solution. You are helping developers who want detailed, technical guidance.
|
||||
|
||||
IMPORTANT INSTRUCTIONS:
|
||||
- You can ONLY answer questions about Stack Auth and authentication topics
|
||||
- If someone asks about anything else, politely redirect them to ask about Stack Auth
|
||||
- Your audience is DEVELOPERS who need in-depth technical information
|
||||
- Provide comprehensive, detailed answers with code examples when available
|
||||
- Include specific implementation details, configuration options, and best practices
|
||||
- Reference exact function names, parameters, and code snippets from the documentation
|
||||
- Don't oversimplify - developers want the full technical depth
|
||||
- When explaining concepts, include relevant code examples and implementation details
|
||||
- If there are multiple approaches, explain the different options and their trade-offs
|
||||
|
||||
${stackAuthDocs ? `
|
||||
Here is the complete Stack Auth documentation with detailed examples and technical information:
|
||||
|
||||
${stackAuthDocs}
|
||||
|
||||
Use this documentation to provide comprehensive, technical answers. Include code examples, configuration details, and implementation specifics. Developers are looking for actionable, detailed guidance, not basic overviews.
|
||||
` : 'Stack Auth documentation could not be loaded. Please answer based on general Stack Auth knowledge, but provide detailed technical information for developers.'}
|
||||
|
||||
Remember: Your responses should match the technical depth and detail level of the Stack Auth documentation. Provide code examples, configuration snippets, and comprehensive implementation guidance.`
|
||||
};
|
||||
|
||||
// Prepend system message to the conversation
|
||||
const messagesWithContext = [systemMessage, ...messages];
|
||||
|
||||
const result = streamText({
|
||||
model: google('gemini-1.5-flash'),
|
||||
messages: messagesWithContext,
|
||||
system: systemPrompt,
|
||||
messages,
|
||||
maxTokens: 1000,
|
||||
temperature: 0.3,
|
||||
tools: {
|
||||
searchDocs: tool({
|
||||
description: 'Search through Stack Auth documentation for specific information',
|
||||
parameters: z.object({
|
||||
query: z.string().describe('The search query to find relevant documentation'),
|
||||
}),
|
||||
execute: async ({ query }) => {
|
||||
// Simple search through the docs content
|
||||
if (!docsContent) {
|
||||
return 'Documentation not available';
|
||||
}
|
||||
|
||||
const lines = docsContent.split('\n');
|
||||
const relevantLines = lines.filter((line: string) =>
|
||||
line.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
if (relevantLines.length === 0) {
|
||||
return `No specific information found for "${query}" in the documentation.`;
|
||||
}
|
||||
|
||||
return relevantLines.slice(0, 10).join('\n');
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return result.toDataStreamResponse({
|
||||
getErrorMessage: errorHandler,
|
||||
getErrorMessage,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in chat API:', error);
|
||||
console.error('Chat API Error:', error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'Internal server error',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
JSON.stringify({
|
||||
error: 'Failed to process chat request',
|
||||
details: getErrorMessage(error),
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,3 +5,63 @@
|
||||
@import '../components/mdx/mdx-info.css';
|
||||
@import '../components/mdx/mdx-cards.css';
|
||||
@import '../components/mdx/reset-code-styles.css';
|
||||
|
||||
/* Chat drawer content shifting for docs pages */
|
||||
body.chat-open:not(.home-page) {
|
||||
padding-right: 25rem; /* 384px + 16px for spacing */
|
||||
transition: padding-right 300ms ease-out;
|
||||
}
|
||||
|
||||
/* Ensure smooth transition when closing for docs pages */
|
||||
body:not(.home-page) {
|
||||
transition: padding-right 300ms ease-out;
|
||||
}
|
||||
|
||||
/* Chat drawer content shifting for homepage - much less padding */
|
||||
body.home-page.chat-open main {
|
||||
padding-right: 12rem; /* Much less padding for homepage */
|
||||
transition: padding-right 300ms ease-out;
|
||||
}
|
||||
|
||||
/* Ensure smooth transition when closing for homepage main content */
|
||||
body.home-page main {
|
||||
transition: padding-right 300ms ease-out;
|
||||
}
|
||||
|
||||
/* Special chat drawer positioning for homepage */
|
||||
body.home-page [data-chat-drawer] {
|
||||
/* Start from top when pill is not visible */
|
||||
top: 56px; /* Same as top-14 */
|
||||
height: calc(100vh - 56px);
|
||||
transition: top 300ms ease-out, height 300ms ease-out;
|
||||
}
|
||||
|
||||
/* When scrolled on homepage, adjust for pill navbar */
|
||||
body.home-page.scrolled [data-chat-drawer] {
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Flat scrollbar design for compact codeblocks */
|
||||
.compact-codeblock-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.compact-codeblock-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.compact-codeblock-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #404040;
|
||||
border-radius: 0px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.compact-codeblock-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #525252;
|
||||
}
|
||||
|
||||
.compact-codeblock-scrollbar::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { StackProvider, StackTheme } from '@stackframe/stack';
|
||||
import { RootProvider } from 'fumadocs-ui/provider';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { ChatProvider } from '../components/chat/ai-chat';
|
||||
import { stackServerApp } from '../stack';
|
||||
import './global.css';
|
||||
|
||||
@ -24,7 +25,9 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
enabled: false, // Completely disable fumadocs search
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<ChatProvider>
|
||||
{children}
|
||||
</ChatProvider>
|
||||
</RootProvider>
|
||||
</StackTheme>
|
||||
</StackProvider>
|
||||
|
||||
@ -1,140 +1,332 @@
|
||||
'use client';
|
||||
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import { Bot, Send, User } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Maximize2, Minimize2, Send, X } from 'lucide-react';
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||
import { MessageFormatter } from './message-formatter';
|
||||
|
||||
export default function AIChat() {
|
||||
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat();
|
||||
// Stack Auth Icon Component (just the icon, not full logo)
|
||||
function StackIcon({ size = 20, className }: { size?: number, className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size * (242/200)} // Maintain aspect ratio
|
||||
viewBox="0 0 200 242"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M103.504 1.81227C101.251 0.68679 98.6002 0.687576 96.3483 1.81439L4.4201 47.8136C1.71103 49.1692 0 51.9387 0 54.968V130.55C0 133.581 1.7123 136.351 4.42292 137.706L96.4204 183.695C98.6725 184.82 101.323 184.82 103.575 183.694L168.422 151.271C173.742 148.611 180 152.479 180 158.426V168.879C180 171.91 178.288 174.68 175.578 176.035L103.577 212.036C101.325 213.162 98.6745 213.162 96.4224 212.036L11.5771 169.623C6.25791 166.964 0 170.832 0 176.779V187.073C0 190.107 1.71689 192.881 4.43309 194.234L96.5051 240.096C98.7529 241.216 101.396 241.215 103.643 240.094L195.571 194.235C198.285 192.881 200 190.109 200 187.076V119.512C200 113.565 193.741 109.697 188.422 112.356L131.578 140.778C126.258 143.438 120 139.57 120 133.623V123.17C120 120.14 121.712 117.37 124.422 116.014L195.578 80.4368C198.288 79.0817 200 76.3116 200 73.2814V54.9713C200 51.9402 198.287 49.1695 195.576 47.8148L103.504 1.81227Z" fill="currentColor"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Chat Context
|
||||
type ChatContextType = {
|
||||
isOpen: boolean,
|
||||
isExpanded: boolean,
|
||||
toggleChat: () => void,
|
||||
expandChat: () => void,
|
||||
collapseChat: () => void,
|
||||
};
|
||||
|
||||
type ChatProviderProps = {
|
||||
children: ReactNode,
|
||||
};
|
||||
|
||||
const ChatContext = createContext<ChatContextType | undefined>(undefined);
|
||||
|
||||
export function ChatProvider({ children }: ChatProviderProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Prevent hydration mismatch by only rendering after client hydration
|
||||
// Load state from localStorage on mount
|
||||
useEffect(() => {
|
||||
setIsHydrated(true);
|
||||
const savedIsOpen = localStorage.getItem('ai-chat-open');
|
||||
const savedIsExpanded = localStorage.getItem('ai-chat-expanded');
|
||||
|
||||
if (savedIsOpen === 'true') {
|
||||
setIsOpen(true);
|
||||
}
|
||||
if (savedIsExpanded === 'true') {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Debug logging
|
||||
console.log('Messages:', messages);
|
||||
// Save state to localStorage when it changes
|
||||
useEffect(() => {
|
||||
localStorage.setItem('ai-chat-open', isOpen.toString());
|
||||
}, [isOpen]);
|
||||
|
||||
// Don't render until hydrated to prevent SSR/client mismatch
|
||||
if (!isHydrated) {
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto">
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="flex items-center gap-2 px-6 py-3 bg-muted/50 rounded-xl shadow-lg font-semibold animate-pulse">
|
||||
<Bot size={20} />
|
||||
Loading AI Assistant...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
useEffect(() => {
|
||||
localStorage.setItem('ai-chat-expanded', isExpanded.toString());
|
||||
}, [isExpanded]);
|
||||
|
||||
// Add/remove body classes for content shifting
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.classList.add('chat-open');
|
||||
} else {
|
||||
document.body.classList.remove('chat-open');
|
||||
setIsExpanded(false); // Close expansion when chat closes
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('chat-open');
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const toggleChat = () => setIsOpen(!isOpen);
|
||||
const expandChat = () => setIsExpanded(true);
|
||||
const collapseChat = () => setIsExpanded(false);
|
||||
|
||||
const value = {
|
||||
isOpen,
|
||||
isExpanded,
|
||||
toggleChat,
|
||||
expandChat,
|
||||
collapseChat,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto">
|
||||
{/* Toggle Button */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-primary text-primary-foreground rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 hover:-translate-y-1 font-semibold"
|
||||
>
|
||||
<Bot size={20} />
|
||||
{isOpen ? 'Hide AI Assistant' : 'Ask AI Assistant'}
|
||||
</button>
|
||||
<ChatContext.Provider value={value}>
|
||||
{children}
|
||||
<AIChatDrawer />
|
||||
</ChatContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useChatContext() {
|
||||
const context = useContext(ChatContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useChatContext must be used within a ChatProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
function AIChatDrawer() {
|
||||
const { isOpen, isExpanded, toggleChat, expandChat, collapseChat } = useChatContext();
|
||||
const [input, setInput] = useState('');
|
||||
const [docsContent, setDocsContent] = useState('');
|
||||
|
||||
// Fetch documentation content when component mounts
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
const fetchDocs = async () => {
|
||||
try {
|
||||
const response = await fetch('/llms.txt');
|
||||
if (response.ok && !isCancelled) {
|
||||
const content = await response.text();
|
||||
setDocsContent(content);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch documentation:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDocs().catch((error) => {
|
||||
console.error('Failed to fetch documentation:', error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const {
|
||||
messages,
|
||||
handleSubmit,
|
||||
isLoading,
|
||||
error,
|
||||
} = useChat({
|
||||
api: '/api/chat',
|
||||
initialMessages: [],
|
||||
body: {
|
||||
docsContent,
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error('Chat error:', error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleFormSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || isLoading) return;
|
||||
|
||||
handleSubmit(e, {
|
||||
body: {
|
||||
docsContent,
|
||||
},
|
||||
});
|
||||
setInput('');
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setInput(e.target.value);
|
||||
};
|
||||
|
||||
// Starter prompts for users
|
||||
const starterPrompts = [
|
||||
{
|
||||
title: "Getting Started",
|
||||
description: "Setup and installation",
|
||||
prompt: "How do I get started with Stack Auth?"
|
||||
},
|
||||
{
|
||||
title: "Next.js Integration",
|
||||
description: "Framework setup",
|
||||
prompt: "How do I implement authentication in Next.js?"
|
||||
},
|
||||
{
|
||||
title: "Authentication Methods",
|
||||
description: "Available options",
|
||||
prompt: "What authentication methods does Stack Auth support?"
|
||||
}
|
||||
];
|
||||
|
||||
const handleStarterPromptClick = (prompt: string) => {
|
||||
setInput(prompt);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-14 right-0 h-[calc(100vh-3.5rem)] bg-fd-background border-l border-fd-border flex flex-col transition-all duration-300 ease-out z-50 ${
|
||||
isExpanded ? 'w-[70vw] z-[70]' : 'w-96'
|
||||
} ${
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-fd-border bg-fd-background">
|
||||
<div className="flex items-center gap-2">
|
||||
<StackIcon size={18} className="text-fd-primary" />
|
||||
<div>
|
||||
<h3 className="font-medium text-fd-foreground text-sm">Stack Auth AI</h3>
|
||||
<p className="text-xs text-fd-muted-foreground">Documentation assistant</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Expand/Collapse Button */}
|
||||
<button
|
||||
onClick={isExpanded ? collapseChat : expandChat}
|
||||
className="p-1 text-fd-muted-foreground hover:text-fd-foreground hover:bg-fd-muted rounded transition-colors"
|
||||
title={isExpanded ? 'Collapse chat' : 'Expand chat'}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<Minimize2 className="w-3 h-3" />
|
||||
) : (
|
||||
<Maximize2 className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={toggleChat}
|
||||
className="p-1 text-fd-muted-foreground hover:text-fd-foreground hover:bg-fd-muted rounded transition-colors"
|
||||
title="Close chat"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Interface */}
|
||||
{isOpen && (
|
||||
<div className="bg-card border border-border rounded-xl shadow-lg p-6 mb-8">
|
||||
<div className="flex items-center gap-2 mb-4 pb-4 border-b border-border">
|
||||
<Bot size={24} className="text-primary" />
|
||||
<h3 className="text-lg font-semibold">Stack Auth AI Assistant</h3>
|
||||
<span className="text-sm text-muted-foreground ml-auto">
|
||||
Ask questions about Stack Auth documentation
|
||||
</span>
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
{messages.length === 0 ? (
|
||||
<div className="text-center py-6">
|
||||
<StackIcon size={24} className="text-fd-muted-foreground mx-auto mb-3" />
|
||||
<h3 className="font-medium text-fd-foreground mb-2 text-sm">How can I help?</h3>
|
||||
<p className="text-fd-muted-foreground text-xs mb-4">
|
||||
Ask me about Stack Auth while you browse the docs.
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{starterPrompts.map((starter, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleStarterPromptClick(starter.prompt)}
|
||||
className="w-full p-2.5 text-left text-xs bg-fd-muted/30 hover:bg-fd-muted/60 rounded-md border border-fd-border/50 transition-colors"
|
||||
>
|
||||
<div className="font-medium">{starter.title}</div>
|
||||
<div className="text-fd-muted-foreground">{starter.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages Container */}
|
||||
<div className="h-96 overflow-y-auto mb-4 space-y-4 scroll-smooth">
|
||||
{messages.length === 0 && (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<Bot size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium mb-2">Welcome to Stack Auth!</p>
|
||||
<p className="text-sm">
|
||||
Ask me anything about authentication, documentation, or how to get started.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map(message => (
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${
|
||||
message.role === 'user' ? 'justify-end' : 'justify-start'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex gap-3 ${
|
||||
message.role === 'user' ? 'justify-end' : 'justify-start'
|
||||
className={`max-w-[85%] p-2 rounded-lg text-xs ${
|
||||
message.role === 'user'
|
||||
? 'bg-fd-primary/10 border border-fd-primary/20 text-fd-foreground'
|
||||
: 'bg-fd-muted text-fd-foreground border border-fd-border'
|
||||
}`}
|
||||
>
|
||||
{message.role === 'assistant' && (
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0 mt-1">
|
||||
<Bot size={16} className="text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`max-w-[80%] rounded-xl px-4 py-3 ${
|
||||
message.role === 'user'
|
||||
? 'bg-primary text-primary-foreground ml-auto'
|
||||
: 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<div className="whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{message.role === 'user' ? (
|
||||
<div className="whitespace-pre-wrap break-words">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message.role === 'user' && (
|
||||
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0 mt-1">
|
||||
<User size={16} />
|
||||
</div>
|
||||
) : (
|
||||
<MessageFormatter content={message.content} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex gap-3 justify-start">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0 mt-1">
|
||||
<Bot size={16} className="text-primary" />
|
||||
</div>
|
||||
<div className="bg-muted rounded-xl px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-muted-foreground/50 rounded-full animate-bounce" />
|
||||
<div className="w-2 h-2 bg-muted-foreground/50 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }} />
|
||||
<div className="w-2 h-2 bg-muted-foreground/50 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }} />
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-fd-muted text-fd-foreground border border-fd-border p-2 rounded-lg text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-1 h-1 bg-current rounded-full animate-bounce [animation-delay:-0.3s]"></div>
|
||||
<div className="w-1 h-1 bg-current rounded-full animate-bounce [animation-delay:-0.15s]"></div>
|
||||
<div className="w-1 h-1 bg-current rounded-full animate-bounce"></div>
|
||||
</div>
|
||||
<span className="ml-1">Thinking...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input Form */}
|
||||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||
<input
|
||||
className="flex-1 px-4 py-3 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent placeholder:text-muted-foreground"
|
||||
value={input}
|
||||
placeholder="Ask about Stack Auth documentation..."
|
||||
onChange={handleInputChange}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !input.trim()}
|
||||
className="px-4 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-red-500 text-xs p-2 bg-red-500/10 rounded border border-red-500/20">
|
||||
Error: {error.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-fd-border p-3">
|
||||
<form onSubmit={handleFormSubmit} className="flex gap-2">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Ask about Stack Auth..."
|
||||
className="flex-1 resize-none border border-fd-border rounded-md px-2 py-1 text-xs bg-fd-background text-fd-foreground placeholder:text-fd-muted-foreground focus:outline-none focus:ring-1 focus:ring-fd-primary focus:border-fd-primary"
|
||||
rows={1}
|
||||
style={{ minHeight: '32px', maxHeight: '96px' }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleFormSubmit(e as React.FormEvent);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim() || isLoading}
|
||||
className="px-2 py-1 bg-fd-primary text-fd-primary-foreground rounded-md text-xs hover:bg-fd-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
<Send className="w-3 h-3" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
159
docs/src/components/chat/compact-codeblock.tsx
Normal file
159
docs/src/components/chat/compact-codeblock.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/cn';
|
||||
import { Check, Copy, Maximize2 } from 'lucide-react';
|
||||
import { useEffect, useState } from "react";
|
||||
import { codeToHtml } from "shiki";
|
||||
|
||||
type CompactCodeblockProps = {
|
||||
code: string,
|
||||
language?: string,
|
||||
maxHeight?: string,
|
||||
className?: string,
|
||||
}
|
||||
|
||||
export function CompactCodeblock({ code, language = 'tsx', maxHeight = '200px', className }: CompactCodeblockProps) {
|
||||
const [highlightedCode, setHighlightedCode] = useState<string>("");
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Update syntax highlighted code when code changes
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
const highlightCode = async () => {
|
||||
try {
|
||||
const html = await codeToHtml(code, {
|
||||
lang: language,
|
||||
theme: 'github-dark-default',
|
||||
});
|
||||
|
||||
if (!isCancelled) {
|
||||
setHighlightedCode(html);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error highlighting code:', error);
|
||||
if (!isCancelled) {
|
||||
// Fallback to plain text wrapped in <pre>
|
||||
setHighlightedCode(`<pre><code>${code}</code></pre>`);
|
||||
}
|
||||
} 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 (
|
||||
<div className={cn(
|
||||
'relative rounded-lg bg-[#0d1117] border border-[#30363d] text-[#e6edf3] text-xs overflow-hidden',
|
||||
className
|
||||
)}>
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-[#161b22] border-b border-[#30363d]">
|
||||
<span className="text-[#7d8590] text-xs font-mono">{language}</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1 hover:bg-[#21262d] rounded transition-colors"
|
||||
title="Copy code"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-[#21262d] rounded mb-2"></div>
|
||||
<div className="h-4 bg-[#21262d] rounded w-3/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'relative group',
|
||||
className
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between bg-fd-muted/30 px-2 py-1 border border-fd-border rounded-t-md">
|
||||
<span className="text-xs text-fd-muted-foreground font-mono">{language}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="p-1 hover:bg-fd-muted/60 rounded text-fd-muted-foreground hover:text-fd-foreground transition-colors"
|
||||
title={isExpanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
<Maximize2 className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1 hover:bg-fd-muted/60 rounded text-fd-muted-foreground hover:text-fd-foreground transition-colors"
|
||||
title="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code content */}
|
||||
<div
|
||||
className="relative overflow-hidden border border-t-0 border-fd-border rounded-b-md bg-[#0a0a0a]"
|
||||
style={{
|
||||
maxHeight: isExpanded ? '400px' : maxHeight,
|
||||
transition: 'max-height 0.3s ease-out',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="overflow-auto h-full compact-codeblock-scrollbar"
|
||||
style={{
|
||||
// Firefox scrollbar styles
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: '#404040 transparent',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="p-3 text-xs leading-relaxed [&_*]:!bg-transparent [&_pre]:!bg-transparent [&_code]:!bg-transparent [&_pre]:!p-0 [&_pre]:!m-0"
|
||||
style={{
|
||||
background: '#0a0a0a !important',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: highlightedCode }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fade overlay when collapsed and content overflows */}
|
||||
{!isExpanded && (
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-[#0a0a0a] to-transparent pointer-events-none"
|
||||
style={{
|
||||
background: 'linear-gradient(to top, #0a0a0a 0%, transparent 100%)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
286
docs/src/components/chat/message-formatter.tsx
Normal file
286
docs/src/components/chat/message-formatter.tsx
Normal file
@ -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<MessageNode[]> {
|
||||
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 (
|
||||
<div key={index} className="my-3">
|
||||
<CompactCodeblock
|
||||
code={node.content || ''}
|
||||
language={node.language || 'text'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'heading': {
|
||||
const HeadingTag = `h${Math.min(node.level || 1, 6)}` as keyof JSX.IntrinsicElements;
|
||||
return (
|
||||
<HeadingTag key={index} className="font-semibold mt-4 mb-2 text-sm">
|
||||
{node.children?.map(renderNode)}
|
||||
</HeadingTag>
|
||||
);
|
||||
}
|
||||
|
||||
case 'paragraph': {
|
||||
return (
|
||||
<p key={index} className="mb-3 last:mb-0">
|
||||
{node.children?.map(renderNode)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
case 'list': {
|
||||
return (
|
||||
<ul key={index} className="list-disc list-inside mb-3 space-y-1">
|
||||
{node.children?.map(renderNode)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
case 'listItem': {
|
||||
return (
|
||||
<li key={index} className="text-xs">
|
||||
{node.children?.map(renderNode)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
case 'strong': {
|
||||
return (
|
||||
<strong key={index} className="font-semibold">
|
||||
{node.children?.map(renderNode)}
|
||||
</strong>
|
||||
);
|
||||
}
|
||||
|
||||
case 'emphasis': {
|
||||
return (
|
||||
<em key={index} className="italic">
|
||||
{node.children?.map(renderNode)}
|
||||
</em>
|
||||
);
|
||||
}
|
||||
|
||||
case 'break': {
|
||||
return <br key={index} />;
|
||||
}
|
||||
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type MessageFormatterProps = {
|
||||
content: string,
|
||||
className?: string,
|
||||
};
|
||||
|
||||
export function MessageFormatter({ content, className = '' }: MessageFormatterProps) {
|
||||
const [parsedNodes, setParsedNodes] = useState<MessageNode[]>([]);
|
||||
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 (
|
||||
<div className={`whitespace-pre-wrap break-words ${className}`}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`break-words ${className}`}>
|
||||
{parsedNodes.map((node, index) => renderNode(node, index))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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<HTMLDivElement>) {
|
||||
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 (
|
||||
<div
|
||||
|
||||
@ -83,6 +83,9 @@ import {
|
||||
} from './shared/section-utils';
|
||||
import { useTOC } from './toc-context';
|
||||
|
||||
// Import chat context
|
||||
import { useChatContext } from '../chat/ai-chat';
|
||||
|
||||
// Context for persisting accordion state
|
||||
type AccordionContextType = {
|
||||
accordionState: Record<string, boolean>,
|
||||
@ -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({
|
||||
)}
|
||||
<div className={cn(
|
||||
'flex-1 transition-all duration-300 min-w-0',
|
||||
isTocOpen && 'xl:mr-72'
|
||||
isTocOpen && !isChatOpen && 'xl:mr-72'
|
||||
)}>
|
||||
<StylesProvider {...pageStyles}>{children}</StylesProvider>
|
||||
</div>
|
||||
|
||||
@ -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 (
|
||||
<button
|
||||
onClick={toggleChat}
|
||||
className={`inline-flex items-center justify-center rounded-lg text-fd-muted-foreground transition-colors hover:bg-fd-muted hover:text-fd-foreground ${
|
||||
compact ? 'h-8 w-8 rounded-full' : 'h-9 w-9'
|
||||
} ${isOpen ? 'bg-fd-foreground text-fd-background hover:bg-fd-foreground/90 hover:text-fd-background' : ''}`}
|
||||
title={isOpen ? 'Close AI chat' : 'Open AI chat'}
|
||||
>
|
||||
<Sparkles className={compact ? 'h-3.5 w-3.5' : 'h-4 w-4'} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 */}
|
||||
<header className={`sticky top-0 z-50 w-full border-b border-fd-border bg-fd-background/95 backdrop-blur supports-[backdrop-filter]:bg-fd-background/60 transition-all duration-300 ${isScrolled ? 'opacity-0 pointer-events-none' : 'opacity-100'}`}>
|
||||
<div className="container flex h-14 max-w-screen-2xl items-center justify-between px-4 md:px-6">
|
||||
<header className={`sticky top-0 z-50 w-full border-b border-fd-border bg-fd-background/95 backdrop-blur supports-[backdrop-filter]:bg-fd-background/60 transition-all duration-300 ${isScrolled ? 'opacity-0 pointer-events-none' : 'opacity-100'} ${(isChatOpen || isChatExpanded) ? 'fixed left-0 right-0 z-[60]' : ''}`}>
|
||||
<div className={`flex h-14 items-center justify-between px-4 md:px-6 ${(isChatOpen || isChatExpanded) ? '' : 'container max-w-screen-2xl'}`}>
|
||||
{/* Left - Logo + Social Links */}
|
||||
<div className="flex items-center gap-4">
|
||||
<StackAuthLogo />
|
||||
@ -107,6 +128,9 @@ function HomeNavbar() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AI Chat Toggle */}
|
||||
<HomeAIChatToggleButton />
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<ThemeToggle className="p-0" />
|
||||
|
||||
@ -212,6 +236,9 @@ function HomeNavbar() {
|
||||
|
||||
{/* Compact Theme Toggle */}
|
||||
<ThemeToggle compact />
|
||||
|
||||
{/* Compact AI Chat Toggle */}
|
||||
<HomeAIChatToggleButton compact />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -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 (
|
||||
<div className="relative flex min-h-screen flex-col bg-fd-background">
|
||||
<HomeNavbar />
|
||||
|
||||
@ -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 (
|
||||
<button
|
||||
onClick={toggleChat}
|
||||
className={`flex items-center justify-center shadow-lg transition-all duration-300 w-8 h-8 rounded-lg text-sm font-medium ${
|
||||
isOpen
|
||||
? 'bg-fd-foreground text-fd-background'
|
||||
: 'bg-fd-muted text-fd-muted-foreground hover:text-fd-foreground hover:bg-fd-muted/80'
|
||||
}`}
|
||||
title={isOpen ? 'Close AI chat' : 'Open AI chat'}
|
||||
>
|
||||
<Sparkles className="w-4 h-4 flex-shrink-0" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<button
|
||||
onClick={toggleToc}
|
||||
className={`flex items-center justify-center gap-2 shadow-lg transition-all duration-300 w-24 h-8 rounded-lg text-sm font-medium ${
|
||||
isTocOpen
|
||||
isTocEffectivelyVisible
|
||||
? 'bg-fd-foreground text-fd-background'
|
||||
: 'bg-fd-muted text-fd-muted-foreground hover:text-fd-foreground hover:bg-fd-muted/80'
|
||||
}`}
|
||||
title={isTocOpen ? 'Hide table of contents' : 'Show table of contents'}
|
||||
title={isTocEffectivelyVisible ? 'Hide table of contents' : 'Show table of contents'}
|
||||
>
|
||||
<List className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="hidden sm:inline">
|
||||
{isTocOpen ? 'Hide' : 'TOC'}
|
||||
{isTocEffectivelyVisible ? 'Hide' : 'TOC'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@ -254,6 +280,11 @@ export function SharedHeader({
|
||||
<TOCToggleButton />
|
||||
</div>
|
||||
|
||||
{/* AI Chat Toggle Button */}
|
||||
<div className="hidden md:block">
|
||||
<AIChatToggleButton />
|
||||
</div>
|
||||
|
||||
{/* Mobile Hamburger Menu - Shown on mobile */}
|
||||
<div className="flex lg:hidden">
|
||||
<button
|
||||
|
||||
Loading…
Reference in New Issue
Block a user