From fbeb545432a81a83e4667dcaf8d3f195bf0a1f1f Mon Sep 17 00:00:00 2001 From: AegisX-Dev Date: Thu, 11 Dec 2025 17:14:26 +0530 Subject: [PATCH 1/3] feat(dashboard): add functional command palette search bar --- src/components/layout/DashboardSearch.tsx | 338 ++++++++++++++++++ .../components-layout/ComponentsNavbar.tsx | 19 +- 2 files changed, 351 insertions(+), 6 deletions(-) create mode 100644 src/components/layout/DashboardSearch.tsx diff --git a/src/components/layout/DashboardSearch.tsx b/src/components/layout/DashboardSearch.tsx new file mode 100644 index 0000000..2fc2f5e --- /dev/null +++ b/src/components/layout/DashboardSearch.tsx @@ -0,0 +1,338 @@ +"use client"; + +import { navigation } from "@/registry/component-navigation"; +import { AnimatePresence, motion } from "motion/react"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { FiCommand, FiSearch } from "react-icons/fi"; + +// ============================================================================= +// Type Definitions +// ============================================================================= +interface FlattenedItem { + title: string; + href: string; + category: string; +} + +interface NavigationItem { + title: string; + href?: string; + submenu?: { name: string; href: string }[]; +} + +// ============================================================================= +// Utility: Flatten Navigation Data +// ============================================================================= +function flattenNavigation(nav: NavigationItem[]): FlattenedItem[] { + const result: FlattenedItem[] = []; + + for (const item of nav) { + // Direct link items (no submenu) + if (item.href && !item.submenu) { + result.push({ + title: item.title, + href: item.href, + category: "General", + }); + } + + // Items with submenu + if (item.submenu) { + for (const sub of item.submenu) { + result.push({ + title: sub.name, + href: sub.href, + category: item.title, + }); + } + } + } + + return result; +} + +// ============================================================================= +// Component: DashboardSearch +// ============================================================================= +const DashboardSearch = () => { + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const [mounted, setMounted] = useState(false); + const inputRef = useRef(null); + const router = useRouter(); + + // Ensure we only render portal on client + useEffect(() => { + setMounted(true); + }, []); + + // Flatten navigation data once + const flattenedItems = useMemo( + () => flattenNavigation(navigation as NavigationItem[]), + [] + ); + + // Filter items based on query + const filteredItems = useMemo(() => { + if (!query.trim()) return flattenedItems; + + const lowerQuery = query.toLowerCase(); + return flattenedItems.filter( + (item) => + item.title.toLowerCase().includes(lowerQuery) || + item.category.toLowerCase().includes(lowerQuery) + ); + }, [query, flattenedItems]); + + // Reset selected index when filtered items change + useEffect(() => { + setSelectedIndex(0); + }, [filteredItems]); + + // Focus input when modal opens + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus(); + } + }, [isOpen]); + + // Handle navigation + const handleNavigate = useCallback( + (href: string) => { + router.push(href); + setIsOpen(false); + setQuery(""); + }, + [router] + ); + + // Keyboard shortcuts: Cmd/Ctrl + K to toggle modal + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + setIsOpen((prev) => !prev); + } + + if (e.key === "Escape") { + setIsOpen(false); + setQuery(""); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, []); + + // Keyboard navigation within modal + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((prev) => + prev < filteredItems.length - 1 ? prev + 1 : 0 + ); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex((prev) => + prev > 0 ? prev - 1 : filteredItems.length - 1 + ); + } else if (e.key === "Enter" && filteredItems[selectedIndex]) { + e.preventDefault(); + handleNavigate(filteredItems[selectedIndex].href); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, filteredItems, selectedIndex, handleNavigate]); + + // Lock body scroll when modal is open + useEffect(() => { + if (isOpen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [isOpen]); + + return ( + <> + {/* Trigger Button - Nur/UI Theme */} + + + {/* Command Palette Modal - Rendered via Portal */} + {mounted && + createPortal( + + {isOpen && ( + <> + {/* Backdrop with Flex Centering */} + { + setIsOpen(false); + setQuery(""); + }} + className="fixed inset-0 z-[9999] bg-black/60 backdrop-blur-sm flex items-center justify-center" + > + {/* Modal Content - Nur/UI Theme */} + e.stopPropagation()} + className="w-[calc(100%-2rem)] max-w-lg + bg-[var(--background-color)] + border border-[var(--primary-color)] dark:border-white/10 + rounded-xl shadow-2xl shadow-black/20 overflow-hidden" + > + {/* Search Input */} +
+ + setQuery(e.target.value)} + placeholder="Search components..." + className="flex-1 bg-transparent text-[var(--black-color)] dark:text-white + placeholder:text-[var(--black-color)]/40 dark:placeholder:text-white/40 + focus:outline-none text-sm" + /> + + ESC + +
+ + {/* Results List */} +
+ {filteredItems.length === 0 ? ( +
+ No results found for “{query}” +
+ ) : ( +
+ {filteredItems.map((item, index) => ( + + ))} +
+ )} +
+ + {/* Footer */} +
+
+ + + ↑ + + + ↓ + + Navigate + + + + ↵ + + Open + +
+ + {filteredItems.length} result + {filteredItems.length !== 1 ? "s" : ""} + +
+
+
+ + )} +
, + document.body + )} + + ); +}; + +export default DashboardSearch; diff --git a/src/components/layout/components-layout/ComponentsNavbar.tsx b/src/components/layout/components-layout/ComponentsNavbar.tsx index d3542cd..1d85871 100644 --- a/src/components/layout/components-layout/ComponentsNavbar.tsx +++ b/src/components/layout/components-layout/ComponentsNavbar.tsx @@ -1,6 +1,7 @@ "use client"; import RoundedButton from "@/components/common/RoundedButton"; import ThemeSwitcher from "@/components/common/ThemeSwitcher"; +import DashboardSearch from "@/components/layout/DashboardSearch"; import VaulDrawer from "@/components/ui/drawer/VaulDrawer"; import { useAppContext } from "@/context/AppContext"; import { useEffect, useState } from "react"; @@ -22,6 +23,7 @@ const ComponentsNavbar = () => { fetchStars(); }, []); + return (
{ : "w-full xl:w-[calc(100vw-5rem)]" }`} > -
diff --git a/src/hooks/useComponentSearch.ts b/src/hooks/useComponentSearch.ts new file mode 100644 index 0000000..5b234a4 --- /dev/null +++ b/src/hooks/useComponentSearch.ts @@ -0,0 +1,73 @@ +import { navigation } from "@/registry/component-navigation"; +import { + flattenNavigation, + FlattenedItem, + NavigationItem, +} from "@/utils/search-utils"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +interface UseComponentSearchReturn { + isOpen: boolean; + setIsOpen: React.Dispatch>; + query: string; + setQuery: React.Dispatch>; + filteredResults: FlattenedItem[]; + toggleOpen: () => void; + closeModal: () => void; +} + +export function useComponentSearch(): UseComponentSearchReturn { + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(""); + + const flattenedItems = useMemo( + () => flattenNavigation(navigation as NavigationItem[]), + [] + ); + + const filteredResults = useMemo(() => { + if (!query.trim()) return flattenedItems; + + const lowerQuery = query.toLowerCase(); + return flattenedItems.filter( + (item) => + item.title.toLowerCase().includes(lowerQuery) || + item.category.toLowerCase().includes(lowerQuery) + ); + }, [query, flattenedItems]); + + const toggleOpen = useCallback(() => { + setIsOpen((prev) => !prev); + }, []); + + const closeModal = useCallback(() => { + setIsOpen(false); + setQuery(""); + }, []); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + toggleOpen(); + } + + if (e.key === "Escape") { + closeModal(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [toggleOpen, closeModal]); + + return { + isOpen, + setIsOpen, + query, + setQuery, + filteredResults, + toggleOpen, + closeModal, + }; +} diff --git a/src/utils/search-utils.ts b/src/utils/search-utils.ts new file mode 100644 index 0000000..0242179 --- /dev/null +++ b/src/utils/search-utils.ts @@ -0,0 +1,37 @@ +export interface FlattenedItem { + title: string; + href: string; + category: string; +} + +export interface NavigationItem { + title: string; + href?: string; + submenu?: { name: string; href: string }[]; +} + +export function flattenNavigation(nav: NavigationItem[]): FlattenedItem[] { + const result: FlattenedItem[] = []; + + for (const item of nav) { + if (item.href && !item.submenu) { + result.push({ + title: item.title, + href: item.href, + category: "General", + }); + } + + if (item.submenu) { + for (const sub of item.submenu) { + result.push({ + title: sub.name, + href: sub.href, + category: item.title, + }); + } + } + } + + return result; +} From d09a1aae573054e0d667813347c2816ee53a74d1 Mon Sep 17 00:00:00 2001 From: Mdafsarx Date: Fri, 12 Dec 2025 15:24:58 +0600 Subject: [PATCH 3/3] component search bar added --- .../layout/{ => components-layout}/ComponentSearchbar.tsx | 0 src/components/layout/components-layout/ComponentsNavbar.tsx | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename src/components/layout/{ => components-layout}/ComponentSearchbar.tsx (100%) diff --git a/src/components/layout/ComponentSearchbar.tsx b/src/components/layout/components-layout/ComponentSearchbar.tsx similarity index 100% rename from src/components/layout/ComponentSearchbar.tsx rename to src/components/layout/components-layout/ComponentSearchbar.tsx diff --git a/src/components/layout/components-layout/ComponentsNavbar.tsx b/src/components/layout/components-layout/ComponentsNavbar.tsx index 8f8b4de..a9ecea8 100644 --- a/src/components/layout/components-layout/ComponentsNavbar.tsx +++ b/src/components/layout/components-layout/ComponentsNavbar.tsx @@ -1,12 +1,12 @@ "use client"; import RoundedButton from "@/components/common/RoundedButton"; import ThemeSwitcher from "@/components/common/ThemeSwitcher"; -import ComponentSearchbar from "@/components/layout/ComponentSearchbar"; import VaulDrawer from "@/components/ui/drawer/VaulDrawer"; import { useAppContext } from "@/context/AppContext"; import { useEffect, useState } from "react"; import { FaGithub, FaXTwitter } from "react-icons/fa6"; import { GoSidebarCollapse, GoSidebarExpand } from "react-icons/go"; +import ComponentSearchbar from "./ComponentSearchbar"; const ComponentsNavbar = () => { const { sideBar, setSideBar } = useAppContext();