diff --git a/package.json b/package.json
index a75e1d907..02ae477d3 100644
--- a/package.json
+++ b/package.json
@@ -44,6 +44,7 @@
"dayjs": "^1.11.10",
"discord-oauth2": "^2.11.0",
"discord.js": "^14.14.1",
+ "fuse.js": "^7.0.0",
"jose": "^5.2.2",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.321.0",
diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts
new file mode 100644
index 000000000..a8cc2938e
--- /dev/null
+++ b/src/app/api/search/route.ts
@@ -0,0 +1,65 @@
+import { Cache } from '@/db/Cache';
+import db from '@/db';
+import { CourseContent } from '@prisma/client';
+import Fuse from 'fuse.js';
+import { NextResponse } from 'next/server';
+
+export type TSearchedVideos = {
+ id: number;
+ parentId: number | null;
+ title: string;
+} & {
+ parent: { courses: CourseContent[] } | null;
+};
+
+const fuzzySearch = (videos: TSearchedVideos[], searchQuery: string) => {
+ const searchedVideos = new Fuse(videos, {
+ keys: ['title'],
+ }).search(searchQuery);
+
+ return searchedVideos.map((video) => video.item);
+};
+
+export async function GET(request: Request) {
+ const { searchParams } = new URL(request.url);
+ const searchQuery = searchParams.get('q');
+
+ if (searchQuery && searchQuery.length > 2) {
+ const value: TSearchedVideos[] = await Cache.getInstance().get(
+ 'getAllVideosForSearch',
+ [],
+ );
+
+ if (value) {
+ return NextResponse.json(fuzzySearch(value, searchQuery));
+ }
+
+ const allVideos = await db.content.findMany({
+ where: {
+ type: 'video',
+ hidden: false,
+ },
+ select: {
+ id: true,
+ parentId: true,
+ title: true,
+ parent: {
+ select: {
+ courses: true,
+ },
+ },
+ },
+ });
+
+ Cache.getInstance().set(
+ 'getAllVideosForSearch',
+ [],
+ allVideos,
+ 24 * 60 * 60,
+ );
+
+ return NextResponse.json(fuzzySearch(allVideos, searchQuery));
+ }
+
+ return NextResponse.json({}, { status: 400 });
+}
diff --git a/src/components/Appbar.tsx b/src/components/Appbar.tsx
index 5d7ef2588..f283b2495 100644
--- a/src/components/Appbar.tsx
+++ b/src/components/Appbar.tsx
@@ -1,7 +1,6 @@
'use client';
import Link from 'next/link';
-import React from 'react';
import { JoinDiscord } from './JoinDiscord';
import { AppbarAuth } from './AppbarAuth';
import { useSession } from 'next-auth/react';
@@ -14,6 +13,8 @@ import { Button } from './ui/button';
import { Sparkles } from 'lucide-react';
import { ThemeToggler } from './ThemeToggler';
import { NavigationMenu } from './landing/appbar/nav-menu';
+import SearchBar from './search/SearchBar';
+import MobileScreenSearch from './search/MobileScreenSearch';
export const Appbar = () => {
const session = useSession();
@@ -40,6 +41,17 @@ export const Appbar = () => {
{session?.data?.user ? (
+ <>
+
+
+
+
+ {/* Search Bar for smaller devices */}
+
+
+
{currentPath.includes('courses') && bookmarkPageUrl && (
@@ -61,30 +73,34 @@ export const Appbar = () => {
-
+
-
+
-
-
+
+
-
+
-
-
+ >
) : (
diff --git a/src/components/search/MobileScreenSearch.tsx b/src/components/search/MobileScreenSearch.tsx
new file mode 100644
index 000000000..64e3ac3cd
--- /dev/null
+++ b/src/components/search/MobileScreenSearch.tsx
@@ -0,0 +1,25 @@
+import { SearchIcon, XIcon } from 'lucide-react';
+import { useState } from 'react';
+import SearchBar from './SearchBar';
+
+const MobileScreenSearch = () => {
+ const [showSearchBar, setShowSearchBar] = useState(false);
+
+ const toggleSearchBar = () => {
+ setShowSearchBar((prev) => !prev);
+ };
+ return (
+
+ {showSearchBar ? (
+
+
+
+
+ ) : (
+
+ )}
+
+ );
+};
+
+export default MobileScreenSearch;
diff --git a/src/components/search/SearchBar.tsx b/src/components/search/SearchBar.tsx
new file mode 100644
index 000000000..6be5e6acc
--- /dev/null
+++ b/src/components/search/SearchBar.tsx
@@ -0,0 +1,127 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { Input } from '@/components/ui/input';
+import { SearchIcon, XCircleIcon } from 'lucide-react';
+import { TSearchedVideos } from '@/app/api/search/route';
+import { useRouter } from 'next/navigation';
+import useClickOutside from '@/hooks/useClickOutside';
+import VideoSearchCard from './VideoSearchCard';
+import VideoSearchInfo from './VideoSearchInfo';
+import { toast } from 'sonner';
+import VideoSearchLoading from './VideoSearchLoading';
+
+const SearchBar = ({ onCardClick }: { onCardClick?: () => void }) => {
+ const [searchTerm, setSearchTerm] = useState('');
+ const [searchedVideos, setSearchedVideos] = useState<
+ TSearchedVideos[] | null
+ >(null);
+ const [isInputFocused, setIsInputFocused] = useState
(false);
+ const [loading, setLoading] = useState(false);
+ const router = useRouter();
+
+ const ref = useRef(null);
+ const searchInputRef = useRef(null);
+
+ useClickOutside(ref, () => {
+ setIsInputFocused(false);
+ });
+
+ const fetchData = useCallback(async (searchTerm: string) => {
+ setLoading(true);
+ try {
+ const response = await fetch(`/api/search?q=${searchTerm}`);
+ const data = await response.json();
+ setSearchedVideos(data);
+ } catch (err) {
+ toast.error('Something went wrong while searching for videos');
+ setSearchTerm('');
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (searchTerm.length > 2) {
+ const timeoutId = setTimeout(() => {
+ fetchData(searchTerm);
+ }, 300);
+
+ return () => clearTimeout(timeoutId);
+ }
+ setSearchedVideos(null);
+ }, [searchTerm, fetchData]);
+
+ const handleInputChange = (event: React.ChangeEvent) => {
+ setSearchTerm(event.target.value);
+ };
+
+ const clearSearchTerm = () => {
+ setSearchTerm('');
+ };
+
+ const handleCardClick = (videoUrl: string) => {
+ if (onCardClick !== undefined) {
+ onCardClick();
+ }
+ clearSearchTerm();
+ router.push(videoUrl);
+ };
+
+ const handleClearInput = () => {
+ clearSearchTerm();
+ if (searchInputRef.current) {
+ searchInputRef.current.focus();
+ }
+ };
+
+ const renderSearchResults = () => {
+ if (searchTerm.length < 3) {
+ return (
+
+ );
+ } else if (loading) {
+ return ;
+ } else if (!searchedVideos || searchedVideos.length === 0) {
+ return ;
+ }
+ return searchedVideos.map((video) => (
+
+ ));
+ };
+
+ return (
+
+ {/* Search Input Bar */}
+
+
setIsInputFocused(true)}
+ ref={searchInputRef}
+ />
+ {searchTerm.length > 0 && (
+
+ )}
+
+ {/* Search Results */}
+ {isInputFocused && searchTerm.length > 0 && (
+
+ {renderSearchResults()}
+
+ )}
+
+ );
+};
+
+export default SearchBar;
diff --git a/src/components/search/VideoSearchCard.tsx b/src/components/search/VideoSearchCard.tsx
new file mode 100644
index 000000000..f50dd37d2
--- /dev/null
+++ b/src/components/search/VideoSearchCard.tsx
@@ -0,0 +1,31 @@
+import { TSearchedVideos } from '@/app/api/search/route';
+import { PlayCircleIcon } from 'lucide-react';
+import React from 'react';
+
+const VideoSearchCard = ({
+ video,
+ onCardClick,
+}: {
+ video: TSearchedVideos;
+ onCardClick: (videoUrl: string) => void;
+}) => {
+ const { id: videoId, parentId, parent } = video;
+
+ if (parentId && parent) {
+ const courseId = parent.courses[0].courseId;
+ const videoUrl = `/courses/${courseId}/${parentId}/${videoId}`;
+ return (
+ onCardClick(videoUrl)}
+ >
+
+
{video.title}
+
+ );
+ }
+};
+
+export default VideoSearchCard;
diff --git a/src/components/search/VideoSearchInfo.tsx b/src/components/search/VideoSearchInfo.tsx
new file mode 100644
index 000000000..1a8d66584
--- /dev/null
+++ b/src/components/search/VideoSearchInfo.tsx
@@ -0,0 +1,15 @@
+import { InfoIcon } from 'lucide-react';
+import React from 'react';
+
+const VideoSearchInfo = ({ text }: { text: string }) => {
+ return (
+
+ );
+};
+
+export default VideoSearchInfo;
diff --git a/src/components/search/VideoSearchLoading.tsx b/src/components/search/VideoSearchLoading.tsx
new file mode 100644
index 000000000..ee0d2d1d5
--- /dev/null
+++ b/src/components/search/VideoSearchLoading.tsx
@@ -0,0 +1,18 @@
+import React from 'react';
+
+const VideoSearchLoading = () => {
+ return (
+
+ {[1, 2, 3].map((value) => (
+
+ ))}
+
+ );
+};
+
+export default VideoSearchLoading;
diff --git a/src/hooks/useClickOutside.ts b/src/hooks/useClickOutside.ts
new file mode 100644
index 000000000..a6fed8f77
--- /dev/null
+++ b/src/hooks/useClickOutside.ts
@@ -0,0 +1,21 @@
+import { RefObject, useEffect } from 'react';
+
+const useClickOutside = (
+ ref: RefObject,
+ handler: (event: MouseEvent) => void,
+) => {
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (ref.current && !ref.current.contains(event.target as Node)) {
+ handler(event);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [ref, handler]);
+};
+
+export default useClickOutside;