diff --git a/frontend/app/(authenticated)/layout.tsx b/frontend/app/(authenticated)/layout.tsx new file mode 100644 index 0000000000..7ab2f1e87c --- /dev/null +++ b/frontend/app/(authenticated)/layout.tsx @@ -0,0 +1,80 @@ +"use client" + +import Link from "next/link"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { User, LogOut } from "lucide-react"; +import { usePathname } from "next/navigation"; + +export default function AuthenticatedLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const pathname = usePathname(); + + return ( +
+
+
+ + PeerPrep + + {process.env.NODE_ENV == "development" && ( + + DEV + + )} +
+
+ +
+
+ {children} +
+ ); +} diff --git a/frontend/app/(authenticated)/profile/page.tsx b/frontend/app/(authenticated)/profile/page.tsx new file mode 100644 index 0000000000..37acac9d3b --- /dev/null +++ b/frontend/app/(authenticated)/profile/page.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { CircleX, Pencil, Save } from "lucide-react"; +import React, { ChangeEvent, useState } from "react"; + +export default function Home() { + const [isEditing, setIsEditing] = useState(false); + const [userData, setUserData] = useState({ + username: "johndoe", + email: "john@example.com", + password: "abcdefgh", + }); + + const handleEdit = () => { + if (isEditing) { + console.log("Saving changes:", userData); + } + setIsEditing(!isEditing); + }; + + const handleInputChange = (e: ChangeEvent) => { + const { id, value } = e.target; + setUserData(prev => ({ ...prev, [id]: value })); + }; + + return ( +
+ + + Profile + {isEditing ? ( +
+ + +
+ ) : ( + + )} +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ 11 + / + 20 +
+
+
+ +

14

+
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/app/question-repo/add-edit-question-dialog.tsx b/frontend/app/(authenticated)/question-repo/add-edit-question-dialog.tsx similarity index 100% rename from frontend/app/question-repo/add-edit-question-dialog.tsx rename to frontend/app/(authenticated)/question-repo/add-edit-question-dialog.tsx diff --git a/frontend/app/question-repo/columns.tsx b/frontend/app/(authenticated)/question-repo/columns.tsx similarity index 100% rename from frontend/app/question-repo/columns.tsx rename to frontend/app/(authenticated)/question-repo/columns.tsx diff --git a/frontend/app/question-repo/data-table-column-header.tsx b/frontend/app/(authenticated)/question-repo/data-table-column-header.tsx similarity index 100% rename from frontend/app/question-repo/data-table-column-header.tsx rename to frontend/app/(authenticated)/question-repo/data-table-column-header.tsx diff --git a/frontend/app/question-repo/data-table-faceted-filter.tsx b/frontend/app/(authenticated)/question-repo/data-table-faceted-filter.tsx similarity index 100% rename from frontend/app/question-repo/data-table-faceted-filter.tsx rename to frontend/app/(authenticated)/question-repo/data-table-faceted-filter.tsx diff --git a/frontend/app/question-repo/data-table-pagination.tsx b/frontend/app/(authenticated)/question-repo/data-table-pagination.tsx similarity index 98% rename from frontend/app/question-repo/data-table-pagination.tsx rename to frontend/app/(authenticated)/question-repo/data-table-pagination.tsx index 403e11e43f..1a69a34c3e 100644 --- a/frontend/app/question-repo/data-table-pagination.tsx +++ b/frontend/app/(authenticated)/question-repo/data-table-pagination.tsx @@ -41,7 +41,7 @@ export function DataTablePagination({ - {[10, 20, 30, 40, 50].map((pageSize) => ( + {[5, 10, 20, 30, 40, 50].map((pageSize) => ( {pageSize} diff --git a/frontend/app/question-repo/data-table-row-actions.tsx b/frontend/app/(authenticated)/question-repo/data-table-row-actions.tsx similarity index 100% rename from frontend/app/question-repo/data-table-row-actions.tsx rename to frontend/app/(authenticated)/question-repo/data-table-row-actions.tsx diff --git a/frontend/app/question-repo/data-table-toolbar.tsx b/frontend/app/(authenticated)/question-repo/data-table-toolbar.tsx similarity index 100% rename from frontend/app/question-repo/data-table-toolbar.tsx rename to frontend/app/(authenticated)/question-repo/data-table-toolbar.tsx diff --git a/frontend/app/question-repo/data-table.tsx b/frontend/app/(authenticated)/question-repo/data-table.tsx similarity index 93% rename from frontend/app/question-repo/data-table.tsx rename to frontend/app/(authenticated)/question-repo/data-table.tsx index 68306ba3cc..ce9cbb208e 100644 --- a/frontend/app/question-repo/data-table.tsx +++ b/frontend/app/(authenticated)/question-repo/data-table.tsx @@ -82,6 +82,11 @@ export function DataTable({ columnFilters, rowSelection }, + initialState: { + pagination: { + pageSize: 5, + }, + }, meta: { removeSelectedRows: (selectedRows: number[]) => { const filterFunc = (old: TData[]) => { @@ -99,12 +104,12 @@ export function DataTable({ }) return ( -
+
-
+
{table.getHeaderGroups().map((headerGroup) => ( @@ -124,7 +129,7 @@ export function DataTable({ ))} - + {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( -
-
- - PeerPrep - - {process.env.NODE_ENV == "development" && ( - - DEV - - )} -
-
- -
-
- -
-
Question Repository
- -
- +
+
Question Repository
+ +
); } diff --git a/frontend/app/(authenticated)/questions/page.tsx b/frontend/app/(authenticated)/questions/page.tsx new file mode 100644 index 0000000000..46431040fd --- /dev/null +++ b/frontend/app/(authenticated)/questions/page.tsx @@ -0,0 +1,320 @@ +"use client"; + +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 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 [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); + + // 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) { + const filtersRect = filtersElement.getBoundingClientRect(); + const totalHeight = filtersRect.bottom; + setFiltersHeight(totalHeight+16); + } + }, []); + + // 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 + + return ( +
+
+
+
+
+ + +
+ {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/app/forgot-password/page.tsx b/frontend/app/forgot-password/page.tsx new file mode 100644 index 0000000000..d23086fba2 --- /dev/null +++ b/frontend/app/forgot-password/page.tsx @@ -0,0 +1,163 @@ +"use client" + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import Link from "next/link"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { useEffect, useState } from "react"; +import { AlertCircle, ChevronLeft, LoaderCircle, TriangleAlert } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; + +const formSchema = z.object({ + email: z.string().min(1, "Email is required").email({ message: "Invalid email address" }), +}) + +export default function ForgotPassword() { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [countdown, setCountdown] = useState(60); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: "", + }, + }); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (success && countdown > 0) { + timer = setInterval(() => { + setCountdown((prevCount) => prevCount - 1); + }, 1000); + } + return () => { + if (timer) clearInterval(timer); + }; + }, [success, countdown]); + + useEffect(() => { + if (countdown === 0) { + setSuccess(false); + setCountdown(60); + } + }, [countdown]); + + async function onSubmit(values: z.infer) { + // Placeholder for auth to user service + try { + await form.trigger(); + if (!form.formState.isValid) { + return; + } + + setIsLoading(true); + + const response = await fetch(`${process.env.NEXT_PUBLIC_USER_API_AUTH_URL}/forgot`, { + method: "POST", + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(values), + }); + + if (response.status == 400) { + setError("Missing email."); + throw new Error("Missing email: " + response.statusText); + } else if (response.status == 500) { + setError("Database or server error. Please try again."); + throw new Error("Database or server error: " + response.statusText); + } else if (!response.ok) { + setError("There was an error resetting your password. Please try again."); + throw new Error("Error sending reset link: " + response.statusText); + } + + const responseData = await response.json(); + setSuccess(true); + setCountdown(60); + } catch (error) { + console.error(error); + } finally { + setIsLoading(false); + } + } + + return ( +
+
+
+ +
+
+ + Forgot your password? + +

+ Enter your email address and we will send you a link to reset your password. +

+
+ {error && ( + + + Error + + {error} + + + )} + {success && ( + + + Check your email + + A link to reset your password has been sent to your email address. + + + )} +
+
+ + ( + + Email + + + + + + )} + /> + + + +
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 1308ca9870..6fcee12e33 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -3,11 +3,11 @@ import { Syne } from 'next/font/google' import localFont from "next/font/local"; import "./globals.css"; -const brandingFont = Syne({ +const brandFont = Syne({ subsets: ['latin'], weight: ["400", "500", "600", "700", "800"], display: "swap", - variable: "--font-branding", + variable: "--font-brand", }); const matterFont = localFont({ src: "./fonts/MatterTRIALVF-Uprights.woff2", @@ -35,7 +35,7 @@ export default function RootLayout({ return ( {children} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx new file mode 100644 index 0000000000..110000cb96 --- /dev/null +++ b/frontend/app/login/page.tsx @@ -0,0 +1,161 @@ +"use client" + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import Link from "next/link"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { useRouter } from "next/navigation" +import { useState } from "react"; +import { AlertCircle, LoaderCircle } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; + +const formSchema = z.object({ + email: z.string().min(1, "Email is required").email({ message: "Invalid email address" }), + password: z.string().min(8, "Password requires at least 8 characters"), // Password has more criterias but we only let user know about length +}) + +export default function Login() { + const router = useRouter() + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: "", + password: "", + }, + }); + + async function onSubmit(values: z.infer) { + // Placeholder for auth to user service + try { + await form.trigger(); + if (!form.formState.isValid) { + return; + } + + setIsLoading(true); + + const response = await fetch(`${process.env.NEXT_PUBLIC_USER_API_AUTH_URL}/login`, { + method: "POST", + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(values), + }); + + if (response.status == 400) { + setError("Missing email or password."); + throw new Error("Missing email or password: " + response.statusText); + } else if (response.status == 401) { + setError("Incorrect email or password."); + throw new Error("Incorrect email or password: " + response.statusText); + } else if (response.status == 500) { + setError("Database or server error. Please try again."); + throw new Error("Database or server error: " + response.statusText); + } else if (!response.ok) { + setError("There was an error logging in. Please try again."); + throw new Error("Error logging in: " + response.statusText); + } + + const responseData = await response.json(); + console.log(responseData.data["accessToken"]); + router.push("/question-repo"); + } catch (error) { + console.error(error); + } finally { + setIsLoading(false); + } + } + + return ( +
+
+
+ + Sign in + +
+ {error && ( + + + Error + + {error} + + + )} +
+
+ + ( + + Email + + + + + + )} + /> + ( + + +
+ Password + + Forgot password? + +
+
+ + + + +
+ )} + /> + + + +
+ Don't have an account?{" "} + + Sign up + +
+
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 873a3299af..e5262b0635 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,153 +1,27 @@ -"use client" - import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import Link from "next/link"; -import { z } from "zod"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { useRouter } from "next/navigation" -import { useState } from "react"; -import { AlertCircle, LoaderCircle } from "lucide-react"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; - -const formSchema = z.object({ - email: z.string().min(1, "Email is required").email({ message: "Invalid email address" }), - password: z.string().min(8, "Password requires at least 8 characters"), // Password has more criterias but we only let user know about length -}) - -export default function Login() { - const router = useRouter() - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - email: "", - password: "", - }, - }); - - async function onSubmit(values: z.infer) { - // Placeholder for auth to user service - try { - await form.trigger(); - if (!form.formState.isValid) { - return; - } - - setIsLoading(true); - - const response = await fetch(`${process.env.NEXT_PUBLIC_USER_API_AUTH_URL}/login`, { - method: "POST", - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(values), - }); - - if (response.status == 400) { - setError("Missing email or password."); - throw new Error("Missing email or password: " + response.statusText); - } else if (response.status == 401) { - setError("Incorrect email or password."); - throw new Error("Incorrect email or password: " + response.statusText); - } else if (response.status == 500) { - setError("Database or server error. Please try again."); - throw new Error("Database or server error: " + response.statusText); - } else if (!response.ok) { - setError("There was an error logging in. Please try again."); - throw new Error("Error logging in: " + response.statusText); - } - - const responseData = await response.json(); - console.log(responseData.data["accessToken"]); - router.push("/question-repo"); - } catch (error) { - console.error(error); - } finally { - setIsLoading(false); - } - } +export default function Landing() { return ( -
-
-
- - Sign in - -
- {error && ( - - - Error - - {error} - - - )} -
-
- - ( - - Email - - - - - - )} - /> - ( - - Password - - - - - - )} - /> - - - -
- Don't have an account?{" "} - - Sign up - -
-
+
+

PeerPrep

+

Temporary landing page with all the links

+
+ + +
+
+ Forgot password (enter email for link) + Reset password (enter password to reset) + Profile page +
+
+ Questions (user facing) + Question Repo (CRUD)
) diff --git a/frontend/app/questions/page.tsx b/frontend/app/questions/page.tsx deleted file mode 100644 index 3152b62757..0000000000 --- a/frontend/app/questions/page.tsx +++ /dev/null @@ -1,360 +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 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 [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); - - // 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 - - 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/app/reset-password/page.tsx b/frontend/app/reset-password/page.tsx new file mode 100644 index 0000000000..c57478510f --- /dev/null +++ b/frontend/app/reset-password/page.tsx @@ -0,0 +1,199 @@ +"use client" + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import Link from "next/link"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { useEffect, useState } from "react"; +import { AlertCircle, LoaderCircle, TriangleAlert } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; + +const formSchema = z.object({ + email: z.string().min(1, "Email is required").email({ message: "Invalid email address" }), + password: z + .string() + .min(8, "Password must be at least 8 characters") + .regex(/[A-Z]/, "Password must contain at least one uppercase letter") + .regex(/[a-z]/, "Password must contain at least one lowercase letter") + .regex(/[0-9]/, "Password must contain at least one number") + .regex(/[^A-Za-z0-9]/, "Password must contain at least one special character"), + confirm: z.string().min(8, "Passwords do not match"), +}).refine((data) => data.password === data.confirm, { + message: "Passwords do not match", + path: ["confirm"], +}); + +export default function ResetPassword() { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: "", // Prefill from email link/backend + password: "", + confirm: "", + }, + }); + + const watchPassword = form.watch("password"); + useEffect(() => { + if (watchPassword) { + form.trigger("password"); + } + }, [watchPassword, form]); + + const watchConfirm = form.watch("confirm"); + useEffect(() => { + if (watchConfirm) { + form.trigger("confirm"); + } + }, [watchConfirm, form]); + + async function onSubmit(values: z.infer) { + // Placeholder for auth to user service + try { + await form.trigger(); + if (!form.formState.isValid) { + return; + } + + setIsLoading(true); + + const { confirm, ...resetValues } = values; + const response = await fetch(`${process.env.NEXT_PUBLIC_USER_API_AUTH_URL}/reset`, { + method: "POST", + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(resetValues), + }); + + if (response.status == 400) { + setError("Missing email."); + throw new Error("Missing email: " + response.statusText); + } else if (response.status == 500) { + setError("Database or server error. Please try again."); + throw new Error("Database or server error: " + response.statusText); + } else if (!response.ok) { + setError("There was an error resetting your password. Please try again."); + throw new Error("Error resetting password: " + response.statusText); + } + + const responseData = await response.json(); + setSuccess(true); + } catch (error) { + console.error(error); + } finally { + setIsLoading(false); + } + } + + return ( +
+
+
+ + Reset your password + +
+ {error && ( + + + Error + + {error} + + + )} + {success && ( + + + Password has been reset + + Login here with your new password. + + + )} +
+
+ + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + Password must have at least: +
    +
  • 8 characters
  • +
  • 1 uppercase character
  • +
  • 1 lowercase character
  • +
  • 1 number
  • +
  • 1 special character
  • +
+
+
+ )} + /> + ( + + Confirm password + + + + + + )} + /> + + + +
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx index e863dc4f64..2334c98a4e 100644 --- a/frontend/app/signup/page.tsx +++ b/frontend/app/signup/page.tsx @@ -11,14 +11,14 @@ import { useForm } from "react-hook-form" import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form" -import { AlertCircle, Info, LoaderCircle } from "lucide-react" +import { AlertCircle, LoaderCircle } from "lucide-react" import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert" -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" const formSchema = z.object({ username: z.string().min(4, "Username requires at least 4 characters"), @@ -134,10 +134,10 @@ export default function Signup() { return (
-
- PeerPrep +
+ PeerPrep
-
+
Create an account @@ -163,23 +163,12 @@ export default function Signup() { name="username" render={({ field }) => ( - -
- Username - - - - -

Minimum 4 characters

-
-
-
-
-
+ Username + Minimum 4 characters
)} /> @@ -201,30 +190,21 @@ export default function Signup() { name="password" render={({ field }) => ( - -
- Password - - - - - Password must have at least: -
    -
  • 8 characters
  • -
  • 1 uppercase character
  • -
  • 1 lowercase character
  • -
  • 1 number
  • -
  • 1 special character
  • -
-
-
-
-
-
+ Password + + Must have at least: +
    +
  • 8 characters
  • +
  • 1 uppercase character
  • +
  • 1 lowercase character
  • +
  • 1 number
  • +
  • 1 special character
  • +
+
)} /> @@ -258,7 +238,7 @@ export default function Signup() {
Already have an account?{" "} Sign in diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx index 0ba4277355..3d357d3016 100644 --- a/frontend/components/ui/button.tsx +++ b/frontend/components/ui/button.tsx @@ -13,7 +13,7 @@ const buttonVariants = cva( destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: - "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + "border border-gray-200 bg-background hover:bg-brand-100 hover:text-brand-700", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", diff --git a/frontend/components/ui/form.tsx b/frontend/components/ui/form.tsx index 20274fb1c6..23838d70b2 100644 --- a/frontend/components/ui/form.tsx +++ b/frontend/components/ui/form.tsx @@ -132,7 +132,7 @@ const FormDescription = React.forwardRef< const { formDescriptionId } = useFormField() return ( -