Improved search, improved UI on search, improved icons on homescreen, using sidebar-context for ai chat, contents, and auth button.

This commit is contained in:
Madison 2025-07-03 15:06:29 -05:00
parent daab72c048
commit 38ded51293
22 changed files with 1223 additions and 639 deletions

View File

@ -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 (
<APIPageWrapper>
<SharedContentLayout className="prose prose-neutral dark:prose-invert max-w-none">
<MDX components={getMDXComponents({ EnhancedAPIPage })} />
</SharedContentLayout>
</APIPageWrapper>
<SharedContentLayout className="prose prose-neutral dark:prose-invert max-w-none">
<MDX components={getMDXComponents({ EnhancedAPIPage })} />
</SharedContentLayout>
);
}

View File

@ -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 (
<div className="flex min-h-screen bg-fd-background">
{/* Full-width header with Stack Auth branding */}
<DocsHeaderWrapper
showSearch={false}
className="fixed top-0 left-0 right-0 z-50 h-14 border-b border-fd-border flex items-center justify-between px-4 md:px-6 bg-fd-background"
apiPages={apiPages}
/>
<SidebarProvider>
<APIPageWrapper>
<div className="flex min-h-screen bg-fd-background">
{/* Full-width header with Stack Auth branding */}
<DocsHeaderWrapper
showSearch={false}
className="fixed top-0 left-0 right-0 z-50 h-14 border-b border-fd-border flex items-center justify-between px-4 md:px-6 bg-fd-background"
apiPages={apiPages}
/>
{/* Custom API Sidebar - positioned under header, hidden on mobile */}
<div className="hidden md:block w-64 flex-shrink-0 border-r border-fd-border fixed left-0 top-14 h-[calc(100vh-3.5rem)] z-30">
<ApiSidebar />
</div>
{/* Custom API Sidebar - positioned under header, hidden on mobile */}
<div className="hidden md:block w-64 flex-shrink-0 border-r border-fd-border fixed left-0 top-14 h-[calc(100vh-3.5rem)] z-30">
<ApiSidebar />
</div>
{/* Main content area - full width on mobile, with left margin on desktop, accounting for header */}
<div className="flex-1 flex flex-col min-w-0 md:ml-64 pt-14">
{/* Page content */}
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
</div>
{/* Main content area - full width on mobile, with left margin on desktop, accounting for header */}
<div className="flex-1 flex flex-col min-w-0 md:ml-64 pt-14">
{/* Page content */}
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
{/* AI Chat Drawer */}
<AIChatDrawer />
{/* Auth Panel */}
<AuthPanel />
</div>
</APIPageWrapper>
</SidebarProvider>
);
}

View File

@ -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<string>();
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);

View File

@ -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 (
<TOCProvider>
<SidebarProvider>
<div className="relative">
{/* Docs Header Wrapper - Provides sidebar content to mobile navigation */}
<DocsHeaderWrapper showSearch={true} pageTree={source.pageTree} />
@ -18,6 +18,6 @@ export default function DocsLayout({ children }: { children: React.ReactNode })
</DynamicDocsLayout>
</div>
</div>
</TOCProvider>
</SidebarProvider>
);
}

View File

@ -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;
}

View File

@ -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
}}
>
<ChatProvider>
{children}
</ChatProvider>
{children}
</RootProvider>
</StackTheme>
</StackProvider>

View File

