Listening Convo Edit and a bunch of ts errors

This commit is contained in:
Carlos-Mesquita
2024-11-06 19:43:06 +00:00
parent b5ac908d09
commit 5165b6ae6d
13 changed files with 555 additions and 218 deletions

View File

@@ -0,0 +1,86 @@
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
import { useState } from "react";
import { FaEdit, FaFemale, FaMale } from "react-icons/fa";
import { FaTrash } from "react-icons/fa6";
import { ScriptLine } from ".";
interface MessageProps {
message: ScriptLine & { position: 'left' | 'right' };
color: string;
editing: boolean;
onEdit?: (text: string) => void;
onDelete?: () => void;
}
const Message: React.FC<MessageProps> = ({ message, color, editing, onEdit, onDelete }) => {
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(message.text);
return (
<div className={`flex items-start gap-2 ${message.position === 'left' ? 'justify-start' : 'justify-end'}`}>
<div className="flex flex-col w-[50%]">
<div className={`flex items-center gap-2 ${message.position === 'right' && 'self-end'}`}>
{message.gender === 'male' ? (
<FaMale className="w-5 h-5 text-blue-500" />
) : (
<FaFemale className="w-5 h-5 text-pink-500" />
)}
<span className="text-sm font-medium">{message.name}</span>
</div>
<div className={`rounded-lg p-3 bg-${color}-100 relative group mt-1`}>
{isEditing ? (
<div className="flex flex-col gap-2">
<AutoExpandingTextArea
value={editText}
onChange={setEditText}
placeholder="Edit message..."
className="w-full min-h-[96px] px-4 py-2 border border-gray-200 rounded-lg focus:ring-1 focus:ring-blue-500 focus:border-blue-500 text-base resize-none"
/>
<div className="flex justify-between">
<button
className="px-3 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors text-sm font-medium"
onClick={() => {
onEdit?.(editText);
setIsEditing(false);
}}
>
Save
</button>
<button
className="px-3 py-1 bg-red-500 rounded-md hover:bg-gray-100 transition-colors text-sm font-medium text-white"
onClick={() => setIsEditing(false)}
>
Cancel
</button>
</div>
</div>
) : (
<div className="flex items-start justify-between gap-2">
<p className="text-gray-700 whitespace-pre-wrap flex-grow">{message.text}</p>
{editing && (
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex gap-1">
<button
onClick={() => setIsEditing(true)}
className="p-1 rounded hover:bg-gray-200 transition-colors"
>
<FaEdit className="w-3.5 h-3.5 text-gray-600" />
</button>
<button
onClick={onDelete}
className="p-1 rounded hover:bg-gray-200 transition-colors"
>
<FaTrash className="w-3.5 h-3.5 text-red-500" />
</button>
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
};
export default Message;

View File

@@ -0,0 +1,330 @@
import React, { useState, useMemo } from 'react';
import { Script } from "@/interfaces/exam";
import Message from './Message';
import AutoExpandingTextArea from '@/components/Low/AutoExpandingTextarea';
import { Card, CardContent } from '@/components/ui/card';
import Input from '@/components/Low/Input';
import { FaFemale, FaMale, FaPlus } from 'react-icons/fa';
import clsx from 'clsx';
export interface Speaker {
id: number;
name: string;
gender: 'male' | 'female';
color: string;
position: 'left' | 'right';
}
type Gender = 'male' | 'female';
export interface ScriptLine {
name: string;
gender: Gender;
text: string;
voice?: string;
}
interface MessageWithPosition extends ScriptLine {
position: 'left' | 'right';
}
interface Props {
section: number;
editing?: boolean;
local: Script;
setLocal: (script: Script) => void;
}
const colorOptions = [
'red', 'blue', 'green', 'purple', 'pink', 'indigo', 'teal', 'orange',
'cyan', 'emerald', 'sky', 'violet', 'fuchsia', 'rose', 'lime', 'slate'
];
const ScriptEditor: React.FC<Props> = ({ section, editing = false, local, setLocal }) => {
const isConversation = [1, 3].includes(section);
const [selectedSpeaker, setSelectedSpeaker] = useState<string>('');
const [newMessage, setNewMessage] = useState('');
const speakerCount = section === 1 ? 2 : 4;
const [speakers, setSpeakers] = useState<Speaker[]>(() => {
const existingScript = local as ScriptLine[];
const existingSpeakers = new Set<string>();
const speakerGenders = new Map<string, 'male' | 'female'>();
if (Array.isArray(existingScript)) {
existingScript.forEach(line => {
existingSpeakers.add(line.name);
speakerGenders.set(line.name, line.gender.toLowerCase() === 'female' ? 'female' : 'male' as 'male' | 'female');
});
}
const speakerArray = Array.from(existingSpeakers);
const totalNeeded = Math.max(speakerCount, speakerArray.length);
return Array.from({ length: totalNeeded }, (_, index) => {
if (index < speakerArray.length) {
return {
id: index,
name: speakerArray[index],
gender: speakerGenders.get(speakerArray[index]) || 'male',
color: colorOptions[index],
position: index % 2 === 0 ? 'left' : 'right'
};
}
return {
id: index,
name: '',
gender: 'male',
color: colorOptions[index],
position: index % 2 === 0 ? 'left' : 'right'
};
});
});
const speakerProperties = useMemo(() => {
return speakers.reduce((acc, speaker) => {
if (speaker.name) {
acc[speaker.name] = {
color: speaker.color,
gender: speaker.gender
};
}
return acc;
}, {} as Record<string, { color: string; gender: 'male' | 'female' }>);
}, [speakers]);
const allSpeakersConfigured = useMemo(() => {
return speakers.every(speaker => speaker.name.trim() !== '');
}, [speakers]);
const updateSpeaker = (index: number, updates: Partial<Speaker>) => {
const updatedSpeakers = speakers.map((speaker, i) => {
if (i === index) {
return { ...speaker, ...updates };
}
return speaker;
});
setSpeakers(updatedSpeakers);
if (Array.isArray(local)) {
if ('name' in updates && speakers[index].name) {
const oldName = speakers[index].name;
const newName = updates.name || '';
const updatedScript = local.map(line => {
if (line.name === oldName) {
return { ...line, name: newName };
}
return line;
});
setLocal(updatedScript);
}
if ('gender' in updates && speakers[index].name && updates.gender) {
const name = speakers[index].name;
const newGender = updates.gender;
const updatedScript = local.map(line => {
if (line.name === name) {
return { ...line, gender: newGender };
}
return line;
});
setLocal(updatedScript);
}
}
if ('name' in updates && speakers[index].name === selectedSpeaker) {
setSelectedSpeaker(updates.name || '');
}
};
const addMessage = () => {
if (!isConversation || !selectedSpeaker || !newMessage.trim()) return;
if (!Array.isArray(local)) return;
const speaker = speakers.find(s => s.name === selectedSpeaker);
if (!speaker) return;
const newLine: ScriptLine = {
name: selectedSpeaker,
gender: speaker.gender,
text: newMessage.trim()
};
const updatedScript = [...local, newLine];
setLocal(updatedScript);
setNewMessage('');
};
const updateMessage = (index: number, newText: string) => {
if (!Array.isArray(local)) return;
const updatedScript = [...local];
updatedScript[index] = { ...updatedScript[index], text: newText };
setLocal(updatedScript);
};
const deleteMessage = (index: number) => {
if (!Array.isArray(local)) return;
const updatedScript = local.filter((_, i) => i !== index);
setLocal(updatedScript);
};
const updateMonologue = (text: string) => {
setLocal(text);
};
const messages = useMemo(() => {
if (typeof local === 'string' || !Array.isArray(local)) return [];
return local.reduce<MessageWithPosition[]>((acc, line, index) => {
const normalizedLine = {
...line,
gender: line.gender.toLowerCase() === 'female' ? 'female' : 'male'
} as ScriptLine;
if (index === 0) {
acc.push({ ...normalizedLine, position: 'left' });
} else {
const prevMsg = acc[index - 1];
const position = line.name === prevMsg.name
? prevMsg.position
: (prevMsg.position === 'left' ? 'right' : 'left');
acc.push({ ...normalizedLine, position });
}
return acc;
}, []);
}, [local]);
if (!isConversation) {
return (
<Card>
<CardContent className="py-10">
<div className="w-full">
{editing ? (
<AutoExpandingTextArea
value={local as string}
onChange={updateMonologue}
placeholder='Write the monologue here...'
/>
) : (
<div className="p-8 shadow-inner border border-gray-200 bg-gray-50 rounded-xl">
<span>{(local as string) || "Edit, generate or import your own audio."}</span>
</div>
)}
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardContent className="py-10">
<div className="space-y-6">
{editing && (
<div className="bg-white rounded-2xl p-6 shadow-inner border">
<h3 className="text-lg font-medium text-gray-700 mb-6">Edit Conversation</h3>
<div className="space-y-4 mb-6">
{speakers.map((speaker, index) => (
<div key={index} className="flex items-center gap-4">
<div className="flex-1">
<Input
type="text"
name=""
value={speaker.name}
onChange={(text) => updateSpeaker(index, { name: text })}
placeholder="Speaker name"
/>
</div>
<div className="w-[140px] relative">
<select
value={speaker.gender}
onChange={(e) => updateSpeaker(index, { gender: e.target.value as 'male' | 'female' })}
className="w-full appearance-none px-4 py-2 border border-gray-200 rounded-full text-base bg-white focus:ring-1 focus:ring-blue-500 focus:outline-none"
>
<option value="female">Female</option>
<option value="male">Male</option>
</select>
<div className="absolute right-3 top-2.5 pointer-events-none">
{speaker.gender === 'male' ? (
<FaMale className="w-5 h-5 text-blue-500" />
) : (
<FaFemale className="w-5 h-5 text-pink-500" />
)}
</div>
</div>
</div>
))}
</div>
<div className="flex gap-4">
<div className="w-[240px] flex flex-col gap-2">
<select
value={selectedSpeaker}
onChange={(e) => setSelectedSpeaker(e.target.value)}
disabled={!allSpeakersConfigured}
className="w-full h-[42px] px-4 appearance-none border border-gray-200 rounded-full focus:ring-1 focus:ring-blue-500 focus:outline-none bg-white text-gray-700 text-base disabled:bg-gray-100"
>
<option value="">Select Speaker ...</option>
{speakers.filter(s => s.name).map((speaker) => (
<option key={speaker.id} value={speaker.name}>
{speaker.name}
</option>
))}
</select>
<button
onClick={addMessage}
disabled={!selectedSpeaker || !newMessage.trim() || !allSpeakersConfigured}
className={clsx(
"w-full h-[42px] rounded-lg flex items-center justify-center gap-2 transition-colors font-medium",
!selectedSpeaker || !newMessage.trim() || !allSpeakersConfigured
? 'bg-gray-100 text-gray-500 cursor-not-allowed'
: 'bg-blue-500 text-white hover:bg-blue-600'
)}
>
<FaPlus className="w-4 h-4" />
Add
</button>
</div>
<div className="flex-1">
<AutoExpandingTextArea
value={newMessage}
onChange={setNewMessage}
placeholder={allSpeakersConfigured ? "Type your message..." : "Configure all speakers first"}
disabled={!allSpeakersConfigured}
className="w-full min-h-[96px] px-4 py-2 border border-gray-200 rounded-lg focus:ring-1 focus:ring-blue-500 focus:border-blue-500 text-base resize-none disabled:bg-gray-100"
/>
</div>
</div>
</div>
)}
<div className="space-y-4">
{messages.map((message, index) => {
const properties = speakerProperties[message.name];
if (!properties) return null;
return (
<Message
key={index}
message={message}
color={properties.color}
editing={editing}
onEdit={(text: string) => updateMessage(index, text)}
onDelete={() => deleteMessage(index)}
/>
);
})}
</div>
</div>
</CardContent>
</Card>
);
};
export default ScriptEditor;

View File

@@ -1,134 +0,0 @@
import React, { useState, useMemo } from 'react';
import { Script } from '@/interfaces/exam';
import { FaFemale, FaMale } from "react-icons/fa";
import AutoExpandingTextArea from '@/components/Low/AutoExpandingTextarea';
import clsx from 'clsx';
const colorOptions = [
'red', 'blue', 'green', 'purple', 'pink', 'indigo', 'teal', 'orange',
'cyan', 'emerald', 'sky', 'violet', 'fuchsia', 'rose', 'lime', 'slate'
];
interface Speaker {
id: number;
name: string;
gender: 'male' | 'female';
color: string;
position: 'left' | 'right';
}
interface Props {
script?: Script;
setScript: React.Dispatch<React.SetStateAction<Script | undefined>>;
editing?: boolean;
}
const ScriptRender: React.FC<Props> = ({ script, setScript, editing = false }) => {
const [speakers, setSpeakers] = useState<Speaker[]>(() => {
if (!script || typeof script === 'string') return [];
const uniqueSpeakers = new Map();
const usedColors = new Set();
let isLeft = true;
script.forEach((line, index) => {
if (!uniqueSpeakers.has(line.name)) {
const availableColors = colorOptions.filter(color => !usedColors.has(color));
if (availableColors.length === 0) {
usedColors.clear();
}
const randomColor = availableColors[Math.floor(Math.random() * availableColors.length)];
usedColors.add(randomColor);
uniqueSpeakers.set(line.name, {
id: index,
name: line.name,
gender: line.gender,
color: randomColor,
position: isLeft ? 'left' : 'right'
});
isLeft = !isLeft;
}
});
return Array.from(uniqueSpeakers.values());
});
const speakerProperties = useMemo(() => {
return speakers.reduce((acc, speaker) => {
acc[speaker.name] = {
color: speaker.color,
position: speaker.position
};
return acc;
}, {} as Record<string, { color: string; position: 'left' | 'right' }>);
}, [speakers]);
if (script === undefined) return null;
if (typeof script === 'string') {
return (
<div className="w-full px-4">
{editing ? (
<AutoExpandingTextArea
className="w-full p-3 border rounded bg-white"
value={script}
onChange={(text) => setScript(text)}
/>
) : (
<p className="text-gray-700 p-3 bg-gray-100 rounded-lg" dangerouslySetInnerHTML={{ __html: script.split("\n").join("<br>") }} />
)}
</div>
);
}
const updateMessage = (index: number, newText: string) => {
setScript([
...script.slice(0, index),
{ ...script[index], text: newText },
...script.slice(index + 1)
]);
};
return (
<div className="w-full px-4">
<div className="space-y-2">
{script.map((line, index) => {
const { color, position } = speakerProperties[line.name];
return (
<div
key={index}
className={`flex items-start gap-2 ${position === 'left' ? 'justify-start' : 'justify-end'}`}
>
<div className="flex flex-col w-[50%]">
<div className={clsx('flex', position !== 'left' && 'self-end')}>
{line.gender === 'male' ? (
<FaMale className="w-5 h-5 text-blue-500 mb-1" />
) : (
<FaFemale className="w-5 h-5 text-pink-500 mb-1" />
)}
<span className="text-sm mb-1">
{line.name}
</span>
</div>
<div className={`rounded-lg p-3 bg-${color}-100`}>
{editing ? (
<AutoExpandingTextArea
className="w-full p-2 border rounded bg-white"
value={line.text}
onChange={(text) => updateMessage(index, text)}
/>
) : (
<p className="text-gray-700">{line.text}</p>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
};
export default ScriptRender;

View File

@@ -2,10 +2,10 @@ import { useEffect, useState } from "react";
import { ListeningPart } from "@/interfaces/exam"; import { ListeningPart } from "@/interfaces/exam";
import SectionContext from "."; import SectionContext from ".";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import { FaFemale, FaMale } from "react-icons/fa";
import useSectionEdit from "../../Hooks/useSectionEdit"; import useSectionEdit from "../../Hooks/useSectionEdit";
import ScriptRender from "../../Exercises/Shared/Script"; import ScriptRender from "../../Exercises/Script";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import Dropdown from "@/components/Dropdown";
const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => { const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
@@ -15,50 +15,55 @@ const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
); );
const listeningPart = state as ListeningPart; const listeningPart = state as ListeningPart;
const [script, setScript] = useState(listeningPart.script); const [scriptLocal, setScriptLocal] = useState(listeningPart.script);
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({ const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
sectionId, sectionId,
mode: "edit", mode: "edit",
onSave: () => { onSave: () => {
const newState = { ...listeningPart }; const newState = { ...listeningPart };
newState.script = script; newState.script = scriptLocal;
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState } }) dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState } })
setEditing(false); setEditing(false);
}, },
onDiscard: () => { onDiscard: () => {
setScript(listeningPart.script); setScriptLocal(listeningPart.script);
}, },
}); });
useEffect(() => { useEffect(() => {
if (genResult !== undefined && generating === "context") { if (genResult !== undefined && generating === "context") {
setEditing(true); setEditing(true);
setScript(genResult[0].script) setScriptLocal(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 // 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) => {
if (scriptLocal === undefined) {
if (script === undefined && !editing) { return (
return (<p className="w-full text-gray-600 px-7 py-8 border-2 bg-white rounded-3xl whitespace-pre-line"> <Card>
Generate or import audio to add exercises! <CardContent className="py-10">
</p> <span>Edit, generate or import your own audio.</span>
) </CardContent>
</Card>
);
} }
return ( return (
<Card>
<CardContent className="py-10"> <Dropdown
<ScriptRender className={`w-full text-left p-4 mb-2 bg-gradient-to-r from-ielts-${currentModule}/60 to-ielts-${currentModule} text-white rounded-lg shadow-lg transition-transform transform hover:scale-102`}
script={script} title="Conversation"
setScript={setScript} contentWrapperClassName="rounded-xl"
editing={editing} >
/> <ScriptRender
</CardContent> local={scriptLocal}
</Card> setLocal={setScriptLocal}
section={sectionId}
editing={editing}
/>
</Dropdown>
); );
}; };

View File

@@ -1,13 +1,13 @@
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import SortableSection from "../../Shared/SortableSection"; import SortableSection from "../../Shared/SortableSection";
import getReadingQuestions from '../SectionExercises/reading'; import getReadingQuestions from '../SectionExercises/reading';
import { Exercise, LevelPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam"; import { Exercise, LevelPart, ListeningPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
import { ReadingExercise } from "./types"; import ExerciseItem, { ReadingExercise } from "./types";
import Dropdown from "@/components/Dropdown"; import Dropdown from "@/components/Dropdown";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import Writing from "../../Exercises/Writing"; import Writing from "../../Exercises/Writing";
import Speaking from "../../Exercises/Speaking"; import Speaking from "../../Exercises/Speaking";
import { ReactNode, useEffect, useState } from "react"; import { ReactElement, ReactNode, useEffect, useState } from "react";
import { import {
DndContext, DndContext,
PointerSensor, PointerSensor,
@@ -15,15 +15,18 @@ import {
useSensors, useSensors,
DragEndEvent, DragEndEvent,
closestCenter, closestCenter,
UniqueIdentifier,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import GenLoader from "../../Exercises/Shared/GenLoader"; import GenLoader from "../../Exercises/Shared/GenLoader";
import { ExamPart } from "@/stores/examEditor/types"; import { ExamPart } from "@/stores/examEditor/types";
import getListeningItems from "./listening"; import getListeningItems from "./listening";
import getLevelQuestionItems from "./level"; import getLevelQuestionItems from "./level";
import React from "react";
export interface Props { interface QuestionItemsResult {
sectionId: number; ids: string[];
items: ExerciseItem[];
} }
const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => { const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
@@ -49,7 +52,7 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
}) })
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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, dispatch, sectionId, currentModule]); }, [genResult, dispatch, sectionId, currentModule]);
const currentSection = sections.find((s) => s.sectionId === sectionId)!; const currentSection = sections.find((s) => s.sectionId === sectionId)!;
@@ -58,27 +61,44 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
useSensor(PointerSensor), useSensor(PointerSensor),
); );
const questionItems = () => { const questionItems = (): QuestionItemsResult => {
let ids, items; let result: QuestionItemsResult = {
ids: [],
items: []
};
switch (currentModule) { switch (currentModule) {
case "reading": case "reading": {
items = getReadingQuestions((currentSection.state as ReadingPart).exercises as ReadingExercise[], sectionId); const items = getReadingQuestions(
ids = items.map(q => q.id.toString()); (currentSection.state as ReadingPart).exercises as ReadingExercise[],
sectionId
);
result.items = items.filter((item): item is ExerciseItem => item !== undefined);
result.ids = result.items.map(item => item.id);
break; break;
case "listening": }
items = getListeningItems((currentSection.state as ReadingPart).exercises as ReadingExercise[], sectionId); case "listening": {
ids = items.map(q => q?.id.toString()); const items = getListeningItems(
(currentSection.state as ListeningPart).exercises as Exercise[],
sectionId
);
result.items = items.filter((item): item is ExerciseItem => item !== undefined);
result.ids = result.items.map(item => item.id);
break; break;
case "level": }
items = getLevelQuestionItems((currentSection.state as LevelPart).exercises as Exercise[], sectionId); case "level": {
ids = items.map(q => q.id.toString()); const items = getLevelQuestionItems(
(currentSection.state as LevelPart).exercises as Exercise[],
sectionId
);
result.items = items.filter((item): item is ExerciseItem => item !== undefined);
result.ids = result.items.map(item => item.id);
break; break;
}
} }
return { ids, items } return result;
} };
const questions = questionItems();
const background = (component: ReactNode) => { const background = (component: ReactNode) => {
return ( return (
@@ -91,6 +111,20 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
if (currentModule == "writing") return background(<Writing sectionId={sectionId} exercise={currentSection.state as WritingExercise} />); if (currentModule == "writing") return background(<Writing sectionId={sectionId} exercise={currentSection.state as WritingExercise} />);
if (currentModule == "speaking") return background(<Speaking sectionId={sectionId} exercise={currentSection.state as SpeakingExercise} />); if (currentModule == "speaking") return background(<Speaking sectionId={sectionId} exercise={currentSection.state as SpeakingExercise} />);
const questions = questionItems();
const filteredIds = (questions.ids ?? []).filter(Boolean);
function isValidItem(item: ExerciseItem | undefined): item is ExerciseItem {
return item !== undefined &&
typeof item.id === 'string' &&
typeof item.sectionId === 'number' &&
React.isValidElement(item.label) &&
React.isValidElement(item.content);
}
const filteredItems = (questions.items ?? []).filter(isValidItem);
return ( return (
<DndContext <DndContext
sensors={sensors} sensors={sensors}
@@ -107,11 +141,11 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
questions.ids.length > 0 && ( questions.ids.length > 0 && (
<div className="mt-4 p-6 rounded-xl shadow-inner border bg-gray-50"> <div className="mt-4 p-6 rounded-xl shadow-inner border bg-gray-50">
<SortableContext <SortableContext
items={questions.ids} items={filteredIds}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
{questions.items.map(item => ( {filteredItems.map(item => (
<SortableSection key={item.id.toString()} id={item.id.toString()}> <SortableSection key={item.id} id={item.id}>
<Dropdown <Dropdown
className={`w-full text-left p-4 mb-2 bg-gradient-to-r from-ielts-${currentModule}/60 to-ielts-${currentModule} text-white rounded-lg shadow-lg transition-transform transform hover:scale-102`} className={`w-full text-left p-4 mb-2 bg-gradient-to-r from-ielts-${currentModule}/60 to-ielts-${currentModule} text-white rounded-lg shadow-lg transition-transform transform hover:scale-102`}
customTitle={item.label} customTitle={item.label}

View File

@@ -1,5 +1,5 @@
import { Exercise } from "@/interfaces/exam"; import { Exercise } from "@/interfaces/exam";
import ExerciseItem from "./types"; import ExerciseItem, { isExerciseItem } from "./types";
import ExerciseLabel from "../../Shared/ExerciseLabel"; import ExerciseLabel from "../../Shared/ExerciseLabel";
import MultipleChoice from "../../Exercises/MultipleChoice"; import MultipleChoice from "../../Exercises/MultipleChoice";
import FillBlanksMC from "../../Exercises/Blanks/MultipleChoice"; import FillBlanksMC from "../../Exercises/Blanks/MultipleChoice";
@@ -17,7 +17,7 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci
firstWordId = exercise.questions[0].id; firstWordId = exercise.questions[0].id;
lastWordId = exercise.questions[exercise.questions.length - 1].id; lastWordId = exercise.questions[exercise.questions.length - 1].id;
return { return {
id: index, id: index.toString(),
sectionId, sectionId,
label: ( label: (
<ExerciseLabel <ExerciseLabel
@@ -35,7 +35,7 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci
firstWordId = exercise.solutions[0].id; firstWordId = exercise.solutions[0].id;
lastWordId = exercise.solutions[exercise.solutions.length - 1].id; lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
return { return {
id: index, id: index.toString(),
sectionId, sectionId,
label: ( label: (
<ExerciseLabel <ExerciseLabel
@@ -52,9 +52,9 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci
default: default:
return {} as unknown as ExerciseItem; return {} as unknown as ExerciseItem;
} }
}).filter((item) => item !== undefined); }).filter(isExerciseItem);
return items || []; return items;
}; };

View File

@@ -1,4 +1,4 @@
import ExerciseItem from './types'; import ExerciseItem, { isExerciseItem } from './types';
import ExerciseLabel from '../../Shared/ExerciseLabel'; import ExerciseLabel from '../../Shared/ExerciseLabel';
import FillBlanksLetters from '../../Exercises/Blanks/Letters'; import FillBlanksLetters from '../../Exercises/Blanks/Letters';
import { Exercise, WriteBlanksExercise } from '@/interfaces/exam'; import { Exercise, WriteBlanksExercise } from '@/interfaces/exam';
@@ -7,15 +7,14 @@ import WriteBlanksForm from '../../Exercises/WriteBlanksForm';
import WriteBlanksFill from '../../Exercises/Blanks/WriteBlankFill'; import WriteBlanksFill from '../../Exercises/Blanks/WriteBlankFill';
import WriteBlanks from '../../Exercises/WriteBlanks'; import WriteBlanks from '../../Exercises/WriteBlanks';
const writeBlanks = (exercise: WriteBlanksExercise, index: number, sectionId: number, previewLabel: (text: string) => string): ExerciseItem => {
const writeBlanks = (exercise: WriteBlanksExercise, index: number, sectionId: number, previewLabel: (text: string) => string) => {
const firstWordId = exercise.solutions[0].id; const firstWordId = exercise.solutions[0].id;
const lastWordId = exercise.solutions[exercise.solutions.length - 1].id; const lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
switch (exercise.variant) { switch (exercise.variant) {
case 'form': case 'form':
return { return {
id: index, id: index.toString(),
sectionId, sectionId,
label: ( label: (
<ExerciseLabel <ExerciseLabel
@@ -31,7 +30,7 @@ const writeBlanks = (exercise: WriteBlanksExercise, index: number, sectionId: nu
}; };
case 'fill': case 'fill':
return { return {
id: index, id: index.toString(),
sectionId, sectionId,
label: ( label: (
<ExerciseLabel <ExerciseLabel
@@ -47,7 +46,7 @@ const writeBlanks = (exercise: WriteBlanksExercise, index: number, sectionId: nu
}; };
case 'questions': case 'questions':
return { return {
id: index, id: index.toString(),
sectionId, sectionId,
label: ( label: (
<ExerciseLabel <ExerciseLabel
@@ -62,23 +61,23 @@ const writeBlanks = (exercise: WriteBlanksExercise, index: number, sectionId: nu
content: <WriteBlanks exercise={exercise} sectionId={sectionId} title='Write Blanks: Questions' /> content: <WriteBlanks exercise={exercise} sectionId={sectionId} title='Write Blanks: Questions' />
}; };
} }
} throw new Error(`Just so that typescript doesnt complain`);
};
const getListeningItems = (exercises: Exercise[], sectionId: number) => {
const getListeningItems = (exercises: Exercise[], sectionId: number): ExerciseItem[] => {
const previewLabel = (text: string) => { const previewLabel = (text: string) => {
return text !== undefined ? text.replaceAll('\\n', ' ').split(' ').slice(0, 15).join(' ') : ""; return text !== undefined ? text.replaceAll('\\n', ' ').split(' ').slice(0, 15).join(' ') : "";
} };
const items = exercises.map((exercise, index) => { const mappedItems = exercises.map((exercise, index): ExerciseItem | null => {
let firstWordId, lastWordId; let firstWordId, lastWordId;
switch (exercise.type) { switch (exercise.type) {
case "fillBlanks": case "fillBlanks":
firstWordId = exercise.solutions[0].id; firstWordId = exercise.solutions[0].id;
lastWordId = exercise.solutions[exercise.solutions.length - 1].id; lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
return { return {
id: index, id: index.toString(),
sectionId, sectionId,
label: ( label: (
<ExerciseLabel <ExerciseLabel
@@ -91,15 +90,16 @@ const getListeningItems = (exercises: Exercise[], sectionId: number) => {
/> />
), ),
content: <FillBlanksLetters exercise={exercise} sectionId={sectionId} /> content: <FillBlanksLetters exercise={exercise} sectionId={sectionId} />
}; };
case "writeBlanks": case "writeBlanks":
return writeBlanks(exercise, index, sectionId, previewLabel); return writeBlanks(exercise, index, sectionId, previewLabel);
case "multipleChoice": case "multipleChoice":
firstWordId = exercise.questions[0].id; firstWordId = exercise.questions[0].id;
lastWordId = exercise.questions[exercise.questions.length - 1].id; lastWordId = exercise.questions[exercise.questions.length - 1].id;
return { return {
id: index, id: index.toString(),
sectionId, sectionId,
label: ( label: (
<ExerciseLabel <ExerciseLabel
@@ -113,11 +113,15 @@ const getListeningItems = (exercises: Exercise[], sectionId: number) => {
), ),
content: <MultipleChoice exercise={exercise} sectionId={sectionId} /> content: <MultipleChoice exercise={exercise} sectionId={sectionId} />
}; };
}
}).filter((item) => item !== undefined);
return items || []; default:
return null;
}
});
return mappedItems.filter((item): item is ExerciseItem =>
item !== null && isExerciseItem(item)
);
}; };
export default getListeningItems; export default getListeningItems;

View File

@@ -1,4 +1,4 @@
import ExerciseItem, { ReadingExercise } from './types'; import ExerciseItem, { isExerciseItem, ReadingExercise } from './types';
import WriteBlanks from "@/editor/Exercises/WriteBlanks"; import WriteBlanks from "@/editor/Exercises/WriteBlanks";
import ExerciseLabel from '../../Shared/ExerciseLabel'; import ExerciseLabel from '../../Shared/ExerciseLabel';
import MatchSentences from '../../Exercises/MatchSentences'; import MatchSentences from '../../Exercises/MatchSentences';
@@ -19,7 +19,7 @@ const getExerciseItems = (exercises: ReadingExercise[], sectionId: number): Exer
firstWordId = exercise.solutions[0].id; firstWordId = exercise.solutions[0].id;
lastWordId = exercise.solutions[exercise.solutions.length - 1].id; lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
return { return {
id: index, id: index.toString(),
sectionId, sectionId,
label: ( label: (
<ExerciseLabel <ExerciseLabel
@@ -37,7 +37,7 @@ const getExerciseItems = (exercises: ReadingExercise[], sectionId: number): Exer
firstWordId = exercise.solutions[0].id; firstWordId = exercise.solutions[0].id;
lastWordId = exercise.solutions[exercise.solutions.length - 1].id; lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
return { return {
id: index, id: index.toString(),
sectionId, sectionId,
label: ( label: (
<ExerciseLabel <ExerciseLabel
@@ -55,7 +55,7 @@ const getExerciseItems = (exercises: ReadingExercise[], sectionId: number): Exer
firstWordId = exercise.sentences[0].id; firstWordId = exercise.sentences[0].id;
lastWordId = exercise.sentences[exercise.sentences.length - 1].id; lastWordId = exercise.sentences[exercise.sentences.length - 1].id;
return { return {
id: index, id: index.toString(),
sectionId, sectionId,
label: ( label: (
<ExerciseLabel <ExerciseLabel
@@ -73,7 +73,7 @@ const getExerciseItems = (exercises: ReadingExercise[], sectionId: number): Exer
firstWordId = exercise.questions[0].id firstWordId = exercise.questions[0].id
lastWordId = exercise.questions[exercise.questions.length - 1].id; lastWordId = exercise.questions[exercise.questions.length - 1].id;
return { return {
id: index, id: index.toString(),
sectionId, sectionId,
label: ( label: (
<ExerciseLabel <ExerciseLabel
@@ -89,9 +89,9 @@ const getExerciseItems = (exercises: ReadingExercise[], sectionId: number): Exer
}; };
} }
}).filter((item) => item !== undefined); }).filter(isExerciseItem);
return items || []; return items;
}; };

View File

@@ -1,10 +1,19 @@
import { FillBlanksExercise, MatchSentencesExercise, TrueFalseExercise, WriteBlanksExercise } from "@/interfaces/exam"; import { FillBlanksExercise, MatchSentencesExercise, TrueFalseExercise, WriteBlanksExercise } from "@/interfaces/exam";
export default interface ExerciseItem { export default interface ExerciseItem {
id: number; id: string;
sectionId: number; sectionId: number;
label: React.ReactNode; label: React.ReactNode;
content: React.ReactNode; content: React.ReactNode;
} }
export type ReadingExercise = FillBlanksExercise | TrueFalseExercise | MatchSentencesExercise | WriteBlanksExercise; export type ReadingExercise = FillBlanksExercise | TrueFalseExercise | MatchSentencesExercise | WriteBlanksExercise;
export function isExerciseItem(item: unknown): item is ExerciseItem {
return item !== undefined &&
item !== null &&
typeof (item as ExerciseItem).id === 'string' &&
typeof (item as ExerciseItem).sectionId === 'number' &&
(item as ExerciseItem).label !== undefined &&
(item as ExerciseItem).content !== undefined;
}

View File

@@ -7,7 +7,7 @@ import ExerciseWizard, { ExerciseConfig } from "./ExerciseWizard";
import { generate } from "../../SettingsEditor/Shared/Generate"; import { generate } from "../../SettingsEditor/Shared/Generate";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import { Dialog, ListeningPart, ReadingPart } from "@/interfaces/exam"; import { ListeningPart, Message, ReadingPart } from "@/interfaces/exam";
interface ExercisePickerProps { interface ExercisePickerProps {
module: string; module: string;
@@ -81,7 +81,7 @@ const ExercisePicker: React.FC<ExercisePickerProps> = ({
switch (sectionId) { switch (sectionId) {
case 1: case 1:
case 3: case 3:
dialog = script as Dialog[]; dialog = script as Message[];
text = dialog.map((d) => `${d.name}: ${d.text}`).join("\n"); text = dialog.map((d) => `${d.name}: ${d.text}`).join("\n");
context = { text: text } context = { text: text }
break; break;

View File

@@ -5,6 +5,7 @@ interface Props {
onChange: (value: string) => void; onChange: (value: string) => void;
className?: string; className?: string;
placeholder?: string; placeholder?: string;
disabled?: boolean;
onBlur?: () => void; onBlur?: () => void;
} }
@@ -12,6 +13,7 @@ const AutoExpandingTextArea: React.FC<Props> = ({
value, value,
className = 'w-full cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl', className = 'w-full cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl',
placeholder = "Enter text here...", placeholder = "Enter text here...",
disabled = false,
onChange, onChange,
onBlur, onBlur,
}) => { }) => {
@@ -45,6 +47,7 @@ const AutoExpandingTextArea: React.FC<Props> = ({
placeholder={placeholder} placeholder={placeholder}
style={{ overflow: 'hidden', resize: 'none' }} style={{ overflow: 'hidden', resize: 'none' }}
onBlur={onBlur} onBlur={onBlur}
disabled={disabled}
autoFocus autoFocus
/> />
); );

View File

@@ -62,8 +62,8 @@ export interface ListeningExam extends ExamBase {
module: "listening"; module: "listening";
} }
export type Dialog = { "name": string; "gender": string; "text": string; }; export type Message = { name: string; gender: string; text: string; voice?: string; };
export type Script = Dialog[] | string; export type Script = Message[] | string;
export interface ListeningPart extends Section { export interface ListeningPart extends Section {
audio?: { audio?: {