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";
type TextToken = {
export type TextToken = {
type: 'text';
content: string;
isWhitespace: boolean;
isLineBreak?: boolean;
};
type BlankToken = {
export type BlankToken = {
type: 'blank';
id: number;
};

View File

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

View File

@@ -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));

View File

@@ -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}

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 [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("");
}
});
@@ -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 => ({
@@ -73,12 +81,15 @@ const Speaking: React.FC<Props> = ({ sectionId, exercise }) => {
},
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(() => {

View File

@@ -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]"
<PromptEdit
value={local.prompt}
onChange={(e) => updateLocal({ ...local, prompt: e.target.value })}
onBlur={() => setEditingPrompt(false)}
autoFocus
onChange={(text) => updateLocal({ ...local, prompt: text })}
/>
) : (
<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">
<QuestionsList
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';
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}

View File

@@ -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]);

View File

@@ -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
};
if (schedule) {
debouncedUpdateGlobal();
}
}, [debouncedUpdateGlobal]);
return {

View File

@@ -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) => {

View File

@@ -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={
<>
&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]);
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

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"}
open={localSettings.isExerciseDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen })}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)}
>
<ExercisePicker
module="level"

View File

@@ -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

View File

@@ -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"

View File

@@ -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" )}>

View File

@@ -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">

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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,6 +198,8 @@ 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">
{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">
@@ -189,10 +218,25 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
disablePause
/>
</div>
</>
) : (
<span>This section will be displayed the audio once it has been generated.</span>
)}
</div>
);
return (
<>
{showPartDivider ?
<PartDivider
module="listening"
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">
@@ -255,6 +299,8 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
Start now
</Button>
)}
</>)
}
</>
);
}

View File

@@ -304,7 +304,8 @@ export default function Reading({ exam, showSolutions = false, preview = false,
return (
<>
{(showPartDivider) ?
{showPartDivider ?
<div className="flex justify-center items-center h-full">
<PartDivider
module="reading"
sectionLabel="Part"
@@ -312,7 +313,8 @@ export default function Reading({ exam, showSolutions = false, preview = false,
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} />

View File

@@ -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 {

View File

@@ -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,10 +54,10 @@ 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());
let oldIds = exercise.solutions.map(s => s.id);
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],
solution: [...solution.solution]
}));
@@ -71,7 +70,7 @@ const reorderWriteBlanks = (exercise: WriteBlanksExercise, startId: number): Reo
);
});
const result = {
let result = {
exercise: {
...exercise,
solutions: newSolutions,
@@ -81,10 +80,10 @@ const reorderWriteBlanks = (exercise: WriteBlanksExercise, startId: number): Reo
};
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,12 +181,11 @@ 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;
let reorderedSections = moduleState.sections.map(section => {
let currentSection = section.state as ReadingPart | ListeningPart | LevelPart;
console.log(currentSection.exercises);
const result = reorderSection(currentSection.exercises, currentId);
let result = reorderSection(currentSection.exercises, currentId);
currentId = result.lastId;
console.log(result);
return {
...section,
state: {
@@ -201,7 +199,7 @@ const reorderModule = (moduleState: ModuleState) => {
...moduleState,
sections: reorderedSections
};
};
};
export {
reorderFillBlanks,