v.1.13.0 add: context menu

This commit is contained in:
gitfromwildan
2025-05-29 19:16:34 +07:00
parent 36242e6942
commit d9ce3893e6
29 changed files with 340 additions and 128 deletions

View File

@@ -0,0 +1,133 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import { ROUTES, EachRoute } from "@/lib/routes-config";
import { cn } from "@/lib/utils";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import * as LucideIcons from "lucide-react";
import { ChevronsUpDown, Check, type LucideIcon } from "lucide-react";
interface ContextPopoverProps {
className?: string;
}
// Get all root-level routes with context
function getContextRoutes(): EachRoute[] {
return ROUTES.filter(route => route.context);
}
// Get the first item's href from a route
function getFirstItemHref(route: EachRoute): string {
return route.items?.[0]?.href ? `${route.href}${route.items[0].href}` : route.href;
}
// Get the active context route from the current path
function getActiveContextRoute(path: string): EachRoute | undefined {
if (!path.startsWith('/docs')) return undefined;
const docPath = path.replace(/^\/docs/, '');
return getContextRoutes().find(route => docPath.startsWith(route.href));
}
// Get icon component by name
function getIcon(name: string) {
const Icon = LucideIcons[name as keyof typeof LucideIcons] as LucideIcon | undefined;
if (!Icon) return <LucideIcons.FileQuestion className="h-4 w-4" />;
return <Icon className="h-4 w-4" />;
}
export default function ContextPopover({ className }: ContextPopoverProps) {
const pathname = usePathname();
const router = useRouter();
const [activeRoute, setActiveRoute] = useState<EachRoute>();
const contextRoutes = getContextRoutes();
useEffect(() => {
if (pathname.startsWith("/docs")) {
setActiveRoute(getActiveContextRoute(pathname));
} else {
setActiveRoute(undefined);
}
}, [pathname]);
if (!pathname.startsWith("/docs") || contextRoutes.length === 0) {
return null;
}
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
className={cn(
"w-full max-w-[240px] flex items-center justify-between font-semibold text-foreground px-0 pt-8",
"hover:bg-transparent hover:text-foreground",
className
)}
>
<div className="flex items-center gap-2">
{activeRoute?.context?.icon && (
<span className="text-primary bg-primary/10 border border-primary rounded p-0.5">
{getIcon(activeRoute.context.icon)}
</span>
)}
<span className="truncate text-sm">
{activeRoute?.context?.title || activeRoute?.title || 'Select context'}
</span>
</div>
<ChevronsUpDown className="h-4 w-4 text-foreground/50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-64 p-2"
align="start"
sideOffset={6}
>
<div className="space-y-1">
{contextRoutes.map((route) => {
const isActive = activeRoute?.href === route.href;
const firstItemPath = getFirstItemHref(route);
const contextPath = `/docs${firstItemPath}`;
return (
<button
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",
"text-left outline-none transition-colors",
isActive
? "bg-primary/20 text-primary"
: "text-foreground/80 hover:bg-primary/20"
)}
>
{route.context?.icon && (
<span className={cn(
"flex h-4 w-4 items-center justify-center",
isActive ? "text-primary" : "text-foreground/60"
)}>
{getIcon(route.context.icon)}
</span>
)}
<div className="flex-1 min-w-0 overflow-hidden">
<div className="truncate font-medium">
{route.context?.title || route.title}
</div>
{route.context?.description && (
<div className="text-xs text-muted-foreground truncate text-ellipsis overflow-hidden max-w-full">
{route.context.description}
</div>
)}
</div>
{isActive && (
<Check className="h-3.5 w-3.5" />
)}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
);
}

View File

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

View File

@@ -14,6 +14,7 @@ import { DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import DocsMenu from "@/components/docs-menu";
import { ModeToggle } from "@/components/theme-toggle";
import ContextPopover from "@/components/context-popover";
// Toggle Button Component
export function ToggleButton({
@@ -51,9 +52,14 @@ export function Leftbar() {
${collapsed ? "w-[24px]" : "w-[280px]"} flex flex-col pr-2`}
>
<ToggleButton collapsed={collapsed} onToggle={toggleCollapse} />
{/* Scrollable DocsMenu */}
{/* Scrollable Content */}
<ScrollArea className="flex-1 px-0.5 pb-4">
{!collapsed && <DocsMenu />}
{!collapsed && (
<div className="space-y-2">
<ContextPopover />
<DocsMenu />
</div>
)}
</ScrollArea>
</aside>
);
@@ -81,7 +87,8 @@ export function SheetLeftbar() {
<div className="flex flex-col gap-2.5 mt-3 mx-2 px-5">
<NavMenu isSheet />
</div>
<div className="mx-2 px-5">
<div className="mx-2 px-5 space-y-2">
<ContextPopover />
<DocsMenu isSheet />
</div>
<div className="flex w-2/4 px-5">