mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
Feat : Gitbook
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { MarkdownRenderer } from "@/utils/markdown";
|
||||
|
||||
export default function DocsContent({ content }) {
|
||||
return (
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<article className="max-w-4xl mx-auto px-6 py-8">
|
||||
<MarkdownRenderer content={content} />
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { DOCS_CONFIG } from "@/constants/docsConfig";
|
||||
import { DEFAULT_LANG } from "@/constants/languages";
|
||||
import { ExternalLink, Menu, X } from "lucide-react";
|
||||
import DocsSidebar from "./DocsSidebar";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
|
||||
export default function DocsHeader({ lang = DEFAULT_LANG }) {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-white/80 backdrop-blur-sm border-gray-200">
|
||||
<div className=" mx-auto px-4 h-16 flex items-center justify-between">
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="w-6 h-6 text-gray-600" />
|
||||
</button>
|
||||
|
||||
{/* Logo */}
|
||||
<Link href={`/${lang}`} className="flex items-center gap-2 font-bold text-2xl text-black hover:opacity-80 transition-opacity">
|
||||
<span>9</span>
|
||||
<span className="text-[#E68A6E]">{DOCS_CONFIG.logo} Docs</span>
|
||||
</Link>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<LanguageSwitcher currentLang={lang} />
|
||||
|
||||
{/* Go to App */}
|
||||
<Link
|
||||
href={DOCS_CONFIG.appUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 sm:px-4 py-2 bg-[#E68A6E] text-white rounded-lg font-medium hover:bg-[#d67a5e] transition-colors text-sm"
|
||||
>
|
||||
<span className="hidden sm:inline">Go to App</span>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile menu */}
|
||||
{mobileMenuOpen && (
|
||||
<>
|
||||
<div
|
||||
className="mobile-menu-overlay lg:hidden"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
|
||||
<div className="mobile-menu-drawer lg:hidden">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||
<span className="font-bold text-lg text-black">
|
||||
<span className="text-[#E68A6E]">9</span>{DOCS_CONFIG.logo} Docs
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
<DocsSidebar isMobile onClose={() => setMobileMenuOpen(false)} lang={lang} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import DocsHeader from "./DocsHeader";
|
||||
import DocsSidebar from "./DocsSidebar";
|
||||
import DocsToc from "./DocsToc";
|
||||
import { DEFAULT_LANG } from "@/constants/languages";
|
||||
|
||||
export default function DocsLayout({ children, headings = [], lang = DEFAULT_LANG }) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-[#FCFBF9]">
|
||||
<DocsHeader lang={lang} />
|
||||
<div className="flex-1 flex">
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden lg:block">
|
||||
<DocsSidebar lang={lang} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex">
|
||||
{children}
|
||||
<DocsToc headings={headings} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { DOCS_CONFIG } from "@/constants/docsConfig";
|
||||
import { DEFAULT_LANG } from "@/constants/languages";
|
||||
import { ChevronDown, ChevronRight, BookOpen, Rocket, Terminal, Monitor, FolderOpen, HelpCircle, MessageCircle, Layers, Plug, Cloud, Zap, Wallet, Gift, GitBranch, BarChart3, Code2, Sparkles, Server, Globe } from "lucide-react";
|
||||
|
||||
const SECTION_ICONS = {
|
||||
"Getting Started": Rocket,
|
||||
"Providers": Layers,
|
||||
"Features": Zap,
|
||||
"Integration": Plug,
|
||||
"Deployment": Cloud,
|
||||
"Help": HelpCircle
|
||||
};
|
||||
|
||||
const ITEM_ICONS = {
|
||||
"Introduction": BookOpen,
|
||||
"Quick Start": Rocket,
|
||||
"Installation": Terminal,
|
||||
"Subscription (Maximize)": Sparkles,
|
||||
"Cheap (Backup)": Wallet,
|
||||
"Free (Fallback)": Gift,
|
||||
"Smart Routing": GitBranch,
|
||||
"Combos & Fallback": Layers,
|
||||
"Quota Tracking": BarChart3,
|
||||
"Claude Code": Code2,
|
||||
"OpenAI Codex": Code2,
|
||||
"Cursor": Code2,
|
||||
"Cline": Code2,
|
||||
"Roo": Code2,
|
||||
"Continue": Code2,
|
||||
"Other Tools": Plug,
|
||||
"Localhost": Monitor,
|
||||
"Cloud (VPS/Docker)": Server,
|
||||
"Troubleshooting": HelpCircle,
|
||||
"FAQ": MessageCircle
|
||||
};
|
||||
|
||||
export default function DocsSidebar({ isMobile = false, onClose, lang = DEFAULT_LANG }) {
|
||||
const pathname = usePathname();
|
||||
const [openSections, setOpenSections] = useState(
|
||||
DOCS_CONFIG.navigation.map((_, i) => i)
|
||||
);
|
||||
|
||||
const toggleSection = (index) => {
|
||||
setOpenSections(prev =>
|
||||
prev.includes(index)
|
||||
? prev.filter(i => i !== index)
|
||||
: [...prev, index]
|
||||
);
|
||||
};
|
||||
|
||||
// Build URL for a navigation slug under current language
|
||||
const buildHref = (slug) => (slug ? `/${lang}/${slug}` : `/${lang}`);
|
||||
|
||||
const isActive = (slug) => pathname === buildHref(slug);
|
||||
|
||||
const handleLinkClick = () => {
|
||||
if (isMobile && onClose) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className={`${isMobile ? 'w-full' : 'w-64'} border-r bg-white border-gray-200 ${isMobile ? 'h-full' : 'h-[calc(100vh-4rem)] sticky top-16'} overflow-y-auto`}>
|
||||
<nav className="p-4 space-y-6">
|
||||
{DOCS_CONFIG.navigation.map((section, sectionIndex) => {
|
||||
const SectionIcon = SECTION_ICONS[section.title] || BookOpen;
|
||||
|
||||
return (
|
||||
<div key={sectionIndex}>
|
||||
{/* Section title */}
|
||||
<button
|
||||
onClick={() => toggleSection(sectionIndex)}
|
||||
className="flex items-center justify-between w-full text-sm font-semibold text-gray-900 mb-2 hover:text-[#E68A6E] transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<SectionIcon className="w-4 h-4" />
|
||||
{section.title}
|
||||
</span>
|
||||
{openSections.includes(sectionIndex) ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Section items */}
|
||||
{openSections.includes(sectionIndex) && (
|
||||
<ul className="space-y-1">
|
||||
{section.items.map((item, itemIndex) => {
|
||||
const ItemIcon = ITEM_ICONS[item.title] || BookOpen;
|
||||
|
||||
return (
|
||||
<li key={itemIndex}>
|
||||
<Link
|
||||
href={buildHref(item.slug)}
|
||||
onClick={handleLinkClick}
|
||||
className={`flex items-center gap-2 px-3 py-2 text-sm rounded-lg transition-colors ${
|
||||
isActive(item.slug)
|
||||
? "bg-[#E68A6E]/10 text-[#E68A6E] font-medium"
|
||||
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
<ItemIcon className="w-4 h-4" />
|
||||
{item.title}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { List } from "lucide-react";
|
||||
|
||||
export default function DocsToc({ headings }) {
|
||||
const [activeId, setActiveId] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveId(entry.target.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ rootMargin: "-80px 0px -80% 0px" }
|
||||
);
|
||||
|
||||
headings.forEach(({ id }) => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) observer.observe(element);
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [headings]);
|
||||
|
||||
if (!headings || headings.length === 0) return null;
|
||||
|
||||
return (
|
||||
<aside className="hidden xl:block w-64 border-l bg-white border-gray-200 h-[calc(100vh-4rem)] sticky top-16 overflow-y-auto">
|
||||
<nav className="p-4">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-gray-900 mb-3">
|
||||
<List className="w-4 h-4" />
|
||||
On this page
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{headings.map((heading, idx) => (
|
||||
<li key={`${heading.id}-${idx}`}>
|
||||
<a
|
||||
href={`#${heading.id}`}
|
||||
className={`block text-sm transition-colors ${
|
||||
heading.level === 3 ? "pl-4" : ""
|
||||
} ${
|
||||
activeId === heading.id
|
||||
? "text-[#E68A6E] font-medium"
|
||||
: "text-gray-600 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{heading.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { Globe, X } from "lucide-react";
|
||||
import { LANGUAGES, getLanguage, DEFAULT_LANG } from "@/constants/languages";
|
||||
|
||||
function extractLangFromPath(pathname) {
|
||||
const match = pathname.match(/^\/([^/]+)(?:\/(.*))?$/);
|
||||
if (!match) return { lang: DEFAULT_LANG, rest: "" };
|
||||
return { lang: match[1], rest: match[2] || "" };
|
||||
}
|
||||
|
||||
export default function LanguageSwitcher({ currentLang }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const current = getLanguage(currentLang);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Lock body scroll when modal is open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const switchTo = (code) => {
|
||||
const { rest } = extractLangFromPath(pathname);
|
||||
const target = rest ? `/${code}/${rest}` : `/${code}`;
|
||||
setOpen(false);
|
||||
router.push(target);
|
||||
};
|
||||
|
||||
const modal = open && (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 p-4" onClick={() => setOpen(false)}>
|
||||
<div
|
||||
className="bg-white rounded-xl shadow-2xl max-w-md w-full max-h-[80vh] overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||
<h2 className="font-bold text-lg text-gray-900">Select Language</h2>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-2 overflow-y-auto max-h-[60vh]">
|
||||
{LANGUAGES.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => switchTo(lang.code)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors ${
|
||||
lang.code === currentLang
|
||||
? "bg-[#E68A6E]/10 text-[#E68A6E] font-medium"
|
||||
: "text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl">{lang.flag}</span>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{lang.native}</div>
|
||||
<div className="text-xs text-gray-500">{lang.name}</div>
|
||||
</div>
|
||||
{lang.code === currentLang && <span className="text-xs">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 text-sm text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
aria-label="Switch language"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{current.flag} {current.native}</span>
|
||||
<span className="sm:hidden">{current.flag}</span>
|
||||
</button>
|
||||
|
||||
{mounted && open && createPortal(modal, document.body)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user