From 34ee997bb2e02c5444ecdd307bc92297ac0d8588 Mon Sep 17 00:00:00 2001 From: Brendan Tan Date: Sat, 19 Oct 2024 00:49:24 +0800 Subject: [PATCH 1/2] Add matching to question page --- frontend/app/(authenticated)/profile/page.tsx | 14 +- .../app/(authenticated)/questions/page.tsx | 293 +++++++++++-- frontend/app/auth/forgot-password/page.tsx | 2 +- .../auth/forgot-password/verify-code/page.tsx | 24 +- frontend/app/auth/login/page.tsx | 2 +- .../app/auth/reset-password/success/page.tsx | 16 +- frontend/app/auth/sign-up/page.tsx | 8 +- frontend/app/layout.tsx | 2 +- frontend/app/question/page.tsx | 407 ------------------ frontend/components/ui/badge.tsx | 2 +- frontend/components/ui/button.tsx | 1 + 11 files changed, 285 insertions(+), 486 deletions(-) delete mode 100644 frontend/app/question/page.tsx diff --git a/frontend/app/(authenticated)/profile/page.tsx b/frontend/app/(authenticated)/profile/page.tsx index 54cddbf14f..ae5c513785 100644 --- a/frontend/app/(authenticated)/profile/page.tsx +++ b/frontend/app/(authenticated)/profile/page.tsx @@ -18,7 +18,7 @@ export default function Home() { email: "john@example.com", password: "abcdefgh", }); - const initialUserData = useRef({ + const initialUserData = useRef({ username: "johndoe", email: "john@example.com", password: "abcdefgh", @@ -51,7 +51,7 @@ export default function Home() { const data = (await response.json()).data; // placeholder for password *Backend wont expose password via any API call - const password = "********"; + const password = ""; setUserData({ username: data.username, @@ -73,7 +73,7 @@ export default function Home() { }, []); // Validate the password before making the API call - const validatePassword = (password: string) => { + const validatePassword = (password: string) => { let errorMessage = ""; if (!/[A-Z]/.test(password)) { errorMessage += "Must contain at least one uppercase letter.\n"; @@ -163,7 +163,7 @@ export default function Home() { throw new Error("Error during email verification process."); } } - + if (userData.password !== initialUserData.current.password) { console.log("detect password change: original:", initialUserData.current.password, " new pw: ", userData.password) // Check for password validity @@ -232,8 +232,8 @@ export default function Home() { } return ( -
- +
+ Profile {isEditing ? ( @@ -253,7 +253,7 @@ export default function Home() { {feedback.message && ( - + {feedback.type === 'error' ? 'Error' : 'Check your email'} diff --git a/frontend/app/(authenticated)/questions/page.tsx b/frontend/app/(authenticated)/questions/page.tsx index cc576c3694..18f3a039c0 100644 --- a/frontend/app/(authenticated)/questions/page.tsx +++ b/frontend/app/(authenticated)/questions/page.tsx @@ -3,12 +3,14 @@ import { Badge, BadgeProps } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; import { MultiSelect } from "@/components/ui/multi-select"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { Flag, MessageSquareText } from "lucide-react"; -import Link from "next/link"; +import { cn } from "@/lib/utils"; +import { Check, ChevronsRight, Flag, MessageSquareText, Plus, X } from "lucide-react"; import { useRouter } from "next/navigation"; -import React, { useEffect, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; type Question = { id: number; @@ -48,7 +50,7 @@ const categoryList: Array<{ { value: "strings", label: "Strings", badgeVariant: "category" }, ]; -export default function Home() { +export default function Questions() { const router = useRouter(); const [selectedComplexities, setSelectedComplexities] = useState( complexityList.map((diff) => diff.value) @@ -62,9 +64,17 @@ export default function Home() { useState(null); const [isSelectAll, setIsSelectAll] = useState(false); const [reset, setReset] = useState(false); + + const [isMatching, setIsMatching] = useState(false); + const [isHovering, setIsHovering] = useState(false); + const [matchTime, setMatchTime] = useState(0); + const [redirectTime, setRedirectTime] = useState(5); + const matchTimerRef = useRef(null); + const [isMatchFoundDialogOpen, setMatchFoundDialogOpen] = useState(false); + const [isMatchFailDialogOpen, setMatchFailDialogOpen] = useState(false); - // authenticate user else redirect them to login page - useEffect(() => { + // authenticate user else redirect them to login page + useEffect(() => { const authenticateUser = async () => { try { const token = localStorage.getItem('token'); @@ -89,7 +99,7 @@ export default function Home() { } const data = (await response.json()).data; - + // if needed // setUsername(data.username); // setEmail(data.email); @@ -100,8 +110,8 @@ export default function Home() { console.error('Error during authentication:', error); router.push('/login'); // Redirect to login in case of any error } - }; - authenticateUser(); + }; + authenticateUser(); }, []); // Fetch questions from backend API @@ -114,7 +124,7 @@ export default function Home() { const data = await response.json(); // Map backend data to match the frontend Question type - const mappedQuestions: Question[] = data.map((q: {id: number, title: string, complexity: string, category: string[], description: string, link: string,selected: boolean}) => ({ + const mappedQuestions: Question[] = data.map((q: { id: number, title: string, complexity: string, category: string[], description: string, link: string, selected: boolean }) => ({ id: q.id, title: q.title, complexity: complexityList.find( @@ -140,7 +150,7 @@ export default function Home() { if (filtersElement) { const filtersRect = filtersElement.getBoundingClientRect(); const totalHeight = filtersRect.bottom; - setFiltersHeight(totalHeight+16); + setFiltersHeight(totalHeight + 16); } }, []); @@ -219,7 +229,81 @@ export default function Home() { useEffect(() => { console.log("Selected complexities:", selectedComplexities); - }, [selectedComplexities]); // This effect runs every time selectedComplexities change + }, [selectedComplexities]); // This effect runs every time selectedcomplexities change + + const handleMatch = useCallback(() => { + setIsMatching(prev => !prev); + setIsHovering(false); + }, []); + + useEffect(() => { + if (isMatching) { + setMatchTime(0); + matchTimerRef.current = setInterval(() => { + setMatchTime((prevTime) => { + if (prevTime >= 32) { // we use 32 so there is buffer + clearInterval(matchTimerRef.current as NodeJS.Timeout); + setMatchFailDialogOpen(true); + // setMatchFoundDialogOpen(true); use this to open match found dialog + return 32; + } + return prevTime + 1; + }); + }, 1000); + } else { + if (matchTimerRef.current) { + clearInterval(matchTimerRef.current); + } + setMatchTime(0); + } + }, [isMatching]); + + useEffect(() => { + if (isMatchFoundDialogOpen) { + setRedirectTime(5); + const redirectTimer = setInterval(() => { + setRedirectTime((prevTime) => { + if (prevTime <= 1) { + clearInterval(redirectTimer); + // router.push('/questions'); Redirect to question page + setMatchFoundDialogOpen(false); + setIsMatching(false); + return 0; + } + return prevTime - 1; + }); + }, 1000); + + return () => clearInterval(redirectTimer); + } + }, [isMatchFoundDialogOpen, router]); + + useEffect(() => { + if (isMatchFailDialogOpen) { + setRedirectTime(5); + const redirectTimer = setInterval(() => { + setRedirectTime((prevTime) => { + if (prevTime <= 1) { + clearInterval(redirectTimer); + setMatchFailDialogOpen(false); + setIsMatching(false); + return 0; + } + return prevTime - 1; + }); + }, 1000); + + return () => clearInterval(redirectTimer); + } + }, [isMatchFailDialogOpen, router]); + + const handleMouseEnter = useCallback(() => { + setIsHovering(true); + }, []); + + const handleMouseLeave = useCallback(() => { + setIsHovering(false); + }, []); return (
@@ -263,7 +347,7 @@ export default function Home() { className="uppercase rounded-3xl" onClick={handleSelectAll} > - {isSelectAll ? "Deselect All" : "Select All"} + {isSelectAll ? "Remove all" : "Add all"} )} @@ -274,7 +358,6 @@ export default function Home() { type="hover" >
- {filteredQuestions.length == 0 ? (

No questions found

@@ -289,7 +372,7 @@ export default function Home() { >

- {question.title} + {question.id}. {question.title}

handleSelectQuestion(question.id)} > - {question.selected ? "Selected" : "Select"} + {question.selected ? ( +
Added
+ ) : ( +
Add
+ )}
@@ -321,43 +409,154 @@ export default function Home() {
-
- {!selectedViewQuestion ? ( -
Select a question to view
- ) : ( -
-

- {selectedViewQuestion.title} -

-
-
- - - {selectedViewQuestion.complexity} - +
+
+
+
Questions added for matching
+ {questionList.filter((question) => question.selected).length == 0 ? ( +
No questions added for matching
+ ) : ( +
+ +
+ {questionList + .filter((question) => question.selected) + .map((question) => ( + + + + {question.title} + + + +
+ + {question.categories.map((category) => ( + + {category} + + ))} +
+
+
+ ))} + +
+
-
- - {selectedViewQuestion.categories.map((category) => ( + )} +
+
+ +
+
+
+ {!selectedViewQuestion ? ( +
Click on a question to view its details
+ ) : ( +
+

+ {selectedViewQuestion.title} +

+
+
+ - {category} + {selectedViewQuestion.complexity} - ))} +
+
+ + {selectedViewQuestion.categories.map((category) => ( + + {category} + + ))} +
+

+ {selectedViewQuestion.description} +

-

- {selectedViewQuestion.description} -

-
- )} + )} +
+ + + + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > + + + Match found + + + +
+

A match has been found!

+

Redirecting you back to the question page in {redirectTime} {redirectTime === 1 ? "second" : "seconds"}

+
+
+
+ + + + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > + + + Match not found + + + +
+

Please try again.

+

Redirecting you back to the question page in {redirectTime} {redirectTime === 1 ? "second" : "seconds"}...

+
+
+
); diff --git a/frontend/app/auth/forgot-password/page.tsx b/frontend/app/auth/forgot-password/page.tsx index 231cf44afa..963a7d5cfe 100644 --- a/frontend/app/auth/forgot-password/page.tsx +++ b/frontend/app/auth/forgot-password/page.tsx @@ -91,7 +91,7 @@ export default function ForgottenPassword() { return (
-
+
diff --git a/frontend/app/auth/forgot-password/verify-code/page.tsx b/frontend/app/auth/forgot-password/verify-code/page.tsx index 40bc46bfec..97aac66b36 100644 --- a/frontend/app/auth/forgot-password/verify-code/page.tsx +++ b/frontend/app/auth/forgot-password/verify-code/page.tsx @@ -174,18 +174,18 @@ export default function OTPForm() { } return ( -
-
-
-
-

Email Verification

-
-
-

We have sent a code to your email {param_email && ":" + param_email}
Please check your email.

-
+
+
+
+ + Email Verification + +

+ We have sent a code to your email{param_email && ": " + param_email} +

{error && ( - + Error @@ -206,7 +206,7 @@ export default function OTPForm() { ( <>
@@ -226,7 +226,7 @@ export default function OTPForm() { {isLoading ? ( ) : ( - "Verify Account" + "Verify" )} diff --git a/frontend/app/auth/login/page.tsx b/frontend/app/auth/login/page.tsx index 9a668f9604..9b565904bb 100644 --- a/frontend/app/auth/login/page.tsx +++ b/frontend/app/auth/login/page.tsx @@ -98,7 +98,7 @@ export default function Login() { } return ( -
+
diff --git a/frontend/app/auth/reset-password/success/page.tsx b/frontend/app/auth/reset-password/success/page.tsx index 85dc67ab6b..d4a5c46c66 100644 --- a/frontend/app/auth/reset-password/success/page.tsx +++ b/frontend/app/auth/reset-password/success/page.tsx @@ -8,21 +8,27 @@ const PasswordChangeSuccess = () => { const router = useRouter(); const handleReturn = () => { - router.push('/'); + router.push('/auth/login'); }; return ( -
+
-

Password Changed!

-

Your password has been changed successfully.

+
+ + Password Changed + +

+ Your password has been changed successfully. +

+
); diff --git a/frontend/app/auth/sign-up/page.tsx b/frontend/app/auth/sign-up/page.tsx index d04569b1f2..4c3640f773 100644 --- a/frontend/app/auth/sign-up/page.tsx +++ b/frontend/app/auth/sign-up/page.tsx @@ -230,7 +230,7 @@ export default function Signup() { await fetch(`${process.env.NEXT_PUBLIC_USER_API_EMAIL_URL}/send-verification-email`, { method: 'POST', headers: { - 'Content-Type': 'application/json', + 'Content-Type': 'application/json', }, body: JSON.stringify({ username: username, @@ -244,11 +244,11 @@ export default function Signup() { return (
-
- PeerPrep +
+ PeerPrep
-
+
Create an account diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 6fcee12e33..b818b83abf 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -35,7 +35,7 @@ export default function RootLayout({ return ( {children} diff --git a/frontend/app/question/page.tsx b/frontend/app/question/page.tsx deleted file mode 100644 index 94ee5a931c..0000000000 --- a/frontend/app/question/page.tsx +++ /dev/null @@ -1,407 +0,0 @@ -"use client"; - -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Badge, BadgeProps } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { MultiSelect } from "@/components/ui/multi-select"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Flag, MessageSquareText } from "lucide-react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import React, { useEffect, useState } from "react"; - -type Question = { - id: number; - title: string; - complexity: string | undefined; - categories: (string | undefined)[]; - description: string; - selected: boolean; -}; - -const complexityList: Array<{ - value: string; - label: string; - badgeVariant: BadgeProps["variant"]; -}> = [ - { value: "easy", label: "Easy", badgeVariant: "easy" }, - { value: "medium", label: "Medium", badgeVariant: "medium" }, - { value: "hard", label: "Hard", badgeVariant: "hard" }, - ]; - -const categoryList: Array<{ - value: string; - label: string; - badgeVariant: BadgeProps["variant"]; -}> = [ - { value: "algorithms", label: "Algorithms", badgeVariant: "category" }, - { value: "arrays", label: "Arrays", badgeVariant: "category" }, - { - value: "bitmanipulation", - label: "Bit Manipulation", - badgeVariant: "category", - }, - { value: "brainteaser", label: "Brainteaser", badgeVariant: "category" }, - { value: "databases", label: "Databases", badgeVariant: "category" }, - { value: "datastructures", label: "Data Structures", badgeVariant: "category" }, - { value: "recursion", label: "Recursion", badgeVariant: "category" }, - { value: "strings", label: "Strings", badgeVariant: "category" }, - ]; - -export default function Home() { - const router = useRouter(); - const [selectedComplexities, setSelectedComplexities] = useState( - complexityList.map((diff) => diff.value) - ); - const [selectedCategories, setSelectedCategories] = useState( - categoryList.map((category) => category.value) - ); - const [filtersHeight, setFiltersHeight] = useState(0); - const [questionList, setQuestionList] = useState([]); // Complete list of questions - const [selectedViewQuestion, setSelectedViewQuestion] = - useState(null); - const [isSelectAll, setIsSelectAll] = useState(false); - const [reset, setReset] = useState(false); - - // authenticate user else redirect them to login page - useEffect(() => { - const authenticateUser = async () => { - try { - const token = localStorage.getItem('token'); - - if (!token) { - router.push('/'); // Redirect to login if no token - return; - } - - // Call the API to verify the token - const response = await fetch(`${process.env.NEXT_PUBLIC_USER_API_AUTH_URL}/verify-token`, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - }); - - if (!response.ok) { - localStorage.removeItem("token"); // remove invalid token from browser - router.push('/'); // Redirect to login if not authenticated - return; - } - - const data = (await response.json()).data; - - // if needed - // setUsername(data.username); - // setEmail(data.email); - // form.setValue("username", data.username); - // form.setValue("email", data.email); - // userId.current = data.id; - } catch (error) { - console.error('Error during authentication:', error); - router.push('/login'); // Redirect to login in case of any error - } - }; - authenticateUser(); - }, []); - - // Fetch questions from backend API - useEffect(() => { - async function fetchQuestions() { - try { - const response = await fetch(`${process.env.NEXT_PUBLIC_QUESTION_API_BASE_URL}/all`, { - cache: "no-store", - }); - const data = await response.json(); - - // Map backend data to match the frontend Question type - const mappedQuestions: Question[] = data.map((q: {id: number, title: string, complexity: string, category: string[], description: string, link: string,selected: boolean}) => ({ - id: q.id, - title: q.title, - complexity: complexityList.find( - (complexity) => complexity.value === q.complexity.toLowerCase() - )?.value, - categories: q.category.sort((a: string, b: string) => a.localeCompare(b)), - description: q.description, - link: q.link, - selected: false, // Set selected to false initially - })); - - setQuestionList(mappedQuestions); // Set the fetched data to state - } catch (error) { - console.error("Error fetching questions:", error); - } - } - - fetchQuestions(); - }, []); - - useEffect(() => { - const filtersElement = document.getElementById("filters"); - if (filtersElement) { - setFiltersHeight(filtersElement.offsetHeight); - } - }, []); - - // Handle filtered questions based on user-selected complexities and categories - const filteredQuestions = questionList.filter((question) => { - const selectedcategoryLabels = selectedCategories.map( - (categoryValue) => - categoryList.find((category) => category.value === categoryValue)?.label - ); - - const matchesComplexity = - selectedComplexities.length === 0 || - (question.complexity && - selectedComplexities.includes(question.complexity)); - - const matchesCategories = - selectedCategories.length === 0 || - selectedcategoryLabels.some((category) => question.categories.includes(category)); - - return matchesComplexity && matchesCategories; - }); - - // Function to reset filters - const resetFilters = () => { - setSelectedComplexities(complexityList.map((diff) => diff.value)); - setSelectedCategories(categoryList.map((category) => category.value)); - setReset(true); - }; - - // Function to handle "Select All" button click - const handleSelectAll = () => { - const newIsSelectAll = !isSelectAll; - setIsSelectAll(newIsSelectAll); - - // Toggle selection of all questions - const updatedQuestions = questionList.map((question) => - filteredQuestions.map((f_qns) => f_qns.id).includes(question.id) - ? { - ...question, - selected: newIsSelectAll, // Select or unselect all questions - } - : question - ); - setQuestionList(updatedQuestions); - }; - - // Function to handle individual question selection - const handleSelectQuestion = (id: number) => { - const updatedQuestions = questionList.map((question) => - question.id === id - ? { ...question, selected: !question.selected } - : question - ); - setQuestionList(updatedQuestions); - }; - - useEffect(() => { - const allSelected = - questionList.length > 0 && questionList.every((q) => q.selected); - const noneSelected = - questionList.length > 0 && questionList.every((q) => !q.selected); - - if (allSelected) { - setIsSelectAll(true); - } else if (noneSelected) { - setIsSelectAll(false); - } - }, [questionList]); - - useEffect(() => { - if (filteredQuestions.length === 0) { - setSelectedViewQuestion(null); - } - }, [filteredQuestions]); - - - useEffect(() => { - console.log("Selected complexities:", selectedComplexities); - }, [selectedComplexities]); // This effect runs every time selectedcomplexities change - - const handleProfileRedirect = () => { - router.push('/profile'); // Update with your actual profile page path - }; - - return ( - //
-
-
-
- - PeerPrep - - {process.env.NODE_ENV == "development" && ( - - DEV - - )} -
-
- -
-
- -
-
-
-
- -
- - -
- {filteredQuestions.length > 0 && ( - - )} -
- - -
-
- {filteredQuestions.length == 0 ? ( -
-

No questions found

- -
- ) : ( - filteredQuestions.map((question) => ( -
- setSelectedViewQuestion(question)} - > -
-

- {question.title} -

-
- - {question.complexity} - - {question.categories.map((category, index) => ( - - {category} - - ))} -
-
- -
-
- )) - )} -
-
-
-
-
- {!selectedViewQuestion ? ( -
Select a question to view
- ) : ( -
-

- {selectedViewQuestion.title} -

-
-
- - - {selectedViewQuestion.complexity} - -
-
- - {selectedViewQuestion.categories.map((category) => ( - - {category} - - ))} -
-
-

- {selectedViewQuestion.description} -

-
- )} -
-
-
- ); -} \ No newline at end of file diff --git a/frontend/components/ui/badge.tsx b/frontend/components/ui/badge.tsx index 98a9e6b417..5e31783df2 100644 --- a/frontend/components/ui/badge.tsx +++ b/frontend/components/ui/badge.tsx @@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const badgeVariants = cva( - "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + "cursor-default inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", { variants: { variant: { diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx index 3d357d3016..224c10840b 100644 --- a/frontend/components/ui/button.tsx +++ b/frontend/components/ui/button.tsx @@ -18,6 +18,7 @@ const buttonVariants = cva( "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", + match: "bg-brand-200 text-brand-800 hover:bg-brand-300 font-branding font-bold tracking-tight", }, size: { default: "h-10 px-4 py-2", From 2459779ee4e7be7ce6478b7d75af124fff11db61 Mon Sep 17 00:00:00 2001 From: Brendan Tan Date: Sat, 19 Oct 2024 15:00:05 +0800 Subject: [PATCH 2/2] Add disabled buttons during matching --- frontend/app/(authenticated)/questions/page.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/app/(authenticated)/questions/page.tsx b/frontend/app/(authenticated)/questions/page.tsx index 18f3a039c0..c4bb82bb9b 100644 --- a/frontend/app/(authenticated)/questions/page.tsx +++ b/frontend/app/(authenticated)/questions/page.tsx @@ -244,7 +244,7 @@ export default function Questions() { if (prevTime >= 32) { // we use 32 so there is buffer clearInterval(matchTimerRef.current as NodeJS.Timeout); setMatchFailDialogOpen(true); - // setMatchFoundDialogOpen(true); use this to open match found dialog + // setMatchFoundDialogOpen(true); // use this to open match found dialog return 32; } return prevTime + 1; @@ -346,6 +346,7 @@ export default function Questions() { variant="outline" className="uppercase rounded-3xl" onClick={handleSelectAll} + disabled={isMatching} > {isSelectAll ? "Remove all" : "Add all"} @@ -392,8 +393,9 @@ export default function Questions() {
- e.preventDefault()} @@ -530,15 +531,14 @@ export default function Questions() { -
-

A match has been found!

-

Redirecting you back to the question page in {redirectTime} {redirectTime === 1 ? "second" : "seconds"}

+
+

You have been matched with username

+

Redirecting you back to the question page in {redirectTime} {redirectTime === 1 ? "second" : "seconds"}...

- e.preventDefault()} @@ -551,7 +551,7 @@ export default function Questions() { -
+

Please try again.

Redirecting you back to the question page in {redirectTime} {redirectTime === 1 ? "second" : "seconds"}...