using new search functionality over fumadocs. New home layout. Content on home page now fills page, dont need to scroll to see content

This commit is contained in:
Madison 2025-06-26 09:33:36 -05:00
parent a085575758
commit 4d04d8e8ad
13 changed files with 1100 additions and 40 deletions

View File

@ -1,18 +1,9 @@
import { baseOptions } from '@/app/layout.config';
import Footer from '@/components/homepage/homepage-footer';
import { HomeLayout } from 'fumadocs-ui/layouts/home';
// Enable search for home page navbar
const homeOptions = {
...baseOptions,
searchToggle: {
enabled: true,
},
};
import { HomeLayout } from '@/components/layouts/home-layout';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<HomeLayout {...homeOptions}>
<HomeLayout>
{children}
<Footer />
</HomeLayout>

View File

@ -4,34 +4,34 @@ export default function HomePage() {
return (
<main className="flex flex-1 flex-col">
{/* Hero Section */}
<section className="relative px-6 py-12 md:py-24 text-center">
<section className="relative px-6 py-6 md:py-8 text-center">
<div className="max-w-4xl mx-auto">
<div className="mb-8">
<div className="mb-4">
<svg
width="80"
height="64"
width="60"
height="48"
viewBox="0 0 200 242"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="mx-auto mb-8"
className="mx-auto mb-4"
>
<path d="M103.504 1.81227C101.251 0.68679 98.6002 0.687576 96.3483 1.81439L4.4201 47.8136C1.71103 49.1692 0 51.9387 0 54.968V130.55C0 133.581 1.7123 136.351 4.42292 137.706L96.4204 183.695C98.6725 184.82 101.323 184.82 103.575 183.694L168.422 151.271C173.742 148.611 180 152.479 180 158.426V168.879C180 171.91 178.288 174.68 175.578 176.035L103.577 212.036C101.325 213.162 98.6745 213.162 96.4224 212.036L11.5771 169.623C6.25791 166.964 0 170.832 0 176.779V187.073C0 190.107 1.71689 192.881 4.43309 194.234L96.5051 240.096C98.7529 241.216 101.396 241.215 103.643 240.094L195.571 194.235C198.285 192.881 200 190.109 200 187.076V119.512C200 113.565 193.741 109.697 188.422 112.356L131.578 140.778C126.258 143.438 120 139.57 120 133.623V123.17C120 120.14 121.712 117.37 124.422 116.014L195.578 80.4368C198.288 79.0817 200 76.3116 200 73.2814V54.9713C200 51.9402 198.287 49.1695 195.576 47.8148L103.504 1.81227Z" fill="currentColor"/>
</svg>
</div>
<h1 className="mb-6 text-5xl md:text-6xl font-bold tracking-tight">
<h1 className="mb-3 text-4xl md:text-5xl font-bold tracking-tight">
Stack Auth
<span className="block text-3xl md:text-4xl font-normal text-fd-muted-foreground mt-2">
<span className="block text-2xl md:text-3xl font-normal text-fd-muted-foreground mt-1">
Documentation
</span>
</h1>
<p className="mb-12 text-xl text-fd-muted-foreground max-w-3xl mx-auto leading-relaxed">
<p className="mb-6 text-lg text-fd-muted-foreground max-w-3xl mx-auto leading-relaxed">
Complete authentication solution with comprehensive guides, API references, and platform-specific examples to get you started quickly.
</p>
{/* Documentation Type Selection */}
<div className="mb-16">
<div className="mb-6">
<DocsSelector />
</div>
</div>

View File

@ -1,4 +1,132 @@
import { createFromSource } from 'fumadocs-core/search/server';
import fs from 'fs';
import { source } from 'lib/source';
import type { NextRequest } from 'next/server';
import path from 'path';
export const { GET } = createFromSource(source);
type SearchResult = {
id: string,
type: 'page' | 'heading' | 'text',
content: string,
url: string,
};
// Helper function to extract text content from MDX
function extractTextFromMDX(filePath: string): string {
try {
const content = fs.readFileSync(filePath, 'utf-8');
// Remove frontmatter
const withoutFrontmatter = content.replace(/^---[\s\S]*?---/, '');
// Remove JSX components and keep only text content
const textOnly = withoutFrontmatter
.replace(/<[^>]*>/g, ' ') // Remove JSX tags
.replace(/\{[^}]*\}/g, ' ') // Remove JSX expressions
.replace(/```[a-zA-Z]*\n/g, ' ') // Remove code block language markers
.replace(/```/g, ' ') // Remove code block delimiters but keep content
.replace(/`([^`]*)`/g, '$1') // Remove inline code backticks but keep content
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // Extract link text
.replace(/[#*_~]/g, '') // Remove markdown formatting (but keep backticks for now)
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
return textOnly;
} catch (error) {
console.error(`Error reading file ${filePath}:`, error);
return '';
}
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const query = searchParams.get('q');
console.log('Search API called with query:', query);
if (!query) {
return Response.json([]);
}
try {
// Get all pages from the source
const pages = source.getPages();
console.log(`Found ${pages.length} pages in source`);
const results: SearchResult[] = [];
const queryLower = query.toLowerCase();
// Search through all pages
pages.forEach((page, pageIndex) => {
const url = page.url;
const title = page.data.title || '';
const description = page.data.description || '';
// Check if page title matches
if (title.toLowerCase().includes(queryLower)) {
results.push({
id: `${url}-page`,
type: 'page',
content: title,
url: url
});
}
// Check if description matches
if (description.toLowerCase().includes(queryLower)) {
results.push({
id: `${url}-description`,
type: 'text',
content: description,
url: url
});
}
// 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
});
}
});
// Full content search by reading the actual MDX file
try {
// Construct file path from URL
const relativePath = url.replace('/docs/', './content/docs/') + '.mdx';
const fullPath = path.resolve(relativePath);
if (fs.existsSync(fullPath)) {
const textContent = extractTextFromMDX(fullPath);
if (textContent.toLowerCase().includes(queryLower)) {
// Find a snippet around the match for better context
const matchIndex = textContent.toLowerCase().indexOf(queryLower);
const start = Math.max(0, matchIndex - 50);
const end = Math.min(textContent.length, matchIndex + 100);
const snippet = textContent.slice(start, end);
results.push({
id: `${url}-content-${pageIndex}`,
type: 'text',
content: `...${snippet}...`,
url: url
});
}
}
} catch (error) {
// Silently ignore file reading errors
}
});
console.log(`Found ${results.length} search results for "${query}"`);
console.log('Sample results with platform info:', results.slice(0, 3));
return Response.json(results);
} catch (error) {
console.error('Search error:', error);
return Response.json({ error: 'Search failed', details: String(error) }, { status: 500 });
}
}

View File

@ -19,7 +19,13 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<body className="flex flex-col min-h-screen">
<StackProvider app={stackServerApp}>
<StackTheme>
<RootProvider>{children}</RootProvider>
<RootProvider
search={{
enabled: false, // Completely disable fumadocs search
}}
>
{children}
</RootProvider>
</StackTheme>
</StackProvider>
</body>

View File

@ -1,6 +1,7 @@
"use client";
import { LargeSearchToggle } from '@/components/layout/search-toggle';
import { CustomSearchDialog } from '@/components/layout/custom-search-dialog';
import { LargeCustomSearchToggle } from '@/components/layout/custom-search-toggle';
import { platformSupportsComponents, platformSupportsSDK } from "@/lib/navigation-utils";
import { PLATFORMS, type Platform } from "@/lib/platform-utils";
import { Book, ChevronDown, Code, Layers, Zap } from "lucide-react";
@ -484,6 +485,7 @@ const DocsIcon3D: React.FC<DocsIcon3DProps> = ({
export default function DocsSelector() {
const [selectedPlatform, setSelectedPlatform] = useState<Platform>("next");
const [searchOpen, setSearchOpen] = useState(false);
const handleSectionSelect = (section: DocsSection) => {
console.log("Selected section:", section);
@ -507,10 +509,18 @@ export default function DocsSelector() {
{/* Search Bar */}
<div className="mb-8 flex justify-center">
<div className="w-full max-w-md">
<LargeSearchToggle className="w-full" />
<LargeCustomSearchToggle
onOpen={() => setSearchOpen(true)}
className="w-full"
/>
</div>
</div>
<CustomSearchDialog
open={searchOpen}
onOpenChange={setSearchOpen}
/>
<DocsIcon3D
selectedPlatform={selectedPlatform}
onSectionSelect={handleSectionSelect}

View File

@ -0,0 +1,456 @@
'use client';
import { AlignLeft, ChevronDown, ExternalLink, FileText, Hash, Search, X } from 'lucide-react';
import Link from 'next/link';
import { useCallback, useEffect, useRef, useState } from 'react';
import { cn } from '../../lib/cn';
// Platform colors matching your theme
const PLATFORM_COLORS = {
'next': '#3B82F6', // Blue - matches rgb(59, 130, 246)
'react': '#10B981', // Green - matches rgb(16, 185, 129)
'js': '#F59E0B', // Yellow - matches rgb(245, 158, 11)
'javascript': '#F59E0B', // Yellow - matches rgb(245, 158, 11)
'python': '#A855F7', // Purple - matches rgb(168, 85, 247)
'api': '#FF6B6B', // Keep existing red for API
} as const;
const PLATFORM_NAMES = {
'next': 'Next.js',
'react': 'React',
'js': 'JavaScript',
'javascript': 'JavaScript',
'python': 'Python',
'api': 'API',
} as const;
type SearchResult = {
id: string,
type: 'page' | 'heading' | 'text',
content: string,
url: string,
};
type GroupedResult = {
platform: string,
basePath: string,
title: string,
results: SearchResult[],
};
function extractPlatformFromUrl(url: string): string {
const match = url.match(/\/docs\/([^\/]+)/);
const platform = match?.[1] || 'api';
return platform;
}
function extractBasePathFromUrl(url: string): string {
// Extract everything after the platform but before any hash
const match = url.match(/\/docs\/[^\/]+(.+?)(?:#|$)/);
return match?.[1] || '';
}
function groupResultsByPage(results: SearchResult[]): GroupedResult[] {
const grouped = new Map<string, GroupedResult>();
for (const result of results) {
const platform = extractPlatformFromUrl(result.url);
const basePath = extractBasePathFromUrl(result.url);
const baseUrl = `/docs/${platform}${basePath}`;
if (!grouped.has(baseUrl)) {
// Find the page title from page-type results, fallback to path-based title
const pageResult = results.find(r => r.url === baseUrl && r.type === 'page');
const title = pageResult?.content || basePath.split('/').pop()?.replace(/-/g, ' ') || 'Unknown';
grouped.set(baseUrl, {
platform,
basePath,
title,
results: []
});
}
const groupedResult = grouped.get(baseUrl);
if (groupedResult) {
groupedResult.results.push(result);
}
}
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;
}
function PlatformBadge({ platform }: { platform: string }) {
const color = PLATFORM_COLORS[platform as keyof typeof PLATFORM_COLORS];
const name = PLATFORM_NAMES[platform as keyof typeof PLATFORM_NAMES];
return (
<span
className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-md"
style={{
backgroundColor: `${color}20`,
color: color,
border: `1px solid ${color}40`
}}
>
{name}
</span>
);
}
function SearchResultIcon({ type }: { type: string }) {
switch (type) {
case 'page': {
return <FileText className="w-4 h-4" />;
}
case 'heading': {
return <Hash className="w-4 h-4" />;
}
case 'text': {
return <AlignLeft className="w-4 h-4" />;
}
default: {
return <FileText className="w-4 h-4" />;
}
}
}
type CustomSearchDialogProps = {
open: boolean,
onOpenChange: (open: boolean) => void,
};
export function CustomSearchDialog({ open, onOpenChange }: CustomSearchDialogProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [selectedPlatformFilter, setSelectedPlatformFilter] = useState<string>('all');
const [selectedIndex, setSelectedIndex] = useState(0);
const [dropdownOpen, setDropdownOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const searchTimeoutRef = useRef<NodeJS.Timeout>();
// Available platforms for the dropdown
const availablePlatforms = ['all', 'next', 'react', 'js', 'python', 'api'];
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setDropdownOpen(false);
}
};
if (dropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [dropdownOpen]);
const performSearch = useCallback(async (searchQuery: string) => {
if (!searchQuery.trim()) {
setResults([]);
return;
}
setLoading(true);
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`);
if (response.ok) {
const data = await response.json();
setResults(data || []);
setSelectedIndex(0);
} else {
console.error('Search response not ok:', response.status, response.statusText);
setResults([]);
}
} catch (error) {
console.error('Search failed:', error);
setResults([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
searchTimeoutRef.current = setTimeout(() => {
performSearch(query).catch((error) => {
console.error('Search failed:', error);
});
}, 300);
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, [query, performSearch]);
const groupedResults = groupResultsByPage(results);
// Filter by selected platform
const filteredResults = selectedPlatformFilter === 'all'
? groupedResults
: groupedResults.filter(group => group.platform === selectedPlatformFilter);
// Flatten results for keyboard navigation
const flatResults = filteredResults.flatMap(group =>
group.results.map(result => ({
...result,
groupTitle: group.title,
platform: group.platform
}))
);
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'Escape': {
if (dropdownOpen) {
setDropdownOpen(false);
} else {
onOpenChange(false);
}
break;
}
case 'ArrowDown': {
if (!dropdownOpen) {
e.preventDefault();
setSelectedIndex(prev => Math.min(prev + 1, flatResults.length - 1));
}
break;
}
case 'ArrowUp': {
if (!dropdownOpen) {
e.preventDefault();
setSelectedIndex(prev => Math.max(prev - 1, 0));
}
break;
}
case 'Enter': {
if (!dropdownOpen) {
e.preventDefault();
const selectedResult = flatResults[selectedIndex];
window.location.href = selectedResult.url;
onOpenChange(false);
}
break;
}
}
};
// Focus input when dialog opens
useEffect(() => {
if (open && inputRef.current) {
inputRef.current.focus();
}
}, [open]);
// Reset state when dialog opens
useEffect(() => {
if (open) {
setQuery('');
setResults([]);
setSelectedIndex(0);
setDropdownOpen(false);
}
}, [open]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
onClick={() => onOpenChange(false)}
>
<div
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-2xl max-h-[80vh] bg-fd-background border border-fd-border rounded-lg shadow-2xl"
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
>
{/* Search Input Header */}
<div className="flex items-center border-b border-fd-border px-3">
<Search className="w-4 h-4 text-fd-muted-foreground mr-3" />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search documentation..."
className="flex-1 px-0 py-4 text-sm bg-transparent outline-none placeholder:text-fd-muted-foreground"
/>
<div className="flex items-center gap-2 ml-3 relative" ref={dropdownRef}>
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className={cn(
"text-xs px-3 py-1.5 rounded-md transition-colors flex items-center gap-2",
dropdownOpen ? "bg-fd-primary text-fd-primary-foreground" : "bg-fd-muted text-fd-muted-foreground hover:bg-fd-muted/80"
)}
>
{selectedPlatformFilter === 'all' ? (
<span>All platforms</span>
) : (
<>
<PlatformBadge platform={selectedPlatformFilter} />
<span>only</span>
</>
)}
<ChevronDown className={cn("w-3 h-3 transition-transform", dropdownOpen && "rotate-180")} />
</button>
{/* Dropdown Menu */}
{dropdownOpen && (
<div className="absolute top-full left-0 mt-1 bg-fd-background border border-fd-border rounded-md shadow-lg z-50 min-w-[140px]">
{availablePlatforms.map((platform) => (
<button
key={platform}
onClick={() => {
setSelectedPlatformFilter(platform);
setDropdownOpen(false);
}}
className={cn(
"w-full text-left px-3 py-2 text-xs hover:bg-fd-muted transition-colors flex items-center gap-2",
selectedPlatformFilter === platform && "bg-fd-primary/10 text-fd-primary"
)}
>
{platform === 'all' ? (
<span>All platforms</span>
) : (
<>
<PlatformBadge platform={platform} />
</>
)}
</button>
))}
</div>
)}
</div>
<button
onClick={() => onOpenChange(false)}
className="ml-3 p-1 hover:bg-fd-muted rounded-md transition-colors"
>
<X className="w-4 h-4 text-fd-muted-foreground" />
</button>
</div>
{/* Results */}
<div className="max-h-[500px] overflow-y-auto p-2">
{loading && (
<div className="flex items-center justify-center py-8">
<div className="animate-spin w-6 h-6 border-2 border-fd-primary border-t-transparent rounded-full" />
</div>
)}
{!loading && query && filteredResults.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Search className="w-8 h-8 text-fd-muted-foreground mb-2" />
<p className="text-sm text-fd-muted-foreground">No results found for &ldquo;{query}&rdquo;</p>
</div>
)}
{!loading && filteredResults.map((group, groupIndex) => (
<div key={`${group.platform}-${group.basePath}`} className="mb-6">
{/* Group Header */}
<div className="flex items-center gap-3 px-3 py-2 mb-3 bg-fd-muted/30 rounded-lg">
<PlatformBadge platform={group.platform} />
<h3 className="text-sm font-semibold text-fd-foreground">
{group.title}
</h3>
<div className="flex-1" />
<span className="text-xs text-fd-muted-foreground">
{group.results.length} result{group.results.length !== 1 ? 's' : ''}
</span>
</div>
{/* Results in this group */}
<div className="space-y-1 pl-3">
{group.results.map((result, resultIndex) => {
const flatIndex = filteredResults
.slice(0, groupIndex)
.reduce((acc, g) => acc + g.results.length, 0) + resultIndex;
const isSelected = flatIndex === selectedIndex;
return (
<Link
key={result.id}
href={result.url}
onClick={() => onOpenChange(false)}
className={cn(
"flex items-start gap-3 px-3 py-3 rounded-lg transition-colors cursor-pointer group",
isSelected
? "bg-fd-primary/10 border border-fd-primary/20"
: "hover:bg-fd-muted/50"
)}
>
<div className="flex-shrink-0 mt-0.5 text-fd-muted-foreground">
<SearchResultIcon type={result.type} />
</div>
<div className="flex-1 min-w-0">
<p className={cn(
"text-sm line-clamp-2 transition-colors",
isSelected
? "text-fd-primary font-medium"
: "text-fd-foreground group-hover:text-fd-primary"
)}>
{result.content}
</p>
<p className="text-xs text-fd-muted-foreground mt-1 truncate">
{result.url}
</p>
</div>
{result.url.includes('#') && (
<ExternalLink className={cn(
"w-3 h-3 transition-colors",
isSelected
? "text-fd-primary"
: "text-fd-muted-foreground group-hover:text-fd-primary"
)} />
)}
</Link>
);
})}
</div>
</div>
))}
{!query && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Search className="w-8 h-8 text-fd-muted-foreground mb-2" />
<p className="text-sm text-fd-muted-foreground">Start typing to search documentation...</p>
</div>
)}
</div>
{/* Footer */}
<div className="border-t border-fd-border px-3 py-2 text-xs text-fd-muted-foreground flex justify-between items-center">
<span>Use to navigate, Enter to select, Esc to close</span>
<span>
{filteredResults.length} result group{filteredResults.length !== 1 ? 's' : ''}
{selectedPlatformFilter !== 'all' && filteredResults.length > 0 && (
<span className="ml-2 text-fd-primary">
{PLATFORM_NAMES[selectedPlatformFilter as keyof typeof PLATFORM_NAMES]} only
</span>
)}
</span>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,181 @@
'use client';
import { 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+';
}
// Compact search button - perfect for navbars
export function CustomSearchToggle({ onOpen, className }: {
onOpen: () => void,
className?: string,
}) {
const [searchKey, setSearchKey] = useState('');
useEffect(() => {
setSearchKey(getSearchKey());
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
onOpen();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onOpen]);
return (
<button
onClick={onOpen}
className={cn(
'group relative inline-flex h-9 items-center gap-2 rounded-lg border border-fd-border/60 bg-fd-background/50 px-3 text-sm text-fd-muted-foreground backdrop-blur-sm transition-all duration-200 hover:border-fd-border hover:bg-fd-background hover:text-fd-foreground hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-fd-primary/20',
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
</kbd>
</div>
)}
</button>
);
}
// Minimal icon-only search button - for tight spaces
export function CompactSearchToggle({ onOpen, className }: {
onOpen: () => void,
className?: string,
}) {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
onOpen();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onOpen]);
return (
<button
onClick={onOpen}
className={cn(
'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)"
>
<Search className="h-4 w-4" />
</button>
);
}
// Enhanced search input-style button - looks like a search field
export function SearchInputToggle({ onOpen, className }: {
onOpen: () => void,
className?: string,
}) {
const [searchKey, setSearchKey] = useState('');
useEffect(() => {
setSearchKey(getSearchKey());
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
onOpen();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onOpen]);
return (
<button
onClick={onOpen}
className={cn(
'group flex h-9 w-full items-center justify-center rounded-lg border border-fd-border/60 bg-fd-background/50 text-sm text-fd-muted-foreground backdrop-blur-sm transition-all duration-200 hover:border-fd-border hover:bg-fd-background hover:text-fd-foreground hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-fd-primary/20',
// On small containers (mobile), center the icon
'sm:justify-start sm:gap-3 sm:px-3',
className
)}
>
<Search className="h-4 w-4 flex-shrink-0 transition-colors" />
{/* Text - hidden on very small containers, shown when there's space */}
<span className="hidden sm:block truncate">
<span className="hidden md:inline">Search documentation...</span>
<span className="sm:inline md:hidden">Search...</span>
</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>
)}
</button>
);
}
// Large prominent search toggle - for hero sections or main content areas
export function LargeCustomSearchToggle({ onOpen, className }: {
onOpen: () => void,
className?: string,
}) {
const [searchKey, setSearchKey] = useState('');
useEffect(() => {
setSearchKey(getSearchKey());
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
onOpen();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onOpen]);
return (
<button
onClick={onOpen}
className={cn(
'group flex w-full items-center gap-4 rounded-xl border border-fd-border/60 bg-fd-background/80 px-4 py-4 text-left text-sm text-fd-muted-foreground backdrop-blur-sm transition-all duration-200 hover:border-fd-border hover:bg-fd-background hover:text-fd-foreground hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-fd-primary/20',
className
)}
>
<Search className="h-5 w-5 flex-shrink-0 transition-colors" />
<div className="flex-1">
<div className="font-medium">Search documentation</div>
<div className="text-xs text-fd-muted-foreground/70">Find guides, API references, and examples</div>
</div>
{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
</kbd>
</div>
)}
</button>
);
}

