diff --git a/docs/src/app/api/[[...slug]]/page.tsx b/docs/src/app/api/[[...slug]]/page.tsx index 2b40e49db..e47c5e758 100644 --- a/docs/src/app/api/[[...slug]]/page.tsx +++ b/docs/src/app/api/[[...slug]]/page.tsx @@ -2,7 +2,6 @@ import { EnhancedAPIPage } from '@/components/api/enhanced-api-page'; import { getMDXComponents } from '@/mdx-components'; import { apiSource } from 'lib/source'; import { notFound } from 'next/navigation'; -import { APIPageWrapper } from '../../../components/api/api-page-wrapper'; import { SharedContentLayout } from '../../../components/layouts/shared-content-layout'; export default async function ApiPage({ @@ -18,10 +17,8 @@ export default async function ApiPage({ const MDX = page.data.body; return ( - - - - - + + + ); } diff --git a/docs/src/app/api/layout.tsx b/docs/src/app/api/layout.tsx index 7463bca84..7cf10dc8e 100644 --- a/docs/src/app/api/layout.tsx +++ b/docs/src/app/api/layout.tsx @@ -1,5 +1,9 @@ +import { APIPageWrapper } from '@/components/api/api-page-wrapper'; +import { AuthPanel } from '@/components/api/auth-panel'; +import { AIChatDrawer } from '@/components/chat/ai-chat'; import { ApiSidebar } from '@/components/layouts/api/api-sidebar-server'; import { DocsHeaderWrapper } from '@/components/layouts/docs-header-wrapper'; +import { SidebarProvider } from '@/components/layouts/sidebar-context'; import { apiSource } from '../../../lib/source'; // Types for the page object structure @@ -84,26 +88,36 @@ export default function ApiLayout({ children }: { children: React.ReactNode }) { } return ( -
- {/* Full-width header with Stack Auth branding */} - + + +
+ {/* Full-width header with Stack Auth branding */} + - {/* Custom API Sidebar - positioned under header, hidden on mobile */} -
- -
+ {/* Custom API Sidebar - positioned under header, hidden on mobile */} +
+ +
- {/* Main content area - full width on mobile, with left margin on desktop, accounting for header */} -
- {/* Page content */} -
- {children} -
-
-
+ {/* Main content area - full width on mobile, with left margin on desktop, accounting for header */} +
+ {/* Page content */} +
+ {children} +
+
+ + {/* AI Chat Drawer */} + + + {/* Auth Panel */} + +
+ + ); } diff --git a/docs/src/app/api/search/route.ts b/docs/src/app/api/search/route.ts index bd558327f..884b2a1c3 100644 --- a/docs/src/app/api/search/route.ts +++ b/docs/src/app/api/search/route.ts @@ -8,8 +8,78 @@ type SearchResult = { type: 'page' | 'heading' | 'text', content: string, url: string, + score: number, // Add scoring for prioritization }; +// Helper function to get platform priority for tie-breaking +function getPlatformPriority(url: string): number { + // Higher number = higher priority + if (url.includes('/docs/next/')) return 100; + if (url.includes('/docs/react/')) return 90; + if (url.includes('/docs/js/')) return 80; + if (url.includes('/docs/python/')) return 70; + // API and other pages + if (url.includes('/api/')) return 60; + return 50; // Default priority +} + +// Helper function to calculate search relevance score +function calculateScore(query: string, text: string, type: 'title' | 'description' | 'heading' | 'content'): number { + const queryLower = query.toLowerCase().trim(); + const textLower = text.toLowerCase().trim(); + // Base scores by type (higher = more important) + const baseScores = { + title: 100, + description: 70, + heading: 50, + content: 20 + }; + + let score = 0; + let matchType = ''; + + // Exact match bonus (highest priority) + if (textLower === queryLower) { + score += baseScores[type] * 3; // Triple score for exact matches + matchType = 'exact'; + } + // Starts with query bonus + else if (textLower.startsWith(queryLower)) { + score += baseScores[type] * 2; // Double score for starts with + matchType = 'starts-with'; + } + // Contains as whole word bonus + else if (new RegExp(`\\b${queryLower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(textLower)) { + score += baseScores[type] * 1.5; + matchType = 'whole-word'; + } + // Contains query bonus + else if (textLower.includes(queryLower)) { + score += baseScores[type]; + matchType = 'contains'; + } + else { + return 0; // No match + } + + // Length penalty - shorter text with match is more relevant + const lengthPenalty = Math.min(text.length / 100, 0.3); + score -= lengthPenalty * 5; + + // Multiple occurrence bonus + const occurrences = (textLower.match(new RegExp(queryLower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length; + if (occurrences > 1) { + score += (occurrences - 1) * 15; + } + + // Log scoring details for debugging + if (score > 0) { + console.log(`Score calculation: "${text}" (${type}) = ${score.toFixed(1)} [${matchType}, ${occurrences} occurrences]`); + } + + return Math.max(score, 0); +} + // Helper function to extract text content from MDX function extractTextFromMDX(filePath: string): string { try { @@ -59,35 +129,44 @@ export async function GET(request: NextRequest) { const description = page.data.description || ''; // Check if page title matches - if (title.toLowerCase().includes(queryLower)) { + const titleScore = calculateScore(query, title, 'title'); + if (titleScore > 0) { + console.log(`Page title match: "${title}" at ${url} - Score: ${titleScore.toFixed(1)}`); results.push({ id: `${url}-page`, type: 'page', content: title, - url: url + url: url, + score: titleScore }); } // Check if description matches - if (description.toLowerCase().includes(queryLower)) { + const descriptionScore = calculateScore(query, description, 'description'); + if (descriptionScore > 0) { results.push({ id: `${url}-description`, type: 'text', content: description, - url: url + url: url, + score: descriptionScore }); } // Search through TOC items (headings) page.data.toc.forEach((tocItem, tocIndex) => { const tocTitle = tocItem.title; - if (typeof tocTitle === 'string' && tocTitle.toLowerCase().includes(queryLower)) { - results.push({ - id: `${url}-${tocIndex}`, - type: 'heading', - content: tocTitle, - url: `${url}#${tocItem.url.slice(1)}` // Remove the # from tocItem.url and add it back - }); + if (typeof tocTitle === 'string') { + const headingScore = calculateScore(query, tocTitle, 'heading'); + if (headingScore > 0) { + results.push({ + id: `${url}-${tocIndex}`, + type: 'heading', + content: tocTitle, + url: `${url}#${tocItem.url.slice(1)}`, // Remove the # from tocItem.url and add it back + score: headingScore + }); + } } }); @@ -99,8 +178,9 @@ export async function GET(request: NextRequest) { if (fs.existsSync(fullPath)) { const textContent = extractTextFromMDX(fullPath); + const contentScore = calculateScore(query, textContent, 'content'); - if (textContent.toLowerCase().includes(queryLower)) { + if (contentScore > 0) { // Find a snippet around the match for better context const matchIndex = textContent.toLowerCase().indexOf(queryLower); const start = Math.max(0, matchIndex - 50); @@ -111,7 +191,8 @@ export async function GET(request: NextRequest) { id: `${url}-content-${pageIndex}`, type: 'text', content: `...${snippet}...`, - url: url + url: url, + score: contentScore }); } } @@ -120,10 +201,54 @@ export async function GET(request: NextRequest) { } }); - console.log(`Found ${results.length} search results for "${query}"`); - console.log('Sample results with platform info:', results.slice(0, 3)); + // Sort results by score in descending order (highest score first) + // Use platform priority as tie-breaker when scores are equal + results.sort((a, b) => { + if (b.score !== a.score) { + return b.score - a.score; // Primary sort by score + } + return getPlatformPriority(b.url) - getPlatformPriority(a.url); // Tie-breaker by platform priority + }); - return Response.json(results); + console.log(`\n=== RAW RESULTS FOR "${query}" ===`); + results.slice(0, 10).forEach((result, i) => { + const priority = getPlatformPriority(result.url); + console.log(`${i + 1}. "${result.content}" (${result.type}) - Score: ${result.score.toFixed(1)} - Priority: ${priority} - URL: ${result.url}`); + }); + + // Remove duplicate URLs and keep only the highest scoring result per URL + const seenUrls = new Set(); + const uniqueResults = results.filter(result => { + const baseUrl = result.url.split('#')[0]; // Remove fragment for deduplication + if (seenUrls.has(baseUrl)) { + console.log(`Duplicate URL filtered: ${result.content} (${result.score.toFixed(1)}) for ${baseUrl}`); + return false; + } + seenUrls.add(baseUrl); + return true; + }); + + // Re-sort after deduplication using the same logic + uniqueResults.sort((a, b) => { + if (b.score !== a.score) { + return b.score - a.score; // Primary sort by score + } + return getPlatformPriority(b.url) - getPlatformPriority(a.url); // Tie-breaker by platform priority + }); + + console.log(`\n=== FINAL RESULTS FOR "${query}" ===`); + uniqueResults.slice(0, 10).forEach((result, i) => { + const priority = getPlatformPriority(result.url); + console.log(`${i + 1}. "${result.content}" (${result.type}) - Score: ${result.score.toFixed(1)} - Priority: ${priority} - URL: ${result.url}`); + }); + + console.log(`\nFound ${uniqueResults.length} unique search results for "${query}"`); + + // Remove score from response (internal use only) + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- score is used internally for sorting. + const responseResults = uniqueResults.map(({ score, ...result }) => result); + + return Response.json(responseResults); } catch (error) { console.error('Search error:', error); diff --git a/docs/src/app/docs/layout.tsx b/docs/src/app/docs/layout.tsx index 3eb063078..a318107ab 100644 --- a/docs/src/app/docs/layout.tsx +++ b/docs/src/app/docs/layout.tsx @@ -1,12 +1,12 @@ import { DocsHeaderWrapper } from '@/components/layouts/docs-header-wrapper'; import { DynamicDocsLayout } from '@/components/layouts/docs-layout-router'; -import { TOCProvider } from '@/components/layouts/toc-context'; +import { SidebarProvider } from '@/components/layouts/sidebar-context'; import { source } from 'lib/source'; import './custom-docs-styles.css'; export default function DocsLayout({ children }: { children: React.ReactNode }) { return ( - +
{/* Docs Header Wrapper - Provides sidebar content to mobile navigation */} @@ -18,6 +18,6 @@ export default function DocsLayout({ children }: { children: React.ReactNode })
-
+ ); } diff --git a/docs/src/app/global.css b/docs/src/app/global.css index 4a7745198..a5dc3c34f 100644 --- a/docs/src/app/global.css +++ b/docs/src/app/global.css @@ -6,18 +6,51 @@ @import '../components/mdx/mdx-cards.css'; @import '../components/mdx/reset-code-styles.css'; -/* Chat drawer content shifting for docs pages */ +/* Standardize top spacing between docs and API pages */ +/* Target the specific fumadocs layout components */ +#nd-page > article > div { + padding-top: 0 !important; +} + +/* Target SharedContentLayout container in docs pages */ +#nd-page article > div.container { + padding-top: 0 !important; +} + +/* Also target the div with mb-12 class inside DocsPage */ +#nd-page .mb-12 { + margin-top: -2rem !important; +} + +/* TOC open state - only when chat is not open */ +body.toc-open:not(.chat-open) #nd-docs-layout > div:last-child { + margin-right: 18rem; /* 288px - same as xl:mr-72 */ + transition: margin-right 300ms ease-out; +} + +/* Chat open state - overrides everything */ 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 */ +/* Auth open state - for API pages */ +body.auth-open:not(.home-page) { + padding-right: 26rem; /* 384px + 32px for more spacing (auth panel is 384px wide) */ + transition: padding-right 300ms ease-out; +} + +/* Default transition for docs pages */ body:not(.home-page) { transition: padding-right 300ms ease-out; } -/* Chat drawer content shifting for homepage - much less padding */ +/* Ensure content area transitions smoothly */ +#nd-docs-layout > div:last-child { + transition: margin-right 300ms ease-out; +} + +/* Chat drawer content shifting for homepage */ body.home-page.chat-open main { padding-right: 12rem; /* Much less padding for homepage */ transition: padding-right 300ms ease-out; @@ -65,3 +98,260 @@ body.home-page.scrolled [data-chat-drawer] { .compact-codeblock-scrollbar::-webkit-scrollbar-corner { background: transparent; } + +/* Subtle pulsing red animation for auth panel errors */ +@keyframes subtle-red-pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } +} + +.auth-error-pulse { + animation: subtle-red-pulse 2s ease-in-out infinite; +} + +/* Colorful moving gradient animation for chat button */ +@keyframes gradient-shift { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } +} + +@keyframes gradient-noise { + 0% { + background-position: 0% 0%, 100% 100%; + } + 25% { + background-position: 100% 0%, 0% 100%; + } + 50% { + background-position: 100% 100%, 100% 0%; + } + 75% { + background-position: 0% 100%, 0% 0%; + } + 100% { + background-position: 0% 0%, 100% 100%; + } +} + +@keyframes gradient-infection { + 0% { + mask: + radial-gradient(circle at 20% 80%, transparent 0%, transparent 100%), + radial-gradient(circle at 80% 30%, transparent 0%, transparent 100%), + radial-gradient(circle at 40% 20%, transparent 0%, transparent 100%); + } + 20% { + mask: + radial-gradient(circle at 20% 80%, black 0%, black 15%, transparent 25%), + radial-gradient(circle at 80% 30%, transparent 0%, transparent 100%), + radial-gradient(circle at 40% 20%, transparent 0%, transparent 100%); + } + 40% { + mask: + radial-gradient(circle at 20% 80%, black 0%, black 25%, transparent 40%), + radial-gradient(circle at 80% 30%, black 0%, black 10%, transparent 20%), + radial-gradient(circle at 40% 20%, transparent 0%, transparent 100%); + } + 60% { + mask: + radial-gradient(circle at 20% 80%, black 0%, black 35%, transparent 55%), + radial-gradient(circle at 80% 30%, black 0%, black 20%, transparent 35%), + radial-gradient(circle at 40% 20%, black 0%, black 8%, transparent 18%); + } + 80% { + mask: + radial-gradient(circle at 20% 80%, black 0%, black 50%, transparent 70%), + radial-gradient(circle at 80% 30%, black 0%, black 35%, transparent 50%), + radial-gradient(circle at 40% 20%, black 0%, black 18%, transparent 28%); + } + 100% { + mask: + radial-gradient(circle at 20% 80%, black 0%, black 70%, transparent 90%), + radial-gradient(circle at 80% 30%, black 0%, black 50%, transparent 70%), + radial-gradient(circle at 40% 20%, black 0%, black 30%, transparent 45%); + } +} + +@keyframes gradient-infection-2 { + 0% { + mask: + radial-gradient(circle at 70% 20%, transparent 0%, transparent 100%), + radial-gradient(circle at 30% 70%, transparent 0%, transparent 100%), + radial-gradient(circle at 60% 80%, transparent 0%, transparent 100%); + } + 20% { + mask: + radial-gradient(circle at 70% 20%, black 0%, black 15%, transparent 25%), + radial-gradient(circle at 30% 70%, transparent 0%, transparent 100%), + radial-gradient(circle at 60% 80%, transparent 0%, transparent 100%); + } + 40% { + mask: + radial-gradient(circle at 70% 20%, black 0%, black 25%, transparent 40%), + radial-gradient(circle at 30% 70%, black 0%, black 10%, transparent 20%), + radial-gradient(circle at 60% 80%, transparent 0%, transparent 100%); + } + 60% { + mask: + radial-gradient(circle at 70% 20%, black 0%, black 35%, transparent 55%), + radial-gradient(circle at 30% 70%, black 0%, black 20%, transparent 35%), + radial-gradient(circle at 60% 80%, black 0%, black 8%, transparent 18%); + } + 80% { + mask: + radial-gradient(circle at 70% 20%, black 0%, black 50%, transparent 70%), + radial-gradient(circle at 30% 70%, black 0%, black 35%, transparent 50%), + radial-gradient(circle at 60% 80%, black 0%, black 18%, transparent 28%); + } + 100% { + mask: + radial-gradient(circle at 70% 20%, black 0%, black 70%, transparent 90%), + radial-gradient(circle at 30% 70%, black 0%, black 50%, transparent 70%), + radial-gradient(circle at 60% 80%, black 0%, black 30%, transparent 45%); + } +} + +@keyframes gradient-infection-3 { + 0% { + mask: + radial-gradient(circle at 50% 10%, transparent 0%, transparent 100%), + radial-gradient(circle at 10% 50%, transparent 0%, transparent 100%), + radial-gradient(circle at 90% 60%, transparent 0%, transparent 100%); + } + 20% { + mask: + radial-gradient(circle at 50% 10%, black 0%, black 15%, transparent 25%), + radial-gradient(circle at 10% 50%, transparent 0%, transparent 100%), + radial-gradient(circle at 90% 60%, transparent 0%, transparent 100%); + } + 40% { + mask: + radial-gradient(circle at 50% 10%, black 0%, black 25%, transparent 40%), + radial-gradient(circle at 10% 50%, black 0%, black 10%, transparent 20%), + radial-gradient(circle at 90% 60%, transparent 0%, transparent 100%); + } + 60% { + mask: + radial-gradient(circle at 50% 10%, black 0%, black 35%, transparent 55%), + radial-gradient(circle at 10% 50%, black 0%, black 20%, transparent 35%), + radial-gradient(circle at 90% 60%, black 0%, black 8%, transparent 18%); + } + 80% { + mask: + radial-gradient(circle at 50% 10%, black 0%, black 50%, transparent 70%), + radial-gradient(circle at 10% 50%, black 0%, black 35%, transparent 50%), + radial-gradient(circle at 90% 60%, black 0%, black 18%, transparent 28%); + } + 100% { + mask: + radial-gradient(circle at 50% 10%, black 0%, black 70%, transparent 90%), + radial-gradient(circle at 10% 50%, black 0%, black 50%, transparent 70%), + radial-gradient(circle at 90% 60%, black 0%, black 30%, transparent 45%); + } +} + +@keyframes gradient-infection-4 { + 0% { + mask: + radial-gradient(circle at 80% 70%, transparent 0%, transparent 100%), + radial-gradient(circle at 20% 30%, transparent 0%, transparent 100%), + radial-gradient(circle at 60% 40%, transparent 0%, transparent 100%); + } + 20% { + mask: + radial-gradient(circle at 80% 70%, black 0%, black 15%, transparent 25%), + radial-gradient(circle at 20% 30%, transparent 0%, transparent 100%), + radial-gradient(circle at 60% 40%, transparent 0%, transparent 100%); + } + 40% { + mask: + radial-gradient(circle at 80% 70%, black 0%, black 25%, transparent 40%), + radial-gradient(circle at 20% 30%, black 0%, black 10%, transparent 20%), + radial-gradient(circle at 60% 40%, transparent 0%, transparent 100%); + } + 60% { + mask: + radial-gradient(circle at 80% 70%, black 0%, black 35%, transparent 55%), + radial-gradient(circle at 20% 30%, black 0%, black 20%, transparent 35%), + radial-gradient(circle at 60% 40%, black 0%, black 8%, transparent 18%); + } + 80% { + mask: + radial-gradient(circle at 80% 70%, black 0%, black 50%, transparent 70%), + radial-gradient(circle at 20% 30%, black 0%, black 35%, transparent 50%), + radial-gradient(circle at 60% 40%, black 0%, black 18%, transparent 28%); + } + 100% { + mask: + radial-gradient(circle at 80% 70%, black 0%, black 70%, transparent 90%), + radial-gradient(circle at 20% 30%, black 0%, black 50%, transparent 70%), + radial-gradient(circle at 60% 40%, black 0%, black 30%, transparent 45%); + } +} + +.chat-gradient-active { + position: relative; + background: hsl(var(--fd-muted)) !important; +} + +.chat-gradient-active::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background: + linear-gradient(45deg, rgba(139, 92, 246, 0.95), rgba(236, 72, 153, 0.95), rgba(59, 130, 246, 0.95), rgba(6, 182, 212, 0.95)), + linear-gradient(-45deg, rgba(236, 72, 153, 0.8), rgba(59, 130, 246, 0.8), rgba(139, 92, 246, 0.8), rgba(6, 182, 212, 0.8)), + linear-gradient(90deg, rgba(139, 92, 246, 0.85), rgba(6, 182, 212, 0.85), rgba(236, 72, 153, 0.85)); + background-size: 400% 400%, 300% 300%, 200% 200%; + mask: + radial-gradient(circle at 20% 80%, black 0%, black 70%, transparent 90%), + radial-gradient(circle at 80% 30%, black 0%, black 50%, transparent 70%), + radial-gradient(circle at 40% 20%, black 0%, black 30%, transparent 45%); + mask-composite: add; + -webkit-mask-composite: source-over; + animation: + gradient-infection 0.1s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards, + gradient-shift 8s ease-in-out infinite 0.15s, + gradient-noise 12s linear infinite 0.15s; + z-index: 0; +} + +/* Random animation variations */ +.chat-gradient-active.variant-2::before { + animation: + gradient-infection-2 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards, + gradient-shift 8s ease-in-out infinite 0.15s, + gradient-noise 12s linear infinite 0.15s; +} + +.chat-gradient-active.variant-3::before { + animation: + gradient-infection-3 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards, + gradient-shift 8s ease-in-out infinite 0.15s, + gradient-noise 12s linear infinite 0.15s; +} + +.chat-gradient-active.variant-4::before { + animation: + gradient-infection-4 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards, + gradient-shift 8s ease-in-out infinite 0.15s, + gradient-noise 12s linear infinite 0.15s; +} + +/* When not active, mask shrinks back */ +button:not(.chat-gradient-active)::before { + mask: radial-gradient(circle at 20% 80%, transparent 0%, transparent 100%); + transition: mask 0.3s ease-out; +} diff --git a/docs/src/app/layout.tsx b/docs/src/app/layout.tsx index 504d186b5..350867271 100644 --- a/docs/src/app/layout.tsx +++ b/docs/src/app/layout.tsx @@ -1,7 +1,6 @@ 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'; @@ -25,9 +24,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { enabled: false, // Completely disable fumadocs search }} > - - {children} - + {children} diff --git a/docs/src/components/api/api-page-wrapper.tsx b/docs/src/components/api/api-page-wrapper.tsx index 2707edd9a..e0c8ebf73 100644 --- a/docs/src/components/api/api-page-wrapper.tsx +++ b/docs/src/components/api/api-page-wrapper.tsx @@ -1,8 +1,7 @@ 'use client'; -import { AlertTriangle, Key, X } from 'lucide-react'; import { createContext, ReactNode, useContext, useState } from 'react'; -import { Button } from './button'; +import { useSidebar } from '../layouts/sidebar-context'; // Stack Auth required headers const STACK_AUTH_HEADERS = { @@ -28,16 +27,17 @@ type APIPageContextType = { sharedHeaders: Record, updateSharedHeaders: (headers: Record) => void, reportError: (status: number, error: APIError) => void, - isHeadersPanelOpen: boolean, + lastError: { status: number, error: APIError } | null, + highlightMissingHeaders: boolean, } const APIPageContext = createContext(null); +// Hook to access API page context - returns null when not used within APIPageWrapper export function useAPIPageContext() { const context = useContext(APIPageContext); - if (!context) { - throw new Error('useAPIPageContext must be used within APIPageWrapper'); - } + // Return null instead of throwing error when context is not available + // This makes it safe to use in components that might be rendered outside of APIPageContextProvider return context; } @@ -46,8 +46,8 @@ type APIPageWrapperProps = { } export function APIPageWrapper({ children }: APIPageWrapperProps) { + const { isAuthOpen, toggleAuth } = useSidebar(); const [sharedHeaders, setSharedHeaders] = useState>(STACK_AUTH_HEADERS); - const [isHeadersPanelOpen, setIsHeadersPanelOpen] = useState(false); const [lastError, setLastError] = useState<{ status: number, error: APIError } | null>(null); const [highlightMissingHeaders, setHighlightMissingHeaders] = useState(false); @@ -64,7 +64,9 @@ export function APIPageWrapper({ children }: APIPageWrapperProps) { // Auto-open panel and highlight missing headers on 400/401/403 errors if ([400, 401, 403].includes(status)) { - setIsHeadersPanelOpen(true); + if (!isAuthOpen) { + toggleAuth(); + } setHighlightMissingHeaders(true); // Auto-hide highlighting after 10 seconds @@ -74,322 +76,9 @@ export function APIPageWrapper({ children }: APIPageWrapperProps) { } }; - const stackAuthHeaders = [ - { key: 'Content-Type', label: 'Content Type', placeholder: 'application/json', required: true }, - { key: 'X-Stack-Access-Type', label: 'Access Type', placeholder: 'client or server', required: true }, - { key: 'X-Stack-Project-Id', label: 'Project ID', placeholder: 'your-project-uuid', required: true }, - { key: 'X-Stack-Publishable-Client-Key', label: 'Client Key', placeholder: 'pck_your_key_here', required: false }, - { key: 'X-Stack-Secret-Server-Key', label: 'Server Key', placeholder: 'ssk_your_key_here', required: false }, - { key: 'X-Stack-Access-Token', label: 'Access Token', placeholder: 'user_access_token', required: false }, - ]; - - const missingRequiredHeaders = stackAuthHeaders.filter( - header => header.required && !sharedHeaders[header.key].trim() - ); - return ( - -
- {/* Desktop Sidebar Headers Panel */} -
-
- {/* Panel Toggle Button */} -
- -
- - {/* Panel Content */} -
- {isHeadersPanelOpen && ( -
- {/* Panel Header */} -
-
-
- {highlightMissingHeaders ? ( - - ) : ( - - )} -
-
-

- {highlightMissingHeaders ? 'Authentication Required' : 'Global Authentication'} -

-

- {highlightMissingHeaders - ? 'Please configure the required headers below' - : 'Configure headers for all API requests' - } -

-
-
- - {/* Error Message */} - {highlightMissingHeaders && lastError && ( -
-
- - - {lastError.status} Error - Authentication required - -
- {missingRequiredHeaders.length > 0 && ( -

- Missing required headers: {missingRequiredHeaders.map(h => h.label).join(', ')} -

- )} -
- )} -
- - {/* Panel Content - Scrollable */} -
-
- {stackAuthHeaders.map((header) => { - const isMissing = highlightMissingHeaders && header.required && !sharedHeaders[header.key].trim(); - - return ( -
- - updateSharedHeaders({ ...sharedHeaders, [header.key]: e.target.value })} - className={`w-full px-3 py-2 border rounded-lg bg-fd-background text-fd-foreground text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-all duration-200 ${ - isMissing - ? 'border-red-300 focus:ring-red-500 dark:border-red-700' - : 'border-fd-border focus:ring-fd-primary' - }`} - /> -
- ); - })} -
- - {/* Status Indicator */} -
-
-
- - {Object.values(sharedHeaders).filter(v => v.trim()).length} of {stackAuthHeaders.length} headers configured - -
- {missingRequiredHeaders.length === 0 && Object.values(sharedHeaders).some(v => v.trim()) && ( -
-
- - Ready to make authenticated requests - -
- )} -
-
-
- )} -
-
-
- - {/* Mobile Floating Button - only show when overlay is closed */} - {!isHeadersPanelOpen && ( -
- -
- )} - - {/* Mobile Full-Screen Overlay */} - {isHeadersPanelOpen && ( -
- {/* Mobile Header */} -
-
-
- {highlightMissingHeaders ? ( - - ) : ( - - )} -
-
-

- {highlightMissingHeaders ? 'Authentication Required' : 'API Authentication'} -

-

- Configure headers for requests -

-
-
- -
- - {/* Error Message - Mobile */} - {highlightMissingHeaders && lastError && ( -
-
- - - {lastError.status} Error - Authentication required - -
- {missingRequiredHeaders.length > 0 && ( -

- Missing required headers: {missingRequiredHeaders.map(h => h.label).join(', ')} -

- )} -
- )} - - {/* Mobile Content - Scrollable */} -
-
- {stackAuthHeaders.map((header) => { - const isMissing = highlightMissingHeaders && header.required && !sharedHeaders[header.key].trim(); - - return ( -
- - updateSharedHeaders({ ...sharedHeaders, [header.key]: e.target.value })} - className={`w-full px-4 py-3 border rounded-lg bg-fd-background text-fd-foreground text-base focus:outline-none focus:ring-2 focus:border-transparent transition-all duration-200 ${ - isMissing - ? 'border-red-300 focus:ring-red-500 dark:border-red-700' - : 'border-fd-border focus:ring-fd-primary' - }`} - /> -
- ); - })} -
-
- - {/* Mobile Footer with Status */} -
-
-
-
- - {Object.values(sharedHeaders).filter(v => v.trim()).length} of {stackAuthHeaders.length} configured - -
- -
- {missingRequiredHeaders.length === 0 && Object.values(sharedHeaders).some(v => v.trim()) && ( -
-
- - Ready to make authenticated requests - -
- )} -
-
- )} - - {/* Main Content - Desktop gets margin, Mobile stays full width */} -
- {children} -
-
+ + {children} ); } diff --git a/docs/src/components/api/auth-panel.tsx b/docs/src/components/api/auth-panel.tsx new file mode 100644 index 000000000..38bd16232 --- /dev/null +++ b/docs/src/components/api/auth-panel.tsx @@ -0,0 +1,327 @@ +'use client'; + +import { AlertTriangle, Key, X } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useSidebar } from '../layouts/sidebar-context'; +import { useAPIPageContext } from './api-page-wrapper'; +import { Button } from './button'; + +export function AuthPanel() { + const { isAuthOpen, toggleAuth } = useSidebar(); + + // Always call hooks at the top level + const apiContext = useAPIPageContext(); + + // Default headers structure + const defaultHeaders = { + 'Content-Type': '', + 'X-Stack-Access-Type': '', + 'X-Stack-Project-Id': '', + 'X-Stack-Publishable-Client-Key': '', + 'X-Stack-Secret-Server-Key': '', + 'X-Stack-Access-Token': '', + }; + + const { sharedHeaders, updateSharedHeaders, lastError, highlightMissingHeaders } = apiContext || { + sharedHeaders: defaultHeaders, + updateSharedHeaders: () => {}, + lastError: null, + highlightMissingHeaders: false + }; + + const [isHomePage, setIsHomePage] = useState(false); + const [isScrolled, setIsScrolled] = useState(false); + + // Detect if we're on homepage and scroll state (same as AIChatDrawer) + useEffect(() => { + const checkHomePage = () => { + setIsHomePage(document.body.classList.contains('home-page')); + }; + + const checkScrolled = () => { + setIsScrolled(document.body.classList.contains('scrolled')); + }; + + // Initial check + checkHomePage(); + checkScrolled(); + + // Set up observers for class changes + const observer = new MutationObserver(() => { + checkHomePage(); + checkScrolled(); + }); + + observer.observe(document.body, { + attributes: true, + attributeFilter: ['class'] + }); + + return () => { + observer.disconnect(); + }; + }, []); + + // Calculate position based on homepage and scroll state (same as AIChatDrawer) + const topPosition = isHomePage && isScrolled ? 'top-0' : 'top-14'; + const height = isHomePage && isScrolled ? 'h-screen' : 'h-[calc(100vh-3.5rem)]'; + + const stackAuthHeaders = [ + { key: 'Content-Type', label: 'Content Type', placeholder: 'application/json', required: true }, + { key: 'X-Stack-Access-Type', label: 'Access Type', placeholder: 'client or server', required: true }, + { key: 'X-Stack-Project-Id', label: 'Project ID', placeholder: 'your-project-uuid', required: true }, + { key: 'X-Stack-Publishable-Client-Key', label: 'Client Key', placeholder: 'pck_your_key_here', required: false }, + { key: 'X-Stack-Secret-Server-Key', label: 'Server Key', placeholder: 'ssk_your_key_here', required: false }, + { key: 'X-Stack-Access-Token', label: 'Access Token', placeholder: 'user_access_token', required: false }, + ]; + + const missingRequiredHeaders = stackAuthHeaders.filter( + header => header.required && !sharedHeaders[header.key].trim() + ); + + return ( + <> + {/* Desktop Auth Panel - Matching AIChatDrawer design */} +
+ {/* Header - Matching AIChatDrawer */} +
+
+
+ {highlightMissingHeaders ? ( + + ) : ( + + )} +
+
+

+ {highlightMissingHeaders ? 'Authentication Required' : 'API Authentication'} +

+

+ Configure headers for requests +

+
+
+ +
+ + {/* Error Message - Reserve space to prevent layout shifts */} +
+ {highlightMissingHeaders && lastError ? ( +
+
+ + + {lastError.status} Error - Authentication required + +
+ {missingRequiredHeaders.length > 0 && ( +

+ Missing: {missingRequiredHeaders.map(h => h.label).join(', ')} +

+ )} +
+ ) : null} +
+ + {/* Content - Fixed height to prevent layout shifts */} +
+
+ {stackAuthHeaders.map((header) => { + const isMissing = highlightMissingHeaders && header.required && !sharedHeaders[header.key].trim(); + + return ( +
+ + updateSharedHeaders({ ...sharedHeaders, [header.key]: e.target.value })} + className={`w-full px-2 py-1.5 border rounded-md text-xs bg-fd-background text-fd-foreground placeholder:text-fd-muted-foreground focus:outline-none focus:ring-1 focus:border-transparent transition-all duration-200 ${ + isMissing + ? 'border-red-300 focus:ring-red-500 dark:border-red-700' + : 'border-fd-border focus:ring-fd-primary focus:border-fd-primary' + }`} + /> +
+ ); + })} +
+
+ + {/* Footer Status */} +
+
+
+ + {Object.values(sharedHeaders).filter(v => v.trim()).length} of {stackAuthHeaders.length} configured + +
+ {missingRequiredHeaders.length === 0 && Object.values(sharedHeaders).some(v => v.trim()) && ( +
+
+ + Ready for API requests + +
+ )} +
+
+ + {/* Mobile Auth Panel */} +
+ {/* Mobile Header */} +
+
+
+ {highlightMissingHeaders ? ( + + ) : ( + + )} +
+
+

