Listening preview and some more patches
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
type TextToken = {
|
||||
export type TextToken = {
|
||||
type: 'text';
|
||||
content: string;
|
||||
isWhitespace: boolean;
|
||||
isLineBreak?: boolean;
|
||||
};
|
||||
|
||||
type BlankToken = {
|
||||
export type BlankToken = {
|
||||
type: 'blank';
|
||||
id: number;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AlertItem } from "../../Shared/Alert";
|
||||
import { BlankState } from "../BlanksReducer";
|
||||
|
||||
|
||||
export const validateWriteBlanks = (
|
||||
|
||||
@@ -18,7 +18,7 @@ import Alert, { AlertItem } from "../Shared/Alert";
|
||||
import clsx from "clsx";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Blank, DropZone } from "./DragNDrop";
|
||||
import { getTextSegments, BlankState, BlanksState, BlanksAction } from "./BlanksReducer";
|
||||
import { getTextSegments, BlankState, BlanksState, BlanksAction, BlankToken } from "./BlanksReducer";
|
||||
|
||||
|
||||
interface Props {
|
||||
@@ -104,11 +104,11 @@ const BlanksEditor: React.FC<Props> = ({
|
||||
|
||||
const existingBlankIds = getTextSegments(state.text)
|
||||
.filter(token => token.type === 'blank')
|
||||
.map(token => token.id);
|
||||
.map(token => (token as BlankToken).id);
|
||||
|
||||
const newBlankIds = getTextSegments(processedText)
|
||||
.filter(token => token.type === 'blank')
|
||||
.map(token => token.id);
|
||||
.map(token => (token as BlankToken).id);
|
||||
|
||||
const removedBlankIds = existingBlankIds.filter(id => !newBlankIds.includes(id));
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import setEditingAlert from '../Shared/setEditingAlert';
|
||||
import { toast } from 'react-toastify';
|
||||
import { DragEndEvent } from '@dnd-kit/core';
|
||||
import { handleMatchSentencesReorder } from '@/stores/examEditor/reorder/local';
|
||||
import PromptEdit from '../Shared/PromptEdit';
|
||||
|
||||
const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: number }> = ({ exercise, sectionId }) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
@@ -31,6 +32,11 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu
|
||||
const [showReference, setShowReference] = useState(false);
|
||||
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||
|
||||
const updateLocal = (exercise: MatchSentencesExercise) => {
|
||||
setLocal(exercise);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({
|
||||
sectionId,
|
||||
onSave: () => {
|
||||
@@ -73,9 +79,8 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu
|
||||
}, [local.sentences]);
|
||||
|
||||
const addHeading = () => {
|
||||
setEditing(true);
|
||||
const newId = (parseInt(local.sentences[local.sentences.length - 1].id) + 1).toString();
|
||||
setLocal({
|
||||
updateLocal({
|
||||
...local,
|
||||
sentences: [
|
||||
...local.sentences,
|
||||
@@ -89,7 +94,6 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu
|
||||
};
|
||||
|
||||
const updateHeading = (index: number, field: string, value: string) => {
|
||||
setEditing(true);
|
||||
const newSentences = [...local.sentences];
|
||||
|
||||
if (field === 'solution') {
|
||||
@@ -100,11 +104,10 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu
|
||||
}
|
||||
|
||||
newSentences[index] = { ...newSentences[index], [field]: value };
|
||||
setLocal({ ...local, sentences: newSentences });
|
||||
updateLocal({ ...local, sentences: newSentences });
|
||||
};
|
||||
|
||||
const deleteHeading = (index: number) => {
|
||||
setEditing(true);
|
||||
if (local.sentences.length <= 1) {
|
||||
toast.error(`There needs to be at least one ${exercise.variant && exercise.variant == "ideaMatch" ? "idea/opinion" : "heading"}!`);
|
||||
return;
|
||||
@@ -116,7 +119,7 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu
|
||||
}
|
||||
|
||||
const newSentences = local.sentences.filter((_, i) => i !== index);
|
||||
setLocal({ ...local, sentences: newSentences });
|
||||
updateLocal({ ...local, sentences: newSentences });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -129,8 +132,7 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu
|
||||
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setEditing(true);
|
||||
setLocal(handleMatchSentencesReorder(event, local));
|
||||
updateLocal(handleMatchSentencesReorder(event, local));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -154,6 +156,10 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu
|
||||
|
||||
<div className="space-y-4">
|
||||
{alerts.length > 0 && <Alert alerts={alerts} />}
|
||||
<PromptEdit
|
||||
value={local.prompt}
|
||||
onChange={(text) => updateLocal({ ...local, prompt: text })}
|
||||
/>
|
||||
<QuestionsList
|
||||
ids={local.sentences.map(s => s.id)}
|
||||
handleDragEnd={handleDragEnd}
|
||||
|
||||
49
src/components/ExamEditor/Exercises/Shared/PromptEdit.tsx
Normal file
49
src/components/ExamEditor/Exercises/Shared/PromptEdit.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useState } from "react";
|
||||
import { MdEdit, MdEditOff } from "react-icons/md";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (text: string) => void;
|
||||
}
|
||||
|
||||
|
||||
const PromptEdit: React.FC<Props> = ({ value, onChange }) => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-6">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
{editing ? (
|
||||
<AutoExpandingTextArea
|
||||
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none min-h-[100px]"
|
||||
value={value}
|
||||
onChange={(text) => onChange(text)}
|
||||
onBlur={()=> setEditing(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-gray-800 mb-2">Question/Instructions displayed to the student:</h3>
|
||||
<p className="text-gray-600">{value}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setEditing(!editing)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
{editing ?
|
||||
<MdEditOff size={20} className="text-gray-500" /> :
|
||||
<MdEdit size={20} className="text-gray-500" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default PromptEdit;
|
||||
@@ -24,11 +24,20 @@ const Speaking: React.FC<Props> = ({ sectionId, exercise }) => {
|
||||
const [loading, setLoading] = useState(generating === "context");
|
||||
const [questions, setQuestions] = useState(() => {
|
||||
if (sectionId === 1) {
|
||||
return (exercise as SpeakingExercise).prompts || Array(5).fill("");
|
||||
if ((exercise as SpeakingExercise).prompts.length > 0) {
|
||||
return (exercise as SpeakingExercise).prompts;
|
||||
}
|
||||
return Array(5).fill("");
|
||||
} else if (sectionId === 2) {
|
||||
return [(exercise as SpeakingExercise).text || "", ...(exercise as SpeakingExercise).prompts || Array(3).fill("")];
|
||||
if ((exercise as SpeakingExercise).text && (exercise as SpeakingExercise).prompts.length > 0) {
|
||||
return (exercise as SpeakingExercise).prompts;
|
||||
}
|
||||
return Array(3).fill("");
|
||||
} else {
|
||||
return (exercise as InteractiveSpeakingExercise).prompts?.map(p => p.text) || Array(5).fill("");
|
||||
if ((exercise as InteractiveSpeakingExercise).prompts.length > 0) {
|
||||
return (exercise as InteractiveSpeakingExercise).prompts?.map(p => p.text);
|
||||
}
|
||||
return Array(5).fill("");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -42,7 +51,7 @@ const Speaking: React.FC<Props> = ({ sectionId, exercise }) => {
|
||||
onSave: () => {
|
||||
let newExercise;
|
||||
if (sectionId === 1) {
|
||||
newExercise = {
|
||||
newExercise = {
|
||||
...local,
|
||||
prompts: questions
|
||||
} as SpeakingExercise;
|
||||
@@ -53,7 +62,6 @@ const Speaking: React.FC<Props> = ({ sectionId, exercise }) => {
|
||||
prompts: questions.slice(1),
|
||||
} as SpeakingExercise;
|
||||
} else {
|
||||
// Section 3
|
||||
newExercise = {
|
||||
...local,
|
||||
prompts: questions.map(text => ({
|
||||
@@ -63,22 +71,25 @@ const Speaking: React.FC<Props> = ({ sectionId, exercise }) => {
|
||||
} as InteractiveSpeakingExercise;
|
||||
}
|
||||
setEditing(false);
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: {
|
||||
sectionId: sectionId,
|
||||
update: newExercise
|
||||
}
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: {
|
||||
sectionId: sectionId,
|
||||
update: newExercise
|
||||
}
|
||||
});
|
||||
},
|
||||
onDiscard: () => {
|
||||
setLocal(exercise);
|
||||
},
|
||||
onMode: () => {
|
||||
setLocal(exercise);
|
||||
if (sectionId === 1) {
|
||||
setQuestions((exercise as SpeakingExercise).prompts || Array(5).fill(""));
|
||||
setQuestions(Array(5).fill(""));
|
||||
} else if (sectionId === 2) {
|
||||
setQuestions([(exercise as SpeakingExercise).text || "", ...(exercise as SpeakingExercise).prompts || Array(3).fill("")]);
|
||||
setQuestions(Array(3).fill(""));
|
||||
} else {
|
||||
setQuestions((exercise as InteractiveSpeakingExercise).prompts?.map(p => p.text) || Array(5).fill(""));
|
||||
setQuestions(Array(5).fill(""));
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -90,6 +101,7 @@ const Speaking: React.FC<Props> = ({ sectionId, exercise }) => {
|
||||
if (isLoading) {
|
||||
updateModule({ edit: Array.from(new Set([...edit, sectionId])) });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [generating, sectionId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -102,14 +114,14 @@ const Speaking: React.FC<Props> = ({ sectionId, exercise }) => {
|
||||
} else {
|
||||
setQuestions(genResult[0].questions);
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: {
|
||||
sectionId,
|
||||
module: currentModule,
|
||||
field: "genResult",
|
||||
value: undefined
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: {
|
||||
sectionId,
|
||||
module: currentModule,
|
||||
field: "genResult",
|
||||
value: undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
MdAdd,
|
||||
MdEdit,
|
||||
MdEditOff,
|
||||
} from 'react-icons/md';
|
||||
import Alert, { AlertItem } from '../Shared/Alert';
|
||||
import { ReadingPart, TrueFalseExercise } from '@/interfaces/exam';
|
||||
@@ -18,6 +15,7 @@ import validateTrueFalseQuestions from './validation';
|
||||
import setEditingAlert from '../Shared/setEditingAlert';
|
||||
import { DragEndEvent } from '@dnd-kit/core';
|
||||
import { handleTrueFalseReorder } from '@/stores/examEditor/reorder/local';
|
||||
import PromptEdit from '../Shared/PromptEdit';
|
||||
|
||||
const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> = ({ exercise, sectionId }) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
@@ -28,7 +26,6 @@ const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> =
|
||||
const section = state as ReadingPart;
|
||||
|
||||
const [local, setLocal] = useState(exercise);
|
||||
const [editingPrompt, setEditingPrompt] = useState(false);
|
||||
|
||||
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||
|
||||
@@ -134,36 +131,10 @@ const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> =
|
||||
handleDiscard={handleDiscard}
|
||||
/>
|
||||
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
{editingPrompt ? (
|
||||
<textarea
|
||||
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none min-h-[100px]"
|
||||
value={local.prompt}
|
||||
onChange={(e) => updateLocal({ ...local, prompt: e.target.value })}
|
||||
onBlur={() => setEditingPrompt(false)}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-gray-800 mb-2">Question/Instructions displayed to the student:</h3>
|
||||
<p className="text-gray-600">{local.prompt}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setEditingPrompt(!editingPrompt)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
{editingPrompt ?
|
||||
<MdEditOff size={20} className="text-gray-500" /> :
|
||||
<MdEdit size={20} className="text-gray-500" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<PromptEdit
|
||||
value={local.prompt}
|
||||
onChange={(text) => updateLocal({ ...local, prompt: text })}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<QuestionsList
|
||||
ids={local.questions.map(q => q.id)}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { handleWriteBlanksReorder } from '@/stores/examEditor/reorder/local';
|
||||
import { ParsedQuestion, parseText, reconstructText } from './parsing';
|
||||
|
||||
|
||||
const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise }> = ({ sectionId, exercise }) => {
|
||||
const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise; title?: string; }> = ({ sectionId, exercise, title = "Write Blanks Exercise" }) => {
|
||||
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const { state } = useExamEditorStore(
|
||||
@@ -198,7 +198,6 @@ const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise }
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setEditing(true);
|
||||
console.log("ASOJNFOAI+SHJOIPFAS");
|
||||
setLocal(handleWriteBlanksReorder(event, local));
|
||||
}
|
||||
|
||||
@@ -223,7 +222,7 @@ const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise }
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Header
|
||||
title="Write Blanks Exercise"
|
||||
title={title}
|
||||
description="Edit questions and their solutions"
|
||||
editing={editing}
|
||||
handleSave={handleSave}
|
||||
|
||||
@@ -15,15 +15,13 @@ interface Props {
|
||||
exercise: WritingExercise;
|
||||
}
|
||||
|
||||
const Writing: React.FC<Props> = ({ sectionId }) => {
|
||||
const Writing: React.FC<Props> = ({ sectionId, exercise }) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const { edit } = useExamEditorStore((store) => store.modules[currentModule]);
|
||||
const { generating, genResult, state } = useExamEditorStore(
|
||||
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||
);
|
||||
|
||||
const exercise = state as WritingExercise;
|
||||
|
||||
|
||||
const [local, setLocal] = useState(exercise);
|
||||
const [prompt, setPrompt] = useState(exercise.prompt);
|
||||
const [loading, setLoading] = useState(generating && generating == "exercises");
|
||||
@@ -43,7 +41,9 @@ const Writing: React.FC<Props> = ({ sectionId }) => {
|
||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: newExercise } });
|
||||
},
|
||||
onDiscard: () => {
|
||||
setEditing(false);
|
||||
setLocal(exercise);
|
||||
setPrompt(exercise.prompt);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -67,10 +67,6 @@ const Writing: React.FC<Props> = ({ sectionId }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [genResult, dispatch, sectionId, setEditing, currentModule]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocal(state as WritingExercise);
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
setEditingAlert(prompt !== local.prompt, setAlerts);
|
||||
}, [prompt, local.prompt]);
|
||||
|
||||
@@ -59,7 +59,7 @@ const useSettingsState = <T extends SectionSettings>(
|
||||
}, [dispatch, module, sectionId]);
|
||||
|
||||
|
||||
const updateLocalAndScheduleGlobal = useCallback((updates: Partial<T>) => {
|
||||
const updateLocalAndScheduleGlobal = useCallback((updates: Partial<T>, schedule: boolean = true) => {
|
||||
setLocalSettings(prev => ({
|
||||
...prev,
|
||||
...updates
|
||||
@@ -69,7 +69,9 @@ const useSettingsState = <T extends SectionSettings>(
|
||||
...pendingUpdatesRef.current,
|
||||
...updates
|
||||
};
|
||||
debouncedUpdateGlobal();
|
||||
if (schedule) {
|
||||
debouncedUpdateGlobal();
|
||||
}
|
||||
}, [debouncedUpdateGlobal]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -37,6 +37,7 @@ const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
||||
setScript(genResult[0].script)
|
||||
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: undefined } })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [genResult, dispatch, sectionId, setEditing, currentModule]);
|
||||
|
||||
const renderContent = (editing: boolean) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Exercise, WriteBlanksExercise } from '@/interfaces/exam';
|
||||
import MultipleChoice from '../../Exercises/MultipleChoice';
|
||||
import WriteBlanksForm from '../../Exercises/WriteBlanksForm';
|
||||
import WriteBlanksFill from '../../Exercises/Blanks/WriteBlankFill';
|
||||
import WriteBlanks from '../../Exercises/WriteBlanks';
|
||||
|
||||
|
||||
const writeBlanks = (exercise: WriteBlanksExercise, index: number, sectionId: number, previewLabel: (text: string) => string) => {
|
||||
@@ -44,6 +45,22 @@ const writeBlanks = (exercise: WriteBlanksExercise, index: number, sectionId: nu
|
||||
),
|
||||
content: <WriteBlanksFill exercise={exercise} sectionId={sectionId} />
|
||||
};
|
||||
case 'questions':
|
||||
return {
|
||||
id: index,
|
||||
sectionId,
|
||||
label: (
|
||||
<ExerciseLabel
|
||||
label={`Write Blanks: Questions #${firstWordId} ${firstWordId === lastWordId ? '' : `- #${lastWordId}`}`}
|
||||
preview={
|
||||
<>
|
||||
"{previewLabel(exercise.prompt)}..."
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
content: <WriteBlanks exercise={exercise} sectionId={sectionId} title='Write Blanks: Questions' />
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,10 +80,6 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
|
||||
});
|
||||
}, [updateLocalAndScheduleGlobal]);
|
||||
|
||||
const submitExam = () => {
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-8 border bg-ielts-${module}/20 rounded-3xl p-8 w-1/3 h-fit`}>
|
||||
<div className={`w-full flex justify-center text-ielts-${module} font-bold text-xl`}>{sectionLabel} Settings</div>
|
||||
@@ -92,7 +88,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
|
||||
title="Category"
|
||||
module={module}
|
||||
open={localSettings.isCategoryDropdownOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isCategoryDropdownOpen: isOpen })}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isCategoryDropdownOpen: isOpen }, false)}
|
||||
>
|
||||
<Input
|
||||
key={`section-${sectionId}`}
|
||||
@@ -108,7 +104,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
|
||||
title="Divider"
|
||||
module={module}
|
||||
open={localSettings.isIntroDropdownOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isIntroDropdownOpen: isOpen })}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isIntroDropdownOpen: isOpen }, false)}
|
||||
>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<Select
|
||||
|
||||
@@ -74,7 +74,7 @@ const LevelSettings: React.FC = () => {
|
||||
}
|
||||
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
|
||||
open={localSettings.isExerciseDropdownOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen })}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)}
|
||||
>
|
||||
<ExercisePicker
|
||||
module="level"
|
||||
|
||||
@@ -8,17 +8,33 @@ import { Generating, ListeningSectionSettings } from "@/stores/examEditor/types"
|
||||
import Option from "@/interfaces/option";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import useSettingsState from "../Hooks/useSettingsState";
|
||||
import { ListeningPart } from "@/interfaces/exam";
|
||||
import { ListeningExam, ListeningPart } from "@/interfaces/exam";
|
||||
import Input from "@/components/Low/Input";
|
||||
import openDetachedTab from "@/utils/popout";
|
||||
import { useRouter } from "next/router";
|
||||
import axios from "axios";
|
||||
import { usePersistentExamStore } from "@/stores/examStore";
|
||||
import { playSound } from "@/utils/sound";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
const ListeningSettings: React.FC = () => {
|
||||
const {currentModule } = useExamEditorStore();
|
||||
const router = useRouter();
|
||||
const {currentModule, title } = useExamEditorStore();
|
||||
const {
|
||||
focusedSection,
|
||||
difficulty,
|
||||
sections,
|
||||
minTimer,
|
||||
isPrivate
|
||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||
|
||||
const {
|
||||
setExam,
|
||||
setExerciseIndex,
|
||||
setPartIndex,
|
||||
setQuestionIndex,
|
||||
} = usePersistentExamStore();
|
||||
|
||||
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<ListeningSectionSettings>(
|
||||
currentModule,
|
||||
focusedSection
|
||||
@@ -61,22 +77,90 @@ const ListeningSettings: React.FC = () => {
|
||||
updateLocalAndScheduleGlobal({ topic });
|
||||
}, [updateLocalAndScheduleGlobal]);
|
||||
|
||||
const submitListening = () => {
|
||||
if (title === "") {
|
||||
toast.error("Enter a title for the exam!");
|
||||
return;
|
||||
}
|
||||
const exam: ListeningExam = {
|
||||
parts: sections.map((s) => {
|
||||
const exercise = s.state as ListeningPart;
|
||||
return {
|
||||
...exercise,
|
||||
intro: localSettings.currentIntro,
|
||||
category: localSettings.category
|
||||
};
|
||||
}),
|
||||
isDiagnostic: false,
|
||||
minTimer,
|
||||
module: "listening",
|
||||
id: title,
|
||||
variant: sections.length === 4 ? "full" : "partial",
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
};
|
||||
|
||||
axios.post(`/api/exam/listening`, 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 preview = () => {
|
||||
setExam({
|
||||
parts: sections.map((s) => {
|
||||
const exercise = s.state as ListeningPart;
|
||||
return {
|
||||
...exercise,
|
||||
intro: localSettings.currentIntro,
|
||||
category: localSettings.category
|
||||
};
|
||||
}),
|
||||
minTimer,
|
||||
module: "listening",
|
||||
id: title,
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
} as ListeningExam);
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
setPartIndex(0);
|
||||
openDetachedTab("popout?type=Exam&module=listening", router)
|
||||
}
|
||||
|
||||
const canPreview = sections.some(
|
||||
(s) => (s.state as ListeningPart).exercises && (s.state as ListeningPart).exercises.length > 0
|
||||
);
|
||||
|
||||
const canSubmit = sections.every(
|
||||
(s) => (s.state as ListeningPart).exercises &&
|
||||
(s.state as ListeningPart).exercises.length > 0 &&
|
||||
(s.state as ListeningPart).audio !== undefined
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsEditor
|
||||
sectionLabel={`Section ${focusedSection}`}
|
||||
sectionId={focusedSection}
|
||||
module="listening"
|
||||
introPresets={[defaultPresets[focusedSection - 1]]}
|
||||
preview={()=> {}}
|
||||
canPreview={false}
|
||||
canSubmit={false}
|
||||
submitModule={()=> {}}
|
||||
canPreview={canPreview}
|
||||
canSubmit={canSubmit}
|
||||
preview={preview}
|
||||
submitModule={submitListening}
|
||||
>
|
||||
<Dropdown
|
||||
title="Audio Context"
|
||||
module={currentModule}
|
||||
open={localSettings.isAudioContextOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen })}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen }, false)}
|
||||
>
|
||||
<div className="flex flex-row gap-2 items-center px-2 pb-4">
|
||||
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||
@@ -105,7 +189,7 @@ const ListeningSettings: React.FC = () => {
|
||||
title="Add Exercises"
|
||||
module={currentModule}
|
||||
open={localSettings.isExerciseDropdownOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen })}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)}
|
||||
disabled={currentSection.script === undefined && currentSection.audio === undefined}
|
||||
>
|
||||
<ExercisePicker
|
||||
|
||||
@@ -85,6 +85,10 @@ const ReadingSettings: React.FC = () => {
|
||||
);
|
||||
|
||||
const submitReading = () => {
|
||||
if (title === "") {
|
||||
toast.error("Enter a title for the exam!");
|
||||
return;
|
||||
}
|
||||
const exam: ReadingExam = {
|
||||
parts: sections.map((s) => {
|
||||
const exercise = s.state as ReadingPart;
|
||||
@@ -154,7 +158,7 @@ const ReadingSettings: React.FC = () => {
|
||||
title="Generate Passage"
|
||||
module={currentModule}
|
||||
open={localSettings.isPassageOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isPassageOpen: isOpen })}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isPassageOpen: isOpen }, false)}
|
||||
>
|
||||
<div className="flex flex-row gap-2 items-center px-2 pb-4">
|
||||
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||
@@ -184,7 +188,7 @@ const ReadingSettings: React.FC = () => {
|
||||
module={currentModule}
|
||||
open={localSettings.isExerciseDropdownOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen })}
|
||||
disabled={currentSection.text.content === "" || currentSection.text.title === ""}
|
||||
disabled={currentSection.text === undefined || currentSection.text.content === "" || currentSection.text.title === ""}
|
||||
>
|
||||
<ExercisePicker
|
||||
module="reading"
|
||||
|
||||
@@ -113,7 +113,7 @@ const SpeakingSettings: React.FC = () => {
|
||||
title="Generate Script"
|
||||
module={currentModule}
|
||||
open={localSettings.isExerciseDropdownOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen })}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)}
|
||||
>
|
||||
|
||||
<div className={clsx("gap-2 px-2 pb-4", focusedSection === 1 ? "flex flex-col w-full" : "flex flex-row items-center" )}>
|
||||
|
||||
@@ -149,7 +149,7 @@ const WritingSettings: React.FC = () => {
|
||||
title="Generate Instructions"
|
||||
module={currentModule}
|
||||
open={localSettings.isExerciseDropdownOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen })}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)}
|
||||
>
|
||||
|
||||
<div className="flex flex-row gap-2 items-center px-2 pb-4">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Module } from "@/interfaces";
|
||||
import clsx from "clsx";
|
||||
import { ReactNode } from "react";
|
||||
import { MdDelete, MdEdit, MdEditOff, MdRefresh, MdSave } from "react-icons/md";
|
||||
import { MdDelete, MdEdit, MdEditOff, MdRefresh, MdSave, MdGrade, MdOutlineGrade } from "react-icons/md";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -11,13 +11,15 @@ interface Props {
|
||||
handleSave: () => void;
|
||||
handleDiscard: () => void;
|
||||
modeHandle?: () => void;
|
||||
evaluationHandle?: () => void;
|
||||
isEvaluationEnabled?: boolean;
|
||||
mode?: "delete" | "edit";
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const Header: React.FC<Props> = ({ title, description, editing, handleSave, handleDiscard, modeHandle, children, mode = "delete", module }) => {
|
||||
const Header: React.FC<Props> = ({ title, description, editing, isEvaluationEnabled, handleSave, handleDiscard, modeHandle, evaluationHandle, children, mode = "delete", module }) => {
|
||||
return (
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="flex justify-between items-center mb-6 text-sm">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">{title}</h1>
|
||||
<p className="text-gray-600 mt-1">{description}</p>
|
||||
@@ -59,10 +61,24 @@ const Header: React.FC<Props> = ({ title, description, editing, handleSave, hand
|
||||
onClick={modeHandle}
|
||||
className={`px-4 py-2 bg-ielts-${module}/80 text-white hover:bg-ielts-${module} rounded-lg transition-all duration-200 flex items-center gap-2`}
|
||||
>
|
||||
{ editing ? <MdEditOff size={18} /> : <MdEdit size={18} /> }
|
||||
{editing ? <MdEditOff size={18} /> : <MdEdit size={18} />}
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{mode === "delete" &&
|
||||
<button
|
||||
onClick={evaluationHandle}
|
||||
className={clsx(
|
||||
"px-4 py-2 rounded-lg flex items-center gap-2 transition-all duration-200",
|
||||
isEvaluationEnabled
|
||||
? 'bg-amber-500 text-white hover:bg-amber-600'
|
||||
: 'bg-gray-200 text-gray-600 hover:bg-gray-300'
|
||||
)}
|
||||
>
|
||||
{isEvaluationEnabled ? <MdGrade size={18} /> : <MdOutlineGrade size={18} />}
|
||||
{isEvaluationEnabled ? 'Graded Exercise' : 'Practice Only'}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import Level from "@/exams/Level";
|
||||
import Listening from "@/exams/Listening";
|
||||
import Reading from "@/exams/Reading";
|
||||
import Writing from "@/exams/Writing";
|
||||
import { usePersistentStorage } from "@/hooks/usePersistentStorage";
|
||||
import { LevelExam, ReadingExam, WritingExam } from "@/interfaces/exam";
|
||||
import { LevelExam, ListeningExam, ReadingExam, WritingExam } from "@/interfaces/exam";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { usePersistentExamStore } from "@/stores/examStore";
|
||||
import clsx from "clsx";
|
||||
@@ -34,6 +35,13 @@ const Popout: React.FC<{ user: User }> = ({ user }) => {
|
||||
state.setQuestionIndex(0);
|
||||
}} preview={true} />
|
||||
}
|
||||
{state.exam?.module == "listening" && state.exam.parts.length > 0 &&
|
||||
<Listening exam={state.exam as ListeningExam} onFinish={() => {
|
||||
state.setPartIndex(0);
|
||||
state.setExerciseIndex(-1);
|
||||
state.setQuestionIndex(0);
|
||||
}} preview={true} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,36 +1,63 @@
|
||||
import {ListeningExam, MultipleChoiceExercise, UserSolution} from "@/interfaces/exam";
|
||||
import {useEffect, useState} from "react";
|
||||
import {renderExercise} from "@/components/Exercises";
|
||||
import {renderSolution} from "@/components/Solutions";
|
||||
import { ListeningExam, MultipleChoiceExercise, UserSolution } from "@/interfaces/exam";
|
||||
import { useEffect, useState } from "react";
|
||||
import { renderExercise } from "@/components/Exercises";
|
||||
import { renderSolution } from "@/components/Solutions";
|
||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import AudioPlayer from "@/components/Low/AudioPlayer";
|
||||
import Button from "@/components/Low/Button";
|
||||
import BlankQuestionsModal from "@/components/QuestionsModal";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {countExercises} from "@/utils/moduleUtils";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
|
||||
import { countExercises } from "@/utils/moduleUtils";
|
||||
import PartDivider from "./Navigation/SectionDivider";
|
||||
|
||||
interface Props {
|
||||
exam: ListeningExam;
|
||||
showSolutions?: boolean;
|
||||
preview?: boolean;
|
||||
onFinish: (userSolutions: UserSolution[]) => void;
|
||||
}
|
||||
|
||||
const INSTRUCTIONS_AUDIO_SRC =
|
||||
"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, preview = false, onFinish }: Props) {
|
||||
const listeningBgColor = "bg-ielts-listening-light";
|
||||
|
||||
const [timesListened, setTimesListened] = useState(0);
|
||||
const [showBlankModal, setShowBlankModal] = useState(false);
|
||||
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
|
||||
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]);
|
||||
|
||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
||||
const {partIndex, setPartIndex} = useExamStore((state) => state);
|
||||
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
||||
const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]);
|
||||
|
||||
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.parts.map((_, index) => index) : []));
|
||||
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && exam.parts[0].intro !== "");
|
||||
|
||||
const examState = useExamStore((state) => state);
|
||||
const persistentExamState = usePersistentExamStore((state) => state);
|
||||
|
||||
const {
|
||||
hasExamEnded,
|
||||
userSolutions,
|
||||
exerciseIndex,
|
||||
partIndex,
|
||||
questionIndex: storeQuestionIndex,
|
||||
setBgColor,
|
||||
setUserSolutions,
|
||||
setHasExamEnded,
|
||||
setExerciseIndex,
|
||||
setPartIndex,
|
||||
setQuestionIndex: setStoreQuestionIndex
|
||||
} = !preview ? examState : persistentExamState;
|
||||
|
||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSolutions && exam.parts[partIndex]?.intro !== undefined && exam.parts[partIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) {
|
||||
setShowPartDivider(true);
|
||||
setBgColor(listeningBgColor);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [partIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showSolutions) return setExerciseIndex(-1);
|
||||
}, [setExerciseIndex, showSolutions]);
|
||||
@@ -52,7 +79,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
previousMultipleChoice = [...previousMultipleChoice, ...partMultipleChoice];
|
||||
}
|
||||
|
||||
setMultipleChoicesDone(previousMultipleChoice.map((x) => ({id: x.id, amount: x.questions.length - 1})));
|
||||
setMultipleChoicesDone(previousMultipleChoice.map((x) => ({ id: x.id, amount: x.questions.length - 1 })));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -74,11 +101,11 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "listening", exam: exam.id}]);
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "listening", exam: exam.id }]);
|
||||
}
|
||||
if (storeQuestionIndex > 0) {
|
||||
const exercise = getExercise();
|
||||
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: storeQuestionIndex}]);
|
||||
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), { id: exercise.id, amount: storeQuestionIndex }]);
|
||||
}
|
||||
setStoreQuestionIndex(0);
|
||||
|
||||
@@ -109,7 +136,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
setHasExamEnded(false);
|
||||
|
||||
if (solution) {
|
||||
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "listening", exam: exam.id}]);
|
||||
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "listening", exam: exam.id }]);
|
||||
} else {
|
||||
onFinish(userSolutions);
|
||||
}
|
||||
@@ -118,7 +145,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "listening", exam: exam.id}]);
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "listening", exam: exam.id }]);
|
||||
}
|
||||
setStoreQuestionIndex(0);
|
||||
|
||||
@@ -171,90 +198,109 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
|
||||
const renderAudioPlayer = () => (
|
||||
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
<h4 className="text-xl font-semibold">Please listen to the following audio attentively.</h4>
|
||||
<span className="text-base">
|
||||
{exam.parts[partIndex].audio.repeatableTimes > 0
|
||||
? `You will only be allowed to listen to the audio ${exam.parts[partIndex].audio.repeatableTimes - timesListened} time(s).`
|
||||
: "You may listen to the audio as many times as you would like."}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
|
||||
<AudioPlayer
|
||||
key={partIndex}
|
||||
src={exam.parts[partIndex].audio.source}
|
||||
color="listening"
|
||||
onEnd={() => setTimesListened((prev) => prev + 1)}
|
||||
disabled={timesListened === exam.parts[partIndex].audio.repeatableTimes}
|
||||
disablePause
|
||||
/>
|
||||
</div>
|
||||
{exam.parts[partIndex].audio ? (
|
||||
<>
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
<h4 className="text-xl font-semibold">Please listen to the following audio attentively.</h4>
|
||||
<span className="text-base">
|
||||
{exam.parts[partIndex].audio.repeatableTimes > 0
|
||||
? `You will only be allowed to listen to the audio ${exam.parts[partIndex].audio.repeatableTimes - timesListened} time(s).`
|
||||
: "You may listen to the audio as many times as you would like."}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
|
||||
<AudioPlayer
|
||||
key={partIndex}
|
||||
src={exam.parts[partIndex].audio.source}
|
||||
color="listening"
|
||||
onEnd={() => setTimesListened((prev) => prev + 1)}
|
||||
disabled={timesListened === exam.parts[partIndex].audio.repeatableTimes}
|
||||
disablePause
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span>This section will be displayed the audio once it has been generated.</span>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
||||
<div className="flex flex-col h-full w-full gap-8 justify-between">
|
||||
<ModuleTitle
|
||||
exerciseIndex={calculateExerciseIndex()}
|
||||
minTimer={exam.minTimer}
|
||||
{showPartDivider ?
|
||||
<PartDivider
|
||||
module="listening"
|
||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||
disableTimer={showSolutions}
|
||||
/>
|
||||
{/* Audio Player for the Instructions */}
|
||||
{partIndex === -1 && renderAudioInstructionsPlayer()}
|
||||
sectionLabel="Section"
|
||||
defaultTitle="Listening exam"
|
||||
section={exam.parts[partIndex]}
|
||||
sectionIndex={partIndex}
|
||||
onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex)) }}
|
||||
/> : (
|
||||
<>
|
||||
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
||||
<div className="flex flex-col h-full w-full gap-8 justify-between">
|
||||
<ModuleTitle
|
||||
exerciseIndex={calculateExerciseIndex()}
|
||||
minTimer={exam.minTimer}
|
||||
module="listening"
|
||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||
disableTimer={showSolutions}
|
||||
/>
|
||||
{/* Audio Player for the Instructions */}
|
||||
{partIndex === -1 && renderAudioInstructionsPlayer()}
|
||||
|
||||
{/* Part's audio player */}
|
||||
{partIndex > -1 && renderAudioPlayer()}
|
||||
{/* Part's audio player */}
|
||||
{partIndex > -1 && renderAudioPlayer()}
|
||||
|
||||
{/* Exercise renderer */}
|
||||
{exerciseIndex > -1 &&
|
||||
partIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
|
||||
{/* Exercise renderer */}
|
||||
{exerciseIndex > -1 &&
|
||||
partIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
|
||||
|
||||
{/* Solution renderer */}
|
||||
{exerciseIndex > -1 &&
|
||||
partIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||
</div>
|
||||
{/* Solution renderer */}
|
||||
{exerciseIndex > -1 &&
|
||||
partIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||
</div>
|
||||
|
||||
{exerciseIndex === -1 && partIndex > -1 && exam.variant !== "partial" && (
|
||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (partIndex === 0) return setPartIndex(-1);
|
||||
{exerciseIndex === -1 && partIndex > -1 && exam.variant !== "partial" && (
|
||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (partIndex === 0) return setPartIndex(-1);
|
||||
|
||||
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
||||
setPartIndex(partIndex - 1);
|
||||
}}
|
||||
className="max-w-[200px] w-full">
|
||||
Back
|
||||
</Button>
|
||||
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
||||
setPartIndex(partIndex - 1);
|
||||
}}
|
||||
className="max-w-[200px] w-full">
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{partIndex === -1 && exam.variant !== "partial" && (
|
||||
<Button color="purple" onClick={() => setPartIndex(0)} className="max-w-[200px] self-end w-full justify-self-end">
|
||||
Start now
|
||||
</Button>
|
||||
)}
|
||||
{exerciseIndex === -1 && partIndex === 0 && exam.variant === "partial" && (
|
||||
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
|
||||
Start now
|
||||
</Button>
|
||||
)}
|
||||
{partIndex === -1 && exam.variant !== "partial" && (
|
||||
<Button color="purple" onClick={() => setPartIndex(0)} className="max-w-[200px] self-end w-full justify-self-end">
|
||||
Start now
|
||||
</Button>
|
||||
)}
|
||||
{exerciseIndex === -1 && partIndex === 0 && exam.variant === "partial" && (
|
||||
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
|
||||
Start now
|
||||
</Button>
|
||||
)}
|
||||
</>)
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function Reading({ exam, showSolutions = false, preview = false,
|
||||
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]);
|
||||
const [isTextMinimized, setIsTextMinimzed] = useState(false);
|
||||
const [exerciseType, setExerciseType] = useState("");
|
||||
|
||||
|
||||
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.parts.map((_, index) => index) : []));
|
||||
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && exam.parts[0].intro !== "");
|
||||
|
||||
@@ -304,15 +304,17 @@ export default function Reading({ exam, showSolutions = false, preview = false,
|
||||
|
||||
return (
|
||||
<>
|
||||
{(showPartDivider) ?
|
||||
<PartDivider
|
||||
module="reading"
|
||||
sectionLabel="Part"
|
||||
defaultTitle="Reading exam"
|
||||
section={exam.parts[partIndex]}
|
||||
sectionIndex={partIndex}
|
||||
onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex)) }}
|
||||
/> : (
|
||||
{showPartDivider ?
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<PartDivider
|
||||
module="reading"
|
||||
sectionLabel="Part"
|
||||
defaultTitle="Reading exam"
|
||||
section={exam.parts[partIndex]}
|
||||
sectionIndex={partIndex}
|
||||
onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex)) }}
|
||||
/>
|
||||
</div> : (
|
||||
<>
|
||||
<div className="flex flex-col h-full w-full gap-8">
|
||||
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
||||
|
||||
@@ -47,6 +47,14 @@ export interface LevelExam extends ExamBase {
|
||||
export interface LevelPart extends Section {
|
||||
context?: string;
|
||||
exercises: Exercise[];
|
||||
audio?: {
|
||||
source: string;
|
||||
repeatableTimes: number; // *The amount of times the user is allowed to repeat the audio, 0 for unlimited
|
||||
};
|
||||
text?: {
|
||||
title: string;
|
||||
content: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ListeningExam extends ExamBase {
|
||||
|
||||
@@ -3,15 +3,14 @@ import { ModuleState } from "../types";
|
||||
import ReorderResult from "./types";
|
||||
|
||||
const reorderFillBlanks = (exercise: FillBlanksExercise, startId: number): ReorderResult<FillBlanksExercise> => {
|
||||
console.log();
|
||||
const newSolutions = exercise.solutions
|
||||
let newSolutions = exercise.solutions
|
||||
.sort((a, b) => parseInt(a.id) - parseInt(b.id))
|
||||
.map((solution, index) => ({
|
||||
...solution,
|
||||
id: (startId + index).toString()
|
||||
}));
|
||||
|
||||
const idMapping = exercise.solutions
|
||||
let idMapping = exercise.solutions
|
||||
.sort((a, b) => parseInt(a.id) - parseInt(b.id))
|
||||
.reduce((acc, solution, index) => {
|
||||
acc[solution.id] = (startId + index).toString();
|
||||
@@ -25,7 +24,7 @@ const reorderFillBlanks = (exercise: FillBlanksExercise, startId: number): Reord
|
||||
});
|
||||
|
||||
|
||||
const newWords = exercise.words.map(word => {
|
||||
let newWords = exercise.words.map(word => {
|
||||
if (typeof word === 'string') {
|
||||
return word;
|
||||
} else if ('letter' in word && 'word' in word) {
|
||||
@@ -36,7 +35,7 @@ const reorderFillBlanks = (exercise: FillBlanksExercise, startId: number): Reord
|
||||
return word;
|
||||
});
|
||||
|
||||
const newUserSolutions = exercise.userSolutions?.map(solution => ({
|
||||
let newUserSolutions = exercise.userSolutions?.map(solution => ({
|
||||
...solution,
|
||||
id: idMapping[solution.id] || solution.id
|
||||
}));
|
||||
@@ -55,36 +54,36 @@ const reorderFillBlanks = (exercise: FillBlanksExercise, startId: number): Reord
|
||||
};
|
||||
|
||||
const reorderWriteBlanks = (exercise: WriteBlanksExercise, startId: number): ReorderResult<WriteBlanksExercise> => {
|
||||
const oldIds = exercise.solutions.map(s => s.id);
|
||||
const newIds = oldIds.map((_, index) => (startId + index).toString());
|
||||
|
||||
const newSolutions = exercise.solutions.map((solution, index) => ({
|
||||
id: newIds[index],
|
||||
solution: [...solution.solution]
|
||||
let oldIds = exercise.solutions.map(s => s.id);
|
||||
let newIds = oldIds.map((_, index) => (startId + index).toString());
|
||||
|
||||
let newSolutions = exercise.solutions.map((solution, index) => ({
|
||||
id: newIds[index],
|
||||
solution: [...solution.solution]
|
||||
}));
|
||||
|
||||
|
||||
let newText = exercise.text;
|
||||
oldIds.forEach((oldId, index) => {
|
||||
newText = newText.replace(
|
||||
`{{${oldId}}}`,
|
||||
`{{${newIds[index]}}}`
|
||||
);
|
||||
newText = newText.replace(
|
||||
`{{${oldId}}}`,
|
||||
`{{${newIds[index]}}}`
|
||||
);
|
||||
});
|
||||
|
||||
const result = {
|
||||
exercise: {
|
||||
...exercise,
|
||||
solutions: newSolutions,
|
||||
text: newText
|
||||
},
|
||||
lastId: startId + newSolutions.length
|
||||
|
||||
let result = {
|
||||
exercise: {
|
||||
...exercise,
|
||||
solutions: newSolutions,
|
||||
text: newText
|
||||
},
|
||||
lastId: startId + newSolutions.length
|
||||
};
|
||||
|
||||
|
||||
return result;
|
||||
};
|
||||
};
|
||||
|
||||
const reorderTrueFalse = (exercise: TrueFalseExercise, startId: number): ReorderResult<TrueFalseExercise> => {
|
||||
const newQuestions = exercise.questions
|
||||
let newQuestions = exercise.questions
|
||||
.sort((a, b) => parseInt(a.id) - parseInt(b.id))
|
||||
.map((question, index) => ({
|
||||
...question,
|
||||
@@ -101,7 +100,7 @@ const reorderTrueFalse = (exercise: TrueFalseExercise, startId: number): Reorder
|
||||
};
|
||||
|
||||
const reorderMatchSentences = (exercise: MatchSentencesExercise, startId: number): ReorderResult<MatchSentencesExercise> => {
|
||||
const newSentences = exercise.sentences
|
||||
let newSentences = exercise.sentences
|
||||
.sort((a, b) => parseInt(a.id) - parseInt(b.id))
|
||||
.map((sentence, index) => ({
|
||||
...sentence,
|
||||
@@ -119,7 +118,7 @@ const reorderMatchSentences = (exercise: MatchSentencesExercise, startId: number
|
||||
|
||||
|
||||
const reorderMultipleChoice = (exercise: MultipleChoiceExercise, startId: number): ReorderResult<MultipleChoiceExercise> => {
|
||||
const newQuestions = exercise.questions
|
||||
let newQuestions = exercise.questions
|
||||
.sort((a, b) => parseInt(a.id) - parseInt(b.id))
|
||||
.map((question, index) => ({
|
||||
...question,
|
||||
@@ -138,7 +137,7 @@ const reorderMultipleChoice = (exercise: MultipleChoiceExercise, startId: number
|
||||
|
||||
const reorderSection = (exercises: Exercise[], startId: number): { exercises: Exercise[], lastId: number } => {
|
||||
let currentId = startId;
|
||||
const reorderedExercises = exercises.map(exercise => {
|
||||
let reorderedExercises = exercises.map(exercise => {
|
||||
let result;
|
||||
|
||||
switch (exercise.type) {
|
||||
@@ -182,26 +181,25 @@ const reorderSection = (exercises: Exercise[], startId: number): { exercises: Ex
|
||||
|
||||
const reorderModule = (moduleState: ModuleState) => {
|
||||
let currentId = 1;
|
||||
const reorderedSections = moduleState.sections.map(section => {
|
||||
const currentSection = section.state as ReadingPart | ListeningPart | LevelPart;
|
||||
console.log(currentSection.exercises);
|
||||
const result = reorderSection(currentSection.exercises, currentId);
|
||||
currentId = result.lastId;
|
||||
console.log(result);
|
||||
return {
|
||||
...section,
|
||||
state: {
|
||||
...currentSection,
|
||||
exercises: result.exercises
|
||||
}
|
||||
};
|
||||
let reorderedSections = moduleState.sections.map(section => {
|
||||
let currentSection = section.state as ReadingPart | ListeningPart | LevelPart;
|
||||
console.log(currentSection.exercises);
|
||||
let result = reorderSection(currentSection.exercises, currentId);
|
||||
currentId = result.lastId;
|
||||
return {
|
||||
...section,
|
||||
state: {
|
||||
...currentSection,
|
||||
exercises: result.exercises
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
...moduleState,
|
||||
sections: reorderedSections
|
||||
...moduleState,
|
||||
sections: reorderedSections
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export {
|
||||
reorderFillBlanks,
|
||||
|
||||
Reference in New Issue
Block a user