chore(web): move site under packages
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu, Search } from "lucide-react"
|
||||
import { Link } from "@/i18n/routing"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { DOC_SECTION_IDS, type DocSectionId } from "@/lib/docs-sections"
|
||||
|
||||
type DocsShellSection = {
|
||||
id: DocSectionId
|
||||
title: string
|
||||
}
|
||||
|
||||
export function DocsShell({
|
||||
mobileHeader,
|
||||
searchPlaceholder,
|
||||
sections,
|
||||
children,
|
||||
}: {
|
||||
mobileHeader: string
|
||||
searchPlaceholder: string
|
||||
sections: DocsShellSection[]
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [searchQuery, setSearchQuery] = React.useState("")
|
||||
const [activeSection, setActiveSection] = React.useState<DocSectionId>("overview")
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false)
|
||||
|
||||
const activeSectionRef = React.useRef<DocSectionId>("overview")
|
||||
|
||||
const findHashSectionId = React.useCallback((hash: string): DocSectionId | null => {
|
||||
const id = hash.replace(/^#/, "")
|
||||
return DOC_SECTION_IDS.find((sectionId) => sectionId === id) ?? null
|
||||
}, [])
|
||||
|
||||
const scrollToSection = React.useCallback((id: DocSectionId, updateHash = true) => {
|
||||
const element = document.getElementById(id)
|
||||
if (!element) return
|
||||
|
||||
window.scrollTo({ top: element.offsetTop - 80, behavior: "auto" })
|
||||
if (updateHash && window.location.hash !== `#${id}`) {
|
||||
window.history.pushState(null, "", `#${id}`)
|
||||
}
|
||||
|
||||
activeSectionRef.current = id
|
||||
setActiveSection(id)
|
||||
setIsMobileMenuOpen(false)
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
activeSectionRef.current = activeSection
|
||||
}, [activeSection])
|
||||
|
||||
React.useEffect(() => {
|
||||
const scrollToHashSection = () => {
|
||||
const sectionId = findHashSectionId(window.location.hash)
|
||||
if (!sectionId) return
|
||||
|
||||
window.requestAnimationFrame(() => scrollToSection(sectionId, false))
|
||||
}
|
||||
|
||||
scrollToHashSection()
|
||||
window.addEventListener("hashchange", scrollToHashSection)
|
||||
|
||||
return () => window.removeEventListener("hashchange", scrollToHashSection)
|
||||
}, [findHashSectionId, scrollToSection])
|
||||
|
||||
const filteredSections = React.useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase()
|
||||
if (!query) return sections
|
||||
return sections.filter((section) => section.title.toLowerCase().includes(query))
|
||||
}, [searchQuery, sections])
|
||||
|
||||
React.useEffect(() => {
|
||||
const sectionEls = DOC_SECTION_IDS.map((id) => document.getElementById(id))
|
||||
let rafId: number | null = null
|
||||
|
||||
const handleScroll = () => {
|
||||
if (rafId !== null) return
|
||||
|
||||
rafId = window.requestAnimationFrame(() => {
|
||||
rafId = null
|
||||
|
||||
const scrollPosition = window.scrollY + 100
|
||||
let nextActive: DocSectionId | null = null
|
||||
|
||||
for (let i = 0; i < sectionEls.length; i++) {
|
||||
const el = sectionEls[i]
|
||||
if (!el) continue
|
||||
if (el.offsetTop <= scrollPosition && el.offsetTop + el.offsetHeight > scrollPosition) {
|
||||
nextActive = el.id as DocSectionId
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (nextActive && nextActive !== activeSectionRef.current) {
|
||||
activeSectionRef.current = nextActive
|
||||
setActiveSection(nextActive)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true })
|
||||
handleScroll()
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll)
|
||||
if (rafId !== null) window.cancelAnimationFrame(rafId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDocsClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
if (!(event.target instanceof Element)) return
|
||||
|
||||
const anchor = event.target.closest("a[href]")
|
||||
if (!(anchor instanceof HTMLAnchorElement)) return
|
||||
|
||||
const sectionId = findHashSectionId(anchor.hash)
|
||||
if (!sectionId) return
|
||||
|
||||
const href = anchor.getAttribute("href")
|
||||
const isSamePath =
|
||||
anchor.origin === window.location.origin && anchor.pathname === window.location.pathname
|
||||
if (!href?.startsWith("#") && !isSamePath) return
|
||||
|
||||
event.preventDefault()
|
||||
scrollToSection(sectionId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background text-foreground flex min-h-screen">
|
||||
<div className="bg-background/95 fixed top-0 right-0 left-0 z-50 flex items-center justify-between border-b px-4 py-3 backdrop-blur md:hidden">
|
||||
<Link href="/" className="font-bold">
|
||||
{mobileHeader}
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
aria-label="Toggle sidebar menu"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<aside
|
||||
className={`bg-background fixed inset-y-0 left-0 z-40 w-64 transform border-r transition-transform duration-200 ease-in-out md:translate-x-0 ${isMobileMenuOpen ? "translate-x-0" : "-translate-x-full"} pt-16`}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="p-4">
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-2.5 left-2 h-4 w-4" />
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
className="pl-8"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto px-2 pb-4">
|
||||
<ul className="space-y-1">
|
||||
{filteredSections.map((section) => (
|
||||
<li key={section.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollToSection(section.id)}
|
||||
className={`w-full rounded-md px-3 py-2 text-left text-sm font-medium transition-colors ${
|
||||
activeSection === section.id
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
} `}
|
||||
>
|
||||
{section.title}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main
|
||||
className="flex-1 px-4 pt-20 pb-20 md:ml-64 md:px-8 md:pt-8"
|
||||
onClickCapture={handleDocsClick}
|
||||
>
|
||||
<div className="mx-auto max-w-4xl space-y-12">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user