feat: Implement Algolia DocSearch, enhance MDX components, and update build configurations.
This commit is contained in:
32
components/DocSearch.tsx
Normal file
32
components/DocSearch.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { DocSearch } from "@docsearch/react";
|
||||
|
||||
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;
|
||||
|
||||
if (!appId || !apiKey || !indexName) {
|
||||
console.error(
|
||||
"DocSearch credentials are not set in the environment variables."
|
||||
);
|
||||
return (
|
||||
<button className="text-sm text-muted-foreground" disabled>
|
||||
Search... (misconfigured)
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="docsearch">
|
||||
<DocSearch
|
||||
appId={appId}
|
||||
apiKey={apiKey}
|
||||
indexName={indexName}
|
||||
placeholder="Type something to search..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
202
components/SearchModal.tsx
Normal file
202
components/SearchModal.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
"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 {
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type ContextInfo = {
|
||||
icon: string;
|
||||
description: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type SearchResult = {
|
||||
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;
|
||||
|
||||
interface SearchModalProps {
|
||||
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)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSearchedInput("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const filteredResults = useMemo<SearchResult[]>(() => {
|
||||
const trimmedInput = searchedInput.trim();
|
||||
|
||||
if (trimmedInput.length < 3) {
|
||||
return page_routes
|
||||
.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]);
|
||||
|
||||
// useEffect(() => {
|
||||
// setSelectedIndex(0);
|
||||
// }, [filteredResults]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleNavigation = (event: KeyboardEvent) => {
|
||||
if (!isOpen || filteredResults.length === 0) return;
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setSelectedIndex((prev) => (prev + 1) % filteredResults.length);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setSelectedIndex((prev) => (prev - 1 + filteredResults.length) % filteredResults.length);
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const selectedItem = filteredResults[selectedIndex];
|
||||
if (selectedItem) {
|
||||
router.push(`/docs${selectedItem.href}`);
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
return (
|
||||
<DialogContent className="p-0 max-w-[650px] sm:top-[38%] top-[45%] !rounded-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="sr-only">Search Documentation</DialogTitle>
|
||||
<DialogDescription className="sr-only">Search through the documentation</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<input
|
||||
value={searchedInput}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
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>
|
||||
</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">
|
||||
{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;
|
||||
|
||||
return (
|
||||
<DialogClose key={item.href} asChild>
|
||||
<Anchor
|
||||
ref={(el) => {
|
||||
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",
|
||||
isActive && "bg-primary/20 dark:bg-primary/30",
|
||||
paddingClass
|
||||
)}
|
||||
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",
|
||||
level > 1 && "border-l pl-4"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<FileTextIcon className="h-[1.1rem] w-[1.1rem] mr-1" />
|
||||
<span>{item.title}</span>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div className="hidden md:flex items-center text-xs text-muted-foreground">
|
||||
<span>Return</span>
|
||||
<CornerDownLeftIcon className="h-3 w-3 ml-1" />
|
||||
</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">
|
||||
<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>
|
||||
<span className="dark:bg-accent/15 bg-slate-200 border rounded p-2">
|
||||
<ArrowDownIcon className="w-3 h-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>
|
||||
<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>
|
||||
<p className="text-muted-foreground">to close</p>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
31
components/SearchTrigger.tsx
Normal file
31
components/SearchTrigger.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { CommandIcon, SearchIcon } from "lucide-react";
|
||||
import { DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export function SearchTrigger() {
|
||||
return (
|
||||
<DialogTrigger asChild>
|
||||
<div className="relative flex-1 cursor-pointer max-w-[140px]">
|
||||
<div className="flex items-center">
|
||||
<div className="md:hidden p-2 -ml-2">
|
||||
<SearchIcon className="h-5 w-5 text-muted-foreground" />
|
||||
</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" />
|
||||
<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"
|
||||
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" />
|
||||
<span>K</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
@@ -10,16 +10,76 @@ interface SponsorItem {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface NavbarConfig {
|
||||
title?: string;
|
||||
logo?: {
|
||||
light?: string;
|
||||
dark?: string;
|
||||
};
|
||||
links?: Array<{
|
||||
title: string;
|
||||
href: string;
|
||||
external?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface FooterConfig {
|
||||
text?: string;
|
||||
links?: Array<{
|
||||
title: string;
|
||||
href: string;
|
||||
external?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface MetaConfig {
|
||||
title?: string;
|
||||
description?: string;
|
||||
favicon?: string;
|
||||
socialBanner?: string;
|
||||
}
|
||||
|
||||
interface RepositoryConfig {
|
||||
url: string;
|
||||
editUrl?: string;
|
||||
branch?: string;
|
||||
directory?: string;
|
||||
}
|
||||
|
||||
interface RouteItem {
|
||||
title: string;
|
||||
href: string;
|
||||
noLink?: boolean;
|
||||
context?: {
|
||||
icon: string;
|
||||
description: string;
|
||||
title: string;
|
||||
};
|
||||
items?: RouteItem[];
|
||||
}
|
||||
|
||||
interface RouteConfig {
|
||||
title: string;
|
||||
href: string;
|
||||
noLink?: boolean;
|
||||
context?: {
|
||||
icon: string;
|
||||
description: string;
|
||||
title: string;
|
||||
};
|
||||
items?: RouteItem[];
|
||||
}
|
||||
|
||||
interface DocuConfig {
|
||||
sponsor?: {
|
||||
title?: string;
|
||||
item?: SponsorItem;
|
||||
};
|
||||
navbar: any; // Anda bisa mendefinisikan tipe yang lebih spesifik jika diperlukan
|
||||
footer: any;
|
||||
meta: any;
|
||||
repository: any;
|
||||
routes: any[];
|
||||
navbar: NavbarConfig;
|
||||
footer: FooterConfig;
|
||||
meta: MetaConfig;
|
||||
repository: RepositoryConfig;
|
||||
routes: RouteConfig[];
|
||||
}
|
||||
|
||||
// Type assertion for docu.json
|
||||
|
||||
@@ -45,6 +45,7 @@ export default function ContextPopover({ className }: ContextPopoverProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname.startsWith("/docs")) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setActiveRoute(getActiveContextRoute(pathname));
|
||||
} else {
|
||||
setActiveRoute(undefined);
|
||||
@@ -61,7 +62,7 @@ export default function ContextPopover({ className }: ContextPopoverProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full max-w-[240px] flex items-center justify-between font-semibold text-foreground px-0 pt-8",
|
||||
"w-full max-w-[240px] cursor-pointer flex items-center justify-between font-semibold text-foreground px-0 pt-8",
|
||||
"hover:bg-transparent hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
@@ -95,7 +96,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"
|
||||
|
||||
4
components/contexts/AccordionContext.ts
Normal file
4
components/contexts/AccordionContext.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
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);
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||
import { type ThemeProviderProps } from "next-themes";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
|
||||
@@ -29,7 +29,7 @@ export function ToggleButton({
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="hover:bg-transparent hover:text-inherit border-none text-muted-foreground"
|
||||
className="cursor-pointer hover:bg-transparent hover:text-inherit border-none text-muted-foreground"
|
||||
onClick={onToggle}
|
||||
>
|
||||
{collapsed ? (
|
||||
|
||||
31
components/markdown/AccordionGroupMdx.tsx
Normal file
31
components/markdown/AccordionGroupMdx.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import React, { ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import { AccordionGroupContext } from "@/components/contexts/AccordionContext";
|
||||
|
||||
interface AccordionGroupProps {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccordionGroup;
|
||||
@@ -1,42 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useState } from 'react';
|
||||
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';
|
||||
|
||||
type AccordionProps = {
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
className?: string;
|
||||
icon?: keyof typeof Icons;
|
||||
};
|
||||
|
||||
const Accordion = ({
|
||||
const Accordion: React.FC<AccordionProps> = ({
|
||||
title,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
className,
|
||||
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;
|
||||
|
||||
// The main wrapper div for the accordion.
|
||||
// All styling logic for the accordion container is handled here.
|
||||
return (
|
||||
<div className={cn("border rounded-lg overflow-hidden", className)}>
|
||||
<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 my-auto space-x-2 space-y-2 w-full px-4 h-12 transition-colors bg-background dark:hover:bg-muted/50 hover:bg-muted/15"
|
||||
className="flex items-center gap-2 w-full px-4 py-3 transition-colors bg-muted/40 dark:bg-muted/20 hover:bg-muted/70 dark:hover:bg-muted/70 cursor-pointer text-start"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"w-4 h-4 text-muted-foreground transition-transform duration-200",
|
||||
"w-4 h-4 text-muted-foreground transition-transform duration-200 flex-shrink-0",
|
||||
isOpen && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
<h3 className="font-medium text-base text-foreground pb-2">{title}</h3>
|
||||
{Icon && <Icon className="text-foreground w-4 h-4 flex-shrink-0" />}
|
||||
<h3 className="font-medium text-base text-foreground !m-0">{title}</h3>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="px-4 py-3 border-t dark:bg-muted/50 bg-muted/15">
|
||||
<div className="px-4 py-3 dark:bg-muted/10 bg-muted/15">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
@@ -44,4 +59,4 @@ const Accordion = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default Accordion;
|
||||
export default Accordion;
|
||||
@@ -1,8 +1,6 @@
|
||||
import React from "react";
|
||||
import * as Icons from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
type IconName = keyof typeof Icons;
|
||||
type ButtonProps = {
|
||||
icon?: keyof typeof Icons;
|
||||
text?: string;
|
||||
|
||||
@@ -8,13 +8,21 @@ interface CardGroupProps {
|
||||
}
|
||||
|
||||
const CardGroup: React.FC<CardGroupProps> = ({ children, cols = 2, className }) => {
|
||||
const cardsArray = React.Children.toArray(children); // Pastikan children berupa array
|
||||
const cardsArray = React.Children.toArray(children);
|
||||
|
||||
// Static grid column classes for Tailwind v4 compatibility
|
||||
const gridColsClass = {
|
||||
1: "grid-cols-1",
|
||||
2: "grid-cols-1 sm:grid-cols-2",
|
||||
3: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
|
||||
4: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4",
|
||||
}[cols] || "grid-cols-1 sm:grid-cols-2";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"grid gap-4 text-foreground",
|
||||
`grid-cols-1 sm:grid-cols-${cols}`,
|
||||
gridColsClass,
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -100,8 +100,8 @@ export const Files = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
rounded-xl border border-muted/50
|
||||
bg-card/50 backdrop-blur-sm
|
||||
rounded-xl border border-muted/20
|
||||
bg-card/20 backdrop-blur-sm
|
||||
shadow-sm overflow-hidden
|
||||
transition-all duration-200
|
||||
hover:shadow-md hover:border-muted/60
|
||||
|
||||
@@ -1,25 +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"];
|
||||
};
|
||||
|
||||
export default function Image({
|
||||
src,
|
||||
alt = "alt",
|
||||
width = 800,
|
||||
height = 350,
|
||||
...props
|
||||
}: ComponentProps<"img">) {
|
||||
if (!src) return null;
|
||||
return (
|
||||
<NextImage
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width as Width}
|
||||
height={height as Height}
|
||||
quality={40}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
src,
|
||||
alt = "alt",
|
||||
width = 800,
|
||||
height = 350,
|
||||
...props
|
||||
}: ImageProps) {
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,109 @@
|
||||
import { ComponentProps } from "react";
|
||||
import { type ComponentProps, type JSX } from "react";
|
||||
import Copy from "./CopyMdx";
|
||||
import {
|
||||
SiJavascript,
|
||||
SiTypescript,
|
||||
SiReact,
|
||||
SiPython,
|
||||
SiGo,
|
||||
SiPhp,
|
||||
SiRuby,
|
||||
SiSwift,
|
||||
SiKotlin,
|
||||
SiHtml5,
|
||||
SiCss3,
|
||||
SiSass,
|
||||
SiPostgresql,
|
||||
SiGraphql,
|
||||
SiYaml,
|
||||
SiToml,
|
||||
SiDocker,
|
||||
SiNginx,
|
||||
SiGit,
|
||||
SiGnubash,
|
||||
SiMarkdown,
|
||||
} from "react-icons/si";
|
||||
import { FaJava, FaCode } from "react-icons/fa";
|
||||
import { TbJson } from "react-icons/tb";
|
||||
|
||||
type PreProps = ComponentProps<"pre"> & {
|
||||
raw?: string;
|
||||
"data-title"?: string;
|
||||
};
|
||||
|
||||
// Component to display an icon based on the programming language
|
||||
const LanguageIcon = ({ lang }: { lang: string }) => {
|
||||
const iconProps = { className: "w-4 h-4" };
|
||||
const languageToIconMap: Record<string, JSX.Element> = {
|
||||
gitignore: <SiGit {...iconProps} />,
|
||||
docker: <SiDocker {...iconProps} />,
|
||||
dockerfile: <SiDocker {...iconProps} />,
|
||||
nginx: <SiNginx {...iconProps} />,
|
||||
sql: <SiPostgresql {...iconProps} />,
|
||||
graphql: <SiGraphql {...iconProps} />,
|
||||
yaml: <SiYaml {...iconProps} />,
|
||||
yml: <SiYaml {...iconProps} />,
|
||||
toml: <SiToml {...iconProps} />,
|
||||
json: <TbJson {...iconProps} />,
|
||||
md: <SiMarkdown {...iconProps} />,
|
||||
markdown: <SiMarkdown {...iconProps} />,
|
||||
bash: <SiGnubash {...iconProps} />,
|
||||
sh: <SiGnubash {...iconProps} />,
|
||||
shell: <SiGnubash {...iconProps} />,
|
||||
swift: <SiSwift {...iconProps} />,
|
||||
kotlin: <SiKotlin {...iconProps} />,
|
||||
kt: <SiKotlin {...iconProps} />,
|
||||
kts: <SiKotlin {...iconProps} />,
|
||||
rb: <SiRuby {...iconProps} />,
|
||||
ruby: <SiRuby {...iconProps} />,
|
||||
php: <SiPhp {...iconProps} />,
|
||||
go: <SiGo {...iconProps} />,
|
||||
py: <SiPython {...iconProps} />,
|
||||
python: <SiPython {...iconProps} />,
|
||||
java: <FaJava {...iconProps} />,
|
||||
tsx: <SiReact {...iconProps} />,
|
||||
typescript: <SiTypescript {...iconProps} />,
|
||||
ts: <SiTypescript {...iconProps} />,
|
||||
jsx: <SiReact {...iconProps} />,
|
||||
js: <SiJavascript {...iconProps} />,
|
||||
javascript: <SiJavascript {...iconProps} />,
|
||||
html: <SiHtml5 {...iconProps} />,
|
||||
css: <SiCss3 {...iconProps} />,
|
||||
scss: <SiSass {...iconProps} />,
|
||||
sass: <SiSass {...iconProps} />,
|
||||
};
|
||||
return languageToIconMap[lang] || <FaCode {...iconProps} />;
|
||||
};
|
||||
|
||||
// Function to extract the language from className
|
||||
function getLanguage(className: string = ""): string {
|
||||
const match = className.match(/language-(\w+)/);
|
||||
return match ? match[1] : "default";
|
||||
}
|
||||
|
||||
export default function Pre({ children, raw, ...rest }: PreProps) {
|
||||
const { "data-title": title, className, ...restProps } = rest;
|
||||
const language = getLanguage(className);
|
||||
const hasTitle = !!title;
|
||||
|
||||
export default function Pre({
|
||||
children,
|
||||
raw,
|
||||
...rest
|
||||
}: ComponentProps<"pre"> & { raw?: string }) {
|
||||
return (
|
||||
<div className="my-5 relative">
|
||||
<div className="absolute top-3 right-2.5 z-10 sm:block hidden">
|
||||
<Copy content={raw!} />
|
||||
<div className="code-block-container">
|
||||
<div className="code-block-actions">
|
||||
{raw && <Copy content={raw} />}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<pre {...rest}>{children}</pre>
|
||||
{hasTitle && (
|
||||
<div className="code-block-header">
|
||||
<div className="flex items-center gap-2">
|
||||
<LanguageIcon lang={language} />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="code-block-body">
|
||||
<pre className={className} {...restProps}>
|
||||
{children}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { MDXRemote, MDXRemoteProps } from 'next-mdx-remote/rsc';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import { Kbd } from './KeyboardMdx';
|
||||
|
||||
// Define components mapping
|
||||
|
||||
@@ -7,14 +7,14 @@ import { useRef, useMemo } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Button } from "./ui/button";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useScrollPosition, useActiveSection } from "@/hooks";
|
||||
import { useActiveSection } from "@/hooks";
|
||||
import { TocItem } from "@/lib/toc";
|
||||
|
||||
interface MobTocProps {
|
||||
tocs: TocItem[];
|
||||
}
|
||||
|
||||
const useClickOutside = (ref: React.RefObject<HTMLElement>, callback: () => void) => {
|
||||
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();
|
||||
|
||||
@@ -32,6 +32,7 @@ export function ScrollToTop({
|
||||
|
||||
useEffect(() => {
|
||||
// Initial check
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
checkScroll();
|
||||
|
||||
// Set up scroll listener with debounce for better performance
|
||||
|
||||
@@ -1,246 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import { ArrowUpIcon, ArrowDownIcon, CommandIcon, FileTextIcon, SearchIcon, CornerDownLeftIcon } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
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 { 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";
|
||||
|
||||
// Define the ContextInfo type to match the one in routes-config
|
||||
type ContextInfo = {
|
||||
icon: string;
|
||||
description: string;
|
||||
title?: string;
|
||||
};
|
||||
interface SearchProps {
|
||||
/**
|
||||
* Specify which search engine to use.
|
||||
* @default 'default'
|
||||
*/
|
||||
type?: "default" | "algolia";
|
||||
}
|
||||
|
||||
type SearchResult = {
|
||||
title: string;
|
||||
href: string;
|
||||
noLink?: boolean;
|
||||
items?: undefined;
|
||||
score?: number;
|
||||
context?: ContextInfo;
|
||||
};
|
||||
|
||||
export default function Search() {
|
||||
const router = useRouter();
|
||||
const [searchedInput, setSearchedInput] = useState("");
|
||||
export default function Search({ type = "default" }: SearchProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
// The useEffect below is ONLY for the 'default' type, which is correct.
|
||||
// DocSearch handles its own keyboard shortcut.
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "k") {
|
||||
event.preventDefault();
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filteredResults = useMemo<SearchResult[]>(() => {
|
||||
const trimmedInput = searchedInput.trim();
|
||||
|
||||
// If search input is empty or less than 3 characters, show initial suggestions
|
||||
if (trimmedInput.length < 3) {
|
||||
return page_routes
|
||||
.filter((route: { href: string }) => !route.href.endsWith('/')) // Filter out directory routes
|
||||
.slice(0, 6) // Limit to 6 posts
|
||||
.map((route: { title: string; href: string; noLink?: boolean; context?: ContextInfo }) => ({
|
||||
title: route.title,
|
||||
href: route.href,
|
||||
noLink: route.noLink,
|
||||
context: route.context
|
||||
}));
|
||||
}
|
||||
|
||||
// For search with 3 or more characters, use the advance search
|
||||
return advanceSearch(trimmedInput) as unknown as SearchResult[];
|
||||
}, [searchedInput]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [filteredResults]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleNavigation = (event: KeyboardEvent) => {
|
||||
if (!isOpen || filteredResults.length === 0) return;
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setSelectedIndex((prev) => (prev + 1) % filteredResults.length);
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setSelectedIndex((prev) => (prev - 1 + filteredResults.length) % filteredResults.length);
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const selectedItem = filteredResults[selectedIndex];
|
||||
if (selectedItem) {
|
||||
router.push(`/docs${selectedItem.href}`);
|
||||
setIsOpen(false);
|
||||
if (type === 'default') {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "k") {
|
||||
event.preventDefault();
|
||||
setIsOpen((open) => !open);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleNavigation);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleNavigation);
|
||||
};
|
||||
}, [isOpen, filteredResults, selectedIndex, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (itemRefs.current[selectedIndex]) {
|
||||
itemRefs.current[selectedIndex]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
}, [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={(open) => {
|
||||
if (!open) setSearchedInput("");
|
||||
setIsOpen(open);
|
||||
}}
|
||||
>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<div className="relative flex-1 cursor-pointer max-w-[140px]">
|
||||
<div className="flex items-center">
|
||||
<div className="md:hidden p-2 -ml-2">
|
||||
<SearchIcon className="h-5 w-5 text-muted-foreground" />
|
||||
</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" />
|
||||
<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"
|
||||
placeholder="Search"
|
||||
type="search"
|
||||
/>
|
||||
<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" />
|
||||
<span>K</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SearchTrigger />
|
||||
</DialogTrigger>
|
||||
<DialogContent className="p-0 max-w-[650px] sm:top-[38%] top-[45%] !rounded-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="sr-only">Search Documentation</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="sr-only">
|
||||
Search through the documentation
|
||||
</DialogDescription>
|
||||
<input
|
||||
value={searchedInput}
|
||||
onChange={(e) => setSearchedInput(e.target.value)}
|
||||
placeholder="Type something to search..."
|
||||
autoFocus
|
||||
className="h-14 px-6 bg-transparent border-b text-[14px] outline-none w-full"
|
||||
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>
|
||||
</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">
|
||||
{filteredResults.map((item, index) => {
|
||||
const level = (item.href.split("/").slice(1).length - 1) as keyof typeof paddingMap;
|
||||
const paddingClass = paddingMap[level];
|
||||
const isActive = index === selectedIndex;
|
||||
|
||||
return (
|
||||
<DialogClose key={item.href} asChild>
|
||||
<Anchor
|
||||
ref={(el) => {
|
||||
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",
|
||||
isActive && "bg-primary/20 dark:bg-primary/30",
|
||||
paddingClass
|
||||
)}
|
||||
href={`/docs${item.href}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center w-full h-full py-3 gap-1.5 px-2 justify-between",
|
||||
level > 1 && "border-l pl-4"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<FileTextIcon className="h-[1.1rem] w-[1.1rem] mr-1" />
|
||||
<span>{item.title}</span>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div className="hidden md:flex items-center text-xs text-muted-foreground">
|
||||
<span>Return</span>
|
||||
<CornerDownLeftIcon className="h-3 w-3 ml-1" />
|
||||
</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">
|
||||
<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>
|
||||
<span className="dark:bg-accent/15 bg-slate-200 border rounded p-2">
|
||||
<ArrowDownIcon className="w-3 h-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>
|
||||
<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>
|
||||
<p className="text-muted-foreground">to close</p>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
<SearchModal isOpen={isOpen} setIsOpen={setIsOpen} />
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const paddingMap = {
|
||||
1: "pl-2",
|
||||
2: "pl-4",
|
||||
3: "pl-10",
|
||||
} as const;
|
||||
}
|
||||
@@ -32,9 +32,6 @@ export default function SubLink({
|
||||
// Full path including parent's href
|
||||
const fullHref = `${parentHref}${href}`;
|
||||
|
||||
// Check if current path exactly matches this link's href
|
||||
const isExactActive = useMemo(() => path === fullHref, [path, fullHref]);
|
||||
|
||||
// Check if any child is active (for parent items)
|
||||
const hasActiveChild = useMemo(() => {
|
||||
if (!items) return false;
|
||||
@@ -47,6 +44,7 @@ export default function SubLink({
|
||||
// Auto-expand if current path is a child of this item
|
||||
useEffect(() => {
|
||||
if (items && (path.startsWith(fullHref) && path !== fullHref)) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [path, fullHref, items]);
|
||||
@@ -88,7 +86,7 @@ export default function SubLink({
|
||||
<div className={cn("flex flex-col gap-1 w-full")}>
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CollapsibleTrigger
|
||||
className="w-full pr-5 text-left"
|
||||
className="w-full pr-5 text-left cursor-pointer"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={`collapsible-${fullHref.replace(/[^a-zA-Z0-9]/g, '-')}`}
|
||||
>
|
||||
|
||||
@@ -1,68 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Moon, Sun, Monitor } from "lucide-react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
|
||||
export function ModeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [selectedTheme, setSelectedTheme] = React.useState<string>("system");
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
// Pastikan toggle tetap di posisi yang benar setelah reload
|
||||
// Untuk menghindari hydration mismatch
|
||||
React.useEffect(() => {
|
||||
if (theme) {
|
||||
setSelectedTheme(theme);
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
// Tentukan theme yang aktif: gunakan resolvedTheme untuk menampilkan ikon yang sesuai
|
||||
// jika theme === "system", resolvedTheme akan menjadi "light" atau "dark" sesuai device
|
||||
const activeTheme = theme === "system" || !theme ? resolvedTheme : theme;
|
||||
|
||||
const handleToggle = () => {
|
||||
// Toggle antara light dan dark
|
||||
// Jika sekarang light, ganti ke dark, dan sebaliknya
|
||||
if (activeTheme === "light") {
|
||||
setTheme("dark");
|
||||
} else {
|
||||
setSelectedTheme("system"); // Default ke system jika undefined
|
||||
setTheme("light");
|
||||
}
|
||||
}, [theme]);
|
||||
};
|
||||
|
||||
return (
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={selectedTheme}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setTheme(value);
|
||||
setSelectedTheme(value);
|
||||
}
|
||||
}}
|
||||
value={activeTheme}
|
||||
onValueChange={handleToggle}
|
||||
className="flex items-center gap-1 rounded-full border border-border bg-background/50 p-1 transition-all"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="light"
|
||||
size="sm"
|
||||
aria-label="Light Mode"
|
||||
className={`rounded-full p-1 transition-all ${
|
||||
selectedTheme === "light"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-transparent hover:bg-muted/50"
|
||||
}`}
|
||||
className={`rounded-full cursor-pointer p-1 transition-all ${activeTheme === "light"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-transparent hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<Sun className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="system"
|
||||
size="sm"
|
||||
aria-label="System Mode"
|
||||
className={`rounded-full p-1 transition-all ${
|
||||
selectedTheme === "system"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-transparent hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<Monitor className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="dark"
|
||||
size="sm"
|
||||
aria-label="Dark Mode"
|
||||
className={`rounded-full p-1 transition-all ${
|
||||
selectedTheme === "dark"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-transparent hover:bg-muted/50"
|
||||
}`}
|
||||
className={`rounded-full cursor-pointer p-1 transition-all ${activeTheme === "dark"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-transparent hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<Moon className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { getDocsTocs } from "@/lib/markdown";
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
@@ -110,7 +109,6 @@ export default function TocObserver({
|
||||
|
||||
// Calculate scroll progress for the active section
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
const [activeSectionIndex, setActiveSectionIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@@ -137,15 +135,6 @@ export default function TocObserver({
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [activeId]);
|
||||
|
||||
// Update active section index when activeId changes
|
||||
useEffect(() => {
|
||||
if (activeId) {
|
||||
const index = data.findIndex(item => item.href.slice(1) === activeId);
|
||||
if (index !== -1) {
|
||||
setActiveSectionIndex(index);
|
||||
}
|
||||
}
|
||||
}, [activeId, data]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -155,8 +144,9 @@ export default function TocObserver({
|
||||
const id = href.slice(1);
|
||||
const isActive = activeId === id;
|
||||
const indent = level > 1 ? (level - 1) * 20 : 0;
|
||||
const isParent = hasChildren(id, level);
|
||||
const isLastInLevel = index === data.length - 1 || data[index + 1].level <= level;
|
||||
// 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">
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } 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 [rotation, setRotation] = 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>();
|
||||
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);
|
||||
const svgString = renderToString(item as React.ReactElement);
|
||||
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,7 @@ import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
|
||||
@@ -2,8 +2,7 @@ import React from "react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface InteractiveHoverButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
|
||||
type InteractiveHoverButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
export const InteractiveHoverButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { motion, MotionProps } from "framer-motion";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface AnimatedSpanProps extends MotionProps {
|
||||
children: React.ReactNode;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const AnimatedSpan = ({
|
||||
children,
|
||||
delay = 0,
|
||||
className,
|
||||
...props
|
||||
}: AnimatedSpanProps) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: delay / 1000 }}
|
||||
className={cn("grid text-sm font-normal tracking-tight", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
interface TypingAnimationProps extends MotionProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
as?: React.ElementType;
|
||||
}
|
||||
|
||||
export const TypingAnimation = ({
|
||||
children,
|
||||
className,
|
||||
duration = 60,
|
||||
delay = 0,
|
||||
as: Component = "span",
|
||||
...props
|
||||
}: TypingAnimationProps) => {
|
||||
if (typeof children !== "string") {
|
||||
throw new Error("TypingAnimation: children must be a string. Received:");
|
||||
}
|
||||
|
||||
const MotionComponent = motion.create(Component, {
|
||||
forwardMotionProps: true,
|
||||
});
|
||||
|
||||
const [displayedText, setDisplayedText] = useState<string>("");
|
||||
const [started, setStarted] = useState(false);
|
||||
const elementRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const startTimeout = setTimeout(() => {
|
||||
setStarted(true);
|
||||
}, delay);
|
||||
return () => clearTimeout(startTimeout);
|
||||
}, [delay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!started) return;
|
||||
|
||||
let i = 0;
|
||||
const typingEffect = setInterval(() => {
|
||||
if (i < children.length) {
|
||||
setDisplayedText(children.substring(0, i + 1));
|
||||
i++;
|
||||
} else {
|
||||
clearInterval(typingEffect);
|
||||
}
|
||||
}, duration);
|
||||
|
||||
return () => {
|
||||
clearInterval(typingEffect);
|
||||
};
|
||||
}, [children, duration, started]);
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
ref={elementRef}
|
||||
className={cn("text-sm font-normal tracking-tight", className)}
|
||||
{...props}
|
||||
>
|
||||
{displayedText}
|
||||
</MotionComponent>
|
||||
);
|
||||
};
|
||||
|
||||
interface TerminalProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Terminal = ({ children, className }: TerminalProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"z-0 h-full max-h-[600px] w-full max-w-[640px] rounded-xl border border-border bg-backgroun",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-y-2 border-b border-border p-4">
|
||||
<div className="flex flex-row gap-x-2">
|
||||
<div className="h-2 w-2 rounded-full bg-red-500"></div>
|
||||
<div className="h-2 w-2 rounded-full bg-yellow-500"></div>
|
||||
<div className="h-2 w-2 rounded-full bg-green-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="p-8">
|
||||
<code className="grid gap-y-1 overflow-auto">{children}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user