@ -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<string, string>,
updateSharedHeaders: (headers: Record<string, string>) => void,
reportError: (status: number, error: APIError) => void,
isHeadersPanelOpen: boolean,
lastError: { status: number, error: APIError } | null,
highlightMissingHeaders: boolean,
}
const APIPageContext = createContext<APIPageContextType | null>(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<Record<string, string>>(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 (
<APIPageContext.Provider value={{ sharedHeaders, updateSharedHeaders, reportError, isHeadersPanelOpen }}>
<div className="relative flex">
{/* Desktop Sidebar Headers Panel */}
<div className={`hidden md:block fixed right-4 top-4 bottom-4 z-50 transition-all duration-300 ${
isHeadersPanelOpen ? 'w-80' : 'w-auto'
}`}>
<div className="h-full flex flex-col">
{/* Panel Toggle Button */}
<div className="mb-4 flex justify-end">
<Button
onClick={() => setIsHeadersPanelOpen(!isHeadersPanelOpen)}
className={`flex items-center justify-center gap-2 shadow-lg transition-all duration-300 w-24 ${
highlightMissingHeaders
? 'bg-red-500 hover:bg-red-600 text-white animate-pulse'
: 'bg-fd-primary hover:bg-fd-primary/90'
} ${isHeadersPanelOpen ? 'rounded-t-xl rounded-b-none' : 'rounded-t-xl rounded-b-none'}`}
>
<Key className="w-4 h-4 flex-shrink-0" />
<span className="hidden sm:inline text-sm">
{isHeadersPanelOpen ? 'Hide' : 'Auth'}
</span>
{isHeadersPanelOpen ? (
<X className="w-4 h-4 flex-shrink-0" />
) : (
missingRequiredHeaders.length > 0 && (
<div className="w-2 h-2 bg-red-400 rounded-full animate-pulse flex-shrink-0" />
)
)}
</Button>
</div>
{/* Panel Content */}
<div className={`flex-1 ${isHeadersPanelOpen ? '' : 'pointer-events-none'}`}>
{isHeadersPanelOpen && (
<div className="h-full bg-fd-card border border-fd-border rounded-xl shadow-xl overflow-hidden flex flex-col w-80">
{/* Panel Header */}
<div className="p-4 bg-fd-muted/30 border-b border-fd-border">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${
highlightMissingHeaders
? 'bg-red-100 dark:bg-red-900/30'
: 'bg-blue-100 dark:bg-blue-900/30'
}`}>
{highlightMissingHeaders ? (
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400" />
) : (
<Key className="w-4 h-4 text-blue-600 dark:text-blue-400" />
)}
</div>
<div>
<h3 className="font-semibold text-fd-foreground">
{highlightMissingHeaders ? 'Authentication Required' : 'Global Authentication'}
</h3>
<p className="text-sm text-fd-muted-foreground">
{highlightMissingHeaders
? 'Please configure the required headers below'
: 'Configure headers for all API requests'
}
</p>
</div>
</div>
{/* Error Message */}
{highlightMissingHeaders && lastError && (
<div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-center gap-2 text-sm">
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400" />
<span className="text-red-800 dark:text-red-300 font-medium">
{lastError.status} Error - Authentication required
</span>
</div>
{missingRequiredHeaders.length > 0 && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
Missing required headers: {missingRequiredHeaders.map(h => h.label).join(', ')}
</p>
)}
</div>
)}
</div>
{/* Panel Content - Scrollable */}
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
{stackAuthHeaders.map((header) => {
const isMissing = highlightMissingHeaders && header.required && !sharedHeaders[header.key].trim();
return (
<div key={header.key} className={`space-y-2 p-3 rounded-lg transition-all duration-300 ${
isMissing
? 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
: 'bg-fd-muted/20'
}`}>
<label className="text-sm font-medium text-fd-foreground flex items-center gap-2">
{header.label}
{header.required && (
<span className={`text-xs px-2 py-0.5 rounded-full ${
isMissing
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 animate-pulse'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
}`}>
required
</span>
)}
{isMissing && (
<AlertTriangle className="w-3 h-3 text-red-500 animate-pulse" />
)}
</label>
<input
type="text"
placeholder={header.placeholder}
value={sharedHeaders[header.key] || ''}
onChange={(e) => 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'
}`}
/>
</div>
);
})}
</div>
{/* Status Indicator */}
<div className="mt-4 pt-4 border-t border-fd-border">
<div className="flex items-center gap-2 text-sm">
<div className={`w-2 h-2 rounded-full ${
missingRequiredHeaders.length === 0
? 'bg-green-500'
: 'bg-red-500'
}`} />
<span className="text-fd-muted-foreground">
{Object.values(sharedHeaders).filter(v => v.trim()).length} of {stackAuthHeaders.length} headers configured
</span>
</div>
{missingRequiredHeaders.length === 0 && Object.values(sharedHeaders).some(v => v.trim()) && (
<div className="flex items-center gap-2 text-sm mt-1">
<div className="w-2 h-2 rounded-full bg-green-500" />
<span className="text-green-600 dark:text-green-400 text-xs">
Ready to make authenticated requests
</span>
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
</div>
{/* Mobile Floating Button - only show when overlay is closed */}
{!isHeadersPanelOpen && (
<div className="md:hidden fixed bottom-6 right-6 z-[100]">
<Button
onClick={() => setIsHeadersPanelOpen(!isHeadersPanelOpen)}
className={`flex items-center justify-center gap-2 shadow-2xl transition-all duration-300 w-14 h-14 rounded-full border-2 border-white/20 ${
highlightMissingHeaders
? 'bg-red-500 hover:bg-red-600 text-white animate-pulse'
: 'bg-fd-primary hover:bg-fd-primary/90 text-white'
}`}
>
<Key className="w-5 h-5 flex-shrink-0" />
{missingRequiredHeaders.length > 0 && (
<div className="absolute -top-1 -right-1 w-3 h-3 bg-red-400 rounded-full animate-pulse flex-shrink-0" />
)}
</Button>
</div>
)}
{/* Mobile Full-Screen Overlay */}
{isHeadersPanelOpen && (
<div className="md:hidden fixed inset-0 z-50 flex flex-col bg-fd-background">
{/* Mobile Header */}
<div className="flex items-center justify-between p-4 border-b border-fd-border bg-fd-card">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${
highlightMissingHeaders
? 'bg-red-100 dark:bg-red-900/30'
: 'bg-blue-100 dark:bg-blue-900/30'
}`}>
{highlightMissingHeaders ? (
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400" />
) : (
<Key className="w-4 h-4 text-blue-600 dark:text-blue-400" />
)}
</div>
<div>
<h3 className="font-semibold text-fd-foreground">
{highlightMissingHeaders ? 'Authentication Required' : 'API Authentication'}
</h3>
<p className="text-sm text-fd-muted-foreground">
Configure headers for requests
</p>
</div>
</div>
<Button
onClick={() => setIsHeadersPanelOpen(false)}
>
<X className="w-5 h-5" />
</Button>
</div>
{/* Error Message - Mobile */}
{highlightMissingHeaders && lastError && (
<div className="mx-4 mt-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-center gap-2 text-sm">
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400" />
<span className="text-red-800 dark:text-red-300 font-medium">
{lastError.status} Error - Authentication required
</span>
</div>
{missingRequiredHeaders.length > 0 && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
Missing required headers: {missingRequiredHeaders.map(h => h.label).join(', ')}
</p>
)}
</div>
)}
{/* Mobile Content - Scrollable */}
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
{stackAuthHeaders.map((header) => {
const isMissing = highlightMissingHeaders && header.required && !sharedHeaders[header.key].trim();
return (
<div key={header.key} className={`space-y-2 p-4 rounded-lg transition-all duration-300 ${
isMissing
? 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
: 'bg-fd-card border border-fd-border'
}`}>
<label className="text-sm font-medium text-fd-foreground flex items-center gap-2">
{header.label}
{header.required && (
<span className={`text-xs px-2 py-0.5 rounded-full ${
isMissing
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 animate-pulse'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
}`}>
required
</span>
)}
{isMissing && (
<AlertTriangle className="w-3 h-3 text-red-500 animate-pulse" />
)}
</label>
<input
type="text"
placeholder={header.placeholder}
value={sharedHeaders[header.key] || ''}
onChange={(e) => 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'
}`}
/>
</div>
);
})}
</div>
</div>
{/* Mobile Footer with Status */}
<div className="border-t border-fd-border p-4 bg-fd-card">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm">
<div className={`w-2 h-2 rounded-full ${
missingRequiredHeaders.length === 0
? 'bg-green-500'
: 'bg-red-500'
}`} />
<span className="text-fd-muted-foreground">
{Object.values(sharedHeaders).filter(v => v.trim()).length} of {stackAuthHeaders.length} configured
</span>
</div>
<Button
onClick={() => setIsHeadersPanelOpen(false)}
>
Done
</Button>
</div>
{missingRequiredHeaders.length === 0 && Object.values(sharedHeaders).some(v => v.trim()) && (
<div className="flex items-center gap-2 text-sm mt-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<span className="text-green-600 dark:text-green-400 text-xs">
Ready to make authenticated requests
</span>
</div>
)}
</div>
</div>
)}
{/* Main Content - Desktop gets margin, Mobile stays full width */}
<div className={`flex-1 transition-all duration-300 ${
isHeadersPanelOpen ? 'md:mr-80' : 'mr-0'
}`}>
{children}
</div>
</div>
<APIPageContext.Provider value={{ sharedHeaders, updateSharedHeaders, reportError, lastError, highlightMissingHeaders }}>
{children}
</APIPageContext.Provider>
);
}

