Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/video search #281

Merged
merged 3 commits into from
Apr 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
65 changes: 65 additions & 0 deletions src/app/api/search/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
56 changes: 36 additions & 20 deletions src/components/Appbar.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();
Expand All @@ -40,6 +41,17 @@ export const Appbar = () => {
<Logo onFooter={false} />

{session?.data?.user ? (
<>
<div className="hidden md:block">
<SearchBar />
</div>
<div className="flex items-center space-x-2">
{/* Search Bar for smaller devices */}
<MobileScreenSearch />
<div className="hidden sm:flex items-center justify-around md:w-auto md:block space-x-2">
<Button variant={'link'} size={'sm'} asChild>
<JoinDiscord isNavigated={false} />
</Button>
<div className="flex items-center space-x-2">
<div className="hidden sm:flex items-center justify-around md:w-auto md:block space-x-2">
{currentPath.includes('courses') && bookmarkPageUrl && (
Expand All @@ -61,30 +73,34 @@ export const Appbar = () => {
<JoinDiscord isNavigated={false} />
</Button>

<Button size={'sm'} variant={'link'} asChild>
<Link href={'https://projects.100xdevs.com/'} target="_blank">
Slides
</Link>
</Button>
<Button size={'sm'} variant={'link'} asChild>
<Link
href={'https://projects.100xdevs.com/'}
target="_blank"
>
Slides
</Link>
</Button>

<Button size={'sm'} variant={'link'} asChild>
<Link
href={'https://github.com/100xdevs-cohort-2/assignments'}
target="_blank"
>
Assignments
</Link>
</Button>
<Button size={'sm'} variant={'link'} asChild>
<Link
href={'https://github.com/100xdevs-cohort-2/assignments'}
target="_blank"
>
Assignments
</Link>
</Button>

<AppbarAuth />
</div>
<AppbarAuth />
</div>

<ThemeToggler />
<ThemeToggler />

<div className="block sm:hidden">
<NavigationMenu />
<div className="block sm:hidden">
<NavigationMenu />
</div>
</div>
</div>
</>
) : (
<div className="flex items-center space-x-2">
<div className="hidden sm:flex items-center justify-around md:w-auto md:block space-x-3">
Expand Down
25 changes: 25 additions & 0 deletions src/components/search/MobileScreenSearch.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="md:hidden">
{showSearchBar ? (
<div className="absolute top-0 px-3 left-0 h-16 bg-white dark:bg-[#020817] border-b z-[100] w-full flex items-center gap-3">
<SearchBar onCardClick={toggleSearchBar} />
<XIcon onClick={toggleSearchBar} />
</div>
) : (
<SearchIcon onClick={toggleSearchBar} size={18} />
)}
</div>
);
};

export default MobileScreenSearch;
127 changes: 127 additions & 0 deletions src/components/search/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>(false);
const [loading, setLoading] = useState(false);
const router = useRouter();

const ref = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<VideoSearchInfo text="Please enter at least 3 characters to search" />
);
} else if (loading) {
return <VideoSearchLoading />;
} else if (!searchedVideos || searchedVideos.length === 0) {
return <VideoSearchInfo text="No videos found" />;
}
return searchedVideos.map((video) => (
<VideoSearchCard
key={video.id}
video={video}
onCardClick={handleCardClick}
/>
));
};

return (
<div
className="relative flex items-center w-full lg:w-[400px] xl:w-[500px] h-10"
ref={ref}
>
{/* Search Input Bar */}
<SearchIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 transform text-gray-500" />
<Input
placeholder="Search for videos..."
className="px-10 border-2 focus-visible:ring-transparent rounded-full"
value={searchTerm}
onChange={handleInputChange}
onFocus={() => setIsInputFocused(true)}
ref={searchInputRef}
/>
{searchTerm.length > 0 && (
<XCircleIcon
className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 transform cursor-pointer"
onClick={handleClearInput}
/>
)}

{/* Search Results */}
{isInputFocused && searchTerm.length > 0 && (
<div className="absolute top-12 bg-white dark:bg-[#020817] rounded-lg border-2 shadow-lg w-full py-2 max-h-[40vh] overflow-y-auto">
{renderSearchResults()}
</div>
)}
</div>
);
};

export default SearchBar;
31 changes: 31 additions & 0 deletions src/components/search/VideoSearchCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="px-3 py-2 hover:bg-gray-200 hover:dark:bg-gray-700 cursor-pointer flex items-center gap-3"
onClick={() => onCardClick(videoUrl)}
>
<div className="min-w-content">
<PlayCircleIcon className="w-4 h-4" />
</div>
<span className="w-4/5">{video.title}</span>
</div>
);
}
};

export default VideoSearchCard;
15 changes: 15 additions & 0 deletions src/components/search/VideoSearchInfo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { InfoIcon } from 'lucide-react';
import React from 'react';

const VideoSearchInfo = ({ text }: { text: string }) => {
return (
<div className="flex items-center gap-3 px-3 text-gray-700 dark:text-gray-500 text-sm">
<div className="min-w-content">
<InfoIcon className="w-4 h-4" />
</div>
<span className="w-4/5">{text}</span>
</div>
);
};

export default VideoSearchInfo;
18 changes: 18 additions & 0 deletions src/components/search/VideoSearchLoading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from 'react';

const VideoSearchLoading = () => {
return (
<div className="animate-pulse flex flex-col gap-4 py-2 ">
{[1, 2, 3].map((value) => (
<div className="flex items-center gap-3 px-3 " key={value}>
<div className="min-w-content bg-slate-50 dark:bg-slate-900 rounded">
<div className="w-4 h-4" />
</div>
<span className="w-full h-6 bg-slate-50 dark:bg-slate-900 rounded"></span>
</div>
))}
</div>
);
};

export default VideoSearchLoading;
Loading
Loading