feat: Implement Algolia DocSearch, enhance MDX components, and update build configurations.

This commit is contained in:
gitfromwildan
2026-02-08 23:10:20 +07:00
parent 8b3de652bb
commit 099c384d9d
43 changed files with 1617 additions and 1156 deletions

38
.gitignore vendored
View File

@@ -1,39 +1,9 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.next/
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.env*
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# bun
bun.lock
node_modules

View File

@@ -12,13 +12,19 @@ import MobToc from "@/components/mob-toc";
const { meta } = docuConfig;
type PageProps = {
params: {
params: Promise<{
slug: string[];
};
}>;
};
// Function to generate metadata dynamically
export async function generateMetadata({ params: { slug = [] } }: PageProps) {
export async function generateMetadata(props: PageProps) {
const params = await props.params;
const {
slug = []
} = params;
const pathName = slug.join("/");
const res = await getDocsForSlug(pathName);
@@ -62,13 +68,19 @@ export async function generateMetadata({ params: { slug = [] } }: PageProps) {
};
}
export default async function DocsPage({ params: { slug = [] } }: PageProps) {
export default async function DocsPage(props: PageProps) {
const params = await props.params;
const {
slug = []
} = params;
const pathName = slug.join("/");
const res = await getDocsForSlug(pathName);
if (!res) notFound();
const { title, description, image, date } = res.frontmatter;
const { title, description, image: _image, date } = res.frontmatter;
// File path for edit link
const filePath = `contents/docs/${slug.join("/") || ""}/index.mdx`;
@@ -85,17 +97,16 @@ export default async function DocsPage({ params: { slug = [] } }: PageProps) {
<p className="-mt-4 text-muted-foreground text-[16.5px]">{description}</p>
<div>{res.content}</div>
<div
className={`my-8 flex items-center border-b-2 border-dashed border-x-muted-foreground ${
docuConfig.repository?.editLink ? "justify-between" : "justify-end"
}`}
>
className={`my-8 flex items-center border-b-2 border-dashed border-x-muted-foreground ${docuConfig.repository?.editLink ? "justify-between" : "justify-end"
}`}
>
{docuConfig.repository?.editLink && <EditThisPage filePath={filePath} />}
{date && (
<p className="text-[13px] text-muted-foreground">
<p className="text-[13px] text-muted-foreground">
Published on {formatDate2(date)}
</p>
</p>
)}
</div>
</div>
<Pagination pathname={pathName} />
</Typography>
</div>

View File

@@ -6,6 +6,9 @@ import { GeistMono } from "geist/font/mono";
import { Footer } from "@/components/footer";
import docuConfig from "@/docu.json";
import { Toaster } from "@/components/ui/sonner";
import "@docsearch/css";
import "@/styles/algolia.css";
import "@/styles/syntax.css";
import "@/styles/globals.css";
const { meta } = docuConfig;

32
components/DocSearch.tsx Normal file
View 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
View 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>
);
}

View 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>
);
}

View File

@@ -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

View File

@@ -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"

View 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);

View File

@@ -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>;

View File

@@ -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 ? (

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
)}
>

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

@@ -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

View File

@@ -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);
};

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

@@ -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>
);
}
}

View File

@@ -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

View File

