326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
/* eslint-disable @next/next/no-img-element */
|
|
import Head from "next/head";
|
|
import { withIronSessionSsr } from "iron-session/next";
|
|
import { sessionOptions } from "@/lib/session";
|
|
import { ToastContainer } from "react-toastify";
|
|
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
|
import { Radio, RadioGroup } from "@headlessui/react";
|
|
import clsx from "clsx";
|
|
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
|
import { capitalize } from "lodash";
|
|
import Input from "@/components/Low/Input";
|
|
import {
|
|
findAllowedEntities,
|
|
findAllowedEntitiesSomePermissions,
|
|
groupAllowedEntitiesByPermissions,
|
|
} from "@/utils/permissions";
|
|
import { User } from "@/interfaces/user";
|
|
import useExamEditorStore from "@/stores/examEditor";
|
|
import ExamEditorStore from "@/stores/examEditor/types";
|
|
import ExamEditor from "@/components/ExamEditor";
|
|
import { mapBy, redirect, serialize } from "@/utils";
|
|
import { requestUser } from "@/utils/api";
|
|
import { Module } from "@/interfaces";
|
|
import { getExam } from "@/utils/exams.be";
|
|
import {
|
|
Exam,
|
|
Exercise,
|
|
InteractiveSpeakingExercise,
|
|
ListeningPart,
|
|
SpeakingExercise,
|
|
} from "@/interfaces/exam";
|
|
import { useEffect, useState } from "react";
|
|
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
|
import { isAdmin } from "@/utils/users";
|
|
import axios from "axios";
|
|
import { EntityWithRoles } from "@/interfaces/entity";
|
|
|
|
type Permission = { [key in Module]: boolean };
|
|
|
|
export const getServerSideProps = withIronSessionSsr(
|
|
async ({ req, res, query }) => {
|
|
const user = await requestUser(req, res);
|
|
if (!user) return redirect("/login");
|
|
|
|
if (shouldRedirectHome(user)) return redirect("/");
|
|
|
|
const entityIDs = mapBy(user.entities, "id");
|
|
|
|
const entities = await getEntitiesWithRoles(
|
|
isAdmin(user) ? undefined : entityIDs
|
|
);
|
|
|
|
const generatePermissions = groupAllowedEntitiesByPermissions(
|
|
user,
|
|
entities,
|
|
[
|
|
"generate_reading",
|
|
"generate_listening",
|
|
"generate_writing",
|
|
"generate_speaking",
|
|
"generate_level",
|
|
]
|
|
);
|
|
|
|
const permissions: Permission = {
|
|
reading: generatePermissions["generate_reading"].length > 0,
|
|
listening: generatePermissions["generate_listening"].length > 0,
|
|
writing: generatePermissions["generate_writing"].length > 0,
|
|
speaking: generatePermissions["generate_speaking"].length > 0,
|
|
level: generatePermissions["generate_level"].length > 0,
|
|
};
|
|
|
|
const {
|
|
["update_exam_privacy"]: entitiesAllowEditPrivacy,
|
|
["create_confidential_exams"]: entitiesAllowConfExams,
|
|
["create_public_exams"]: entitiesAllowPublicExams,
|
|
} = groupAllowedEntitiesByPermissions(user, entities, [
|
|
"update_exam_privacy",
|
|
"create_confidential_exams",
|
|
"create_public_exams",
|
|
]);
|
|
|
|
if (Object.keys(permissions).every((p) => !permissions[p as Module]))
|
|
return redirect("/");
|
|
|
|
const { id, module: examModule } = query as {
|
|
id?: string;
|
|
module?: Module;
|
|
};
|
|
if (!id || !examModule) return { props: serialize({ user, permissions }) };
|
|
|
|
//if (!permissions[module]) return redirect("/generation")
|
|
|
|
const exam = await getExam(examModule, id);
|
|
if (!exam) return redirect("/generation");
|
|
|
|
return {
|
|
props: serialize({
|
|
id,
|
|
user,
|
|
exam,
|
|
examModule,
|
|
permissions,
|
|
entitiesAllowEditPrivacy,
|
|
entitiesAllowConfExams,
|
|
entitiesAllowPublicExams,
|
|
}),
|
|
};
|
|
},
|
|
sessionOptions
|
|
);
|
|
|
|
export default function Generation({
|
|
id,
|
|
user,
|
|
exam,
|
|
examModule,
|
|
permissions,
|
|
entitiesAllowEditPrivacy,
|
|
entitiesAllowConfExams,
|
|
entitiesAllowPublicExams,
|
|
}: {
|
|
id: string;
|
|
user: User;
|
|
exam?: Exam;
|
|
examModule?: Module;
|
|
permissions: Permission;
|
|
entitiesAllowEditPrivacy: EntityWithRoles[];
|
|
entitiesAllowPublicExams: EntityWithRoles[];
|
|
entitiesAllowConfExams: EntityWithRoles[];
|
|
}) {
|
|
const { title, currentModule, modules, dispatch } = useExamEditorStore();
|
|
const [examLevelParts, setExamLevelParts] = useState<number | undefined>(
|
|
undefined
|
|
);
|
|
|
|
const updateRoot = (updates: Partial<ExamEditorStore>) => {
|
|
dispatch({ type: "UPDATE_ROOT", payload: { updates } });
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (id && exam && examModule) {
|
|
if (examModule === "level" && exam.module === "level") {
|
|
setExamLevelParts(exam.parts.length);
|
|
}
|
|
updateRoot({ currentModule: examModule });
|
|
dispatch({ type: "INIT_EXAM_EDIT", payload: { exam, id, examModule } });
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [id, exam, module]);
|
|
|
|
useEffect(() => {
|
|
const fetchAvatars = async () => {
|
|
const response = await axios.get("/api/exam/avatars");
|
|
updateRoot({ speakingAvatars: response.data });
|
|
};
|
|
|
|
fetchAvatars();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// media cleanup on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
const state = modules;
|
|
|
|
if (state.writing.academic_url) {
|
|
URL.revokeObjectURL(state.writing.academic_url);
|
|
}
|
|
|
|
state.listening.sections.forEach((section) => {
|
|
const listeningPart = section.state as ListeningPart;
|
|
if (listeningPart.audio?.source) {
|
|
URL.revokeObjectURL(listeningPart.audio.source);
|
|
dispatch({
|
|
type: "UPDATE_SECTION_SINGLE_FIELD",
|
|
payload: {
|
|
sectionId: section.sectionId,
|
|
module: "listening",
|
|
field: "state",
|
|
value: { ...listeningPart, audio: undefined },
|
|
},
|
|
});
|
|
}
|
|
});
|
|
|
|
if (
|
|
state.listening.instructionsState.customInstructionsURL.startsWith(
|
|
"blob:"
|
|
)
|
|
) {
|
|
URL.revokeObjectURL(
|
|
state.listening.instructionsState.customInstructionsURL
|
|
);
|
|
}
|
|
|
|
state.speaking.sections.forEach((section) => {
|
|
const sectionState = section.state as Exercise;
|
|
if (sectionState.type === "speaking") {
|
|
const speakingExercise = sectionState as SpeakingExercise;
|
|
URL.revokeObjectURL(speakingExercise.video_url);
|
|
dispatch({
|
|
type: "UPDATE_SECTION_SINGLE_FIELD",
|
|
payload: {
|
|
sectionId: section.sectionId,
|
|
module: "listening",
|
|
field: "state",
|
|
value: { ...speakingExercise, video_url: undefined },
|
|
},
|
|
});
|
|
}
|
|
if (sectionState.type === "interactiveSpeaking") {
|
|
const interactiveSpeaking =
|
|
sectionState as InteractiveSpeakingExercise;
|
|
interactiveSpeaking.prompts.forEach((prompt) => {
|
|
URL.revokeObjectURL(prompt.video_url);
|
|
});
|
|
dispatch({
|
|
type: "UPDATE_SECTION_SINGLE_FIELD",
|
|
payload: {
|
|
sectionId: section.sectionId,
|
|
module: "listening",
|
|
field: "state",
|
|
value: {
|
|
...interactiveSpeaking,
|
|
prompts: interactiveSpeaking.prompts.map((p) => ({
|
|
...p,
|
|
video_url: undefined,
|
|
})),
|
|
},
|
|
},
|
|
});
|
|
}
|
|
});
|
|
dispatch({ type: "FULL_RESET" });
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>Exam Generation | EnCoach</title>
|
|
<meta
|
|
name="description"
|
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
|
/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<link rel="icon" href="/favicon.ico" />
|
|
</Head>
|
|
<ToastContainer />
|
|
{user && (
|
|
<>
|
|
<h1 className="text-2xl font-semibold">Exam Editor</h1>
|
|
<div className="flex flex-col gap-3">
|
|
<Input
|
|
type="text"
|
|
placeholder="Insert a title here"
|
|
name="title"
|
|
label="Title"
|
|
onChange={(title) => updateRoot({ title })}
|
|
roundness="xl"
|
|
value={title}
|
|
defaultValue={title}
|
|
required
|
|
/>
|
|
<label className="font-normal text-base text-mti-gray-dim">
|
|
Module
|
|
</label>
|
|
<RadioGroup
|
|
value={currentModule}
|
|
onChange={(currentModule) => updateRoot({ currentModule })}
|
|
className="flex flex-row flex-wrap w-full gap-4 -md:justify-center justify-between"
|
|
>
|
|
{[...MODULE_ARRAY].reduce((acc, x) => {
|
|
if (permissions[x])
|
|
acc.push(
|
|
<Radio value={x} key={x}>
|
|
{({ checked }) => (
|
|
<span
|
|
className={clsx(
|
|
"px-6 py-4 w-64 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
|
"transition duration-300 ease-in-out",
|
|
x === "reading" &&
|
|
(!checked
|
|
? "bg-white border-mti-gray-platinum"
|
|
: "bg-ielts-reading/70 border-ielts-reading text-white"),
|
|
x === "listening" &&
|
|
(!checked
|
|
? "bg-white border-mti-gray-platinum"
|
|
: "bg-ielts-listening/70 border-ielts-listening text-white"),
|
|
x === "writing" &&
|
|
(!checked
|
|
? "bg-white border-mti-gray-platinum"
|
|
: "bg-ielts-writing/70 border-ielts-writing text-white"),
|
|
x === "speaking" &&
|
|
(!checked
|
|
? "bg-white border-mti-gray-platinum"
|
|
: "bg-ielts-speaking/70 border-ielts-speaking text-white"),
|
|
x === "level" &&
|
|
(!checked
|
|
? "bg-white border-mti-gray-platinum"
|
|
: "bg-ielts-level/70 border-ielts-level text-white")
|
|
)}
|
|
>
|
|
{capitalize(x)}
|
|
</span>
|
|
)}
|
|
</Radio>
|
|
);
|
|
return acc;
|
|
}, [] as JSX.Element[])}
|
|
</RadioGroup>
|
|
</div>
|
|
<ExamEditor
|
|
levelParts={examLevelParts}
|
|
entitiesAllowEditPrivacy={entitiesAllowEditPrivacy}
|
|
entitiesAllowConfExams={entitiesAllowConfExams}
|
|
entitiesAllowPublicExams={entitiesAllowPublicExams}
|
|
/>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|