View File

@ -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 */}
<div
className={`hidden md:block fixed ${topPosition} right-0 ${height} bg-fd-background border-l border-fd-border flex flex-col transition-all duration-300 ease-out z-50 w-96 ${
isAuthOpen ? 'translate-x-0' : 'translate-x-full'
}`}
>
{/* Header - Matching AIChatDrawer */}
<div className="flex items-center justify-between p-3 border-b border-fd-border bg-fd-background">
<div className="flex items-center gap-2">
<div className={`w-5 h-5 rounded flex items-center justify-center ${
highlightMissingHeaders
? 'bg-red-100 dark:bg-red-900/30 auth-error-pulse'
: 'bg-blue-100 dark:bg-blue-900/30'
}`}>
{highlightMissingHeaders ? (
<AlertTriangle className="w-3 h-3 text-red-600 dark:text-red-400" />
) : (
<Key className="w-3 h-3 text-blue-600 dark:text-blue-400" />
)}
</div>
<div>
<h3 className="font-medium text-fd-foreground text-sm">
{highlightMissingHeaders ? 'Authentication Required' : 'API Authentication'}
</h3>
<p className="text-xs text-fd-muted-foreground">
Configure headers for requests
</p>
</div>
</div>
<button
onClick={toggleAuth}
className="p-1 text-fd-muted-foreground hover:text-fd-foreground hover:bg-fd-muted rounded transition-colors"
title="Close auth panel"
>
<X className="w-3 h-3" />
</button>
</div>
{/* Error Message - Reserve space to prevent layout shifts */}
<div className="mx-3 mt-3 h-auto">
{highlightMissingHeaders && lastError ? (
<div className="p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md auth-error-pulse">
<div className="flex items-center gap-2 text-xs">
<AlertTriangle className="w-3 h-3 text-red-600 dark:text-red-400 flex-shrink-0" />
<span className="text-red-800 dark:text-red-300 font-medium">
{lastError.status} Error - Authentication required
</span>
</div>
{missingRequiredHeaders.length > 0 && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
Missing: {missingRequiredHeaders.map(h => h.label).join(', ')}
</p>
)}
</div>
) : null}
</div>
{/* Content - Fixed height to prevent layout shifts */}
<div className="flex-1 min-h-0">
<div className="h-full overflow-y-auto p-3 space-y-3">
{stackAuthHeaders.map((header) => {
const isMissing = highlightMissingHeaders && header.required && !sharedHeaders[header.key].trim();
return (
<div key={header.key} className={`space-y-1.5 ${
isMissing ? 'p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md auth-error-pulse' : ''
}`}>
<label className="text-xs font-medium text-fd-foreground flex items-center gap-2">
{header.label}
{header.required && (
<span className={`text-xs px-1.5 py-0.5 rounded text-xs ${
isMissing
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
}`}>
required
</span>
)}
{isMissing && (
<AlertTriangle className="w-3 h-3 text-red-500" />
)}
</label>
<input
type="text"
placeholder={header.placeholder}
value={sharedHeaders[header.key] || ''}
onChange={(e) => 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'
}`}
/>
</div>
);
})}
</div>
</div>
{/* Footer Status */}
<div className="border-t border-fd-border p-3">
<div className="flex items-center gap-2 text-xs">
<div className={`w-2 h-2 rounded-full ${
missingRequiredHeaders.length === 0 ? 'bg-green-500' : 'bg-red-500 auth-error-pulse'
}`} />
<span className="text-fd-muted-foreground">
{Object.values(sharedHeaders).filter(v => v.trim()).length} of {stackAuthHeaders.length} configured
</span>
</div>
{missingRequiredHeaders.length === 0 && Object.values(sharedHeaders).some(v => v.trim()) && (
<div className="flex items-center gap-2 text-xs mt-1">
<div className="w-2 h-2 rounded-full bg-green-500" />
<span className="text-green-600 dark:text-green-400">
Ready for API requests
</span>
</div>
)}
</div>
</div>
{/* Mobile Auth Panel */}
<div className="md:hidden fixed inset-0 z-50 flex flex-col bg-fd-background">
{/* Mobile 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">
<div className={`w-5 h-5 rounded flex items-center justify-center ${
highlightMissingHeaders
? 'bg-red-100 dark:bg-red-900/30 auth-error-pulse'
: 'bg-blue-100 dark:bg-blue-900/30'
}`}>
{highlightMissingHeaders ? (
<AlertTriangle className="w-3 h-3 text-red-600 dark:text-red-400" />
) : (
<Key className="w-3 h-3 text-blue-600 dark:text-blue-400" />
)}
</div>
<div>
<h3 className="font-medium text-fd-foreground text-sm">
{highlightMissingHeaders ? 'Authentication Required' : 'API Authentication'}
</h3>
<p className="text-xs text-fd-muted-foreground">
Configure headers for requests
</p>
</div>
</div>
<button
onClick={toggleAuth}
className="p-1 text-fd-muted-foreground hover:text-fd-foreground hover:bg-fd-muted rounded transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Error Message - Mobile */}
<div className="mx-3 mt-3 h-auto">
{highlightMissingHeaders && lastError ? (
<div className="p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md auth-error-pulse">
<div className="flex items-center gap-2 text-xs">
<AlertTriangle className="w-3 h-3 text-red-600 dark:text-red-400" />
<span className="text-red-800 dark:text-red-300 font-medium">
{lastError.status} Error - Authentication required
</span>
</div>
{missingRequiredHeaders.length > 0 && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
Missing: {missingRequiredHeaders.map(h => h.label).join(', ')}
</p>
)}
</div>
) : null}
</div>
{/* Mobile Content - Fixed height to prevent layout shifts */}
<div className="flex-1 min-h-0">
<div className="h-full overflow-y-auto p-3">
<div className="space-y-3">
{stackAuthHeaders.map((header) => {
const isMissing = highlightMissingHeaders && header.required && !sharedHeaders[header.key].trim();
return (
<div key={header.key} className={`space-y-1.5 ${
isMissing ? 'p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md auth-error-pulse' : ''
}`}>
<label className="text-sm font-medium text-fd-foreground flex items-center gap-2">
{header.label}
{header.required && (
<span className={`text-xs px-1.5 py-0.5 rounded ${
isMissing
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
}`}>
required
</span>
)}
{isMissing && (
<AlertTriangle className="w-3 h-3 text-red-500" />
)}
</label>
<input
type="text"
placeholder={header.placeholder}
value={sharedHeaders[header.key] || ''}
onChange={(e) => 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'
}`}
/>
</div>
);
})}
</div>
</div>
</div>
{/* Mobile Footer */}
<div className="border-t border-fd-border p-3 bg-fd-background">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs">
<div className={`w-2 h-2 rounded-full ${
missingRequiredHeaders.length === 0 ? 'bg-green-500' : 'bg-red-500 auth-error-pulse'
}`} />
<span className="text-fd-muted-foreground">
{Object.values(sharedHeaders).filter(v => v.trim()).length} of {stackAuthHeaders.length} configured
</span>
</div>
<Button onClick={toggleAuth} className="text-xs px-3 py-1">
Done
</Button>
</div>
{missingRequiredHeaders.length === 0 && Object.values(sharedHeaders).some(v => v.trim()) && (
<div className="flex items-center gap-2 text-xs mt-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<span className="text-green-600 dark:text-green-400">
Ready for API requests
</span>
</div>
)}
</div>
</div>
</>
);
}

