Listening preview and some more patches

This commit is contained in:
Carlos-Mesquita
2024-11-06 09:23:34 +00:00
parent ffa2045a2d
commit b50913eda8
24 changed files with 467 additions and 253 deletions

View File

@@ -1,13 +1,13 @@
import { toast } from "react-toastify"; import { toast } from "react-toastify";
type TextToken = { export type TextToken = {
type: 'text'; type: 'text';
content: string; content: string;
isWhitespace: boolean; isWhitespace: boolean;
isLineBreak?: boolean; isLineBreak?: boolean;
}; };
type BlankToken = { export type BlankToken = {
type: 'blank'; type: 'blank';
id: number; id: number;
}; };

View File

@@ -1,5 +1,4 @@
import { AlertItem } from "../../Shared/Alert"; import { AlertItem } from "../../Shared/Alert";
import { BlankState } from "../BlanksReducer";
export const validateWriteBlanks = ( export const validateWriteBlanks = (

View File

@@ -18,7 +18,7 @@ import Alert, { AlertItem } from "../Shared/Alert";
import clsx from "clsx"; import clsx from "clsx";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Blank, DropZone } from "./DragNDrop"; import { Blank, DropZone } from "./DragNDrop";
import { getTextSegments, BlankState, BlanksState, BlanksAction } from "./BlanksReducer"; import { getTextSegments, BlankState, BlanksState, BlanksAction, BlankToken } from "./BlanksReducer";
interface Props { interface Props {
@@ -104,11 +104,11 @@ const BlanksEditor: React.FC<Props> = ({
const existingBlankIds = getTextSegments(state.text) const existingBlankIds = getTextSegments(state.text)
.filter(token => token.type === 'blank') .filter(token => token.type === 'blank')
.map(token => token.id); .map(token => (token as BlankToken).id);
const newBlankIds = getTextSegments(processedText) const newBlankIds = getTextSegments(processedText)
.filter(token => token.type === 'blank') .filter(token => token.type === 'blank')
.map(token => token.id); .map(token => (token as BlankToken).id);
const removedBlankIds = existingBlankIds.filter(id => !newBlankIds.includes(id)); const removedBlankIds = existingBlankIds.filter(id => !newBlankIds.includes(id));

View File

@@ -17,6 +17,7 @@ import setEditingAlert from '../Shared/setEditingAlert';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { DragEndEvent } from '@dnd-kit/core'; import { DragEndEvent } from '@dnd-kit/core';
import { handleMatchSentencesReorder } from '@/stores/examEditor/reorder/local'; import { handleMatchSentencesReorder } from '@/stores/examEditor/reorder/local';
import PromptEdit from '../Shared/PromptEdit';
const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: number }> = ({ exercise, sectionId }) => { const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: number }> = ({ exercise, sectionId }) => {
const { currentModule, dispatch } = useExamEditorStore(); const { currentModule, dispatch } = useExamEditorStore();
@@ -31,6 +32,11 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu
const [showReference, setShowReference] = useState(false); const [showReference, setShowReference] = useState(false);
const [alerts, setAlerts] = useState<AlertItem[]>([]); const [alerts, setAlerts] = useState<AlertItem[]>([]);
const updateLocal = (exercise: MatchSentencesExercise) => {
setLocal(exercise);
setEditing(true);
};
const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({ const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({
sectionId, sectionId,
onSave: () => { onSave: () => {
@@ -73,9 +79,8 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu
}, [local.sentences]); }, [local.sentences]);
const addHeading = () => { const addHeading = () => {
setEditing(true);
const newId = (parseInt(local.sentences[local.sentences.length - 1].id) + 1).toString(); const newId = (parseInt(local.sentences[local.sentences.length - 1].id) + 1).toString();
setLocal({ updateLocal({
...local, ...local,
sentences: [ sentences: [
...local.sentences, ...local.sentences,
@@ -89,7 +94,6 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu
}; };
const updateHeading = (index: number, field: string, value: string) => { const updateHeading = (index: number, field: string, value: string) => {
setEditing(true);
const newSentences = [...local.sentences]; const newSentences = [...local.sentences];
if (field === 'solution') { if (field === 'solution') {
@@ -100,11 +104,10 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu
} }
newSentences[index] = { ...newSentences[index], [field]: value }; newSentences[index] = { ...newSentences[index], [field]: value };
setLocal({ ...local, sentences: newSentences }); updateLocal({ ...local, sentences: newSentences });
}; };
const deleteHeading = (index: number) => { const deleteHeading = (index: number) => {
setEditing(true);
if (local.sentences.length <= 1) { if (local.sentences.length <= 1) {
toast.error(`There needs to be at least one ${exercise.variant && exercise.variant == "ideaMatch" ? "idea/opinion" : "heading"}!`); toast.error(`There needs to be at least one ${exercise.variant && exercise.variant == "ideaMatch" ? "idea/opinion" : "heading"}!`);
return; return;
@@ -116,7 +119,7 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu
} }
const newSentences = local.sentences.filter((_, i) => i !== index); const newSentences = local.sentences.filter((_, i) => i !== index);
setLocal({ ...local, sentences: newSentences }); updateLocal({ ...local, sentences: newSentences });
}; };
useEffect(() => { useEffect(() => {
@@ -129,8 +132,7 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
setEditing(true); updateLocal(handleMatchSentencesReorder(event, local));
setLocal(handleMatchSentencesReorder(event, local));
} }
return ( return (
@@ -154,6 +156,10 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu
<div className="space-y-4"> <div className="space-y-4">
{alerts.length > 0 && <Alert alerts={alerts} />} {alerts.length > 0 && <Alert alerts={alerts} />}
<PromptEdit
value={local.prompt}
onChange={(text) => updateLocal({ ...local, prompt: text })}
/>
<QuestionsList <QuestionsList
ids={local.sentences.map(s => s.id)} ids={local.sentences.map(s => s.id)}
handleDragEnd={handleDragEnd} handleDragEnd={handleDragEnd}

View 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;

View File

@@ -24,11 +24,20 @@ const Speaking: React.FC<Props> = ({ sectionId, exercise }) => {
const [loading, setLoading] = useState(generating === "context"); const [loading, setLoading] = useState(generating === "context");
const [questions, setQuestions] = useState(() => { const [questions, setQuestions] = useState(() => {
if (sectionId === 1) { 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) { } 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 { } 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("");
} }
}); });
@@ -53,7 +62,6 @@ const Speaking: React.FC<Props> = ({ sectionId, exercise }) => {
prompts: questions.slice(1), prompts: questions.slice(1),
} as SpeakingExercise; } as SpeakingExercise;
} else { } else {
// Section 3
newExercise = { newExercise = {
...local, ...local,
prompts: questions.map(text => ({ prompts: questions.map(text => ({
@@ -73,12 +81,15 @@ const Speaking: React.FC<Props> = ({ sectionId, exercise }) => {
}, },
onDiscard: () => { onDiscard: () => {
setLocal(exercise); setLocal(exercise);
},
onMode: () => {
setLocal(exercise);
if (sectionId === 1) { if (sectionId === 1) {
setQuestions((exercise as SpeakingExercise).prompts || Array(5).fill("")); setQuestions(Array(5).fill(""));
} else if (sectionId === 2) { } else if (sectionId === 2) {
setQuestions([(exercise as SpeakingExercise).text || "", ...(exercise as SpeakingExercise).prompts || Array(3).fill("")]); setQuestions(Array(3).fill(""));
} else { } 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) { if (isLoading) {
updateModule({ edit: Array.from(new Set([...edit, sectionId])) }); updateModule({ edit: Array.from(new Set([...edit, sectionId])) });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [generating, sectionId]); }, [generating, sectionId]);
useEffect(() => { useEffect(() => {

View File

@@ -1,9 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { import {
MdAdd, MdAdd,
MdEdit,
MdEditOff,
} from 'react-icons/md'; } from 'react-icons/md';
import Alert, { AlertItem } from '../Shared/Alert'; import Alert, { AlertItem } from '../Shared/Alert';
import { ReadingPart, TrueFalseExercise } from '@/interfaces/exam'; import { ReadingPart, TrueFalseExercise } from '@/interfaces/exam';
@@ -18,6 +15,7 @@ import validateTrueFalseQuestions from './validation';
import setEditingAlert from '../Shared/setEditingAlert'; import setEditingAlert from '../Shared/setEditingAlert';
import { DragEndEvent } from '@dnd-kit/core'; import { DragEndEvent } from '@dnd-kit/core';
import { handleTrueFalseReorder } from '@/stores/examEditor/reorder/local'; import { handleTrueFalseReorder } from '@/stores/examEditor/reorder/local';
import PromptEdit from '../Shared/PromptEdit';
const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> = ({ exercise, sectionId }) => { const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> = ({ exercise, sectionId }) => {
const { currentModule, dispatch } = useExamEditorStore(); const { currentModule, dispatch } = useExamEditorStore();
@@ -28,7 +26,6 @@ const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> =
const section = state as ReadingPart; const section = state as ReadingPart;
const [local, setLocal] = useState(exercise); const [local, setLocal] = useState(exercise);
const [editingPrompt, setEditingPrompt] = useState(false);
const [alerts, setAlerts] = useState<AlertItem[]>([]); const [alerts, setAlerts] = useState<AlertItem[]>([]);
@@ -134,36 +131,10 @@ const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> =
handleDiscard={handleDiscard} handleDiscard={handleDiscard}
/> />
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />} {alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
<Card className="mb-6"> <PromptEdit
<CardContent className="p-4"> value={local.prompt}
<div className="flex justify-between items-start gap-4"> onChange={(text) => updateLocal({ ...local, prompt: text })}
{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>
<div className="space-y-4"> <div className="space-y-4">
<QuestionsList <QuestionsList
ids={local.questions.map(q => q.id)} ids={local.questions.map(q => q.id)}

View File

@@ -23,7 +23,7 @@ import { handleWriteBlanksReorder } from '@/stores/examEditor/reorder/local';
import { ParsedQuestion, parseText, reconstructText } from './parsing'; 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 { currentModule, dispatch } = useExamEditorStore();
const { state } = useExamEditorStore( const { state } = useExamEditorStore(
@@ -198,7 +198,6 @@ const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise }
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
setEditing(true); setEditing(true);
console.log("ASOJNFOAI+SHJOIPFAS");
setLocal(handleWriteBlanksReorder(event, local)); setLocal(handleWriteBlanksReorder(event, local));
} }
@@ -223,7 +222,7 @@ const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise }
return ( return (
<div className="p-4"> <div className="p-4">
<Header <Header
title="Write Blanks Exercise" title={title}
description="Edit questions and their solutions" description="Edit questions and their solutions"
editing={editing} editing={editing}
handleSave={handleSave} handleSave={handleSave}

View File

@@ -15,15 +15,13 @@ interface Props {
exercise: WritingExercise; exercise: WritingExercise;
} }
const Writing: React.FC<Props> = ({ sectionId }) => { const Writing: React.FC<Props> = ({ sectionId, exercise }) => {
const { currentModule, dispatch } = useExamEditorStore(); const { currentModule, dispatch } = useExamEditorStore();
const { edit } = useExamEditorStore((store) => store.modules[currentModule]); const { edit } = useExamEditorStore((store) => store.modules[currentModule]);
const { generating, genResult, state } = useExamEditorStore( const { generating, genResult, state } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
); );
const exercise = state as WritingExercise;
const [local, setLocal] = useState(exercise); const [local, setLocal] = useState(exercise);
const [prompt, setPrompt] = useState(exercise.prompt); const [prompt, setPrompt] = useState(exercise.prompt);
const [loading, setLoading] = useState(generating && generating == "exercises"); 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 } }); dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: newExercise } });
}, },
onDiscard: () => { onDiscard: () => {
setEditing(false);
setLocal(exercise); setLocal(exercise);
setPrompt(exercise.prompt);
}, },
}); });
@@ -67,10 +67,6 @@ const Writing: React.FC<Props> = ({ sectionId }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, dispatch, sectionId, setEditing, currentModule]); }, [genResult, dispatch, sectionId, setEditing, currentModule]);
useEffect(() => {
setLocal(state as WritingExercise);
}, [state]);
useEffect(() => { useEffect(() => {
setEditingAlert(prompt !== local.prompt, setAlerts); setEditingAlert(prompt !== local.prompt, setAlerts);
}, [prompt, local.prompt]); }, [prompt, local.prompt]);

View File

@@ -59,7 +59,7 @@ const useSettingsState = <T extends SectionSettings>(
}, [dispatch, module, sectionId]); }, [dispatch, module, sectionId]);
const updateLocalAndScheduleGlobal = useCallback((updates: Partial<T>) => { const updateLocalAndScheduleGlobal = useCallback((updates: Partial<T>, schedule: boolean = true) => {
setLocalSettings(prev => ({ setLocalSettings(prev => ({
...prev, ...prev,
...updates ...updates
@@ -69,7 +69,9 @@ const useSettingsState = <T extends SectionSettings>(
...pendingUpdatesRef.current, ...pendingUpdatesRef.current,
...updates ...updates
}; };
debouncedUpdateGlobal(); if (schedule) {
debouncedUpdateGlobal();
}
}, [debouncedUpdateGlobal]); }, [debouncedUpdateGlobal]);
return { return {

View File

@@ -37,6 +37,7 @@ const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
setScript(genResult[0].script) setScript(genResult[0].script)
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 } })
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, dispatch, sectionId, setEditing, currentModule]); }, [genResult, dispatch, sectionId, setEditing, currentModule]);
const renderContent = (editing: boolean) => { const renderContent = (editing: boolean) => {

View File

@@ -5,6 +5,7 @@ import { Exercise, WriteBlanksExercise } from '@/interfaces/exam';
import MultipleChoice from '../../Exercises/MultipleChoice'; import MultipleChoice from '../../Exercises/MultipleChoice';
import WriteBlanksForm from '../../Exercises/WriteBlanksForm'; import WriteBlanksForm from '../../Exercises/WriteBlanksForm';
import WriteBlanksFill from '../../Exercises/Blanks/WriteBlankFill'; import WriteBlanksFill from '../../Exercises/Blanks/WriteBlankFill';
import WriteBlanks from '../../Exercises/WriteBlanks';
const writeBlanks = (exercise: WriteBlanksExercise, index: number, sectionId: number, previewLabel: (text: string) => string) => { 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} /> content: <WriteBlanksFill exercise={exercise} sectionId={sectionId} />
}; };
case 'questions':
return {
id: index,
sectionId,
label: (
<ExerciseLabel
label={`Write Blanks: Questions #${firstWordId} ${firstWordId === lastWordId ? '' : `- #${lastWordId}`}`}
preview={
<>
&quot;{previewLabel(exercise.prompt)}...&quot;
</>
}
/>
),
content: <WriteBlanks exercise={exercise} sectionId={sectionId} title='Write Blanks: Questions' />
};
} }
} }

View File

@@ -80,10 +80,6 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
}); });
}, [updateLocalAndScheduleGlobal]); }, [updateLocalAndScheduleGlobal]);
const submitExam = () => {
}
return ( return (
<div className={`flex flex-col gap-8 border bg-ielts-${module}/20 rounded-3xl p-8 w-1/3 h-fit`}> <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> <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" title="Category"
module={module} module={module}
open={localSettings.isCategoryDropdownOpen} open={localSettings.isCategoryDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isCategoryDropdownOpen: isOpen })} setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isCategoryDropdownOpen: isOpen }, false)}
> >
<Input <Input
key={`section-${sectionId}`} key={`section-${sectionId}`}
@@ -108,7 +104,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
title="Divider" title="Divider"
module={module} module={module}
open={localSettings.isIntroDropdownOpen} 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"> <div className="flex flex-col gap-3 w-full">
<Select <Select