+ {highlightMissingHeaders ? 'Authentication Required' : 'API Authentication'} +

+

+ Configure headers for requests +

+
+
+ +
+ + {/* Error Message - Mobile */} +
+ {highlightMissingHeaders && lastError ? ( +
+
+ + + {lastError.status} Error - Authentication required + +
+ {missingRequiredHeaders.length > 0 && ( +

+ Missing: {missingRequiredHeaders.map(h => h.label).join(', ')} +

+ )} +
+ ) : null} +
+ + {/* Mobile Content - Fixed height to prevent layout shifts */} +
+
+
+ {stackAuthHeaders.map((header) => { + const isMissing = highlightMissingHeaders && header.required && !sharedHeaders[header.key].trim(); + + return ( +
+ + updateSharedHeaders({ ...sharedHeaders, [header.key]: e.target.value })} + className={`w-full px-3 py-2 border rounded-md text-sm bg-fd-background text-fd-foreground placeholder:text-fd-muted-foreground focus:outline-none focus:ring-1 focus:border-transparent transition-all duration-200 ${ + isMissing + ? 'border-red-300 focus:ring-red-500 dark:border-red-700' + : 'border-fd-border focus:ring-fd-primary focus:border-fd-primary' + }`} + /> +
+ ); + })} +
+
+
+ + {/* Mobile Footer */} +
+
+
+
+ + {Object.values(sharedHeaders).filter(v => v.trim()).length} of {stackAuthHeaders.length} configured + +
+ +
+ {missingRequiredHeaders.length === 0 && Object.values(sharedHeaders).some(v => v.trim()) && ( +
+
+ + Ready for API requests + +
+ )} +
+
+ + ); +} diff --git a/docs/src/components/api/enhanced-api-page.tsx b/docs/src/components/api/enhanced-api-page.tsx index 13e540603..0093059e2 100644 --- a/docs/src/components/api/enhanced-api-page.tsx +++ b/docs/src/components/api/enhanced-api-page.tsx @@ -110,7 +110,7 @@ const HTTP_METHOD_COLORS = { export function EnhancedAPIPage({ document, operations, description }: EnhancedAPIPageProps) { - const { sharedHeaders, reportError, isHeadersPanelOpen } = useAPIPageContext(); + const { sharedHeaders, reportError } = useAPIPageContext(); const [spec, setSpec] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -392,7 +392,6 @@ export function EnhancedAPIPage({ document, operations, description }: EnhancedA copyToClipboard(text) .catch(error => console.error('Failed to copy to clipboard:', error)); }} - isHeadersPanelOpen={isHeadersPanelOpen} description={description || operation.description} /> ); @@ -411,7 +410,6 @@ function ModernAPIPlayground({ setRequestState, onExecute, onCopy, - isHeadersPanelOpen, description, }: { operation: OpenAPIOperation, @@ -422,7 +420,6 @@ function ModernAPIPlayground({ setRequestState: React.Dispatch>, onExecute: () => void, onCopy: (text: string) => void, - isHeadersPanelOpen: boolean, description?: string, }) { const [copied, setCopied] = useState(false); @@ -587,9 +584,7 @@ function ModernAPIPlayground({ }; return ( -
+
{/* Header Section */}
@@ -618,7 +613,7 @@ function ModernAPIPlayground({ {/* Description */} {description && (
-

+

{description}

@@ -630,7 +625,7 @@ function ModernAPIPlayground({ @@ -158,7 +158,7 @@ const PlatformSelector: React.FC<{ onMouseEnter={() => setHoveredPlatform(platform)} onMouseLeave={() => setHoveredPlatform(null)} className={` - w-full px-4 py-3 text-left transition-all duration-200 + w-full px-4 py-3 text-left border-l-4 border-transparent ${isHighlighted ? "bg-muted/70" : "hover:bg-muted/30"} `} @@ -169,7 +169,7 @@ const PlatformSelector: React.FC<{ >
{isSelected && (
)} {isHovered && !isSelected && (
)}
-
{platform === "next" && "Full-stack React framework"} @@ -227,6 +227,15 @@ const DocsIcon3D: React.FC = ({ } }; + // Helper function to convert rgb to rgba + const rgbToRgba = (rgb: string, alpha: number) => { + const match = rgb.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); + if (match) { + return `rgba(${match[1]}, ${match[2]}, ${match[3]}, ${alpha})`; + } + return rgb; + }; + return (
= ({ {platformSections.map((section) => (
setHoveredSection(section.id)} onMouseLeave={() => setHoveredSection(null)} onClick={() => handleSectionClick(section)} @@ -251,36 +260,36 @@ const DocsIcon3D: React.FC = ({ bg-card border-[0.5px] border-border rounded-xl p-6 w-full h-40 flex flex-col items-center justify-center shadow-sm hover:shadow-lg - transition-all duration-200 `} style={{ - borderColor: hoveredSection === section.id ? section.color : undefined, + borderColor: hoveredSection === section.id ? section.color : rgbToRgba(section.color, 0.4), }} > {/* Icon Container */}
{React.cloneElement(section.icon as React.ReactElement, { size: 20, - strokeWidth: 2, + strokeWidth: hoveredSection === section.id ? 2.5 : 2, })}
{/* Title */}

{section.title} diff --git a/docs/src/components/layout/custom-search-dialog.tsx b/docs/src/components/layout/custom-search-dialog.tsx index b1a49cafd..19e586f98 100644 --- a/docs/src/components/layout/custom-search-dialog.tsx +++ b/docs/src/components/layout/custom-search-dialog.tsx @@ -52,6 +52,7 @@ function extractBasePathFromUrl(url: string): string { function groupResultsByPage(results: SearchResult[]): GroupedResult[] { const grouped = new Map(); + const groupOrder: string[] = []; // Track the order groups are first encountered for (const result of results) { const platform = extractPlatformFromUrl(result.url); @@ -69,6 +70,9 @@ function groupResultsByPage(results: SearchResult[]): GroupedResult[] { title, results: [] }); + + // Track the order this group was first encountered (preserves relevance order) + groupOrder.push(baseUrl); } const groupedResult = grouped.get(baseUrl); @@ -77,17 +81,9 @@ function groupResultsByPage(results: SearchResult[]): GroupedResult[] { } } - const groupedArray = Array.from(grouped.values()).sort((a, b) => { - // Sort by platform first, then by title - if (a.platform !== b.platform) { - const order = ['next', 'react', 'js', 'python', 'api']; - return order.indexOf(a.platform) - order.indexOf(b.platform); - } - // eslint-disable-next-line no-restricted-syntax - return a.title.localeCompare(b.title); - }); - - return groupedArray; + // Return groups in the order they were first encountered (preserves API scoring order) + // This maintains the relevance ranking from our search API + return groupOrder.map(url => grouped.get(url)!); } function PlatformBadge({ platform }: { platform: string }) { diff --git a/docs/src/components/layout/custom-search-toggle.tsx b/docs/src/components/layout/custom-search-toggle.tsx index 872765b0d..55dbcb442 100644 --- a/docs/src/components/layout/custom-search-toggle.tsx +++ b/docs/src/components/layout/custom-search-toggle.tsx @@ -1,12 +1,12 @@ 'use client'; -import { Search } from 'lucide-react'; +import { Command, Search } from 'lucide-react'; import { useEffect, useState } from 'react'; import { cn } from '../../lib/cn'; function getSearchKey() { - if (typeof window === 'undefined') return ''; - return navigator.platform.toLowerCase().includes('mac') ? '⌘' : 'Ctrl+'; + if (typeof window === 'undefined') return 'mac'; + return navigator.platform.toLowerCase().includes('mac') ? 'mac' : 'ctrl'; } // Compact search button - perfect for navbars @@ -14,7 +14,7 @@ export function CustomSearchToggle({ onOpen, className }: { onOpen: () => void, className?: string, }) { - const [searchKey, setSearchKey] = useState(''); + const [searchKey, setSearchKey] = useState('mac'); useEffect(() => { setSearchKey(getSearchKey()); @@ -43,9 +43,16 @@ export function CustomSearchToggle({ onOpen, className }: { Search... {searchKey && ( -
- - {searchKey}K +
+ + {searchKey === 'mac' ? ( + + ) : ( + 'Ctrl' + )} + + + K
)} @@ -77,7 +84,7 @@ export function CompactSearchToggle({ onOpen, className }: { 'inline-flex h-9 w-9 items-center justify-center rounded-lg text-fd-muted-foreground transition-colors hover:bg-fd-muted hover:text-fd-foreground focus:outline-none focus:ring-2 focus:ring-fd-primary/20', className )} - title="Search documentation (⌘K)" + title="Search documentation" > @@ -89,7 +96,7 @@ export function SearchInputToggle({ onOpen, className }: { onOpen: () => void, className?: string, }) { - const [searchKey, setSearchKey] = useState(''); + const [searchKey, setSearchKey] = useState('mac'); useEffect(() => { setSearchKey(getSearchKey()); @@ -125,9 +132,18 @@ export function SearchInputToggle({ onOpen, className }: { {/* Keyboard shortcut - only shown on larger containers */} {searchKey && ( - - {searchKey}K - +
+ + {searchKey === 'mac' ? ( + + ) : ( + 'Ctrl' + )} + + + K + +
)} ); @@ -138,7 +154,7 @@ export function LargeCustomSearchToggle({ onOpen, className }: { onOpen: () => void, className?: string, }) { - const [searchKey, setSearchKey] = useState(''); + const [searchKey, setSearchKey] = useState('mac'); useEffect(() => { setSearchKey(getSearchKey()); @@ -172,7 +188,14 @@ export function LargeCustomSearchToggle({ onOpen, className }: { {searchKey && (
- {searchKey}K + {searchKey === 'mac' ? ( + + ) : ( + 'Ctrl' + )} + + + K
)} diff --git a/docs/src/components/layout/toc.tsx b/docs/src/components/layout/toc.tsx index 06646d579..e46638549 100644 --- a/docs/src/components/layout/toc.tsx +++ b/docs/src/components/layout/toc.tsx @@ -7,11 +7,10 @@ import { type ComponentProps, type HTMLAttributes, type ReactNode, - useRef, + useRef } from 'react'; import { cn } from '../../lib/cn'; -import { useChatContext } from '../chat/ai-chat'; -import { useTOC } from '../layouts/toc-context'; +import { useSidebar } from '../layouts/sidebar-context'; import { TocThumb } from './toc-thumb'; export type TOCProps = { @@ -30,11 +29,10 @@ export type TOCProps = { export function Toc(props: HTMLAttributes) { const { toc } = usePageStyles(); - const { isTocOpen } = useTOC(); - const { isOpen: isChatOpen } = useChatContext(); + const { isTocOpen } = useSidebar(); - // Hide TOC if not open or if chat is open - if (!isTocOpen || isChatOpen) return null; + // Hide TOC if not open + if (!isTocOpen) return null; return (
getSidebarTabsFromOptions(sidebar.tabs, props.tree) ?? [], @@ -567,12 +564,12 @@ export function DocsLayout({ />, )}
{children}
+ diff --git a/docs/src/components/layouts/home-layout.tsx b/docs/src/components/layouts/home-layout.tsx index ee3f6752e..3de2d83d9 100644 --- a/docs/src/components/layouts/home-layout.tsx +++ b/docs/src/components/layouts/home-layout.tsx @@ -3,10 +3,11 @@ 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 { AIChatDrawer } from '../chat/ai-chat'; import { CustomSearchDialog } from '../layout/custom-search-dialog'; import { SearchInputToggle } from '../layout/custom-search-toggle'; import { ThemeToggle } from '../layout/theme-toggle'; +import { SidebarProvider, useSidebar } from './sidebar-context'; // Discord Icon Component function DiscordIcon({ className }: { className?: string }) { @@ -43,18 +44,34 @@ function StackAuthLogo() { } // AI Chat Toggle Button for Home Layout -function HomeAIChatToggleButton({ compact = false }: { compact?: boolean }) { - const { isOpen, toggleChat } = useChatContext(); +function HomeAIChatToggleButton() { + const { isChatOpen, toggleChat } = useSidebar(); + const [animationVariant, setAnimationVariant] = useState(''); + + // Generate random variant when chat is opened + const handleToggle = () => { + if (!isChatOpen) { + // Generate random variant (2-4, keeping 1 as default) + const variants = ['variant-2', 'variant-3', 'variant-4']; + const randomVariant = variants[Math.floor(Math.random() * variants.length)]; + setAnimationVariant(randomVariant); + } else { + setAnimationVariant(''); + } + toggleChat(); + }; return ( ); } @@ -64,9 +81,7 @@ 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(); + const { isChatOpen, isChatExpanded } = useSidebar(); // Scroll detection useEffect(() => { @@ -89,7 +104,7 @@ function HomeNavbar() { return ( <> {/* Full Navbar */} -
+
{/* Left - Logo + Social Links */}
@@ -183,7 +198,7 @@ function HomeNavbar() {
{/* Compact Pill Navbar */} -
+
{/* Compact Logo */} @@ -238,7 +253,7 @@ function HomeNavbar() { {/* Compact AI Chat Toggle */} - +
@@ -283,11 +298,14 @@ export function HomeLayout({ children }: { children: ReactNode }) { }, []); return ( -
- -
- {children} -
-
+ +
+ +
+ {children} +
+ +
+
); } diff --git a/docs/src/components/layouts/page.tsx b/docs/src/components/layouts/page.tsx index 16bed9bca..b6e2c2f13 100644 --- a/docs/src/components/layouts/page.tsx +++ b/docs/src/components/layouts/page.tsx @@ -24,7 +24,7 @@ import { import { BackToTop } from '../ui/back-to-top'; import { buttonVariants } from '../ui/button'; import { slot } from './shared'; -import { useTOC } from './toc-context'; +import { useSidebar } from './sidebar-context'; const ClerkTOCItems = lazy(() => import('@/components/layout/toc-clerk')); @@ -118,7 +118,7 @@ export function DocsPage({ } = {}, ...props }: DocsPageProps) { - const { setIsFullPage } = useTOC(); + const { setIsFullPage } = useSidebar(); // Update the full page state in the context useEffect(() => { diff --git a/docs/src/components/layouts/shared-header.tsx b/docs/src/components/layouts/shared-header.tsx index 3c2b5f245..b25d4b4be 100644 --- a/docs/src/components/layouts/shared-header.tsx +++ b/docs/src/components/layouts/shared-header.tsx @@ -4,13 +4,13 @@ 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, Sparkles, X } from 'lucide-react'; +import { Key, Menu, Sparkles, TableOfContents, 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'; +import { cn } from '../../lib/cn'; +import { useSidebar } from './sidebar-context'; type SharedHeaderProps = { /** Navigation links to display */ @@ -73,19 +73,41 @@ function isNavLinkActive(pathname: string, navLink: NavLink): boolean { * AI Chat Toggle Button */ function AIChatToggleButton() { - const { isOpen, toggleChat } = useChatContext(); + const sidebarContext = useSidebar(); + const [animationVariant, setAnimationVariant] = useState(''); + + // Return null if context is not available + if (!sidebarContext) { + return null; + } + + const { isChatOpen, toggleChat } = sidebarContext; + + // Generate random variant when chat is opened + const handleToggle = () => { + if (!isChatOpen) { + // Generate random variant (2-4, keeping 1 as default) + const variants = ['variant-2', 'variant-3', 'variant-4']; + const randomVariant = variants[Math.floor(Math.random() * variants.length)]; + setAnimationVariant(randomVariant); + } else { + setAnimationVariant(''); + } + toggleChat(); + }; return ( ); } @@ -94,40 +116,35 @@ function AIChatToggleButton() { * Inner TOC Toggle Button that uses the context */ function TOCToggleButtonInner() { - const { isTocOpen, toggleToc } = useTOC(); - const { isOpen: isChatOpen } = useChatContext(); + const sidebarContext = useSidebar(); - // TOC is effectively visible only if it's open AND chat is not open - const isTocEffectivelyVisible = isTocOpen && !isChatOpen; + // Return null if context is not available + if (!sidebarContext) { + return null; + } - return ( - - ); -} - -/** - * TOC Toggle Button Wrapper that safely checks full page state - */ -function TOCToggleButtonWrapper() { - const { isFullPage } = useTOC(); + const { isTocOpen, toggleToc, isChatOpen, isFullPage } = sidebarContext; // Hide TOC button on full pages if (isFullPage) return null; - return ; + // When chat is open, TOC is effectively not visible + const isTocEffectivelyVisible = isTocOpen && !isChatOpen; + + return ( + + ); } /** @@ -141,12 +158,36 @@ function TOCToggleButton() { if (!isDocsPage) return null; - try { - return ; - } catch { - // TOC context not available + return ; +} + +/** + * Auth Toggle Button - Shows on all pages like AI Chat button + */ +function AuthToggleButton() { + const sidebarContext = useSidebar(); + + // Return null if context is not available + if (!sidebarContext) { return null; } + + const { isAuthOpen, toggleAuth } = sidebarContext; + + return ( + + ); } /** @@ -280,6 +321,11 @@ export function SharedHeader({
+ {/* Auth Toggle Button - Shows on all pages like AI Chat button */} +
+ +
+ {/* AI Chat Toggle Button */}
diff --git a/docs/src/components/layouts/sidebar-context.tsx b/docs/src/components/layouts/sidebar-context.tsx new file mode 100644 index 000000000..74735049e --- /dev/null +++ b/docs/src/components/layouts/sidebar-context.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; + +type SidebarType = 'toc' | 'chat' | 'auth' | null; + +type SidebarContextType = { + // Current active sidebar + activeSidebar: SidebarType, + + // TOC state + isTocOpen: boolean, + setTocOpen: (open: boolean) => void, + toggleToc: () => void, + + // Chat state + isChatOpen: boolean, + setChatOpen: (open: boolean) => void, + toggleChat: () => void, + + // Chat expansion + isChatExpanded: boolean, + setChatExpanded: (expanded: boolean) => void, + + // Auth state + isAuthOpen: boolean, + setAuthOpen: (open: boolean) => void, + toggleAuth: () => void, + + // Full page state + isFullPage: boolean, + setIsFullPage: (fullPage: boolean) => void, + + // Unified controls + openSidebar: (type: SidebarType) => void, + closeSidebar: () => void, +} + +const SidebarContext = createContext(null); + +export function useSidebar() { + const context = useContext(SidebarContext); + return context; +} + +export function SidebarProvider({ children }: { children: ReactNode }) { + const [activeSidebar, setActiveSidebar] = useState(null); + const [isChatExpanded, setIsChatExpanded] = useState(false); + const [isFullPage, setIsFullPage] = useState(false); + + // Load state from localStorage on mount + useEffect(() => { + const savedChat = localStorage.getItem('ai-chat-open'); + const savedExpanded = localStorage.getItem('ai-chat-expanded'); + + if (savedChat === 'true') { + setActiveSidebar('chat'); + } + if (savedExpanded === 'true') { + setIsChatExpanded(true); + } + }, []); + + // Manage body classes based on sidebar state + useEffect(() => { + // Remove all classes first + document.body.classList.remove('chat-open', 'toc-open', 'auth-open'); + + // Add appropriate class based on active sidebar + if (activeSidebar === 'chat') { + document.body.classList.add('chat-open'); + } else if (activeSidebar === 'toc') { + document.body.classList.add('toc-open'); + } else if (activeSidebar === 'auth') { + document.body.classList.add('auth-open'); + } + + return () => { + document.body.classList.remove('chat-open', 'toc-open', 'auth-open'); + }; + }, [activeSidebar]); + + // Derived state + const isTocOpen = activeSidebar === 'toc'; + const isChatOpen = activeSidebar === 'chat'; + const isAuthOpen = activeSidebar === 'auth'; + + // Individual controls + const setTocOpen = (open: boolean) => { + setActiveSidebar(open ? 'toc' : null); + }; + + const setChatOpen = (open: boolean) => { + if (open) { + setActiveSidebar('chat'); + localStorage.setItem('ai-chat-open', 'true'); + } else { + setActiveSidebar(null); + localStorage.setItem('ai-chat-open', 'false'); + setIsChatExpanded(false); + } + }; + + const setAuthOpen = (open: boolean) => { + setActiveSidebar(open ? 'auth' : null); + }; + + const toggleToc = () => setTocOpen(!isTocOpen); + const toggleChat = () => setChatOpen(!isChatOpen); + const toggleAuth = () => setAuthOpen(!isAuthOpen); + + const setChatExpanded = (expanded: boolean) => { + setIsChatExpanded(expanded); + localStorage.setItem('ai-chat-expanded', expanded.toString()); + }; + + // Unified controls + const openSidebar = (type: SidebarType) => { + setActiveSidebar(type); + + if (type === 'chat') { + localStorage.setItem('ai-chat-open', 'true'); + } else { + localStorage.setItem('ai-chat-open', 'false'); + } + }; + + const closeSidebar = () => { + setActiveSidebar(null); + localStorage.setItem('ai-chat-open', 'false'); + setIsChatExpanded(false); + }; + + return ( + + {children} + + ); +} diff --git a/docs/src/components/layouts/toc-context.tsx b/docs/src/components/layouts/toc-context.tsx deleted file mode 100644 index 1d2a1ada4..000000000 --- a/docs/src/components/layouts/toc-context.tsx +++ /dev/null @@ -1,40 +0,0 @@ -'use client'; - -import { createContext, useContext, useState, type ReactNode } from 'react'; - -type TOCContextType = { - isTocOpen: boolean, - setIsTocOpen: (open: boolean) => void, - toggleToc: () => void, - isFullPage: boolean, - setIsFullPage: (fullPage: boolean) => void, -} - -const TOCContext = createContext(null); - -export function useTOC() { - const context = useContext(TOCContext); - if (!context) { - throw new Error('useTOC must be used within TOCProvider'); - } - return context; -} - -export function TOCProvider({ children }: { children: ReactNode }) { - const [isTocOpen, setIsTocOpen] = useState(false); // Default closed - const [isFullPage, setIsFullPage] = useState(false); // Default not full page - - const toggleToc = () => setIsTocOpen(!isTocOpen); - - return ( - - {children} - - ); -}