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 ( +
+
+ +
+ {text} +
+ ); +}; + +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;