View File

@ -26,9 +26,11 @@ const full = [
export function ThemeToggle({
className,
mode = 'light-dark',
compact = false,
...props
}: HTMLAttributes<HTMLElement> & {
mode?: 'light-dark' | 'light-dark-system',
compact?: boolean,
}) {
const { setTheme, theme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
@ -37,6 +39,28 @@ export function ThemeToggle({
setMounted(true);
}, []);
// Compact mode: single icon button without container
if (compact) {
const value = mounted ? resolvedTheme : null;
const Icon = value === 'light' ? Moon : Sun;
return (
<button
className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded-full text-fd-muted-foreground transition-colors hover:bg-fd-muted hover:text-fd-foreground',
className,
)}
aria-label={`Switch to ${value === 'light' ? 'dark' : 'light'} theme`}
onClick={() => setTheme(value === 'light' ? 'dark' : 'light')}
data-theme-toggle=""
title={`Switch to ${value === 'light' ? 'dark' : 'light'} theme`}
{...props}
>
<Icon className="h-3.5 w-3.5" fill="currentColor" />
</button>
);
}
const container = cn(
'inline-flex items-center rounded-full border p-1',
className,

View File

@ -45,7 +45,7 @@ import { useSidebar } from 'fumadocs-ui/contexts/sidebar';
import { Menu, Sidebar as SidebarIcon } from 'lucide-react';
import { type ComponentProps } from 'react';
import { cn } from '../../lib/cn';
import { SearchToggle } from '../layout/search-toggle';
import { CompactSearchToggle } from '../layout/custom-search-toggle';
import { SidebarCollapseTrigger } from '../layout/sidebar';
import { buttonVariants } from '../ui/button';
@ -92,7 +92,7 @@ export function NavbarSidebarTrigger({
);
}
export function CollapsibleControl() {
export function CollapsibleControl({ onSearchOpen }: { onSearchOpen?: () => void }) {
const { collapsed } = useSidebar();
if (!collapsed) return;
@ -114,7 +114,12 @@ export function CollapsibleControl() {
>
<SidebarIcon />
</SidebarCollapseTrigger>
<SearchToggle size="icon-sm" className="rounded-lg" hideIfDisabled />
{onSearchOpen && (
<CompactSearchToggle
onOpen={onSearchOpen}
className="rounded-lg w-9 h-9"
/>
)}
</div>
);
}

View File

@ -46,14 +46,15 @@ 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 { CustomSearchDialog } from '../layout/custom-search-dialog';
import {
SearchInputToggle
} from '../layout/custom-search-toggle';
import {
LanguageToggle,
LanguageToggleText,
} from '../layout/language-toggle';
import { RootToggle } from '../layout/root-toggle';
import {
SearchToggle
} from '../layout/search-toggle';
import { ThemeToggle } from '../layout/theme-toggle';
import { buttonVariants } from '../ui/button';
import { HideIfEmpty } from '../ui/hide-if-empty';
@ -470,6 +471,8 @@ export function DocsLayout({
...props
}: DocsLayoutProps): ReactNode {
const { isTocOpen } = useTOC();
const [searchOpen, setSearchOpen] = useState(false);
const tabs = useMemo(
() => getSidebarTabsFromOptions(sidebar.tabs, props.tree) ?? [],
[sidebar.tabs, props.tree],
@ -502,10 +505,14 @@ export function DocsLayout({
{nav.title}
</Link>
<div className="flex-1">{nav.children}</div>
{slots('sm', searchToggle, <SearchToggle hideIfDisabled />)}
{slots('sm', searchToggle, <SearchInputToggle onOpen={() => setSearchOpen(true)} />)}
<NavbarSidebarTrigger className="-me-2 md:hidden" />
</Navbar>,
)}
<CustomSearchDialog
open={searchOpen}
onOpenChange={setSearchOpen}
/>
<main
id="nd-docs-layout"
{...props.containerProps}
@ -525,6 +532,7 @@ export function DocsLayout({
{...omit(sidebar, 'enabled', 'component', 'tabs')}
links={links}
tree={props.tree}
onSearchOpen={() => setSearchOpen(true)}
nav={
<>
<Link
@ -570,17 +578,19 @@ export function DocsLayout({
export function DocsLayoutSidebar({
collapsible = true,
banner,
onSearchOpen,
...props
}: Omit<SidebarOptions, 'tabs'> & {
links?: LinkItemType[],
nav?: ReactNode,
tree?: PageTree.Root,
onSearchOpen?: () => void,
}) {
const pathname = usePathname();
return (
<>
{collapsible ? <CollapsibleControl /> : null}
{collapsible ? <CollapsibleControl onSearchOpen={onSearchOpen} /> : null}
{/* Sidebar positioned under the header */}
<div className="hidden md:block fixed left-0 top-14 w-64 border-r border-fd-border bg-fd-background z-30">
<div className="h-[calc(100vh-3.5rem)] flex flex-col">

View File

@ -0,0 +1,238 @@
'use client';
import { Github, Menu, X } from 'lucide-react';
import Link from 'next/link';
import { type ReactNode, useEffect, useState } from 'react';
import { CustomSearchDialog } from '../layout/custom-search-dialog';
import { SearchInputToggle } from '../layout/custom-search-toggle';
import { ThemeToggle } from '../layout/theme-toggle';
// Discord Icon Component
function DiscordIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418Z"/>
</svg>
);
}
// Stack Auth Logo Component
function StackAuthLogo() {
return (
<Link href="https://stack-auth.com" className="flex items-center gap-2.5 text-fd-foreground hover:text-fd-foreground/80 transition-colors">
<svg
width="30"
height="24"
viewBox="0 0 200 242"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-label="Stack Logo"
className="flex-shrink-0"
>
<path d="M103.504 1.81227C101.251 0.68679 98.6002 0.687576 96.3483 1.81439L4.4201 47.8136C1.71103 49.1692 0 51.9387 0 54.968V130.55C0 133.581 1.7123 136.351 4.42292 137.706L96.4204 183.695C98.6725 184.82 101.323 184.82 103.575 183.694L168.422 151.271C173.742 148.611 180 152.479 180 158.426V168.879C180 171.91 178.288 174.68 175.578 176.035L103.577 212.036C101.325 213.162 98.6745 213.162 96.4224 212.036L11.5771 169.623C6.25791 166.964 0 170.832 0 176.779V187.073C0 190.107 1.71689 192.881 4.43309 194.234L96.5051 240.096C98.7529 241.216 101.396 241.215 103.643 240.094L195.571 194.235C198.285 192.881 200 190.109 200 187.076V119.512C200 113.565 193.741 109.697 188.422 112.356L131.578 140.778C126.258 143.438 120 139.57 120 133.623V123.17C120 120.14 121.712 117.37 124.422 116.014L195.578 80.4368C198.288 79.0817 200 76.3116 200 73.2814V54.9713C200 51.9402 198.287 49.1695 195.576 47.8148L103.504 1.81227Z" fill="currentColor"/>
</svg>
<span className="font-medium text-[15px]">Stack Auth</span>
</Link>
);
}
// Home Navbar Component
function HomeNavbar() {
const [searchOpen, setSearchOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
// Scroll detection
useEffect(() => {
const handleScroll = () => {
const scrollY = window.scrollY;
setIsScrolled(scrollY > 50);
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// Close mobile menu when scrolling
useEffect(() => {
if (isScrolled && mobileMenuOpen) {
setMobileMenuOpen(false);
}
}, [isScrolled, mobileMenuOpen]);
return (
<>
{/* Full Navbar */}
<header className={`sticky top-0 z-50 w-full border-b border-fd-border bg-fd-background/95 backdrop-blur supports-[backdrop-filter]:bg-fd-background/60 transition-all duration-300 ${isScrolled ? 'opacity-0 pointer-events-none' : 'opacity-100'}`}>
<div className="container flex h-14 max-w-screen-2xl items-center justify-between px-4 md:px-6">
{/* Left - Logo + Social Links */}
<div className="flex items-center gap-4">
<StackAuthLogo />
{/* Desktop Social Links */}
<div className="hidden md:flex items-center gap-1">
<Link
href="https://github.com/stack-auth/stack"
target="_blank"
rel="noopener noreferrer"
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"
title="GitHub"
>
<Github className="h-4 w-4" />
</Link>
<Link
href="https://discord.gg/stack-auth"
target="_blank"
rel="noopener noreferrer"
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"
title="Discord"
>
<DiscordIcon className="h-4 w-4" />
</Link>
</div>
</div>
{/* Right - Search + Actions */}
<div className="flex items-center gap-2">
{/* Desktop Search */}
<div className="hidden md:block w-64">
<SearchInputToggle
onOpen={() => setSearchOpen(true)}
className="w-full"
/>
</div>
{/* Theme Toggle */}
<ThemeToggle className="p-0" />
{/* Mobile Search */}
<div className="md:hidden">
<SearchInputToggle
onOpen={() => setSearchOpen(true)}
className="w-9"
/>
</div>
{/* Mobile Menu Toggle */}
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="md:hidden 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"
aria-label="Toggle menu"
>
{mobileMenuOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
</button>
</div>
</div>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div className="md:hidden border-t border-fd-border bg-fd-background">
<div className="container px-4 py-4 space-y-3">
<Link
href="https://github.com/stack-auth/stack"
target="_blank"
rel="noopener noreferrer"
onClick={() => setMobileMenuOpen(false)}
className="flex items-center gap-3 px-3 py-2 rounded-lg text-fd-muted-foreground hover:bg-fd-muted hover:text-fd-foreground transition-colors"
>
<Github className="h-4 w-4" />
<span>GitHub</span>
</Link>
<Link
href="https://discord.gg/stack-auth"
target="_blank"
rel="noopener noreferrer"
onClick={() => setMobileMenuOpen(false)}
className="flex items-center gap-3 px-3 py-2 rounded-lg text-fd-muted-foreground hover:bg-fd-muted hover:text-fd-foreground transition-colors"
>
<DiscordIcon className="h-4 w-4" />
<span>Discord</span>
</Link>
</div>
</div>
)}
</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="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">
<svg
width="20"
height="16"
viewBox="0 0 200 242"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-label="Stack Logo"
className="flex-shrink-0"
>
<path d="M103.504 1.81227C101.251 0.68679 98.6002 0.687576 96.3483 1.81439L4.4201 47.8136C1.71103 49.1692 0 51.9387 0 54.968V130.55C0 133.581 1.7123 136.351 4.42292 137.706L96.4204 183.695C98.6725 184.82 101.323 184.82 103.575 183.694L168.422 151.271C173.742 148.611 180 152.479 180 158.426V168.879C180 171.91 178.288 174.68 175.578 176.035L103.577 212.036C101.325 213.162 98.6745 213.162 96.4224 212.036L11.5771 169.623C6.25791 166.964 0 170.832 0 176.779V187.073C0 190.107 1.71689 192.881 4.43309 194.234L96.5051 240.096C98.7529 241.216 101.396 241.215 103.643 240.094L195.571 194.235C198.285 192.881 200 190.109 200 187.076V119.512C200 113.565 193.741 109.697 188.422 112.356L131.578 140.778C126.258 143.438 120 139.57 120 133.623V123.17C120 120.14 121.712 117.37 124.422 116.014L195.578 80.4368C198.288 79.0817 200 76.3116 200 73.2814V54.9713C200 51.9402 198.287 49.1695 195.576 47.8148L103.504 1.81227Z" fill="currentColor"/>
</svg>
<span className="hidden sm:inline font-medium text-sm">Stack Auth</span>
</Link>
{/* Compact Actions */}
<div className="flex items-center gap-1 ml-2">
{/* Compact Social Links */}
<Link
href="https://github.com/stack-auth/stack"
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-fd-muted-foreground transition-colors hover:bg-fd-muted hover:text-fd-foreground"
title="GitHub"
>
<Github className="h-3.5 w-3.5" />
</Link>
<Link
href="https://discord.gg/stack-auth"
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-fd-muted-foreground transition-colors hover:bg-fd-muted hover:text-fd-foreground"
title="Discord"
>
<DiscordIcon className="h-3.5 w-3.5" />
</Link>
{/* Compact Search */}
<button
onClick={() => setSearchOpen(true)}
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-fd-muted-foreground transition-colors hover:bg-fd-muted hover:text-fd-foreground"
title="Search (⌘K)"
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
{/* Compact Theme Toggle */}
<ThemeToggle compact />
</div>
</div>
</div>
{/* Search Dialog */}
<CustomSearchDialog
open={searchOpen}
onOpenChange={setSearchOpen}
/>
</>
);
}
// Main Home Layout Component
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>
);
}

View File

@ -1,5 +1,6 @@
'use client';
import { LargeSearchToggle } from '@/components/layout/search-toggle';
import { CustomSearchDialog } from '@/components/layout/custom-search-dialog';
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';
@ -151,6 +152,7 @@ export function SharedHeader({
}: SharedHeaderProps) {
const pathname = usePathname();
const [showMobileNav, setShowMobileNav] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
// Close mobile nav when pathname changes
useEffect(() => {
@ -234,12 +236,17 @@ export function SharedHeader({
<div className="flex items-center gap-4 relative z-10">
{/* Search Bar - Responsive sizing */}
{showSearch && (
<div className="w-32 sm:w-48 lg:w-64">
<LargeSearchToggle
hideIfDisabled
className="w-full"
<>
<div className="w-9 sm:w-32 md:w-48 lg:w-64">
<SearchInputToggle
onOpen={() => setSearchOpen(true)}
/>
</div>
<CustomSearchDialog
open={searchOpen}
onOpenChange={setSearchOpen}
/>
</div>
</>
)}
{/* TOC Toggle Button - Only on docs pages */}

View File

@ -23,7 +23,11 @@
"concepts/user-onboarding",
"concepts/webhooks",
"---Customization---",
"...customization",
"customization/custom-pages",
"customization/custom-styles",
"customization/dark-mode",
"customization/internationalization",
"customization/page-examples",
"---Other---",
"others/cli-authentication",
"others/self-host",