@@ -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();

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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, '-')}`}
>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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"
/>
);
}

View File

@@ -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) => {

View File

@@ -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,

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

@@ -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>
);
};

35
eslint.config.mjs Normal file
View File

@@ -0,0 +1,35 @@
import { defineConfig } from "eslint/config";
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import nextTypescript from "eslint-config-next/typescript";
import path from "node:path";
import { fileURLToPath } from "node:url";
import js from "@eslint/js";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all
});
export default defineConfig([{
extends: [
...nextCoreWebVitals,
...nextTypescript,
...compat.extends("plugin:@typescript-eslint/recommended")
],
rules: {
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": ["warn", {
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
}],
"@typescript-eslint/no-empty-object-type": "off",
},
}]);

View File

@@ -2,27 +2,28 @@ import { useState, useCallback, useEffect } from 'react';
export function useScrollPosition(threshold = 0.5) {
const [isScrolled, setIsScrolled] = useState(false);
const handleScroll = useCallback(() => {
if (typeof window === 'undefined') return;
const scrollPosition = window.scrollY;
const viewportHeight = window.innerHeight;
const shouldBeSticky = scrollPosition > viewportHeight * threshold;
setIsScrolled(prev => shouldBeSticky !== prev ? shouldBeSticky : prev);
}, [threshold]);
// Add scroll event listener
useEffect(() => {
// Initial check
// eslint-disable-next-line react-hooks/set-state-in-effect
handleScroll();
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [handleScroll]);
return isScrolled;
}

View File

@@ -8,10 +8,30 @@ import rehypeSlug from "rehype-slug";
import rehypeCodeTitles from "rehype-code-titles";
import { page_routes, ROUTES } from "./routes-config";
import { visit } from "unist-util-visit";
import type { Node, Parent } from "unist";
import matter from "gray-matter";
// Type definitions for unist-util-visit
interface Element extends Node {
type: string;
tagName?: string;
properties?: Record<string, unknown> & {
className?: string[];
raw?: string;
};
children?: Node[];
value?: string;
raw?: string; // For internal use in processing
}
interface TextNode extends Node {
type: 'text';
value: string;
}
// custom components imports
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell } from "@/components/ui/table";
import Pre from "@/components/markdown/PreMdx";
import Note from "@/components/markdown/NoteMdx";
import { Stepper, StepperItem } from "@/components/markdown/StepperMdx";
@@ -27,6 +47,7 @@ import CardGroup from "@/components/markdown/CardGroupMdx";
import Kbd from "@/components/markdown/KeyboardMdx";
import { Release, Changes } from "@/components/markdown/ReleaseMdx";
import { File, Files, Folder } from "@/components/markdown/FileTreeMdx";
import AccordionGroup from "@/components/markdown/AccordionGroupMdx";
// add custom components
const components = {
@@ -34,20 +55,22 @@ const components = {
TabsContent,
TabsList,
TabsTrigger,
pre: Pre,
Note,
Stepper,
StepperItem,
img: Image,
a: Link,
Outlet,
Youtube,
Tooltip,
Card,
Button,
Accordion,
AccordionGroup,
CardGroup,
Kbd,
// Table Components
table: Table,
thead: TableHeader,
tbody: TableBody,
tfoot: TableFooter,
tr: TableRow,
th: TableHead,
td: TableCell,
// Release Note Components
Release,
Changes,
@@ -55,6 +78,56 @@ const components = {
File,
Files,
Folder,
pre: Pre,
Note,
Stepper,
StepperItem,
img: Image,
a: Link,
Outlet,
};
// helper function to handle rehype code titles, since by default we can't inject into the className of rehype-code-titles
const handleCodeTitles = () => (tree: Node) => {
visit(tree, "element", (node: Element, index: number | null, parent: Parent | null) => {
// Ensure the visited node is valid
if (!parent || index === null || node.tagName !== 'div') {
return;
}
// Check if this is the title div from rehype-code-titles
const isTitleDiv = node.properties?.className?.includes('rehype-code-title');
if (!isTitleDiv) {
return;
}
// Find the next <pre> element, skipping over other nodes like whitespace text
let nextElement = null;
for (let i = index + 1; i < parent.children.length; i++) {
const sibling = parent.children[i];
if (sibling.type === 'element') {
nextElement = sibling as Element;
break;
}
}
// If the next element is a <pre>, move the title to it
if (nextElement && nextElement.tagName === 'pre') {
const titleNode = node.children?.[0] as TextNode;
if (titleNode && titleNode.type === 'text') {
if (!nextElement.properties) {
nextElement.properties = {};
}
nextElement.properties['data-title'] = titleNode.value;
// Remove the original title div
parent.children.splice(index, 1);
// Return the same index to continue visiting from the correct position
return index;
}
}
});
};
// can be used for other pages like blogs, Guides etc
@@ -67,6 +140,7 @@ async function parseMdx<Frontmatter>(rawMdx: string) {
rehypePlugins: [
preProcess,
rehypeCodeTitles,
handleCodeTitles,
rehypePrism,
rehypeSlug,
rehypeAutolinkHeadings,
@@ -139,11 +213,11 @@ function justGetFrontmatterFromMD<Frontmatter>(rawMd: string): Frontmatter {
}
export async function getAllChilds(pathString: string) {
const items = pathString.split("/").filter((it) => it != "");
const items = pathString.split("/").filter((it) => it !== "");
let page_routes_copy = ROUTES;
let prevHref = "";
for (let it of items) {
for (const it of items) {
const found = page_routes_copy.find((innerIt) => innerIt.href == `/${it}`);
if (!found) break;
prevHref += found.href;
@@ -170,20 +244,28 @@ export async function getAllChilds(pathString: string) {
}
// for copying the code in pre
const preProcess = () => (tree: any) => {
visit(tree, (node) => {
if (node?.type === "element" && node?.tagName === "pre") {
const [codeEl] = node.children;
if (codeEl.tagName !== "code") return;
node.raw = codeEl.children?.[0].value;
const preProcess = () => (tree: Node) => {
visit(tree, (node: Node) => {
const element = node as Element;
if (element?.type === "element" && element?.tagName === "pre" && element.children) {
const [codeEl] = element.children as Element[];
if (codeEl.tagName !== "code" || !codeEl.children?.[0]) return;
const textNode = codeEl.children[0] as TextNode;
if (textNode.type === 'text' && textNode.value) {
element.raw = textNode.value;
}
}
});
};
const postProcess = () => (tree: any) => {
visit(tree, "element", (node) => {
if (node?.type === "element" && node?.tagName === "pre") {
node.properties["raw"] = node.raw;
const postProcess = () => (tree: Node) => {
visit(tree, "element", (node: Node) => {
const element = node as Element;
if (element?.type === "element" && element?.tagName === "pre") {
if (element.properties && element.raw) {
element.properties.raw = element.raw;
}
}
});
};

View File

@@ -1,59 +1,68 @@
{
"name": "docubook",
"version": "1.13.6",
"version": "2.0.0-beta.3",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "eslint ."
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-collapsible": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"class-variance-authority": "^0.7.0",
"@docsearch/css": "^3.9.0",
"@docsearch/react": "^3.9.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"algoliasearch": "^5.46.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.0",
"framer-motion": "^12.4.1",
"geist": "^1.3.1",
"cmdk": "^1.1.1",
"framer-motion": "^12.26.2",
"geist": "^1.5.1",
"gray-matter": "^4.0.3",
"lucide-react": "^0.511.0",
"next": "^14.2.6",
"next": "^16.1.6",
"next-mdx-remote": "^5.0.0",
"next-themes": "^0.3.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"next-themes": "^0.4.4",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-icons": "^5.5.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-code-titles": "^1.2.0",
"rehype-prism-plus": "^2.0.0",
"rehype-code-titles": "^1.2.1",
"rehype-prism-plus": "^2.0.1",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.0",
"sonner": "^1.4.3",
"tailwind-merge": "^2.5.2",
"remark-gfm": "^4.0.1",
"sonner": "^1.7.4",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.14",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.4.20",
"eslint": "^8",
"eslint-config-next": "^14.2.6",
"postcss": "^8",
"tailwindcss": "^3.4.10",
"typescript": "^5"
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^20.19.30",
"@types/react": "19.2.8",
"@types/react-dom": "19.2.3",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.2",
"eslint-config-next": "16.1.3",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
"overrides": {
"@types/react": "19.2.8",
"@types/react-dom": "19.2.3"
},
"packageManager": "bun@1.3.8"
}

View File

@@ -1,6 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
'@tailwindcss/postcss': {},
autoprefixer: {},
},
};

162
styles/algolia.css Normal file
View File

@@ -0,0 +1,162 @@
/*
================================================================================
DocSearch Component Styling (Refactored Version)
================================================================================
*/
/* -- LANGKAH 1: Definisi Variabel Global --
Variabel tema DocSearch sekarang didefinisikan secara global di :root.
Ini menyederhanakan pewarisan tema dan memastikan konsistensi.
Mode gelap secara otomatis menimpa variabel ini karena .dark di globals.css.
*/
:root {
--docsearch-primary-color: hsl(var(--primary));
--docsearch-text-color: hsl(var(--muted-foreground));
--docsearch-spacing: 12px;
--docsearch-icon-stroke-width: 1.4;
--docsearch-highlight-color: var(--docsearch-primary-color);
--docsearch-muted-color: hsl(var(--muted-foreground));
--docsearch-container-background: rgba(0, 0, 0, 0.7);
--docsearch-logo-color: hsl(var(--primary-foreground));
/* Modal */
--docsearch-modal-width: 560px;
--docsearch-modal-height: 600px;
--docsearch-modal-background: hsl(var(--background));
--docsearch-modal-shadow: 0 0 0 1px hsl(var(--border)), 0 8px 20px rgba(0, 0, 0, 0.2);
/* SearchBox */
--docsearch-searchbox-height: 56px;
--docsearch-searchbox-background: hsl(var(--input));
--docsearch-searchbox-focus-background: hsl(var(--card));
--docsearch-searchbox-shadow: none;
/* Hit (Hasil Pencarian) */
--docsearch-hit-height: 56px;
--docsearch-hit-color: hsl(var(--foreground));
--docsearch-hit-active-color: hsl(var(--primary-foreground));
--docsearch-hit-background: hsl(var(--card));
--docsearch-hit-shadow: none;
/* Keys */
--docsearch-key-gradient: none;
--docsearch-key-shadow: none;
--docsearch-key-pressed-shadow: none;
/* Footer */
--docsearch-footer-height: 44px;
--docsearch-footer-background: hsl(var(--background));
--docsearch-footer-shadow: none;
}
/* -- LANGKAH 2: Gaya untuk Tombol Awal --
Gaya ini spesifik untuk tombol yang ada di Navbar,
yang dibungkus oleh <div class="docsearch">.
*/
.docsearch .DocSearch-Button {
background-color: hsl(var(--secondary));
border: 1px solid hsl(var(--border));
border-radius: 9999px;
width: 160px;
height: 40px;
color: hsl(var(--muted-foreground));
transition: width 0.3s ease;
margin: 0;
}
.docsearch .DocSearch-Button:hover {
border-color: var(--docsearch-primary-color);
box-shadow: none;
}
.docsearch .DocSearch-Search-Icon {
color: var(--docsearch-muted-color);
width: 1rem;
height: 1rem;
}
.docsearch .DocSearch-Button-Placeholder {
font-style: normal;
margin-left: 0.25rem;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--docsearch-muted-color);
}
.docsearch .DocSearch-Button-Key {
background: var(--docsearch-primary-color);
color: var(--docsearch-logo-color); /* Menggunakan variabel yg relevan */
border-radius: 6px;
font-size: 14px;
font-weight: 500;
height: 24px;
padding: 0 6px;
border: none;
box-shadow: none;
top: 0;
}
/* -- LANGKAH 3: Gaya untuk Modal dan Isinya --
Gaya ini menargetkan elemen-elemen modal yang dirender terpisah.
Karena variabel sudah global, kita hanya perlu menata elemennya.
*/
.DocSearch-Container .DocSearch-Modal {
backdrop-filter: blur(8px);
}
.DocSearch-Form {
border: 1px solid hsl(var(--border));
background-color: transparent;
}
.DocSearch-Input {
font-size: 15px !important;
}
.DocSearch-Footer {
border-top: 1px solid hsl(var(--border));
}
/* Gaya untuk tombol keyboard di footer */
.DocSearch-Footer--commands kbd {
background-color: hsl(var(--secondary));
border: 1px solid hsl(var(--border));
border-bottom-width: 2px;
border-radius: 6px;
color: var(--docsearch-muted-color);
padding: 4px 8px;
display: flex;
align-items: center;
justify-content: center;
}
/* Menghilangkan gaya default dari ikon di dalam tombol footer */
.DocSearch-Commands-Key {
background: none;
color: hsl(var(--muted-foreground));
border: 1px solid hsl(var(--border));
box-shadow: none;
padding: 2px 4px;
margin-right: 0.4em;
height: 20px;
width: 32px;
border-radius: 6px;
}
/* -- LANGKAH 4: Gaya Responsif --
Tidak ada perubahan, hanya mempertahankan fungsionalitas mobile.
*/
@media (max-width: 768px) {
.docsearch .DocSearch-Button {
width: 40px;
height: 40px;
padding: 0;
justify-content: center;
background: none;
border: none;
}
.docsearch .DocSearch-Button-Placeholder,
.docsearch .DocSearch-Button-Key {
display: none;
}
}

View File

@@ -1,10 +1,131 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
@import url("../styles/syntax.css");
/* ocean */
@custom-variant dark (&:is(.dark *));
@utility container {
margin-inline: auto;
padding-inline: 2rem;
@media (width >=--theme(--breakpoint-sm)) {
max-width: none;
}
@media (width >=1440px) {
max-width: 1440px;
}
}
@theme {
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-sidebar: hsl(var(--sidebar-background));
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
--color-sidebar-primary: hsl(var(--sidebar-primary));
--color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
--color-sidebar-accent: hsl(var(--sidebar-accent));
--color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
--color-sidebar-border: hsl(var(--sidebar-border));
--color-sidebar-ring: hsl(var(--sidebar-ring));
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--font-code: var(--font-geist-mono);
--font-regular: var(--font-geist-sans);
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
--animate-shiny-text: shiny-text 8s infinite;
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
@keyframes shiny-text {
0%,
90%,
100% {
background-position: calc(-100% - var(--shiny-width)) 0;
}
30%,
60% {
background-position: calc(100% + var(--shiny-width)) 0;
}
}
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@utility animate-shine {
--animate-shine: shine var(--duration) infinite linear;
animation: var(--animate-shine);
background-size: 200% 200%;
}
/* Modern Blue Theme */
@layer base {
:root {
--background: 210 50% 95%;
@@ -21,17 +142,18 @@
--muted-foreground: 220 20% 40%;
--accent: 132 86% 32%;
--accent-foreground: 0 0% 100%;
--destructive: 0 65% 55%;
--destructive-foreground: 220 20% 95%;
--border: 220 15% 90%;
--input: 220 15% 90%;
--ring: 132 86% 42%;
--destructive: 0 85% 60%;
--destructive-foreground: 0 0% 100%;
--border: 210 20% 85%;
--input: 210 20% 85%;
--ring: 210 81% 56%;
--radius: 0.5rem;
--chart-1: 210 60% 50%;
--chart-2: 220 40% 65%;
--chart-3: 132 86% 42%;
--chart-4: 200 60% 55%;
--chart-5: 240 30% 40%;
--chart-1: 210 81% 56%;
--chart-2: 200 100% 40%;
--chart-3: 220 76% 60%;
--chart-4: 190 90% 50%;
--chart-5: 230 86% 45%;
--line-number-color: rgba(0, 0, 0, 0.05);
}
.dark {
@@ -72,82 +194,66 @@
}
}
.prose {
margin: 0 !important;
}
@layer utilities {
.prose {
margin: 0 !important;
}
pre {
padding: 2px 0 !important;
width: inherit !important;
overflow-x: auto;
}
pre {
padding: 2px 0 !important;
width: inherit !important;
overflow-x: auto;
}
pre>code {
display: grid;
max-width: inherit !important;
padding: 14px 0 !important;
}
pre>code {
display: grid;
max-width: inherit !important;
padding: 14px 0 !important;
border: 0 !important;
}
.code-line {
padding: 0.75px 16px;
@apply border-l-2 border-transparent
}
.code-line {
padding: 0.75px 16px;
@apply border-l-2 border-transparent;
}
.line-number::before {
display: inline-block;
width: 1rem;
margin-right: 22px;
margin-left: -2px;
color: rgb(110, 110, 110);
content: attr(line);
font-size: 13.5px;
text-align: right;
}
.line-number::before {
display: inline-block;
width: 1rem;
margin-right: 22px;
margin-left: -2px;
color: rgb(110, 110, 110);
content: attr(line);
font-size: 13.5px;
text-align: right;
}
.highlight-line {
@apply bg-primary/5 border-l-2 border-primary/30;
}
.highlight-line {
@apply bg-primary/5 border-l-2 border-primary/30;
}
.rehype-code-title {
@apply px-2 -mb-8 w-full text-sm pb-5 font-medium mt-5 font-code;
}
.rehype-code-title {
@apply px-2 -mb-8 w-full text-sm pb-5 font-medium mt-5 font-code;
}
.highlight-comp>code {
background-color: transparent !important;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
.highlight-comp>code {
background-color: transparent !important;
}
}
@layer utilities {
.animate-shine {
--animate-shine: shine var(--duration) infinite linear;
animation: var(--animate-shine);
background-size: 200% 200%;
@keyframes shine {
0% {
background-position: 0% 0%;
}
@keyframes shine {
0% {
background-position: 0% 0%;
}
50% {
background-position: 100% 100%;
}
100% {
background-position: 0% 0%;
}
50% {
background-position: 100% 100%;
}
100% {
background-position: 0% 0%;
}
}
}

View File

@@ -1,23 +1,27 @@
/* ocean with green variant */
/* Light Mode */
.keyword {
color: #1EAB18; /* Slightly darker green */
/* Dark Lime */
color: #1EAB18;
/* Slightly darker green */
/* Dark Lime */
}
.function {
color: #39D833; /* Brighter lime green */
/* Bright Lime */
color: #39D833;
/* Brighter lime green */
/* Bright Lime */
}
.punctuation {
color: #357C30; /* Muted green-gray */
/* Sage Green */
color: #357C30;
/* Muted green-gray */
/* Sage Green */
}
.comment {
color: #5F935B; /* Muted green */
/* Olive Green */
color: #5F935B;
/* Muted green */
/* Olive Green */
}
.string,
@@ -25,34 +29,40 @@
.annotation,
.boolean,
.number {
color: #2E8F2A; /* Darker green */
/* Dark Forest Green */
color: #2E8F2A;
/* Darker green */
/* Dark Forest Green */
}
.tag {
color: #1FC01B; /* Original vibrant green */
/* Vibrant Green */
color: #1FC01B;
/* Original vibrant green */
/* Vibrant Green */
}
.attr-name {
color: #4FE34A; /* Light and bright green */
/* Electric Green */
color: #4FE34A;
/* Light and bright green */
/* Electric Green */
}
.attr-value {
color: #1EAB18; /* Slightly darker green */
/* Dark Lime */
color: #1EAB18;
/* Slightly darker green */
/* Dark Lime */
}
/* Dark Mode */
.dark .keyword {
color: #8CFF7D; /* Soft light green */
/* Soft Mint */
color: #8CFF7D;
/* Soft light green */
/* Soft Mint */
}
.dark .function {
color: #A0FF93; /* Light lime green */
/* Minty Green */
color: #A0FF93;
/* Light lime green */
/* Minty Green */
}
.dark .string,
@@ -60,33 +70,52 @@
.dark .annotation,
.dark .boolean,
.dark .number {
color: #72FF73; /* Light green */
/* Spring Green */
color: #72FF73;
/* Light green */
/* Spring Green */
}
.dark .tag {
color: #7FFF7A; /* Vibrant green */
/* Neon Green */
color: #7FFF7A;
/* Vibrant green */
/* Neon Green */
}
.dark .attr-name {
color: #B2FFA3; /* Soft pastel green */
/* Mint Green */
color: #B2FFA3;
/* Soft pastel green */
/* Mint Green */
}
.dark .attr-value {
color: #8CFF7D; /* Soft light green */
/* Soft Mint */
color: #1EAB18;
/* Slightly darker green */
/* Dark Lime */
}
.dark .comment {
color: #9ca3af;
/* Lighter gray for dark mode */
}
.dark .punctuation {
color: #9ca3af;
/* Lighter gray for dark mode */
}
.youtube {
position: relative;
padding-bottom: 56.25%; /* Rasio aspek 16:9 */
padding-bottom: 56.25%;
/* Rasio aspek 16:9 */
height: 0;
overflow: hidden;
background: #000; /* Latar belakang hitam untuk memadukan player */
border-radius: 8px; /* Sudut melengkung */
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); /* Bayangan lembut */
background: #000;
/* Latar belakang hitam untuk memadukan player */
border-radius: 8px;
/* Sudut melengkung */
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
/* Bayangan lembut */
margin: 24px 0;
}
.youtube iframe {
@@ -96,5 +125,95 @@
width: 100%;
height: 100%;
border: none;
border-radius: 8px; /* Sudut melengkung pada iframe */
border-radius: 8px;
/* Sudut melengkung pada iframe */
}
/* ======================================================================== */
/* Custom styling for code blocks */
/* ======================================================================== */
.code-block-container {
position: relative;
margin: 1.5rem 0;
border: 1px solid hsl(var(--border));
overflow: hidden;
font-size: 0.875rem;
border-radius: 0.75rem;
}
.code-block-header {
display: flex;
align-items: center;
gap: 0.5rem;
background-color: hsl(var(--muted));
padding: 0.5rem 1rem;
border-bottom: 1px solid hsl(var(--border));
color: hsl(var(--muted-foreground));
font-family: monospace;
font-size: 0.8rem;
}
.code-block-actions {
position: absolute;
top: 0.5rem;
right: 0.75rem;
z-index: 10;
}
.code-block-actions button {
color: hsl(var(--muted-foreground));
transition: color 0.2s ease-in-out;
}
.code-block-actions button:hover {
color: hsl(var(--foreground));
}
.code-block-body pre[class*="language-"] {
margin: 0 !important;
padding: 0 !important;
background: transparent !important;
}
.line-numbers-wrapper {
position: absolute;
top: 0;
left: 0;
width: 3rem;
padding-top: 1rem;
text-align: right;
color: var(--line-number-color);
user-select: none;
}
.line-highlight {
position: absolute;
left: 0;
right: 0;
background: hsl(var(--primary) / 0.1);
border-left: 2px solid hsl(var(--primary));
pointer-events: none;
}
.code-block-body pre[data-line-numbers="true"] .line-highlight {
padding-left: 3.5rem;
}
.code-block-body::-webkit-scrollbar {
height: 8px;
}
.code-block-body::-webkit-scrollbar-track {
background: transparent;
}
.code-block-body::-webkit-scrollbar-thumb {
background: hsl(var(--border));
border-radius: 4px;
}
.code-block-body::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted));
}

View File

@@ -1,111 +1,113 @@
import type { Config } from "tailwindcss";
import tailwindAnimate from "tailwindcss-animate";
import typography from "@tailwindcss/typography";
const config = {
darkMode: ["class"],
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
prefix: "",
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1440px'
}
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
fontFamily: {
code: ["var(--font-geist-mono)"],
regular: ["var(--font-geist-sans)"]
},
keyframes: {
'accordion-down': {
from: {
height: '0'
},
to: {
height: 'var(--radix-accordion-content-height)'
}
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)'
},
to: {
height: '0'
}
},
'shiny-text': {
'0%, 90%, 100%': {
'background-position': 'calc(-100% - var(--shiny-width)) 0'
},
'30%, 60%': {
'background-position': 'calc(100% + var(--shiny-width)) 0'
}
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'shiny-text': 'shiny-text 8s infinite'
}
}
},
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
darkMode: "class",
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
prefix: "",
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1440px'
}
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
fontFamily: {
code: ["var(--font-geist-mono)"],
regular: ["var(--font-geist-sans)"]
},
keyframes: {
'accordion-down': {
from: {
height: '0'
},
to: {
height: 'var(--radix-accordion-content-height)'
}
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)'
},
to: {
height: '0'
}
},
'shiny-text': {
'0%, 90%, 100%': {
'background-position': 'calc(-100% - var(--shiny-width)) 0'
},
'30%, 60%': {
'background-position': 'calc(100% + var(--shiny-width)) 0'
}
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'shiny-text': 'shiny-text 8s infinite'
}
}
},
plugins: [tailwindAnimate, typography],
} satisfies Config;
export default config;

View File

@@ -1,6 +1,10 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -10,7 +14,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -18,9 +22,20 @@
}
],
"paths": {
"@/*": ["./*"]
}
"@/*": [
"./*"
]
},
"target": "ES2017"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}