View File

@ -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<OpenAPISpec | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<React.SetStateAction<RequestState>>,
onExecute: () => void,
onCopy: (text: string) => void,
isHeadersPanelOpen: boolean,
description?: string,
}) {
const [copied, setCopied] = useState(false);
@ -587,9 +584,7 @@ function ModernAPIPlayground({
};
return (
<div className={`max-w-6xl mx-auto px-6 py-8 transition-all duration-200 ${
isHeadersPanelOpen ? 'pr-8' : ''
}`}>
<div className="pb-8">
{/* Header Section */}
<div className="mb-8 border-b border-fd-border pb-8">
<div className="flex items-start justify-between gap-8">
@ -618,7 +613,7 @@ function ModernAPIPlayground({
{/* Description */}
{description && (
<div className="mt-6">
<p className="text-fd-muted-foreground text-base leading-relaxed max-w-3xl">
<p className="text-fd-muted-foreground text-base leading-relaxed">
{description}
</p>
</div>
@ -630,7 +625,7 @@ function ModernAPIPlayground({
<Button
onClick={onExecute}
disabled={requestState.response.loading}
className="px-6 py-3 bg-fd-primary text-fd-primary-foreground font-semibold rounded-lg border-0 shadow-sm hover:shadow-md transition-all duration-200"
className="w-[140px] py-3 bg-fd-primary text-fd-primary-foreground font-semibold rounded-lg border-0 shadow-sm hover:shadow-md transition-all duration-200 flex items-center justify-center"
>
{requestState.response.loading ? (
<>

View File

@ -3,7 +3,6 @@
import { ArrowRight, Check, Code, Copy, Sparkles, Webhook } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { codeToHtml } from 'shiki';
import { useAPIPageContext } from './api-page-wrapper';
import { Button } from './button';
// Types for OpenAPI specification (focused on webhooks)
@ -82,7 +81,6 @@ type WebhooksAPIPageProps = {
}
export function WebhooksAPIPage({ document, webhooks, description }: WebhooksAPIPageProps) {
const { isHeadersPanelOpen } = useAPIPageContext();
const [spec, setSpec] = useState<OpenAPISpec | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -172,7 +170,6 @@ export function WebhooksAPIPage({ document, webhooks, description }: WebhooksAPI
copyToClipboard(text)
.catch(error => console.error('Failed to copy to clipboard:', error));
}}
isHeadersPanelOpen={isHeadersPanelOpen}
description={description || webhook.description}
/>
);
@ -188,7 +185,6 @@ function ModernWebhookDisplay({
method,
spec,
onCopy,
isHeadersPanelOpen,
description,
}: {
webhook: OpenAPIWebhookOperation,
@ -196,7 +192,6 @@ function ModernWebhookDisplay({
method: string,
spec: OpenAPISpec,
onCopy: (text: string) => void,
isHeadersPanelOpen: boolean,
description?: string,
}) {
const [copied, setCopied] = useState(false);
@ -419,9 +414,7 @@ def handle_webhook():
}, [activeCodeTab, getCodeExample]);
return (
<div className={`max-w-6xl mx-auto px-6 py-8 transition-all duration-200 ${
isHeadersPanelOpen ? 'pr-8' : ''
}`}>
<div className="max-w-6xl mx-auto px-6 py-8">
{/* Header Section */}
<div className="mb-8 border-b border-fd-border pb-8">
<div className="flex items-start justify-between gap-8">
@ -450,7 +443,7 @@ def handle_webhook():
{/* Description */}
{description && (
<div className="mt-6">
<p className="text-fd-muted-foreground text-base leading-relaxed max-w-3xl">
<p className="text-fd-muted-foreground text-base leading-relaxed">
{description}
</p>
</div>

View File

@ -2,7 +2,8 @@
import { useChat } from '@ai-sdk/react';
import { Maximize2, Minimize2, Send, X } from 'lucide-react';
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { useEffect, useState } from 'react';
import { useSidebar } from '../layouts/sidebar-context';
import { MessageFormatter } from './message-formatter';
// Stack Auth Icon Component (just the icon, not full logo)
@ -21,92 +22,45 @@ function StackIcon({ size = 20, className }: { size?: number, className?: string
);
}
// Chat Context
type ChatContextType = {
isOpen: boolean,
isExpanded: boolean,
toggleChat: () => void,
expandChat: () => void,
collapseChat: () => void,
};
export function AIChatDrawer() {
const { isChatOpen, isChatExpanded, toggleChat, setChatExpanded } = useSidebar();
const [docsContent, setDocsContent] = useState('');
const [isHomePage, setIsHomePage] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
type ChatProviderProps = {
children: ReactNode,
};
const ChatContext = createContext<ChatContextType | undefined>(undefined);
export function ChatProvider({ children }: ChatProviderProps) {
const [isOpen, setIsOpen] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
// Load state from localStorage on mount
// Detect if we're on homepage and scroll state
useEffect(() => {
const savedIsOpen = localStorage.getItem('ai-chat-open');
const savedIsExpanded = localStorage.getItem('ai-chat-expanded');
const checkHomePage = () => {
setIsHomePage(document.body.classList.contains('home-page'));
};
if (savedIsOpen === 'true') {
setIsOpen(true);
}
if (savedIsExpanded === 'true') {
setIsExpanded(true);
}
}, []);
const checkScrolled = () => {
setIsScrolled(document.body.classList.contains('scrolled'));
};
// Save state to localStorage when it changes
useEffect(() => {
localStorage.setItem('ai-chat-open', isOpen.toString());
}, [isOpen]);
// Initial check
checkHomePage();
checkScrolled();
useEffect(() => {
localStorage.setItem('ai-chat-expanded', isExpanded.toString());
}, [isExpanded]);
// Set up observers for class changes
const observer = new MutationObserver(() => {
checkHomePage();
checkScrolled();
});
// 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
}
observer.observe(document.body, {
attributes: true,
attributeFilter: ['class']
});
return () => {
document.body.classList.remove('chat-open');
observer.disconnect();
};
}, [isOpen]);
}, []);
const toggleChat = () => setIsOpen(!isOpen);
const expandChat = () => setIsExpanded(true);
const collapseChat = () => setIsExpanded(false);
const value = {
isOpen,
isExpanded,
toggleChat,
expandChat,
collapseChat,
};
return (
<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 [docsContent, setDocsContent] = useState('');
// Calculate position based on homepage and scroll state
const topPosition = isHomePage && isScrolled ? 'top-0' : 'top-14';
const height = isHomePage && isScrolled ? 'h-screen' : 'h-[calc(100vh-3.5rem)]';
// Fetch documentation content when component mounts
useEffect(() => {
@ -177,10 +131,10 @@ function AIChatDrawer() {
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'
className={`fixed ${topPosition} right-0 ${height} bg-fd-background border-l border-fd-border flex flex-col transition-all duration-300 ease-out z-50 ${
isChatExpanded ? 'w-[70vw] z-[70]' : 'w-96'
} ${
isOpen ? 'translate-x-0' : 'translate-x-full'
isChatOpen ? 'translate-x-0' : 'translate-x-full'
}`}
>
{/* Header */}
@ -195,11 +149,11 @@ function AIChatDrawer() {
<div className="flex items-center gap-1">
{/* Expand/Collapse Button */}
<button
onClick={isExpanded ? collapseChat : expandChat}
onClick={() => setChatExpanded(!isChatExpanded)}
className="p-1 text-fd-muted-foreground hover:text-fd-foreground hover:bg-fd-muted rounded transition-colors"
title={isExpanded ? 'Collapse chat' : 'Expand chat'}
title={isChatExpanded ? 'Collapse chat' : 'Expand chat'}
>
{isExpanded ? (
{isChatExpanded ? (
<Minimize2 className="w-3 h-3" />
) : (
<Maximize2 className="w-3 h-3" />

View File

@ -189,7 +189,7 @@ function renderNode(node: MessageNode, index: number): React.ReactNode {
case 'list': {
return (
<ul key={index} className="list-disc list-inside mb-3 space-y-1">
<ul key={index} className="mb-3 space-y-1 ml-3">
{node.children?.map(renderNode)}
</ul>
);
@ -197,7 +197,7 @@ function renderNode(node: MessageNode, index: number): React.ReactNode {
case 'listItem': {
return (
<li key={index} className="text-xs">
<li key={index} className="text-xs relative pl-3 before:content-['•'] before:absolute before:left-0 before:text-fd-muted-foreground">
{node.children?.map(renderNode)}
</li>
);

View File

@ -121,7 +121,7 @@ const PlatformSelector: React.FC<{
<div className="relative inline-block w-64 mx-auto" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between px-4 py-3 bg-background border-2 border-border rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 hover:-translate-y-1"
className="w-full flex items-center justify-between px-4 py-3 bg-background border-2 border-border rounded-xl shadow-lg hover:shadow-xl hover:-translate-y-1"
style={{
borderColor: platformColors[selectedPlatform],
boxShadow: `0 4px 20px ${platformColors[selectedPlatform]}20`,
@ -135,7 +135,7 @@ const PlatformSelector: React.FC<{
</span>
<ChevronDown
size={20}
className={`transform transition-transform duration-300 ${isOpen ? "rotate-180" : ""}`}
className={`transform ${isOpen ? "rotate-180" : ""}`}
style={{ color: platformColors[selectedPlatform] }}
/>
</button>
@ -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<{
>
<div className="flex items-center justify-between">
<span
className={`font-medium transition-all duration-200 ${
className={`font-medium ${
isHighlighted ? "font-semibold" : ""
}`}
style={{
@ -180,18 +180,18 @@ const PlatformSelector: React.FC<{
</span>
{isSelected && (
<div
className="w-2 h-2 rounded-full transition-all duration-200"
className="w-2 h-2 rounded-full"
style={{ backgroundColor: platformColors[platform] }}
/>
)}
{isHovered && !isSelected && (
<div
className="w-1.5 h-1.5 rounded-full transition-all duration-200"
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: platformColors[platform], opacity: 0.6 }}
/>
)}
</div>
<div className={`text-xs mt-1 transition-all duration-200 ${
<div className={`text-xs mt-1 ${
isHighlighted ? "text-muted-foreground" : "text-muted-foreground/70"
}`}>
{platform === "next" && "Full-stack React framework"}
@ -227,6 +227,15 @@ const DocsIcon3D: React.FC<DocsIcon3DProps> = ({
}
};
// 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 (
<div className="flex justify-center items-center p-4">
<div
@ -241,7 +250,7 @@ const DocsIcon3D: React.FC<DocsIcon3DProps> = ({
{platformSections.map((section) => (
<div
key={section.id}
className="cursor-pointer group transform transition-all duration-200 hover:scale-105"
className="cursor-pointer group transform hover:scale-105"
onMouseEnter={() => setHoveredSection(section.id)}
onMouseLeave={() => setHoveredSection(null)}
onClick={() => handleSectionClick(section)}
@ -251,36 +260,36 @@ const DocsIcon3D: React.FC<DocsIcon3DProps> = ({
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 */}
<div className="mb-4">
<div
className={`
w-12 h-12 rounded-lg flex items-center justify-center
transition-all duration-200
w-12 h-12 rounded-lg flex items-center justify-center
`}
style={{
backgroundColor: hoveredSection === section.id ? section.color : `${section.color}20`,
color: hoveredSection === section.id ? 'white' : 'hsl(var(--foreground))',
backgroundColor: hoveredSection === section.id ? section.color : rgbToRgba(section.color, 0.2),
color: hoveredSection === section.id ? 'white' : section.color,
transform: hoveredSection === section.id ? 'scale(1.1)' : 'scale(1)',
}}
>
{React.cloneElement(section.icon as React.ReactElement, {
size: 20,
strokeWidth: 2,
strokeWidth: hoveredSection === section.id ? 2.5 : 2,
})}
</div>
</div>
{/* Title */}
<h3
className="text-sm font-semibold mb-2 text-center transition-all duration-200"
className="text-sm font-semibold mb-2 text-center"
style={{
color: hoveredSection === section.id ? section.color : 'hsl(var(--card-foreground))',
color: section.color,
transform: hoveredSection === section.id ? 'scale(1.05)' : 'scale(1)',
}}
>
{section.title}

View File

@ -52,6 +52,7 @@ function extractBasePathFromUrl(url: string): string {
function groupResultsByPage(results: SearchResult[]): GroupedResult[] {
const grouped = new Map<string, GroupedResult>();
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 }) {

View File

@ -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 className="h-4 w-4 transition-colors" />
<span className="hidden sm:inline">Search...</span>
{searchKey && (
<div className="hidden md:flex items-center">
<kbd className="ml-1 inline-flex h-5 min-w-[20px] items-center justify-center rounded border border-fd-border/60 bg-fd-muted/50 px-1 font-mono text-[11px] font-medium text-fd-muted-foreground/80 transition-colors group-hover:border-fd-border group-hover:bg-fd-muted group-hover:text-fd-muted-foreground">
{searchKey}K
<div className="hidden md:flex items-center gap-1">
<kbd className="inline-flex h-5 min-w-[20px] items-center justify-center rounded border border-fd-border/60 bg-fd-muted/50 px-1 font-mono text-[11px] font-medium text-fd-muted-foreground/80 transition-colors group-hover:border-fd-border group-hover:bg-fd-muted group-hover:text-fd-muted-foreground">
{searchKey === 'mac' ? (
<Command className="h-3 w-3" />
) : (
'Ctrl'
)}
</kbd>
<kbd className="inline-flex h-5 w-5 items-center justify-center rounded border border-fd-border/60 bg-fd-muted/50 font-mono text-[11px] font-medium text-fd-muted-foreground/80 transition-colors group-hover:border-fd-border group-hover:bg-fd-muted group-hover:text-fd-muted-foreground">
K
</kbd>
</div>
)}
@ -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"
>
<Search className="h-4 w-4" />
</button>
@ -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 }: {
</span>
{/* Keyboard shortcut - only shown on larger containers */}
{searchKey && (
<kbd className="hidden md:inline-flex h-6 min-w-[32px] items-center justify-center rounded-md border border-fd-border/60 bg-fd-muted/50 px-2 font-mono text-xs font-semibold text-fd-muted-foreground/90 transition-colors group-hover:border-fd-border group-hover:bg-fd-muted group-hover:text-fd-muted-foreground ml-auto">
{searchKey}K
</kbd>
<div className="hidden md:flex items-center gap-1 ml-auto">
<kbd className="inline-flex h-6 min-w-[24px] items-center justify-center rounded-md border border-fd-border/60 bg-fd-muted/50 px-1.5 font-mono text-xs font-semibold text-fd-muted-foreground/90 transition-colors group-hover:border-fd-border group-hover:bg-fd-muted group-hover:text-fd-muted-foreground">
{searchKey === 'mac' ? (
<Command className="h-3.5 w-3.5" />
) : (
'Ctrl'
)}
</kbd>
<kbd className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-fd-border/60 bg-fd-muted/50 font-mono text-xs font-semibold text-fd-muted-foreground/90 transition-colors group-hover:border-fd-border group-hover:bg-fd-muted group-hover:text-fd-muted-foreground">
K
</kbd>
</div>
)}
</button>
);
@ -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 && (
<div className="flex items-center gap-1">
<kbd className="inline-flex h-7 min-w-[28px] items-center justify-center rounded-md border border-fd-border/60 bg-fd-muted/50 px-2 font-mono text-xs font-medium text-fd-muted-foreground/80 transition-colors group-hover:border-fd-border group-hover:bg-fd-muted group-hover:text-fd-muted-foreground">
{searchKey}K
{searchKey === 'mac' ? (
<Command className="h-4 w-4" />
) : (
'Ctrl'
)}
</kbd>
<kbd className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-fd-border/60 bg-fd-muted/50 font-mono text-xs font-medium text-fd-muted-foreground/80 transition-colors group-hover:border-fd-border group-hover:bg-fd-muted group-hover:text-fd-muted-foreground">
K
</kbd>
</div>
)}

View File

@ -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<HTMLDivElement>) {
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 (
<div

View File

@ -46,6 +46,7 @@ import { usePathname } from 'next/navigation';
import { createContext, type HTMLAttributes, type ReactNode, useContext, useMemo, useRef, useState } from 'react';
import { cn } from '../../lib/cn';
import { getCurrentPlatform } from '../../lib/platform-utils';
import { AIChatDrawer } from '../chat/ai-chat';
import { CustomSearchDialog } from '../layout/custom-search-dialog';
import {
SearchInputToggle
@ -81,10 +82,8 @@ import {
isInComponentsSection,
isInSdkSection
} 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 = {
@ -473,9 +472,7 @@ export function DocsLayout({
children,
...props
}: DocsLayoutProps): ReactNode {
const { isTocOpen } = useTOC();
const [searchOpen, setSearchOpen] = useState(false);
const { isOpen: isChatOpen } = useChatContext();
const tabs = useMemo(
() => getSidebarTabsFromOptions(sidebar.tabs, props.tree) ?? [],
@ -567,12 +564,12 @@ export function DocsLayout({
/>,
)}
<div className={cn(
'flex-1 transition-all duration-300 min-w-0',
isTocOpen && !isChatOpen && 'xl:mr-72'
'flex-1 transition-all duration-300 min-w-0'
)}>
<StylesProvider {...pageStyles}>{children}</StylesProvider>
</div>
</main>
<AIChatDrawer />
</NavProvider>
</TreeContextProvider>
</AccordionProvider>

View File

@ -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 (
<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'}
onClick={handleToggle}
className={`flex items-center justify-center transition-all duration-500 ease-out w-8 h-8 rounded-lg text-sm font-medium relative overflow-hidden ${
isChatOpen
? `text-white chat-gradient-active ${animationVariant}`
: 'bg-fd-muted text-fd-muted-foreground hover:text-fd-foreground hover:bg-fd-muted/80'
}`}
title={isChatOpen ? 'Close AI chat' : 'Open AI chat'}
>
<Sparkles className={compact ? 'h-3.5 w-3.5' : 'h-4 w-4'} />
<Sparkles className="h-4 w-4 relative z-10" />
</button>
);
}
@ -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 */}
<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]' : ''}`}>
<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-[80]' : ''}`}>
<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">
@ -183,7 +198,7 @@ function HomeNavbar() {
</header>
{/* Compact Pill Navbar */}
<div className={`fixed top-4 left-1/2 -translate-x-1/2 z-50 transition-all duration-300 ${isScrolled ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4 pointer-events-none'}`}>
<div className={`fixed top-4 left-1/2 -translate-x-1/2 transition-all duration-300 ${isScrolled ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4 pointer-events-none'} ${isChatExpanded ? 'z-[80]' : 'z-50'}`}>
<div className="flex items-center gap-2 px-4 py-2 bg-fd-background/95 backdrop-blur border border-fd-border rounded-full shadow-lg supports-[backdrop-filter]:bg-fd-background/80">
{/* Compact Logo */}
<Link href="https://stack-auth.com" className="flex items-center gap-2 text-fd-foreground hover:text-fd-foreground/80 transition-colors">
@ -238,7 +253,7 @@ function HomeNavbar() {
<ThemeToggle compact />
{/* Compact AI Chat Toggle */}
<HomeAIChatToggleButton compact />
<HomeAIChatToggleButton />
</div>
</div>
</div>
@ -283,11 +298,14 @@ export function HomeLayout({ children }: { children: ReactNode }) {
}, []);
return (
<div className="relative flex min-h-screen flex-col bg-fd-background">
<HomeNavbar />
<main className="flex-1">
{children}
</main>
</div>
<SidebarProvider>
<div className="relative flex min-h-screen flex-col bg-fd-background">
<HomeNavbar />
<main className="flex-1">
{children}
</main>
<AIChatDrawer />
</div>
</SidebarProvider>
);
}

View File

@ -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(() => {

View File

@ -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 (
<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'}
className={cn(
'flex items-center justify-center rounded-md w-8 h-8 text-xs transition-all duration-500 ease-out relative overflow-hidden',
isChatOpen
? `text-white chat-gradient-active ${animationVariant}`
: 'text-fd-muted-foreground hover:text-fd-foreground hover:bg-fd-muted/50'
)}
onClick={handleToggle}
title="AI Chat"
>
<Sparkles className="w-4 h-4 flex-shrink-0" />
<Sparkles className="h-4 w-4 relative z-10" />
</button>
);
}
@ -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 (
<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 ${
isTocEffectivelyVisible
? 'bg-fd-foreground text-fd-background'
: 'bg-fd-muted text-fd-muted-foreground hover:text-fd-foreground hover:bg-fd-muted/80'
}`}
title={isTocEffectivelyVisible ? 'Hide table of contents' : 'Show table of contents'}
>
<List className="w-4 h-4 flex-shrink-0" />
<span className="hidden sm:inline">
{isTocEffectivelyVisible ? 'Hide' : 'TOC'}
</span>
</button>
);
}
/**
* 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 <TOCToggleButtonInner />;
// When chat is open, TOC is effectively not visible
const isTocEffectivelyVisible = isTocOpen && !isChatOpen;
return (
<button
className={cn(
'flex items-center gap-2 rounded-md px-2 py-1 text-xs transition-colors',
isTocEffectivelyVisible
? 'bg-fd-primary/10 text-fd-primary hover:bg-fd-primary/20'
: 'text-fd-muted-foreground hover:text-fd-foreground hover:bg-fd-muted/50'
)}
onClick={toggleToc}
>
<TableOfContents className="h-3 w-3" />
<span className="font-medium">Contents</span>
</button>
);
}
/**
@ -141,12 +158,36 @@ function TOCToggleButton() {
if (!isDocsPage) return null;
try {
return <TOCToggleButtonWrapper />;
} catch {
// TOC context not available
return <TOCToggleButtonInner />;
}
/**
* 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 (
<button
className={cn(
'flex items-center gap-2 rounded-md px-2 py-1 text-xs transition-colors',
isAuthOpen
? 'bg-fd-primary/10 text-fd-primary hover:bg-fd-primary/20'
: 'text-fd-muted-foreground hover:text-fd-foreground hover:bg-fd-muted/50'
)}
onClick={toggleAuth}
>
<Key className="h-3 w-3" />
<span className="font-medium">Auth</span>
</button>
);
}
/**
@ -280,6 +321,11 @@ export function SharedHeader({
<TOCToggleButton />
</div>
{/* Auth Toggle Button - Shows on all pages like AI Chat button */}
<div className="hidden md:block">
<AuthToggleButton />
</div>
{/* AI Chat Toggle Button */}
<div className="hidden md:block">
<AIChatToggleButton />

View File

@ -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<SidebarContextType | null>(null);
export function useSidebar() {
const context = useContext(SidebarContext);
return context;
}
export function SidebarProvider({ children }: { children: ReactNode }) {
const [activeSidebar, setActiveSidebar] = useState<SidebarType>(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 (
<SidebarContext.Provider value={{
activeSidebar,
isTocOpen,
setTocOpen,
toggleToc,
isChatOpen,
setChatOpen,
toggleChat,
isChatExpanded,
setChatExpanded,
isAuthOpen,
setAuthOpen,
toggleAuth,
isFullPage,
setIsFullPage,
openSidebar,
closeSidebar,
}}>
{children}
</SidebarContext.Provider>
);
}

View File

@ -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<TOCContextType | null>(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 (
<TOCContext.Provider value={{
isTocOpen,
setIsTocOpen,
toggleToc,
isFullPage,
setIsFullPage
}}>
{children}
</TOCContext.Provider>
);
}