Merge branch 'develop'
This commit is contained in:
@@ -41,7 +41,7 @@ export default function Writing({
|
|||||||
if (inputText.length > 0 && saveTimer % 10 === 0) {
|
if (inputText.length > 0 && saveTimer % 10 === 0) {
|
||||||
setUserSolutions([
|
setUserSolutions([
|
||||||
...storeUserSolutions.filter((x) => x.exercise !== id),
|
...storeUserSolutions.filter((x) => x.exercise !== id),
|
||||||
{exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type},
|
{exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type, module: "writing"},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -64,7 +64,8 @@ export default function Writing({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type});
|
if (hasExamEnded)
|
||||||
|
onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type, module: "writing"});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [hasExamEnded]);
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
@@ -160,6 +161,7 @@ export default function Writing({
|
|||||||
solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}],
|
solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}],
|
||||||
score: {correct: 1, total: 1, missing: 0},
|
score: {correct: 1, total: 1, missing: 0},
|
||||||
type,
|
type,
|
||||||
|
module: "writing",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="max-w-[200px] self-end w-full">
|
className="max-w-[200px] self-end w-full">
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export default function Layout({user, children, className, navDisabled = false,
|
|||||||
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
|
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
|
||||||
className="-md:hidden"
|
className="-md:hidden"
|
||||||
userType={user.type}
|
userType={user.type}
|
||||||
|
userId={user.id}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
|
|||||||
const [timer, setTimer] = useState(minTimer * 60);
|
const [timer, setTimer] = useState(minTimer * 60);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [warningMode, setWarningMode] = useState(false);
|
const [warningMode, setWarningMode] = useState(false);
|
||||||
|
|
||||||
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
||||||
|
const {timeSpent} = useExamStore((state) => state);
|
||||||
|
|
||||||
|
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!disableTimer) {
|
if (!disableTimer) {
|
||||||
|
|||||||
70
src/components/Medium/TopicModal.tsx
Normal file
70
src/components/Medium/TopicModal.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import topics from "@/resources/topics";
|
||||||
|
import {useState} from "react";
|
||||||
|
import {BsArrowLeft, BsArrowRight} from "react-icons/bs";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
initialTopics: string[];
|
||||||
|
onClose: VoidFunction;
|
||||||
|
selectTopics: (topics: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TopicModal({isOpen, initialTopics, onClose, selectTopics}: Props) {
|
||||||
|
const [selectedTopics, setSelectedTopics] = useState([...initialTopics]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title="Preferred Topics">
|
||||||
|
<div className="flex flex-col w-full h-full gap-4 mt-4">
|
||||||
|
<div className="w-full h-full grid grid-cols-2 -md:gap-1 gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="border-b border-b-neutral-400/30">Available Topics</span>
|
||||||
|
<div className=" max-h-[500px] overflow-y-scroll scrollbar-hide">
|
||||||
|
{topics
|
||||||
|
.filter((x) => !selectedTopics.includes(x))
|
||||||
|
.map((x) => (
|
||||||
|
<div key={x} className="odd:bg-mti-purple-ultralight/40 p-2 flex justify-between items-center">
|
||||||
|
<span>{x}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedTopics((prev) => [...prev, x])}
|
||||||
|
className="border border-mti-purple-light cursor-pointer p-2 rounded-lg bg-white drop-shadow transition ease-in-out duration-300 hover:bg-mti-purple hover:text-white">
|
||||||
|
<BsArrowRight />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="border-b border-b-neutral-400/30">Preferred Topics ({selectedTopics.length || "All"})</span>
|
||||||
|
<div className=" max-h-[500px] overflow-y-scroll scrollbar-hide">
|
||||||
|
{selectedTopics.map((x) => (
|
||||||
|
<div key={x} className="odd:bg-mti-purple-ultralight/40 p-2 flex justify-between items-center text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedTopics((prev) => [...prev.filter((y) => y !== x)])}
|
||||||
|
className="border border-mti-purple-light cursor-pointer p-2 rounded-lg bg-white drop-shadow transition ease-in-out duration-300 hover:bg-mti-purple hover:text-white">
|
||||||
|
<BsArrowLeft />
|
||||||
|
</button>
|
||||||
|
<span>{x}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex gap-4 items-center justify-end">
|
||||||
|
<Button variant="outline" color="rose" className="w-full max-w-[200px]" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
onClick={() => {
|
||||||
|
selectTopics(selectedTopics);
|
||||||
|
onClose();
|
||||||
|
}}>
|
||||||
|
Select
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { IconType } from "react-icons";
|
import {IconType} from "react-icons";
|
||||||
import { MdSpaceDashboard } from "react-icons/md";
|
import {MdSpaceDashboard} from "react-icons/md";
|
||||||
import {
|
import {
|
||||||
BsFileEarmarkText,
|
BsFileEarmarkText,
|
||||||
BsClockHistory,
|
BsClockHistory,
|
||||||
@@ -13,17 +13,18 @@ import {
|
|||||||
BsCurrencyDollar,
|
BsCurrencyDollar,
|
||||||
BsClipboardData,
|
BsClipboardData,
|
||||||
} from "react-icons/bs";
|
} from "react-icons/bs";
|
||||||
import { RiLogoutBoxFill } from "react-icons/ri";
|
import {RiLogoutBoxFill} from "react-icons/ri";
|
||||||
import { SlPencil } from "react-icons/sl";
|
import {SlPencil} from "react-icons/sl";
|
||||||
import { FaAward } from "react-icons/fa";
|
import {FaAward} from "react-icons/fa";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import FocusLayer from "@/components/FocusLayer";
|
import FocusLayer from "@/components/FocusLayer";
|
||||||
import { preventNavigation } from "@/utils/navigation.disabled";
|
import {preventNavigation} from "@/utils/navigation.disabled";
|
||||||
import { useState } from "react";
|
import {useEffect, useState} from "react";
|
||||||
import usePreferencesStore from "@/stores/preferencesStore";
|
import usePreferencesStore from "@/stores/preferencesStore";
|
||||||
import { Type } from "@/interfaces/user";
|
import {Type} from "@/interfaces/user";
|
||||||
|
import useTicketsListener from "@/hooks/useTicketsListener";
|
||||||
interface Props {
|
interface Props {
|
||||||
path: string;
|
path: string;
|
||||||
navDisabled?: boolean;
|
navDisabled?: boolean;
|
||||||
@@ -31,6 +32,7 @@ interface Props {
|
|||||||
onFocusLayerMouseEnter?: () => void;
|
onFocusLayerMouseEnter?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
userType?: Type;
|
userType?: Type;
|
||||||
|
userId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavProps {
|
interface NavProps {
|
||||||
@@ -40,47 +42,44 @@ interface NavProps {
|
|||||||
keyPath: string;
|
keyPath: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isMinimized?: boolean;
|
isMinimized?: boolean;
|
||||||
|
badge?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Nav = ({
|
const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false, badge}: NavProps) => {
|
||||||
Icon,
|
return (
|
||||||
label,
|
|
||||||
path,
|
|
||||||
keyPath,
|
|
||||||
disabled = false,
|
|
||||||
isMinimized = false,
|
|
||||||
}: NavProps) => (
|
|
||||||
<Link
|
<Link
|
||||||
href={!disabled ? keyPath : ""}
|
href={!disabled ? keyPath : ""}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
|
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
|
||||||
"transition-all duration-300 ease-in-out",
|
"transition-all duration-300 ease-in-out relative",
|
||||||
disabled
|
disabled ? "hover:bg-mti-gray-dim cursor-not-allowed" : "hover:bg-mti-purple-light cursor-pointer",
|
||||||
? "hover:bg-mti-gray-dim cursor-not-allowed"
|
|
||||||
: "hover:bg-mti-purple-light cursor-pointer",
|
|
||||||
path === keyPath && "bg-mti-purple-light text-white",
|
path === keyPath && "bg-mti-purple-light text-white",
|
||||||
isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]",
|
isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]",
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
<Icon size={24} />
|
<Icon size={24} />
|
||||||
{!isMinimized && <span className="text-lg font-semibold">{label}</span>}
|
{!isMinimized && <span className="text-lg font-semibold">{label}</span>}
|
||||||
|
{!!badge && badge > 0 && (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-light h-5 w-5 text-xs rounded-full flex items-center justify-center text-white",
|
||||||
|
"transition ease-in-out duration-300",
|
||||||
|
isMinimized && "absolute right-0 top-0",
|
||||||
|
)}>
|
||||||
|
{badge}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default function Sidebar({
|
export default function Sidebar({path, navDisabled = false, focusMode = false, userType, onFocusLayerMouseEnter, className, userId}: Props) {
|
||||||
path,
|
|
||||||
navDisabled = false,
|
|
||||||
focusMode = false,
|
|
||||||
userType,
|
|
||||||
onFocusLayerMouseEnter,
|
|
||||||
className,
|
|
||||||
}: Props) {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [
|
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
|
||||||
state.isSidebarMinimized,
|
|
||||||
state.toggleSidebarMinimized,
|
const {totalAssignedTickets} = useTicketsListener(userId);
|
||||||
]);
|
|
||||||
|
useEffect(() => console.log(totalAssignedTickets), [totalAssignedTickets]);
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
axios.post("/api/logout").finally(() => {
|
axios.post("/api/logout").finally(() => {
|
||||||
@@ -96,20 +95,10 @@ export default function Sidebar({
|
|||||||
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
|
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
|
||||||
isMinimized ? "w-fit" : "-xl:w-fit w-1/6",
|
isMinimized ? "w-fit" : "-xl:w-fit w-1/6",
|
||||||
className,
|
className,
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
<div className="-xl:hidden flex-col gap-3 xl:flex">
|
<div className="-xl:hidden flex-col gap-3 xl:flex">
|
||||||
<Nav
|
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
|
||||||
disabled={disableNavigation}
|
{(userType === "student" || userType === "teacher" || userType === "developer") && (
|
||||||
Icon={MdSpaceDashboard}
|
|
||||||
label="Dashboard"
|
|
||||||
path={path}
|
|
||||||
keyPath="/"
|
|
||||||
isMinimized={isMinimized}
|
|
||||||
/>
|
|
||||||
{(userType === "student" ||
|
|
||||||
userType === "teacher" ||
|
|
||||||
userType === "developer") && (
|
|
||||||
<>
|
<>
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
@@ -129,25 +118,9 @@ export default function Sidebar({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Nav
|
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
|
||||||
disabled={disableNavigation}
|
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
|
||||||
Icon={BsGraphUp}
|
{["admin", "developer", "agent", "corporate"].includes(userType || "") && (
|
||||||
label="Stats"
|
|
||||||
path={path}
|
|
||||||
keyPath="/stats"
|
|
||||||
isMinimized={isMinimized}
|
|
||||||
/>
|
|
||||||
<Nav
|
|
||||||
disabled={disableNavigation}
|
|
||||||
Icon={BsClockHistory}
|
|
||||||
label="Record"
|
|
||||||
path={path}
|
|
||||||
keyPath="/record"
|
|
||||||
isMinimized={isMinimized}
|
|
||||||
/>
|
|
||||||
{["admin", "developer", "agent", "corporate"].includes(
|
|
||||||
userType || "",
|
|
||||||
) && (
|
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
Icon={BsCurrencyDollar}
|
Icon={BsCurrencyDollar}
|
||||||
@@ -157,9 +130,7 @@ export default function Sidebar({
|
|||||||
isMinimized={isMinimized}
|
isMinimized={isMinimized}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{["admin", "developer", "corporate", "teacher"].includes(
|
{["admin", "developer", "corporate", "teacher"].includes(userType || "") && (
|
||||||
userType || "",
|
|
||||||
) && (
|
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
Icon={BsShieldFill}
|
Icon={BsShieldFill}
|
||||||
@@ -177,6 +148,7 @@ export default function Sidebar({
|
|||||||
path={path}
|
path={path}
|
||||||
keyPath="/tickets"
|
keyPath="/tickets"
|
||||||
isMinimized={isMinimized}
|
isMinimized={isMinimized}
|
||||||
|
badge={totalAssignedTickets}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{userType === "developer" && (
|
{userType === "developer" && (
|
||||||
@@ -191,65 +163,16 @@ export default function Sidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
||||||
<Nav
|
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
|
||||||
disabled={disableNavigation}
|
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
|
||||||
Icon={MdSpaceDashboard}
|
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
|
||||||
label="Dashboard"
|
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
|
||||||
path={path}
|
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
|
||||||
keyPath="/"
|
|
||||||
isMinimized={true}
|
|
||||||
/>
|
|
||||||
<Nav
|
|
||||||
disabled={disableNavigation}
|
|
||||||
Icon={BsFileEarmarkText}
|
|
||||||
label="Exams"
|
|
||||||
path={path}
|
|
||||||
keyPath="/exam"
|
|
||||||
isMinimized={true}
|
|
||||||
/>
|
|
||||||
<Nav
|
|
||||||
disabled={disableNavigation}
|
|
||||||
Icon={BsPencil}
|
|
||||||
label="Exercises"
|
|
||||||
path={path}
|
|
||||||
keyPath="/exercises"
|
|
||||||
isMinimized={true}
|
|
||||||
/>
|
|
||||||
<Nav
|
|
||||||
disabled={disableNavigation}
|
|
||||||
Icon={BsGraphUp}
|
|
||||||
label="Stats"
|
|
||||||
path={path}
|
|
||||||
keyPath="/stats"
|
|
||||||
isMinimized={true}
|
|
||||||
/>
|
|
||||||
<Nav
|
|
||||||
disabled={disableNavigation}
|
|
||||||
Icon={BsClockHistory}
|
|
||||||
label="Record"
|
|
||||||
path={path}
|
|
||||||
keyPath="/record"
|
|
||||||
isMinimized={true}
|
|
||||||
/>
|
|
||||||
{userType !== "student" && (
|
{userType !== "student" && (
|
||||||
<Nav
|
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} />
|
||||||
disabled={disableNavigation}
|
|
||||||
Icon={BsShieldFill}
|
|
||||||
label="Settings"
|
|
||||||
path={path}
|
|
||||||
keyPath="/settings"
|
|
||||||
isMinimized={true}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{userType === "developer" && (
|
{userType === "developer" && (
|
||||||
<Nav
|
<Nav disabled={disableNavigation} Icon={BsCloudFill} label="Generation" path={path} keyPath="/generation" isMinimized={true} />
|
||||||
disabled={disableNavigation}
|
|
||||||
Icon={BsCloudFill}
|
|
||||||
label="Generation"
|
|
||||||
path={path}
|
|
||||||
keyPath="/generation"
|
|
||||||
isMinimized={true}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -261,16 +184,9 @@ export default function Sidebar({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"hover:text-mti-rose -xl:hidden flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
"hover:text-mti-rose -xl:hidden flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
||||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
||||||
)}
|
)}>
|
||||||
>
|
{isMinimized ? <BsChevronBarRight size={24} /> : <BsChevronBarLeft size={24} />}
|
||||||
{isMinimized ? (
|
{!isMinimized && <span className="text-lg font-medium">Minimize</span>}
|
||||||
<BsChevronBarRight size={24} />
|
|
||||||
) : (
|
|
||||||
<BsChevronBarLeft size={24} />
|
|
||||||
)}
|
|
||||||
{!isMinimized && (
|
|
||||||
<span className="text-lg font-medium">Minimize</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
@@ -279,17 +195,12 @@ export default function Sidebar({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
||||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
<RiLogoutBoxFill size={24} />
|
<RiLogoutBoxFill size={24} />
|
||||||
{!isMinimized && (
|
{!isMinimized && <span className="-xl:hidden text-lg font-medium">Log Out</span>}
|
||||||
<span className="-xl:hidden text-lg font-medium">Log Out</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{focusMode && (
|
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||||
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const INSTRUCTIONS_AUDIO_SRC =
|
const INSTRUCTIONS_AUDIO_SRC =
|
||||||
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_recordings%2Fgeneric_intro.mp3?alt=media&token=9b9cfdb8-e90d-40d1-854b-51c4378a5c4b";
|
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/generic_listening_intro_v2.mp3?alt=media&token=16769f5f-1e9b-4a72-86a9-45a6f0fa9f82";
|
||||||
|
|
||||||
export default function Listening({exam, showSolutions = false, onFinish}: Props) {
|
export default function Listening({exam, showSolutions = false, onFinish}: Props) {
|
||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
const [questionIndex, setQuestionIndex] = useState(0);
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import { Ticket } from "@/interfaces/ticket";
|
import { Ticket } from "@/interfaces/ticket";
|
||||||
import { Code, Group, User } from "@/interfaces/user";
|
import { Code, Group, User } from "@/interfaces/user";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
|
||||||
export default function useTickets() {
|
export default function useTickets() {
|
||||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isError, setIsError] = useState(false);
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
const getData = () => {
|
const getData = useCallback(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.get<Ticket[]>(`/api/tickets`)
|
.get<Ticket[]>(`/api/tickets`)
|
||||||
.then((response) => setTickets(response.data))
|
.then((response) => setTickets(response.data))
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
useEffect(getData, []);
|
useEffect(getData, [getData]);
|
||||||
|
|
||||||
return { tickets, isLoading, isError, reload: getData };
|
return { tickets, isLoading, isError, reload: getData };
|
||||||
}
|
}
|
||||||
|
|||||||
29
src/hooks/useTicketsListener.tsx
Normal file
29
src/hooks/useTicketsListener.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import React from "react";
|
||||||
|
import useTickets from "./useTickets";
|
||||||
|
|
||||||
|
const useTicketsListener = (userId?: string) => {
|
||||||
|
const { tickets, reload } = useTickets();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
reload();
|
||||||
|
}, 60 * 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
const assignedTickets = tickets.filter(
|
||||||
|
(ticket) => ticket.assignedTo === userId && ticket.status === "submitted"
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
assignedTickets,
|
||||||
|
totalAssignedTickets: assignedTickets.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useTicketsListener;
|
||||||
@@ -69,7 +69,7 @@ export interface UserSolution {
|
|||||||
export interface WritingExam {
|
export interface WritingExam {
|
||||||
module: "writing";
|
module: "writing";
|
||||||
id: string;
|
id: string;
|
||||||
exercises: Exercise[];
|
exercises: WritingExercise[];
|
||||||
minTimer: number;
|
minTimer: number;
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
@@ -84,7 +84,7 @@ interface WordCounter {
|
|||||||
export interface SpeakingExam {
|
export interface SpeakingExam {
|
||||||
id: string;
|
id: string;
|
||||||
module: "speaking";
|
module: "speaking";
|
||||||
exercises: Exercise[];
|
exercises: (SpeakingExercise | InteractiveSpeakingExercise)[];
|
||||||
minTimer: number;
|
minTimer: number;
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
@@ -148,6 +148,7 @@ export interface WritingExercise {
|
|||||||
solution: string;
|
solution: string;
|
||||||
evaluation?: CommonEvaluation;
|
evaluation?: CommonEvaluation;
|
||||||
}[];
|
}[];
|
||||||
|
topic?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpeakingExercise {
|
export interface SpeakingExercise {
|
||||||
@@ -162,6 +163,7 @@ export interface SpeakingExercise {
|
|||||||
solution: string;
|
solution: string;
|
||||||
evaluation?: SpeakingEvaluation;
|
evaluation?: SpeakingEvaluation;
|
||||||
}[];
|
}[];
|
||||||
|
topic?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InteractiveSpeakingExercise {
|
export interface InteractiveSpeakingExercise {
|
||||||
@@ -175,6 +177,7 @@ export interface InteractiveSpeakingExercise {
|
|||||||
solution: {questionIndex: number; question: string; answer: string}[];
|
solution: {questionIndex: number; question: string; answer: string}[];
|
||||||
evaluation?: InteractiveSpeakingEvaluation;
|
evaluation?: InteractiveSpeakingEvaluation;
|
||||||
}[];
|
}[];
|
||||||
|
topic?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FillBlanksExercise {
|
export interface FillBlanksExercise {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface StudentUser extends BasicUser {
|
|||||||
type: "student";
|
type: "student";
|
||||||
preferredGender?: InstructorGender;
|
preferredGender?: InstructorGender;
|
||||||
demographicInformation?: DemographicInformation;
|
demographicInformation?: DemographicInformation;
|
||||||
|
preferredTopics?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeacherUser extends BasicUser {
|
export interface TeacherUser extends BasicUser {
|
||||||
@@ -52,6 +53,7 @@ export interface DeveloperUser extends BasicUser {
|
|||||||
type: "developer";
|
type: "developer";
|
||||||
preferredGender?: InstructorGender;
|
preferredGender?: InstructorGender;
|
||||||
demographicInformation?: DemographicInformation;
|
demographicInformation?: DemographicInformation;
|
||||||
|
preferredTopics?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CorporateInformation {
|
export interface CorporateInformation {
|
||||||
|
|||||||
@@ -336,12 +336,13 @@ export default function ExamPage({page}: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
answers.forEach((x) => {
|
answers.forEach((x) => {
|
||||||
console.log({x});
|
const examModule =
|
||||||
|
x.module || (x.type === "writing" ? "writing" : x.type === "speaking" || x.type === "interactiveSpeaking" ? "speaking" : undefined);
|
||||||
|
|
||||||
scores[x.module!] = {
|
scores[examModule!] = {
|
||||||
total: scores[x.module!].total + x.score.total,
|
total: scores[examModule!].total + x.score.total,
|
||||||
correct: scores[x.module!].correct + x.score.correct,
|
correct: scores[examModule!].correct + x.score.correct,
|
||||||
missing: scores[x.module!].missing + x.score.missing,
|
missing: scores[examModule!].missing + x.score.missing,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ const PartTab = ({
|
|||||||
|
|
||||||
playSound(isError ? "error" : "check");
|
playSound(isError ? "error" : "check");
|
||||||
if (isError) return toast.error("Something went wrong, please try to generate the video again.");
|
if (isError) return toast.error("Something went wrong, please try to generate the video again.");
|
||||||
setPart({...part, result: result.data, gender, avatar});
|
setPart({...part, result: {...result.data, topic: part?.topic}, gender, avatar});
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast.error("Something went wrong!");
|
toast.error("Something went wrong!");
|
||||||
@@ -79,6 +79,8 @@ const PartTab = ({
|
|||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => console.log(part), [part]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-speaking/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {CorporateUser, EmploymentStatus, EMPLOYMENT_STATUS, Gender, User} from "
|
|||||||
import CountrySelect from "@/components/Low/CountrySelect";
|
import CountrySelect from "@/components/Low/CountrySelect";
|
||||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {BsCamera} from "react-icons/bs";
|
import {BsCamera, BsQuestionCircleFill} from "react-icons/bs";
|
||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
@@ -31,6 +31,8 @@ import ModuleLevelSelector from "@/components/Medium/ModuleLevelSelector";
|
|||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
import {InstructorGender} from "@/interfaces/exam";
|
import {InstructorGender} from "@/interfaces/exam";
|
||||||
import {capitalize} from "lodash";
|
import {capitalize} from "lodash";
|
||||||
|
import TopicModal from "@/components/Medium/TopicModal";
|
||||||
|
import {v4} from "uuid";
|
||||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
|
|
||||||
@@ -90,6 +92,9 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
const [preferredGender, setPreferredGender] = useState<InstructorGender | undefined>(
|
const [preferredGender, setPreferredGender] = useState<InstructorGender | undefined>(
|
||||||
user.type === "student" || user.type === "developer" ? user.preferredGender || "varied" : undefined,
|
user.type === "student" || user.type === "developer" ? user.preferredGender || "varied" : undefined,
|
||||||
);
|
);
|
||||||
|
const [preferredTopics, setPreferredTopics] = useState<string[] | undefined>(
|
||||||
|
user.type === "student" || user.type === "developer" ? user.preferredTopics : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined);
|
const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined);
|
||||||
const [corporateInformation, setCorporateInformation] = useState(user.type === "corporate" ? user.corporateInformation : undefined);
|
const [corporateInformation, setCorporateInformation] = useState(user.type === "corporate" ? user.corporateInformation : undefined);
|
||||||
@@ -99,6 +104,8 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
);
|
);
|
||||||
const [timezone, setTimezone] = useState<string>(user.demographicInformation?.timezone || moment.tz.guess());
|
const [timezone, setTimezone] = useState<string>(user.demographicInformation?.timezone || moment.tz.guess());
|
||||||
|
|
||||||
|
const [isPreferredTopicsOpen, setIsPreferredTopicsOpen] = useState(false);
|
||||||
|
|
||||||
const {groups} = useGroups();
|
const {groups} = useGroups();
|
||||||
const {users} = useUsers();
|
const {users} = useUsers();
|
||||||
|
|
||||||
@@ -156,6 +163,7 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
profilePicture,
|
profilePicture,
|
||||||
desiredLevels,
|
desiredLevels,
|
||||||
preferredGender,
|
preferredGender,
|
||||||
|
preferredTopics,
|
||||||
demographicInformation: {
|
demographicInformation: {
|
||||||
phone,
|
phone,
|
||||||
country,
|
country,
|
||||||
@@ -350,6 +358,7 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
{preferredGender && ["developer", "student"].includes(user.type) && (
|
{preferredGender && ["developer", "student"].includes(user.type) && (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
<DoubleColumnRow>
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor's Gender</label>
|
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor's Gender</label>
|
||||||
<Select
|
<Select
|
||||||
@@ -362,6 +371,28 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim flex gap-2 items-center">
|
||||||
|
Preferred Topics{" "}
|
||||||
|
<span
|
||||||
|
className="tooltip"
|
||||||
|
data-tip="These topics will be considered for speaking and writing modules, aiming to include at least one exercise containing of the these in the selected exams.">
|
||||||
|
<BsQuestionCircleFill />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<Button className="w-full" variant="outline" onClick={() => setIsPreferredTopicsOpen(true)}>
|
||||||
|
Select Topics ({preferredTopics?.length || "All"} selected)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DoubleColumnRow>
|
||||||
|
|
||||||
|
<TopicModal
|
||||||
|
key={v4()}
|
||||||
|
isOpen={isPreferredTopicsOpen}
|
||||||
|
onClose={() => setIsPreferredTopicsOpen(false)}
|
||||||
|
selectTopics={setPreferredTopics}
|
||||||
|
initialTopics={preferredTopics || []}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
55
src/resources/topics.ts
Normal file
55
src/resources/topics.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
const topics = [
|
||||||
|
"Education",
|
||||||
|
"Technology",
|
||||||
|
"Environment",
|
||||||
|
"Health and Fitness",
|
||||||
|
"Globalization",
|
||||||
|
"Engineering",
|
||||||
|
"Work and Careers",
|
||||||
|
"Travel and Tourism",
|
||||||
|
"Culture and Traditions",
|
||||||
|
"Social Issues",
|
||||||
|
"Arts and Entertainment",
|
||||||
|
"Climate Change",
|
||||||
|
"Social Media",
|
||||||
|
"Sustainable Development",
|
||||||
|
"Health Care",
|
||||||
|
"Immigration",
|
||||||
|
"Artificial Intelligence",
|
||||||
|
"Consumerism",
|
||||||
|
"Online Shopping",
|
||||||
|
"Energy",
|
||||||
|
"Oil and Gas",
|
||||||
|
"Poverty and Inequality",
|
||||||
|
"Cultural Diversity",
|
||||||
|
"Democracy and Governance",
|
||||||
|
"Mental Health",
|
||||||
|
"Ethics and Morality",
|
||||||
|
"Population Growth",
|
||||||
|
"Science and Innovation",
|
||||||
|
"Poverty Alleviation",
|
||||||
|
"Cybersecurity and Privacy",
|
||||||
|
"Human Rights",
|
||||||
|
"Social Justice",
|
||||||
|
"Food and Agriculture",
|
||||||
|
"Cyberbullying and Online Safety",
|
||||||
|
"Linguistic Diversity",
|
||||||
|
"Urbanization",
|
||||||
|
"Artificial Intelligence in Education",
|
||||||
|
"Youth Empowerment",
|
||||||
|
"Disaster Management",
|
||||||
|
"Mental Health Stigma",
|
||||||
|
"Internet Censorship",
|
||||||
|
"Sustainable Fashion",
|
||||||
|
"Indigenous Rights",
|
||||||
|
"Water Scarcity",
|
||||||
|
"Social Entrepreneurship",
|
||||||
|
"Privacy in the Digital Age",
|
||||||
|
"Sustainable Transportation",
|
||||||
|
"Gender Equality",
|
||||||
|
"Automation and Job Displacement",
|
||||||
|
"Digital Divide",
|
||||||
|
"Education Inequality",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default topics;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import {collection, getDocs, query, where, setDoc, doc, Firestore, getDoc} from "firebase/firestore";
|
import {collection, getDocs, query, where, setDoc, doc, Firestore, getDoc} from "firebase/firestore";
|
||||||
import {shuffle} from "lodash";
|
import {shuffle} from "lodash";
|
||||||
import {Difficulty, Exam, InstructorGender, Variant} from "@/interfaces/exam";
|
import {Difficulty, Exam, InstructorGender, SpeakingExam, Variant, WritingExam} from "@/interfaces/exam";
|
||||||
import {Stat, User} from "@/interfaces/user";
|
import {DeveloperUser, Stat, StudentUser, User} from "@/interfaces/user";
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
|
|
||||||
export const getExams = async (
|
export const getExams = async (
|
||||||
@@ -28,9 +28,10 @@ export const getExams = async (
|
|||||||
})),
|
})),
|
||||||
) as Exam[];
|
) as Exam[];
|
||||||
|
|
||||||
const variantExams: Exam[] = filterByVariant(allExams, variant);
|
let exams: Exam[] = filterByVariant(allExams, variant);
|
||||||
const genderedExams: Exam[] = filterByInstructorGender(variantExams, instructorGender);
|
exams = filterByInstructorGender(exams, instructorGender);
|
||||||
const difficultyExams: Exam[] = await filterByDifficulty(db, genderedExams, module, userId);
|
exams = await filterByDifficulty(db, exams, module, userId);
|
||||||
|
exams = await filterByPreference(db, exams, module, userId);
|
||||||
|
|
||||||
if (avoidRepeated === "true") {
|
if (avoidRepeated === "true") {
|
||||||
const statsQ = query(collection(db, "stats"), where("user", "==", userId));
|
const statsQ = query(collection(db, "stats"), where("user", "==", userId));
|
||||||
@@ -40,12 +41,12 @@ export const getExams = async (
|
|||||||
id: doc.id,
|
id: doc.id,
|
||||||
...doc.data(),
|
...doc.data(),
|
||||||
})) as unknown as Stat[];
|
})) as unknown as Stat[];
|
||||||
const filteredExams = difficultyExams.filter((x) => !stats.map((s) => s.exam).includes(x.id));
|
const filteredExams = exams.filter((x) => !stats.map((s) => s.exam).includes(x.id));
|
||||||
|
|
||||||
return filteredExams.length > 0 ? filteredExams : difficultyExams;
|
return filteredExams.length > 0 ? filteredExams : exams;
|
||||||
}
|
}
|
||||||
|
|
||||||
return difficultyExams;
|
return exams;
|
||||||
};
|
};
|
||||||
|
|
||||||
const filterByInstructorGender = (exams: Exam[], instructorGender?: InstructorGender) => {
|
const filterByInstructorGender = (exams: Exam[], instructorGender?: InstructorGender) => {
|
||||||
@@ -70,3 +71,25 @@ const filterByDifficulty = async (db: Firestore, exams: Exam[], module: Module,
|
|||||||
const filteredExams = exams.filter((exam) => exam.difficulty === difficulty);
|
const filteredExams = exams.filter((exam) => exam.difficulty === difficulty);
|
||||||
return filteredExams.length === 0 ? exams : filteredExams;
|
return filteredExams.length === 0 ? exams : filteredExams;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filterByPreference = async (db: Firestore, exams: Exam[], module: Module, userID?: string) => {
|
||||||
|
if (!["speaking", "writing"].includes(module)) return exams;
|
||||||
|
|
||||||
|
if (!userID) return exams;
|
||||||
|
const userRef = await getDoc(doc(db, "users", userID));
|
||||||
|
if (!userRef.exists()) return exams;
|
||||||
|
|
||||||
|
const user = {...userRef.data(), id: userRef.id} as StudentUser | DeveloperUser;
|
||||||
|
if (!["developer", "student"].includes(user.type)) return exams;
|
||||||
|
if (!user.preferredTopics || user.preferredTopics.length === 0) return exams;
|
||||||
|
|
||||||
|
const userTopics = user.preferredTopics;
|
||||||
|
const topicalExams = exams.filter((e) => {
|
||||||
|
const exam = e as WritingExam | SpeakingExam;
|
||||||
|
const topics = exam.exercises.map((x) => x.topic).filter((x) => !!x) as string[];
|
||||||
|
|
||||||
|
return topics.some((topic) => userTopics.map((x) => x.toLowerCase()).includes(topic.toLowerCase()));
|
||||||
|
});
|
||||||
|
|
||||||
|
return topicalExams.length > 0 ? shuffle(topicalExams) : exams;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user