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:
arsam
2026-09-18 23:11:00 +03:30
parent 19c4661b6e
commit 9e70772838
+254
View File
@@ -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>
);
}