diff --git a/next.config.js b/next.config.js index 4a6bb16f..771f71d2 100644 --- a/next.config.js +++ b/next.config.js @@ -1,4 +1,5 @@ /** @type {import('next').NextConfig} */ +const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000"; const nextConfig = { reactStrictMode: true, output: "standalone", @@ -8,7 +9,7 @@ const nextConfig = { source: "/api/packages", headers: [ {key: "Access-Control-Allow-Credentials", value: "false"}, - {key: "Access-Control-Allow-Origin", value: "https://encoach.com"}, + {key: "Access-Control-Allow-Origin", value: websiteUrl}, { key: "Access-Control-Allow-Methods", value: "GET", @@ -19,6 +20,21 @@ const nextConfig = { }, ], }, + { + source: "/api/tickets", + headers: [ + {key: "Access-Control-Allow-Credentials", value: "false"}, + {key: "Access-Control-Allow-Origin", value: websiteUrl}, + { + key: "Access-Control-Allow-Methods", + value: "POST,OPTIONS", + }, + { + key: "Access-Control-Allow-Headers", + value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date", + }, + ], + }, ]; }, }; diff --git a/src/components/Exercises/InteractiveSpeaking.tsx b/src/components/Exercises/InteractiveSpeaking.tsx index c9f324fe..a4c7ea22 100644 --- a/src/components/Exercises/InteractiveSpeaking.tsx +++ b/src/components/Exercises/InteractiveSpeaking.tsx @@ -5,6 +5,8 @@ import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} import dynamic from "next/dynamic"; import Button from "../Low/Button"; import useExamStore from "@/stores/examStore"; +import {downloadBlob} from "@/utils/evaluation"; +import axios from "axios"; const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), { @@ -14,9 +16,11 @@ const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mo export default function InteractiveSpeaking({ id, title, + examID, text, type, prompts, + userSolutions, updateIndex, onNext, onBack, @@ -24,20 +28,109 @@ export default function InteractiveSpeaking({ const [recordingDuration, setRecordingDuration] = useState(0); const [isRecording, setIsRecording] = useState(false); const [mediaBlob, setMediaBlob] = useState(); - const [promptIndex, setPromptIndex] = useState(0); - const [answers, setAnswers] = useState<{prompt: string; blob: string}[]>([]); + const [answers, setAnswers] = useState<{prompt: string; blob: string; questionIndex: number}[]>([]); + const [isLoading, setIsLoading] = useState(false); + + const {questionIndex, setQuestionIndex} = useExamStore((state) => state); + const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state); const hasExamEnded = useExamStore((state) => state.hasExamEnded); + const saveToStorage = async (previousURL?: string) => { + if (mediaBlob && mediaBlob.startsWith("blob")) { + const blobBuffer = await downloadBlob(mediaBlob); + const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"}); + + const seed = Math.random().toString().replace("0.", ""); + + const formData = new FormData(); + formData.append("audio", audioFile, `${seed}.wav`); + formData.append("root", "speaking_recordings"); + + const config = { + headers: { + "Content-Type": "audio/wav", + }, + }; + + const response = await axios.post<{path: string}>("/api/storage/insert", formData, config); + if (previousURL && !previousURL.startsWith("blob")) await axios.post("/api/storage/delete", {path: previousURL}); + return response.data.path; + } + + return undefined; + }; + + const back = async () => { + setIsLoading(true); + + const answer = await saveAnswer(questionIndex); + if (questionIndex - 1 >= 0) { + setQuestionIndex(questionIndex - 1); + setIsLoading(false); + + return; + } + setIsLoading(false); + + onBack({ + exercise: id, + solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer], + score: {correct: 1, total: 1, missing: 0}, + type, + }); + }; + + const next = async () => { + setIsLoading(true); + + const answer = await saveAnswer(questionIndex); + if (questionIndex + 1 < prompts.length) { + setQuestionIndex(questionIndex + 1); + setIsLoading(false); + + return; + } + setIsLoading(false); + + onNext({ + exercise: id, + solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer], + score: {correct: 1, total: 1, missing: 0}, + type, + }); + }; + useEffect(() => { - if (updateIndex) updateIndex(promptIndex); - }, [promptIndex, updateIndex]); + if (userSolutions.length > 0 && answers.length === 0) { + console.log(userSolutions); + const solutions = userSolutions as unknown as typeof answers; + setAnswers(solutions); + + if (!mediaBlob) setMediaBlob(solutions.find((x) => x.questionIndex === questionIndex)?.blob); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userSolutions, mediaBlob, answers]); + + useEffect(() => { + console.log({answers}); + }, [answers]); + + useEffect(() => { + if (updateIndex) updateIndex(questionIndex); + }, [questionIndex, updateIndex]); useEffect(() => { if (hasExamEnded) { + const answer = { + questionIndex, + prompt: prompts[questionIndex].text, + blob: mediaBlob!, + }; + onNext({ exercise: id, - solutions: mediaBlob ? [{id, solution: mediaBlob}] : [], + solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer], score: {correct: 1, total: 1, missing: 0}, type, }); @@ -59,19 +152,38 @@ export default function InteractiveSpeaking({ }, [isRecording]); useEffect(() => { - if (promptIndex === answers.length - 1) { - setMediaBlob(answers[promptIndex].blob); + if (questionIndex <= answers.length - 1) { + const blob = answers.find((x) => x.questionIndex === questionIndex)?.blob; + setMediaBlob(blob); } - }, [answers, promptIndex]); + }, [answers, questionIndex]); + + const saveAnswer = async (index: number) => { + const previousURL = answers.find((x) => x.questionIndex === questionIndex)?.blob; + const audioPath = await saveToStorage(previousURL); - const saveAnswer = () => { const answer = { - prompt: prompts[promptIndex].text, - blob: mediaBlob!, + questionIndex, + prompt: prompts[questionIndex].text, + blob: audioPath ? audioPath : mediaBlob!, }; - setAnswers((prev) => [...prev, answer]); + setAnswers((prev) => [...prev.filter((x) => x.questionIndex !== index), answer]); setMediaBlob(undefined); + + setUserSolutions([ + ...storeUserSolutions.filter((x) => x.exercise !== id), + { + exercise: id, + solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer], + score: {correct: 1, total: 1, missing: 0}, + module: "speaking", + exam: examID, + type, + }, + ]); + + return answer; }; return ( @@ -82,8 +194,8 @@ export default function InteractiveSpeaking({ {prompts && prompts.length > 0 && (
-
)} @@ -91,13 +203,13 @@ export default function InteractiveSpeaking({ setMediaBlob(blob)} render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (

Record your answer:

- {status === "idle" && ( + {status === "idle" && !mediaBlob && ( <>
{status === "idle" && ( @@ -176,9 +288,9 @@ export default function InteractiveSpeaking({
)} - {status === "stopped" && mediaBlobUrl && ( + {((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && ( <> - +
- -
diff --git a/src/components/Exercises/MultipleChoice.tsx b/src/components/Exercises/MultipleChoice.tsx index b16063c5..6bf1a25e 100644 --- a/src/components/Exercises/MultipleChoice.tsx +++ b/src/components/Exercises/MultipleChoice.tsx @@ -59,10 +59,18 @@ export default function MultipleChoice({ onBack, }: MultipleChoiceExercise & CommonProps) { const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions); - const [questionIndex, setQuestionIndex] = useState(0); + const {questionIndex, setQuestionIndex} = useExamStore((state) => state); + const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state); const hasExamEnded = useExamStore((state) => state.hasExamEnded); + const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); + + useEffect(() => { + setUserSolutions([...storeUserSolutions.filter((x) => x.exercise !== id), {exercise: id, solutions: answers, score: calculateScore(), type}]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [answers]); + useEffect(() => { if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type}); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -91,16 +99,20 @@ export default function MultipleChoice({ if (questionIndex === questions.length - 1) { onNext({exercise: id, solutions: answers, score: calculateScore(), type}); } else { - setQuestionIndex((prev) => prev + 1); + setQuestionIndex(questionIndex + 1); } + + scrollToTop(); }; const back = () => { if (questionIndex === 0) { onBack({exercise: id, solutions: answers, score: calculateScore(), type}); } else { - setQuestionIndex((prev) => prev - 1); + setQuestionIndex(questionIndex - 1); } + + scrollToTop(); }; return ( diff --git a/src/components/Exercises/Speaking.tsx b/src/components/Exercises/Speaking.tsx index 03c79fd0..40cb55a9 100644 --- a/src/components/Exercises/Speaking.tsx +++ b/src/components/Exercises/Speaking.tsx @@ -5,28 +5,58 @@ import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} import dynamic from "next/dynamic"; import Button from "../Low/Button"; import useExamStore from "@/stores/examStore"; +import {downloadBlob} from "@/utils/evaluation"; +import axios from "axios"; const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), { ssr: false, }); -export default function Speaking({id, title, text, video_url, type, prompts, onNext, onBack}: SpeakingExercise & CommonProps) { +export default function Speaking({id, title, text, video_url, type, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) { const [recordingDuration, setRecordingDuration] = useState(0); const [isRecording, setIsRecording] = useState(false); const [mediaBlob, setMediaBlob] = useState(); + const [audioURL, setAudioURL] = useState(); + const [isLoading, setIsLoading] = useState(false); const hasExamEnded = useExamStore((state) => state.hasExamEnded); - useEffect(() => { - if (hasExamEnded) { - onNext({ - exercise: id, - solutions: mediaBlob ? [{id, solution: mediaBlob}] : [], - score: {correct: 1, total: 1, missing: 0}, - type, - }); + const saveToStorage = async () => { + if (mediaBlob && mediaBlob.startsWith("blob")) { + const blobBuffer = await downloadBlob(mediaBlob); + const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"}); + + const seed = Math.random().toString().replace("0.", ""); + + const formData = new FormData(); + formData.append("audio", audioFile, `${seed}.wav`); + formData.append("root", "speaking_recordings"); + + const config = { + headers: { + "Content-Type": "audio/wav", + }, + }; + + const response = await axios.post<{path: string}>("/api/storage/insert", formData, config); + if (audioURL) await axios.post("/api/storage/delete", {path: audioURL}); + return response.data.path; } + + return undefined; + }; + + useEffect(() => { + if (userSolutions.length > 0) { + const {solution} = userSolutions[0] as {solution?: string}; + if (solution && !mediaBlob) setMediaBlob(solution); + if (solution && !solution.startsWith("blob")) setAudioURL(solution); + } + }, [userSolutions, mediaBlob]); + + useEffect(() => { + if (hasExamEnded) next(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasExamEnded]); @@ -43,6 +73,32 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN }; }, [isRecording]); + const next = async () => { + setIsLoading(true); + const storagePath = await saveToStorage(); + setIsLoading(false); + + onNext({ + exercise: id, + solutions: storagePath ? [{id, solution: storagePath}] : [], + score: {correct: 1, total: 1, missing: 0}, + type, + }); + }; + + const back = async () => { + setIsLoading(true); + const storagePath = await saveToStorage(); + setIsLoading(false); + + onBack({ + exercise: id, + solutions: storagePath ? [{id, solution: storagePath}] : [], + score: {correct: 1, total: 1, missing: 0}, + type, + }); + }; + return (
@@ -89,7 +145,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN

Record your answer:

- {status === "idle" && ( + {status === "idle" && !mediaBlob && ( <>
{status === "idle" && ( @@ -168,9 +224,9 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
)} - {status === "stopped" && mediaBlobUrl && ( + {((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && ( <> - +
- -
diff --git a/src/components/Exercises/Writing.tsx b/src/components/Exercises/Writing.tsx index 58366a5c..6004a198 100644 --- a/src/components/Exercises/Writing.tsx +++ b/src/components/Exercises/Writing.tsx @@ -22,9 +22,31 @@ export default function Writing({ const [isModalOpen, setIsModalOpen] = useState(false); const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : ""); const [isSubmitEnabled, setIsSubmitEnabled] = useState(false); + const [saveTimer, setSaveTimer] = useState(0); + const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state); const hasExamEnded = useExamStore((state) => state.hasExamEnded); + useEffect(() => { + const saveTimerInterval = setInterval(() => { + setSaveTimer((prev) => prev + 1); + }, 1000); + + return () => { + clearInterval(saveTimerInterval); + }; + }, []); + + useEffect(() => { + if (inputText.length > 0 && saveTimer % 10 === 0) { + setUserSolutions([ + ...storeUserSolutions.filter((x) => x.exercise !== id), + {exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type}, + ]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [saveTimer]); + useEffect(() => { if (localStorage.getItem("enable_paste")) return; diff --git a/src/components/Exercises/index.tsx b/src/components/Exercises/index.tsx index fd63f1f9..2b1a623b 100644 --- a/src/components/Exercises/index.tsx +++ b/src/components/Exercises/index.tsx @@ -22,6 +22,7 @@ import InteractiveSpeaking from "./InteractiveSpeaking"; const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false}); export interface CommonProps { + examID?: string; updateIndex?: (internalIndex: number) => void; onNext: (userSolutions: UserSolution) => void; onBack: (userSolutions: UserSolution) => void; @@ -29,17 +30,18 @@ export interface CommonProps { export const renderExercise = ( exercise: Exercise, + examID: string, onNext: (userSolutions: UserSolution) => void, onBack: (userSolutions: UserSolution) => void, updateIndex?: (internalIndex: number) => void, ) => { switch (exercise.type) { case "fillBlanks": - return ; + return ; case "trueFalse": - return ; + return ; case "matchSentences": - return ; + return ; case "multipleChoice": return ( ); case "writeBlanks": - return ; + return ; case "writing": - return ; + return ; case "speaking": - return ; + return ; case "interactiveSpeaking": return ( ["admin", "developer"].includes(x.type)) + .filter((x) => ["admin", "developer", "agent"].includes(x.type)) .map((u) => ({ value: u.id, label: `${u.name} - ${u.email}`, diff --git a/src/components/High/TicketSubmission.tsx b/src/components/High/TicketSubmission.tsx index 83862ce4..5671e738 100644 --- a/src/components/High/TicketSubmission.tsx +++ b/src/components/High/TicketSubmission.tsx @@ -1,134 +1,101 @@ -import { Ticket, TicketType, TicketTypeLabel } from "@/interfaces/ticket"; -import { User } from "@/interfaces/user"; +import {Ticket, TicketType, TicketTypeLabel} from "@/interfaces/ticket"; +import {User} from "@/interfaces/user"; import axios from "axios"; -import { useState } from "react"; -import { toast } from "react-toastify"; +import {useState} from "react"; +import {toast} from "react-toastify"; import ShortUniqueId from "short-unique-id"; import Button from "../Low/Button"; import Input from "../Low/Input"; import Select from "../Low/Select"; interface Props { - user: User; - page: string; - onClose: () => void; + user: User; + page: string; + onClose: () => void; } -export default function TicketSubmission({ user, page, onClose }: Props) { - const [subject, setSubject] = useState(""); - const [type, setType] = useState(); - const [description, setDescription] = useState(""); - const [isLoading, setIsLoading] = useState(false); +export default function TicketSubmission({user, page, onClose}: Props) { + const [subject, setSubject] = useState(""); + const [type, setType] = useState(); + const [description, setDescription] = useState(""); + const [isLoading, setIsLoading] = useState(false); - const submit = () => { - if (!type) - return toast.error("Please choose a type!", { toastId: "missing-type" }); - if (subject.trim() === "") - return toast.error("Please input a subject!", { - toastId: "missing-subject", - }); - if (description.trim() === "") - return toast.error("Please describe your ticket!", { - toastId: "missing-desc", - }); + const submit = () => { + if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"}); + if (subject.trim() === "") + return toast.error("Please input a subject!", { + toastId: "missing-subject", + }); + if (description.trim() === "") + return toast.error("Please describe your ticket!", { + toastId: "missing-desc", + }); - setIsLoading(true); + setIsLoading(true); - const shortUID = new ShortUniqueId(); - const ticket: Ticket = { - id: shortUID.randomUUID(8), - date: new Date().toISOString(), - reporter: { - id: user.id, - email: user.email, - name: user.name, - type: user.type, - }, - status: "submitted", - subject, - type, - reportedFrom: page, - description, - }; + const shortUID = new ShortUniqueId(); + const ticket: Ticket = { + id: shortUID.randomUUID(8), + date: new Date().toISOString(), + reporter: { + id: user.id, + email: user.email, + name: user.name, + type: user.type, + }, + status: "submitted", + subject, + type, + reportedFrom: page, + description, + }; - axios - .post(`/api/tickets`, ticket) - .then(() => { - toast.success( - `Your ticket has been submitted! You will be contacted by e-mail for further discussion.`, - { toastId: "submitted" }, - ); - onClose(); - }) - .catch((e) => { - console.error(e); - toast.error("Something went wrong, please try again later!", { - toastId: "error", - }); - }) - .finally(() => setIsLoading(false)); - }; + axios + .post(`/api/tickets`, ticket) + .then(() => { + toast.success(`Your ticket has been submitted! You will be contacted by e-mail for further discussion.`, {toastId: "submitted"}); + onClose(); + }) + .catch((e) => { + console.error(e); + toast.error("Something went wrong, please try again later!", { + toastId: "error", + }); + }) + .finally(() => setIsLoading(false)); + }; - return ( -
- setSubject(e)} - /> -
-
- - null} - value={`${user.name} - ${user.email}`} - disabled - /> -
-