View File

@@ -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"} contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
open={localSettings.isExerciseDropdownOpen} open={localSettings.isExerciseDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen })} setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)}
> >
<ExercisePicker <ExercisePicker
module="level" module="level"

View File

@@ -8,17 +8,33 @@ import { Generating, ListeningSectionSettings } from "@/stores/examEditor/types"
import Option from "@/interfaces/option"; import Option from "@/interfaces/option";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import useSettingsState from "../Hooks/useSettingsState"; import useSettingsState from "../Hooks/useSettingsState";
import { ListeningPart } from "@/interfaces/exam"; import { ListeningExam, ListeningPart } from "@/interfaces/exam";
import Input from "@/components/Low/Input"; 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 ListeningSettings: React.FC = () => {
const {currentModule } = useExamEditorStore(); const router = useRouter();
const {currentModule, title } = useExamEditorStore();
const { const {
focusedSection, focusedSection,
difficulty, difficulty,
sections, sections,
minTimer,
isPrivate
} = useExamEditorStore(state => state.modules[currentModule]); } = useExamEditorStore(state => state.modules[currentModule]);
const {
setExam,
setExerciseIndex,
setPartIndex,
setQuestionIndex,
} = usePersistentExamStore();
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<ListeningSectionSettings>( const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<ListeningSectionSettings>(
currentModule, currentModule,
focusedSection focusedSection
@@ -61,22 +77,90 @@ const ListeningSettings: React.FC = () => {
updateLocalAndScheduleGlobal({ topic }); updateLocalAndScheduleGlobal({ topic });
}, [updateLocalAndScheduleGlobal]); }, [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 ( return (
<SettingsEditor <SettingsEditor
sectionLabel={`Section ${focusedSection}`} sectionLabel={`Section ${focusedSection}`}
sectionId={focusedSection} sectionId={focusedSection}
module="listening" module="listening"
introPresets={[defaultPresets[focusedSection - 1]]} introPresets={[defaultPresets[focusedSection - 1]]}
preview={()=> {}} canPreview={canPreview}
canPreview={false} canSubmit={canSubmit}
canSubmit={false} preview={preview}
submitModule={()=> {}} submitModule={submitListening}
> >
<Dropdown <Dropdown
title="Audio Context" title="Audio Context"
module={currentModule} module={currentModule}
open={localSettings.isAudioContextOpen} 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-row gap-2 items-center px-2 pb-4">
<div className="flex flex-col flex-grow gap-4 px-2"> <div className="flex flex-col flex-grow gap-4 px-2">
@@ -105,7 +189,7 @@ const ListeningSettings: React.FC = () => {
title="Add Exercises" title="Add Exercises"
module={currentModule} module={currentModule}
open={localSettings.isExerciseDropdownOpen} open={localSettings.isExerciseDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen })} setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)}
disabled={currentSection.script === undefined && currentSection.audio === undefined} disabled={currentSection.script === undefined && currentSection.audio === undefined}
> >
<ExercisePicker <ExercisePicker

View File

@@ -85,6 +85,10 @@ const ReadingSettings: React.FC = () => {
); );
const submitReading = () => { const submitReading = () => {
if (title === "") {
toast.error("Enter a title for the exam!");
return;
}
const exam: ReadingExam = { const exam: ReadingExam = {
parts: sections.map((s) => { parts: sections.map((s) => {
const exercise = s.state as ReadingPart; const exercise = s.state as ReadingPart;
@@ -154,7 +158,7 @@ const ReadingSettings: React.FC = () => {
title="Generate Passage" title="Generate Passage"
module={currentModule} module={currentModule}
open={localSettings.isPassageOpen} 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-row gap-2 items-center px-2 pb-4">
<div className="flex flex-col flex-grow gap-4 px-2"> <div className="flex flex-col flex-grow gap-4 px-2">
@@ -184,7 +188,7 @@ const ReadingSettings: React.FC = () => {
module={currentModule} module={currentModule}
open={localSettings.isExerciseDropdownOpen} open={localSettings.isExerciseDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen })} setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen })}
disabled={currentSection.text.content === "" || currentSection.text.title === ""} disabled={currentSection.text === undefined || currentSection.text.content === "" || currentSection.text.title === ""}
> >
<ExercisePicker <ExercisePicker
module="reading" module="reading"

View File

@@ -113,7 +113,7 @@ const SpeakingSettings: React.FC = () => {
title="Generate Script" title="Generate Script"
module={currentModule} module={currentModule}
open={localSettings.isExerciseDropdownOpen} 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" )}> <div className={clsx("gap-2 px-2 pb-4", focusedSection === 1 ? "flex flex-col w-full" : "flex flex-row items-center" )}>

View File

@@ -149,7 +149,7 @@ const WritingSettings: React.FC = () => {
title="Generate Instructions" title="Generate Instructions"
module={currentModule} module={currentModule}
open={localSettings.isExerciseDropdownOpen} 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"> <div className="flex flex-row gap-2 items-center px-2 pb-4">

View File

@@ -1,7 +1,7 @@
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import clsx from "clsx"; import clsx from "clsx";
import { ReactNode } from "react"; 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 { interface Props {
title: string; title: string;
@@ -11,13 +11,15 @@ interface Props {
handleSave: () => void; handleSave: () => void;
handleDiscard: () => void; handleDiscard: () => void;
modeHandle?: () => void; modeHandle?: () => void;
evaluationHandle?: () => void;
isEvaluationEnabled?: boolean;
mode?: "delete" | "edit"; mode?: "delete" | "edit";
children?: ReactNode; 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 ( return (
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6 text-sm">
<div> <div>
<h1 className="text-2xl font-bold text-gray-800">{title}</h1> <h1 className="text-2xl font-bold text-gray-800">{title}</h1>
<p className="text-gray-600 mt-1">{description}</p> <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} 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`} 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 Edit
</button> </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>
</div> </div>
); );

View File

@@ -1,8 +1,9 @@
import Level from "@/exams/Level"; import Level from "@/exams/Level";
import Listening from "@/exams/Listening";
import Reading from "@/exams/Reading"; import Reading from "@/exams/Reading";
import Writing from "@/exams/Writing"; import Writing from "@/exams/Writing";
import { usePersistentStorage } from "@/hooks/usePersistentStorage"; 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 { User } from "@/interfaces/user";
import { usePersistentExamStore } from "@/stores/examStore"; import { usePersistentExamStore } from "@/stores/examStore";
import clsx from "clsx"; import clsx from "clsx";
@@ -34,6 +35,13 @@ const Popout: React.FC<{ user: User }> = ({ user }) => {
state.setQuestionIndex(0); state.setQuestionIndex(0);
}} preview={true} /> }} 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>
</div> </div>
); );

View File

@@ -1,36 +1,63 @@
import {ListeningExam, MultipleChoiceExercise, UserSolution} from "@/interfaces/exam"; import { ListeningExam, MultipleChoiceExercise, UserSolution } from "@/interfaces/exam";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import {renderExercise} from "@/components/Exercises"; import { renderExercise } from "@/components/Exercises";
import {renderSolution} from "@/components/Solutions"; import { renderSolution } from "@/components/Solutions";
import ModuleTitle from "@/components/Medium/ModuleTitle"; import ModuleTitle from "@/components/Medium/ModuleTitle";
import AudioPlayer from "@/components/Low/AudioPlayer"; import AudioPlayer from "@/components/Low/AudioPlayer";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import BlankQuestionsModal from "@/components/QuestionsModal"; import BlankQuestionsModal from "@/components/QuestionsModal";
import useExamStore from "@/stores/examStore"; import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
import {countExercises} from "@/utils/moduleUtils"; import { countExercises } from "@/utils/moduleUtils";
import PartDivider from "./Navigation/SectionDivider";
interface Props { interface Props {
exam: ListeningExam; exam: ListeningExam;
showSolutions?: boolean; showSolutions?: boolean;
preview?: boolean;
onFinish: (userSolutions: UserSolution[]) => void; onFinish: (userSolutions: UserSolution[]) => void;
} }
const INSTRUCTIONS_AUDIO_SRC = 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"; "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 [timesListened, setTimesListened] = useState(0);
const [showBlankModal, setShowBlankModal] = useState(false); 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 [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.parts.map((_, index) => index) : []));
const {partIndex, setPartIndex} = useExamStore((state) => state); const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && exam.parts[0].intro !== "");
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]); 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)); 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(() => { useEffect(() => {
if (showSolutions) return setExerciseIndex(-1); if (showSolutions) return setExerciseIndex(-1);
}, [setExerciseIndex, showSolutions]); }, [setExerciseIndex, showSolutions]);
@@ -52,7 +79,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
previousMultipleChoice = [...previousMultipleChoice, ...partMultipleChoice]; 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 // 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) => { const nextExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
if (solution) { 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) { if (storeQuestionIndex > 0) {
const exercise = getExercise(); 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); setStoreQuestionIndex(0);
@@ -109,7 +136,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
setHasExamEnded(false); setHasExamEnded(false);
if (solution) { 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 { } else {
onFinish(userSolutions); onFinish(userSolutions);
} }
@@ -118,7 +145,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
const previousExercise = (solution?: UserSolution) => { const previousExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
if (solution) { 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); setStoreQuestionIndex(0);
@@ -171,90 +198,109 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
const renderAudioPlayer = () => ( 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 gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
<div className="flex flex-col w-full gap-2"> {exam.parts[partIndex].audio ? (
<h4 className="text-xl font-semibold">Please listen to the following audio attentively.</h4> <>
<span className="text-base"> <div className="flex flex-col w-full gap-2">
{exam.parts[partIndex].audio.repeatableTimes > 0 <h4 className="text-xl font-semibold">Please listen to the following audio attentively.</h4>
? `You will only be allowed to listen to the audio ${exam.parts[partIndex].audio.repeatableTimes - timesListened} time(s).` <span className="text-base">
: "You may listen to the audio as many times as you would like."} {exam.parts[partIndex].audio.repeatableTimes > 0
</span> ? `You will only be allowed to listen to the audio ${exam.parts[partIndex].audio.repeatableTimes - timesListened} time(s).`
</div> : "You may listen to the audio as many times as you would like."}
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit"> </span>
<AudioPlayer </div>
key={partIndex} <div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
src={exam.parts[partIndex].audio.source} <AudioPlayer
color="listening" key={partIndex}
onEnd={() => setTimesListened((prev) => prev + 1)} src={exam.parts[partIndex].audio.source}
disabled={timesListened === exam.parts[partIndex].audio.repeatableTimes} color="listening"
disablePause onEnd={() => setTimesListened((prev) => prev + 1)}
/> disabled={timesListened === exam.parts[partIndex].audio.repeatableTimes}
</div> disablePause
/>
</div>
</>
) : (
<span>This section will be displayed the audio once it has been generated.</span>
)}
</div> </div>
); );
return ( return (
<> <>
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} /> {showPartDivider ?
<div className="flex flex-col h-full w-full gap-8 justify-between"> <PartDivider
<ModuleTitle
exerciseIndex={calculateExerciseIndex()}
minTimer={exam.minTimer}
module="listening" module="listening"
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))} sectionLabel="Section"
disableTimer={showSolutions} defaultTitle="Listening exam"
/> section={exam.parts[partIndex]}
{/* Audio Player for the Instructions */} sectionIndex={partIndex}
{partIndex === -1 && renderAudioInstructionsPlayer()} 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 */} {/* Part's audio player */}
{partIndex > -1 && renderAudioPlayer()} {partIndex > -1 && renderAudioPlayer()}
{/* Exercise renderer */} {/* Exercise renderer */}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
partIndex > -1 && partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length && exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions && !showSolutions &&
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)} renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
{/* Solution renderer */} {/* Solution renderer */}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
partIndex > -1 && partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length && exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions && showSolutions &&
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)} renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
</div> </div>
{exerciseIndex === -1 && partIndex > -1 && exam.variant !== "partial" && ( {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"> <div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button <Button
color="purple" color="purple"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
if (partIndex === 0) return setPartIndex(-1); if (partIndex === 0) return setPartIndex(-1);
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1); setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
setPartIndex(partIndex - 1); setPartIndex(partIndex - 1);
}} }}
className="max-w-[200px] w-full"> className="max-w-[200px] w-full">
Back Back
</Button> </Button>
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full"> <Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
Next Next
</Button> </Button>
</div> </div>
)} )}
{partIndex === -1 && exam.variant !== "partial" && ( {partIndex === -1 && exam.variant !== "partial" && (
<Button color="purple" onClick={() => setPartIndex(0)} className="max-w-[200px] self-end w-full justify-self-end"> <Button color="purple" onClick={() => setPartIndex(0)} className="max-w-[200px] self-end w-full justify-self-end">
Start now Start now
</Button> </Button>
)} )}
{exerciseIndex === -1 && partIndex === 0 && exam.variant === "partial" && ( {exerciseIndex === -1 && partIndex === 0 && exam.variant === "partial" && (
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end"> <Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
Start now Start now
</Button> </Button>
)} )}
</>)
}
</> </>
); );
} }

View File

@@ -304,15 +304,17 @@ export default function Reading({ exam, showSolutions = false, preview = false,
return ( return (
<> <>
{(showPartDivider) ? {showPartDivider ?
<PartDivider <div className="flex justify-center items-center h-full">
module="reading" <PartDivider
sectionLabel="Part" module="reading"
defaultTitle="Reading exam" sectionLabel="Part"
section={exam.parts[partIndex]} defaultTitle="Reading exam"
sectionIndex={partIndex} section={exam.parts[partIndex]}
onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex)) }} 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"> <div className="flex flex-col h-full w-full gap-8">
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} /> <BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />

View File

@@ -47,6 +47,14 @@ export interface LevelExam extends ExamBase {
export interface LevelPart extends Section { export interface LevelPart extends Section {
context?: string; context?: string;
exercises: Exercise[]; 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 { export interface ListeningExam extends ExamBase {

View File

@@ -3,15 +3,14 @@ import { ModuleState } from "../types";
import ReorderResult from "./types"; import ReorderResult from "./types";
const reorderFillBlanks = (exercise: FillBlanksExercise, startId: number): ReorderResult<FillBlanksExercise> => { const reorderFillBlanks = (exercise: FillBlanksExercise, startId: number): ReorderResult<FillBlanksExercise> => {
console.log(); let newSolutions = exercise.solutions
const newSolutions = exercise.solutions
.sort((a, b) => parseInt(a.id) - parseInt(b.id)) .sort((a, b) => parseInt(a.id) - parseInt(b.id))
.map((solution, index) => ({ .map((solution, index) => ({
...solution, ...solution,
id: (startId + index).toString() id: (startId + index).toString()
})); }));
const idMapping = exercise.solutions let idMapping = exercise.solutions
.sort((a, b) => parseInt(a.id) - parseInt(b.id)) .sort((a, b) => parseInt(a.id) - parseInt(b.id))
.reduce((acc, solution, index) => { .reduce((acc, solution, index) => {
acc[solution.id] = (startId + index).toString(); 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') { if (typeof word === 'string') {
return word; return word;
} else if ('letter' in word && 'word' in word) { } else if ('letter' in word && 'word' in word) {
@@ -36,7 +35,7 @@ const reorderFillBlanks = (exercise: FillBlanksExercise, startId: number): Reord
return word; return word;
}); });
const newUserSolutions = exercise.userSolutions?.map(solution => ({ let newUserSolutions = exercise.userSolutions?.map(solution => ({
...solution, ...solution,
id: idMapping[solution.id] || solution.id 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 reorderWriteBlanks = (exercise: WriteBlanksExercise, startId: number): ReorderResult<WriteBlanksExercise> => {
const oldIds = exercise.solutions.map(s => s.id); let oldIds = exercise.solutions.map(s => s.id);
const newIds = oldIds.map((_, index) => (startId + index).toString()); let newIds = oldIds.map((_, index) => (startId + index).toString());
const newSolutions = exercise.solutions.map((solution, index) => ({ let newSolutions = exercise.solutions.map((solution, index) => ({
id: newIds[index], id: newIds[index],
solution: [...solution.solution] solution: [...solution.solution]
})); }));
let newText = exercise.text; let newText = exercise.text;
oldIds.forEach((oldId, index) => { oldIds.forEach((oldId, index) => {
newText = newText.replace( newText = newText.replace(
`{{${oldId}}}`, `{{${oldId}}}`,
`{{${newIds[index]}}}` `{{${newIds[index]}}}`
); );
}); });
const result = { let result = {
exercise: { exercise: {
...exercise, ...exercise,
solutions: newSolutions, solutions: newSolutions,
text: newText text: newText
}, },
lastId: startId + newSolutions.length lastId: startId + newSolutions.length
}; };
return result; return result;
}; };
const reorderTrueFalse = (exercise: TrueFalseExercise, startId: number): ReorderResult<TrueFalseExercise> => { 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)) .sort((a, b) => parseInt(a.id) - parseInt(b.id))
.map((question, index) => ({ .map((question, index) => ({
...question, ...question,
@@ -101,7 +100,7 @@ const reorderTrueFalse = (exercise: TrueFalseExercise, startId: number): Reorder
}; };
const reorderMatchSentences = (exercise: MatchSentencesExercise, startId: number): ReorderResult<MatchSentencesExercise> => { 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)) .sort((a, b) => parseInt(a.id) - parseInt(b.id))
.map((sentence, index) => ({ .map((sentence, index) => ({
...sentence, ...sentence,
@@ -119,7 +118,7 @@ const reorderMatchSentences = (exercise: MatchSentencesExercise, startId: number
const reorderMultipleChoice = (exercise: MultipleChoiceExercise, startId: number): ReorderResult<MultipleChoiceExercise> => { 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)) .sort((a, b) => parseInt(a.id) - parseInt(b.id))
.map((question, index) => ({ .map((question, index) => ({
...question, ...question,
@@ -138,7 +137,7 @@ const reorderMultipleChoice = (exercise: MultipleChoiceExercise, startId: number
const reorderSection = (exercises: Exercise[], startId: number): { exercises: Exercise[], lastId: number } => { const reorderSection = (exercises: Exercise[], startId: number): { exercises: Exercise[], lastId: number } => {
let currentId = startId; let currentId = startId;
const reorderedExercises = exercises.map(exercise => { let reorderedExercises = exercises.map(exercise => {
let result; let result;
switch (exercise.type) { switch (exercise.type) {
@@ -182,26 +181,25 @@ const reorderSection = (exercises: Exercise[], startId: number): { exercises: Ex
const reorderModule = (moduleState: ModuleState) => { const reorderModule = (moduleState: ModuleState) => {
let currentId = 1; let currentId = 1;
const reorderedSections = moduleState.sections.map(section => { let reorderedSections = moduleState.sections.map(section => {
const currentSection = section.state as ReadingPart | ListeningPart | LevelPart; let currentSection = section.state as ReadingPart | ListeningPart | LevelPart;
console.log(currentSection.exercises); console.log(currentSection.exercises);
const result = reorderSection(currentSection.exercises, currentId); let result = reorderSection(currentSection.exercises, currentId);
currentId = result.lastId; currentId = result.lastId;
console.log(result); return {
return { ...section,
...section, state: {
state: { ...currentSection,
...currentSection, exercises: result.exercises
exercises: result.exercises }
} };
};
}); });
return { return {
...moduleState, ...moduleState,
sections: reorderedSections sections: reorderedSections
}; };
}; };
export { export {
reorderFillBlanks, reorderFillBlanks,