diff --git a/src/components/ExamEditor/Exercises/Writing/index.tsx b/src/components/ExamEditor/Exercises/Writing/index.tsx index 46fc87a6..27ae951f 100644 --- a/src/components/ExamEditor/Exercises/Writing/index.tsx +++ b/src/components/ExamEditor/Exercises/Writing/index.tsx @@ -20,6 +20,9 @@ interface Props { const Writing: React.FC = ({ sectionId, exercise, module, index }) => { const { currentModule, dispatch } = useExamEditorStore(); + const { type, academic_url } = useExamEditorStore( + (state) => state.modules[currentModule] + ); const { generating, genResult, state } = useExamEditorStore( (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! ); @@ -64,11 +67,11 @@ const Writing: React.FC = ({ sectionId, exercise, module, index }) => { } }, onPractice: () => { - const newState = { - ...state, + const newState = { + ...state, isPractice: !local.isPractice }; - setLocal((prev) => ({...prev, isPractice: !local.isPractice})) + setLocal((prev) => ({ ...prev, isPractice: !local.isPractice })) dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } }); } }); @@ -96,7 +99,7 @@ const Writing: React.FC = ({ sectionId, exercise, module, index }) => { <>
= ({ sectionId, exercise, module, index }) => {
{loading ? : - ( - editing ? ( -
- setPrompt(text)} - placeholder="Instructions ..." - /> + <> + { + editing ? ( +
+ setPrompt(text)} + placeholder="Instructions ..." + /> +
+ ) : ( +

+ {prompt === "" ? "Instructions ..." : prompt} +

+ ) + } + {academic_url && sectionId == 1 && ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Visual Information +
- ) : ( -

- {prompt === "" ? "Instructions ..." : prompt} -

- ) - )} + )} + + }
); diff --git a/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts b/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts index fbd9cbe8..b48bafec 100644 --- a/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts +++ b/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts @@ -8,6 +8,7 @@ import { Module } from "@/interfaces"; interface GeneratorConfig { method: 'GET' | 'POST'; queryParams?: Record; + files?: Record; body?: Record; } @@ -66,18 +67,60 @@ export function generate( const url = `/api/exam/generate/${module}/${sectionId}${queryString ? `?${queryString}` : ''}`; - const request = config.method === 'POST' - ? axios.post(url, config.body) - : axios.get(url); + let body = null; + console.log(config.files); + if (config.files && Object.keys(config.files).length > 0 && config.method === 'POST') { + const formData = new FormData(); - request - .then((result) => { - playSound("check"); - setGeneratedResult(level ? levelSectionId! : sectionId, type, mapData(result.data), level); - }) - .catch((error) => { - setGenerating(sectionId, undefined, level, true); - playSound("error"); - toast.error("Something went wrong! Try to generate again."); - }) + const buildForm = async () => { + await Promise.all( + Object.entries(config.files ?? {}).map(async ([key, blobUrl]) => { + const response = await fetch(blobUrl); + const blob = await response.blob(); + const file = new File([blob], key, { type: blob.type }); + formData.append(key, file); + }) + ); + + if (config.body) { + Object.entries(config.body).forEach(([key, value]) => { + formData.append(key, value as string); + }); + } + return formData; + }; + + buildForm().then(form => { + body = form; + + const request = axios.post(url, body, { headers: { 'Content-Type': 'multipart/form-data' } }); + request + .then((result) => { + playSound("check"); + setGeneratedResult(level ? levelSectionId! : sectionId, type, mapData(result.data), level); + }) + .catch((error) => { + setGenerating(sectionId, undefined, level, true); + playSound("error"); + toast.error("Something went wrong! Try to generate again."); + }); + }); + } else { + body = config.body; + + const request = config.method === 'POST' + ? axios.post(url, body, { headers: { 'Content-Type': 'application/json' } }) + : axios.get(url); + + request + .then((result) => { + playSound("check"); + setGeneratedResult(level ? levelSectionId! : sectionId, type, mapData(result.data), level); + }) + .catch((error) => { + setGenerating(sectionId, undefined, level, true); + playSound("error"); + toast.error("Something went wrong! Try to generate again."); + }); + } } \ No newline at end of file diff --git a/src/components/ExamEditor/SettingsEditor/writing/components.tsx b/src/components/ExamEditor/SettingsEditor/writing/components.tsx index e2c6b0e6..acd0e18c 100644 --- a/src/components/ExamEditor/SettingsEditor/writing/components.tsx +++ b/src/components/ExamEditor/SettingsEditor/writing/components.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from "react"; +import React, { useCallback, useRef, useState } from "react"; import Dropdown from "../Shared/SettingsDropdown"; import Input from "@/components/Low/Input"; import { generate } from "../Shared/Generate"; @@ -6,6 +6,8 @@ import GenerateBtn from "../Shared/GenerateBtn"; import { LevelSectionSettings, WritingSectionSettings } from "@/stores/examEditor/types"; import useExamEditorStore from "@/stores/examEditor"; import { WritingExercise } from "@/interfaces/exam"; +import clsx from "clsx"; +import { FaFileUpload } from "react-icons/fa"; interface Props { @@ -15,69 +17,155 @@ interface Props { level?: boolean; } -const WritingComponents: React.FC = ({localSettings, updateLocalAndScheduleGlobal, level}) => { - const { currentModule } = useExamEditorStore(); +const WritingComponents: React.FC = ({ localSettings, updateLocalAndScheduleGlobal, level }) => { + const { currentModule, dispatch } = useExamEditorStore(); const { difficulty, focusedSection, + type, + academic_url } = useExamEditorStore((store) => store.modules["writing"]); - const generatePassage = useCallback((sectionId: number) => { - generate( - sectionId, - currentModule, - "writing", - { - method: 'GET', - queryParams: { - difficulty, - ...(localSettings.writingTopic && { topic: localSettings.writingTopic }) - } - }, - (data: any) => [{ - prompt: data.question - }] - ); + if (type === "academic" && academic_url !== undefined && sectionId == 1) { + generate( + sectionId, + currentModule, + "writing", + { + method: 'POST', + queryParams: { + difficulty, + type: type! + }, + files: { + file: academic_url!, + } + }, + (data: any) => [{ + prompt: data.question + }] + ) + } else { + generate( + sectionId, + currentModule, + "writing", + { + method: 'GET', + queryParams: { + difficulty, + type: type!, + ...(localSettings.writingTopic && { topic: localSettings.writingTopic }) + } + }, + (data: any) => [{ + prompt: data.question + }] + ); + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [localSettings.writingTopic, difficulty]); + }, [localSettings.writingTopic, difficulty, academic_url]); const onTopicChange = useCallback((writingTopic: string) => { updateLocalAndScheduleGlobal({ writingTopic }); }, [updateLocalAndScheduleGlobal]); + const fileInputRef = useRef(null); + + const triggerFileInput = () => { + fileInputRef.current?.click(); + }; + + const handleFileUpload = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + const blobUrl = URL.createObjectURL(file); + if (academic_url !== undefined) { + URL.revokeObjectURL(academic_url); + } + dispatch({ type: "UPDATE_MODULE", payload: { updates: { academic_url: blobUrl } } }); + } + }; + return ( <> - updateLocalAndScheduleGlobal({ isWritingTopicOpen: isOpen }, false)} - contentWrapperClassName={level ? `border border-ielts-writing`: ''} + open={localSettings.isImageUploadOpen} + setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isImageUploadOpen: isOpen }, false)} + contentWrapperClassName={level ? `border border-ielts-writing` : ''} > -
-
- - + + Upload a graph, chart or diagram + + +
-
- + className={clsx( + "flex items-center w-[140px] px-4 py-2 text-white rounded-xl transition-colors duration-300 text-lg disabled:cursor-not-allowed", + `bg-ielts-writing/70 border border-ielts-writing hover:bg-ielts-writing disabled:bg-ielts-writing/40`, + )} + onClick={triggerFileInput} + > +
+ + Upload +
+
-
+ } + { + type !== "academic" || (type === "academic" && academic_url !== undefined) && updateLocalAndScheduleGlobal({ isWritingTopicOpen: isOpen }, false)} + contentWrapperClassName={level ? `border border-ielts-writing` : ''} + > + +
+ {type !== "academic" ? +
+ + +
+ : +
+ + Generate instructions based on the uploaded image. + +
+ } +
+ +
+
+
+ } ); }; diff --git a/src/components/ExamEditor/SettingsEditor/writing/index.tsx b/src/components/ExamEditor/SettingsEditor/writing/index.tsx index 0d1aab5f..923bd635 100644 --- a/src/components/ExamEditor/SettingsEditor/writing/index.tsx +++ b/src/components/ExamEditor/SettingsEditor/writing/index.tsx @@ -8,7 +8,6 @@ import { useRouter } from "next/router"; import { usePersistentExamStore } from "@/stores/exam"; import openDetachedTab from "@/utils/popout"; import { WritingExam, WritingExercise } from "@/interfaces/exam"; -import { v4 } from "uuid"; import axios from "axios"; import { playSound } from "@/utils/sound"; import { toast } from "react-toastify"; @@ -25,7 +24,8 @@ const WritingSettings: React.FC = () => { isPrivate, sections, focusedSection, - type + type, + academic_url } = useExamEditorStore((store) => store.modules["writing"]); const states = sections.flatMap((s) => s.state) as WritingExercise[]; @@ -58,8 +58,16 @@ const WritingSettings: React.FC = () => { const openTab = () => { setExam({ - exercises: sections.map((s) => { + exercises: sections.map((s, index) => { const exercise = s.state as WritingExercise; + if (type === "academic" && index == 0 && academic_url) { + console.log("Added the URL"); + exercise["attachment"] = { + url: academic_url, + description: "Visual Information" + } + } + return { ...exercise, intro: s.settings.currentIntro, @@ -79,36 +87,68 @@ const WritingSettings: React.FC = () => { openDetachedTab("popout?type=Exam&module=writing", router) } - const submitWriting = () => { - const exam: WritingExam = { - exercises: sections.map((s) => { - const exercise = s.state as WritingExercise; - return { - ...exercise, - intro: localSettings.currentIntro, - category: localSettings.category - }; - }), - minTimer, - module: "writing", - id: title, - isDiagnostic: false, - variant: undefined, - difficulty, - private: isPrivate, - type: type! - }; + const submitWriting = async () => { + if (title === "") { + toast.error("Enter a title for the exam!"); + return; + } + try { + let firebase_url = undefined; + if (type === "academic" && academic_url) { + const formData = new FormData(); + const sectionMap = new Map(); + const fetchedBlob = await fetch(academic_url); + const blob = await fetchedBlob.blob(); + formData.append('file', blob); + sectionMap.set(1, academic_url); - axios - .post(`/api/exam/reading`, exam) - .then((result) => { - playSound("sent"); - toast.success(`Submitted Exam ID: ${result.data.id}`); - }) - .catch((error) => { - console.log(error); - toast.error(error.response.data.error || "Something went wrong while submitting, please try again later."); - }) + const response = await axios.post('/api/storage', formData, { + params: { + directory: 'writing_attachments' + }, + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + firebase_url = response.data.urls[1]; + } + + const exam: WritingExam = { + exercises: sections.map((s) => { + const exercise = s.state as WritingExercise; + if (exercise.sectionId == 1 && firebase_url) { + exercise["attachment"] = { + url: firebase_url, + description: "Visual Information" + } + } + + return { + ...exercise, + intro: localSettings.currentIntro, + category: localSettings.category + }; + }), + minTimer, + module: "writing", + id: title, + isDiagnostic: false, + variant: undefined, + difficulty, + private: isPrivate, + type: type! + }; + + const result = await axios.post(`/api/exam/writing`, exam) + playSound("sent"); + toast.success(`Submitted Exam ID: ${result.data.id}`); + + } catch (error: any) { + console.error('Error submitting exam:', error); + toast.error( + "Something went wrong while submitting, please try again later." + ); + } } return ( diff --git a/src/pages/(admin)/Lists/BatchCreateUser.tsx b/src/pages/(admin)/Lists/BatchCreateUser.tsx index a2c46f02..814ae38f 100644 --- a/src/pages/(admin)/Lists/BatchCreateUser.tsx +++ b/src/pages/(admin)/Lists/BatchCreateUser.tsx @@ -185,12 +185,12 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi if (!confirm(`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`)) return; - /*Promise.all(duplicatedUsers.map(async (u) => await axios.post(`/api/invites`, {to: u.id, entity, from: user.id}))) - .then(() => toast.success(`Successfully invited ${duplicatedUsers.length} registered student(s)!`)) + Promise.all(newUsers.map(async (u) => await axios.post(`/api/invites`, {to: u.id, entity, from: user.id}))) + .then(() => toast.success(`Successfully invited ${newUsers.length} registered student(s)!`)) .finally(() => { if (newUsers.length === 0) setIsLoading(false); }); - */ + if (newUsers.length > 0) { setIsLoading(true); @@ -392,6 +392,7 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi
)} + {(duplicatedUsers.length !== 0 && newUsers.length === 0) && The imported .csv only contains duplicated users!} diff --git a/src/pages/(exam)/ExamPage.tsx b/src/pages/(exam)/ExamPage.tsx index 5cb5829f..eca9075c 100644 --- a/src/pages/(exam)/ExamPage.tsx +++ b/src/pages/(exam)/ExamPage.tsx @@ -11,7 +11,7 @@ import Reading from "@/exams/Reading"; import Selection from "@/exams/Selection"; import Speaking from "@/exams/Speaking"; import Writing from "@/exams/Writing"; -import { Exam, LevelExam, UserSolution, Variant } from "@/interfaces/exam"; +import { Exam, LevelExam, UserSolution, Variant, WritingExam } from "@/interfaces/exam"; import { User } from "@/interfaces/user"; import { evaluateSpeakingAnswer, evaluateWritingAnswer } from "@/utils/evaluation"; import { getExam } from "@/utils/exams"; @@ -126,7 +126,7 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar = await Promise.all( exam.exercises.map(async (exercise, index) => { if (exercise.type === "writing") - await evaluateWritingAnswer(user.id, sessionId, exercise, index + 1, userSolutions.find((x) => x.exercise === exercise.id)!); + await evaluateWritingAnswer(user.id, sessionId, exercise, index + 1, userSolutions.find((x) => x.exercise === exercise.id)!, exercise.attachment?.url); if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking"){ await evaluateSpeakingAnswer( diff --git a/src/pages/api/exam/[module]/index.ts b/src/pages/api/exam/[module]/index.ts index 0e8d7eef..d54e7835 100644 --- a/src/pages/api/exam/[module]/index.ts +++ b/src/pages/api/exam/[module]/index.ts @@ -65,7 +65,8 @@ async function POST(req: NextApiRequest, res: NextApiResponse) { // Check whether the id of the exam matches another exam with different // owners, throw exception if there is, else allow editing const ownersSet = new Set(docSnap?.owners || []); - if (docSnap?.owners?.length === exam.owners.lenght && exam.owners.every((e: string) => ownersSet.has(e))) { + + if (docSnap !== null && docSnap?.owners?.length === exam.owners.lenght && exam.owners.every((e: string) => ownersSet.has(e))) { throw new Error("Name already exists"); } diff --git a/src/pages/api/exam/generate/[...module].ts b/src/pages/api/exam/generate/[...module].ts index 4ae0a692..73710469 100644 --- a/src/pages/api/exam/generate/[...module].ts +++ b/src/pages/api/exam/generate/[...module].ts @@ -31,6 +31,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) { if (!req.session.user) return res.status(401).json({ ok: false }); const queryParams = queryToURLSearchParams(req); + let endpoint = queryParams.getAll('module').join("/"); if (endpoint.startsWith("level")) { @@ -41,12 +42,27 @@ async function post(req: NextApiRequest, res: NextApiResponse) { endpoint = "reading/" } - const result = await axios.post(`${process.env.BACKEND_URL}/${endpoint}`, - req.body, - { - headers: { Authorization: `Bearer ${process.env.BACKEND_JWT}` }, - }, - ); + queryParams.delete('module'); + const queryString = queryParams.toString(); - res.status(200).json(result.data); -} \ No newline at end of file + const hasFiles = req.headers['content-type']?.startsWith('multipart/form-data'); + + // https://github.com/vercel/next.js/discussions/36153#discussioncomment-3029675 + // This just proxies the request + + const { data } = await axios.post( + `${process.env.BACKEND_URL}/${endpoint}${hasFiles ? '/attachment' : ''}${queryString.length > 0 ? `?${queryString}` : ''}`, req, { + responseType: "stream", + headers: { + "Content-Type": req.headers["content-type"], + Authorization: `Bearer ${process.env.BACKEND_JWT}`, + }, + }); + data.pipe(res); +} + +export const config = { + api: { + bodyParser: false, + }, +}; diff --git a/src/pages/generation.tsx b/src/pages/generation.tsx index fd519537..fdbc61e6 100644 --- a/src/pages/generation.tsx +++ b/src/pages/generation.tsx @@ -93,6 +93,11 @@ export default function Generation({ id, user, exam, examModule, permissions }: 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) { diff --git a/src/stores/examEditor/defaults.ts b/src/stores/examEditor/defaults.ts index c1d66518..3b1f79e4 100644 --- a/src/stores/examEditor/defaults.ts +++ b/src/stores/examEditor/defaults.ts @@ -23,7 +23,8 @@ export const defaultSettings = (module: Module) => { return { ...baseSettings, writingTopic: '', - isWritingTopicOpen: false + isWritingTopicOpen: false, + isImageUploadOpen: false, } case 'reading': return { @@ -57,6 +58,7 @@ export const defaultSettings = (module: Module) => { isListeningDropdownOpen: false, isWritingTopicOpen: false, + isImageUploadOpen: false, writingTopic: '', isPassageOpen: false, diff --git a/src/stores/examEditor/types.ts b/src/stores/examEditor/types.ts index b2b1454a..22f66938 100644 --- a/src/stores/examEditor/types.ts +++ b/src/stores/examEditor/types.ts @@ -1,7 +1,6 @@ import { Difficulty, InteractiveSpeakingExercise, LevelPart, ListeningPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam"; import { Module } from "@/interfaces"; import Option from "@/interfaces/option"; -import { ExerciseConfig } from "@/components/ExamEditor/ExercisePicker/ExerciseWizard"; export interface GeneratedExercises { exercises: Record[]; @@ -42,6 +41,7 @@ export interface ListeningSectionSettings extends SectionSettings { export interface WritingSectionSettings extends SectionSettings { isWritingTopicOpen: boolean; writingTopic: string; + isImageUploadOpen: boolean; } export interface LevelSectionSettings extends SectionSettings { @@ -55,6 +55,7 @@ export interface LevelSectionSettings extends SectionSettings { // writing isWritingTopicOpen: boolean; + isImageUploadOpen: boolean; writingTopic: string; // reading @@ -116,6 +117,7 @@ export interface ModuleState { importing: boolean; edit: number[]; type?: "general" | "academic"; + academic_url?: string | undefined; } export interface Avatar { diff --git a/src/utils/evaluation.ts b/src/utils/evaluation.ts index 19fdd4c1..532e33b0 100644 --- a/src/utils/evaluation.ts +++ b/src/utils/evaluation.ts @@ -12,14 +12,16 @@ export const evaluateWritingAnswer = async ( exercise: WritingExercise, task: number, solution: UserSolution, + attachment?: string, ): Promise => { await axios.post("/api/evaluate/writing", { - question: `${exercise.prompt} ${exercise.attachment ? exercise.attachment.description : ""}`.replaceAll("\n", ""), + question: `${exercise.prompt}`.replaceAll("\n", ""), answer: solution.solutions[0].solution.trim().replaceAll("\n", " "), task, userId, sessionId, - exerciseId: exercise.id + exerciseId: exercise.id, + attachment, }); };