Skip to content

Commit

Permalink
Merge pull request #34 from CS3219-AY2425S1/add-match-timer
Browse files Browse the repository at this point in the history
Add matching button functionality
  • Loading branch information
WZWren authored Oct 20, 2024
2 parents 8454cf8 + 6770d98 commit d8f75ac
Show file tree
Hide file tree
Showing 13 changed files with 170 additions and 56 deletions.
28 changes: 24 additions & 4 deletions peerprep/api/gateway.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { cookies } from "next/headers";
import { LoginResponse, Question, SigninResponse, StatusBody } from "./structs";
import { LoginResponse, Question, UserServiceResponse, StatusBody } from "./structs";
import DOMPurify from "isomorphic-dompurify";

export function generateAuthHeaders() {
return {
Authorization: `Bearer ${cookies().get("session")}`,
};
"Authorization": `Bearer ${cookies().get("session")?.value}`,
};;
}

export function generateJSONHeaders() {
Expand Down Expand Up @@ -75,7 +75,7 @@ export async function postSignupUser(validatedFields: {
username: string;
email: string;
password: string;
}): Promise<SigninResponse | StatusBody> {
}): Promise<UserServiceResponse | StatusBody> {
try {
console.log(JSON.stringify(validatedFields));
const res = await fetch(`${process.env.NEXT_PUBLIC_USER_SERVICE}/users`, {
Expand All @@ -97,3 +97,23 @@ export async function postSignupUser(validatedFields: {
return { error: err.message, status: 400 };
}
}

export async function verifyUser(): Promise<UserServiceResponse | StatusBody> {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_USER_SERVICE}/auth/verify-token`,
{
method: "GET",
headers: generateAuthHeaders(),
}
);
const json = await res.json();

if (!res.ok) {
return { error: json.message, status: res.status };
}
return json;
} catch (err: any) {
return { error: err.message, status: 400 };
}
}
2 changes: 1 addition & 1 deletion peerprep/api/structs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export interface LoginResponse {
data: UserDataAccessToken;
}

export interface SigninResponse {
export interface UserServiceResponse {
message: string;
data: UserData;
}
Expand Down
21 changes: 19 additions & 2 deletions peerprep/app/actions/server_actions.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
"use server";
import { getSessionLogin, postSignupUser } from "@/api/gateway";
import { getSessionLogin, postSignupUser, verifyUser } from "@/api/gateway";
// defines the server-sided login action.
import {
SignupFormSchema,
LoginFormSchema,
FormState,
isError,
UserServiceResponse,
} from "@/api/structs";
import { createSession } from "@/app/actions/session";
import { createSession, expireSession } from "@/app/actions/session";
import { redirect } from "next/navigation";
import { cookies } from "next/headers";

// credit - taken from Next.JS Auth tutorial
export async function signup(state: FormState, formData: FormData) {
Expand Down Expand Up @@ -59,3 +61,18 @@ export async function login(state: FormState, formData: FormData) {
console.log(json.error);
}
}

export async function hydrateUid(): Promise<undefined | string> {
if (!cookies().has("session")) {
console.log("No session found - triggering switch back to login page.");
redirect("/auth/login");
}
const json = await verifyUser();
if (isError(json)) {
console.log("Failed to fetch user ID.");
console.log(`Error ${json.status}: ${json.error}`);
redirect("/api/internal/auth/expire");
}

return json.data.id;
}
4 changes: 4 additions & 0 deletions peerprep/app/actions/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@ export async function createSession(accessToken: string) {
path: "/",
});
}

export async function expireSession() {
cookies().delete("session");
}
9 changes: 9 additions & 0 deletions peerprep/app/api/internal/auth/expire/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { expireSession } from "@/app/actions/session";
import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
await expireSession();
const url = request.nextUrl.clone();
url.pathname = "/auth/login";
return NextResponse.redirect(url);
}
1 change: 0 additions & 1 deletion peerprep/app/api/internal/questions/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { generateAuthHeaders, generateJSONHeaders } from "@/api/gateway";
import { QuestionFullBody } from "@/api/structs";
import { NextRequest, NextResponse } from "next/server";

export async function GET() {
Expand Down
6 changes: 4 additions & 2 deletions peerprep/app/questions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import QuestionList from "@/components/questionpage/QuestionList";
import Matchmaking from "@/components/questionpage/Matchmaking";
import { QuestionFilterProvider } from "@/contexts/QuestionFilterContext";
import { UserInfoProvider } from "@/contexts/UserInfoContext";
import { hydrateUid } from "../actions/server_actions";

const QuestionsPage = () => {
async function QuestionsPage() {
const userId = await hydrateUid();
return (
<UserInfoProvider>
<UserInfoProvider userid={userId}>
<QuestionFilterProvider>
<Matchmaking></Matchmaking>
<QuestionList></QuestionList>
Expand Down
50 changes: 37 additions & 13 deletions peerprep/components/questionpage/Matchmaking.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ import { useRouter } from "next/navigation";
import PeerprepButton from "../shared/PeerprepButton";
import { useQuestionFilter } from "@/contexts/QuestionFilterContext";
import { useUserInfo } from "@/contexts/UserInfoContext";
import { isError, MatchRequest, MatchResponse } from "@/api/structs";
import {
Difficulty,
isError,
MatchRequest,
MatchResponse,
} from "@/api/structs";
import {
checkMatchStatus,
findMatch,
} from "@/app/api/internal/matching/helper";
import { match } from "assert";
import { TIMEOUT } from "dns";
import ResettingStopwatch from "../shared/ResettingStopwatch";
import PeerprepDropdown from "../shared/PeerprepDropdown";

const QUERY_INTERVAL_MILLISECONDS = 5000;
const TIMEOUT_MILLISECONDS = 30000;
Expand Down Expand Up @@ -50,7 +55,9 @@ const usePeriodicCallback = (
const Matchmaking = () => {
const router = useRouter();
const [isMatching, setIsMatching] = useState<boolean>(false);
const { difficulty, topics } = useQuestionFilter();
const [difficultyFilter, setDifficultyFilter] = useState<string>(Difficulty.Easy);
const [topicFilter, setTopicFilter] = useState<string[]>(["all"]);
const { difficulties, topicList } = useQuestionFilter();
const { userid } = useUserInfo();
const timeout = useRef<NodeJS.Timeout>();

Expand All @@ -62,6 +69,17 @@ const Matchmaking = () => {
}
};

const getMatchMakingRequest = (): MatchRequest => {
const matchRequest: MatchRequest = {
userId: userid,
difficulty: difficultyFilter,
topicTags: topicFilter,
requestTime: getMatchRequestTime(),
};

return matchRequest;
};

const handleMatch = async () => {
if (!isMatching) {
setIsMatching(true);
Expand All @@ -73,12 +91,7 @@ const Matchmaking = () => {
}, TIMEOUT_MILLISECONDS);

// assemble the match request
const matchRequest: MatchRequest = {
userId: userid,
difficulty: difficulty,
topicTags: topics,
requestTime: getMatchRequestTime(),
};
const matchRequest = getMatchMakingRequest();
console.log("Match attempted");
console.debug(matchRequest);

Expand Down Expand Up @@ -128,9 +141,20 @@ const Matchmaking = () => {
<PeerprepButton onClick={handleMatch}>
{isMatching ? "Cancel Match" : "Find Match"}
</PeerprepButton>
{isMatching && (
<div className="w-3 h-3 bg-difficulty-hard rounded-full ml-2" />
)}
{!isMatching &&
<PeerprepDropdown label="Difficulty"
value={difficultyFilter}
onChange={e => setDifficultyFilter(e.target.value)}
// truthfully we don't need this difficulties list, but we are temporarily including it
options={difficulties} />
}
{!isMatching &&
<PeerprepDropdown label="Topics"
value={topicFilter[0]}
onChange={e => setTopicFilter(e.target.value === "all" ? topicList : [e.target.value])}
options={topicList} />
}
{isMatching && <ResettingStopwatch isActive={isMatching} />}
</div>
</div>
);
Expand Down
33 changes: 15 additions & 18 deletions peerprep/components/questionpage/QuestionList.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";
import React, { useEffect, useState } from "react";
import QuestionCard from "./QuestionCard";
import { Question, StatusBody, Difficulty, isError } from "@/api/structs";
import { Question, Difficulty, isError } from "@/api/structs";
import PeerprepDropdown from "../shared/PeerprepDropdown";
import PeerprepSearchBar from "../shared/PeerprepSearchBar";
import { useQuestionFilter } from "@/contexts/QuestionFilterContext";
Expand All @@ -10,9 +10,12 @@ const QuestionList: React.FC = () => {
const [questions, setQuestions] = useState<Question[]>([]);
const [loading, setLoading] = useState(true);
const [searchFilter, setSearchFilter] = useState<string>("");
const [topicsList, setTopicsList] = useState<string[]>(["all"]);
const [difficultyFilter, setDifficultyFilter] = useState<string>(
Difficulty.All
);
const [topicFilter, setTopicFilter] = useState<string>("all");

const { difficulty, setDifficulty, topics, setTopics } = useQuestionFilter();
const { topicList, setTopicList } = useQuestionFilter();

useEffect(() => {
const fetchQuestions = async () => {
Expand All @@ -33,19 +36,18 @@ const QuestionList: React.FC = () => {
const uniqueTopics = Array.from(
new Set(data.flatMap((question) => question.topicTags))
).sort();
setTopicsList(["all", ...uniqueTopics]);
setTopics(["all", ...uniqueTopics]);
setTopicList(["all", ...uniqueTopics]);
};

fetchQuestions();
}, []);

const filteredQuestions = questions.filter((question) => {
const matchesDifficulty =
difficulty === Difficulty.All ||
Difficulty[question.difficulty] === difficulty;
difficultyFilter === Difficulty.All ||
Difficulty[question.difficulty] === difficultyFilter;
const matchesTopic =
topics[0] === "all" || (question.topicTags ?? []).includes(topics[0]);
topicFilter === "all" || (question.topicTags ?? []).includes(topicFilter);
const matchesSearch =
searchFilter === "" ||
(question.title ?? "").toLowerCase().includes(searchFilter.toLowerCase());
Expand All @@ -57,16 +59,12 @@ const QuestionList: React.FC = () => {

const handleSetDifficulty = (e: React.ChangeEvent<HTMLSelectElement>) => {
const diff = e.target.value;
setDifficulty(diff);
setDifficultyFilter(diff);
};

const handleSetTopics = (e: React.ChangeEvent<HTMLSelectElement>) => {
const topic = e.target.value;
if (topic === "all") {
setTopics(topicsList);
} else {
setTopics([topic]);
}
setTopicFilter(topic);
};

return (
Expand All @@ -79,16 +77,15 @@ const QuestionList: React.FC = () => {
/>
<PeerprepDropdown
label="Difficulty"
value={difficulty}
value={difficultyFilter}
onChange={handleSetDifficulty}
options={Object.keys(Difficulty).filter((key) => isNaN(Number(key)))}
/>
<PeerprepDropdown
label="Topics"
// coincidentally "all" is at the top of the list so the display works out...dumb luck!
value={topics[0]}
value={topicFilter}
onChange={handleSetTopics}
options={topicsList}
options={topicList}
/>
</div>

Expand Down
38 changes: 38 additions & 0 deletions peerprep/components/shared/ResettingStopwatch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React, { useState, useEffect } from "react";

interface ResettingStopwatchProps {
isActive: boolean;
}

// pass isActive from parent component
//
const ResettingStopwatch: React.FC<ResettingStopwatchProps> = ({
isActive,
}) => {
const [elapsedTime, setElapsedTime] = useState<number>(0);

useEffect(() => {
let interval: NodeJS.Timeout | null = null;

if (isActive) {
interval = setInterval(() => {
setElapsedTime((prevTime) => prevTime + 1);
}, 1000);
}

return () => {
if (interval) clearInterval(interval);
setElapsedTime(0);
};
}, [isActive]);

const formatTime = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = time % 60;
return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
};

return <div>{formatTime(elapsedTime)}</div>;
};

export default ResettingStopwatch;
17 changes: 10 additions & 7 deletions peerprep/contexts/QuestionFilterContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@ import { Difficulty } from "@/api/structs";
import React, { createContext, useContext, useState, ReactNode } from "react";

interface QuestionFilterContextType {
difficulty: string;
setDifficulty: (difficulty: string) => void;
topics: string[];
setTopics: (topics: string[]) => void;
difficulties: string[];
topicList: string[];
setTopicList: (topics: string[]) => void;
}

const QuestionFilterContext = createContext<
Expand All @@ -16,12 +15,16 @@ const QuestionFilterContext = createContext<
export const QuestionFilterProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [difficulty, setDifficulty] = useState<string>(Difficulty.All); // default to all
const [topics, setTopics] = useState<string[]>(["all"]); // I guess default set this too the whole list of topics from questionlist
const difficulties = [Difficulty.Easy, Difficulty.Medium, Difficulty.Hard];
const [topicList, setTopicList] = useState<string[]>([]);
// I guess default set this too the whole list of topics from questionlist
// can also consider moving all the uniqu topics here? -- yes, we are doing that now
// TODO: since QuestionFilterProvider now exists to wrap the QuestionList,
// we can move the question fetching 1 layer higher, theoretically, so look into this

return (
<QuestionFilterContext.Provider
value={{ difficulty, setDifficulty, topics, setTopics }}
value={{ difficulties, topicList, setTopicList }}
>
{children}
</QuestionFilterContext.Provider>
Expand Down
Loading

0 comments on commit d8f75ac

Please sign in to comment.