Skip to content

Commit

Permalink
feat(576)/added search on frontend with fuse.js
Browse files Browse the repository at this point in the history
  • Loading branch information
toufiqfarhan0 committed Aug 31, 2024
1 parent ebd1d22 commit 4d52419
Show file tree
Hide file tree
Showing 5 changed files with 95 additions and 103 deletions.
152 changes: 81 additions & 71 deletions apps/web/components/ContentSearch.tsx
Original file line number Diff line number Diff line change
@@ -1,92 +1,96 @@
"use client";
import { useEffect, useRef, useState, useDeferredValue } from "react";
import Link from "next/link";

import { Cross2Icon, MagnifyingGlassIcon } from "@radix-ui/react-icons";
import { Dialog, DialogClose, DialogContent, Input, Card, CardDescription, CardHeader, CardTitle } from "@repo/ui";
import { getSearchResults } from "../lib/search";
import Image from "next/image";
import { Dialog, DialogClose, DialogContent, Button, Input, Card, CardHeader, CardTitle, CardDescription } from "@repo/ui";
import { useEffect, useRef, useState } from "react";
import { Track, Problem } from "@prisma/client";
import Fuse from "fuse.js";
import Link from "next/link";

declare global {
interface Window {
SpeechRecognition: any;
}
}

export function ContentSearch() {
export function ContentSearch({ tracks }: { tracks: (Track & { problems: Problem[] })[] }) {
const [dialogOpen, setDialogOpen] = useState(false);
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const [input, setInput] = useState("");
const [searchTracks, setSearchTracks] = useState<any[]>([]);
const [searchTracks, setSearchTracks] = useState<(Track & { problems: Problem[] })[]>(tracks);
const [selectedIndex, setSelectedIndex] = useState(-1);
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const deferredInput = useDeferredValue(input);
const [shortcut, setShortcut] = useState("Ctrl K");
const [isListening, setIsListening] = useState(false);
const speechRecognitionRef = useRef<any | null>(null);

useEffect(() => {
async function fetchSearchResults() {
if (deferredInput.length > 0) {
const data = await getSearchResults(deferredInput);
setSearchTracks(data);
} else {
setSearchTracks([]);
}
const SpeechRecognition = window.SpeechRecognition || (window as any).webkitSpeechRecognition;
if (SpeechRecognition) {
speechRecognitionRef.current = new SpeechRecognition();
speechRecognitionRef.current.continuous = false;
speechRecognitionRef.current.interimResults = false;
speechRecognitionRef.current.lang = 'en-US';

speechRecognitionRef.current.onresult = (event: any) => {
const transcript = event.results[0][0].transcript;
setInput(transcript);
setIsListening(false);
};

speechRecognitionRef.current.onstart = () => setIsListening(true);
speechRecognitionRef.current.onend = () => setIsListening(false);
}
fetchSearchResults();
}, [deferredInput]);
}, []);

useEffect(() => {
const handleKeyPress = (event: KeyboardEvent) => {
switch (event.code) {
case "KeyK":
if (event.ctrlKey) {
event.preventDefault();
setDialogOpen(true);
}
break;
case "ArrowDown":
event.preventDefault();
setSelectedIndex((prevIndex) => (prevIndex + 1) % searchTracks.length);
break;
case "ArrowUp":
event.preventDefault();
setSelectedIndex((prevIndex) => (prevIndex - 1 + searchTracks.length) % searchTracks.length);
break;
case "Enter":
if (selectedIndex !== -1) {
event.preventDefault();
const selectedTrack = searchTracks[selectedIndex];
window.open(`/tracks/${selectedTrack?.payload.trackId}/${selectedTrack?.payload.problemId}`, "_blank");
}
break;
default:
break;
}
};
const startListening = () => {
if (speechRecognitionRef.current) {
speechRecognitionRef.current.start();
}
};

window.addEventListener("keydown", handleKeyPress);
return () => window.removeEventListener("keydown", handleKeyPress);
}, [searchTracks, selectedIndex]);
const endListening = () =>{
if(speechRecognitionRef.current){
speechRecognitionRef.current.stop();
}
}

useEffect(() => {
if (selectedIndex !== -1 && scrollableContainerRef.current) {
const selectedElement = scrollableContainerRef.current.children[selectedIndex];
if (selectedElement) {
selectedElement.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}
}, [selectedIndex]);
const fuse = new Fuse(tracks, {
keys: ["title", "description"],
});

const handleClose = (open: boolean) => {
if (!open) {
setDialogOpen(false);
setInput("");
if (input.length > 0) {
const result = fuse.search(input);
setSearchTracks(result.map(({ item }) => item));
} else {
setSearchTracks(tracks);
}
};
setSelectedIndex(-1);
}, [input, tracks]);

useEffect(() => {
const isMacOS = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
setShortcut(isMacOS ? "Cmd + K" : "Ctrl + K");
}, []);

function handleClose(open: boolean) {
if (!open) setDialogOpen(false);
setInput("");
endListening();
}

return (
<Dialog open={dialogOpen} onOpenChange={handleClose}>
<div
className="md:max-w-screen border border-primary/15 p-3 rounded-lg cursor-text w-full mx-auto"
onClick={() => setDialogOpen(true)}
>
<div className="md:flex gap-2 items-center hidden justify-between ">
<div className="md:flex gap-2 items-center hidden justify-between">
<div className="flex gap-2 items-center">
<MagnifyingGlassIcon className="size-4" />
Search
</div>
<kbd className="bg-white/15 p-2 rounded-sm text-sm leading-3">Ctrl + K</kbd>
<kbd className="bg-white/15 p-2 rounded-sm text-sm leading-3">{shortcut}</kbd>
</div>
<div className="block md:hidden">
<MagnifyingGlassIcon className="size-4" />
Expand All @@ -102,6 +106,13 @@ export function ContentSearch() {
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<button onClick={isListening ? endListening : startListening}>
<div className={`h-8 w-8 mr-2 ${isListening ? `bg-red-600` : `bg-black`} rounded-full flex items-center justify-center`}>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 304 408">
<path fill="white" d="M149.5 259q-26.5 0-45.5-19t-19-45V67q0-27 19-45.5T149.5 3t45 18.5T213 67v128q0 26-18.5 45t-45 19zM262 195h37q0 54-37.5 94.5T171 338v70h-43v-70q-53-8-90.5-49T0 195h36q0 46 34 77t79.5 31t79-31t33.5-77z" />
</svg>
</div>
</button>
<DialogClose>
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
Expand All @@ -110,25 +121,24 @@ export function ContentSearch() {
<div className="h-[400px] py-4 space-y-4 overflow-y-scroll" ref={scrollableContainerRef}>
{searchTracks.length > 0 &&
searchTracks.map((track, index) => (
<div key={track.payload.problemId} className={`p-2 ${index === selectedIndex ? "bg-blue-600/20" : ""}`}>
<div key={track.id} className={`p-2 ${index === selectedIndex ? "bg-blue-600/20" : ""}`}>
<Link
className="flex"
href={`/tracks/${track.payload.trackId}/${track.payload.problemId}`}
href={`/tracks/${track.id}`}
target="_blank"
passHref
>
<Card className="p-2 w-full mx-2">
<div className="flex my-2">
<Image
alt={track.payload.problemTitle}
src={track.payload.image}
<img
alt={track.title}
src={track.image}
className="flex mx-2 w-1/6 rounded-xl"
/>

<div>
<CardHeader>
<CardTitle>{track.payload.problemTitle}</CardTitle>
<CardDescription>{track.payload.trackTitle}</CardDescription>
<CardTitle>{track.title}</CardTitle>
<CardDescription>{track.description}</CardDescription>
</CardHeader>
</div>
</div>
Expand Down
7 changes: 4 additions & 3 deletions apps/web/components/Hero.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"use client";
import React, { useEffect, useState } from "react";
import { ContentSearch } from "../components/ContentSearch";
import { motion } from "framer-motion";
import { Track, Problem } from "@prisma/client";
import { Spotlight } from "@repo/ui";
import { ContentSearch } from "./ContentSearch";

export default function Hero() {
export default function Hero({ tracks }: { tracks: (Track & { problems: Problem[] })[] }) {
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });

const handleMouseMove = (event: MouseEvent) => {
Expand Down Expand Up @@ -165,7 +166,7 @@ export default function Hero() {
<p className="text-primary/80 max-w-lg text-center tracking-tight md:text-lg font-light">
A platform where you'll find the right content to help you improve your skills and grow your knowledge.
</p>
<ContentSearch />
<ContentSearch tracks={tracks}/>
</motion.div>
<Spotlight className="-top-40 left-0 md:left-60 md:-top-20 -z-10" fill="blue" />
</div>
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"clsx": "^2.1.0",
"date-fns": "^3.6.0",
"framer-motion": "^11.3.30",
"fuse.js": "^7.0.0",
"next": "^14.0.4",
"next-auth": "^4.24.7",
"next-themes": "^0.2.1",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/screens/Landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export async function Landing() {
return (
<div className="flex flex-col">
<AppbarClient />
<Hero />
<Hero tracks={tracks}/>
<Tracks tracks={tracks} categories={categories} />
<FooterCTA />
<Footer />
Expand Down
36 changes: 8 additions & 28 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3461,6 +3461,11 @@ functions-have-names@^1.2.3:
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==

fuse.js@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.0.0.tgz#6573c9fcd4c8268e403b4fc7d7131ffcf99a9eb2"
integrity sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==

gensync@^1.0.0-beta.2:
version "1.0.0-beta.2"
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
Expand Down Expand Up @@ -6832,16 +6837,7 @@ string-argv@~0.3.2:
resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6"
integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==

"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"

string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
Expand Down Expand Up @@ -6945,14 +6941,7 @@ stringify-entities@^4.0.0:
character-entities-html4 "^2.0.0"
character-entities-legacy "^3.0.0"

"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"

strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
Expand Down Expand Up @@ -7761,7 +7750,7 @@ wordwrap@^1.0.0:
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==

"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
Expand All @@ -7779,15 +7768,6 @@ wrap-ansi@^6.0.1:
string-width "^4.1.0"
strip-ansi "^6.0.0"

wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"

wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
Expand Down

0 comments on commit 4d52419

Please sign in to comment.