mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
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:
parent
a085575758
commit
4d04d8e8ad
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
456
docs/src/components/layout/custom-search-dialog.tsx
Normal file
456
docs/src/components/layout/custom-search-dialog.tsx
Normal 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 “{query}”</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>
|
||||
);
|
||||
}
|
||||
181
docs/src/components/layout/custom-search-toggle.tsx
Normal file
181
docs/src/components/layout/custom-search-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
238
docs/src/components/layouts/home-layout.tsx
Normal file
238
docs/src/components/layouts/home-layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 */}
|
||||
|
||||
6
docs/templates/meta.json
vendored
6
docs/templates/meta.json
vendored
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user