diff --git a/src/components/Low/Select.tsx b/src/components/Low/Select.tsx index e79ce93e..d35b4076 100644 --- a/src/components/Low/Select.tsx +++ b/src/components/Low/Select.tsx @@ -1,68 +1,61 @@ import clsx from "clsx"; -import { ComponentProps } from "react"; +import {ComponentProps, useEffect, useState} from "react"; import ReactSelect from "react-select"; interface Option { - [key: string]: any; - value: string; - label: string; + [key: string]: any; + value: string; + label: string; } interface Props { - defaultValue?: Option; - value?: Option | null; - options: Option[]; - disabled?: boolean; - placeholder?: string; - onChange: (value: Option | null) => void; - isClearable?: boolean; + defaultValue?: Option; + value?: Option | null; + options: Option[]; + disabled?: boolean; + placeholder?: string; + onChange: (value: Option | null) => void; + isClearable?: boolean; } -export default function Select({ - value, - defaultValue, - options, - placeholder, - disabled, - onChange, - isClearable, -}: Props) { - return ( - ({ ...base, zIndex: 9999 }), - control: (styles) => ({ - ...styles, - paddingLeft: "4px", - border: "none", - outline: "none", - ":focus": { - outline: "none", - }, - }), - option: (styles, state) => ({ - ...styles, - backgroundColor: state.isFocused - ? "#D5D9F0" - : state.isSelected - ? "#7872BF" - : "white", - color: state.isFocused ? "black" : styles.color, - }), - }} - isDisabled={disabled} - isClearable={isClearable} - /> - ); +export default function Select({value, defaultValue, options, placeholder, disabled, onChange, isClearable}: Props) { + const [target, setTarget] = useState(); + + useEffect(() => { + if (document) setTarget(document.body); + }, []); + + return ( + ({...base, zIndex: 9999}), + control: (styles) => ({ + ...styles, + paddingLeft: "4px", + border: "none", + outline: "none", + ":focus": { + outline: "none", + }, + }), + option: (styles, state) => ({ + ...styles, + backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", + color: state.isFocused ? "black" : styles.color, + }), + }} + isDisabled={disabled} + isClearable={isClearable} + /> + ); } diff --git a/src/interfaces/exam.ts b/src/interfaces/exam.ts index ef74432d..6374d9ed 100644 --- a/src/interfaces/exam.ts +++ b/src/interfaces/exam.ts @@ -2,6 +2,7 @@ import {Module} from "."; export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam; export type Variant = "full" | "partial"; +export type InstructorGender = "male" | "female" | "varied"; export interface ReadingExam { parts: ReadingPart[]; @@ -82,7 +83,7 @@ export interface SpeakingExam { minTimer: number; isDiagnostic: boolean; variant?: Variant; - instructorGender: "male" | "female" | "varied"; + instructorGender: InstructorGender; } export type Exercise = diff --git a/src/interfaces/user.ts b/src/interfaces/user.ts index b14bf510..c5dc5ad3 100644 --- a/src/interfaces/user.ts +++ b/src/interfaces/user.ts @@ -1,4 +1,5 @@ import {Module} from "."; +import {InstructorGender} from "./exam"; export type User = StudentUser | TeacherUser | CorporateUser | AgentUser | AdminUser | DeveloperUser; @@ -21,6 +22,7 @@ export interface BasicUser { export interface StudentUser extends BasicUser { type: "student"; + preferredGender?: InstructorGender; demographicInformation?: DemographicInformation; } @@ -48,6 +50,7 @@ export interface AdminUser extends BasicUser { export interface DeveloperUser extends BasicUser { type: "developer"; + preferredGender?: InstructorGender; demographicInformation?: DemographicInformation; } diff --git a/src/pages/(exam)/ExamPage.tsx b/src/pages/(exam)/ExamPage.tsx index 7ac609c1..724a58f7 100644 --- a/src/pages/(exam)/ExamPage.tsx +++ b/src/pages/(exam)/ExamPage.tsx @@ -123,6 +123,7 @@ export default function ExamPage({page}: Props) { setSessionId(shortUID.randomUUID(8)); } }, [setSessionId, selectedModules, sessionId]); + useEffect(() => { if (user?.type === "developer") console.log(exam); }, [exam, user]); @@ -160,7 +161,14 @@ export default function ExamPage({page}: Props) { useEffect(() => { (async () => { if (selectedModules.length > 0 && exams.length === 0) { - const examPromises = selectedModules.map((module) => getExam(module, avoidRepeated, variant)); + const examPromises = selectedModules.map((module) => + getExam( + module, + avoidRepeated, + variant, + user?.type === "student" || user?.type === "developer" ? user.preferredGender : undefined, + ), + ); Promise.all(examPromises).then((values) => { if (values.every((x) => !!x)) { setExams(values.map((x) => x!)); diff --git a/src/pages/(generation)/SpeakingGeneration.tsx b/src/pages/(generation)/SpeakingGeneration.tsx index ab6bd22f..9585458e 100644 --- a/src/pages/(generation)/SpeakingGeneration.tsx +++ b/src/pages/(generation)/SpeakingGeneration.tsx @@ -1,5 +1,7 @@ import Input from "@/components/Low/Input"; +import Select from "@/components/Low/Select"; import {Exercise, InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise} from "@/interfaces/exam"; +import {AVATARS} from "@/resources/speakingAvatars"; import useExamStore from "@/stores/examStore"; import {getExamById} from "@/utils/exams"; import {playSound} from "@/utils/sound"; @@ -7,6 +9,7 @@ import {convertCamelCaseToReadable} from "@/utils/string"; import {Tab} from "@headlessui/react"; import axios from "axios"; import clsx from "clsx"; +import {capitalize, sample, uniq} from "lodash"; import moment from "moment"; import {useRouter} from "next/router"; import {useEffect, useState} from "react"; @@ -15,6 +18,7 @@ import {toast} from "react-toastify"; import {v4} from "uuid"; const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; setPart: (part?: SpeakingPart) => void}) => { + const [gender, setGender] = useState<"male" | "female">("male"); const [isLoading, setIsLoading] = useState(false); const generate = () => { @@ -39,17 +43,19 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se if (!part) return toast.error("Please generate the first part before generating the video!"); toast.info("This will take quite a while, please do not leave this page or close the tab/window."); + const avatar = sample(AVATARS.filter((x) => x.gender === gender)); + setIsLoading(true); const initialTime = moment(); axios - .post(`/api/exam/speaking/generate/speaking/generate_${index === 3 ? "interactive" : "speaking"}_video`, part) + .post(`/api/exam/speaking/generate/speaking/generate_${index === 3 ? "interactive" : "speaking"}_video`, {...part, avatar: avatar?.id}) .then((result) => { const isError = typeof result.data === "string" || moment().diff(initialTime, "seconds") < 60; playSound(isError ? "error" : "check"); if (isError) return toast.error("Something went wrong, please try to generate the video again."); - setPart({...part, result: result.data}); + setPart({...part, result: result.data, gender, avatar}); }) .catch((e) => { toast.error("Something went wrong!"); @@ -60,6 +66,18 @@ const PartTab = ({part, index, setPart}: {part?: SpeakingPart; index: number; se return ( +
+ + (value ? setPreferredGender(value.value as InstructorGender) : null)} + options={[ + {value: "male", label: "Male"}, + {value: "female", label: "Female"}, + {value: "varied", label: "Varied"}, + ]} + /> +
+ + )} + {user.type === "corporate" && ( diff --git a/src/resources/speakingAvatars.ts b/src/resources/speakingAvatars.ts new file mode 100644 index 00000000..ddff86ac --- /dev/null +++ b/src/resources/speakingAvatars.ts @@ -0,0 +1,37 @@ +export const AVATARS = [ + { + name: "Matthew Noah", + id: "5912afa7c77c47d3883af3d874047aaf", + gender: "male", + }, + { + name: "Vera Cerise", + id: "9e58d96a383e4568a7f1e49df549e0e4", + gender: "female", + }, + { + name: "Edward Tony", + id: "d2cdd9c0379a4d06ae2afb6e5039bd0c", + gender: "male", + }, + { + name: "Tanya Molly", + id: "045cb5dcd00042b3a1e4f3bc1c12176b", + gender: "female", + }, + { + name: "Kayla Abbi", + id: "1ae1e5396cc444bfad332155fdb7a934", + gender: "female", + }, + { + name: "Jerome Ryan", + id: "0ee6aa7cc1084063a630ae514fccaa31", + gender: "male", + }, + { + name: "Tyler Christopher", + id: "5772cff935844516ad7eeff21f839e43", + gender: "male", + }, +]; diff --git a/src/utils/exams.be.ts b/src/utils/exams.be.ts index b6d15244..76ee4d1e 100644 --- a/src/utils/exams.be.ts +++ b/src/utils/exams.be.ts @@ -1,6 +1,6 @@ import {collection, getDocs, query, where, setDoc, doc, Firestore} from "firebase/firestore"; import {shuffle} from "lodash"; -import {Exam, Variant} from "@/interfaces/exam"; +import {Exam, InstructorGender, Variant} from "@/interfaces/exam"; import {Stat} from "@/interfaces/user"; export const getExams = async ( @@ -12,22 +12,23 @@ export const getExams = async ( // by the teacher that performed the request userId: string | undefined, variant?: Variant, + instructorGender?: InstructorGender, ): Promise => { const moduleRef = collection(db, module); const q = query(moduleRef, where("isDiagnostic", "==", false)); const snapshot = await getDocs(q); - const exams: Exam[] = filterByVariant( - shuffle( - snapshot.docs.map((doc) => ({ - id: doc.id, - ...doc.data(), - module, - })), - ) as Exam[], - variant, - ); + const allExams = shuffle( + snapshot.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + module, + })), + ) as Exam[]; + + const variantExams: Exam[] = filterByVariant(allExams, variant); + const genderedExams: Exam[] = filterByInstructorGender(variantExams, instructorGender); if (avoidRepeated === "true") { const statsQ = query(collection(db, "stats"), where("user", "==", userId)); @@ -37,12 +38,18 @@ export const getExams = async ( id: doc.id, ...doc.data(), })) as unknown as Stat[]; - const filteredExams = exams.filter((x) => !stats.map((s) => s.exam).includes(x.id)); + const filteredExams = genderedExams.filter((x) => !stats.map((s) => s.exam).includes(x.id)); - return filteredExams.length > 0 ? filteredExams : exams; + return filteredExams.length > 0 ? filteredExams : genderedExams; } - return exams; + return genderedExams; +}; + +const filterByInstructorGender = (exams: Exam[], instructorGender?: InstructorGender) => { + if (!instructorGender || instructorGender === "varied") return exams; + + return exams.filter((e) => (e.module === "speaking" ? e.instructorGender === instructorGender : true)); }; const filterByVariant = (exams: Exam[], variant?: Variant) => { diff --git a/src/utils/exams.ts b/src/utils/exams.ts index db80b5d0..8a9d8fcf 100644 --- a/src/utils/exams.ts +++ b/src/utils/exams.ts @@ -1,11 +1,29 @@ import {Module} from "@/interfaces"; -import {Exam, ReadingExam, ListeningExam, WritingExam, SpeakingExam, Exercise, UserSolution, LevelExam, Variant} from "@/interfaces/exam"; +import { + Exam, + ReadingExam, + ListeningExam, + WritingExam, + SpeakingExam, + Exercise, + UserSolution, + LevelExam, + Variant, + InstructorGender, +} from "@/interfaces/exam"; import axios from "axios"; -export const getExam = async (module: Module, avoidRepeated: boolean, variant?: Variant): Promise => { +export const getExam = async ( + module: Module, + avoidRepeated: boolean, + variant?: Variant, + instructorGender?: InstructorGender, +): Promise => { const url = new URLSearchParams(); url.append("avoidRepeated", avoidRepeated.toString()); + if (variant) url.append("variant", variant); + if (module === "speaking" && instructorGender) url.append("instructorGender", instructorGender); const examRequest = await axios(`/api/exam/${module}?${url.toString()}`); if (examRequest.status !== 200) {