Import source: src/components/shell/sidebar
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -0,0 +1,254 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { ChevronDown, Menu, Search, Star, X } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Logo } from "@/components/common/logo";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { groupRoutes, routeItems, sidebarRoutes } from "@/lib/routes";
|
||||||
|
import { useUiStore } from "@/stores/ui-store";
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const collapsed = useUiStore((state) => state.sidebarCollapsed);
|
||||||
|
const toggleSidebar = useUiStore((state) => state.toggleSidebar);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"glass-bar fixed bottom-2 left-2 top-2 z-30 hidden overflow-hidden rounded-2xl border border-border/60 lg:flex lg:flex-col",
|
||||||
|
collapsed ? "w-[72px]" : "w-[var(--sidebar-width)]",
|
||||||
|
)}
|
||||||
|
aria-label="Primary navigation"
|
||||||
|
>
|
||||||
|
<SidebarBody
|
||||||
|
collapsed={collapsed}
|
||||||
|
headerAction={
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
aria-label="Collapse sidebar"
|
||||||
|
>
|
||||||
|
<Menu className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileSidebar() {
|
||||||
|
const open = useUiStore((state) => state.mobileSidebarOpen);
|
||||||
|
const setOpen = useUiStore((state) => state.setMobileSidebarOpen);
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, [pathname, setOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}, [open, setOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("fixed inset-0 z-40 lg:hidden", !open && "pointer-events-none")}
|
||||||
|
aria-hidden={!open}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 bg-background/60 backdrop-blur-sm transition-opacity duration-300",
|
||||||
|
open ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"glass-bar absolute inset-y-0 left-0 flex w-[min(86vw,300px)] flex-col overflow-hidden rounded-r-2xl border-r border-border/60 transition-transform duration-300 ease-out",
|
||||||
|
open ? "translate-x-0" : "-translate-x-full",
|
||||||
|
)}
|
||||||
|
aria-label="Primary navigation"
|
||||||
|
>
|
||||||
|
<SidebarBody
|
||||||
|
collapsed={false}
|
||||||
|
onNavigate={() => setOpen(false)}
|
||||||
|
headerAction={
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
aria-label="Close navigation"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarBody({
|
||||||
|
collapsed,
|
||||||
|
headerAction,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
collapsed: boolean;
|
||||||
|
headerAction: React.ReactNode;
|
||||||
|
onNavigate?: () => void;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const favorites = useUiStore((state) => state.favorites);
|
||||||
|
const recent = useUiStore((state) => state.recent);
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
|
|
||||||
|
const favoritesRoutes = routeItems.filter((item) => favorites.includes(item.path));
|
||||||
|
const recentRoutes = routeItems.filter((item) => recent.includes(item.path));
|
||||||
|
const visible = useMemo(() => {
|
||||||
|
if (!filter) return sidebarRoutes;
|
||||||
|
const term = filter.toLowerCase();
|
||||||
|
return routeItems.filter((item) =>
|
||||||
|
[item.label, item.category, item.description, ...item.keywords].some((value) =>
|
||||||
|
value.toLowerCase().includes(term),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, [filter]);
|
||||||
|
const groups = groupRoutes(visible);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex h-[var(--header-height)] items-center justify-between border-b border-border/60 px-3">
|
||||||
|
<Link href="/dashboard" className="overflow-hidden" onClick={onNavigate}>
|
||||||
|
<Logo compact={collapsed} className={collapsed ? "" : "h-9"} />
|
||||||
|
</Link>
|
||||||
|
{headerAction}
|
||||||
|
</div>
|
||||||
|
{!collapsed ? (
|
||||||
|
<div className="border-b border-border/60 p-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={filter}
|
||||||
|
onChange={(event) => setFilter(event.target.value)}
|
||||||
|
placeholder="Find tools"
|
||||||
|
className="h-8 pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
|
||||||
|
{!collapsed && !filter ? (
|
||||||
|
<SidebarSection
|
||||||
|
title="Favorites"
|
||||||
|
routes={favoritesRoutes.slice(0, 6)}
|
||||||
|
pathname={pathname}
|
||||||
|
collapsed={collapsed}
|
||||||
|
icon="star"
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!collapsed && !filter ? (
|
||||||
|
<SidebarSection
|
||||||
|
title="Recently used"
|
||||||
|
routes={recentRoutes.slice(0, 4)}
|
||||||
|
pathname={pathname}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{Object.entries(groups).map(([title, routes]) => (
|
||||||
|
<SidebarSection
|
||||||
|
key={title}
|
||||||
|
title={title}
|
||||||
|
routes={routes}
|
||||||
|
pathname={pathname}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarSection({
|
||||||
|
title,
|
||||||
|
routes,
|
||||||
|
pathname,
|
||||||
|
collapsed,
|
||||||
|
icon,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
routes: typeof routeItems;
|
||||||
|
pathname: string;
|
||||||
|
collapsed: boolean;
|
||||||
|
icon?: "star";
|
||||||
|
onNavigate?: () => void;
|
||||||
|
}) {
|
||||||
|
if (!routes.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="mb-3">
|
||||||
|
{!collapsed ? (
|
||||||
|
<div className="mb-1 flex items-center gap-1 px-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{icon === "star" ? (
|
||||||
|
<Star className="h-3 w-3 text-accent" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{routes.map((route) => {
|
||||||
|
const Icon = route.icon;
|
||||||
|
const active =
|
||||||
|
pathname === route.path ||
|
||||||
|
(route.path !== "/dashboard" && pathname.startsWith(`${route.path}/`));
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={`${title}-${route.path}`}
|
||||||
|
href={route.path}
|
||||||
|
title={collapsed ? route.label : undefined}
|
||||||
|
onClick={onNavigate}
|
||||||
|
className={cn(
|
||||||
|
"focus-ring group relative flex items-center gap-2.5 rounded-md px-2 py-2 text-sm text-muted-foreground transition-colors duration-150 hover:bg-muted/70 hover:text-foreground",
|
||||||
|
active &&
|
||||||
|
"bg-primary/10 font-medium text-primary hover:bg-primary/15 hover:text-primary",
|
||||||
|
collapsed && "justify-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{active ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute left-0 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-full bg-primary shadow-[0_0_8px_hsl(var(--primary)/0.7)]",
|
||||||
|
collapsed && "left-0.5",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 shrink-0 transition-transform duration-150 group-hover:scale-110",
|
||||||
|
active && "scale-105",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{!collapsed ? <span className="truncate">{route.label}</span> : null}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user