refactor: Migrate documentation content, rebuild UI components, and update core architecture.

This commit is contained in:
gitfromwildan
2026-03-10 01:38:58 +07:00
parent aac81dff8a
commit ab755844a3
132 changed files with 3947 additions and 12862 deletions

View File

@@ -2,12 +2,13 @@
import { usePathname, useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import { ROUTES, EachRoute } from "@/lib/routes-config";
import { ROUTES, EachRoute } from "@/lib/routes";
import { cn } from "@/lib/utils";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import * as LucideIcons from "lucide-react";
import { ChevronsUpDown, Check, type LucideIcon } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
interface ContextPopoverProps {
className?: string;
@@ -62,7 +63,7 @@ export default function ContextPopover({ className }: ContextPopoverProps) {
<Button
variant="ghost"
className={cn(
"w-full max-w-[240px] cursor-pointer flex items-center justify-between font-semibold text-foreground px-0 pt-8",
"w-full cursor-pointer flex items-center justify-between font-semibold text-foreground px-2 py-4 border border-muted",
"hover:bg-transparent hover:text-foreground",
className
)}
@@ -74,7 +75,7 @@ export default function ContextPopover({ className }: ContextPopoverProps) {
</span>
)}
<span className="truncate text-sm">
{activeRoute?.context?.title || activeRoute?.title || 'Select context'}
{activeRoute?.context?.title || activeRoute?.title || <Skeleton className="h-3.5 w-24" />}
</span>
</div>
<ChevronsUpDown className="h-4 w-4 text-foreground/50" />
@@ -96,7 +97,7 @@ export default function ContextPopover({ className }: ContextPopoverProps) {
key={route.href}
onClick={() => router.push(contextPath)}
className={cn(
"relative flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm",
"relative flex w-full items-center gap-2 cursor-pointer rounded px-2 py-1.5 text-sm",
"text-left outline-none transition-colors",
isActive
? "bg-primary/20 text-primary dark:bg-accent/20 dark:text-accent"

View File

@@ -1,26 +1,27 @@
"use client";
"use client"
import React from "react";
import { DocSearch } from "@docsearch/react";
import { DocSearch } from "@docsearch/react"
import { algoliaConfig } from "@/lib/search/algolia"
import { cn } from "@/lib/utils"
export default function DocSearchComponent() {
const appId = process.env.NEXT_PUBLIC_ALGOLIA_DOCSEARCH_APP_ID;
const apiKey = process.env.NEXT_PUBLIC_ALGOLIA_DOCSEARCH_API_KEY;
const indexName = process.env.NEXT_PUBLIC_ALGOLIA_DOCSEARCH_INDEX_NAME;
interface AlgoliaSearchProps {
className?: string
}
export default function AlgoliaSearch({ className }: AlgoliaSearchProps) {
const { appId, apiKey, indexName } = algoliaConfig
if (!appId || !apiKey || !indexName) {
console.error(
"DocSearch credentials are not set in the environment variables."
);
console.error("DocSearch credentials are not set in the environment variables.")
return (
<button className="text-sm text-muted-foreground" disabled>
<button className="text-muted-foreground text-sm" disabled>
Search... (misconfigured)
</button>
);
)
}
return (
<div className="docsearch">
<div className={cn("docsearch", className)}>
<DocSearch
appId={appId}
apiKey={apiKey}
@@ -28,5 +29,5 @@ export default function DocSearchComponent() {
placeholder="Type something to search..."
/>
</div>
);
}
)
}

View File

@@ -10,7 +10,7 @@ import { Fragment } from "react";
export default function DocsBreadcrumb({ paths }: { paths: string[] }) {
return (
<div className="pb-5 max-lg:pt-12">
<div className="pb-5 max-lg:pt-6">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
@@ -21,10 +21,7 @@ export default function DocsBreadcrumb({ paths }: { paths: string[] }) {
<BreadcrumbSeparator />
<BreadcrumbItem>
{index < paths.length - 1 ? (
<BreadcrumbLink
className="a"
href={`/docs/${paths.slice(0, index + 1).join("/")}`}
>
<BreadcrumbLink className="a">
{toTitleCase(path)}
</BreadcrumbLink>
) : (

63
components/DocsMenu.tsx Normal file
View File

@@ -0,0 +1,63 @@
"use client";
import { ROUTES, EachRoute } from "@/lib/routes";
import SubLink from "./sublink";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
interface DocsMenuProps {
isSheet?: boolean;
className?: string;
}
// Get the current context from the path
function getCurrentContext(path: string): string | undefined {
if (!path.startsWith('/docs')) return undefined;
// Extract the first segment after /docs/
const match = path.match(/^\/docs\/([^/]+)/);
return match ? match[1] : undefined;
}
// Get the route that matches the current context
function getContextRoute(contextPath: string): EachRoute | undefined {
return ROUTES.find(route => {
const normalizedHref = route.href.replace(/^\/+|\/+$/g, '');
return normalizedHref === contextPath;
});
}
export default function DocsMenu({ isSheet = false, className = "" }: DocsMenuProps) {
const pathname = usePathname();
// Skip rendering if not on a docs page
if (!pathname.startsWith("/docs")) return null;
// Get the current context
const currentContext = getCurrentContext(pathname);
// Get the route for the current context
const contextRoute = currentContext ? getContextRoute(currentContext) : undefined;
// If no context route is found, don't render anything
if (!contextRoute) return null;
return (
<nav
aria-label="Documentation navigation"
className={cn("transition-all duration-200", className)}
>
<ul className="flex flex-col gap-1.5 py-4">
{/* Display only the items from the current context */}
<li key={contextRoute.title}>
<SubLink
{...contextRoute}
href={`/docs${contextRoute.href}`}
level={0}
isSheet={isSheet}
/>
</li>
</ul>
</nav>
);
}

44
components/DocsNavbar.tsx Normal file
View File

@@ -0,0 +1,44 @@
"use client";
import { ArrowUpRight } from "lucide-react";
import Anchor from "@/components/anchor";
import docuConfig from "@/docu.json";
interface NavbarItem {
title: string;
href: string;
}
const { navbar } = docuConfig;
export function DocsNavbar() {
// Show all nav items
const navItems = navbar?.menu || [];
return (
<div className="hidden lg:flex items-center justify-end gap-6 h-14 px-8 mt-2">
{/* Navigation Links */}
<div className="flex items-center gap-6 text-sm font-medium text-foreground/80">
{navItems.map((item: NavbarItem) => {
const isExternal = item.href.startsWith("http");
return (
<Anchor
key={`${item.title}-${item.href}`}
href={item.href}
absolute
activeClassName="text-primary dark:text-accent md:font-semibold font-medium"
className="flex items-center gap-1 hover:text-foreground transition-colors"
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
>
{item.title}
{isExternal && <ArrowUpRight className="w-3.5 h-3.5" />}
</Anchor>
);
})}
</div>
</div>
);
}
export default DocsNavbar;

205
components/DocsSidebar.tsx Normal file
View File

@@ -0,0 +1,205 @@
"use client"
import { ChevronDown, ChevronUp, PanelRight, MoreVertical } from "lucide-react"
import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTrigger } from "@/components/ui/sheet"
import DocsMenu from "@/components/DocsMenu"
import { ModeToggle } from "@/components/ThemeToggle"
import { DialogTitle, DialogDescription } from "@/components/ui/dialog"
import ContextPopover from "@/components/ContextPopover"
import TocObserver from "./TocObserver"
import * as React from "react"
import { useRef, useMemo } from "react"
import { usePathname } from "next/navigation"
import { Button } from "./ui/button"
import { motion, AnimatePresence } from "framer-motion"
import { useActiveSection } from "@/hooks"
import { TocItem } from "@/lib/toc"
import Search from "@/components/SearchBox"
import { NavMenu } from "@/components/navbar"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
interface MobTocProps {
tocs: TocItem[]
title?: string
}
const useClickOutside = (ref: React.RefObject<HTMLElement | null>, callback: () => void) => {
const handleClick = React.useCallback(
(event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback()
}
},
[ref, callback]
)
React.useEffect(() => {
document.addEventListener("mousedown", handleClick)
return () => {
document.removeEventListener("mousedown", handleClick)
}
}, [handleClick])
}
export default function MobToc({ tocs, title }: MobTocProps) {
const pathname = usePathname()
const [isExpanded, setIsExpanded] = React.useState(false)
const tocRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
// Use custom hooks
const { activeId, setActiveId } = useActiveSection(tocs)
// Only show on /docs pages
const isDocsPage = useMemo(() => pathname?.startsWith("/docs"), [pathname])
// Get title from active section if available, otherwise document title
const activeSection = useMemo(() => {
return tocs.find((toc) => toc.href.slice(1) === activeId)
}, [tocs, activeId])
const displayTitle = activeSection?.text || title || "On this page"
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => {
setMounted(true)
}, [])
// Toggle expanded state
const toggleExpanded = React.useCallback((e: React.MouseEvent) => {
e.stopPropagation()
setIsExpanded((prev) => !prev)
}, [])
// Close TOC when clicking outside
useClickOutside(tocRef, () => {
if (isExpanded) {
setIsExpanded(false)
}
})
// Handle body overflow when TOC is expanded
React.useEffect(() => {
if (isExpanded) {
document.body.style.overflow = "hidden"
} else {
document.body.style.overflow = ""
}
return () => {
document.body.style.overflow = ""
}
}, [isExpanded])
// Don't render anything if not on docs page
if (!isDocsPage || !mounted) return null
const chevronIcon = isExpanded ? (
<ChevronUp className="text-muted-foreground h-4 w-4 shrink-0" />
) : (
<ChevronDown className="text-muted-foreground h-4 w-4 shrink-0" />
)
return (
<AnimatePresence>
<motion.div
ref={tocRef}
className="sticky top-0 z-50 -mx-4 -mt-4 mb-4 lg:hidden"
initial={{ y: -100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -100, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
<div className="bg-background/95 border-muted dark:border-foreground/10 dark:bg-background w-full border-b shadow-sm backdrop-blur-sm">
<div className="p-2">
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
aria-label="Navigation menu"
>
<MoreVertical className="text-muted-foreground h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="flex min-w-[160px] flex-col gap-1 p-2"
>
<NavMenu />
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="sm"
className="-mx-1 h-auto flex-1 justify-between rounded-md px-2 py-2 hover:bg-transparent hover:text-inherit"
onClick={toggleExpanded}
aria-label={isExpanded ? "Collapse table of contents" : "Expand table of contents"}
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium capitalize">{displayTitle}</span>
</div>
{chevronIcon}
</Button>
<Search />
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="hidden max-lg:flex">
<PanelRight className="text-muted-foreground h-6 w-6 shrink-0" />
</Button>
</SheetTrigger>
<SheetContent className="flex w-full flex-col gap-4 px-0 lg:w-auto" side="right">
<DialogTitle className="sr-only">Navigation Menu</DialogTitle>
<DialogDescription className="sr-only">
Main navigation menu with links to different sections
</DialogDescription>
<SheetHeader>
<SheetClose className="px-4" asChild>
<div className="flex items-center justify-between">
<div className="mr-8">
<ModeToggle />
</div>
</div>
</SheetClose>
</SheetHeader>
<div className="flex flex-col gap-4 overflow-y-auto">
<div className="mx-2 space-y-2 px-5">
<ContextPopover />
<DocsMenu isSheet />
</div>
</div>
</SheetContent>
</Sheet>
</div>
<AnimatePresence>
{isExpanded && (
<motion.div
ref={contentRef}
className="-mx-1 mt-2 max-h-[60vh] overflow-y-auto px-1 pb-2"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
{tocs?.length ? (
<TocObserver data={tocs} activeId={activeId} onActiveIdChange={setActiveId} />
) : (
<p className="text-muted-foreground py-2 text-sm">No headings</p>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</motion.div>
</AnimatePresence>
)
}

26
components/Github.tsx Normal file
View File

@@ -0,0 +1,26 @@
import Link from 'next/link';
import docuConfig from "@/docu.json";
export default function GitHubButton() {
const { repository } = docuConfig;
return (
<Link
href={repository.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center rounded-full p-1 text-sm font-medium text-muted-foreground border no-underline hover:bg-muted/50 transition-colors"
aria-label="View on GitHub"
>
<svg
height="16"
width="16"
viewBox="0 0 16 16"
aria-hidden="true"
className="fill-current"
>
<path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 005.47 7.59c.4.07.55-.17.55-.38v-1.32c-2.22.48-2.69-1.07-2.69-1.07-.36-.92-.89-1.17-.89-1.17-.73-.5.06-.49.06-.49.81.06 1.23.83 1.23.83.72 1.23 1.89.88 2.35.67.07-.52.28-.88.5-1.08-1.77-.2-3.64-.88-3.64-3.93 0-.87.31-1.58.82-2.14-.08-.2-.36-1.01.08-2.12 0 0 .67-.21 2.2.82a7.7 7.7 0 012.01-.27 7.7 7.7 0 012.01.27c1.53-1.03 2.2-.82 2.2-.82.44 1.11.16 1.92.08 2.12.51.56.82 1.27.82 2.14 0 3.06-1.87 3.73-3.65 3.93.29.25.54.73.54 1.48v2.2c0 .21.15.46.55.38A8 8 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
</Link>
);
}

View File

@@ -1,44 +0,0 @@
'use client';
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
const GitHubStarButton: React.FC = () => {
const [stars, setStars] = useState<number | null>(null);
useEffect(() => {
fetch('https://api.github.com/repos/gitfromwildan/docubook')
.then((res) => res.json())
.then((data) => {
if (data.stargazers_count !== undefined) {
setStars(data.stargazers_count);
}
})
.catch((error) => console.error('Failed to fetch stars:', error));
}, []);
const formatStars = (count: number) =>
count >= 1000 ? `${(count / 1000).toFixed(1)}K` : `${count}`;
return (
<Link
href="https://github.com/gitfromwildan/docubook"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center rounded-full px-3 py-1.5 text-sm font-medium text-muted-foreground border no-underline"
>
<svg
height="16"
width="16"
viewBox="0 0 16 16"
aria-hidden="true"
className="fill-current mr-1.5"
>
<path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 005.47 7.59c.4.07.55-.17.55-.38v-1.32c-2.22.48-2.69-1.07-2.69-1.07-.36-.92-.89-1.17-.89-1.17-.73-.5.06-.49.06-.49.81.06 1.23.83 1.23.83.72 1.23 1.89.88 2.35.67.07-.52.28-.88.5-1.08-1.77-.2-3.64-.88-3.64-3.93 0-.87.31-1.58.82-2.14-.08-.2-.36-1.01.08-2.12 0 0 .67-.21 2.2.82a7.7 7.7 0 012.01-.27 7.7 7.7 0 012.01.27c1.53-1.03 2.2-.82 2.2-.82.44 1.11.16 1.92.08 2.12.51.56.82 1.27.82 2.14 0 3.06-1.87 3.73-3.65 3.93.29.25.54.73.54 1.48v2.2c0 .21.15.46.55.38A8 8 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
{stars !== null ? formatStars(stars) : '...'}
</Link>
);
};
export default GitHubStarButton;

View File

@@ -19,10 +19,14 @@ export function ScrollToTop({
const [isVisible, setIsVisible] = useState(false);
const checkScroll = useCallback(() => {
// Check local scroll container or document
const container = document.getElementById("scroll-container");
const scrollY = container ? container.scrollTop : window.scrollY;
// Calculate 50% of viewport height
const halfViewportHeight = window.innerHeight * 0.5;
// Check if scrolled past half viewport height (plus any offset)
const scrolledPastHalfViewport = window.scrollY > (halfViewportHeight + offset);
const scrolledPastHalfViewport = scrollY > (halfViewportHeight + offset);
// Only update state if it changes to prevent unnecessary re-renders
if (scrolledPastHalfViewport !== isVisible) {
@@ -42,21 +46,24 @@ export function ScrollToTop({
timeoutId = setTimeout(checkScroll, 100);
};
window.addEventListener('scroll', handleScroll, { passive: true });
const container = document.getElementById("scroll-container") || window;
container.addEventListener('scroll', handleScroll, { passive: true });
// Cleanup
return () => {
window.removeEventListener('scroll', handleScroll);
container.removeEventListener('scroll', handleScroll);
if (timeoutId) clearTimeout(timeoutId);
};
}, [checkScroll]);
const scrollToTop = useCallback((e: React.MouseEvent) => {
e.preventDefault();
window.scrollTo({
top: 0,
behavior: 'smooth'
});
const container = document.getElementById("scroll-container");
if (container) {
container.scrollTo({ top: 0, behavior: 'smooth' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, []);
if (!isVisible) return null;
@@ -75,11 +82,11 @@ export function ScrollToTop({
onClick={scrollToTop}
className={cn(
"inline-flex items-center text-sm text-muted-foreground hover:text-foreground",
"transition-all duration-200 hover:translate-y-[-1px]"
"transition-all duration-200 hover:translate-y-px"
)}
aria-label="Scroll to top"
>
{showIcon && <ArrowUpIcon className="mr-1 h-3.5 w-3.5 flex-shrink-0" />}
{showIcon && <ArrowUpIcon className="mr-1 h-3.5 w-3.5 shrink-0" />}
<span>Scroll to Top</span>
</Link>
</div>

39
components/SearchBox.tsx Normal file
View File

@@ -0,0 +1,39 @@
"use client"
import { Dialog } from "@/components/ui/dialog"
import { SearchTrigger } from "@/components/SearchTrigger"
import { SearchModal } from "@/components/SearchModal"
import AlgoliaSearch from "@/components/DocSearch"
import { useSearch } from "./SearchContext"
import { DialogTrigger } from "@/components/ui/dialog"
import { searchConfig } from "@/lib/search/config"
interface SearchProps {
/**
* Override the search type from config.
* If not provided, uses the config value.
*/
type?: "default" | "algolia"
className?: string
}
export default function Search({ type, className }: SearchProps) {
const { isOpen, setIsOpen } = useSearch()
const searchType = type ?? searchConfig.type
if (searchType === "algolia") {
return <AlgoliaSearch className={className} />
}
// Logic for 'default' search
return (
<div className={className}>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<SearchTrigger className={className} />
</DialogTrigger>
<SearchModal isOpen={isOpen} setIsOpen={setIsOpen} />
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,47 @@
"use client";
import { createContext, useContext, useState, useEffect, useCallback } from "react";
interface SearchContextType {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
toggle: () => void;
}
const SearchContext = createContext<SearchContextType | undefined>(undefined);
export function SearchProvider({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const toggle = useCallback(() => {
setIsOpen((prev) => !prev);
}, []);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === "k") {
event.preventDefault();
toggle();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [toggle]);
return (
<SearchContext.Provider value={{ isOpen, setIsOpen, toggle }}>
{children}
</SearchContext.Provider>
);
}
export function useSearch() {
const context = useContext(SearchContext);
if (!context) {
throw new Error("useSearch must be used within a SearchProvider");
}
return context;
}

View File

@@ -1,12 +1,13 @@
"use client";
"use client"
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useRef } from "react";
import { ArrowUpIcon, ArrowDownIcon, CornerDownLeftIcon, FileTextIcon } from "lucide-react";
import Anchor from "./anchor";
import { advanceSearch, cn } from "@/lib/utils";
import { ScrollArea } from "@/components/ui/scroll-area";
import { page_routes } from "@/lib/routes-config";
import { useRouter } from "next/navigation"
import { useEffect, useMemo, useState, useRef } from "react"
import { ArrowUpIcon, ArrowDownIcon, CornerDownLeftIcon, FileTextIcon } from "lucide-react"
import Anchor from "./anchor"
import { cn } from "@/lib/utils"
import { advanceSearch } from "@/lib/search/built-in"
import { ScrollArea } from "@/components/ui/scroll-area"
import { page_routes } from "@/lib/routes"
import {
DialogContent,
DialogHeader,
@@ -14,63 +15,63 @@ import {
DialogClose,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
} from "@/components/ui/dialog"
type ContextInfo = {
icon: string;
description: string;
title?: string;
};
icon: string
description: string
title?: string
}
type SearchResult = {
title: string;
href: string;
noLink?: boolean;
items?: undefined;
score?: number;
context?: ContextInfo;
};
title: string
href: string
noLink?: boolean
items?: undefined
score?: number
context?: ContextInfo
}
const paddingMap = {
1: "pl-2",
2: "pl-4",
3: "pl-10",
} as const;
} as const
interface SearchModalProps {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
isOpen: boolean
setIsOpen: (open: boolean) => void
}
export function SearchModal({ isOpen, setIsOpen }: SearchModalProps) {
const router = useRouter();
const [searchedInput, setSearchedInput] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const router = useRouter()
const [searchedInput, setSearchedInput] = useState("")
const [selectedIndex, setSelectedIndex] = useState(0)
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
useEffect(() => {
if (!isOpen) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSearchedInput("");
setSearchedInput("")
}
}, [isOpen]);
}, [isOpen])
const filteredResults = useMemo<SearchResult[]>(() => {
const trimmedInput = searchedInput.trim();
const trimmedInput = searchedInput.trim()
if (trimmedInput.length < 3) {
return page_routes
.filter((route) => !route.href.endsWith('/'))
.filter((route) => !route.href.endsWith("/"))
.slice(0, 6)
.map((route: { title: string; href: string; noLink?: boolean; context?: ContextInfo }) => ({
title: route.title,
href: route.href,
noLink: route.noLink,
context: route.context,
}));
}))
}
return advanceSearch(trimmedInput) as unknown as SearchResult[];
}, [searchedInput]);
return advanceSearch(trimmedInput) as unknown as SearchResult[]
}, [searchedInput])
// useEffect(() => {
// setSelectedIndex(0);
@@ -78,39 +79,39 @@ export function SearchModal({ isOpen, setIsOpen }: SearchModalProps) {
useEffect(() => {
const handleNavigation = (event: KeyboardEvent) => {
if (!isOpen || filteredResults.length === 0) return;
if (!isOpen || filteredResults.length === 0) return
if (event.key === "ArrowDown") {
event.preventDefault();
setSelectedIndex((prev) => (prev + 1) % filteredResults.length);
event.preventDefault()
setSelectedIndex((prev) => (prev + 1) % filteredResults.length)
} else if (event.key === "ArrowUp") {
event.preventDefault();
setSelectedIndex((prev) => (prev - 1 + filteredResults.length) % filteredResults.length);
event.preventDefault()
setSelectedIndex((prev) => (prev - 1 + filteredResults.length) % filteredResults.length)
} else if (event.key === "Enter") {
event.preventDefault();
const selectedItem = filteredResults[selectedIndex];
event.preventDefault()
const selectedItem = filteredResults[selectedIndex]
if (selectedItem) {
router.push(selectedItem.href);
setIsOpen(false);
router.push(`/docs${selectedItem.href}`)
setIsOpen(false)
}
}
};
}
window.addEventListener("keydown", handleNavigation);
return () => window.removeEventListener("keydown", handleNavigation);
}, [isOpen, filteredResults, selectedIndex, router, setIsOpen]);
window.addEventListener("keydown", handleNavigation)
return () => window.removeEventListener("keydown", handleNavigation)
}, [isOpen, filteredResults, selectedIndex, router, setIsOpen])
useEffect(() => {
if (itemRefs.current[selectedIndex]) {
itemRefs.current[selectedIndex]?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
})
}
}, [selectedIndex]);
}, [selectedIndex])
return (
<DialogContent className="p-0 max-w-[650px] sm:top-[38%] top-[45%] !rounded-md">
<DialogContent className="rounded-md! top-[45%] max-w-[650px] p-0 sm:top-[38%]">
<DialogHeader>
<DialogTitle className="sr-only">Search Documentation</DialogTitle>
<DialogDescription className="sr-only">Search through the documentation</DialogDescription>
@@ -119,84 +120,81 @@ export function SearchModal({ isOpen, setIsOpen }: SearchModalProps) {
<input
value={searchedInput}
onChange={(e) => {
setSearchedInput(e.target.value);
setSelectedIndex(0);
setSearchedInput(e.target.value)
setSelectedIndex(0)
}}
placeholder="Type something to search..."
autoFocus
className="h-14 px-6 bg-transparent border-b text-[14px] outline-none w-full"
className="h-14 w-full border-b bg-transparent px-6 text-[14px] outline-none"
aria-label="Search documentation"
/>
{filteredResults.length == 0 && searchedInput && (
<p className="text-muted-foreground mx-auto mt-2 text-sm">
No results found for{" "}
<span className="text-primary">{`"${searchedInput}"`}</span>
No results found for <span className="text-primary">{`"${searchedInput}"`}</span>
</p>
)}
<ScrollArea className="max-h-[400px] overflow-y-auto">
<div className="flex flex-col items-start overflow-y-auto sm:px-2 px-1 pb-4">
<div className="flex flex-col items-start overflow-y-auto px-1 pb-4 sm:px-2">
{filteredResults.map((item, index) => {
const level = (item.href.split("/").slice(1).length - 1) as keyof typeof paddingMap;
const paddingClass = paddingMap[level] || 'pl-2';
const isActive = index === selectedIndex;
const level = (item.href.split("/").slice(1).length - 1) as keyof typeof paddingMap
const paddingClass = paddingMap[level] || "pl-2"
const isActive = index === selectedIndex
return (
<DialogClose key={item.href} asChild>
<Anchor
ref={(el) => {
itemRefs.current[index] = el as HTMLDivElement | null;
itemRefs.current[index] = el as HTMLDivElement | null
}}
className={cn(
"dark:hover:bg-accent/15 hover:bg-accent/10 w-full px-3 rounded-sm text-sm flex items-center gap-2.5",
"dark:hover:bg-accent/15 hover:bg-accent/10 flex w-full items-center gap-2.5 rounded-sm px-3 text-sm",
isActive && "bg-primary/20 dark:bg-primary/30",
paddingClass
)}
href={item.href}
href={`/docs${item.href}`}
tabIndex={-1}
>
<div
className={cn(
"flex items-center w-full h-full py-3 gap-1.5 px-2 justify-between",
"flex h-full w-full items-center justify-between gap-1.5 px-2 py-3",
level > 1 && "border-l pl-4"
)}
>
<div className="flex items-center">
<FileTextIcon className="h-[1.1rem] w-[1.1rem] mr-1" />
<FileTextIcon className="mr-1 h-[1.1rem] w-[1.1rem]" />
<span>{item.title}</span>
</div>
{isActive && (
<div className="hidden md:flex items-center text-xs text-muted-foreground">
<div className="text-muted-foreground hidden items-center text-xs md:flex">
<span>Return</span>
<CornerDownLeftIcon className="h-3 w-3 ml-1" />
<CornerDownLeftIcon className="ml-1 h-3 w-3" />
</div>
)}
</div>
</Anchor>
</DialogClose>
);
)
})}
</div>
</ScrollArea>
<DialogFooter className="md:flex md:justify-start hidden h-14 px-6 bg-transparent border-t text-[14px] outline-none">
<DialogFooter className="hidden h-14 border-t bg-transparent px-6 text-[14px] outline-none md:flex md:justify-start">
<div className="flex items-center gap-2">
<span className="dark:bg-accent/15 bg-slate-200 border rounded p-2">
<ArrowUpIcon className="w-3 h-3" />
<span className="dark:bg-accent/15 rounded border bg-slate-200 p-2">
<ArrowUpIcon className="h-3 w-3" />
</span>
<span className="dark:bg-accent/15 bg-slate-200 border rounded p-2">
<ArrowDownIcon className="w-3 h-3" />
<span className="dark:bg-accent/15 rounded border bg-slate-200 p-2">
<ArrowDownIcon className="h-3 w-3" />
</span>
<p className="text-muted-foreground">to navigate</p>
<span className="dark:bg-accent/15 bg-slate-200 border rounded p-2">
<CornerDownLeftIcon className="w-3 h-3" />
<span className="dark:bg-accent/15 rounded border bg-slate-200 p-2">
<CornerDownLeftIcon className="h-3 w-3" />
</span>
<p className="text-muted-foreground">to select</p>
<span className="dark:bg-accent/15 bg-slate-200 border rounded px-2 py-1">
esc
</span>
<span className="dark:bg-accent/15 rounded border bg-slate-200 px-2 py-1">esc</span>
<p className="text-muted-foreground">to close</p>
</div>
</DialogFooter>
</DialogContent>
);
}
)
}

View File

@@ -1,31 +1,36 @@
"use client";
"use client"
import { CommandIcon, SearchIcon } from "lucide-react";
import { DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { CommandIcon, SearchIcon } from "lucide-react"
import { DialogTrigger } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
export function SearchTrigger() {
interface SearchTriggerProps {
className?: string
}
export function SearchTrigger({ className }: SearchTriggerProps) {
return (
<DialogTrigger asChild>
<div className="relative flex-1 cursor-pointer max-w-[140px]">
<div className={cn("relative flex-1 cursor-pointer", className)}>
<div className="flex items-center">
<div className="md:hidden p-2 -ml-2">
<SearchIcon className="h-5 w-5 text-muted-foreground" />
<div className="-ml-2 block p-2 lg:hidden">
<SearchIcon className="text-muted-foreground h-6 w-6" />
</div>
<div className="hidden md:block w-full">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<div className="hidden w-full lg:block">
<SearchIcon className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
className="w-full rounded-full dark:bg-background/95 bg-background border h-9 pl-10 pr-0 sm:pr-4 text-sm shadow-sm overflow-ellipsis"
className="dark:bg-background/95 bg-background h-9 w-full overflow-ellipsis rounded-full border pl-10 pr-0 text-sm shadow-sm sm:pr-4"
placeholder="Search"
readOnly // This input is for display only
/>
<div className="flex absolute top-1/2 -translate-y-1/2 right-2 text-xs font-medium font-mono items-center gap-0.5 dark:bg-accent bg-accent text-white px-2 py-0.5 rounded-full">
<CommandIcon className="w-3 h-3" />
<div className="dark:bg-accent bg-accent absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 rounded-full px-2 py-0.5 font-mono text-xs font-medium text-white">
<CommandIcon className="h-3 w-3" />
<span>K</span>
</div>
</div>
</div>
</div>
</DialogTrigger>
);
}
)
}

View File

@@ -89,7 +89,7 @@ export function Sponsor() {
// Safely get sponsor data with optional chaining and default values
const sponsor = docuConfig?.sponsor || {};
const item = sponsor?.item;
// Return null if required fields are missing
if (!item?.url || !item?.image || !item?.title) {
return null;
@@ -106,7 +106,7 @@ export function Sponsor() {
rel="noopener noreferrer"
className="flex flex-col justify-center gap-2 p-4 border rounded-lg hover:shadow transition-shadow"
>
<div className="relative w-8 h-8 flex-shrink-0">
<div className="relative w-8 h-8 shrink-0">
<Image
src={item.image}
alt={item.title}

View File

@@ -17,9 +17,9 @@ export function ModeToggle() {
// Jika belum mounted, jangan render apapun untuk menghindari mismatch
if (!mounted) {
return (
<div className="flex items-center gap-1 rounded-full border border-border bg-background/50 p-1">
<div className="rounded-full p-1 w-8 h-8" />
<div className="rounded-full p-1 w-8 h-8" />
<div className="flex items-center gap-1 rounded-full border border-border bg-background/50 p-0.5">
<div className="rounded-full p-0 w-1 h-1" />
<div className="rounded-full p-0 w-1 h-1" />
</div>
);
}
@@ -43,29 +43,29 @@ export function ModeToggle() {
type="single"
value={activeTheme}
onValueChange={handleToggle}
className="flex items-center gap-1 rounded-full border border-border bg-background/50 p-1 transition-all"
className="flex items-center gap-1 rounded-full border border-border bg-background/50 p-0.5 transition-all"
>
<ToggleGroupItem
value="light"
size="sm"
size="xs"
aria-label="Light Mode"
className={`rounded-full p-1 transition-all ${activeTheme === "light"
className={`rounded-full cursor-pointer p-0.5 transition-all ${activeTheme === "light"
? "bg-primary text-primary-foreground"
: "bg-transparent hover:bg-muted/50"
}`}
>
<Sun className="h-4 w-4" />
<Sun className="h-0.5 w-0.5" />
</ToggleGroupItem>
<ToggleGroupItem
value="dark"
size="sm"
size="xs"
aria-label="Dark Mode"
className={`rounded-full p-1 transition-all ${activeTheme === "dark"
className={`rounded-full cursor-pointer p-0.5 transition-all ${activeTheme === "dark"
? "bg-primary text-primary-foreground"
: "bg-transparent hover:bg-muted/50"
}`}
>
<Moon className="h-4 w-4" />
<Moon className="h-0.5 w-0.5" />
</ToggleGroupItem>
</ToggleGroup>
);

197
components/TocObserver.tsx Normal file
View File

@@ -0,0 +1,197 @@
"use client"
import clsx from "clsx"
import Link from "next/link"
import { useState, useRef, useEffect, useCallback } from "react"
import { motion } from "framer-motion"
import { ScrollToTop } from "./ScrollToTop"
import { TocItem } from "@/lib/toc"
interface TocObserverProps {
data: TocItem[]
activeId?: string | null
onActiveIdChange?: (id: string | null) => void
}
export default function TocObserver({
data,
activeId: externalActiveId,
onActiveIdChange,
}: TocObserverProps) {
const itemRefs = useRef<Map<string, HTMLAnchorElement>>(new Map())
const activeId = externalActiveId ?? null
const handleLinkClick = useCallback(
(id: string) => {
onActiveIdChange?.(id)
},
[onActiveIdChange]
)
// Function to check if an item has children
const hasChildren = (currentId: string, currentLevel: number) => {
const currentIndex = data.findIndex((item) => item.href.slice(1) === currentId)
if (currentIndex === -1 || currentIndex === data.length - 1) return false
const nextItem = data[currentIndex + 1]
return nextItem.level > currentLevel
}
// Calculate scroll progress for the active section
const [scrollProgress, setScrollProgress] = useState(0)
useEffect(() => {
const handleScroll = () => {
if (!activeId) return
const activeElement = document.getElementById(activeId)
if (!activeElement) return
const rect = activeElement.getBoundingClientRect()
const windowHeight = window.innerHeight
const elementTop = rect.top
const elementHeight = rect.height
// Calculate how much of the element is visible
let progress = 0
if (elementTop < windowHeight) {
progress = Math.min(1, (windowHeight - elementTop) / (windowHeight + elementHeight))
}
setScrollProgress(progress)
}
const container = document.getElementById("scroll-container") || window
container.addEventListener("scroll", handleScroll, { passive: true })
// Initial calculation
handleScroll()
return () => container.removeEventListener("scroll", handleScroll)
}, [activeId])
return (
<div className="relative">
<div className="text-foreground/70 hover:text-foreground relative text-sm transition-colors">
<div className="flex flex-col gap-0">
{data.map(({ href, level, text }, index) => {
const id = href.slice(1)
const isActive = activeId === id
const indent = level > 1 ? (level - 1) * 20 : 0
// Prefix with underscore to indicate intentionally unused
const _isParent = hasChildren(id, level)
const _isLastInLevel = index === data.length - 1 || data[index + 1].level <= level
return (
<div key={href} className="relative">
{/* Simple L-shaped connector */}
{level > 1 && (
<div
className={clsx("absolute top-0 h-full w-6", {
"left-[6px]": indent === 20, // Level 2
"left-[22px]": indent === 40, // Level 3
"left-[38px]": indent === 60, // Level 4
})}
>
{/* Vertical line */}
<div
className={clsx(
"absolute left-0 top-0 h-full w-px",
isActive
? "bg-primary/20 dark:bg-primary/30"
: "bg-border/50 dark:bg-border/50"
)}
>
{isActive && (
<motion.div
className="bg-primary absolute left-0 top-0 h-full w-full origin-top"
initial={{ scaleY: 0 }}
animate={{ scaleY: scrollProgress }}
transition={{ duration: 0.3 }}
/>
)}
</div>
{/* Horizontal line */}
<div
className={clsx(
"absolute left-0 top-1/2 h-px w-6",
isActive
? "bg-primary/20 dark:bg-primary/30"
: "bg-border/50 dark:bg-border/50"
)}
>
{isActive && (
<motion.div
className="bg-primary dark:bg-accent absolute left-0 top-0 h-full w-full origin-left"
initial={{ scaleX: 0 }}
animate={{ scaleX: scrollProgress }}
transition={{ duration: 0.3, delay: 0.1 }}
/>
)}
</div>
</div>
)}
<Link
href={href}
onClick={() => handleLinkClick(id)}
className={clsx("relative flex items-center py-2 transition-colors", {
"text-primary dark:text-primary font-medium": isActive,
"text-muted-foreground hover:text-foreground dark:hover:text-foreground/90":
!isActive,
})}
style={{
paddingLeft: `${indent}px`,
marginLeft: level > 1 ? "12px" : "0",
}}
ref={(el) => {
const map = itemRefs.current
if (el) {
map.set(id, el)
} else {
map.delete(id)
}
}}
>
{/* Circle indicator */}
<div className="relative flex h-4 w-4 shrink-0 items-center justify-center">
<div
className={clsx(
"relative z-10 h-1.5 w-1.5 rounded-full transition-all duration-300",
{
"bg-primary dark:bg-primary/90 scale-100": isActive,
"bg-muted-foreground/30 dark:bg-muted-foreground/30 group-hover:bg-primary/50 dark:group-hover:bg-primary/50 scale-75 group-hover:scale-100":
!isActive,
}
)}
>
{isActive && (
<motion.div
className="bg-primary/20 dark:bg-primary/30 absolute inset-0 rounded-full"
initial={{ scale: 1 }}
animate={{ scale: 1.8 }}
transition={{
duration: 2,
repeat: Infinity,
repeatType: "reverse",
}}
/>
)}
</div>
</div>
<span className="truncate text-sm">{text}</span>
</Link>
</div>
)
})}
</div>
</div>
{/* Add scroll to top link at the bottom of TOC */}
<ScrollToTop className="mt-6" />
</div>
)
}

View File

@@ -1,4 +0,0 @@
import { createContext } from 'react';
// Create a context to check if a component is inside an accordion group
export const AccordionGroupContext = createContext<{ inGroup: boolean } | null>(null);

View File

@@ -1,37 +0,0 @@
"use client";
import { ROUTES, EachRoute } from "@/lib/routes-config";
import SubLink from "./sublink";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
interface DocsMenuProps {
isSheet?: boolean;
className?: string;
}
export default function DocsMenu({ isSheet = false, className = "" }: DocsMenuProps) {
const pathname = usePathname();
if (!pathname.startsWith("/docs")) return null;
return (
<nav
aria-label="Documentation navigation"
className={cn("transition-all duration-200", className)}
>
<ul className="flex flex-col gap-1.5 py-4">
{ROUTES.map((route, index) => (
<li key={route.title + index}>
<SubLink
{...route}
href={`${route.href}`}
level={0}
isSheet={isSheet}
/>
</li>
))}
</ul>
</nav>
);
}

View File

@@ -1,5 +1,5 @@
import Link from "next/link";
import { ModeToggle } from "@/components/theme-toggle";
import { ModeToggle } from "@/components/ThemeToggle";
import docuData from "@/docu.json";
import * as LucideIcons from "lucide-react";
@@ -20,21 +20,25 @@ const docuConfig = docuData as {
footer: FooterConfig;
};
export function Footer() {
interface FooterProps {
id?: string;
}
export function Footer({ id }: FooterProps) {
const { footer } = docuConfig;
return (
<footer className="w-full py-8 border-t bg-background">
<footer id={id} className="w-full py-8 border-t bg-background">
<div className="container flex flex-col lg:flex-row items-center justify-between text-sm">
<div className="flex flex-col items-center lg:items-start justify-start gap-4 w-full lg:w-3/5 text-center lg:text-left">
<p className="text-muted-foreground">
Copyright © {new Date().getFullYear()} {footer.copyright} - <MadeWith />
</p>
<div className="flex items-center justify-center lg:justify-start gap-6 mt-2 w-full">
<FooterButtons />
</div>
<p className="text-muted-foreground">
Copyright © {new Date().getFullYear()} {footer.copyright} - <MadeWith />
</p>
<div className="flex items-center justify-center lg:justify-start gap-6 mt-2 w-full">
<FooterButtons />
</div>
</div>
<div className="hidden lg:flex items-center justify-end lg:w-2/5">
<ModeToggle />
<ModeToggle />
</div>
</div>
</footer>
@@ -79,9 +83,9 @@ export function MadeWith() {
<span className="text-muted-foreground">Made with </span>
<span className="text-primary">
<Link href="https://www.docubook.pro" target="_blank" rel="noopener noreferrer" className="underline underline-offset-2 text-muted-foreground">
DocuBook
DocuBook
</Link>
</span>
</span>
</>
);
}

View File

@@ -1,101 +1,69 @@
"use client"
import { useState } from "react";
import {
Sheet,
SheetClose,
SheetContent,
SheetHeader,
SheetTrigger,
} from "@/components/ui/sheet";
import { Logo, NavMenu } from "@/components/navbar";
import { Button } from "@/components/ui/button";
import { LayoutGrid, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import DocsMenu from "@/components/docs-menu";
import { ModeToggle } from "@/components/theme-toggle";
import ContextPopover from "@/components/context-popover";
// Toggle Button Component
export function ToggleButton({
collapsed,
onToggle
}: {
collapsed: boolean,
onToggle: () => void
}) {
return (
<div className="absolute top-0 right-0 py-6 z-10 -mt-4">
<Button
size="icon"
variant="outline"
className="cursor-pointer hover:bg-transparent hover:text-inherit border-none text-muted-foreground"
onClick={onToggle}
>
{collapsed ? (
<PanelLeftOpen size={18} />
) : (
<PanelLeftClose size={18} />
)}
</Button>
</div>
)
}
import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTrigger } from "@/components/ui/sheet"
import { Logo, NavMenu } from "@/components/navbar"
import { Button } from "@/components/ui/button"
import { PanelRight } from "lucide-react"
import { DialogTitle, DialogDescription } from "@/components/ui/dialog"
import { ScrollArea } from "@/components/ui/scroll-area"
import DocsMenu from "@/components/DocsMenu"
import { ModeToggle } from "@/components/ThemeToggle"
import ContextPopover from "@/components/ContextPopover"
import Search from "@/components/SearchBox"
export function Leftbar() {
const [collapsed, setCollapsed] = useState(false);
const toggleCollapse = () => setCollapsed(prev => !prev);
return (
<aside
className={`sticky lg:flex hidden top-16 h-[calc(100vh-4rem)] border-r bg-background transition-all duration-300
${collapsed ? "w-[24px]" : "w-[280px]"} flex flex-col pr-2`}
>
<ToggleButton collapsed={collapsed} onToggle={toggleCollapse} />
{/* Scrollable Content */}
<ScrollArea className="flex-1 px-0.5 pb-4">
{!collapsed && (
<div className="space-y-2">
<ContextPopover />
<DocsMenu />
</div>
)}
<aside className="sticky top-0 hidden h-screen w-[280px] shrink-0 flex-col lg:flex">
{/* Logo */}
<div className="flex h-14 shrink-0 items-center px-5">
<Logo />
</div>
<div className="flex shrink-0 items-center gap-2 px-4 pb-4">
<Search className="min-w-[250px] max-w-[250px]" />
</div>
{/* Scrollable Navigation */}
<ScrollArea className="flex-1 px-4">
<div className="space-y-2">
<ContextPopover />
<DocsMenu />
</div>
</ScrollArea>
{/* Bottom: Theme Toggle */}
<div className="flex px-4 py-3">
<ModeToggle />
</div>
</aside>
);
)
}
export function SheetLeftbar() {
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="max-lg:flex hidden">
<LayoutGrid />
<Button variant="ghost" size="icon" className="hidden max-md:flex">
<PanelRight className="text-muted-foreground h-6 w-6 shrink-0" />
</Button>
</SheetTrigger>
<SheetContent className="flex flex-col gap-4 px-0" side="left">
<SheetContent className="flex flex-col gap-4 px-0" side="right">
<DialogTitle className="sr-only">Navigation Menu</DialogTitle>
<DialogDescription className="sr-only">
Main navigation menu with links to different sections
</DialogDescription>
<SheetHeader>
<SheetClose className="px-5" asChild>
<span className="px-2"><Logo /></span>
<SheetClose className="px-4" asChild>
<div className="flex items-center justify-between">
<ModeToggle />
</div>
</SheetClose>
</SheetHeader>
<div className="flex flex-col gap-4 overflow-y-auto">
<div className="flex flex-col gap-2.5 mt-3 mx-2 px-5">
<NavMenu isSheet />
</div>
<div className="mx-2 px-5 space-y-2">
<ContextPopover />
<DocsMenu isSheet />
</div>
<div className="flex w-2/4 px-5">
<ModeToggle />
<div className="mx-2 mt-3 flex flex-col gap-2.5 px-5">
<NavMenu />
</div>
</div>
</SheetContent>
</Sheet>
);
)
}

View File

@@ -0,0 +1,21 @@
import { createContext, useState, useId } from "react"
type AccordionGroupContextType = {
inGroup: boolean
groupId: string
openTitle: string | null
setOpenTitle: (title: string | null) => void
}
export const AccordionGroupContext = createContext<AccordionGroupContextType | null>(null)
export function AccordionGroupProvider({ children }: { children: React.ReactNode }) {
const [openTitle, setOpenTitle] = useState<string | null>(null)
const groupId = useId()
return (
<AccordionGroupContext.Provider value={{ inGroup: true, groupId, openTitle, setOpenTitle }}>
{children}
</AccordionGroupContext.Provider>
)
}

View File

@@ -1,31 +1,20 @@
"use client"
import React, { ReactNode } from "react";
import clsx from "clsx";
import { AccordionGroupContext } from "@/components/contexts/AccordionContext";
import React, { ReactNode } from "react"
import clsx from "clsx"
import { AccordionGroupProvider } from "@/components/markdown/AccordionContext"
interface AccordionGroupProps {
children: ReactNode;
className?: string;
children: ReactNode
className?: string
}
const AccordionGroup: React.FC<AccordionGroupProps> = ({ children, className }) => {
return (
// Wrap all children with the AccordionGroupContext.Provider
// so that any nested accordions know they are inside a group.
// This enables group-specific behavior in child components.
<AccordionGroupContext.Provider value={{ inGroup: true }}>
<div
className={clsx(
"border rounded-lg overflow-hidden",
className
)}
>
{children}
</div>
</AccordionGroupContext.Provider>
);
};
<AccordionGroupProvider>
<div className={clsx("overflow-hidden rounded-lg border", className)}>{children}</div>
</AccordionGroupProvider>
)
}
export default AccordionGroup;
export default AccordionGroup

View File

@@ -1,62 +1,61 @@
"use client";
"use client"
import { ReactNode, useState, useContext } from 'react';
import { ChevronRight } from 'lucide-react';
import * as Icons from "lucide-react";
import { cn } from '@/lib/utils';
import { AccordionGroupContext } from '@/components/contexts/AccordionContext';
import { ReactNode, useContext, useState } from "react"
import { ChevronRight } from "lucide-react"
import * as Icons from "lucide-react"
import { cn } from "@/lib/utils"
import { AccordionGroupContext } from "@/components/markdown/AccordionContext"
type AccordionProps = {
title: string;
children?: ReactNode;
defaultOpen?: boolean;
icon?: keyof typeof Icons;
};
title: string
children?: ReactNode
icon?: keyof typeof Icons
}
const Accordion: React.FC<AccordionProps> = ({
title,
children,
defaultOpen = false,
icon,
}: AccordionProps) => {
const groupContext = useContext(AccordionGroupContext);
const isInGroup = groupContext?.inGroup === true;
const [isOpen, setIsOpen] = useState(defaultOpen);
const Icon = icon ? (Icons[icon] as React.FC<{ className?: string }>) : null;
const Accordion: React.FC<AccordionProps> = ({ title, children, icon }: AccordionProps) => {
const groupContext = useContext(AccordionGroupContext)
const isInGroup = groupContext?.inGroup === true
const groupOpen = groupContext?.openTitle === title
const setGroupOpen = groupContext?.setOpenTitle
const [localOpen, setLocalOpen] = useState(false)
// The main wrapper div for the accordion.
// All styling logic for the accordion container is handled here.
return (
<div
className={cn(
// Style for STANDALONE: full card with border & shadow
!isInGroup && "border rounded-lg shadow-sm",
// Style for IN GROUP: only a bottom border separator
isInGroup && "border-b last:border-b-0 border-border"
)}
>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 w-full px-4 h-12 transition-colors bg-muted/40 dark:bg-muted/20 hover:bg-muted/70 dark:hover:bg-muted/70"
>
<ChevronRight
className={cn(
"w-4 h-4 text-muted-foreground transition-transform duration-200 flex-shrink-0",
isOpen && "rotate-90"
)}
/>
{Icon && <Icon className="text-foreground w-4 h-4 flex-shrink-0" />}
<h3 className="font-medium text-base text-foreground !m-0 leading-none">{title}</h3>
</button>
const isOpen = isInGroup ? groupOpen : localOpen
{isOpen && (
<div className="px-4 py-3 dark:bg-muted/10 bg-muted/15">
{children}
</div>
)}
</div>
);
};
const handleToggle = () => {
if (isInGroup && setGroupOpen) {
setGroupOpen(groupOpen ? null : title)
} else {
setLocalOpen(!localOpen)
}
}
export default Accordion;
const Icon = icon ? (Icons[icon] as React.FC<{ className?: string }>) : null
return (
<div
className={cn(
!isInGroup && "rounded-lg border shadow-sm",
isInGroup && "border-border border-b last:border-b-0"
)}
>
<button
type="button"
onClick={handleToggle}
className="bg-muted/40 dark:bg-muted/20 hover:bg-muted/70 dark:hover:bg-muted/70 flex w-full cursor-pointer items-center gap-2 px-4 py-3 text-start transition-colors"
>
<ChevronRight
className={cn(
"text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200",
isOpen && "rotate-90"
)}
/>
{Icon && <Icon className="text-foreground h-4 w-4 shrink-0" />}
<h3 className="text-foreground m-0! text-base font-medium">{title}</h3>
</button>
{isOpen && <div className="dark:bg-muted/10 bg-muted/15 px-4 py-3">{children}</div>}
</div>
)
}
export default Accordion

View File

@@ -24,13 +24,13 @@ const Card: React.FC<CardProps> = ({ title, icon, href, horizontal, children, cl
"bg-card text-card-foreground border-border",
"hover:bg-accent/5 hover:border-accent/30",
"flex gap-2",
horizontal ? "flex-row items-center gap-1" : "flex-col space-y-1",
horizontal ? "flex-row items-start gap-1" : "flex-col space-y-1",
className
)}
>
{Icon && <Icon className="w-5 h-5 text-primary flex-shrink-0" />}
<div className="flex-1 min-w-0 my-auto h-full">
<span className="text-base font-semibold text-foreground">{title}</span>
{Icon && <Icon className={clsx("w-5 h-5 text-primary shrink-0", horizontal && "mt-0.5")} />}
<div className="flex-1 min-w-0">
<div className="text-base font-semibold text-foreground leading-6">{title}</div>
<div className="text-sm text-muted-foreground -mt-3">{children}</div>
</div>
</div>

View File

@@ -19,7 +19,7 @@ export default function Copy({ content }: { content: string }) {
return (
<Button
variant="secondary"
className="border"
className="border cursor-copy"
size="xs"
onClick={handleCopy}
>

View File

@@ -24,7 +24,7 @@ const FileComponent = ({ name }: FileProps) => {
tabIndex={-1}
>
<FileIcon className={`
h-3.5 w-3.5 flex-shrink-0 transition-colors
h-3.5 w-3.5 shrink-0 transition-colors
${isHovered ? 'text-accent' : 'text-muted-foreground'}
`} />
<span className="font-mono text-sm text-foreground truncate">{name}</span>
@@ -61,7 +61,7 @@ const FolderComponent = ({ name, children }: FileProps) => {
{hasChildren ? (
<ChevronRight
className={`
h-3.5 w-3.5 flex-shrink-0 transition-transform duration-200
h-3.5 w-3.5 shrink-0 transition-transform duration-200
${isOpen ? 'rotate-90' : ''}
${isHovered ? 'text-foreground/70' : 'text-muted-foreground'}
`}
@@ -71,12 +71,12 @@ const FolderComponent = ({ name, children }: FileProps) => {
)}
{isOpen ? (
<FolderOpen className={`
h-4 w-4 flex-shrink-0 transition-colors
h-4 w-4 shrink-0 transition-colors
${isHovered ? 'text-accent' : 'text-muted-foreground'}
`} />
) : (
<FolderIcon className={`
h-4 w-4 flex-shrink-0 transition-colors
h-4 w-4 shrink-0 transition-colors
${isHovered ? 'text-accent/80' : 'text-muted-foreground/80'}
`} />
)}

View File

@@ -1,29 +1,129 @@
import { ComponentProps } from "react";
"use client";
import { ComponentProps, useState, useEffect } from "react";
import NextImage from "next/image";
import { createPortal } from "react-dom";
import { motion, AnimatePresence } from "framer-motion";
import { X, ZoomIn } from "lucide-react";
type Height = ComponentProps<typeof NextImage>["height"];
type Width = ComponentProps<typeof NextImage>["width"];
type ImageProps = Omit<ComponentProps<"img">, "src"> & {
src?: ComponentProps<typeof NextImage>["src"];
src?: ComponentProps<typeof NextImage>["src"];
};
export default function Image({
src,
alt = "alt",
width = 800,
height = 350,
...props
src,
alt = "alt",
width = 800,
height = 350,
...props
}: ImageProps) {
if (!src) return null;
return (
<NextImage
src={src}
alt={alt}
width={width as Width}
height={height as Height}
quality={40}
{...props}
/>
);
const [isOpen, setIsOpen] = useState(false);
// Lock scroll when open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
// Check for Escape key
const handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsOpen(false);
};
window.addEventListener("keydown", handleEsc);
return () => {
document.body.style.overflow = "auto";
window.removeEventListener("keydown", handleEsc);
};
}
}, [isOpen]);
if (!src) return null;
return (
<>
<button
type="button"
className="relative group cursor-zoom-in my-6 w-full flex justify-center rounded-lg"
onClick={() => setIsOpen(true)}
aria-label="Zoom image"
>
<span className="absolute inset-0 bg-black/0 group-hover:bg-black/5 transition-colors z-10 flex items-center justify-center opacity-0 group-hover:opacity-100 rounded-lg">
<ZoomIn className="w-8 h-8 text-white drop-shadow-md" />
</span>
<NextImage
src={src}
alt={alt}
width={width as Width}
height={height as Height}
quality={85}
className="w-full h-auto rounded-lg transition-transform duration-300 group-hover:scale-[1.01]"
{...props}
/>
</button>
<AnimatePresence>
{isOpen && (
<Portal>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-99999 flex items-center justify-center bg-black/90 backdrop-blur-md p-4 md:p-10 cursor-zoom-out"
onClick={() => setIsOpen(false)}
>
{/* Close Button */}
<button
className="absolute top-4 right-4 z-50 p-2 text-white/70 hover:text-white bg-black/20 hover:bg-white/10 rounded-full transition-colors"
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
}}
>
<X className="w-6 h-6" />
</button>
{/* Image Container */}
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
className="relative max-w-7xl w-full h-full flex items-center justify-center"
onClick={(e) => e.stopPropagation()}
>
<div className="relative w-full h-full flex items-center justify-center" onClick={() => setIsOpen(false)}>
<NextImage
src={src}
alt={alt}
width={1920}
height={1080}
className="object-contain max-h-[90vh] w-auto h-auto rounded-md shadow-2xl"
quality={95}
/>
</div>
</motion.div>
{/* Caption */}
{alt && alt !== "alt" && (
<motion.div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
className="absolute bottom-6 left-1/2 -translate-x-1/2 bg-black/60 text-white px-4 py-2 rounded-full text-sm font-medium backdrop-blur-md border border-white/10"
>
{alt}
</motion.div>
)}
</motion.div>
</Portal>
)}
</AnimatePresence>
</>
);
}
const Portal = ({ children }: { children: React.ReactNode }) => {
if (typeof window === "undefined") return null;
return createPortal(children, document.body);
};

View File

@@ -1,52 +1,69 @@
"use client";
import { cn } from "@/lib/utils";
import clsx from "clsx";
import { PropsWithChildren } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import {
Info,
AlertTriangle,
ShieldAlert,
CheckCircle,
CheckCircle2,
} from "lucide-react";
import React from "react";
type NoteProps = PropsWithChildren & {
title?: string;
type?: "note" | "danger" | "warning" | "success";
};
const noteVariants = cva(
"relative w-full rounded-lg border border-l-4 p-4 mb-4 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
note: "bg-muted/30 border-border border-l-primary/50 text-foreground [&>svg]:text-primary",
danger: "border-destructive/20 border-l-destructive/60 bg-destructive/5 text-destructive [&>svg]:text-destructive dark:border-destructive/30",
warning: "border-orange-500/20 border-l-orange-500/60 bg-orange-500/5 text-orange-600 dark:text-orange-400 [&>svg]:text-orange-600 dark:[&>svg]:text-orange-400",
success: "border-emerald-500/20 border-l-emerald-500/60 bg-emerald-500/5 text-emerald-600 dark:text-emerald-400 [&>svg]:text-emerald-600 dark:[&>svg]:text-emerald-400",
},
},
defaultVariants: {
variant: "note",
},
}
);
const iconMap = {
note: <Info size={16} className="text-blue-500" />,
danger: <ShieldAlert size={16} className="text-red-500" />,
warning: <AlertTriangle size={16} className="text-orange-500" />,
success: <CheckCircle size={16} className="text-green-500" />,
note: Info,
danger: ShieldAlert,
warning: AlertTriangle,
success: CheckCircle2,
};
interface NoteProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof noteVariants> {
title?: string;
type?: "note" | "danger" | "warning" | "success";
}
export default function Note({
children,
className,
title = "Note",
type = "note",
children,
...props
}: NoteProps) {
const noteClassNames = clsx({
"dark:bg-stone-950/25 bg-stone-50": type === "note",
"dark:bg-red-950 bg-red-100 border-red-200 dark:border-red-900":
type === "danger",
"bg-orange-50 border-orange-200 dark:border-orange-900 dark:bg-orange-900/50":
type === "warning",
"dark:bg-green-950 bg-green-100 border-green-200 dark:border-green-900":
type === "success",
});
const Icon = iconMap[type] || Info;
return (
<div
className={cn(
"border rounded-md px-5 pb-0.5 mt-5 mb-7 text-sm tracking-wide",
noteClassNames
)}
className={cn(noteVariants({ variant: type }), className)}
{...props}
>
<div className="flex items-center gap-2 font-bold -mb-2.5 pt-6">
{iconMap[type]}
<span className="text-base">{title}:</span>
<Icon className="h-5 w-5" />
<div className="pl-8">
<h5 className="mb-1 font-medium leading-none tracking-tight">
{title}
</h5>
<div className="text-sm [&_p]:leading-relaxed opacity-90">
{children}
</div>
</div>
{children}
</div>
);
}

View File

@@ -11,7 +11,7 @@ import {
SiSwift,
SiKotlin,
SiHtml5,
SiCss3,
SiCss,
SiSass,
SiPostgresql,
SiGraphql,
@@ -68,7 +68,7 @@ const LanguageIcon = ({ lang }: { lang: string }) => {
js: <SiJavascript {...iconProps} />,
javascript: <SiJavascript {...iconProps} />,
html: <SiHtml5 {...iconProps} />,
css: <SiCss3 {...iconProps} />,
css: <SiCss {...iconProps} />,
scss: <SiSass {...iconProps} />,
sass: <SiSass {...iconProps} />,
};

View File

@@ -12,25 +12,29 @@ function Release({ version, title, date, children }: ReleaseProps) {
return (
<div className="mb-16 group">
<div className="mb-6">
<div className="flex items-center gap-3 mb-2">
<div className="bg-primary/10 text-primary border-2 border-primary/20 rounded-full px-4 py-1.5 text-base font-medium">
v{version}
</div>
{date && (
<div className="text-muted-foreground text-sm">
<div className="flex items-center gap-3 mt-6 mb-2">
<div
id={version}
className="inline-flex items-center rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-sm font-semibold text-primary transition-colors hover:bg-primary/15 scroll-m-20 backdrop-blur-sm"
>
v{version}
</div>
{date && (
<div className="flex items-center gap-3 text-sm font-medium text-muted-foreground">
<span className="h-1 w-1 rounded-full bg-muted-foreground/30"></span>
<time dateTime={date}>
{new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</div>
)}
</div>
<h2 className="text-2xl font-bold text-foreground/90 mb-3">
{title}
</h2>
</time>
</div>
)}
</div>
<h3 className="text-2xl font-bold text-foreground/90 mb-6 mt-0!">
{title}
</h3>
<div className="space-y-8">
{children}
</div>

View File

@@ -19,7 +19,7 @@ const Tooltip: React.FC<TooltipProps> = ({ text, tip }) => {
{text}
</span>
{visible && (
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 w-64 bg-popover text-popover-foreground text-sm p-3 rounded-md shadow-lg border border-border/50 break-words text-left z-50">
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 w-64 bg-popover text-popover-foreground text-sm p-3 rounded-md shadow-lg border border-border/50 wrap-break-word text-left z-50">
{tip}
<span className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-popover rotate-45 border-b border-r border-border/50 -z-10" />
</span>

View File

@@ -1,128 +0,0 @@
"use client";
import { List, ChevronDown, ChevronUp } from "lucide-react";
import TocObserver from "./toc-observer";
import * as React from "react";
import { useRef, useMemo } from "react";
import { usePathname } from "next/navigation";
import { Button } from "./ui/button";
import { motion, AnimatePresence } from "framer-motion";
import { useActiveSection } from "@/hooks";
import { TocItem } from "@/lib/toc";
interface MobTocProps {
tocs: TocItem[];
}
const useClickOutside = (ref: React.RefObject<HTMLElement | null>, callback: () => void) => {
const handleClick = React.useCallback((event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback();
}
}, [ref, callback]);
React.useEffect(() => {
document.addEventListener('mousedown', handleClick);
return () => {
document.removeEventListener('mousedown', handleClick);
};
}, [handleClick]);
};
export default function MobToc({ tocs }: MobTocProps) {
const pathname = usePathname();
const [isExpanded, setIsExpanded] = React.useState(false);
const tocRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
// Use custom hooks
const { activeId, setActiveId } = useActiveSection(tocs);
// Only show on /docs pages
const isDocsPage = useMemo(() => pathname?.startsWith('/docs'), [pathname]);
// Toggle expanded state
const toggleExpanded = React.useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setIsExpanded(prev => !prev);
}, []);
// Close TOC when clicking outside
useClickOutside(tocRef, () => {
if (isExpanded) {
setIsExpanded(false);
}
});
// Handle body overflow when TOC is expanded
React.useEffect(() => {
if (isExpanded) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isExpanded]);
// Don't render anything if not on docs page or no TOC items
if (!isDocsPage || !tocs?.length) return null;
const chevronIcon = isExpanded ? (
<ChevronUp className="w-4 h-4 text-muted-foreground flex-shrink-0" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
);
return (
<AnimatePresence>
<motion.div
ref={tocRef}
className="lg:hidden fixed top-16 left-0 right-0 z-50"
initial={{ y: -100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -100, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<div className="w-full bg-background/95 backdrop-blur-sm border-b border-stone-200 dark:border-stone-800 shadow-sm">
<div className="sm:px-8 px-4 py-2">
<Button
variant="ghost"
size="sm"
className="w-full justify-between h-auto py-2 px-2 -mx-1 rounded-md hover:bg-transparent hover:text-inherit"
onClick={toggleExpanded}
aria-label={isExpanded ? 'Collapse table of contents' : 'Expand table of contents'}
>
<div className="flex items-center gap-2">
<List className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="font-medium text-sm">On this page</span>
</div>
{chevronIcon}
</Button>
<AnimatePresence>
{isExpanded && (
<motion.div
ref={contentRef}
className="mt-2 pb-2 max-h-[60vh] overflow-y-auto px-1 -mx-1"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<TocObserver
data={tocs}
activeId={activeId}
onActiveIdChange={setActiveId}
/>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</motion.div>
</AnimatePresence>
);
}

View File

@@ -1,87 +1,158 @@
import { ArrowUpRight } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import Search from "@/components/search";
import Anchor from "@/components/anchor";
import { SheetLeftbar } from "@/components/leftbar";
import { SheetClose } from "@/components/ui/sheet";
import { Separator } from "@/components/ui/separator";
import docuConfig from "@/docu.json"; // Import JSON
"use client"
export function Navbar() {
import { ArrowUpRight, ChevronDown, ChevronUp } from "lucide-react"
import Link from "next/link"
import Image from "next/image"
import Search from "@/components/SearchBox"
import Anchor from "@/components/anchor"
import { Separator } from "@/components/ui/separator"
import docuConfig from "@/docu.json"
import { Button } from "@/components/ui/button"
import { useState, useCallback } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { ModeToggle } from "@/components/ThemeToggle"
interface NavbarProps {
id?: string
}
export function Navbar({ id }: NavbarProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false)
const toggleMenu = useCallback(() => {
setIsMenuOpen((prev) => !prev)
}, [])
return (
<nav className="sticky top-0 z-50 w-full h-16 border-b bg-background">
<div className="sm:container mx-auto w-[95vw] h-full flex items-center justify-between md:gap-2">
<div className="flex items-center gap-5">
<SheetLeftbar />
<div className="sticky top-0 z-50 w-full">
<nav id={id} className="bg-background h-16 w-full border-b">
<div className="mx-auto flex h-full w-[95vw] items-center justify-between sm:container md:gap-2">
<div className="flex items-center gap-6">
<div className="hidden lg:flex">
<div className="flex">
<Logo />
</div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="items-center hidden gap-4 text-sm font-medium lg:flex text-muted-foreground">
<div className="flex items-center gap-0 max-md:flex-row-reverse md:gap-2">
<div className="text-muted-foreground hidden items-center gap-4 text-sm font-medium md:flex">
<NavMenu />
</div>
<Separator className="hidden lg:flex my-4 h-9" orientation="vertical" />
<Button
variant="ghost"
size="sm"
onClick={toggleMenu}
aria-label={isMenuOpen ? "Close navigation menu" : "Open navigation menu"}
aria-expanded={isMenuOpen}
className="flex items-center gap-1 px-2 text-sm font-medium md:hidden"
>
{isMenuOpen ? (
<ChevronUp className="text-muted-foreground h-6 w-6" />
) : (
<ChevronDown className="text-muted-foreground h-6 w-6" />
)}
</Button>
<Separator className="my-4 hidden h-9 md:flex" orientation="vertical" />
<Search />
</div>
</div>
</div>
</nav>
);
</nav>
<AnimatePresence>
{isMenuOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="bg-background/95 w-full border-b shadow-sm backdrop-blur-sm md:hidden"
>
<div className="mx-auto w-[95vw] sm:container">
<ul className="flex flex-col py-2">
<NavMenuCollapsible onItemClick={() => setIsMenuOpen(false)} />
</ul>
<div className="flex items-center justify-between border-t px-1 py-3">
<ModeToggle />
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
export function Logo() {
const { navbar } = docuConfig; // Extract navbar from JSON
const { navbar } = docuConfig
return (
<Link href="/" className="flex items-center gap-1.5">
<div className="relative w-8 h-8">
<Image
src={navbar.logo.src}
alt={navbar.logo.alt}
fill
sizes="32px"
className="object-contain"
/>
</div>
<h2 className="font-bold font-code text-md">{navbar.logoText}</h2>
</Link>
);
return (
<Link href="/" className="flex items-center gap-1.5">
<div className="relative h-8 w-8">
<Image
src={navbar.logo.src}
alt={navbar.logo.alt}
fill
sizes="32px"
className="object-contain"
/>
</div>
<h2 className="font-code dark:text-accent text-primary text-lg font-bold">
{navbar.logoText}
</h2>
</Link>
)
}
export function NavMenu({ isSheet = false }) {
const { navbar } = docuConfig; // Extract navbar from JSON
// Desktop NavMenu — horizontal list
export function NavMenu() {
const { navbar } = docuConfig
return (
<>
{navbar?.menu?.map((item) => {
const isExternal = item.href.startsWith("http");
const Comp = (
const isExternal = item.href.startsWith("http")
return (
<Anchor
key={`${item.title}-${item.href}`}
activeClassName="text-primary dark:text-accent md:font-semibold font-medium"
absolute
className="flex items-center gap-1 text-foreground/80 hover:text-foreground transition-colors"
className="text-foreground/80 hover:text-foreground flex items-center gap-1 transition-colors"
href={item.href}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
>
{item.title}
{isExternal && <ArrowUpRight className="w-4 h-4 text-foreground/80" />}
{isExternal && <ArrowUpRight className="text-foreground/80 h-4 w-4" />}
</Anchor>
);
return isSheet ? (
<SheetClose key={item.title + item.href} asChild>
{Comp}
</SheetClose>
) : (
Comp
);
)
})}
</>
);
)
}
// Mobile Collapsible NavMenu — vertical list items
function NavMenuCollapsible({ onItemClick }: { onItemClick: () => void }) {
const { navbar } = docuConfig
return (
<>
{navbar?.menu?.map((item) => {
const isExternal = item.href.startsWith("http")
return (
<li key={item.title + item.href}>
<Anchor
activeClassName="text-primary dark:text-accent font-semibold"
absolute
className="text-foreground/80 hover:text-foreground hover:bg-muted flex w-full items-center justify-between gap-2 rounded-md px-3 py-2.5 text-sm font-medium transition-colors"
href={item.href}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
onClick={onItemClick}
>
{item.title}
{isExternal && <ArrowUpRight className="text-foreground/60 h-4 w-4 shrink-0" />}
</Anchor>
</li>
)
})}
</>
)
}

View File

@@ -16,7 +16,7 @@ export default function Pagination({ pathname }: { pathname: string }) {
className:
"no-underline w-full flex flex-col pl-3 !py-8 !items-start",
})}
href={`${res.prev.href}`}
href={`/docs${res.prev.href}`}
>
<span className="flex items-center text-xs">
<ChevronLeftIcon className="w-[1rem] h-[1rem] mr-1" />
@@ -34,7 +34,7 @@ export default function Pagination({ pathname }: { pathname: string }) {
className:
"no-underline w-full flex flex-col pr-3 !py-8 !items-end",
})}
href={`${res.next.href}`}
href={`/docs${res.next.href}`}
>
<span className="flex items-center text-xs">
Next

View File

@@ -1,55 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Dialog } from "@/components/ui/dialog";
import { SearchTrigger } from "@/components/SearchTrigger";
import { SearchModal } from "@/components/SearchModal";
import DocSearchComponent from "@/components/DocSearch";
import { DialogTrigger } from "@radix-ui/react-dialog";
interface SearchProps {
/**
* Specify which search engine to use.
* @default 'default'
*/
type?: "default" | "algolia";
}
export default function Search({ type = "default" }: SearchProps) {
const [isOpen, setIsOpen] = useState(false);
// The useEffect below is ONLY for the 'default' type, which is correct.
// DocSearch handles its own keyboard shortcut.
useEffect(() => {
if (type === 'default') {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === "k") {
event.preventDefault();
setIsOpen((open) => !open);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}
}, [type]);
if (type === "algolia") {
// Just render the component without passing any state props
return <DocSearchComponent />;
}
// Logic for 'default' search
return (
<div>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<SearchTrigger />
</DialogTrigger>
<SearchModal isOpen={isOpen} setIsOpen={setIsOpen} />
</Dialog>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { EachRoute } from "@/lib/routes-config";
import { EachRoute } from "@/lib/routes";
import Anchor from "./anchor";
import {
Collapsible,
@@ -27,7 +27,7 @@ export default function SubLink({
parentHref = "",
}: SubLinkProps) {
const path = usePathname();
const [isOpen, setIsOpen] = useState(false);
const [isOpen, setIsOpen] = useState(level === 0);
// Full path including parent's href
const fullHref = `${parentHref}${href}`;
@@ -54,6 +54,7 @@ export default function SubLink({
<Anchor
activeClassName={!hasActiveChild ? "dark:text-accent text-primary font-medium" : ""}
href={fullHref}
data-search-lvl0={level === 0 && hasActiveChild ? "true" : undefined}
className={cn(
"text-foreground/80 hover:text-foreground transition-colors",
hasActiveChild && "font-medium text-foreground"
@@ -61,7 +62,7 @@ export default function SubLink({
>
{title}
</Anchor>
), [title, fullHref, hasActiveChild]);
), [title, fullHref, hasActiveChild, level]);
const titleOrLink = !noLink ? (
isSheet ? (
@@ -70,10 +71,13 @@ export default function SubLink({
Comp
)
) : (
<h4 className={cn(
"font-medium sm:text-sm text-foreground/90 hover:text-foreground transition-colors",
hasActiveChild ? "text-foreground" : "text-foreground/80"
)}>
<h4
data-search-lvl0={level === 0 && hasActiveChild ? "true" : undefined}
className={cn(
"font-medium sm:text-sm text-foreground/90 hover:text-foreground transition-colors",
hasActiveChild ? "text-foreground" : "text-foreground/80"
)}
>
{title}
</h4>
);
@@ -86,11 +90,7 @@ export default function SubLink({
<div className={cn("flex flex-col gap-1 w-full")}>
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger
className={cn(
"w-full pr-5 text-left rounded-md transition-colors",
isOpen && "bg-muted/30 pb-2 pt-2", // Background when open
hasActiveChild && "bg-primary/5" // Accent tint when child is active
)}
className="w-full pr-5 text-left cursor-pointer"
aria-expanded={isOpen}
aria-controls={`collapsible-${fullHref.replace(/[^a-zA-Z0-9]/g, '-')}`}
>
@@ -108,13 +108,13 @@ export default function SubLink({
<CollapsibleContent
id={`collapsible-${fullHref.replace(/[^a-zA-Z0-9]/g, '-')}`}
className={cn(
"pl-3 overflow-hidden transition-all duration-200 ease-in-out",
"overflow-hidden transition-all duration-200 ease-in-out",
isOpen ? "animate-collapsible-down" : "animate-collapsible-up"
)}
>
<div
className={cn(
"flex flex-col items-start sm:text-sm text-foreground/80 ml-0.5 mt-1.5 gap-3 transition-colors",
"flex flex-col items-start sm:text-sm text-foreground/80 ml-0.5 mt-2.5 gap-3 hover:[&_a]:text-foreground transition-colors",
level > 0 && "pl-4 border-l border-border ml-1.5"
)}
>

View File

@@ -1,254 +0,0 @@
"use client";
import clsx from "clsx";
import Link from "next/link";
import { useState, useRef, useEffect, useCallback } from "react";
import { motion } from "framer-motion";
import { ScrollToTop } from "./scroll-to-top";
import { TocItem } from "@/lib/toc";
interface TocObserverProps {
data: TocItem[];
activeId?: string | null;
onActiveIdChange?: (id: string | null) => void;
}
export default function TocObserver({
data,
activeId: externalActiveId,
onActiveIdChange
}: TocObserverProps) {
const [internalActiveId, setInternalActiveId] = useState<string | null>(null);
const observer = useRef<IntersectionObserver | null>(null);
const [clickedId, setClickedId] = useState<string | null>(null);
const itemRefs = useRef<Map<string, HTMLAnchorElement>>(new Map());
// Use external activeId if provided, otherwise use internal state
const activeId = externalActiveId !== undefined ? externalActiveId : internalActiveId;
const setActiveId = onActiveIdChange || setInternalActiveId;
// Handle intersection observer for auto-highlighting
useEffect(() => {
const handleIntersect = (entries: IntersectionObserverEntry[]) => {
const visibleEntries = entries.filter(entry => entry.isIntersecting);
// Find the most recently scrolled-into-view element
const mostVisibleEntry = visibleEntries.reduce((prev, current) => {
// Prefer the entry that's more visible or higher on the page
const prevRatio = prev?.intersectionRatio || 0;
const currentRatio = current.intersectionRatio;
if (currentRatio > prevRatio) return current;
if (currentRatio === prevRatio &&
current.boundingClientRect.top < prev.boundingClientRect.top) {
return current;
}
return prev;
}, visibleEntries[0]);
if (mostVisibleEntry && !clickedId) {
const newActiveId = mostVisibleEntry.target.id;
if (newActiveId !== activeId) {
setActiveId(newActiveId);
}
}
};
observer.current = new IntersectionObserver(handleIntersect, {
root: null,
rootMargin: "-20% 0px -70% 0px", // Adjusted margins for better section detection
threshold: [0, 0.1, 0.5, 0.9, 1], // Multiple thresholds for better accuracy
});
const elements = data.map((item) =>
document.getElementById(item.href.slice(1))
);
elements.forEach((el) => {
if (el && observer.current) {
observer.current.observe(el);
}
});
// Set initial active ID if none is set
if (!activeId && elements[0]) {
setActiveId(elements[0].id);
}
return () => {
if (observer.current) {
elements.forEach((el) => {
if (el) {
observer.current!.unobserve(el);
}
});
}
};
}, [data, clickedId, activeId, setActiveId]);
const handleLinkClick = useCallback((id: string) => {
setClickedId(id);
setActiveId(id);
// Reset the clicked state after a delay to allow for smooth scrolling
const timer = setTimeout(() => {
setClickedId(null);
}, 1000);
return () => clearTimeout(timer);
}, [setActiveId]);
// Function to check if an item has children
const hasChildren = (currentId: string, currentLevel: number) => {
const currentIndex = data.findIndex(item => item.href.slice(1) === currentId);
if (currentIndex === -1 || currentIndex === data.length - 1) return false;
const nextItem = data[currentIndex + 1];
return nextItem.level > currentLevel;
};
// Calculate scroll progress for the active section
const [scrollProgress, setScrollProgress] = useState(0);
useEffect(() => {
const handleScroll = () => {
if (!activeId) return;
const activeElement = document.getElementById(activeId);
if (!activeElement) return;
const rect = activeElement.getBoundingClientRect();
const windowHeight = window.innerHeight;
const elementTop = rect.top;
const elementHeight = rect.height;
// Calculate how much of the element is visible
let progress = 0;
if (elementTop < windowHeight) {
progress = Math.min(1, (windowHeight - elementTop) / (windowHeight + elementHeight));
}
setScrollProgress(progress);
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, [activeId]);
return (
<div className="relative">
<div className="relative text-sm text-foreground/70 hover:text-foreground transition-colors">
<div className="flex flex-col gap-0">
{data.map(({ href, level, text }, index) => {
const id = href.slice(1);
const isActive = activeId === id;
const indent = level > 1 ? (level - 1) * 20 : 0;
// Prefix with underscore to indicate intentionally unused
const _isParent = hasChildren(id, level);
const _isLastInLevel = index === data.length - 1 || data[index + 1].level <= level;
return (
<div key={href} className="relative">
{/* Simple L-shaped connector */}
{level > 1 && (
<div
className={clsx("absolute top-0 h-full w-6", {
"left-[6px]": indent === 20, // Level 2
"left-[22px]": indent === 40, // Level 3
"left-[38px]": indent === 60, // Level 4
})}
>
{/* Vertical line */}
<div className={clsx(
"absolute left-0 top-0 h-full w-px",
isActive ? "bg-primary/20 dark:bg-primary/30" : "bg-border/50 dark:bg-border/50"
)}>
{isActive && (
<motion.div
className="absolute left-0 top-0 w-full h-full bg-primary origin-top"
initial={{ scaleY: 0 }}
animate={{ scaleY: scrollProgress }}
transition={{ duration: 0.3 }}
/>
)}
</div>
{/* Horizontal line */}
<div className={clsx(
"absolute left-0 top-1/2 h-px w-6",
isActive ? "bg-primary/20 dark:bg-primary/30" : "bg-border/50 dark:bg-border/50"
)}>
{isActive && (
<motion.div
className="absolute left-0 top-0 h-full w-full bg-primary dark:bg-accent origin-left"
initial={{ scaleX: 0 }}
animate={{ scaleX: scrollProgress }}
transition={{ duration: 0.3, delay: 0.1 }}
/>
)}
</div>
</div>
)}
<Link
href={href}
onClick={() => handleLinkClick(id)}
className={clsx(
"relative flex items-center py-2 transition-colors",
{
"text-primary dark:text-primary font-medium": isActive,
"text-muted-foreground hover:text-foreground dark:hover:text-foreground/90": !isActive,
}
)}
style={{
paddingLeft: `${indent}px`,
marginLeft: level > 1 ? '12px' : '0',
}}
ref={(el) => {
const map = itemRefs.current;
if (el) {
map.set(id, el);
} else {
map.delete(id);
}
}}
>
{/* Circle indicator */}
<div className="relative w-4 h-4 flex items-center justify-center flex-shrink-0">
<div className={clsx(
"w-1.5 h-1.5 rounded-full transition-all duration-300 relative z-10",
{
"bg-primary scale-100 dark:bg-primary/90": isActive,
"bg-muted-foreground/30 dark:bg-muted-foreground/30 scale-75 group-hover:scale-100 group-hover:bg-primary/50 dark:group-hover:bg-primary/50": !isActive,
}
)}>
{isActive && (
<motion.div
className="absolute inset-0 rounded-full bg-primary/20 dark:bg-primary/30"
initial={{ scale: 1 }}
animate={{ scale: 1.8 }}
transition={{
duration: 2,
repeat: Infinity,
repeatType: "reverse"
}}
/>
)}
</div>
</div>
<span className="truncate text-sm">
{text}
</span>
</Link>
</div>
);
})}
</div>
</div>
{/* Add scroll to top link at the bottom of TOC */}
<ScrollToTop className="mt-6" />
</div>
);
}

View File

@@ -1,27 +1,29 @@
import { getDocsTocs } from "@/lib/markdown";
import TocObserver from "./toc-observer";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ListIcon } from "lucide-react";
import Sponsor from "./Sponsor";
"use client"
import TocObserver from "./TocObserver"
import { ScrollArea } from "@/components/ui/scroll-area"
import { ListIcon } from "lucide-react"
import Sponsor from "./Sponsor"
import { useActiveSection } from "@/hooks"
import { TocItem } from "@/lib/toc"
export default async function Toc({ path }: { path: string }) {
const tocs = await getDocsTocs(path);
export default function Toc({ tocs }: { tocs: TocItem[] }) {
const { activeId, setActiveId } = useActiveSection(tocs)
return (
<div className="lg:flex hidden toc flex-[1.5] min-w-[238px] py-5 sticky top-16 h-[calc(100vh-4rem)]">
<div className="flex flex-col h-full w-full px-2 gap-2 mb-auto">
<div className="toc flex-3 sticky top-4 hidden h-[calc(100vh-8rem)] min-w-[238px] self-start lg:flex lg:p-8">
<div className="mb-auto flex h-full w-full flex-col gap-2 px-2">
<div className="flex items-center gap-2">
<ListIcon className="w-4 h-4" />
<h3 className="font-medium text-sm">On this page</h3>
<ListIcon className="h-4 w-4" />
<h3 className="text-sm font-medium">On this page</h3>
</div>
<div className="flex-shrink-0 min-h-0 max-h-[calc(70vh-4rem)]">
<div className="max-h-[calc(70vh-2rem)] min-h-0 shrink-0">
<ScrollArea className="h-full">
<TocObserver data={tocs} />
<TocObserver data={tocs} activeId={activeId} onActiveIdChange={setActiveId} />
</ScrollArea>
</div>
<Sponsor />
</div>
</div>
);
)
}

View File

@@ -2,7 +2,7 @@ import { PropsWithChildren } from "react";
export function Typography({ children }: PropsWithChildren) {
return (
<div className="prose prose-zinc dark:prose-invert prose-code:font-code dark:prose-code:bg-stone-900/25 prose-code:bg-stone-50 prose-pre:bg-background prose-headings:scroll-m-20 w-[85vw] sm:w-full sm:mx-auto prose-code:text-sm prose-code:leading-6 dark:prose-code:text-white prose-code:text-stone-800 prose-code:p-1 prose-code:rounded-md prose-code:border pt-2 !min-w-full prose-img:rounded-md prose-img:border prose-code:before:content-none prose-code:after:content-none prose-code:px-1.5 prose-code:overflow-x-auto !max-w-[500px] prose-img:my-3 prose-h2:my-4 prose-h2:mt-8">
<div className="prose prose-zinc dark:prose-invert prose-code:font-code dark:prose-code:bg-stone-900/25 prose-code:bg-stone-50 prose-pre:bg-background max-lg:prose-headings:scroll-mt-54 prose-headings:scroll-mt-4 w-[85vw] sm:w-full sm:mx-auto prose-code:text-sm prose-code:leading-6 dark:prose-code:text-white prose-code:text-stone-800 prose-code:p-1 prose-code:rounded-md prose-code:border pt-2 min-w-full! prose-img:rounded-md prose-img:border prose-code:before:content-none prose-code:after:content-none prose-code:px-1.5 prose-code:overflow-x-auto max-w-[500px]! prose-img:my-3 prose-h2:my-4 prose-h2:mt-8">
{children}
</div>
);

View File

@@ -0,0 +1,353 @@
"use client";
import React, { useEffect, useRef, useState, useMemo } from "react";
import { renderToString } from "react-dom/server";
interface Icon {
x: number;
y: number;
z: number;
scale: number;
opacity: number;
id: number;
}
interface IconCloudProps {
icons?: React.ReactNode[];
images?: string[];
}
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
export function IconCloud({ icons, images }: IconCloudProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
// const [iconPositions, setIconPositions] = useState<Icon[]>([]);
const iconPositions = useMemo<Icon[]>(() => {
const items = icons || images || [];
const newIcons: Icon[] = [];
const numIcons = items.length || 20;
// Fibonacci sphere parameters
const offset = 2 / numIcons;
const increment = Math.PI * (3 - Math.sqrt(5));
for (let i = 0; i < numIcons; i++) {
const y = i * offset - 1 + offset / 2;
const r = Math.sqrt(1 - y * y);
const phi = i * increment;
const x = Math.cos(phi) * r;
const z = Math.sin(phi) * r;
newIcons.push({
x: x * 100,
y: y * 100,
z: z * 100,
scale: 1,
opacity: 1,
id: i,
});
}
return newIcons;
}, [icons, images]);
const [rotation] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [lastMousePos, setLastMousePos] = useState({ x: 0, y: 0 });
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const [targetRotation, setTargetRotation] = useState<{
x: number;
y: number;
startX: number;
startY: number;
distance: number;
startTime: number;
duration: number;
} | null>(null);
const animationFrameRef = useRef<number>(undefined);
const rotationRef = useRef(rotation);
const iconCanvasesRef = useRef<HTMLCanvasElement[]>([]);
const imagesLoadedRef = useRef<boolean[]>([]);
// Create icon canvases once when icons/images change
useEffect(() => {
if (!icons && !images) return;
const items = icons || images || [];
imagesLoadedRef.current = new Array(items.length).fill(false);
const newIconCanvases = items.map((item, index) => {
const offscreen = document.createElement("canvas");
offscreen.width = 40;
offscreen.height = 40;
const offCtx = offscreen.getContext("2d");
if (offCtx) {
if (images) {
// Handle image URLs directly
const img = new Image();
img.crossOrigin = "anonymous";
img.src = items[index] as string;
img.onload = () => {
offCtx.clearRect(0, 0, offscreen.width, offscreen.height);
// Create circular clipping path
offCtx.beginPath();
offCtx.arc(20, 20, 20, 0, Math.PI * 2);
offCtx.closePath();
offCtx.clip();
// Draw the image
offCtx.drawImage(img, 0, 0, 40, 40);
imagesLoadedRef.current[index] = true;
};
} else {
// Handle SVG icons
offCtx.scale(0.4, 0.4);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const svgString = renderToString(item as React.ReactElement<any>);
const img = new Image();
img.src = "data:image/svg+xml;base64," + btoa(svgString);
img.onload = () => {
offCtx.clearRect(0, 0, offscreen.width, offscreen.height);
offCtx.drawImage(img, 0, 0);
imagesLoadedRef.current[index] = true;
};
}
}
return offscreen;
});
iconCanvasesRef.current = newIconCanvases;
}, [icons, images]);
// Generate initial icon positions on a sphere
// useEffect(() => {
// const items = icons || images || [];
// const newIcons: Icon[] = [];
// const numIcons = items.length || 20;
// // Fibonacci sphere parameters
// const offset = 2 / numIcons;
// const increment = Math.PI * (3 - Math.sqrt(5));
// for (let i = 0; i < numIcons; i++) {
// const y = i * offset - 1 + offset / 2;
// const r = Math.sqrt(1 - y * y);
// const phi = i * increment;
// const x = Math.cos(phi) * r;
// const z = Math.sin(phi) * r;
// newIcons.push({
// x: x * 100,
// y: y * 100,
// z: z * 100,
// scale: 1,
// opacity: 1,
// id: i,
// });
// }
// setIconPositions(newIcons);
// }, [icons, images]);
// Handle mouse events
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect || !canvasRef.current) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const ctx = canvasRef.current.getContext("2d");
if (!ctx) return;
iconPositions.forEach((icon) => {
const cosX = Math.cos(rotationRef.current.x);
const sinX = Math.sin(rotationRef.current.x);
const cosY = Math.cos(rotationRef.current.y);
const sinY = Math.sin(rotationRef.current.y);
const rotatedX = icon.x * cosY - icon.z * sinY;
const rotatedZ = icon.x * sinY + icon.z * cosY;
const rotatedY = icon.y * cosX + rotatedZ * sinX;
const screenX = canvasRef.current!.width / 2 + rotatedX;
const screenY = canvasRef.current!.height / 2 + rotatedY;
const scale = (rotatedZ + 200) / 300;
const radius = 20 * scale;
const dx = x - screenX;
const dy = y - screenY;
if (dx * dx + dy * dy < radius * radius) {
const targetX = -Math.atan2(
icon.y,
Math.sqrt(icon.x * icon.x + icon.z * icon.z),
);
const targetY = Math.atan2(icon.x, icon.z);
const currentX = rotationRef.current.x;
const currentY = rotationRef.current.y;
const distance = Math.sqrt(
Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2),
);
const duration = Math.min(2000, Math.max(800, distance * 1000));
setTargetRotation({
x: targetX,
y: targetY,
startX: currentX,
startY: currentY,
distance,
startTime: performance.now(),
duration,
});
return;
}
});
setIsDragging(true);
setLastMousePos({ x: e.clientX, y: e.clientY });
};
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
const rect = canvasRef.current?.getBoundingClientRect();
if (rect) {
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setMousePos({ x, y });
}
if (isDragging) {
const deltaX = e.clientX - lastMousePos.x;
const deltaY = e.clientY - lastMousePos.y;
rotationRef.current = {
x: rotationRef.current.x + deltaY * 0.002,
y: rotationRef.current.y + deltaX * 0.002,
};
setLastMousePos({ x: e.clientX, y: e.clientY });
}
};
const handleMouseUp = () => {
setIsDragging(false);
};
// Animation and rendering
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
const animate = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const maxDistance = Math.sqrt(centerX * centerX + centerY * centerY);
const dx = mousePos.x - centerX;
const dy = mousePos.y - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
const speed = 0.003 + (distance / maxDistance) * 0.01;
if (targetRotation) {
const elapsed = performance.now() - targetRotation.startTime;
const progress = Math.min(1, elapsed / targetRotation.duration);
const easedProgress = easeOutCubic(progress);
rotationRef.current = {
x:
targetRotation.startX +
(targetRotation.x - targetRotation.startX) * easedProgress,
y:
targetRotation.startY +
(targetRotation.y - targetRotation.startY) * easedProgress,
};
if (progress >= 1) {
setTargetRotation(null);
}
} else if (!isDragging) {
rotationRef.current = {
x: rotationRef.current.x + (dy / canvas.height) * speed,
y: rotationRef.current.y + (dx / canvas.width) * speed,
};
}
iconPositions.forEach((icon, index) => {
const cosX = Math.cos(rotationRef.current.x);
const sinX = Math.sin(rotationRef.current.x);
const cosY = Math.cos(rotationRef.current.y);
const sinY = Math.sin(rotationRef.current.y);
const rotatedX = icon.x * cosY - icon.z * sinY;
const rotatedZ = icon.x * sinY + icon.z * cosY;
const rotatedY = icon.y * cosX + rotatedZ * sinX;
const scale = (rotatedZ + 200) / 300;
const opacity = Math.max(0.2, Math.min(1, (rotatedZ + 150) / 200));
ctx.save();
ctx.translate(
canvas.width / 2 + rotatedX,
canvas.height / 2 + rotatedY,
);
ctx.scale(scale, scale);
ctx.globalAlpha = opacity;
if (icons || images) {
// Only try to render icons/images if they exist
if (
iconCanvasesRef.current[index] &&
imagesLoadedRef.current[index]
) {
ctx.drawImage(iconCanvasesRef.current[index], -20, -20, 40, 40);
}
} else {
// Show numbered circles if no icons/images are provided
ctx.beginPath();
ctx.arc(0, 0, 20, 0, Math.PI * 2);
ctx.fillStyle = "#4444ff";
ctx.fill();
ctx.fillStyle = "white";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "16px Arial";
ctx.fillText(`${icon.id + 1}`, 0, 0);
}
ctx.restore();
});
animationFrameRef.current = requestAnimationFrame(animate);
};
animate();
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [icons, images, iconPositions, isDragging, mousePos, targetRotation]);
return (
<canvas
ref={canvasRef}
width={400}
height={400}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
className="rounded-full"
aria-label="Interactive 3D Icon Cloud"
role="img"
/>
);
}

View File

@@ -33,9 +33,9 @@ const ScrollBar = React.forwardRef<
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
"h-full w-2.5 border-l border-l-transparent p-px",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
"h-2.5 flex-col border-t border-t-transparent p-px",
className
)}
{...props}

View File

@@ -3,7 +3,7 @@
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { PanelRightClose } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -51,7 +51,7 @@ const sheetVariants = cva(
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
VariantProps<typeof sheetVariants> { }
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
@@ -65,8 +65,8 @@ const SheetContent = React.forwardRef<
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-7 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<SheetPrimitive.Close className="absolute top-7 right-4 z-50 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<PanelRightClose className="w-6 h-6 shrink-0 text-muted-foreground" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>

View File

@@ -6,10 +6,10 @@ const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<div className="relative w-full overflow-auto border border-border rounded-lg">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
className={cn("w-full caption-bottom text-sm !my-0", className)}
{...props}
/>
</div>
@@ -20,7 +20,7 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
<thead ref={ref} className={cn("[&_tr]:border-b bg-muted", className)} {...props} />
))
TableHeader.displayName = "TableHeader"

View File

@@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap px-1.5 py-[0.58rem] text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:border-primary border-b-2 border-transparent data-[state=active]:text-foreground font-code",
"inline-flex items-center justify-center whitespace-nowrap px-1.5 py-[0.58rem] text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:border-primary border-b-2 border-transparent data-[state=active]:text-foreground font-code cursor-pointer",
className
)}
{...props}

View File

@@ -7,7 +7,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-1 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-1 rounded-md text-xs font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-3 [&_svg]:shrink-0",
{
variants: {
variant: {
@@ -18,7 +18,7 @@ const toggleVariants = cva(
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
xs: "h-7 px-1 min-w-7",
xs: "h-6 px-1.5 min-w-6",
lg: "h-10 px-2.5 min-w-10",
},
},
@@ -32,7 +32,7 @@ const toggleVariants = cva(
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}