Feat : Gitbook

This commit is contained in:
decolua
2026-05-11 11:50:24 +07:00
parent 7ad538bcf2
commit fd92af77a0
124 changed files with 34154 additions and 4 deletions
+13
View File
@@ -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>
);
}
+78
View File
@@ -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>
</>
)}
</>
);
}
+25
View File
@@ -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>
);
}
+122
View File
@@ -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>
);
}
+59
View File
@@ -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>
);
}
+96
View File
@@ -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)}
</>
);
}