Skip to content

Commit

Permalink
feat(search): Implement global search functionality
Browse files Browse the repository at this point in the history
- Add global search in `Navbar` with optimizations (mobile-friendly)
- Implement command menu for page navigation
- Utilize debounce hook for optimized API calls
- Refactor `SearchBar` component for reusability
  • Loading branch information
gupta-soham committed Oct 6, 2024
1 parent c522961 commit cb22d59
Show file tree
Hide file tree
Showing 7 changed files with 536 additions and 137 deletions.
4 changes: 0 additions & 4 deletions src/app/(main)/(pages)/home/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Greeting } from '@/components/Greeting';
import { MyCourses } from '@/components/MyCourses';
import { Redirect } from '@/components/Redirect';
import { SearchBar } from '@/components/search/SearchBar';
import { getServerSession } from 'next-auth';

export default async function MyCoursesPage() {
Expand All @@ -17,9 +16,6 @@ export default async function MyCoursesPage() {
<h1 className="text-wrap text-3xl font-extrabold capitalize tracking-tighter md:text-4xl">
<Greeting /> {session.user.name}
</h1>
<div>
<SearchBar />
</div>
</div>

<div className="flex h-full flex-col gap-4 rounded-2xl py-4">
Expand Down
78 changes: 64 additions & 14 deletions src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,42 @@
import { motion, AnimatePresence } from 'framer-motion';
import { useSession } from 'next-auth/react';
import { usePathname, useRouter } from 'next/navigation';
import { useState } from 'react';
import React, { useState, useCallback, useMemo } from 'react';
import Link from 'next/link';
import { ArrowLeft, Menu } from 'lucide-react';
import { ArrowLeft, Menu, Search, X } from 'lucide-react';
import { Button } from './ui/button';
import { AppbarAuth } from './AppbarAuth';
import { SelectTheme } from './ThemeToggler';
import ProfileDropdown from './profile-menu/ProfileDropdown';
import { SearchBar } from './search/SearchBar';

export const Navbar = () => {
const { data: session } = useSession();
const router = useRouter();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const pathname = usePathname();
const toggleMenu = () => setIsMenuOpen(!isMenuOpen);

const navItemVariants = {
hidden: { opacity: 0, y: -20 },
visible: (i: number) => ({
opacity: 1,
y: 0,
transition: {
delay: i * 0.1,
duration: 0.5,
ease: [0.43, 0.13, 0.23, 0.96],
},
// Memoizing the toggleMenu and toggleSearch functions
const toggleMenu = useCallback(() => setIsMenuOpen((prev) => !prev), []);
const toggleSearch = useCallback(() => setIsSearchOpen((prev) => !prev), []);

// Memoizing the navItemVariants object
const navItemVariants = useMemo(
() => ({
hidden: { opacity: 0, y: -20 },
visible: (i: number) => ({
opacity: 1,
y: 0,
transition: {
delay: i * 0.1,
duration: 0.5,
ease: [0.43, 0.13, 0.23, 0.96],
},
}),
}),
};
[],
);

return (
<AnimatePresence>
Expand Down Expand Up @@ -85,6 +94,25 @@ export const Navbar = () => {
variants={navItemVariants}
custom={1}
>
{session?.user && (
<>
<div className="hidden md:block">
<SearchBar />
</div>

<div className="md:hidden">
<Button
onClick={toggleSearch}
variant={'ghost'}
size={'icon'}
className="mr-2"
>
<Search className="size-6" />
</Button>
</div>
</>
)}

<SelectTheme text={false} />
{session?.user && <ProfileDropdown />}

Expand Down Expand Up @@ -145,6 +173,28 @@ export const Navbar = () => {
</>
)}
</motion.nav>

{/* Mobile search overlay */}
<AnimatePresence>
{isSearchOpen && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="fixed inset-0 z-[1000] flex flex-col bg-background p-4 md:hidden"
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">Search</h2>

<Button variant="ghost" size="icon" onClick={toggleSearch}>
<X className="size-6" />
</Button>
</div>

<SearchBar onCardClick={toggleSearch} isMobile />
</motion.div>
)}
</AnimatePresence>
</AnimatePresence>
);
};
194 changes: 194 additions & 0 deletions src/components/search/CommandMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
'use client';

import { TSearchedVideos } from '@/app/api/search/route';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from '@/components/ui/command';
import { SiGithub, SiNotion } from '@icons-pack/react-simple-icons';
import {
Bookmark,
Calendar,
History,
LogOut,
MessageCircleQuestion,
NotebookPen,
NotebookText,
Play,
} from 'lucide-react';
import { signOut } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect } from 'react';

interface CommandMenuProps {
icon: string;
open: boolean;
onOpenChange: (open: boolean) => void;
commandSearchTerm: string;
onCommandSearchTermChange: (value: string) => void;
loading: boolean;
searchedVideos: TSearchedVideos[] | null;
onCardClick: (videoUrl: string) => void;
onClose: () => void;
}

export function CommandMenu({
icon,
open,
onOpenChange,
commandSearchTerm,
onCommandSearchTermChange,
loading,
searchedVideos,
onCardClick,
onClose,
}: CommandMenuProps) {
const router = useRouter();

const handleShortcut = useCallback(
(route: string) => {
if (route.startsWith('http')) {
window.location.href = route;
} else {
router.push(route);
}
onClose();
},
[router, onClose],
);

// Shortcut Handlers
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (open && e.ctrlKey) {
const shortcuts = {
c: '/home',
h: '/watch-history',
b: '/bookmark',
d: '/question',
s: 'https://projects.100xdevs.com/',
g: 'https://github.com/code100x/',
};

const key = e.key.toLowerCase() as keyof typeof shortcuts;
if (shortcuts[key]) {
e.preventDefault();
handleShortcut(shortcuts[key]);
}
}
},
[open, handleShortcut],
);

useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);

