diff --git a/extension/src/components/buttons/RoomSettingsButton.tsx b/extension/src/components/buttons/RoomSettingsButton.tsx index d4d7bdd..bb8367e 100644 --- a/extension/src/components/buttons/RoomSettingsButton.tsx +++ b/extension/src/components/buttons/RoomSettingsButton.tsx @@ -1,5 +1,13 @@ import { Dialog, Transition, Tab } from "@headlessui/react"; -import { ChangeEvent, Fragment, useCallback, useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + ChangeEvent, + Fragment, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import Spinner from "../Spinner"; import XIcon from "../../icons/XIcon"; import SettingsIcon from "../../icons/SettingsIcon"; @@ -11,7 +19,8 @@ import { topics, defaultRoomSettings, } from "../../types/RoomSettings"; -import { Difficulty } from "../../types/Question"; +import { Difficulty, QuestionInterface } from "../../types/Question"; +import { SERVER_URL } from "../../config"; function classNames(...classes: any[]) { return classes.filter(Boolean).join(" "); @@ -210,7 +219,7 @@ function SettingsTabs({ roomSettings: RoomSettings; setRoomSettings: (roomSettings: RoomSettings) => void; }) { - let tabs = ["Topics"]; + let tabs = ["Topics", "Questions"]; return (
@@ -236,6 +245,10 @@ function SettingsTabs({ roomSettings={roomSettings} setRoomSettings={setRoomSettings} /> +
@@ -387,6 +400,195 @@ function TopicSelector({ ); } +function useDebounce(value: any, delay: number) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} + +function QuestionSelector({ + roomSettings, + setRoomSettings, +}: { + roomSettings: RoomSettings; + setRoomSettings: (roomSettings: RoomSettings) => void; +}) { + let searchQuestionRef = useRef(null); + const [searchTerm, setSearchTerm] = useState(""); + const debounceSearch = useDebounce(searchTerm, 500); + const [selectedQuestions, setSelectedQuestions] = useState< + QuestionInterface[] + >(roomSettings?.questionFilter.questions || []); + + let { + data: questions, + isFetching, + refetch, + } = useQuery({ + queryKey: ["questions"], + queryFn: async ({ signal }) => { + let response = await fetch( + `${SERVER_URL}/questions/search/${searchTerm}`, + { + credentials: "include", + method: "GET", + headers: { + "Content-Type": "application/json", + }, + signal, + } + ); + if (!response.ok) { + throw new Error("Failed to fetch current players"); + } + + return response.json(); + }, + refetchOnWindowFocus: false, + enabled: false, + keepPreviousData: false, + }); + + useEffect(() => { + if (searchTerm) { + refetch(); + } + }, [debounceSearch]); + + useEffect(() => { + setRoomSettings({ + ...roomSettings, + questionFilter: { + kind: QuestionFilterKind.Questions, + selections: selectedQuestions.map((q) => q.titleSlug), + questions: selectedQuestions, + }, + }); + }, [selectedQuestions]); + + function handleSelectQuestion( + event: ChangeEvent, + question: QuestionInterface + ) { + const isChecked = event.target.checked; + if (isChecked) { + setSelectedQuestions([...selectedQuestions, question]); + } else { + const deSelected = selectedQuestions.filter( + (each) => each.id !== question.id + ); + setSelectedQuestions(deSelected); + } + } + + return ( + +
+ setSearchTerm(e.target.value)} + value={searchTerm} + /> +
+ +
+ +
+ {isFetching ? ( + "Loading" + ) : ( +
+
    + {questions?.length && searchTerm ? ( + questions?.map((q) => ( + + )) + ) : ( +

    No result found

    + )} +
+
+ )} +
+ +
+ + Selected question ({selectedQuestions.length} / 4) + + + {selectedQuestions.map((q) => { + return ( + + ); + })} +
+
+ ); +} + function DurationSelector({ roomSettings, setRoomSettings, diff --git a/extension/src/types/RoomSettings.ts b/extension/src/types/RoomSettings.ts index 3270ec9..77eab55 100644 --- a/extension/src/types/RoomSettings.ts +++ b/extension/src/types/RoomSettings.ts @@ -1,3 +1,5 @@ +import { QuestionInterface } from "./Question"; + export interface RoomSettings { questionFilter: QuestionFilter; duration?: number | null; @@ -13,10 +15,12 @@ export interface RoomDifficulty { export interface QuestionFilter { kind: QuestionFilterKind; selections: string[]; + questions?: QuestionInterface[]; } export enum QuestionFilterKind { Topics = "topics", + Questions = "questions", } export const topics = [ diff --git a/server/src/api/app.ts b/server/src/api/app.ts index 60dcd04..f755060 100644 --- a/server/src/api/app.ts +++ b/server/src/api/app.ts @@ -19,6 +19,7 @@ import http from "http"; import { Server } from "socket.io"; import { errorHandler, ensureAuthenticated } from "./middleware"; import roomsRoute from "../api/rooms/rooms.route"; +import questionRoute from "../api/questions/question.router"; import submissionsRoute from "../api/submissions/submissions.route"; import authRoute from "../api/auth/auth.route"; import session, { Session } from "express-session"; @@ -294,6 +295,7 @@ app.use(httplog); app.use("/auth", authRoute); app.use("/submissions", ensureAuthenticated, submissionsRoute); app.use("/rooms", ensureAuthenticated, roomsRoute); +app.use("/questions", ensureAuthenticated, questionRoute); app.use(errorHandler); declare module "http" { diff --git a/server/src/api/questions/question.router.ts b/server/src/api/questions/question.router.ts new file mode 100644 index 0000000..aba8415 --- /dev/null +++ b/server/src/api/questions/question.router.ts @@ -0,0 +1,10 @@ +import { Router } from "express"; +import { getQuestions, getQuestion } from "./questions.handler"; +// import * as RoomsHandler from "./rooms.handler"; + +const router = Router(); + +router.get("/", getQuestions); +router.get("/search/:searchTerm", getQuestion); + +export default router; diff --git a/server/src/api/questions/questions.handler.ts b/server/src/api/questions/questions.handler.ts new file mode 100644 index 0000000..54c7b5d --- /dev/null +++ b/server/src/api/questions/questions.handler.ts @@ -0,0 +1,41 @@ +import { Request, Response, NextFunction } from "express"; +import prisma from "../../index"; +import { Question } from "@prisma/client"; + +// get 20 questions randomly to search +export async function getQuestions(_: Request, res: Response) { + const questions = await prisma.$queryRaw` + SELECT * + FROM Questions + ORDER BY random() + LIMIT 20;`; + + res.json(questions); +} + +// search a specific question +export async function getQuestion( + req: Request, + res: Response, + next: NextFunction +) { + const searchTerm = req.params.searchTerm ?? ""; + + if (!searchTerm) return; + + try { + const results = await prisma.question.findMany({ + where: { + title: { + contains: searchTerm, + mode: "insensitive", + }, + }, + take: 5, + }); + + res.json(results); + } catch (error) { + next(error); + } +} diff --git a/server/src/api/rooms/rooms.handler.ts b/server/src/api/rooms/rooms.handler.ts index 372fad6..54667e1 100644 --- a/server/src/api/rooms/rooms.handler.ts +++ b/server/src/api/rooms/rooms.handler.ts @@ -75,114 +75,200 @@ export async function createRoom( let roomSettings: RoomSettings = req.body; let { kind: filterKind, selections } = roomSettings.questionFilter; - if (filterKind !== QuestionFilterKind.Topics) { - throw new Error(`Invalid question filter kind: ${filterKind}`); - } - - let filteredQuestions: Question[] = await prisma.question.findMany({ - where: { - tags: { - hasSome: selections, - }, - }, - }); - - let easyQuestions = filteredQuestions.filter( - (question) => question.difficulty === "Easy" - ); - let mediumQuestions = filteredQuestions.filter( - (question) => question.difficulty === "Medium" - ); - let hardQuestions = filteredQuestions.filter( - (question) => question.difficulty === "Hard" - ); - - let { - Easy: numberOfEasy, - Medium: numberOfMedium, - Hard: numberOfHard, - } = getNumberOfQuestionsPerDifficulty( - roomSettings.difficulty, - easyQuestions, - mediumQuestions, - hardQuestions - ); - - // Select 4 random questions - let randomlySelectedEasyQuestions: Question[] = easyQuestions - .sort(() => Math.random() - 0.5) - .slice(0, numberOfEasy); - let randomlySelectedMediumQuestions: Question[] = mediumQuestions - .sort(() => Math.random() - 0.5) - .slice(0, numberOfMedium); - let randomlySelectedHardQuestions: Question[] = hardQuestions - .sort(() => Math.random() - 0.5) - .slice(0, numberOfHard); - - let randomlySelectedQuestions = - randomlySelectedEasyQuestions.concat( - randomlySelectedMediumQuestions, - randomlySelectedHardQuestions - ); - - // Generate a room ID - let newRoomId = nanoid(ROOM_ID_LENGTH); - - // Create a new room in the db - let newRoom = await prisma.room.create({ - data: { - id: newRoomId, - questionFilterKind: filterKind, - questionFilterSelections: selections, - duration: roomSettings.duration, - }, - }); - - // Get the roomId and questionId so that you can update the RoomQuestion table - let questionIdsAndRoom: RoomQuestion[] = - randomlySelectedQuestions.map((question) => { - return { questionId: question.id, roomId: newRoom.id }; - }); - - // Add the questions to the room in the join table (RoomQuestion) - await prisma.roomQuestion.createMany({ - data: questionIdsAndRoom, - }); - - // Update the user table with the roomId - let user = await prisma.user.update({ - data: { - roomId: newRoomId, - }, - where: { - id: req.user.id, - }, - }); - - // Update the room user table with the join time - const { joinedAt } = await prisma.roomUser.create({ - data: { - userId: user.id, - roomId: newRoomId, - }, - }); - // Update the user session - req.user.updatedAt = user.updatedAt; - - // Update the room session - let roomSession: RoomSession = { - roomId: newRoomId, - questions: randomlySelectedQuestions, - userColor: generateRandomUserColor(), - createdAt: newRoom.createdAt, - duration: newRoom.duration, - joinedAt, - }; - await setUserRoomSession(req.user.id, roomSession); - sendJoinRoomMessage(req.user.username, roomSession); - - return res.redirect("../sessions"); + switch (filterKind) { + case QuestionFilterKind.Topics: { + let filteredQuestions: Question[] = + await prisma.question.findMany({ + where: { + tags: { + hasSome: selections, + }, + }, + }); + + let easyQuestions = filteredQuestions.filter( + (question) => question.difficulty === "Easy" + ); + let mediumQuestions = filteredQuestions.filter( + (question) => question.difficulty === "Medium" + ); + let hardQuestions = filteredQuestions.filter( + (question) => question.difficulty === "Hard" + ); + + let { + Easy: numberOfEasy, + Medium: numberOfMedium, + Hard: numberOfHard, + } = getNumberOfQuestionsPerDifficulty( + roomSettings.difficulty, + easyQuestions, + mediumQuestions, + hardQuestions + ); + + // Select 4 random questions + let randomlySelectedEasyQuestions: Question[] = + easyQuestions + .sort(() => Math.random() - 0.5) + .slice(0, numberOfEasy); + let randomlySelectedMediumQuestions: Question[] = + mediumQuestions + .sort(() => Math.random() - 0.5) + .slice(0, numberOfMedium); + let randomlySelectedHardQuestions: Question[] = + hardQuestions + .sort(() => Math.random() - 0.5) + .slice(0, numberOfHard); + + let randomlySelectedQuestions = + randomlySelectedEasyQuestions.concat( + randomlySelectedMediumQuestions, + randomlySelectedHardQuestions + ); + + // Generate a room ID + const newRoomId = nanoid(ROOM_ID_LENGTH); + + // Create a new room in the db + const newRoom = await prisma.room.create({ + data: { + id: newRoomId, + questionFilterKind: filterKind, + questionFilterSelections: selections, + duration: roomSettings.duration, + }, + }); + + // Get the roomId and questionId so that you can update the RoomQuestion table + let questionIdsAndRoom: RoomQuestion[] = + randomlySelectedQuestions.map((question) => { + return { + questionId: question.id, + roomId: newRoom.id, + }; + }); + + // Add the questions to the room in the join table (RoomQuestion) + await prisma.roomQuestion.createMany({ + data: questionIdsAndRoom, + }); + + // Update the user table with the roomId + let user = await prisma.user.update({ + data: { + roomId: newRoomId, + }, + where: { + id: req.user.id, + }, + }); + + // Update the room user table with the join time + const { joinedAt } = await prisma.roomUser.create({ + data: { + userId: user.id, + roomId: newRoomId, + }, + }); + + // Update the user session + req.user.updatedAt = user.updatedAt; + + // Update the room session + let roomSession: RoomSession = { + roomId: newRoomId, + questions: randomlySelectedQuestions, + userColor: generateRandomUserColor(), + createdAt: newRoom.createdAt, + duration: newRoom.duration, + joinedAt, + }; + await setUserRoomSession(req.user.id, roomSession); + sendJoinRoomMessage(req.user.username, roomSession); + + return res.redirect("../sessions"); + } + + case QuestionFilterKind.Questions: { + const questions = await prisma.question.findMany({ + where: { + titleSlug: { + in: selections, + }, + }, + }); + + // Generate a room ID + const newRoomId = nanoid(ROOM_ID_LENGTH); + + // Create a new room in the db + const newRoom = await prisma.room.create({ + data: { + id: newRoomId, + questionFilterKind: filterKind, + questionFilterSelections: selections, + duration: roomSettings.duration, + }, + }); + + // Get the roomId and questionId so that you can update the RoomQuestion table + let questionIdsAndRoom: RoomQuestion[] = questions.map( + (question) => { + return { + questionId: question.id, + roomId: newRoom.id, + }; + } + ); + + // Add the questions to the room in the join table (RoomQuestion) + await prisma.roomQuestion.createMany({ + data: questionIdsAndRoom, + }); + + // Update the user table with the roomId + let user = await prisma.user.update({ + data: { + roomId: newRoomId, + }, + where: { + id: req.user.id, + }, + }); + + // Update the room user table with the join time + const { joinedAt } = await prisma.roomUser.create({ + data: { + userId: user.id, + roomId: newRoomId, + }, + }); + + // Update the user session + req.user.updatedAt = user.updatedAt; + + // Update the room session + let roomSession: RoomSession = { + roomId: newRoomId, + questions, + userColor: generateRandomUserColor(), + createdAt: newRoom.createdAt, + duration: newRoom.duration, + joinedAt, + }; + await setUserRoomSession(req.user.id, roomSession); + sendJoinRoomMessage(req.user.username, roomSession); + + return res.redirect("../sessions"); + } + + default: + throw new Error( + `Invalid question filter kind: ${filterKind}` + ); + } }); } catch (error) { return next(error); diff --git a/server/src/types/RoomSettings.ts b/server/src/types/RoomSettings.ts index 11ec294..31e4520 100644 --- a/server/src/types/RoomSettings.ts +++ b/server/src/types/RoomSettings.ts @@ -23,6 +23,7 @@ export interface QuestionFilter { export enum QuestionFilterKind { Topics = "topics", + Questions = "questions", } export const topics = [