mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
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:
parent
daab72c048
commit
38ded51293
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
327
docs/src/components/api/auth-panel.tsx
Normal file
327
docs/src/components/api/auth-panel.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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 ? (
|
||||
<>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 }) {
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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 />
|
||||
|
||||
156
docs/src/components/layouts/sidebar-context.tsx
Normal file
156
docs/src/components/layouts/sidebar-context.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user