return (
<CommandDialog open={open} onOpenChange={onOpenChange}>
<CommandInput
placeholder="Type a command or search..."
value={commandSearchTerm}
onValueChange={onCommandSearchTermChange}
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>

<CommandGroup heading="Videos">
{!loading &&
searchedVideos &&
searchedVideos.length > 0 &&
searchedVideos.map((video) => (
<CommandItem
key={video.id}
onSelect={() => {
if (video.parentId && video.parent?.courses.length) {
const courseId = video.parent.courses[0].courseId;
const videoUrl = `/courses/${courseId}/${video.parentId}/${video.id}`;
onCardClick(videoUrl);
onClose();
}
}}
>
<Play className="mr-2 h-4 w-4" />
<span className="truncate">{video.title}</span>
</CommandItem>
))}
{!loading && (!searchedVideos || searchedVideos.length === 0) && (
<CommandItem>No videos found</CommandItem>
)}
</CommandGroup>

<CommandSeparator />

<CommandGroup heading="Suggestions">
<CommandItem onSelect={() => handleShortcut('/calendar')}>
<Calendar className="mr-2 h-4 w-4" />
<span>Calendar</span>
</CommandItem>
<CommandItem
onSelect={() =>
handleShortcut('https://github.com/100xdevs-cohort-3/assignments')
}
>
<NotebookPen className="mr-2 h-4 w-4" />
<span>Cohort 3 Assignments</span>
</CommandItem>
<CommandItem
onSelect={() => {
signOut();
onClose();
}}
>
<LogOut className="mr-2 h-4 w-4" />
<span>Log Out</span>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Hotkeys">
<CommandItem onSelect={() => handleShortcut('/home')}>
<NotebookText className="mr-2 h-4 w-4" />
<span>Courses</span>
<CommandShortcut>{icon}C</CommandShortcut>
</CommandItem>
<CommandItem onSelect={() => handleShortcut('/watch-history')}>
<History className="mr-2 h-4 w-4" />
<span>Watch History</span>
<CommandShortcut>{icon}H</CommandShortcut>
</CommandItem>
<CommandItem onSelect={() => handleShortcut('/bookmark')}>
<Bookmark className="mr-2 h-4 w-4" />
<span>Bookmarks</span>
<CommandShortcut>{icon}B</CommandShortcut>
</CommandItem>
<CommandItem onSelect={() => handleShortcut('/question')}>
<MessageCircleQuestion className="mr-2 h-4 w-4" />
<span>Questions</span>
<CommandShortcut>{icon}D</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => handleShortcut('https://projects.100xdevs.com/')}
>
<SiNotion className="mr-2 h-4 w-4" />
<span>Slides</span>
<CommandShortcut>{icon}S</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => handleShortcut('https://github.com/code100x/')}
>
<SiGithub className="mr-2 h-4 w-4" />
<span>Contribute to code100x</span>
<CommandShortcut>{icon}G</CommandShortcut>
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
);
}
Loading

0 comments on commit cb22d59

Please sign in to comment.