Exam generation rework, batch user tables, fastapi endpoint switch
This commit is contained in:
38
package-lock.json
generated
38
package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@beam-australia/react-env": "^3.1.1",
|
"@beam-australia/react-env": "^3.1.1",
|
||||||
"@dnd-kit/core": "^6.1.0",
|
"@dnd-kit/core": "^6.1.0",
|
||||||
|
"@dnd-kit/modifiers": "^7.0.0",
|
||||||
"@dnd-kit/sortable": "^8.0.0",
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
"@firebase/util": "^1.9.7",
|
"@firebase/util": "^1.9.7",
|
||||||
"@headlessui/react": "^2.1.2",
|
"@headlessui/react": "^2.1.2",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"formidable-serverless": "^1.1.1",
|
"formidable-serverless": "^1.1.1",
|
||||||
"framer-motion": "^9.0.2",
|
"framer-motion": "^9.0.2",
|
||||||
"howler": "^2.2.4",
|
"howler": "^2.2.4",
|
||||||
|
"immer": "^10.1.1",
|
||||||
"iron-session": "^6.3.1",
|
"iron-session": "^6.3.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
@@ -463,6 +465,19 @@
|
|||||||
"react-dom": ">=16.8.0"
|
"react-dom": ">=16.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/modifiers": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-BG/ETy3eBjFap7+zIti53f0PCLGDzNXyTmn6fSdrudORf+OH04MxrW4p5+mPu4mgMk9kM41iYONjc3DOUWTcfg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.1.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@dnd-kit/sortable": {
|
"node_modules/@dnd-kit/sortable": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
|
||||||
@@ -7302,6 +7317,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
|
||||||
|
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
||||||
@@ -12008,6 +12032,15 @@
|
|||||||
"tslib": "^2.0.0"
|
"tslib": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@dnd-kit/modifiers": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-BG/ETy3eBjFap7+zIti53f0PCLGDzNXyTmn6fSdrudORf+OH04MxrW4p5+mPu4mgMk9kM41iYONjc3DOUWTcfg==",
|
||||||
|
"requires": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@dnd-kit/sortable": {
|
"@dnd-kit/sortable": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
|
||||||
@@ -17337,6 +17370,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
|
||||||
},
|
},
|
||||||
|
"immer": {
|
||||||
|
"version": "10.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
|
||||||
|
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw=="
|
||||||
|
},
|
||||||
"import-fresh": {
|
"import-fresh": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@beam-australia/react-env": "^3.1.1",
|
"@beam-australia/react-env": "^3.1.1",
|
||||||
"@dnd-kit/core": "^6.1.0",
|
"@dnd-kit/core": "^6.1.0",
|
||||||
|
"@dnd-kit/modifiers": "^7.0.0",
|
||||||
"@dnd-kit/sortable": "^8.0.0",
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
"@firebase/util": "^1.9.7",
|
"@firebase/util": "^1.9.7",
|
||||||
"@headlessui/react": "^2.1.2",
|
"@headlessui/react": "^2.1.2",
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
"formidable-serverless": "^1.1.1",
|
"formidable-serverless": "^1.1.1",
|
||||||
"framer-motion": "^9.0.2",
|
"framer-motion": "^9.0.2",
|
||||||
"howler": "^2.2.4",
|
"howler": "^2.2.4",
|
||||||
|
"immer": "^10.1.1",
|
||||||
"iron-session": "^6.3.1",
|
"iron-session": "^6.3.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
|
|||||||
BIN
public/microsoft-word-icon.png
Normal file
BIN
public/microsoft-word-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -2,29 +2,42 @@ import React, { useState, ReactNode, useRef, useEffect } from 'react';
|
|||||||
import { animated, useSpring } from '@react-spring/web';
|
import { animated, useSpring } from '@react-spring/web';
|
||||||
|
|
||||||
interface DropdownProps {
|
interface DropdownProps {
|
||||||
title: ReactNode;
|
title?: ReactNode;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
|
setIsOpen?: React.Dispatch<React.SetStateAction<boolean>> | ((isOpen: boolean) => void);
|
||||||
className?: string;
|
className?: string;
|
||||||
contentWrapperClassName?: string;
|
contentWrapperClassName?: string;
|
||||||
|
titleClassName?: string;
|
||||||
bottomPadding?: number;
|
bottomPadding?: number;
|
||||||
|
disabled?: boolean,
|
||||||
|
wrapperClassName?: string;
|
||||||
|
customTitle?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Dropdown: React.FC<DropdownProps> = ({
|
const Dropdown: React.FC<DropdownProps> = ({
|
||||||
title,
|
title,
|
||||||
open = false,
|
open = false,
|
||||||
|
titleClassName = "",
|
||||||
|
setIsOpen: externalSetIsOpen,
|
||||||
className = "w-full text-left font-semibold flex justify-between items-center p-4",
|
className = "w-full text-left font-semibold flex justify-between items-center p-4",
|
||||||
contentWrapperClassName = "px-6",
|
contentWrapperClassName = "px-6",
|
||||||
bottomPadding = 12,
|
bottomPadding = 12,
|
||||||
|
disabled = false,
|
||||||
|
customTitle = undefined,
|
||||||
|
wrapperClassName,
|
||||||
children
|
children
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState<boolean>(open);
|
const [internalIsOpen, setInternalIsOpen] = useState<boolean>(open);
|
||||||
|
const isOpen = externalSetIsOpen !== undefined ? open : internalIsOpen;
|
||||||
|
const toggleOpen = externalSetIsOpen !== undefined ? externalSetIsOpen : setInternalIsOpen;
|
||||||
|
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const [contentHeight, setContentHeight] = useState<number>(0);
|
const [contentHeight, setContentHeight] = useState<number>(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
if (contentRef.current) {
|
if (contentRef.current) {
|
||||||
resizeObserver = new ResizeObserver(entries => {
|
resizeObserver = new ResizeObserver(entries => {
|
||||||
for (let entry of entries) {
|
for (let entry of entries) {
|
||||||
@@ -38,10 +51,10 @@ const Dropdown: React.FC<DropdownProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
resizeObserver.observe(contentRef.current);
|
resizeObserver.observe(contentRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (resizeObserver) {
|
if (resizeObserver) {
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
@@ -56,28 +69,35 @@ const Dropdown: React.FC<DropdownProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className={wrapperClassName}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => toggleOpen(!isOpen)}
|
||||||
className={className}
|
className={className}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{title}
|
<div className='flex flex-row w-full justify-between items-center'>
|
||||||
<svg
|
{customTitle ? (
|
||||||
className={`w-4 h-4 transform transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
customTitle
|
||||||
fill="none"
|
) : (
|
||||||
stroke="currentColor"
|
<p className={titleClassName}>{title}</p>
|
||||||
viewBox="0 0 24 24"
|
)}
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<svg
|
||||||
>
|
className={`w-4 h-4 transform transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
fill="none"
|
||||||
</svg>
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<animated.div style={springProps} className="overflow-hidden">
|
<animated.div style={springProps} className="overflow-hidden">
|
||||||
<div ref={contentRef} className={contentWrapperClassName} style={{paddingBottom: bottomPadding}}>
|
<div ref={contentRef} className={contentWrapperClassName} style={{ paddingBottom: bottomPadding }}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</animated.div>
|
</animated.div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
129
src/components/ExamEditor/Exercises/Blanks/DragNDrop.tsx
Normal file
129
src/components/ExamEditor/Exercises/Blanks/DragNDrop.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { useDraggable, useDroppable } from "@dnd-kit/core";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { MdClose, MdDelete, MdDragIndicator } from "react-icons/md";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import ConfirmDeleteBtn from "../../Shared/ConfirmDeleteBtn";
|
||||||
|
|
||||||
|
interface BlankProps {
|
||||||
|
id: number;
|
||||||
|
module: string;
|
||||||
|
variant: "text" | "bank";
|
||||||
|
isSelected?: boolean;
|
||||||
|
isDragging?: boolean;
|
||||||
|
onSelect?: (id: number) => void;
|
||||||
|
onRemove?: (id: number) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Blank: React.FC<BlankProps> = ({
|
||||||
|
id,
|
||||||
|
module,
|
||||||
|
variant,
|
||||||
|
isSelected,
|
||||||
|
isDragging,
|
||||||
|
onSelect,
|
||||||
|
onRemove,
|
||||||
|
disabled,
|
||||||
|
}) => {
|
||||||
|
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
||||||
|
id: `${variant}-blank-${id}`,
|
||||||
|
disabled: disabled || variant !== "text",
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = transform ? {
|
||||||
|
transform: CSS.Translate.toString(transform),
|
||||||
|
transition: 'none',
|
||||||
|
zIndex: 999,
|
||||||
|
position: 'relative' as const,
|
||||||
|
touchAction: 'none',
|
||||||
|
} : {
|
||||||
|
transition: 'transform 0.2s cubic-bezier(0.25, 1, 0.5, 1)',
|
||||||
|
touchAction: 'none',
|
||||||
|
position: 'relative' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
if (variant === "bank" && !disabled && onSelect) {
|
||||||
|
onSelect(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dragProps = variant === "text" ? {
|
||||||
|
...attributes,
|
||||||
|
...listeners,
|
||||||
|
} : {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={clsx(
|
||||||
|
"group relative inline-flex items-center gap-2 px-2 py-1.5 rounded-lg select-none",
|
||||||
|
"transform-gpu transition-colors duration-150",
|
||||||
|
"hover:ring-2 hover:ring-offset-1 shadow-sm",
|
||||||
|
(
|
||||||
|
isSelected ? (
|
||||||
|
isDragging ?
|
||||||
|
`bg-ielts-${module}/20 bg-ielts-${module} hover:ring-ielts-${module}/50` :
|
||||||
|
`bg-ielts-${module}/20 bg-ielts-${module}/80 hover:ring-ielts-${module}/40`
|
||||||
|
)
|
||||||
|
: `bg-ielts-${module}/20 bg-ielts-${module} hover:ring-ielts-${module}/50`
|
||||||
|
),
|
||||||
|
!disabled && (variant === "text" ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"),
|
||||||
|
disabled && "cursor-default",
|
||||||
|
variant === "bank" && "w-12"
|
||||||
|
)}
|
||||||
|
onClick={variant === "bank" ? handleClick : undefined}
|
||||||
|
{...dragProps}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
{variant === "text" && (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"text-xl p-1.5 -ml-1 rounded-md",
|
||||||
|
"transition-colors duration-150"
|
||||||
|
)}
|
||||||
|
title="Drag to reorder"
|
||||||
|
>
|
||||||
|
{isSelected ?
|
||||||
|
<MdDragIndicator className="transform scale-125" color="white" /> :
|
||||||
|
<MdDragIndicator className="transform scale-125" color="#898492" />
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={clsx(
|
||||||
|
"font-semibold px-1 text-mti-gray-taupe",
|
||||||
|
isSelected && !isDragging && "text-white"
|
||||||
|
)}>
|
||||||
|
{id}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{onRemove && !isDragging && (
|
||||||
|
<ConfirmDeleteBtn
|
||||||
|
onDelete={() => onRemove(id)}
|
||||||
|
size="md"
|
||||||
|
position="top-right"
|
||||||
|
className="-translate-y-2 translate-x-1.5"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DropZone: React.FC<{ index: number, module: string; }> = ({ index, module }) => {
|
||||||
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
|
id: `drop-${index}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={clsx(
|
||||||
|
"inline-block h-6 w-4 mx-px transition-all duration-200 select-none",
|
||||||
|
isOver ? `bg-ielts-${module}/20 w-4.5` : `bg-transparent hover:bg-ielts-${module}/20`
|
||||||
|
)}
|
||||||
|
role="presentation"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
247
src/components/ExamEditor/Exercises/Blanks/FillBlanksReducer.tsx
Normal file
247
src/components/ExamEditor/Exercises/Blanks/FillBlanksReducer.tsx
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
type TextToken = {
|
||||||
|
type: 'text';
|
||||||
|
content: string;
|
||||||
|
isWhitespace: boolean;
|
||||||
|
isLineBreak?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BlankToken = {
|
||||||
|
type: 'blank';
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Token = TextToken | BlankToken;
|
||||||
|
|
||||||
|
export type BlankState = {
|
||||||
|
id: number;
|
||||||
|
position: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const getTextSegments = (text: string): Token[] => {
|
||||||
|
const tokens: Token[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
const regex = /{{(\d+)}}/g;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
const addTextTokens = (text: string) => {
|
||||||
|
// Split by newlines first
|
||||||
|
const lines = text.replaceAll("\\n",'\n').split(/(\n)/);
|
||||||
|
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
if (line === '\n') {
|
||||||
|
tokens.push({
|
||||||
|
type: 'text',
|
||||||
|
content: '<br>',
|
||||||
|
isWhitespace: false,
|
||||||
|
isLineBreak: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedText = line.replace(/\s+/g, ' ');
|
||||||
|
if (normalizedText) {
|
||||||
|
const parts = normalizedText.split(/(\s)/);
|
||||||
|
parts.forEach(part => {
|
||||||
|
if (part) {
|
||||||
|
tokens.push({
|
||||||
|
type: 'text',
|
||||||
|
content: part,
|
||||||
|
isWhitespace: /^\s+$/.test(part)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
while ((match = regex.exec(text)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
addTextTokens(text.slice(lastIndex, match.index));
|
||||||
|
}
|
||||||
|
tokens.push({
|
||||||
|
type: 'blank',
|
||||||
|
id: parseInt(match[1])
|
||||||
|
});
|
||||||
|
lastIndex = regex.lastIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
addTextTokens(text.slice(lastIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reconstructTextFromTokens = (tokens: Token[]): string => {
|
||||||
|
return tokens.map(token => {
|
||||||
|
if (token.type === 'blank') {
|
||||||
|
return `{{${token.id}}}`;
|
||||||
|
}
|
||||||
|
if (token.type === 'text' && token.isLineBreak) {
|
||||||
|
return '\n';
|
||||||
|
}
|
||||||
|
return token.content;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type BlanksState = {
|
||||||
|
text: string;
|
||||||
|
blanks: BlankState[];
|
||||||
|
selectedBlankId: number | null;
|
||||||
|
draggedItemId: string | null;
|
||||||
|
textMode: boolean;
|
||||||
|
setEditing: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BlanksAction =
|
||||||
|
| { type: "SET_TEXT"; payload: string }
|
||||||
|
| { type: "SET_BLANKS"; payload: BlankState[] }
|
||||||
|
| { type: "ADD_BLANK" }
|
||||||
|
| { type: "REMOVE_BLANK"; payload: number }
|
||||||
|
| { type: "SELECT_BLANK"; payload: number | null }
|
||||||
|
| { type: "SET_DRAGGED_ITEM"; payload: string | null }
|
||||||
|
| { type: "MOVE_BLANK"; payload: { blankId: number; newPosition: number } }
|
||||||
|
| { type: "TOGGLE_EDIT_MODE" }
|
||||||
|
| { type: "RESET", payload: { text: string } };
|
||||||
|
|
||||||
|
|
||||||
|
export const blanksReducer = (state: BlanksState, action: BlanksAction): BlanksState => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "SET_TEXT": {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
text: action.payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "SET_BLANKS": {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
blanks: action.payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "ADD_BLANK":
|
||||||
|
state.setEditing(true);
|
||||||
|
const newBlankId = Math.max(...state.blanks.map(b => b.id), 0) + 1;
|
||||||
|
const newBlanks = [
|
||||||
|
...state.blanks,
|
||||||
|
{ id: newBlankId, position: state.blanks.length }
|
||||||
|
];
|
||||||
|
const newText = state.text + ` {{${newBlankId}}}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
blanks: newBlanks,
|
||||||
|
text: newText
|
||||||
|
};
|
||||||
|
|
||||||
|
case "REMOVE_BLANK": {
|
||||||
|
if (state.blanks.length === 1) {
|
||||||
|
toast.error("There needs to be at least 1 blank!");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
state.setEditing(true);
|
||||||
|
const blanksToKeep = state.blanks.filter(b => b.id !== action.payload);
|
||||||
|
const updatedBlanks = blanksToKeep.map((blank, index) => ({
|
||||||
|
...blank,
|
||||||
|
position: index
|
||||||
|
}));
|
||||||
|
|
||||||
|
const tokens = getTextSegments(state.text).filter(
|
||||||
|
token => !(token.type === 'blank' && token.id === action.payload)
|
||||||
|
);
|
||||||
|
|
||||||
|
const newText = reconstructTextFromTokens(tokens);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
blanks: updatedBlanks,
|
||||||
|
text: newText,
|
||||||
|
selectedBlankId: state.selectedBlankId === action.payload ? null : state.selectedBlankId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "MOVE_BLANK": {
|
||||||
|
state.setEditing(true);
|
||||||
|
const { blankId, newPosition } = action.payload;
|
||||||
|
const tokens = getTextSegments(state.text);
|
||||||
|
|
||||||
|
// Find the current position of the blank
|
||||||
|
const currentPosition = tokens.findIndex(
|
||||||
|
token => token.type === 'blank' && token.id === blankId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentPosition === -1) return state;
|
||||||
|
|
||||||
|
// Remove the blank and its surrounding whitespace
|
||||||
|
const blankToken = tokens[currentPosition];
|
||||||
|
tokens.splice(currentPosition, 1);
|
||||||
|
|
||||||
|
// When inserting at new position, ensure there's whitespace around the blank
|
||||||
|
let insertPosition = newPosition;
|
||||||
|
const prevToken = tokens[insertPosition - 1];
|
||||||
|
const nextToken = tokens[insertPosition];
|
||||||
|
|
||||||
|
// Insert space before if needed
|
||||||
|
if (!prevToken || (prevToken.type === 'text' && !prevToken.isWhitespace)) {
|
||||||
|
tokens.splice(insertPosition, 0, {
|
||||||
|
type: 'text',
|
||||||
|
content: ' ',
|
||||||
|
isWhitespace: true
|
||||||
|
});
|
||||||
|
insertPosition++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the blank
|
||||||
|
tokens.splice(insertPosition, 0, blankToken);
|
||||||
|
insertPosition++;
|
||||||
|
|
||||||
|
// Insert space after if needed
|
||||||
|
if (!nextToken || (nextToken.type === 'text' && !nextToken.isWhitespace)) {
|
||||||
|
tokens.splice(insertPosition, 0, {
|
||||||
|
type: 'text',
|
||||||
|
content: ' ',
|
||||||
|
isWhitespace: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct the text
|
||||||
|
const newText = reconstructTextFromTokens(tokens);
|
||||||
|
|
||||||
|
// Update blank positions
|
||||||
|
const updatedBlanks = tokens.reduce((acc, token, idx) => {
|
||||||
|
if (token.type === 'blank') {
|
||||||
|
acc.push({ id: token.id, position: idx });
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [] as BlankState[]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
text: newText,
|
||||||
|
blanks: updatedBlanks
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "SELECT_BLANK":
|
||||||
|
return { ...state, selectedBlankId: action.payload };
|
||||||
|
case "SET_DRAGGED_ITEM":
|
||||||
|
state.setEditing(true);
|
||||||
|
return { ...state, draggedItemId: action.payload };
|
||||||
|
case "TOGGLE_EDIT_MODE":
|
||||||
|
return { ...state, textMode: !state.textMode };
|
||||||
|
|
||||||
|
case "RESET":
|
||||||
|
return {
|
||||||
|
text: action.payload.text || "",
|
||||||
|
blanks: [],
|
||||||
|
selectedBlankId: null,
|
||||||
|
draggedItemId: null,
|
||||||
|
textMode: false,
|
||||||
|
setEditing: state.setEditing
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { MdDelete } from "react-icons/md";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
letter: string;
|
||||||
|
word: string;
|
||||||
|
isSelected: boolean;
|
||||||
|
isUsed: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
onRemove?: () => void;
|
||||||
|
onEdit?: (newWord: string) => void;
|
||||||
|
isEditMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FillBlanksWord: React.FC<Props> = ({
|
||||||
|
letter,
|
||||||
|
word,
|
||||||
|
isSelected,
|
||||||
|
isUsed,
|
||||||
|
onClick,
|
||||||
|
onRemove,
|
||||||
|
onEdit,
|
||||||
|
isEditMode
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full flex items-center gap-2">
|
||||||
|
{isEditMode ? (
|
||||||
|
<div className="min-w-0 flex-1 flex items-center gap-2 p-2 rounded-md border border-gray-200">
|
||||||
|
<span className="font-medium min-w-[24px] text-center shrink-0">{letter}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={word}
|
||||||
|
onChange={(e) => onEdit?.(e.target.value)}
|
||||||
|
className="w-full min-w-0 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={isUsed}
|
||||||
|
className={`
|
||||||
|
min-w-0 flex-1 flex items-center gap-2 p-2 rounded-md border text-left transition-colors
|
||||||
|
${isUsed ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:bg-blue-50'}
|
||||||
|
${isSelected ? 'border-blue-500 bg-blue-100' : 'border-gray-200'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span className="font-medium min-w-[24px] text-center shrink-0">{letter}</span>
|
||||||
|
<span className="truncate">{word}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isEditMode && onRemove && (
|
||||||
|
<button
|
||||||
|
onClick={onRemove}
|
||||||
|
className="p-1 rounded text-red-500 hover:bg-gray-100 shrink-0"
|
||||||
|
aria-label="Remove word"
|
||||||
|
>
|
||||||
|
<MdDelete className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default FillBlanksWord;
|
||||||
301
src/components/ExamEditor/Exercises/Blanks/Letters/index.tsx
Normal file
301
src/components/ExamEditor/Exercises/Blanks/Letters/index.tsx
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import { FillBlanksExercise, ReadingPart } from "@/interfaces/exam";
|
||||||
|
import { useEffect, useReducer, useState } from "react";
|
||||||
|
import BlanksEditor from "..";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { MdEdit, MdEditOff } from "react-icons/md";
|
||||||
|
import FillBlanksWord from "./FillBlanksWord";
|
||||||
|
import { FaPlus } from "react-icons/fa";
|
||||||
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
import { blanksReducer, BlankState, getTextSegments } from "../FillBlanksReducer";
|
||||||
|
import useSectionEdit from "@/components/ExamEditor/Hooks/useSectionEdit";
|
||||||
|
import { AlertItem } from "../../Shared/Alert";
|
||||||
|
import validateBlanks from "../validateBlanks";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import setEditingAlert from "../../Shared/setEditingAlert";
|
||||||
|
|
||||||
|
interface Word {
|
||||||
|
letter: string;
|
||||||
|
word: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => {
|
||||||
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
|
const { state } = useExamEditorStore(
|
||||||
|
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||||
|
);
|
||||||
|
|
||||||
|
const section = state as ReadingPart;
|
||||||
|
|
||||||
|
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||||
|
|
||||||
|
const [local, setLocal] = useState(exercise);
|
||||||
|
const [selectedBlankId, setSelectedBlankId] = useState<string | null>(null);
|
||||||
|
const [answers, setAnswers] = useState<Map<string, string>>(
|
||||||
|
new Map(exercise.solutions.map(({ id, solution }) => [id, solution]))
|
||||||
|
);
|
||||||
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
|
const [newWord, setNewWord] = useState('');
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
const [blanksState, blanksDispatcher] = useReducer(blanksReducer, {
|
||||||
|
text: exercise.text || "",
|
||||||
|
blanks: [],
|
||||||
|
selectedBlankId: null,
|
||||||
|
draggedItemId: null,
|
||||||
|
textMode: false,
|
||||||
|
setEditing,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleSave, handleDiscard, modeHandle } = useSectionEdit({
|
||||||
|
sectionId,
|
||||||
|
editing,
|
||||||
|
setEditing,
|
||||||
|
onSave: () => {
|
||||||
|
if (!validateBlanks(blanksState.blanks, answers, alerts, setAlerts)) {
|
||||||
|
toast.error("Please fix the errors before saving!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditing(false);
|
||||||
|
setAlerts([]);
|
||||||
|
|
||||||
|
const updatedExercise = {
|
||||||
|
...local,
|
||||||
|
text: blanksState.text,
|
||||||
|
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
|
||||||
|
id,
|
||||||
|
solution
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
const newState = { ...section };
|
||||||
|
newState.exercises = newState.exercises.map((ex) =>
|
||||||
|
ex.id === exercise.id ? updatedExercise : ex
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState } });
|
||||||
|
dispatch({ type: "REORDER_EXERCISES" });
|
||||||
|
},
|
||||||
|
onDiscard: () => {
|
||||||
|
setSelectedBlankId(null);
|
||||||
|
setAnswers(new Map(exercise.solutions.map(({ id, solution }) => [id, solution])));
|
||||||
|
setIsEditMode(false);
|
||||||
|
setNewWord('');
|
||||||
|
setLocal(exercise);
|
||||||
|
|
||||||
|
blanksDispatcher({ type: "RESET", payload: { text: exercise.text } });
|
||||||
|
blanksDispatcher({ type: "SET_TEXT", payload: exercise.text || "" });
|
||||||
|
|
||||||
|
const tokens = getTextSegments(exercise.text || "");
|
||||||
|
const initialBlanks = tokens.reduce((acc, token, idx) => {
|
||||||
|
if (token.type === 'blank') {
|
||||||
|
acc.push({ id: token.id, position: idx });
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [] as BlankState[]);
|
||||||
|
blanksDispatcher({ type: "SET_BLANKS", payload: initialBlanks });
|
||||||
|
|
||||||
|
},
|
||||||
|
onMode: () => {
|
||||||
|
const newSection = {
|
||||||
|
...section,
|
||||||
|
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
||||||
|
};
|
||||||
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
|
||||||
|
dispatch({ type: "REORDER_EXERCISES" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing) {
|
||||||
|
setLocal(exercise);
|
||||||
|
setAnswers(new Map(exercise.solutions.map(({ id, solution }) => [id, solution])));
|
||||||
|
}
|
||||||
|
}, [exercise, editing]);
|
||||||
|
|
||||||
|
const handleWordSelect = (word: string) => {
|
||||||
|
if (!selectedBlankId) return;
|
||||||
|
|
||||||
|
if (!editing) setEditing(true);
|
||||||
|
|
||||||
|
const newAnswers = new Map(answers);
|
||||||
|
newAnswers.set(selectedBlankId, word);
|
||||||
|
|
||||||
|
setAnswers(newAnswers);
|
||||||
|
|
||||||
|
setLocal(prev => ({
|
||||||
|
...prev,
|
||||||
|
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||||
|
id,
|
||||||
|
solution
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddWord = () => {
|
||||||
|
const word = newWord.trim();
|
||||||
|
if (!word) return;
|
||||||
|
|
||||||
|
setLocal(prev => {
|
||||||
|
const nextLetter = String.fromCharCode(65 + prev.words.length);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
words: [...prev.words, { letter: nextLetter, word }]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setNewWord('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveWord = (index: number) => {
|
||||||
|
if (!editing) setEditing(true);
|
||||||
|
|
||||||
|
if (answers.size === 1) {
|
||||||
|
toast.error("There needs to be at least 1 word!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocal(prev => {
|
||||||
|
const newWords = prev.words.filter((_, i) => i !== index) as Word[];
|
||||||
|
const removedWord = prev.words[index] as Word;
|
||||||
|
|
||||||
|
const newAnswers = new Map(answers);
|
||||||
|
for (const [blankId, answer] of newAnswers.entries()) {
|
||||||
|
if (answer === removedWord.word) {
|
||||||
|
newAnswers.delete(blankId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setAnswers(newAnswers);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
words: newWords,
|
||||||
|
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||||
|
id,
|
||||||
|
solution
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isWordUsed = (word: string): boolean => {
|
||||||
|
if (local.allowRepetition) return false;
|
||||||
|
return Array.from(answers.values()).includes(word);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditWord = (index: number, newWord: string) => {
|
||||||
|
if (!editing) setEditing(true);
|
||||||
|
|
||||||
|
|
||||||
|
setLocal(prev => {
|
||||||
|
const newWords = [...prev.words] as Word[];
|
||||||
|
const oldWord = newWords[index].word;
|
||||||
|
newWords[index] = { ...newWords[index], word: newWord };
|
||||||
|
|
||||||
|
const newAnswers = new Map(answers);
|
||||||
|
for (const [blankId, answer] of newAnswers.entries()) {
|
||||||
|
if (answer === oldWord) {
|
||||||
|
newAnswers.set(blankId, newWord);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setAnswers(newAnswers);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
words: newWords,
|
||||||
|
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||||
|
id,
|
||||||
|
solution
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
validateBlanks(blanksState.blanks, answers, alerts, setAlerts);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [answers, blanksState.blanks, blanksState.textMode])
|
||||||
|
|
||||||
|
useEffect(()=> {
|
||||||
|
setEditingAlert(editing, setAlerts);
|
||||||
|
}, [editing])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<BlanksEditor
|
||||||
|
alerts={alerts}
|
||||||
|
editing={editing}
|
||||||
|
state={blanksState}
|
||||||
|
blanksDispatcher={blanksDispatcher}
|
||||||
|
description="Place blanks and assign words from the word bank"
|
||||||
|
initialText={local.text}
|
||||||
|
module={currentModule}
|
||||||
|
showBlankBank={true}
|
||||||
|
onBlankSelect={(blankId) => setSelectedBlankId(blankId?.toString() || null)}
|
||||||
|
onSave={handleSave}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
onDelete={modeHandle}
|
||||||
|
setEditing={setEditing}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
{!blanksState.textMode && <Card className="p-4">
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<div className="text-lg font-semibold">Word Bank</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditMode(!isEditMode)}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{isEditMode ?
|
||||||
|
<MdEditOff size={20} className="text-gray-500" /> :
|
||||||
|
<MdEdit size={20} className="text-gray-500" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||||
|
{(local.words as Word[]).map((wordItem, index) => (
|
||||||
|
<FillBlanksWord
|
||||||
|
key={wordItem.letter}
|
||||||
|
letter={wordItem.letter}
|
||||||
|
word={wordItem.word}
|
||||||
|
isSelected={answers.get(selectedBlankId || '') === wordItem.word}
|
||||||
|
isUsed={isWordUsed(wordItem.word)}
|
||||||
|
onClick={() => handleWordSelect(wordItem.word)}
|
||||||
|
onRemove={isEditMode ? () => handleRemoveWord(index) : undefined}
|
||||||
|
onEdit={isEditMode ? (newWord) => handleEditWord(index, newWord) : undefined}
|
||||||
|
isEditMode={isEditMode}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditMode && (
|
||||||
|
<div className="flex flex-row mt-8">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newWord}
|
||||||
|
onChange={(e) => setNewWord(e.target.value)}
|
||||||
|
placeholder="Enter new word"
|
||||||
|
className="flex-1 px-3 py-2 border border-r-0 rounded-l-md focus:outline-none"
|
||||||
|
name=""
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleAddWord}
|
||||||
|
disabled={!isEditMode || newWord === ""}
|
||||||
|
className="px-4 bg-blue-500 text-white rounded-r-md border border-blue-500 hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<FaPlus className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
</BlanksEditor>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FillBlanksLetters;
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
|
||||||
|
import { MdDelete, } from "react-icons/md";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
interface MCOptionProps {
|
||||||
|
id: string;
|
||||||
|
options: {
|
||||||
|
A: string;
|
||||||
|
B: string;
|
||||||
|
C: string;
|
||||||
|
D: string;
|
||||||
|
};
|
||||||
|
selectedOption?: string;
|
||||||
|
onSelect: (option: string) => void;
|
||||||
|
isEditMode?: boolean;
|
||||||
|
onEdit?: (key: 'A' | 'B' | 'C' | 'D', value: string) => void;
|
||||||
|
onRemove?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MCOption: React.FC<MCOptionProps> = ({
|
||||||
|
id,
|
||||||
|
options,
|
||||||
|
selectedOption,
|
||||||
|
onSelect,
|
||||||
|
isEditMode,
|
||||||
|
onEdit,
|
||||||
|
onRemove
|
||||||
|
}) => {
|
||||||
|
const optionKeys = ['A', 'B', 'C', 'D'] as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="font-medium">Question {id}</span>
|
||||||
|
{isEditMode && onRemove && (
|
||||||
|
<button
|
||||||
|
onClick={onRemove}
|
||||||
|
className="p-1 rounded text-red-500 hover:bg-gray-100"
|
||||||
|
aria-label="Remove question"
|
||||||
|
>
|
||||||
|
<MdDelete className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{optionKeys.map((key) => (
|
||||||
|
<div key={key} className="flex gap-2">
|
||||||
|
{isEditMode ? (
|
||||||
|
<div className="flex-1 flex items-center gap-2 p-2 rounded-md border border-gray-200">
|
||||||
|
<span className="font-medium min-w-[24px] text-center">{key}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={options[key]}
|
||||||
|
onChange={(e) => onEdit?.(key, e.target.value)}
|
||||||
|
className="w-full focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect(key)}
|
||||||
|
className={clsx(
|
||||||
|
"flex-1 flex items-center gap-2 p-2 rounded-md border transition-colors text-left",
|
||||||
|
selectedOption === key
|
||||||
|
? "border-blue-500 bg-blue-100"
|
||||||
|
: "border-gray-200 hover:bg-blue-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-medium min-w-[24px] text-center">{key}</span>
|
||||||
|
<span>{options[key]}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MCOption;
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { FillBlanksExercise, FillBlanksMCOption, ReadingPart } from "@/interfaces/exam";
|
||||||
|
import { useEffect, useReducer, useState } from "react";
|
||||||
|
import BlanksEditor from "..";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
import { blanksReducer, BlankState, getTextSegments } from "../FillBlanksReducer";
|
||||||
|
import useSectionEdit from "@/components/ExamEditor/Hooks/useSectionEdit";
|
||||||
|
import { AlertItem } from "../../Shared/Alert";
|
||||||
|
import validateBlanks from "../validateBlanks";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import setEditingAlert from "../../Shared/setEditingAlert";
|
||||||
|
import { MdEdit, MdEditOff } from "react-icons/md";
|
||||||
|
import MCOption from "./MCOption";
|
||||||
|
|
||||||
|
|
||||||
|
const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => {
|
||||||
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
|
const { state } = useExamEditorStore(
|
||||||
|
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||||
|
);
|
||||||
|
|
||||||
|
const section = state as ReadingPart;
|
||||||
|
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||||
|
const [local, setLocal] = useState(exercise);
|
||||||
|
const [selectedBlankId, setSelectedBlankId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [answers, setAnswers] = useState<Map<string, string>>(() => {
|
||||||
|
return new Map(
|
||||||
|
exercise.solutions.map(({ id, solution }) => [
|
||||||
|
id.toString(),
|
||||||
|
solution
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
const [blanksState, blanksDispatcher] = useReducer(blanksReducer, {
|
||||||
|
text: exercise.text || "",
|
||||||
|
blanks: [],
|
||||||
|
selectedBlankId: null,
|
||||||
|
draggedItemId: null,
|
||||||
|
textMode: false,
|
||||||
|
setEditing,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleSave, handleDiscard, modeHandle } = useSectionEdit({
|
||||||
|
sectionId,
|
||||||
|
editing,
|
||||||
|
setEditing,
|
||||||
|
onSave: () => {
|
||||||
|
if (!validateBlanks(blanksState.blanks, answers, alerts, setAlerts)) {
|
||||||
|
toast.error("Please fix the errors before saving!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditing(false);
|
||||||
|
setAlerts([]);
|
||||||
|
|
||||||
|
const updatedExercise = {
|
||||||
|
...local,
|
||||||
|
text: blanksState.text,
|
||||||
|
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
|
||||||
|
id,
|
||||||
|
solution
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
const newState = { ...section };
|
||||||
|
newState.exercises = newState.exercises.map((ex) =>
|
||||||
|
ex.id === exercise.id ? updatedExercise : ex
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState } });
|
||||||
|
dispatch({ type: "REORDER_EXERCISES" });
|
||||||
|
},
|
||||||
|
onDiscard: () => {
|
||||||
|
setSelectedBlankId(null);
|
||||||
|
setAnswers(new Map(exercise.solutions.map(({ id, solution }) => [id, solution])));
|
||||||
|
setIsEditMode(false);
|
||||||
|
setLocal(exercise);
|
||||||
|
|
||||||
|
blanksDispatcher({ type: "RESET", payload: { text: exercise.text } });
|
||||||
|
blanksDispatcher({ type: "SET_TEXT", payload: exercise.text || "" });
|
||||||
|
|
||||||
|
const tokens = getTextSegments(exercise.text || "");
|
||||||
|
const initialBlanks = tokens.reduce((acc, token, idx) => {
|
||||||
|
if (token.type === 'blank') {
|
||||||
|
acc.push({ id: token.id, position: idx });
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [] as BlankState[]);
|
||||||
|
blanksDispatcher({ type: "SET_BLANKS", payload: initialBlanks });
|
||||||
|
},
|
||||||
|
onMode: () => {
|
||||||
|
const newSection = {
|
||||||
|
...section,
|
||||||
|
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
||||||
|
};
|
||||||
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
|
||||||
|
dispatch({ type: "REORDER_EXERCISES" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing) {
|
||||||
|
setLocal(exercise);
|
||||||
|
setAnswers(new Map(exercise.solutions.map(({ id, solution }) => [id, solution])));
|
||||||
|
}
|
||||||
|
}, [exercise, editing]);
|
||||||
|
|
||||||
|
const handleOptionSelect = (option: string) => {
|
||||||
|
if (!selectedBlankId) return;
|
||||||
|
if (!editing) setEditing(true);
|
||||||
|
|
||||||
|
const newAnswers = new Map(answers);
|
||||||
|
newAnswers.set(selectedBlankId, option);
|
||||||
|
|
||||||
|
setAnswers(newAnswers);
|
||||||
|
|
||||||
|
setLocal(prev => ({
|
||||||
|
...prev,
|
||||||
|
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||||
|
id,
|
||||||
|
solution
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditOption = (mcOptionIndex: number, key: keyof FillBlanksMCOption['options'], value: string) => {
|
||||||
|
if (!editing) setEditing(true);
|
||||||
|
|
||||||
|
setLocal(prev => {
|
||||||
|
const newWords = [...prev.words] as FillBlanksMCOption[];
|
||||||
|
const mcOption = newWords[mcOptionIndex] as FillBlanksMCOption;
|
||||||
|
|
||||||
|
const newOptions = { ...mcOption.options, [key]: value };
|
||||||
|
newWords[mcOptionIndex] = { ...mcOption, options: newOptions };
|
||||||
|
|
||||||
|
const oldValue = (mcOption.options as any)[key];
|
||||||
|
const newAnswers = new Map(answers);
|
||||||
|
for (const [blankId, answer] of newAnswers.entries()) {
|
||||||
|
if (answer === oldValue) {
|
||||||
|
newAnswers.set(blankId, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setAnswers(newAnswers);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
words: newWords,
|
||||||
|
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||||
|
id,
|
||||||
|
solution
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveOption = (index: number) => {
|
||||||
|
if (!editing) setEditing(true);
|
||||||
|
|
||||||
|
if (answers.size === 1) {
|
||||||
|
toast.error("There needs to be at least 1 question!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocal(prev => {
|
||||||
|
const newWords = prev.words.filter((_, i) => i !== index) as FillBlanksMCOption[];
|
||||||
|
const removedOption = prev.words[index] as FillBlanksMCOption;
|
||||||
|
const removedValues = Object.values(removedOption.options);
|
||||||
|
const newAnswers = new Map(answers);
|
||||||
|
for (const [blankId, answer] of newAnswers.entries()) {
|
||||||
|
if (removedValues.includes(answer)) {
|
||||||
|
newAnswers.delete(blankId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setAnswers(newAnswers);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
words: newWords,
|
||||||
|
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||||
|
id,
|
||||||
|
solution
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
validateBlanks(blanksState.blanks, answers, alerts, setAlerts);
|
||||||
|
}, [answers, blanksState.blanks, blanksState.textMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditingAlert(editing, setAlerts);
|
||||||
|
}, [editing]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing) {
|
||||||
|
setLocal(exercise);
|
||||||
|
setAnswers(new Map(
|
||||||
|
exercise.solutions.map(({ id, solution }) => [
|
||||||
|
id.toString(),
|
||||||
|
solution
|
||||||
|
])
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}, [exercise, editing]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAnswers(new Map(
|
||||||
|
exercise.solutions.map(({ id, solution }) => [
|
||||||
|
id.toString(),
|
||||||
|
solution
|
||||||
|
])
|
||||||
|
));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<BlanksEditor
|
||||||
|
alerts={alerts}
|
||||||
|
editing={editing}
|
||||||
|
state={blanksState}
|
||||||
|
blanksDispatcher={blanksDispatcher}
|
||||||
|
description="Place blanks and select the correct answer from multiple choice options"
|
||||||
|
initialText={local.text}
|
||||||
|
module={currentModule}
|
||||||
|
showBlankBank={true}
|
||||||
|
onBlankSelect={(blankId) => setSelectedBlankId(blankId?.toString() || null)}
|
||||||
|
onSave={handleSave}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
onDelete={modeHandle}
|
||||||
|
setEditing={setEditing}
|
||||||
|
>
|
||||||
|
{!blanksState.textMode && selectedBlankId && (
|
||||||
|
<Card className="p-4">
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<div className="text-lg font-semibold">Multiple Choice Options</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditMode(!isEditMode)}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{isEditMode ?
|
||||||
|
<MdEditOff size={20} className="text-gray-500" /> :
|
||||||
|
<MdEdit size={20} className="text-gray-500" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(local.words as FillBlanksMCOption[]).map((mcOption) => {
|
||||||
|
if (mcOption.id.toString() !== selectedBlankId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MCOption
|
||||||
|
key={mcOption.id}
|
||||||
|
id={mcOption.id}
|
||||||
|
options={mcOption.options}
|
||||||
|
selectedOption={answers.get(selectedBlankId)}
|
||||||
|
onSelect={(option) => handleOptionSelect(option)}
|
||||||
|
isEditMode={isEditMode}
|
||||||
|
onEdit={(key, value) => handleEditOption(
|
||||||
|
(local.words as FillBlanksMCOption[]).findIndex(w => w.id === mcOption.id),
|
||||||
|
key as "A" | "B" | "C" | "D",
|
||||||
|
value
|
||||||
|
)}
|
||||||
|
onRemove={() => handleRemoveOption(
|
||||||
|
(local.words as FillBlanksMCOption[]).findIndex(w => w.id === mcOption.id)
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</BlanksEditor>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FillBlanksMC;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { MdDelete, MdAdd } from "react-icons/md";
|
||||||
|
|
||||||
|
interface AlternativeSolutionProps {
|
||||||
|
solutions: string[];
|
||||||
|
onAdd: () => void;
|
||||||
|
onRemove: (index: number) => void;
|
||||||
|
onEdit: (index: number, value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AlternativeSolutions: React.FC<AlternativeSolutionProps> = ({
|
||||||
|
solutions,
|
||||||
|
onAdd,
|
||||||
|
onRemove,
|
||||||
|
onEdit,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 mt-4">
|
||||||
|
{solutions.map((solution, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={solution}
|
||||||
|
onChange={(e) => onEdit(index, e.target.value)}
|
||||||
|
className="flex-1 p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
placeholder={`Solution ${index + 1}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(index)}
|
||||||
|
className="p-2 text-gray-500 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title="Delete solution"
|
||||||
|
>
|
||||||
|
<MdDelete size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={onAdd}
|
||||||
|
className="w-full mt-2 p-2 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
<MdAdd size={18} />
|
||||||
|
Add Alternative Solution
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AlternativeSolutions;
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import useSectionEdit from "@/components/ExamEditor/Hooks/useSectionEdit";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { WriteBlanksExercise, ReadingPart } from "@/interfaces/exam";
|
||||||
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
import { useState, useReducer, useEffect } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import BlanksEditor from "..";
|
||||||
|
import { AlertItem } from "../../Shared/Alert";
|
||||||
|
import setEditingAlert from "../../Shared/setEditingAlert";
|
||||||
|
import { blanksReducer } from "../FillBlanksReducer";
|
||||||
|
import { validateWriteBlanks } from "./validation";
|
||||||
|
import AlternativeSolutions from "./AlternativeSolutions";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
const WriteBlanksFill: React.FC<{ exercise: WriteBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => {
|
||||||
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
|
const { state } = useExamEditorStore(
|
||||||
|
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||||
|
);
|
||||||
|
|
||||||
|
const section = state as ReadingPart;
|
||||||
|
|
||||||
|
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||||
|
const [local, setLocal] = useState(exercise);
|
||||||
|
const [selectedBlankId, setSelectedBlankId] = useState<string | null>(null);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
const [blanksState, blanksDispatcher] = useReducer(blanksReducer, {
|
||||||
|
text: exercise.text || "",
|
||||||
|
blanks: [],
|
||||||
|
selectedBlankId: null,
|
||||||
|
draggedItemId: null,
|
||||||
|
textMode: false,
|
||||||
|
setEditing,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleSave, handleDiscard, modeHandle } = useSectionEdit({
|
||||||
|
sectionId,
|
||||||
|
editing,
|
||||||
|
setEditing,
|
||||||
|
onSave: () => {
|
||||||
|
if (!validateWriteBlanks(local.solutions, local.maxWords, setAlerts)) {
|
||||||
|
toast.error("Please fix the errors before saving!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditing(false);
|
||||||
|
setAlerts([]);
|
||||||
|
|
||||||
|
const updatedExercise = {
|
||||||
|
...local,
|
||||||
|
text: blanksState.text,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newState = { ...section };
|
||||||
|
newState.exercises = newState.exercises.map((ex) =>
|
||||||
|
ex.id === exercise.id ? updatedExercise : ex
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState } });
|
||||||
|
},
|
||||||
|
onDiscard: () => {
|
||||||
|
setSelectedBlankId(null);
|
||||||
|
setLocal(exercise);
|
||||||
|
blanksDispatcher({ type: "RESET", payload: { text: exercise.text } });
|
||||||
|
},
|
||||||
|
onMode: () => {
|
||||||
|
const newSection = {
|
||||||
|
...section,
|
||||||
|
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
||||||
|
};
|
||||||
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing) {
|
||||||
|
setLocal(exercise);
|
||||||
|
}
|
||||||
|
}, [exercise, editing]);
|
||||||
|
|
||||||
|
const handleAddSolution = (blankId: string) => {
|
||||||
|
if (!editing) setEditing(true);
|
||||||
|
setLocal(prev => ({
|
||||||
|
...prev,
|
||||||
|
solutions: prev.solutions.map(s =>
|
||||||
|
s.id === blankId
|
||||||
|
? { ...s, solution: [...s.solution, ""] }
|
||||||
|
: s
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveSolution = (blankId: string, index: number) => {
|
||||||
|
if (!editing) setEditing(true);
|
||||||
|
|
||||||
|
const solutions = local.solutions.find(s => s.id === blankId);
|
||||||
|
if (solutions && solutions.solution.length <= 1) {
|
||||||
|
toast.error("Each blank must have at least one solution!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocal(prev => ({
|
||||||
|
...prev,
|
||||||
|
solutions: prev.solutions.map(s =>
|
||||||
|
s.id === blankId
|
||||||
|
? { ...s, solution: s.solution.filter((_, i) => i !== index) }
|
||||||
|
: s
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSolution = (blankId: string, index: number, value: string) => {
|
||||||
|
if (!editing) setEditing(true);
|
||||||
|
|
||||||
|
setLocal(prev => ({
|
||||||
|
...prev,
|
||||||
|
solutions: prev.solutions.map(s =>
|
||||||
|
s.id === blankId
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
solution: s.solution.map((sol, i) => i === index ? value : sol)
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
validateWriteBlanks(local.solutions, local.maxWords, setAlerts);
|
||||||
|
}, [local.solutions, local.maxWords]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditingAlert(editing, setAlerts);
|
||||||
|
}, [editing]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<BlanksEditor
|
||||||
|
title="Write Blanks: Fill"
|
||||||
|
alerts={alerts}
|
||||||
|
editing={editing}
|
||||||
|
state={blanksState}
|
||||||
|
blanksDispatcher={blanksDispatcher}
|
||||||
|
description={local.prompt}
|
||||||
|
initialText={local.text}
|
||||||
|
module={currentModule}
|
||||||
|
showBlankBank={true}
|
||||||
|
onBlankSelect={(blankId) => setSelectedBlankId(blankId?.toString() || null)}
|
||||||
|
onSave={handleSave}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
onDelete={modeHandle}
|
||||||
|
setEditing={setEditing}
|
||||||
|
>
|
||||||
|
{!blanksState.textMode && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-lg font-semibold">
|
||||||
|
{selectedBlankId
|
||||||
|
? `Solutions for Blank ${selectedBlankId}`
|
||||||
|
: "Click a blank to edit its solutions"}
|
||||||
|
</span>
|
||||||
|
{selectedBlankId && (
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
Max words per solution: {local.maxWords}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
{selectedBlankId && (
|
||||||
|
<AlternativeSolutions
|
||||||
|
solutions={local.solutions.find(s => s.id === selectedBlankId)?.solution || []}
|
||||||
|
onAdd={() => handleAddSolution(selectedBlankId)}
|
||||||
|
onRemove={(index: number) => handleRemoveSolution(selectedBlankId, index)}
|
||||||
|
onEdit={(index: number, value: string) => handleEditSolution(selectedBlankId, index, value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</BlanksEditor>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WriteBlanksFill;
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { AlertItem } from "../../Shared/Alert";
|
||||||
|
import { BlankState } from "../FillBlanksReducer";
|
||||||
|
|
||||||
|
|
||||||
|
export const validateWriteBlanks = (
|
||||||
|
solutions: { id: string; solution: string[] }[],
|
||||||
|
maxWords: number,
|
||||||
|
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
|
||||||
|
): boolean => {
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
const emptySolutions = solutions.flatMap(s =>
|
||||||
|
s.solution.map((sol, index) => ({
|
||||||
|
blankId: s.id,
|
||||||
|
solutionIndex: index,
|
||||||
|
isEmpty: !sol.trim()
|
||||||
|
}))
|
||||||
|
).filter(({ isEmpty }) => isEmpty);
|
||||||
|
|
||||||
|
if (emptySolutions.length > 0) {
|
||||||
|
isValid = false;
|
||||||
|
setAlerts(prev => {
|
||||||
|
const filtered = prev.filter(a => !a.tag?.startsWith('empty-solution'));
|
||||||
|
return [...filtered, ...emptySolutions.map(({ blankId, solutionIndex }) => ({
|
||||||
|
variant: "error" as const,
|
||||||
|
tag: `empty-solution-${blankId}-${solutionIndex}`,
|
||||||
|
description: `Solution ${solutionIndex + 1} for blank ${blankId} cannot be empty`
|
||||||
|
}))];
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setAlerts(prev => prev.filter(a => !a.tag?.startsWith('empty-solution')));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxWords > 0) {
|
||||||
|
const invalidWordCount = solutions.flatMap(s =>
|
||||||
|
s.solution.map((sol, index) => ({
|
||||||
|
blankId: s.id,
|
||||||
|
solutionIndex: index,
|
||||||
|
wordCount: sol.trim().split(/\s+/).length
|
||||||
|
}))
|
||||||
|
).filter(({ wordCount }) => wordCount > maxWords);
|
||||||
|
|
||||||
|
if (invalidWordCount.length > 0) {
|
||||||
|
isValid = false;
|
||||||
|
setAlerts(prev => {
|
||||||
|
const filtered = prev.filter(a => !a.tag?.startsWith('word-count'));
|
||||||
|
return [...filtered, ...invalidWordCount.map(({ blankId, solutionIndex, wordCount }) => ({
|
||||||
|
variant: "error" as const,
|
||||||
|
tag: `word-count-${blankId}-${solutionIndex}`,
|
||||||
|
description: `Solution ${solutionIndex + 1} for blank ${blankId} exceeds maximum of ${maxWords} words (current: ${wordCount} words)`
|
||||||
|
}))];
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setAlerts(prev => prev.filter(a => !a.tag?.startsWith('word-count')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
};
|
||||||
246
src/components/ExamEditor/Exercises/Blanks/index.tsx
Normal file
246
src/components/ExamEditor/Exercises/Blanks/index.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import React, { useCallback, useMemo, useReducer, useEffect, ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
DragStartEvent,
|
||||||
|
MeasuringStrategy,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
restrictToWindowEdges,
|
||||||
|
snapCenterToCursor,
|
||||||
|
} from "@dnd-kit/modifiers";
|
||||||
|
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||||
|
import Header from "../../Shared/Header";
|
||||||
|
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 "./FillBlanksReducer";
|
||||||
|
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
initialText: string;
|
||||||
|
description: string;
|
||||||
|
state: BlanksState;
|
||||||
|
module: string;
|
||||||
|
editing: boolean;
|
||||||
|
showBlankBank: boolean;
|
||||||
|
alerts: AlertItem[];
|
||||||
|
setEditing: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
blanksDispatcher: React.Dispatch<BlanksAction>
|
||||||
|
onBlankSelect?: (blankId: number | null) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onDiscard: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BlanksEditor: React.FC<Props> = ({
|
||||||
|
title = "Fill Blanks",
|
||||||
|
initialText,
|
||||||
|
description,
|
||||||
|
state,
|
||||||
|
editing,
|
||||||
|
module,
|
||||||
|
children,
|
||||||
|
showBlankBank = true,
|
||||||
|
alerts,
|
||||||
|
blanksDispatcher,
|
||||||
|
onBlankSelect,
|
||||||
|
onSave,
|
||||||
|
onDiscard,
|
||||||
|
onDelete,
|
||||||
|
setEditing
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tokens = getTextSegments(initialText);
|
||||||
|
const initialBlanks = tokens.reduce((acc, token, idx) => {
|
||||||
|
if (token.type === 'blank') {
|
||||||
|
acc.push({ id: token.id, position: idx });
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [] as BlankState[]);
|
||||||
|
|
||||||
|
blanksDispatcher({ type: "SET_TEXT", payload: initialText });
|
||||||
|
blanksDispatcher({ type: "SET_BLANKS", payload: initialBlanks });
|
||||||
|
}, [initialText, blanksDispatcher]);
|
||||||
|
|
||||||
|
const tokens = useMemo(() => {
|
||||||
|
return getTextSegments(state.text || "");
|
||||||
|
}, [state.text]);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
|
blanksDispatcher({ type: "SET_DRAGGED_ITEM", payload: event.active.id.toString() });
|
||||||
|
}, [blanksDispatcher]);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
const blankId = parseInt(active.id.toString().split("-").pop() || "");
|
||||||
|
const dropIndex = parseInt(over.id.toString().split("-")[1]);
|
||||||
|
|
||||||
|
blanksDispatcher({
|
||||||
|
type: "MOVE_BLANK",
|
||||||
|
payload: { blankId, newPosition: dropIndex },
|
||||||
|
});
|
||||||
|
|
||||||
|
blanksDispatcher({ type: "SET_DRAGGED_ITEM", payload: null });
|
||||||
|
},
|
||||||
|
[blanksDispatcher]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTextChange = useCallback(
|
||||||
|
(newText: string) => {
|
||||||
|
const processedText = newText.replace(/\[(\d+)\]/g, "{{$1}}");
|
||||||
|
blanksDispatcher({ type: "SET_TEXT", payload: processedText });
|
||||||
|
},
|
||||||
|
[blanksDispatcher]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (onBlankSelect !== undefined) onBlankSelect(state.selectedBlankId);
|
||||||
|
}, [state.selectedBlankId, onBlankSelect]);
|
||||||
|
|
||||||
|
const handleBlankSelect = (blankId: number) => {
|
||||||
|
blanksDispatcher({
|
||||||
|
type: "SELECT_BLANK",
|
||||||
|
payload: blankId === state.selectedBlankId ? null : blankId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlankRemove = useCallback((blankId: number) => {
|
||||||
|
blanksDispatcher({ type: "REMOVE_BLANK", payload: blankId });
|
||||||
|
}, [blanksDispatcher]);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 4,
|
||||||
|
tolerance: 5,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const modifiers = [snapCenterToCursor, restrictToWindowEdges];
|
||||||
|
|
||||||
|
const measuring = {
|
||||||
|
droppable: {
|
||||||
|
strategy: MeasuringStrategy.Always,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<Header
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
editing={editing}
|
||||||
|
handleSave={onSave}
|
||||||
|
modeHandle={onDelete}
|
||||||
|
handleDiscard={onDiscard}
|
||||||
|
/>
|
||||||
|
{alerts.length > 0 && <Alert alerts={alerts} />}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 text-white font-semibold flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => blanksDispatcher({ type: "ADD_BLANK" }) }
|
||||||
|
className={`px-3 py-1.5 bg-ielts-${module} rounded-md hover:bg-ielts-${module}/50 transition-colors`}
|
||||||
|
>
|
||||||
|
Add Blank
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => blanksDispatcher({ type: "TOGGLE_EDIT_MODE" })}
|
||||||
|
className={clsx(
|
||||||
|
"px-3 py-1.5 rounded-md transition-colors",
|
||||||
|
`bg-ielts-${module} text-white hover:bg-ielts-${module}/50`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{state.textMode ? "Drag Mode" : "Text Mode"}
|
||||||
|
</button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
modifiers={modifiers}
|
||||||
|
measuring={measuring}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
{state.textMode ? (
|
||||||
|
<AutoExpandingTextArea
|
||||||
|
value={state.text.replace(/{{(\d+)}}/g, "[$1]")}
|
||||||
|
onChange={(text) => { handleTextChange(text); if (!editing) setEditing(true) } }
|
||||||
|
className="w-full h-full min-h-[200px] p-2 bg-white border rounded-md"
|
||||||
|
placeholder="Enter text here. Use [1], [2], etc. for blanks..."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="leading-relaxed p-4">
|
||||||
|
{tokens.map((token, index) => {
|
||||||
|
const isWordToken = token.type === 'text' && !token.isWhitespace;
|
||||||
|
const showDropZone = isWordToken || token.type === 'blank';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
{showDropZone && <DropZone index={index} module={module} />}
|
||||||
|
{token.type === 'blank' ? (
|
||||||
|
<Blank
|
||||||
|
id={token.id}
|
||||||
|
module={module}
|
||||||
|
variant="text"
|
||||||
|
isSelected={token.id === state.selectedBlankId}
|
||||||
|
isDragging={state.draggedItemId === `text-blank-${token.id}`}
|
||||||
|
/>
|
||||||
|
) : token.isLineBreak ? (
|
||||||
|
<br />
|
||||||
|
) : (
|
||||||
|
<span className="select-none">{token.content}</span>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{tokens.length > 0 &&
|
||||||
|
tokens[tokens.length - 1].type === 'text' && (
|
||||||
|
<DropZone index={tokens.length} module={module} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
|
||||||
|
{(!state.textMode && showBlankBank) && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-wrap gap-2 p-4">
|
||||||
|
{state.blanks.map(blank => (
|
||||||
|
<Blank
|
||||||
|
key={blank.id}
|
||||||
|
id={blank.id}
|
||||||
|
module={module}
|
||||||
|
variant="bank"
|
||||||
|
isSelected={blank.id === state.selectedBlankId}
|
||||||
|
isDragging={state.draggedItemId === `bank-blank-${blank.id}`}
|
||||||
|
onSelect={handleBlankSelect}
|
||||||
|
onRemove={handleBlankRemove}
|
||||||
|
disabled={state.textMode}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BlanksEditor;
|
||||||
38
src/components/ExamEditor/Exercises/Blanks/validateBlanks.ts
Normal file
38
src/components/ExamEditor/Exercises/Blanks/validateBlanks.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { AlertItem } from "../Shared/Alert";
|
||||||
|
import { BlankState } from "./FillBlanksReducer";
|
||||||
|
|
||||||
|
|
||||||
|
const validateBlanks = (
|
||||||
|
blanks: BlankState[],
|
||||||
|
answers: Map<string, string>,
|
||||||
|
alerts: AlertItem[],
|
||||||
|
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>,
|
||||||
|
save: boolean = false,
|
||||||
|
): boolean => {
|
||||||
|
const unfilledBlanks = blanks.filter(blank => !answers.has(blank.id.toString()));
|
||||||
|
const filteredAlerts = alerts.filter(alert => alert.tag !== "unfilled-blanks");
|
||||||
|
|
||||||
|
if (unfilledBlanks.length > 0) {
|
||||||
|
if (!save && !filteredAlerts.some(alert => alert.tag === "editing")) {
|
||||||
|
filteredAlerts.push({
|
||||||
|
variant: "info",
|
||||||
|
description: "You have unsaved changes. Don't forget to save your work!",
|
||||||
|
tag: "editing"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setAlerts([
|
||||||
|
...filteredAlerts,
|
||||||
|
{
|
||||||
|
variant: "error",
|
||||||
|
tag: "unfilled-blanks",
|
||||||
|
description: `${unfilledBlanks.length} blank${unfilledBlanks.length > 1 ? 's' : ''} ${unfilledBlanks.length > 1 ? 'are' : 'is'} missing a word (blanks: ${unfilledBlanks.map(blank => blank.id).join(", ")})`
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
return false;
|
||||||
|
} else if (filteredAlerts.length !== alerts.length) {
|
||||||
|
setAlerts(filteredAlerts);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default validateBlanks;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { MatchSentenceExerciseOption } from "@/interfaces/exam";
|
||||||
|
import { MdVisibilityOff } from "react-icons/md";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
showReference: boolean;
|
||||||
|
selectedReference: string | null;
|
||||||
|
options: MatchSentenceExerciseOption[];
|
||||||
|
headings: boolean;
|
||||||
|
setShowReference: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReferenceViewer: React.FC<Props> = ({ showReference, selectedReference, options, setShowReference, headings = true}) => (
|
||||||
|
<div
|
||||||
|
className={`fixed inset-y-0 right-0 w-96 bg-white shadow-lg transform transition-transform duration-300 ease-in-out ${showReference ? 'translate-x-0' : 'translate-x-full'}`}
|
||||||
|
>
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="p-4 border-b bg-gray-50 flex justify-between items-center">
|
||||||
|
<h3 className="font-semibold text-gray-800">{headings ? "Reference Paragraphs" : "Authors"}</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowReference(false)}
|
||||||
|
className="p-2 hover:bg-gray-200 rounded-full"
|
||||||
|
>
|
||||||
|
<MdVisibilityOff size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{options.map((option) => (
|
||||||
|
<Card key={option.id} className={`bg-gray-50 transition-all duration-200 ${selectedReference === option.id ? 'ring-2 ring-blue-500' : ''}`}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-md text-black">{headings ? "Paragraph" : "Author" } {option.id}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-gray-600">{option.sentence}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ReferenceViewer;
|
||||||
230
src/components/ExamEditor/Exercises/MatchSentences/index.tsx
Normal file
230
src/components/ExamEditor/Exercises/MatchSentences/index.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
MdAdd,
|
||||||
|
MdVisibility,
|
||||||
|
MdVisibilityOff
|
||||||
|
} from 'react-icons/md';
|
||||||
|
import { MatchSentencesExercise, ReadingPart } from '@/interfaces/exam';
|
||||||
|
import Alert, { AlertItem } from '../Shared/Alert';
|
||||||
|
import ReferenceViewer from './ParagraphViewer';
|
||||||
|
import Header from '../../Shared/Header';
|
||||||
|
import SortableQuestion from '../Shared/SortableQuestion';
|
||||||
|
import QuestionsList from '../Shared/QuestionsList';
|
||||||
|
import useExamEditorStore from '@/stores/examEditor';
|
||||||
|
import useSectionEdit from '../../Hooks/useSectionEdit';
|
||||||
|
import validateMatchSentences from './validation';
|
||||||
|
import setEditingAlert from '../Shared/setEditingAlert';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { DragEndEvent } from '@dnd-kit/core';
|
||||||
|
import { handleMatchSentencesReorder } from '@/stores/examEditor/reorder/local';
|
||||||
|
|
||||||
|
const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: number }> = ({ exercise, sectionId }) => {
|
||||||
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
|
const { state } = useExamEditorStore(
|
||||||
|
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||||
|
);
|
||||||
|
|
||||||
|
const section = state as ReadingPart;
|
||||||
|
|
||||||
|
const [local, setLocal] = useState(exercise);
|
||||||
|
const [selectedParagraph, setSelectedParagraph] = useState<string | null>(null);
|
||||||
|
const [showReference, setShowReference] = useState(false);
|
||||||
|
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||||
|
|
||||||
|
const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({
|
||||||
|
sectionId,
|
||||||
|
onSave: () => {
|
||||||
|
|
||||||
|
const isValid = validateMatchSentences(local.sentences, setAlerts);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
toast.error("Please fix the errors before saving!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditing(false);
|
||||||
|
setAlerts([]);
|
||||||
|
|
||||||
|
const newState = { ...section };
|
||||||
|
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? local : ex);
|
||||||
|
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState } });
|
||||||
|
dispatch({ type: "REORDER_EXERCISES" });
|
||||||
|
},
|
||||||
|
onDiscard: () => {
|
||||||
|
setLocal(exercise);
|
||||||
|
setSelectedParagraph(null);
|
||||||
|
setShowReference(false);
|
||||||
|
},
|
||||||
|
onMode: () => {
|
||||||
|
const newSection = {
|
||||||
|
...section,
|
||||||
|
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
||||||
|
};
|
||||||
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
|
||||||
|
dispatch({ type: "REORDER_EXERCISES" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const usedOptions = useMemo(() => {
|
||||||
|
return local.sentences.reduce((acc, sentence) => {
|
||||||
|
if (sentence.solution) {
|
||||||
|
acc.add(sentence.solution);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, new Set<string>());
|
||||||
|
}, [local.sentences]);
|
||||||
|
|
||||||
|
const addHeading = () => {
|
||||||
|
setEditing(true);
|
||||||
|
const newId = (parseInt(local.sentences[local.sentences.length - 1].id) + 1).toString();
|
||||||
|
setLocal({
|
||||||
|
...local,
|
||||||
|
sentences: [
|
||||||
|
...local.sentences,
|
||||||
|
{
|
||||||
|
id: newId,
|
||||||
|
sentence: "",
|
||||||
|
solution: ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateHeading = (index: number, field: string, value: string) => {
|
||||||
|
setEditing(true);
|
||||||
|
const newSentences = [...local.sentences];
|
||||||
|
|
||||||
|
if (field === 'solution') {
|
||||||
|
const oldSolution = newSentences[index].solution;
|
||||||
|
if (oldSolution) {
|
||||||
|
usedOptions.delete(oldSolution);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newSentences[index] = { ...newSentences[index], [field]: value };
|
||||||
|
setLocal({ ...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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedSolution = local.sentences[index].solution;
|
||||||
|
if (deletedSolution) {
|
||||||
|
usedOptions.delete(deletedSolution);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSentences = local.sentences.filter((_, i) => i !== index);
|
||||||
|
setLocal({ ...local, sentences: newSentences });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
validateMatchSentences(local.sentences, setAlerts);
|
||||||
|
}, [local.sentences]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditingAlert(editing, setAlerts);
|
||||||
|
}, [editing]);
|
||||||
|
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
setEditing(true);
|
||||||
|
setLocal(handleMatchSentencesReorder(event, local));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col mx-auto p-2">
|
||||||
|
<Header
|
||||||
|
title={exercise.variant && exercise.variant == "ideaMatch" ? "Idea Match" : "Paragraph Match"}
|
||||||
|
description={`Edit ${exercise.variant && exercise.variant == "ideaMatch" ? "ideas/opinions" : "headings"} and their matches`}
|
||||||
|
editing={editing}
|
||||||
|
handleSave={handleSave}
|
||||||
|
modeHandle={modeHandle}
|
||||||
|
handleDiscard={handleDiscard}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowReference(!showReference)}
|
||||||
|
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{showReference ? <MdVisibilityOff size={18} /> : <MdVisibility size={18} />}
|
||||||
|
{showReference ? 'Hide Reference' : 'Show Reference'}
|
||||||
|
</button>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{alerts.length > 0 && <Alert alerts={alerts} />}
|
||||||
|
<QuestionsList
|
||||||
|
ids={local.sentences.map(s => s.id)}
|
||||||
|
handleDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
{local.sentences.map((sentence, index) => (
|
||||||
|
<SortableQuestion
|
||||||
|
key={sentence.id}
|
||||||
|
id={sentence.id}
|
||||||
|
index={index}
|
||||||
|
deleteQuestion={() => deleteHeading(index)}
|
||||||
|
onFocus={() => setSelectedParagraph(sentence.solution)}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={sentence.sentence}
|
||||||
|
onChange={(e) => updateHeading(index, 'sentence', e.target.value)}
|
||||||
|
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none text-mti-gray-dim"
|
||||||
|
placeholder={`Enter ${exercise.variant && exercise.variant == "ideaMatch" ? "idea/opinion" : "heading"} ...`}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<select
|
||||||
|
value={sentence.solution}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateHeading(index, 'solution', e.target.value);
|
||||||
|
setSelectedParagraph(e.target.value);
|
||||||
|
}}
|
||||||
|
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white text-mti-gray-dim"
|
||||||
|
>
|
||||||
|
<option value="">Select matching {exercise.variant == "ideaMatch" ? "author" : "paragraph"}...</option>
|
||||||
|
{local.options.map((option) => {
|
||||||
|
const isUsed = usedOptions.has(option.id);
|
||||||
|
const isCurrentSelection = sentence.solution === option.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<option
|
||||||
|
key={option.id}
|
||||||
|
value={option.id}
|
||||||
|
disabled={isUsed && !isCurrentSelection}
|
||||||
|
>
|
||||||
|
{exercise.variant == "ideaMatch" ? "Author" : "Paragraph"} {option.id}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</SortableQuestion>
|
||||||
|
))}
|
||||||
|
</QuestionsList>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={addHeading}
|
||||||
|
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
<MdAdd size={18} />
|
||||||
|
Add New Match
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReferenceViewer
|
||||||
|
headings={exercise.variant !== "ideaMatch"}
|
||||||
|
showReference={showReference}
|
||||||
|
selectedReference={selectedParagraph}
|
||||||
|
options={local.options}
|
||||||
|
setShowReference={setShowReference}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MatchSentences;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { AlertItem } from "../Shared/Alert";
|
||||||
|
|
||||||
|
const validateMatchSentences = (
|
||||||
|
sentences: {id: string; sentence: string; solution: string;}[],
|
||||||
|
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
|
||||||
|
): boolean => {
|
||||||
|
let hasErrors = false;
|
||||||
|
|
||||||
|
const emptySentences = sentences.filter(s => !s.sentence.trim());
|
||||||
|
if (emptySentences.length > 0) {
|
||||||
|
hasErrors = true;
|
||||||
|
setAlerts(prev => {
|
||||||
|
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('empty-sentence'));
|
||||||
|
return [...filteredAlerts, ...emptySentences.map(s => ({
|
||||||
|
variant: "error" as const,
|
||||||
|
tag: `empty-sentence-${s.id}`,
|
||||||
|
description: `Heading ${s.id} is empty`
|
||||||
|
}))];
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('empty-sentence')));
|
||||||
|
}
|
||||||
|
|
||||||
|
const unmatchedSentences = sentences.filter(s => !s.solution);
|
||||||
|
if (unmatchedSentences.length > 0) {
|
||||||
|
hasErrors = true;
|
||||||
|
setAlerts(prev => {
|
||||||
|
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('unmatched-sentence'));
|
||||||
|
return [...filteredAlerts, ...unmatchedSentences.map(s => ({
|
||||||
|
variant: "error" as const,
|
||||||
|
tag: `unmatched-sentence-${s.id}`,
|
||||||
|
description: `Heading ${s.id} has no paragraph selected`
|
||||||
|
}))];
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('unmatched-sentence')));
|
||||||
|
}
|
||||||
|
|
||||||
|
return !hasErrors;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default validateMatchSentences;
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { MultipleChoiceQuestion } from "@/interfaces/exam";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { MdEdit, MdEditOff } from "react-icons/md";
|
||||||
|
|
||||||
|
interface UnderlineQuestionProps {
|
||||||
|
question: MultipleChoiceQuestion;
|
||||||
|
onQuestionChange: (updatedQuestion: MultipleChoiceQuestion) => void;
|
||||||
|
onValidationChange?: (isValid: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
id: string;
|
||||||
|
text?: string;
|
||||||
|
src?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UnderlineQuestion: React.FC<UnderlineQuestionProps> = ({
|
||||||
|
question,
|
||||||
|
onQuestionChange,
|
||||||
|
onValidationChange,
|
||||||
|
}) => {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const stripUnderlineTags = (text: string = '') => text.replace(/<\/?u>/g, '');
|
||||||
|
|
||||||
|
const addUnderlineTags = (text: string, options: Option[]) => {
|
||||||
|
let result = text;
|
||||||
|
|
||||||
|
// Sort options by length (longest first) to handle overlapping matches
|
||||||
|
const sortedOptions = [...options]
|
||||||
|
.filter(opt => opt.text?.trim() && opt.text.trim().length > 1)
|
||||||
|
.sort((a, b) => ((b.text?.length || 0) - (a.text?.length || 0)));
|
||||||
|
|
||||||
|
for (const option of sortedOptions) {
|
||||||
|
if (!option.text?.trim()) continue;
|
||||||
|
|
||||||
|
const optionText = stripUnderlineTags(option.text).trim();
|
||||||
|
const textLower = result.toLowerCase();
|
||||||
|
const optionLower = optionText.toLowerCase();
|
||||||
|
|
||||||
|
let startIndex = textLower.indexOf(optionLower);
|
||||||
|
while (startIndex !== -1) {
|
||||||
|
// Check if this portion is already underlined
|
||||||
|
const beforeTag = result.slice(Math.max(0, startIndex - 3), startIndex);
|
||||||
|
const afterTag = result.slice(startIndex + optionText.length, startIndex + optionText.length + 4);
|
||||||
|
|
||||||
|
if (!beforeTag.includes('<u>') && !afterTag.includes('</u>')) {
|
||||||
|
const before = result.substring(0, startIndex);
|
||||||
|
const match = result.substring(startIndex, startIndex + optionText.length);
|
||||||
|
const after = result.substring(startIndex + optionText.length);
|
||||||
|
result = `${before}<u>${match}</u>${after}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find next occurrence
|
||||||
|
startIndex = textLower.indexOf(optionLower, startIndex + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateQuestion = (q: MultipleChoiceQuestion) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const rawPrompt = stripUnderlineTags(q.prompt).toLowerCase();
|
||||||
|
|
||||||
|
q.options.forEach((option) => {
|
||||||
|
if (option.text?.trim() && !rawPrompt.includes(stripUnderlineTags(option.text).trim().toLowerCase())) {
|
||||||
|
errors.push(`Option ${option.id} text not found in prompt`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setValidationErrors(errors);
|
||||||
|
onValidationChange?.(errors.length === 0);
|
||||||
|
return errors.length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
validateQuestion(question);
|
||||||
|
}, [question]);
|
||||||
|
|
||||||
|
const handlePromptChange = (value: string) => {
|
||||||
|
const newPrompt = addUnderlineTags(value, question.options);
|
||||||
|
onQuestionChange({
|
||||||
|
...question,
|
||||||
|
prompt: newPrompt
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOptionChange = (optionIndex: number, value: string) => {
|
||||||
|
const updatedOptions = question.options.map((opt, idx) =>
|
||||||
|
idx === optionIndex ? { ...opt, text: value } : opt
|
||||||
|
);
|
||||||
|
|
||||||
|
const strippedPrompt = stripUnderlineTags(question.prompt);
|
||||||
|
const newPrompt = addUnderlineTags(strippedPrompt, updatedOptions);
|
||||||
|
|
||||||
|
onQuestionChange({
|
||||||
|
...question,
|
||||||
|
prompt: newPrompt,
|
||||||
|
options: updatedOptions
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
value={stripUnderlineTags(question.prompt)}
|
||||||
|
onChange={(e) => handlePromptChange(e.target.value)}
|
||||||
|
className="flex-1 p-3 border rounded-lg focus:outline-none"
|
||||||
|
placeholder="Enter text for underlining..."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="flex-1 p-3 border rounded-lg min-h-[50px]"
|
||||||
|
dangerouslySetInnerHTML={{ __html: question.prompt || '' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditing(!isEditing)}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{isEditing ?
|
||||||
|
<MdEditOff size={24} className="text-gray-500" /> :
|
||||||
|
<MdEdit size={24} className="text-gray-500" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{validationErrors.length > 0 && (
|
||||||
|
<div className="text-red-500 text-sm">
|
||||||
|
{validationErrors.map((error, index) => (
|
||||||
|
<div key={index}>{error}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{question.options.map((option, optionIndex) => {
|
||||||
|
const isInvalidOption = option.text?.trim() &&
|
||||||
|
!stripUnderlineTags(question.prompt || '').toLowerCase()
|
||||||
|
.includes(stripUnderlineTags(option.text).trim().toLowerCase());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={option.id} className="flex gap-2">
|
||||||
|
<label
|
||||||
|
className={clsx(
|
||||||
|
"flex-none w-12 p-3 text-center rounded-lg border-2 transition-all cursor-pointer",
|
||||||
|
question.solution === option.id
|
||||||
|
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`solution-${question.id}`}
|
||||||
|
value={option.id}
|
||||||
|
checked={question.solution === option.id}
|
||||||
|
onChange={(e) => onQuestionChange({
|
||||||
|
...question,
|
||||||
|
solution: e.target.value
|
||||||
|
})}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
{option.id}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={stripUnderlineTags(option.text || '')}
|
||||||
|
onChange={(e) => handleOptionChange(optionIndex, e.target.value)}
|
||||||
|
className={clsx(
|
||||||
|
"flex-1 p-3 border rounded-lg focus:ring-2 focus:outline-none",
|
||||||
|
isInvalidOption
|
||||||
|
? "border-red-500 focus:ring-red-500 bg-red-50"
|
||||||
|
: "focus:ring-blue-500"
|
||||||
|
)}
|
||||||
|
placeholder={`Option ${option.id}...`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UnderlineQuestion;
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import Header from "@/components/ExamEditor/Shared/Header";
|
||||||
|
import QuestionsList from "../../Shared/QuestionsList";
|
||||||
|
import SortableQuestion from "../../Shared/SortableQuestion";
|
||||||
|
import UnderlineQuestion from "./UnderlineQuestion";
|
||||||
|
import useSectionEdit from "@/components/ExamEditor/Hooks/useSectionEdit";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import setEditingAlert from "../../Shared/setEditingAlert";
|
||||||
|
import { LevelPart, ListeningPart, MultipleChoiceExercise, MultipleChoiceQuestion, ReadingPart } from "@/interfaces/exam";
|
||||||
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { MdAdd } from "react-icons/md";
|
||||||
|
import Alert, { AlertItem } from "../../Shared/Alert";
|
||||||
|
|
||||||
|
|
||||||
|
const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, sectionId: number}> = ({
|
||||||
|
exercise,
|
||||||
|
sectionId,
|
||||||
|
}) => {
|
||||||
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
|
const { state } = useExamEditorStore(
|
||||||
|
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||||
|
);
|
||||||
|
const section = state as ReadingPart | ListeningPart | LevelPart;
|
||||||
|
|
||||||
|
const [local, setLocal] = useState(exercise);
|
||||||
|
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocal(exercise);
|
||||||
|
}, [exercise]);
|
||||||
|
|
||||||
|
const updateLocal = (exercise: MultipleChoiceExercise) => {
|
||||||
|
setLocal(exercise);
|
||||||
|
setEditingAlert(true, setAlerts);
|
||||||
|
setEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuestionChange = (questionIndex: number, updatedQuestion: MultipleChoiceQuestion) => {
|
||||||
|
const newQuestions = [...local.questions];
|
||||||
|
newQuestions[questionIndex] = updatedQuestion;
|
||||||
|
updateLocal({ ...local, questions: newQuestions });
|
||||||
|
};
|
||||||
|
|
||||||
|
const addQuestion = () => {
|
||||||
|
const newId = (parseInt(local.questions[local.questions.length - 1].id) + 1).toString();
|
||||||
|
const options = Array.from({ length: 4 }, (_, i) => ({
|
||||||
|
id: String.fromCharCode(65 + i),
|
||||||
|
text: ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
updateLocal({
|
||||||
|
...local,
|
||||||
|
questions: [
|
||||||
|
...local.questions,
|
||||||
|
{
|
||||||
|
prompt: "",
|
||||||
|
solution: "",
|
||||||
|
id: newId,
|
||||||
|
options,
|
||||||
|
variant: "text"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteQuestion = (index: number) => {
|
||||||
|
if (local.questions.length === 1) {
|
||||||
|
toast.error("There needs to be at least one question!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newQuestions = local.questions.filter((_, i) => i !== index);
|
||||||
|
updateLocal({ ...local, questions: newQuestions });
|
||||||
|
};
|
||||||
|
|
||||||
|
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
|
||||||
|
sectionId,
|
||||||
|
mode: "edit",
|
||||||
|
onSave: () => {
|
||||||
|
setEditing(false);
|
||||||
|
setAlerts([]);
|
||||||
|
const newSection = {
|
||||||
|
...section,
|
||||||
|
exercises: section.exercises.map((ex) =>
|
||||||
|
ex.id === local.id ? local : ex
|
||||||
|
)
|
||||||
|
};
|
||||||
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
|
||||||
|
dispatch({ type: "REORDER_EXERCISES" });
|
||||||
|
},
|
||||||
|
onDiscard: () => {
|
||||||
|
setLocal(exercise);
|
||||||
|
setEditing(false);
|
||||||
|
},
|
||||||
|
onMode: () => {
|
||||||
|
const newSection = {
|
||||||
|
...section,
|
||||||
|
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
||||||
|
};
|
||||||
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
|
||||||
|
dispatch({ type: "REORDER_EXERCISES" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<Header
|
||||||
|
title='Underline Multiple Choice Exercise'
|
||||||
|
description="Edit questions with 4 underline options each"
|
||||||
|
editing={editing}
|
||||||
|
handleSave={handleSave}
|
||||||
|
modeHandle={modeHandle}
|
||||||
|
handleDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<QuestionsList
|
||||||
|
ids={local.questions.map(q => q.id)}
|
||||||
|
handleDragEnd={()=> {}}
|
||||||
|
>
|
||||||
|
{local.questions.map((question, questionIndex) => (
|
||||||
|
<SortableQuestion
|
||||||
|
key={question.id}
|
||||||
|
id={question.id}
|
||||||
|
index={questionIndex}
|
||||||
|
deleteQuestion={() => deleteQuestion(questionIndex)}
|
||||||
|
>
|
||||||
|
<UnderlineQuestion
|
||||||
|
question={question}
|
||||||
|
onQuestionChange={(updatedQuestion) =>
|
||||||
|
handleQuestionChange(questionIndex, updatedQuestion)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SortableQuestion>
|
||||||
|
))}
|
||||||
|
</QuestionsList>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={addQuestion}
|
||||||
|
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
<MdAdd size={18} />
|
||||||
|
Add New Question
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UnderlineMultipleChoice;
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
MdAdd,
|
||||||
|
MdEdit,
|
||||||
|
MdEditOff,
|
||||||
|
} from 'react-icons/md';
|
||||||
|
import { ReadingPart, MultipleChoiceExercise, MultipleChoiceQuestion, LevelPart, ListeningPart } from '@/interfaces/exam';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import useExamEditorStore from '@/stores/examEditor';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { DragEndEvent } from '@dnd-kit/core';
|
||||||
|
import useSectionEdit from '@/components/ExamEditor/Hooks/useSectionEdit';
|
||||||
|
import Header from '@/components/ExamEditor/Shared/Header';
|
||||||
|
import Alert, { AlertItem } from '../../Shared/Alert';
|
||||||
|
import QuestionsList from '../../Shared/QuestionsList';
|
||||||
|
import SortableQuestion from '../../Shared/SortableQuestion';
|
||||||
|
import setEditingAlert from '../../Shared/setEditingAlert';
|
||||||
|
import { handleMultipleChoiceReorder } from '@/stores/examEditor/reorder/local';
|
||||||
|
|
||||||
|
interface MultipleChoiceProps {
|
||||||
|
exercise: MultipleChoiceExercise;
|
||||||
|
sectionId: number;
|
||||||
|
optionsQuantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateMultipleChoiceQuestions = (
|
||||||
|
questions: MultipleChoiceQuestion[],
|
||||||
|
optionsQuantity: number,
|
||||||
|
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
|
||||||
|
) => {
|
||||||
|
const validationAlerts: AlertItem[] = [];
|
||||||
|
|
||||||
|
questions.forEach((question, index) => {
|
||||||
|
if (!question.prompt.trim()) {
|
||||||
|
validationAlerts.push({
|
||||||
|
variant: 'error',
|
||||||
|
tag: `missing-prompt-${index}`,
|
||||||
|
description: `Question ${index + 1} is missing a prompt`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!question.solution) {
|
||||||
|
validationAlerts.push({
|
||||||
|
variant: 'error',
|
||||||
|
tag: `missing-solution-${index}`,
|
||||||
|
description: `Question ${index + 1} is missing a solution`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (question.options.length !== optionsQuantity) {
|
||||||
|
validationAlerts.push({
|
||||||
|
variant: 'error',
|
||||||
|
tag: `invalid-options-${index}`,
|
||||||
|
description: `Question ${index + 1} must have exactly ${optionsQuantity} options`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
question.options.forEach((option, optionIndex) => {
|
||||||
|
if (option.text && option.text.trim() === "") {
|
||||||
|
validationAlerts.push({
|
||||||
|
variant: 'error',
|
||||||
|
tag: `empty-option-${index}-${optionIndex}`,
|
||||||
|
description: `Question ${index + 1} has an empty option`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setAlerts(prev => {
|
||||||
|
const editingAlert = prev.find(alert => alert.tag === 'editing');
|
||||||
|
return [...validationAlerts, ...(editingAlert ? [editingAlert] : [])];
|
||||||
|
});
|
||||||
|
|
||||||
|
return validationAlerts.length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MultipleChoice: React.FC<MultipleChoiceProps> = ({ exercise, sectionId, optionsQuantity }) => {
|
||||||
|
const { currentModule, dispatch} = useExamEditorStore();
|
||||||
|
const { state } = useExamEditorStore(
|
||||||
|
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||||
|
);
|
||||||
|
|
||||||
|
const section = state as ReadingPart | ListeningPart| LevelPart;
|
||||||
|
|
||||||
|
const [local, setLocal] = useState(exercise);
|
||||||
|
const [editingPrompt, setEditingPrompt] = useState(false);
|
||||||
|
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||||
|
|
||||||
|
const updateLocal = (exercise: MultipleChoiceExercise) => {
|
||||||
|
setLocal(exercise);
|
||||||
|
setEditingAlert(true, setAlerts);
|
||||||
|
setEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateQuestion = (index: number, field: string, value: string) => {
|
||||||
|
const newQuestions = [...local.questions];
|
||||||
|
newQuestions[index] = { ...newQuestions[index], [field]: value };
|
||||||
|
updateLocal({ ...local, questions: newQuestions });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateOption = (questionIndex: number, optionIndex: number, value: string) => {
|
||||||
|
const newQuestions = [...local.questions];
|
||||||
|
const newOptions = [...newQuestions[questionIndex].options];
|
||||||
|
newOptions[optionIndex] = { ...newOptions[optionIndex], text: value };
|
||||||
|
newQuestions[questionIndex] = { ...newQuestions[questionIndex], options: newOptions };
|
||||||
|
updateLocal({ ...local, questions: newQuestions });
|
||||||
|
};
|
||||||
|
|
||||||
|
const addQuestion = () => {
|
||||||
|
const newId = (parseInt(local.questions[local.questions.length - 1].id) + 1).toString();
|
||||||
|
const options = Array.from({ length: optionsQuantity }, (_, i) => ({
|
||||||
|
id: String.fromCharCode(65 + i),
|
||||||
|
text: ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
updateLocal({
|
||||||
|
...local,
|
||||||
|
questions: [
|
||||||
|
...local.questions,
|
||||||
|
{
|
||||||
|
prompt: "",
|
||||||
|
solution: "",
|
||||||
|
id: newId,
|
||||||
|
options,
|
||||||
|
variant: "text"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteQuestion = (index: number) => {
|
||||||
|
if (local.questions.length === 1) {
|
||||||
|
toast.error("There needs to be at least one question!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newQuestions = local.questions.filter((_, i) => i !== index);
|
||||||
|
const minId = Math.min(...newQuestions.map(q => parseInt(q.id)));
|
||||||
|
|
||||||
|
const updatedQuestions = newQuestions.map((question, i) => ({
|
||||||
|
...question,
|
||||||
|
id: String(minId + i)
|
||||||
|
}));
|
||||||
|
|
||||||
|
updateLocal({ ...local, questions: updatedQuestions });
|
||||||
|
};
|
||||||
|
|
||||||
|
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
|
||||||
|
sectionId,
|
||||||
|
mode: "edit",
|
||||||
|
onSave: () => {
|
||||||
|
const isValid = validateMultipleChoiceQuestions(
|
||||||
|
local.questions,
|
||||||
|
optionsQuantity,
|
||||||
|
setAlerts
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
toast.error("Please fix the errors before saving!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditing(false);
|
||||||
|
setAlerts([]);
|
||||||
|
const newSection = {
|
||||||
|
...section,
|
||||||
|
exercises: section.exercises.map((ex) => ex.id === local.id ? local : ex)
|
||||||
|
};
|
||||||
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
|
||||||
|
dispatch({ type: "REORDER_EXERCISES" });
|
||||||
|
},
|
||||||
|
onDiscard: () => {
|
||||||
|
setLocal(exercise);
|
||||||
|
},
|
||||||
|
onMode: () => {
|
||||||
|
const newSection = {
|
||||||
|
...section,
|
||||||
|
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
||||||
|
};
|
||||||
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
|
||||||
|
dispatch({ type: "REORDER_EXERCISES" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
validateMultipleChoiceQuestions(local.questions, optionsQuantity, setAlerts);
|
||||||
|
}, [local.questions, optionsQuantity]);
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
setEditingAlert(true, setAlerts);
|
||||||
|
setEditing(true);
|
||||||
|
setLocal(handleMultipleChoiceReorder(event, local));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<Header
|
||||||
|
title='Multiple Choice Exercise'
|
||||||
|
description={`Edit questions with ${optionsQuantity} options each`}
|
||||||
|
editing={editing}
|
||||||
|
handleSave={handleSave}
|
||||||
|
modeHandle={modeHandle}
|
||||||
|
handleDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex justify-between items-start gap-4">
|
||||||
|
{editingPrompt ? (
|
||||||
|
<textarea
|
||||||
|
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none min-h-[100px]"
|
||||||
|
value={local.prompt}
|
||||||
|
onChange={(e) => updateLocal({ ...local, prompt: e.target.value })}
|
||||||
|
onBlur={() => setEditingPrompt(false)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-medium text-gray-800 mb-2">Question/Instructions displayed to the student:</h3>
|
||||||
|
<p className="text-gray-600">{local.prompt}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingPrompt(!editingPrompt)}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{editingPrompt ?
|
||||||
|
<MdEditOff size={20} className="text-gray-500" /> :
|
||||||
|
<MdEdit size={20} className="text-gray-500" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<QuestionsList
|
||||||
|
ids={local.questions.map(q => q.id)}
|
||||||
|
handleDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
{local.questions.map((question, questionIndex) => (
|
||||||
|
<SortableQuestion
|
||||||
|
key={question.id}
|
||||||
|
id={question.id}
|
||||||
|
index={questionIndex}
|
||||||
|
deleteQuestion={deleteQuestion}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={question.prompt}
|
||||||
|
onChange={(e) => updateQuestion(questionIndex, 'prompt', e.target.value)}
|
||||||
|
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
placeholder="Enter question..."
|
||||||
|
/>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{question.options.map((option, optionIndex) => (
|
||||||
|
<div key={option.id} className="flex gap-2">
|
||||||
|
<label
|
||||||
|
className={clsx(
|
||||||
|
"flex-none w-12 p-3 text-center rounded-lg border-2 transition-all cursor-pointer",
|
||||||
|
question.solution === option.id
|
||||||
|
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`solution-${question.id}`}
|
||||||
|
value={option.id}
|
||||||
|
checked={question.solution === option.id}
|
||||||
|
onChange={(e) => updateQuestion(questionIndex, 'solution', e.target.value)}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
{option.id}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={option.text}
|
||||||
|
onChange={(e) => updateOption(questionIndex, optionIndex, e.target.value)}
|
||||||
|
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
placeholder={`Option ${option.id}...`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SortableQuestion>
|
||||||
|
))}
|
||||||
|
</QuestionsList>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={addQuestion}
|
||||||
|
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
<MdAdd size={18} />
|
||||||
|
Add New Question
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MultipleChoice;
|
||||||
17
src/components/ExamEditor/Exercises/MultipleChoice/index.tsx
Normal file
17
src/components/ExamEditor/Exercises/MultipleChoice/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { MultipleChoiceExercise } from "@/interfaces/exam";
|
||||||
|
import Vanilla from "./Vanilla";
|
||||||
|
import MultipleChoiceUnderline from "./Underline";
|
||||||
|
|
||||||
|
const MultipleChoice: React.FC<{sectionId: number; exercise: MultipleChoiceExercise}> = (props) => {
|
||||||
|
const {exercise} = props;
|
||||||
|
|
||||||
|
const length = exercise.questions[0].options.length;
|
||||||
|
|
||||||
|
if (exercise.questions[0].prompt.includes('<u>')) {
|
||||||
|
return <MultipleChoiceUnderline {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (<Vanilla {...props} optionsQuantity={length}/>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MultipleChoice;
|
||||||
61
src/components/ExamEditor/Exercises/Shared/Alert.tsx
Normal file
61
src/components/ExamEditor/Exercises/Shared/Alert.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import { BiErrorCircle } from "react-icons/bi";
|
||||||
|
import { IoInformationCircle } from "react-icons/io5";
|
||||||
|
|
||||||
|
export interface AlertItem {
|
||||||
|
variant: "info" | "error";
|
||||||
|
description: string;
|
||||||
|
tag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
alerts: AlertItem[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Alert: React.FC<Props> = ({ alerts, className }) => {
|
||||||
|
const hasError = alerts.some(alert => alert.variant === "error");
|
||||||
|
const alertsToShow = hasError ? alerts.filter(alert => alert.variant === "error") : alerts;
|
||||||
|
|
||||||
|
if (alertsToShow.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx("space-y-2", className)}>
|
||||||
|
{alertsToShow.map((alert, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={clsx(
|
||||||
|
"border rounded-xl flex items-center gap-2 py-2 px-4",
|
||||||
|
{
|
||||||
|
'bg-amber-50': alert.variant === 'info',
|
||||||
|
'bg-red-50': alert.variant === 'error'
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{alert.variant === 'info' ? (
|
||||||
|
<IoInformationCircle
|
||||||
|
className="h-5 w-5 text-amber-700"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<BiErrorCircle
|
||||||
|
className="h-5 w-5 text-red-700"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p
|
||||||
|
className={clsx(
|
||||||
|
"font-medium py-0.5",
|
||||||
|
{
|
||||||
|
'text-amber-700': alert.variant === 'info',
|
||||||
|
'text-red-700': alert.variant === 'error'
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{alert.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Alert;
|
||||||
14
src/components/ExamEditor/Exercises/Shared/GenLoader.tsx
Normal file
14
src/components/ExamEditor/Exercises/Shared/GenLoader.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
const GenLoader: React.FC<{module: string, custom?: string, className?: string}> = ({module, custom, className}) => {
|
||||||
|
return (
|
||||||
|
<div className={clsx("w-full cursor-text px-7 py-8 border-2 border-mti-gray-platinum rounded-3xl", className)}>
|
||||||
|
<div className="flex flex-col items-center justify-center animate-pulse">
|
||||||
|
<span className={`loading loading-infinity w-32 bg-ielts-${module}`} />
|
||||||
|
<span className={`font-bold text-2xl text-ielts-${module}`}>{`${custom ? custom : "Generating..."}`}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GenLoader;
|
||||||
34
src/components/ExamEditor/Exercises/Shared/QuestionsList.tsx
Normal file
34
src/components/ExamEditor/Exercises/Shared/QuestionsList.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
|
||||||
|
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ids: string[];
|
||||||
|
handleDragEnd: (event: any) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QuestionsList: React.FC<Props> = ({ ids, handleDragEnd, children }) => {
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={ids}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuestionsList;
|
||||||
134
src/components/ExamEditor/Exercises/Shared/Script.tsx
Normal file
134
src/components/ExamEditor/Exercises/Shared/Script.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
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;
|
||||||
155
src/components/ExamEditor/Exercises/Shared/SortableQuestion.tsx
Normal file
155
src/components/ExamEditor/Exercises/Shared/SortableQuestion.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import React, { ReactNode, useState } from 'react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { MdDragIndicator, MdDelete, MdEdit, MdEditOff } from 'react-icons/md';
|
||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
index: number;
|
||||||
|
deleteQuestion: (index: any) => void;
|
||||||
|
onFocus?: () => void;
|
||||||
|
extra?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
variant?: 'default' | 'writeBlanks' | 'del-up';
|
||||||
|
title?: string;
|
||||||
|
onQuestionChange?: (value: string) => void;
|
||||||
|
questionText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortableQuestion: React.FC<Props> = ({
|
||||||
|
id,
|
||||||
|
index,
|
||||||
|
deleteQuestion,
|
||||||
|
children,
|
||||||
|
extra,
|
||||||
|
onFocus,
|
||||||
|
variant = 'default',
|
||||||
|
questionText = "",
|
||||||
|
onQuestionChange
|
||||||
|
}) => {
|
||||||
|
const [isEditingQuestion, setIsEditingQuestion] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (variant === 'writeBlanks') {
|
||||||
|
return (
|
||||||
|
<Card ref={setNodeRef} style={style} onFocus={onFocus}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-stretch gap-4">
|
||||||
|
<div className='flex flex-col flex-none w-12'>
|
||||||
|
<div className="flex-none">
|
||||||
|
<span className="text-sm font-medium text-gray-500">ID: {id}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className='flex-1 flex items-center justify-center group'
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<div className="p-2 rounded-lg group-hover:bg-gray-100 cursor-grab active:cursor-grabbing transition-colors">
|
||||||
|
<MdDragIndicator size={24} className="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
{isEditingQuestion ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={questionText}
|
||||||
|
onChange={(e) => onQuestionChange?.(e.target.value)}
|
||||||
|
className="flex-1 p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
autoFocus
|
||||||
|
onBlur={() => setIsEditingQuestion(false)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
setIsEditingQuestion(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="flex-1 font-bold text-gray-800">{questionText}</span>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 flex-none">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditingQuestion(!isEditingQuestion)}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{isEditingQuestion ?
|
||||||
|
<MdEditOff size={20} className="text-gray-500" /> :
|
||||||
|
<MdEdit size={20} className="text-gray-500" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteQuestion(index)}
|
||||||
|
className="p-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title="Delete question"
|
||||||
|
>
|
||||||
|
<MdDelete size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{extra && <div className="mt-4">{extra}</div>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card ref={setNodeRef} style={style} onFocus={onFocus}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-stretch gap-4">
|
||||||
|
<div className='flex flex-col flex-none w-12'>
|
||||||
|
<div className="flex-none">
|
||||||
|
<span className="text-sm font-medium text-gray-500">ID: {id}</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex-1 flex items-center justify-center group'>
|
||||||
|
<div
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="p-2 rounded-lg group-hover:bg-gray-100 cursor-grab active:cursor-grabbing transition-colors"
|
||||||
|
>
|
||||||
|
<MdDragIndicator size={24} className="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<div className={clsx('flex flex-col gap-4', variant !== "del-up" ? "justify-center": "mt-1.5")}>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteQuestion(index)}
|
||||||
|
className="p-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title="Delete question"
|
||||||
|
>
|
||||||
|
<MdDelete size={variant !== "del-up" ? 20 : 24} />
|
||||||
|
</button>
|
||||||
|
{extra}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SortableQuestion;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { AlertItem } from "./Alert";
|
||||||
|
|
||||||
|
|
||||||
|
const setEditingAlert = (editing: boolean, setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>) => {
|
||||||
|
if (editing) {
|
||||||
|
setAlerts(prev => {
|
||||||
|
if (!prev.some(alert => alert.variant === "info")) {
|
||||||
|
return [...prev, {
|
||||||
|
variant: "info",
|
||||||
|
description: "You have unsaved changes. Don't forget to save your work!",
|
||||||
|
tag: "editing"
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setAlerts([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default setEditingAlert;
|
||||||
177
src/components/ExamEditor/Exercises/Speaking/index.tsx
Normal file
177
src/components/ExamEditor/Exercises/Speaking/index.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
import { ModuleState } from "@/stores/examEditor/types";
|
||||||
|
import { SpeakingExercise, InteractiveSpeakingExercise } from "@/interfaces/exam";
|
||||||
|
import useSectionEdit from "../../Hooks/useSectionEdit";
|
||||||
|
import Header from "../../Shared/Header";
|
||||||
|
import GenLoader from "../Shared/GenLoader";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sectionId: number;
|
||||||
|
exercise: SpeakingExercise | InteractiveSpeakingExercise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Speaking: React.FC<Props> = ({ sectionId, exercise }) => {
|
||||||
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
|
const { generating, genResult } = useExamEditorStore(
|
||||||
|
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||||
|
);
|
||||||
|
const { edit } = useExamEditorStore((store) => store.modules[currentModule]);
|
||||||
|
|
||||||
|
const [local, setLocal] = useState(exercise);
|
||||||
|
const [loading, setLoading] = useState(generating === "context");
|
||||||
|
const [questions, setQuestions] = useState(() => {
|
||||||
|
if (sectionId === 1) {
|
||||||
|
return (exercise as SpeakingExercise).prompts || Array(5).fill("");
|
||||||
|
} else if (sectionId === 2) {
|
||||||
|
return [(exercise as SpeakingExercise).text || "", ...(exercise as SpeakingExercise).prompts || Array(3).fill("")];
|
||||||
|
} else {
|
||||||
|
return (exercise as InteractiveSpeakingExercise).prompts?.map(p => p.text) || Array(5).fill("");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateModule = useCallback((updates: Partial<ModuleState>) => {
|
||||||
|
dispatch({ type: 'UPDATE_MODULE', payload: { updates } });
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({
|
||||||
|
sectionId,
|
||||||
|
mode: "edit",
|
||||||
|
onSave: () => {
|
||||||
|
let newExercise;
|
||||||
|
if (sectionId === 1) {
|
||||||
|
newExercise = {
|
||||||
|
...local,
|
||||||
|
prompts: questions
|
||||||
|
} as SpeakingExercise;
|
||||||
|
} else if (sectionId === 2) {
|
||||||
|
newExercise = {
|
||||||
|
...local,
|
||||||
|
text: questions[0],
|
||||||
|
prompts: questions.slice(1),
|
||||||
|
} as SpeakingExercise;
|
||||||
|
} else {
|
||||||
|
// Section 3
|
||||||
|
newExercise = {
|
||||||
|
...local,
|
||||||
|
prompts: questions.map(text => ({
|
||||||
|
text,
|
||||||
|
video_url: (local as InteractiveSpeakingExercise).prompts?.[0]?.video_url || ""
|
||||||
|
}))
|
||||||
|
} as InteractiveSpeakingExercise;
|
||||||
|
}
|
||||||
|
setEditing(false);
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_SECTION_STATE",
|
||||||
|
payload: {
|
||||||
|
sectionId: sectionId,
|
||||||
|
update: newExercise
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onDiscard: () => {
|
||||||
|
setLocal(exercise);
|
||||||
|
if (sectionId === 1) {
|
||||||
|
setQuestions((exercise as SpeakingExercise).prompts || Array(5).fill(""));
|
||||||
|
} else if (sectionId === 2) {
|
||||||
|
setQuestions([(exercise as SpeakingExercise).text || "", ...(exercise as SpeakingExercise).prompts || Array(3).fill("")]);
|
||||||
|
} else {
|
||||||
|
setQuestions((exercise as InteractiveSpeakingExercise).prompts?.map(p => p.text) || Array(5).fill(""));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isLoading = generating === "context";
|
||||||
|
setLoading(isLoading);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
updateModule({ edit: Array.from(new Set([...edit, sectionId])) });
|
||||||
|
}
|
||||||
|
}, [generating, sectionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (genResult && generating === "context") {
|
||||||
|
setEditing(true);
|
||||||
|
if (sectionId === 1) {
|
||||||
|
setQuestions(genResult[0].questions);
|
||||||
|
} else if (sectionId === 2) {
|
||||||
|
setQuestions([genResult[0].question, ...genResult[0].prompts]);
|
||||||
|
} else {
|
||||||
|
setQuestions(genResult[0].questions);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||||
|
payload: {
|
||||||
|
sectionId,
|
||||||
|
module: currentModule,
|
||||||
|
field: "genResult",
|
||||||
|
value: undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [genResult, generating, dispatch, sectionId, setEditing, currentModule]);
|
||||||
|
|
||||||
|
const handleQuestionChange = (index: number, value: string) => {
|
||||||
|
setQuestions(prev => {
|
||||||
|
const newQuestions = [...prev];
|
||||||
|
newQuestions[index] = value;
|
||||||
|
return newQuestions;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getQuestionLabel = (index: number) => {
|
||||||
|
if (sectionId === 2 && index === 0) {
|
||||||
|
return "Main Question";
|
||||||
|
} else if (sectionId === 2) {
|
||||||
|
return `Prompt ${index}`;
|
||||||
|
} else {
|
||||||
|
return `Question ${index + 1}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='relative pb-4'>
|
||||||
|
<Header
|
||||||
|
title={`Speaking ${sectionId} Script`}
|
||||||
|
description='Generate or write the script for the video.'
|
||||||
|
editing={editing}
|
||||||
|
handleSave={handleSave}
|
||||||
|
modeHandle={modeHandle}
|
||||||
|
handleDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<GenLoader module={currentModule} />
|
||||||
|
) : (
|
||||||
|
<div className="mx-auto p-3 space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex flex-col space-y-6">
|
||||||
|
{questions.map((question: string, index: number) => (
|
||||||
|
<div key={index} className="flex flex-col">
|
||||||
|
<h2 className="font-semibold my-2">{getQuestionLabel(index)}</h2>
|
||||||
|
<AutoExpandingTextArea
|
||||||
|
value={question}
|
||||||
|
onChange={(text) => handleQuestionChange(index, text)}
|
||||||
|
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all"
|
||||||
|
placeholder={`Enter ${getQuestionLabel(index).toLowerCase()}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Speaking;
|
||||||
235
src/components/ExamEditor/Exercises/TrueFalse/index.tsx
Normal file
235
src/components/ExamEditor/Exercises/TrueFalse/index.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
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';
|
||||||
|
import QuestionsList from '../Shared/QuestionsList';
|
||||||
|
import Header from '../../Shared/Header';
|
||||||
|
import SortableQuestion from '../Shared/SortableQuestion';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import useExamEditorStore from '@/stores/examEditor';
|
||||||
|
import useSectionEdit from '../../Hooks/useSectionEdit';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import validateTrueFalseQuestions from './validation';
|
||||||
|
import setEditingAlert from '../Shared/setEditingAlert';
|
||||||
|
import { DragEndEvent } from '@dnd-kit/core';
|
||||||
|
import { handleTrueFalseReorder } from '@/stores/examEditor/reorder/local';
|
||||||
|
|
||||||
|
const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> = ({ exercise, sectionId }) => {
|
||||||
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
|
const { state } = useExamEditorStore(
|
||||||
|
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||||
|
);
|
||||||
|
|
||||||
|
const section = state as ReadingPart;
|
||||||
|
|
||||||
|
const [local, setLocal] = useState(exercise);
|
||||||
|
const [editingPrompt, setEditingPrompt] = useState(false);
|
||||||
|
|
||||||
|
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||||
|
|
||||||
|
const updateLocal = (exercise: TrueFalseExercise) => {
|
||||||
|
setLocal(exercise);
|
||||||
|
setEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateQuestion = (index: number, field: string, value: string) => {
|
||||||
|
const newQuestions = [...local.questions];
|
||||||
|
newQuestions[index] = { ...newQuestions[index], [field]: value };
|
||||||
|
updateLocal({ ...local, questions: newQuestions });
|
||||||
|
};
|
||||||
|
|
||||||
|
const addQuestion = () => {
|
||||||
|
const newId = (parseInt(local.questions[local.questions.length - 1].id) + 1).toString();
|
||||||
|
updateLocal({
|
||||||
|
...local,
|
||||||
|
questions: [
|
||||||
|
...local.questions,
|
||||||
|
{
|
||||||
|
prompt: "",
|
||||||
|
solution: undefined,
|
||||||
|
id: newId
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteQuestion = (index: number) => {
|
||||||
|
if (local.questions.length == 1) {
|
||||||
|
toast.error("There needs to be at least one question!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newQuestions = local.questions.filter((_, i) => i !== index);
|
||||||
|
const minId = Math.min(...newQuestions.map(q => parseInt(q.id)));
|
||||||
|
|
||||||
|
const updatedQuestions = newQuestions.map((question, i) => ({
|
||||||
|
...question,
|
||||||
|
id: String(minId + i)
|
||||||
|
}));
|
||||||
|
|
||||||
|
updateLocal({ ...local, questions: updatedQuestions });
|
||||||
|
};
|
||||||
|
|
||||||
|
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
|
||||||
|
sectionId,
|
||||||
|
mode: "edit",
|
||||||
|
onSave: () => {
|
||||||
|
const isValid = validateTrueFalseQuestions(
|
||||||
|
local.questions,
|
||||||
|
setAlerts
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
toast.error("Please fix the errors before saving!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditing(false);
|
||||||
|
setAlerts([]);
|
||||||
|
//dispatch({ type: 'UPDATE_ROOT', payload: { updates: { globalEdit: globalEdit.filter(id => id !== sectionId) } } });
|
||||||
|
const newSection = {
|
||||||
|
...section,
|
||||||
|
exercises: section.exercises.map((ex) => ex.id === local.id ? local : ex)
|
||||||
|
};
|
||||||
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
|
||||||
|
dispatch({ type: "REORDER_EXERCISES" })
|
||||||
|
},
|
||||||
|
onDiscard: () => {
|
||||||
|
setLocal(exercise);
|
||||||
|
},
|
||||||
|
onMode: () => {
|
||||||
|
const newSection = {
|
||||||
|
...section,
|
||||||
|
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
||||||
|
};
|
||||||
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
|
||||||
|
dispatch({ type: "REORDER_EXERCISES" })
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
validateTrueFalseQuestions(local.questions, setAlerts);
|
||||||
|
}, [local.questions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditingAlert(editing, setAlerts);
|
||||||
|
}, [editing]);
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
setEditing(true);
|
||||||
|
setLocal(handleTrueFalseReorder(event, local));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<Header
|
||||||
|
title='True/False/Not Given Exercise'
|
||||||
|
description='Edit questions and their solutions'
|
||||||
|
editing={editing}
|
||||||
|
handleSave={handleSave}
|
||||||
|
modeHandle={modeHandle}
|
||||||
|
handleDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex justify-between items-start gap-4">
|
||||||
|
{editingPrompt ? (
|
||||||
|
<textarea
|
||||||
|
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none min-h-[100px]"
|
||||||
|
value={local.prompt}
|
||||||
|
onChange={(e) => updateLocal({ ...local, prompt: e.target.value })}
|
||||||
|
onBlur={() => setEditingPrompt(false)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-medium text-gray-800 mb-2">Question/Instructions displayed to the student:</h3>
|
||||||
|
<p className="text-gray-600">{local.prompt}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingPrompt(!editingPrompt)}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{editingPrompt ?
|
||||||
|
<MdEditOff size={20} className="text-gray-500" /> :
|
||||||
|
<MdEdit size={20} className="text-gray-500" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<QuestionsList
|
||||||
|
ids={local.questions.map(q => q.id)}
|
||||||
|
handleDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
{local.questions.map((question, index) => (
|
||||||
|
<SortableQuestion
|
||||||
|
key={question.id}
|
||||||
|
id={question.id}
|
||||||
|
index={index}
|
||||||
|
deleteQuestion={deleteQuestion}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={question.prompt}
|
||||||
|
onChange={(e) => updateQuestion(index, 'prompt', e.target.value)}
|
||||||
|
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
placeholder="Enter question..."
|
||||||
|
/>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{['true', 'false', 'not_given'].map((value) => (
|
||||||
|
<label
|
||||||
|
key={value}
|
||||||
|
className="flex-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"p-3 text-center rounded-lg border-2 transition-all flex items-center justify-center gap-2",
|
||||||
|
question.solution === value
|
||||||
|
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`solution-${question.id}`}
|
||||||
|
value={value}
|
||||||
|
checked={question.solution === value}
|
||||||
|
onChange={(e) => updateQuestion(index, 'solution', e.target.value)}
|
||||||
|
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 sr-only"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{value.replace('_', ' ').charAt(0).toUpperCase() + value.slice(1).replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</SortableQuestion>
|
||||||
|
))}
|
||||||
|
</QuestionsList>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={addQuestion}
|
||||||
|
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
<MdAdd size={18} />
|
||||||
|
Add New Question
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TrueFalse;
|
||||||
46
src/components/ExamEditor/Exercises/TrueFalse/validation.ts
Normal file
46
src/components/ExamEditor/Exercises/TrueFalse/validation.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { AlertItem } from "../Shared/Alert";
|
||||||
|
|
||||||
|
const validateTrueFalseQuestions = (
|
||||||
|
questions: {
|
||||||
|
id: string;
|
||||||
|
prompt: string;
|
||||||
|
solution?: string;
|
||||||
|
}[],
|
||||||
|
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
|
||||||
|
): boolean => {
|
||||||
|
let hasErrors = false;
|
||||||
|
|
||||||
|
const emptyPrompts = questions.filter(q => !q.prompt.trim());
|
||||||
|
if (emptyPrompts.length > 0) {
|
||||||
|
hasErrors = true;
|
||||||
|
setAlerts(prev => {
|
||||||
|
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('empty-prompt'));
|
||||||
|
return [...filteredAlerts, ...emptyPrompts.map(q => ({
|
||||||
|
variant: "error" as const,
|
||||||
|
tag: `empty-prompt-${q.id}`,
|
||||||
|
description: `Question ${q.id} has an empty prompt`
|
||||||
|
}))];
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('empty-prompt')));
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingSolutions = questions.filter(q => q.solution === undefined);
|
||||||
|
if (missingSolutions.length > 0) {
|
||||||
|
hasErrors = true;
|
||||||
|
setAlerts(prev => {
|
||||||
|
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('missing-solution'));
|
||||||
|
return [...filteredAlerts, ...missingSolutions.map(q => ({
|
||||||
|
variant: "error" as const,
|
||||||
|
tag: `missing-solution-${q.id}`,
|
||||||
|
description: `Question ${q.id} is missing a solution`
|
||||||
|
}))];
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('missing-solution')));
|
||||||
|
}
|
||||||
|
|
||||||
|
return !hasErrors;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default validateTrueFalseQuestions;
|
||||||
341
src/components/ExamEditor/Exercises/WriteBlanks/index.tsx
Normal file
341
src/components/ExamEditor/Exercises/WriteBlanks/index.tsx
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
MdAdd,
|
||||||
|
MdEdit,
|
||||||
|
MdEditOff,
|
||||||
|
MdDelete,
|
||||||
|
} from 'react-icons/md';
|
||||||
|
import QuestionsList from '../Shared/QuestionsList';
|
||||||
|
import SortableQuestion from '../Shared/SortableQuestion';
|
||||||
|
import { DragEndEvent } from '@dnd-kit/core';
|
||||||
|
import Header from '../../Shared/Header';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import Alert, { AlertItem } from '../Shared/Alert';
|
||||||
|
import AutoExpandingTextArea from '@/components/Low/AutoExpandingTextarea';
|
||||||
|
import { ReadingPart, WriteBlanksExercise } from '@/interfaces/exam';
|
||||||
|
import useExamEditorStore from '@/stores/examEditor';
|
||||||
|
import useSectionEdit from '../../Hooks/useSectionEdit';
|
||||||
|
import setEditingAlert from '../Shared/setEditingAlert';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { validateEmptySolutions, validateQuestionText, validateWordCount } from './validation';
|
||||||
|
import { handleWriteBlanksReorder } from '@/stores/examEditor/reorder/local';
|
||||||
|
import { ParsedQuestion, parseText, reconstructText } from './parsing';
|
||||||
|
|
||||||
|
|
||||||
|
const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise }> = ({ sectionId, exercise }) => {
|
||||||
|
|
||||||
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
|
const { state } = useExamEditorStore(
|
||||||
|
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||||
|
);
|
||||||
|
|
||||||
|
const section = state as ReadingPart;
|
||||||
|
|
||||||
|
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||||
|
const [local, setLocal] = useState(exercise);
|
||||||
|
const [editingPrompt, setEditingPrompt] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<{ [key: string]: string[] }>({});
|
||||||
|
const [parsedQuestions, setParsedQuestions] = useState<ParsedQuestion[]>([]);
|
||||||
|
|
||||||
|
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
|
||||||
|
sectionId,
|
||||||
|
mode: "edit",
|
||||||
|
onSave: () => {
|
||||||
|
const isQuestionTextValid = validateQuestionText(
|
||||||
|
parsedQuestions,
|
||||||
|
setAlerts
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSolutionsValid = validateEmptySolutions(
|
||||||
|
local.solutions,
|
||||||
|
setAlerts
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isQuestionTextValid || !isSolutionsValid) {
|
||||||
|
toast.error("Please fix the errors before saving!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditing(false);
|
||||||
|
setAlerts([]);
|
||||||
|
//dispatch({ type: 'UPDATE_ROOT', payload: { updates: {globalEdit: globalEdit.filter(id => id !== sectionId)} } });
|
||||||
|
|
||||||
|
const newSection = {
|
||||||
|
...section,
|
||||||
|
exercises: section.exercises.map((ex) => ex.id === local.id ? local : ex)
|
||||||
|
};
|
||||||
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
|
||||||
|
},
|
||||||
|
onDiscard: () => {
|
||||||
|
setLocal(exercise);
|
||||||
|
},
|
||||||
|
onMode: () => {
|
||||||
|
const newSection = {
|
||||||
|
...section,
|
||||||
|
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
||||||
|
};
|
||||||
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setParsedQuestions(parseText(local.text));
|
||||||
|
}, [local.text]);
|
||||||
|
|
||||||
|
const updateLocal = (exercise: WriteBlanksExercise) => {
|
||||||
|
setLocal(exercise);
|
||||||
|
setEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addQuestion = () => {
|
||||||
|
const existingIds = parsedQuestions.map(q => parseInt(q.id));
|
||||||
|
const newId = (Math.max(...existingIds, 0) + 1).toString();
|
||||||
|
|
||||||
|
const newQuestion = {
|
||||||
|
id: newId,
|
||||||
|
questionText: "New question"
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedQuestions = [...parsedQuestions, newQuestion];
|
||||||
|
const updatedText = reconstructText(updatedQuestions);
|
||||||
|
|
||||||
|
const updatedSolutions = [...local.solutions, {
|
||||||
|
id: newId,
|
||||||
|
solution: [""]
|
||||||
|
}];
|
||||||
|
|
||||||
|
updateLocal({
|
||||||
|
...local,
|
||||||
|
text: updatedText,
|
||||||
|
solutions: updatedSolutions
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateQuestionText = (id: string, newText: string) => {
|
||||||
|
const updatedQuestions = parsedQuestions.map(q =>
|
||||||
|
q.id === id ? { ...q, questionText: newText } : q
|
||||||
|
);
|
||||||
|
const updatedText = reconstructText(updatedQuestions);
|
||||||
|
updateLocal({ ...local, text: updatedText });
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteQuestion = (id: string) => {
|
||||||
|
if (parsedQuestions.length == 1) {
|
||||||
|
toast.error("There needs to be at least one question!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updatedQuestions = parsedQuestions.filter(q => q.id !== id);
|
||||||
|
const updatedText = reconstructText(updatedQuestions);
|
||||||
|
const updatedSolutions = local.solutions.filter(s => s.id !== id);
|
||||||
|
updateLocal({
|
||||||
|
...local,
|
||||||
|
text: updatedText,
|
||||||
|
solutions: updatedSolutions
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSolutionToQuestion = (questionId: string) => {
|
||||||
|
const newSolutions = [...local.solutions];
|
||||||
|
const questionIndex = newSolutions.findIndex(s => s.id === questionId);
|
||||||
|
|
||||||
|
if (questionIndex !== -1) {
|
||||||
|
newSolutions[questionIndex] = {
|
||||||
|
...newSolutions[questionIndex],
|
||||||
|
solution: [...newSolutions[questionIndex].solution, ""]
|
||||||
|
};
|
||||||
|
updateLocal({ ...local, solutions: newSolutions });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSolution = (questionId: string, solutionIndex: number, value: string) => {
|
||||||
|
const wordCount = value.trim().split(/\s+/).length;
|
||||||
|
|
||||||
|
const newSolutions = [...local.solutions];
|
||||||
|
const questionIndex = newSolutions.findIndex(s => s.id === questionId);
|
||||||
|
|
||||||
|
if (questionIndex !== -1) {
|
||||||
|
const newSolutionArray = [...newSolutions[questionIndex].solution];
|
||||||
|
newSolutionArray[solutionIndex] = value;
|
||||||
|
newSolutions[questionIndex] = {
|
||||||
|
...newSolutions[questionIndex],
|
||||||
|
solution: newSolutionArray
|
||||||
|
};
|
||||||
|
updateLocal({ ...local, solutions: newSolutions });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wordCount > local.maxWords) {
|
||||||
|
setAlerts(prev => {
|
||||||
|
const filteredAlerts = prev.filter(alert => alert.tag !== `solution-error-${questionId}-${solutionIndex}`);
|
||||||
|
return [...filteredAlerts, {
|
||||||
|
variant: "error",
|
||||||
|
tag: `solution-error-${questionId}-${solutionIndex}`,
|
||||||
|
description: `Alternative solution ${solutionIndex + 1} for question ${questionId} exceeds maximum of ${local.maxWords} words (current: ${wordCount} words)`
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setAlerts(prev => prev.filter(alert => alert.tag !== `solution-error-${questionId}-${solutionIndex}`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSolution = (questionId: string, solutionIndex: number) => {
|
||||||
|
const newSolutions = [...local.solutions];
|
||||||
|
const questionIndex = newSolutions.findIndex(s => s.id === questionId);
|
||||||
|
|
||||||
|
if (questionIndex !== -1) {
|
||||||
|
if (newSolutions[questionIndex].solution.length == 1) {
|
||||||
|
toast.error("There needs to be at least one solution!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newSolutionArray = newSolutions[questionIndex].solution.filter((_, i) => i !== solutionIndex);
|
||||||
|
newSolutions[questionIndex] = {
|
||||||
|
...newSolutions[questionIndex],
|
||||||
|
solution: newSolutionArray
|
||||||
|
};
|
||||||
|
updateLocal({ ...local, solutions: newSolutions });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
setEditing(true);
|
||||||
|
setLocal(handleWriteBlanksReorder(event, local));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditingAlert(editing, setAlerts);
|
||||||
|
}, [editing]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
validateWordCount(local.solutions, local.maxWords, setAlerts);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [local.maxWords, local.solutions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
validateQuestionText(parsedQuestions, setAlerts);
|
||||||
|
}, [parsedQuestions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
validateEmptySolutions(local.solutions, setAlerts);
|
||||||
|
}, [local.solutions]);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<Header
|
||||||
|
title="Write Blanks Exercise"
|
||||||
|
description="Edit questions and their solutions"
|
||||||
|
editing={editing}
|
||||||
|
handleSave={handleSave}
|
||||||
|
handleDiscard={handleDiscard}
|
||||||
|
modeHandle={modeHandle}
|
||||||
|
/>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{alerts.length > 0 && <Alert alerts={alerts} />}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardContent className="p-4 space-y-4">
|
||||||
|
<div className="flex justify-between items-start gap-4 mb-6">
|
||||||
|
{editingPrompt ? (
|
||||||
|
<AutoExpandingTextArea
|
||||||
|
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={(text) => updateLocal({ ...local, prompt: text })}
|
||||||
|
onBlur={() => setEditingPrompt(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">{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>
|
||||||
|
<div className="flex justify-between items-start gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-gray-800">Maximum words per solution:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={local.maxWords}
|
||||||
|
onChange={(e) => updateLocal({ ...local, maxWords: parseInt(e.target.value) })}
|
||||||
|
className="w-20 p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<QuestionsList
|
||||||
|
ids={parsedQuestions.map(q => q.id)}
|
||||||
|
handleDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
{parsedQuestions.map((question) => {
|
||||||
|
const questionSolutions = local.solutions.find(s => s.id === question.id)?.solution || [];
|
||||||
|
return (
|
||||||
|
<SortableQuestion
|
||||||
|
key={question.id}
|
||||||
|
id={question.id}
|
||||||
|
index={parseInt(question.id)}
|
||||||
|
deleteQuestion={() => deleteQuestion(question.id)}
|
||||||
|
variant="writeBlanks"
|
||||||
|
questionText={question.questionText}
|
||||||
|
onQuestionChange={(value) => updateQuestionText(question.id, value)}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{questionSolutions.map((solution, solutionIndex) => (
|
||||||
|
<div key={solutionIndex} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={solution}
|
||||||
|
onChange={(e) => updateSolution(question.id, solutionIndex, e.target.value)}
|
||||||
|
className={clsx(
|
||||||
|
"flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none",
|
||||||
|
errors[question.id]?.[solutionIndex] && "border-red-500"
|
||||||
|
)}
|
||||||
|
placeholder="Enter solution..."
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteSolution(question.id, solutionIndex)}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<MdDelete size={20} className="text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => addSolutionToQuestion(question.id)}
|
||||||
|
className="w-full p-2 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
<MdAdd size={18} />
|
||||||
|
Add Alternative Solution
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</SortableQuestion>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</QuestionsList>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={addQuestion}
|
||||||
|
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
<MdAdd size={18} />
|
||||||
|
Add New Question
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WriteBlanks;
|
||||||
27
src/components/ExamEditor/Exercises/WriteBlanks/parsing.ts
Normal file
27
src/components/ExamEditor/Exercises/WriteBlanks/parsing.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export interface ParsedQuestion {
|
||||||
|
id: string;
|
||||||
|
questionText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseText = (text: string): ParsedQuestion[] => {
|
||||||
|
const lines = text.split('\\n').filter(line => line.trim());
|
||||||
|
return lines.map(line => {
|
||||||
|
const match = line.match(/(.*?)\{\{(\d+)\}\}/);
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
questionText: match[1],
|
||||||
|
id: match[2]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { questionText: line, id: '' };
|
||||||
|
}).filter(q => q.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reconstructText = (questions: ParsedQuestion[]): string => {
|
||||||
|
return questions.map(q => `${q.questionText}{{${q.id}}}`).join('\\n') + '\\n';
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
parseText,
|
||||||
|
reconstructText
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { AlertItem } from "../Shared/Alert";
|
||||||
|
import { ParsedQuestion } from "./parsing";
|
||||||
|
|
||||||
|
export const validateQuestionText = (
|
||||||
|
parsedQuestions: ParsedQuestion[],
|
||||||
|
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
|
||||||
|
): boolean => {
|
||||||
|
const unmodifiedQuestions = parsedQuestions.filter(q => q.questionText === "New question");
|
||||||
|
if (unmodifiedQuestions.length > 0) {
|
||||||
|
setAlerts(prev => {
|
||||||
|
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('unmodified-question'));
|
||||||
|
return [...filteredAlerts, ...unmodifiedQuestions.map(q => ({
|
||||||
|
variant: "error" as const,
|
||||||
|
tag: `unmodified-question-${q.id}`,
|
||||||
|
description: `Question ${q.id} is unmodified`
|
||||||
|
}))];
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('unmodified-question')));
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateEmptySolutions = (
|
||||||
|
solutions: Array<{ id: string; solution: string[] }>,
|
||||||
|
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
|
||||||
|
): boolean => {
|
||||||
|
const questionsWithEmptySolutions = solutions.flatMap(solution =>
|
||||||
|
solution.solution.map((sol, index) => ({
|
||||||
|
questionId: solution.id,
|
||||||
|
solutionIndex: index,
|
||||||
|
isEmpty: !sol.trim()
|
||||||
|
})).filter(({ isEmpty }) => isEmpty)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (questionsWithEmptySolutions.length > 0) {
|
||||||
|
setAlerts(prev => {
|
||||||
|
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('empty-solution'));
|
||||||
|
return [...filteredAlerts, ...questionsWithEmptySolutions.map(({ questionId, solutionIndex }) => ({
|
||||||
|
variant: "error" as const,
|
||||||
|
tag: `empty-solution-${questionId}-${solutionIndex}`,
|
||||||
|
description: `Solution ${solutionIndex + 1} for question ${questionId} cannot be empty`
|
||||||
|
}))];
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('empty-solution')));
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateWordCount = (
|
||||||
|
solutions: Array<{ id: string; solution: string[] }>,
|
||||||
|
maxWords: number,
|
||||||
|
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
|
||||||
|
): boolean => {
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
solutions.forEach((solution) => {
|
||||||
|
solution.solution.forEach((value, solutionIndex) => {
|
||||||
|
const wordCount = value.trim().split(/\s+/).length;
|
||||||
|
if (wordCount > maxWords) {
|
||||||
|
isValid = false;
|
||||||
|
setAlerts(prev => {
|
||||||
|
const filteredAlerts = prev.filter(alert =>
|
||||||
|
alert.tag !== `solution-error-${solution.id}-${solutionIndex}`
|
||||||
|
);
|
||||||
|
return [...filteredAlerts, {
|
||||||
|
variant: "error",
|
||||||
|
tag: `solution-error-${solution.id}-${solutionIndex}`,
|
||||||
|
description: `Solution ${solutionIndex + 1} for question ${solution.id} exceeds maximum of ${maxWords} words (current: ${wordCount} words)`
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setAlerts(prev =>
|
||||||
|
prev.filter(alert =>
|
||||||
|
alert.tag !== `solution-error-${solution.id}-${solutionIndex}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
};
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { useSensors, useSensor, PointerSensor, KeyboardSensor, DragEndEvent, DndContext, closestCenter } from "@dnd-kit/core";
|
||||||
|
import { sortableKeyboardCoordinates, arrayMove, SortableContext, horizontalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { BsCursorText } from "react-icons/bs";
|
||||||
|
import { MdSpaceBar } from "react-icons/md";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { formatDisplayContent, formatStorageContent, PromptPart, reconstructLine } from "./parsing";
|
||||||
|
import SortableBlank from "./SortableBlank";
|
||||||
|
import { validatePlaceholders } from "./validation";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
parts: PromptPart[];
|
||||||
|
onUpdate: (newText: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditingState {
|
||||||
|
text: string;
|
||||||
|
isPlaceholderMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const BlanksFormEditor: React.FC<Props> = ({ parts, onUpdate }) => {
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||||
|
);
|
||||||
|
|
||||||
|
const [editingState, setEditingState] = useState<EditingState>({
|
||||||
|
text: formatDisplayContent(reconstructLine(parts)),
|
||||||
|
isPlaceholderMode: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleTextChange = (newText: string) => {
|
||||||
|
const placeholder = parts.find(p => p.isPlaceholder);
|
||||||
|
if (!placeholder) return;
|
||||||
|
|
||||||
|
const displayPlaceholder = formatDisplayContent(placeholder.content);
|
||||||
|
|
||||||
|
if (!newText.includes(displayPlaceholder)) {
|
||||||
|
const placeholderIndex = editingState.text.indexOf(displayPlaceholder);
|
||||||
|
|
||||||
|
if (placeholderIndex >= 0) {
|
||||||
|
const beforePlaceholder = newText.slice(0, Math.min(placeholderIndex, newText.length));
|
||||||
|
const afterPlaceholder = newText.slice(Math.min(placeholderIndex, newText.length));
|
||||||
|
newText = beforePlaceholder + displayPlaceholder + afterPlaceholder;
|
||||||
|
} else {
|
||||||
|
newText = newText + ' ' + displayPlaceholder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingState(prev => ({
|
||||||
|
...prev,
|
||||||
|
text: newText
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
|
const oldIndex = parts.findIndex(part => part.id === active.id);
|
||||||
|
const newIndex = parts.findIndex(part => part.id === over.id);
|
||||||
|
|
||||||
|
const newParts = [...parts];
|
||||||
|
const [movedPart] = newParts.splice(oldIndex, 1);
|
||||||
|
newParts.splice(newIndex, 0, movedPart);
|
||||||
|
|
||||||
|
onUpdate(reconstructLine(newParts));
|
||||||
|
|
||||||
|
setEditingState(prev => ({
|
||||||
|
...prev,
|
||||||
|
text: formatDisplayContent(reconstructLine(newParts))
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEditMode = () => {
|
||||||
|
setEditingState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isPlaceholderMode: !prev.isPlaceholderMode
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveTextChanges = () => {
|
||||||
|
const placeholderId = parts.find(p => p.isPlaceholder)?.id;
|
||||||
|
if (!placeholderId) return;
|
||||||
|
|
||||||
|
const validation = validatePlaceholders(editingState.text, placeholderId);
|
||||||
|
if (!validation.isValid) {
|
||||||
|
toast.error(validation.message);
|
||||||
|
setEditingState(prev => ({
|
||||||
|
...prev,
|
||||||
|
text: formatDisplayContent(reconstructLine(parts))
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdate(formatStorageContent(editingState.text));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<div className="flex-grow">
|
||||||
|
{editingState.isPlaceholderMode ? (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={parts.map(part => part.id)}
|
||||||
|
strategy={horizontalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-1 min-h-[40px] p-2 border rounded-lg bg-white">
|
||||||
|
{parts.map((part) => (
|
||||||
|
<SortableBlank
|
||||||
|
key={part.id}
|
||||||
|
id={part.id}
|
||||||
|
isPlaceholder={part.isPlaceholder}
|
||||||
|
>
|
||||||
|
{part.isPlaceholder ? (
|
||||||
|
<div className="bg-blue-200 px-2 py-1 rounded cursor-move">
|
||||||
|
{formatDisplayContent(part.content)}
|
||||||
|
</div>
|
||||||
|
) : /^\s+$/.test(part.content) ? (
|
||||||
|
<div className="px-1 border-l-2 border-r-2 border-transparent">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-1">
|
||||||
|
{part.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SortableBlank>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingState.text}
|
||||||
|
onChange={(e) => handleTextChange(e.target.value)}
|
||||||
|
onPaste={(e) => e.preventDefault()}
|
||||||
|
onBlur={saveTextChanges}
|
||||||
|
className="w-full p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={`p-2 rounded ${editingState.isPlaceholderMode ? 'bg-blue-500 text-white' : 'bg-gray-200'}`}
|
||||||
|
onClick={toggleEditMode}
|
||||||
|
title={editingState.isPlaceholderMode ? "Switch to text editing" : "Switch to placeholder editing"}
|
||||||
|
>
|
||||||
|
{editingState.isPlaceholderMode ? <BsCursorText size={20} /> : <MdSpaceBar size={20} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BlanksFormEditor;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
|
||||||
|
interface SortableBlankProps {
|
||||||
|
id: string;
|
||||||
|
isPlaceholder?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortableBlank: React.FC<SortableBlankProps> = ({ id, isPlaceholder, children }) => {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id });
|
||||||
|
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : undefined,
|
||||||
|
cursor: isPlaceholder ? 'move' : 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
const draggableProps = isPlaceholder ? { ...attributes, ...listeners } : {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...draggableProps}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SortableBlank;
|
||||||
318
src/components/ExamEditor/Exercises/WriteBlanksForm/index.tsx
Normal file
318
src/components/ExamEditor/Exercises/WriteBlanksForm/index.tsx
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { WriteBlanksExercise, ReadingPart } from "@/interfaces/exam";
|
||||||
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
import { DragEndEvent } from "@dnd-kit/core";
|
||||||
|
import { arrayMove } from "@dnd-kit/sortable";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { MdEditOff, MdEdit, MdDelete, MdAdd } from "react-icons/md";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import useSectionEdit from "../../Hooks/useSectionEdit";
|
||||||
|
import Alert, { AlertItem } from "../Shared/Alert";
|
||||||
|
import QuestionsList from "../Shared/QuestionsList";
|
||||||
|
import setEditingAlert from "../Shared/setEditingAlert";
|
||||||
|
import SortableQuestion from "../Shared/SortableQuestion";
|
||||||
|
import { ParsedQuestion, parseLine, reconstructLine } from "./parsing";
|
||||||
|
import { validateQuestions, validateEmptySolutions, validateWordCount } from "./validation";
|
||||||
|
import Header from "../../Shared/Header";
|
||||||
|
import BlanksFormEditor from "./BlanksFormEditor";
|
||||||
|
|
||||||
|
|
||||||
|
const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExercise }> = ({ sectionId, exercise }) => {
|
||||||
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
|
const { state } = useExamEditorStore(
|
||||||
|
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||||
|
);
|
||||||
|
|
||||||
|
const section = state as ReadingPart;
|
||||||
|
|
||||||
|
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||||
|
const [local, setLocal] = useState(exercise);
|
||||||
|
const [editingPrompt, setEditingPrompt] = useState(false);
|
||||||
|
const [parsedQuestions, setParsedQuestions] = useState<ParsedQuestion[]>([]);
|
||||||
|
|
||||||
|
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
|
||||||
|
sectionId,
|
||||||
|
mode: "edit",
|
||||||
|
onSave: () => {
|
||||||
|
const isQuestionsValid = validateQuestions(parsedQuestions, setAlerts);
|
||||||
|
const isSolutionsValid = validateEmptySolutions(local.solutions, setAlerts);
|
||||||
|
|
||||||
|
if (!isQuestionsValid || !isSolutionsValid) {
|
||||||
|
toast.error("Please fix the errors before saving!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditing(false);
|
||||||
|
setAlerts([]);
|
||||||
|
|
||||||
|
const newSection = {
|
||||||
|
...section,
|
||||||
|
exercises: section.exercises.map((ex) => ex.id === local.id ? local : ex)
|
||||||
|
};
|
||||||
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
|
||||||
|
},
|
||||||
|
onDiscard: () => {
|
||||||
|
setLocal(exercise);
|
||||||
|
setParsedQuestions([]);
|
||||||
|
},
|
||||||
|
onMode: () => {
|
||||||
|
const newSection = {
|
||||||
|
...section,
|
||||||
|
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
||||||
|
};
|
||||||
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const questions = local.text.split('\\n')
|
||||||
|
.filter(line => line.trim())
|
||||||
|
.map(line => {
|
||||||
|
const match = line.match(/{{(\d+)}}/);
|
||||||
|
return {
|
||||||
|
id: match ? match[1] : `unknown-${Date.now()}`,
|
||||||
|
parts: parseLine(line),
|
||||||
|
editingPlaceholders: true
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setParsedQuestions(questions);
|
||||||
|
}, [local.text]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditingAlert(editing, setAlerts);
|
||||||
|
}, [editing]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
validateWordCount(local.solutions, local.maxWords, setAlerts);
|
||||||
|
}, [local.maxWords, local.solutions]);
|
||||||
|
|
||||||
|
const updateLocal = (exercise: WriteBlanksExercise) => {
|
||||||
|
setLocal(exercise);
|
||||||
|
setEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addQuestion = () => {
|
||||||
|
const existingIds = parsedQuestions.map(q => parseInt(q.id));
|
||||||
|
const newId = (Math.max(...existingIds, 0) + 1).toString();
|
||||||
|
|
||||||
|
const newLine = `New question with blank {{${newId}}}`;
|
||||||
|
const updatedQuestions = [...parsedQuestions, {
|
||||||
|
id: newId,
|
||||||
|
parts: parseLine(newLine),
|
||||||
|
editingPlaceholders: true
|
||||||
|
}];
|
||||||
|
|
||||||
|
const newText = updatedQuestions
|
||||||
|
.map(q => reconstructLine(q.parts))
|
||||||
|
.join('\\n') + '\\n';
|
||||||
|
|
||||||
|
const updatedSolutions = [...local.solutions, {
|
||||||
|
id: newId,
|
||||||
|
solution: [""]
|
||||||
|
}];
|
||||||
|
|
||||||
|
updateLocal({
|
||||||
|
...local,
|
||||||
|
text: newText,
|
||||||
|
solutions: updatedSolutions
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteQuestion = (id: string) => {
|
||||||
|
if (parsedQuestions.length === 1) {
|
||||||
|
toast.error("There needs to be at least one question!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updatedQuestions = parsedQuestions.filter(q => q.id !== id);
|
||||||
|
const newText = updatedQuestions
|
||||||
|
.map(q => reconstructLine(q.parts))
|
||||||
|
.join('\\n') + '\\n';
|
||||||
|
|
||||||
|
const updatedSolutions = local.solutions.filter(s => s.id !== id);
|
||||||
|
updateLocal({
|
||||||
|
...local,
|
||||||
|
text: newText,
|
||||||
|
solutions: updatedSolutions
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuestionUpdate = (questionId: string, newText: string) => {
|
||||||
|
const updatedQuestions = parsedQuestions.map(q =>
|
||||||
|
q.id === questionId ? { ...q, parts: parseLine(newText) } : q
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedText = updatedQuestions
|
||||||
|
.map(q => reconstructLine(q.parts))
|
||||||
|
.join('\\n') + '\\n';
|
||||||
|
|
||||||
|
updateLocal({ ...local, text: updatedText });
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSolution = (questionId: string) => {
|
||||||
|
const newSolutions = local.solutions.map(s =>
|
||||||
|
s.id === questionId
|
||||||
|
? { ...s, solution: [...s.solution, ""] }
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
updateLocal({ ...local, solutions: newSolutions });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSolution = (questionId: string, index: number, value: string) => {
|
||||||
|
const newSolutions = local.solutions.map(s =>
|
||||||
|
s.id === questionId
|
||||||
|
? { ...s, solution: s.solution.map((sol, i) => i === index ? value : sol) }
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
updateLocal({ ...local, solutions: newSolutions });
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSolution = (questionId: string, index: number) => {
|
||||||
|
const solutions = local.solutions.find(s => s.id === questionId);
|
||||||
|
if (solutions && solutions.solution.length <= 1) {
|
||||||
|
toast.error("Each question must have at least one solution!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newSolutions = local.solutions.map(s =>
|
||||||
|
s.id === questionId
|
||||||
|
? { ...s, solution: s.solution.filter((_, i) => i !== index) }
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
updateLocal({ ...local, solutions: newSolutions });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuestionsReorder = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
|
const oldIndex = parsedQuestions.findIndex(q => q.id === active.id);
|
||||||
|
const newIndex = parsedQuestions.findIndex(q => q.id === over.id);
|
||||||
|
|
||||||
|
const reorderedQuestions = arrayMove(parsedQuestions, oldIndex, newIndex);
|
||||||
|
const newText = reorderedQuestions
|
||||||
|
.map(q => reconstructLine(q.parts))
|
||||||
|
.join('\\n') + '\\n';
|
||||||
|
|
||||||
|
updateLocal({ ...local, text: newText });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<Header
|
||||||
|
title="Write Blanks: Form Exercise"
|
||||||
|
description="Edit questions and their solutions"
|
||||||
|
editing={editing}
|
||||||
|
handleSave={handleSave}
|
||||||
|
handleDiscard={handleDiscard}
|
||||||
|
modeHandle={modeHandle}
|
||||||
|
/>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{alerts.length > 0 && <Alert alerts={alerts} />}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardContent className="p-4 space-y-4">
|
||||||
|
<div className="flex justify-between items-start gap-4 mb-6">
|
||||||
|
{editingPrompt ? (
|
||||||
|
<AutoExpandingTextArea
|
||||||
|
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={(text) => updateLocal({ ...local, prompt: text })}
|
||||||
|
onBlur={() => setEditingPrompt(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-medium text-gray-800 mb-2">Question/Instructions:</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>
|
||||||
|
<div className="flex justify-between items-start gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-gray-800">Maximum words per solution:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={local.maxWords}
|
||||||
|
onChange={(e) => updateLocal({ ...local, maxWords: parseInt(e.target.value) })}
|
||||||
|
className="w-20 p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<QuestionsList
|
||||||
|
ids={parsedQuestions.map(q => q.id)}
|
||||||
|
handleDragEnd={handleQuestionsReorder}
|
||||||
|
>
|
||||||
|
{parsedQuestions.map((question, index) => (
|
||||||
|
<SortableQuestion
|
||||||
|
key={question.id}
|
||||||
|
id={question.id}
|
||||||
|
index={index}
|
||||||
|
deleteQuestion={() => deleteQuestion(question.id)}
|
||||||
|
variant="del-up"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<BlanksFormEditor
|
||||||
|
parts={question.parts}
|
||||||
|
onUpdate={(newText) => handleQuestionUpdate(question.id, newText)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700">Solutions:</h4>
|
||||||
|
{local.solutions.find(s => s.id === question.id)?.solution.map((solution, index) => (
|
||||||
|
<div key={index} className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={solution}
|
||||||
|
onChange={(e) => updateSolution(question.id, index, e.target.value)}
|
||||||
|
className="flex-1 p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
placeholder={`Solution ${index + 1}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteSolution(question.id, index)}
|
||||||
|
className="p-2 text-gray-500 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title="Delete solution"
|
||||||
|
>
|
||||||
|
<MdDelete size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => addSolution(question.id)}
|
||||||
|
className="w-full p-2 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
<MdAdd size={18} />
|
||||||
|
Add Alternative Solution
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SortableQuestion>
|
||||||
|
))}
|
||||||
|
</QuestionsList>
|
||||||
|
<button
|
||||||
|
onClick={addQuestion}
|
||||||
|
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
<MdAdd size={18} />
|
||||||
|
Add New Question
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WriteBlanksForm;
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
export interface PromptPart {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
isPlaceholder?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface ParsedQuestion {
|
||||||
|
id: string;
|
||||||
|
parts: PromptPart[];
|
||||||
|
editingPlaceholders: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseLine = (line: string): PromptPart[] => {
|
||||||
|
const parts: PromptPart[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
const regex = /{{(\d+)}}/g;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = regex.exec(line)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
const textBefore = line.slice(lastIndex, match.index);
|
||||||
|
const words = textBefore.split(/(\s+)/).filter(Boolean);
|
||||||
|
words.forEach(word => {
|
||||||
|
parts.push({
|
||||||
|
id: `text-${Date.now()}-${parts.length}`,
|
||||||
|
content: word
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholderId = match[1];
|
||||||
|
parts.push({
|
||||||
|
id: placeholderId,
|
||||||
|
content: match[0],
|
||||||
|
isPlaceholder: true
|
||||||
|
});
|
||||||
|
|
||||||
|
lastIndex = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < line.length) {
|
||||||
|
const textAfter = line.slice(lastIndex);
|
||||||
|
const words = textAfter.split(/(\s+)/).filter(Boolean);
|
||||||
|
words.forEach(word => {
|
||||||
|
parts.push({
|
||||||
|
id: `text-${Date.now()}-${parts.length}`,
|
||||||
|
content: word
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
};
|
||||||
|
|
||||||
|
const reconstructLine = (parts: PromptPart[]): string => {
|
||||||
|
const text = parts
|
||||||
|
.map(part => part.content)
|
||||||
|
.join(' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const formatDisplayContent = (content: string): string => {
|
||||||
|
return content.replace(/{{(\d+)}}/g, '[$1]');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatStorageContent = (content: string): string => {
|
||||||
|
return content.replace(/\[(\d+)\]/g, '{{$1}}');
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
parseLine,
|
||||||
|
reconstructLine,
|
||||||
|
formatDisplayContent,
|
||||||
|
formatStorageContent
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { AlertItem } from "../Shared/Alert";
|
||||||
|
import { ParsedQuestion, reconstructLine } from "./parsing";
|
||||||
|
|
||||||
|
|
||||||
|
const validatePlaceholders = (text: string, originalId: string): { isValid: boolean; message?: string } => {
|
||||||
|
const matches = text.match(/\[(\d+)\]/g) || [];
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
message: "Each question must have exactly one blank"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.length > 1) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
message: "Only one blank is allowed per question"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const idMatch = matches[0]?.match(/\[(\d+)\]/);
|
||||||
|
if (!idMatch || idMatch[1] !== originalId) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
message: "The blank ID cannot be changed"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateQuestions = (
|
||||||
|
parsedQuestions: ParsedQuestion[],
|
||||||
|
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
|
||||||
|
): boolean => {
|
||||||
|
const emptyQuestions = parsedQuestions.filter(q => reconstructLine(q.parts).trim() === '');
|
||||||
|
if (emptyQuestions.length > 0) {
|
||||||
|
setAlerts(prev => {
|
||||||
|
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('empty-question'));
|
||||||
|
return [...filteredAlerts, ...emptyQuestions.map(q => ({
|
||||||
|
variant: "error" as const,
|
||||||
|
tag: `empty-question-${q.id}`,
|
||||||
|
description: `Question ${q.id} is empty`
|
||||||
|
}))];
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('empty-question')));
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateEmptySolutions = (
|
||||||
|
solutions: Array<{ id: string; solution: string[] }>,
|
||||||
|
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
|
||||||
|
): boolean => {
|
||||||
|
const questionsWithEmptySolutions = solutions.flatMap(solution =>
|
||||||
|
solution.solution.map((sol, index) => ({
|
||||||
|
questionId: solution.id,
|
||||||
|
solutionIndex: index,
|
||||||
|
isEmpty: !sol.trim()
|
||||||
|
})).filter(({ isEmpty }) => isEmpty)
|
||||||
|
);
|
||||||
|
if (questionsWithEmptySolutions.length > 0) {
|
||||||
|
setAlerts(prev => {
|
||||||
|
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('empty-solution'));
|
||||||
|
return [...filteredAlerts, ...questionsWithEmptySolutions.map(({ questionId, solutionIndex }) => ({
|
||||||
|
variant: "error" as const,
|
||||||
|
tag: `empty-solution-${questionId}-${solutionIndex}`,
|
||||||
|
description: `Solution ${solutionIndex + 1} for question ${questionId} cannot be empty`
|
||||||
|
}))];
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('empty-solution')));
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateWordCount = (
|
||||||
|
solutions: Array<{ id: string; solution: string[] }>,
|
||||||
|
maxWords: number,
|
||||||
|
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
|
||||||
|
): boolean => {
|
||||||
|
let isValid = true;
|
||||||
|
solutions.forEach((solution) => {
|
||||||
|
solution.solution.forEach((value, solutionIndex) => {
|
||||||
|
const wordCount = value.trim().split(/\s+/).length;
|
||||||
|
if (wordCount > maxWords) {
|
||||||
|
isValid = false;
|
||||||
|
setAlerts(prev => {
|
||||||
|
const filteredAlerts = prev.filter(alert =>
|
||||||
|
alert.tag !== `solution-error-${solution.id}-${solutionIndex}`
|
||||||
|
);
|
||||||
|
return [...filteredAlerts, {
|
||||||
|
variant: "error",
|
||||||
|
tag: `solution-error-${solution.id}-${solutionIndex}`,
|
||||||
|
description: `Solution ${solutionIndex + 1} for question ${solution.id} exceeds maximum of ${maxWords} words (current: ${wordCount} words)`
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setAlerts(prev =>
|
||||||
|
prev.filter(alert =>
|
||||||
|
alert.tag !== `solution-error-${solution.id}-${solutionIndex}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return isValid;
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
validateQuestions,
|
||||||
|
validateEmptySolutions,
|
||||||
|
validateWordCount,
|
||||||
|
validatePlaceholders
|
||||||
|
}
|
||||||
119
src/components/ExamEditor/Exercises/Writing/index.tsx
Normal file
119
src/components/ExamEditor/Exercises/Writing/index.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
import ExamEditorStore, { ModuleState } from "@/stores/examEditor/types";
|
||||||
|
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||||
|
import { WritingExercise } from "@/interfaces/exam";
|
||||||
|
import Header from "../../Shared/Header";
|
||||||
|
import Alert, { AlertItem } from "../Shared/Alert";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import useSectionEdit from "../../Hooks/useSectionEdit";
|
||||||
|
import GenLoader from "../Shared/GenLoader";
|
||||||
|
import setEditingAlert from "../Shared/setEditingAlert";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sectionId: number;
|
||||||
|
exercise: WritingExercise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Writing: React.FC<Props> = ({ sectionId }) => {
|
||||||
|
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");
|
||||||
|
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||||
|
|
||||||
|
const updateModule = useCallback((updates: Partial<ModuleState>) => {
|
||||||
|
dispatch({ type: 'UPDATE_MODULE', payload: { updates } });
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
|
||||||
|
sectionId,
|
||||||
|
onSave: () => {
|
||||||
|
const newExercise = { ...local } as WritingExercise;
|
||||||
|
newExercise.prompt = prompt;
|
||||||
|
setAlerts([]);
|
||||||
|
setEditing(false);
|
||||||
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: newExercise } });
|
||||||
|
},
|
||||||
|
onDiscard: () => {
|
||||||
|
setLocal(exercise);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loading = generating && generating == "context";
|
||||||
|
setLoading(loading);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
updateModule({ edit: Array.from(new Set(edit).add(sectionId)) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [generating, updateModule]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (genResult !== undefined && generating === "context") {
|
||||||
|
setEditing(true);
|
||||||
|
setPrompt(genResult[0].prompt);
|
||||||
|
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: undefined }})
|
||||||
|
}
|
||||||
|
}, [genResult, dispatch, sectionId, setEditing, currentModule]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocal(state as WritingExercise);
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditingAlert(prompt !== local.prompt, setAlerts);
|
||||||
|
}, [prompt, local.prompt]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='relative pb-4'>
|
||||||
|
<Header
|
||||||
|
title={`Task ${sectionId} Instructions`}
|
||||||
|
description='Generate or edit the instructions for the task'
|
||||||
|
editing={editing}
|
||||||
|
handleSave={handleSave}
|
||||||
|
modeHandle={modeHandle}
|
||||||
|
handleDiscard={handleDiscard}
|
||||||
|
mode="edit"
|
||||||
|
module={"writing"}
|
||||||
|
/>
|
||||||
|
{alerts.length !== 0 && <Alert alerts={alerts} />}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
{loading ?
|
||||||
|
<GenLoader module={currentModule} /> :
|
||||||
|
(
|
||||||
|
editing ? (
|
||||||
|
<div className="text-gray-600 p-4">
|
||||||
|
<AutoExpandingTextArea
|
||||||
|
value={prompt}
|
||||||
|
onChange={(text) => setPrompt(text)}
|
||||||
|
placeholder="Instructions ..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className={
|
||||||
|
clsx("w-full px-7 py-8 border-2 bg-white rounded-3xl whitespace-pre-line",
|
||||||
|
prompt === "" ? "text-gray-600/50" : "text-gray-600"
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
{prompt === "" ? "Instructions ..." : prompt}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Writing;
|
||||||
67
src/components/ExamEditor/Hooks/useSectionEdit.tsx
Normal file
67
src/components/ExamEditor/Hooks/useSectionEdit.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import useExamEditorStore from '@/stores/examEditor';
|
||||||
|
import ExamEditorStore from '@/stores/examEditor/types';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sectionId: number;
|
||||||
|
mode?: "delete" | "edit";
|
||||||
|
editing?: boolean;
|
||||||
|
setEditing?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
onSave?: () => void;
|
||||||
|
onDiscard?: () => void;
|
||||||
|
onMode?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useSectionEdit = ({
|
||||||
|
sectionId,
|
||||||
|
editing: externalEditing = false,
|
||||||
|
setEditing: externalSetEditing,
|
||||||
|
onSave,
|
||||||
|
onDiscard,
|
||||||
|
onMode
|
||||||
|
}: Props) => {
|
||||||
|
const { dispatch } = useExamEditorStore();
|
||||||
|
const [internalEditing, setInternalEditing] = useState<boolean>(externalEditing);
|
||||||
|
const editing = externalSetEditing !== undefined ? externalEditing : internalEditing;
|
||||||
|
const setEditing = externalSetEditing !== undefined ? externalSetEditing : setInternalEditing;
|
||||||
|
|
||||||
|
|
||||||
|
const updateRoot = useCallback((updates: Partial<ExamEditorStore>) => {
|
||||||
|
dispatch({ type: 'UPDATE_ROOT', payload: { updates } });
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const handleEdit = useCallback(() => {
|
||||||
|
setEditing(true);
|
||||||
|
}, [sectionId, setEditing, updateRoot]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
if (onSave) {
|
||||||
|
onSave();
|
||||||
|
} else {
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
}, [setEditing, updateRoot, onSave, sectionId]);
|
||||||
|
|
||||||
|
const handleDiscard = useCallback(() => {
|
||||||
|
setEditing(false);
|
||||||
|
onDiscard?.();
|
||||||
|
}, [setEditing, updateRoot, onDiscard, sectionId]);
|
||||||
|
|
||||||
|
const modeHandle = useCallback(() => {
|
||||||
|
setEditing(!editing);
|
||||||
|
onMode?.();
|
||||||
|
|
||||||
|
}, [setEditing, editing, updateRoot, onMode, sectionId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
editing,
|
||||||
|
setEditing,
|
||||||
|
handleEdit,
|
||||||
|
handleSave,
|
||||||
|
handleDiscard,
|
||||||
|
modeHandle,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useSectionEdit;
|
||||||
81
src/components/ExamEditor/Hooks/useSettingsState.tsx
Normal file
81
src/components/ExamEditor/Hooks/useSettingsState.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import { debounce } from "lodash";
|
||||||
|
import { SectionSettings } from "@/stores/examEditor/types";
|
||||||
|
|
||||||
|
|
||||||
|
// Since all the other components have a local state
|
||||||
|
// that then gets updated all at once, if the keydowns
|
||||||
|
// aren't here aren't throttled things can get messy
|
||||||
|
const useSettingsState = <T extends SectionSettings>(
|
||||||
|
module: Module,
|
||||||
|
sectionId: number,
|
||||||
|
) => {
|
||||||
|
const globalSettings = useExamEditorStore((state) => {
|
||||||
|
const settings = state.modules[module].sections.find(
|
||||||
|
(section) => section.sectionId === sectionId
|
||||||
|
)?.settings;
|
||||||
|
return settings as T;
|
||||||
|
});
|
||||||
|
|
||||||
|
const dispatch = useExamEditorStore((state) => state.dispatch);
|
||||||
|
|
||||||
|
const [localSettings, setLocalSettings] = useState<T>(() =>
|
||||||
|
globalSettings || {} as T
|
||||||
|
);
|
||||||
|
|
||||||
|
const pendingUpdatesRef = useRef<Partial<T>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (globalSettings) {
|
||||||
|
setLocalSettings(globalSettings);
|
||||||
|
}
|
||||||
|
}, [globalSettings]);
|
||||||
|
|
||||||
|
const debouncedUpdateGlobal = useMemo(() => {
|
||||||
|
const debouncedFn = debounce(() => {
|
||||||
|
if (Object.keys(pendingUpdatesRef.current).length > 0) {
|
||||||
|
dispatch({
|
||||||
|
type: 'UPDATE_SECTION_SETTINGS',
|
||||||
|
payload: { sectionId, update: pendingUpdatesRef.current}
|
||||||
|
});
|
||||||
|
pendingUpdatesRef.current = {};
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return debouncedFn;
|
||||||
|
}, [dispatch, sectionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (Object.keys(pendingUpdatesRef.current).length > 0) {
|
||||||
|
dispatch({
|
||||||
|
type: 'UPDATE_SECTION_SETTINGS',
|
||||||
|
payload: {sectionId, update: pendingUpdatesRef.current}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [dispatch, module, sectionId]);
|
||||||
|
|
||||||
|
|
||||||
|
const updateLocalAndScheduleGlobal = useCallback((updates: Partial<T>) => {
|
||||||
|
setLocalSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
...updates
|
||||||
|
}));
|
||||||
|
|
||||||
|
pendingUpdatesRef.current = {
|
||||||
|
...pendingUpdatesRef.current,
|
||||||
|
...updates
|
||||||
|
};
|
||||||
|
debouncedUpdateGlobal();
|
||||||
|
}, [debouncedUpdateGlobal]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
localSettings,
|
||||||
|
updateLocalAndScheduleGlobal
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useSettingsState;
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
import ExamEditorStore from "@/stores/examEditor/types";
|
||||||
|
import Header from "../../Shared/Header";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sectionId: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
editing: boolean;
|
||||||
|
renderContent: (editing: boolean) => React.ReactNode;
|
||||||
|
mode?: "edit" | "delete";
|
||||||
|
onSave: () => void;
|
||||||
|
onDiscard: () => void;
|
||||||
|
onEdit?: () => void;
|
||||||
|
module?: Module;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SectionContext: React.FC<Props> = ({ sectionId, title, description, renderContent, editing, onSave, onDiscard, onEdit, mode = "edit", module}) => {
|
||||||
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
|
const { generating } = useExamEditorStore(
|
||||||
|
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||||
|
);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(generating && generating == "context");
|
||||||
|
|
||||||
|
const updateRoot = useCallback((updates: Partial<ExamEditorStore>) => {
|
||||||
|
dispatch({ type: 'UPDATE_ROOT', payload: { updates } });
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loading = generating && generating == "context";
|
||||||
|
setLoading(loading);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [generating, updateRoot]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 shadow-inner border border-gray-200 bg-gray-50 rounded-xl">
|
||||||
|
<div className='relative pb-4'>
|
||||||
|
<Header
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
editing={editing}
|
||||||
|
handleSave={onSave}
|
||||||
|
handleDiscard={onDiscard}
|
||||||
|
modeHandle={onEdit}
|
||||||
|
mode={mode}
|
||||||
|
module={module}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="w-full cursor-text px-7 py-8 border-2 border-mti-gray-platinum bg-white rounded-3xl">
|
||||||
|
<div className="flex flex-col items-center justify-center animate-pulse">
|
||||||
|
<span className={`loading loading-infinity w-32 bg-ielts-${currentModule}`} />
|
||||||
|
<span className={`font-bold text-2xl text-ielts-${currentModule}`}>Generating...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
renderContent(editing)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SectionContext;
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ListeningPart } from "@/interfaces/exam";
|
||||||
|
import SectionContext from ".";
|
||||||
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
import { FaFemale, FaMale } from "react-icons/fa";
|
||||||
|
import useSectionEdit from "../../Hooks/useSectionEdit";
|
||||||
|
import ScriptRender from "../../Exercises/Shared/Script";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
|
||||||
|
const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
||||||
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
|
const { genResult, state, generating } = useExamEditorStore(
|
||||||
|
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||||
|
);
|
||||||
|
const listeningPart = state as ListeningPart;
|
||||||
|
|
||||||
|
const [script, setScript] = useState(listeningPart.script);
|
||||||
|
|
||||||
|
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
|
||||||
|
sectionId,
|
||||||
|
mode: "edit",
|
||||||
|
onSave: () => {
|
||||||
|
const newState = { ...listeningPart };
|
||||||
|
newState.script = script;
|
||||||
|
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState } })
|
||||||
|
setEditing(false);
|
||||||
|
},
|
||||||
|
onDiscard: () => {
|
||||||
|
setScript(listeningPart.script);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (genResult !== undefined && generating === "context") {
|
||||||
|
setEditing(true);
|
||||||
|
setScript(genResult[0].script)
|
||||||
|
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: undefined } })
|
||||||
|
}
|
||||||
|
}, [genResult, dispatch, sectionId, setEditing, currentModule]);
|
||||||
|
|
||||||
|
const renderContent = (editing: boolean) => {
|
||||||
|
|
||||||
|
if (script === undefined && !editing) {
|
||||||
|
return (<p className="w-full text-gray-600 px-7 py-8 border-2 bg-white rounded-3xl whitespace-pre-line">
|
||||||
|
Generate or import audio to add exercises!
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-10">
|
||||||
|
<ScriptRender
|
||||||
|
script={script}
|
||||||
|
setScript={setScript}
|
||||||
|
editing={editing}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionContext
|
||||||
|
sectionId={sectionId}
|
||||||
|
title={(sectionId === 1 || sectionId === 3) ? "Conversation" : "Monologue"}
|
||||||
|
description={`Enter the section's ${(sectionId === 1 || sectionId === 3) ? "conversation" : "monologue"} or import your own`}
|
||||||
|
renderContent={renderContent}
|
||||||
|
editing={editing}
|
||||||
|
onSave={handleSave}
|
||||||
|
onEdit={modeHandle}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
module={currentModule}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListeningContext;
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ReadingPart } from "@/interfaces/exam";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||||
|
import Passage from "../../Shared/Passage";
|
||||||
|
import SectionContext from ".";
|
||||||
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
import useSectionEdit from "../../Hooks/useSectionEdit";
|
||||||
|
|
||||||
|
|
||||||
|
const ReadingContext: React.FC<{sectionId: number;}> = ({sectionId}) => {
|
||||||
|
const {currentModule, dispatch } = useExamEditorStore();
|
||||||
|
const { genResult, state, generating } = useExamEditorStore(
|
||||||
|
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||||
|
);
|
||||||
|
const readingPart = state as ReadingPart;
|
||||||
|
|
||||||
|
const [title, setTitle] = useState(readingPart.text.title);
|
||||||
|
const [content, setContent] = useState(readingPart.text.content);
|
||||||
|
const [passageOpen, setPassageOpen] = useState(false);
|
||||||
|
|
||||||
|
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
|
||||||
|
sectionId,
|
||||||
|
mode: "edit",
|
||||||
|
onSave: () => {
|
||||||
|
const newState = {...state} as ReadingPart;
|
||||||
|
newState.text.title = title;
|
||||||
|
newState.text.content = content;
|
||||||
|
dispatch({type: 'UPDATE_SECTION_STATE', payload: {sectionId, update: newState}})
|
||||||
|
setEditing(false);
|
||||||
|
},
|
||||||
|
onDiscard: () => {
|
||||||
|
setTitle(readingPart.text.title);
|
||||||
|
setContent(readingPart.text.content);
|
||||||
|
},
|
||||||
|
onMode: () => {
|
||||||
|
setPassageOpen(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(()=> {
|
||||||
|
if (genResult !== undefined && generating === "context") {
|
||||||
|
setEditing(true);
|
||||||
|
setTitle(genResult[0].title);
|
||||||
|
setContent(genResult[0].text)
|
||||||
|
dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {sectionId, module: currentModule, field: "genResult", value: undefined}})
|
||||||
|
}
|
||||||
|
}, [genResult, dispatch, sectionId, setEditing, currentModule]);
|
||||||
|
|
||||||
|
|
||||||
|
const renderContent = (editing: boolean) => {
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col text-mti-gray-dim p-4 gap-4">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Insert a title here"
|
||||||
|
name="title"
|
||||||
|
label="Title"
|
||||||
|
onChange={setTitle}
|
||||||
|
roundness="xl"
|
||||||
|
defaultValue={title}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Content *</label>
|
||||||
|
<AutoExpandingTextArea
|
||||||
|
value={content}
|
||||||
|
placeholder="Insert a passage here"
|
||||||
|
onChange={(text) => setContent(text)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content === "" || title === "" ? (
|
||||||
|
<p className="w-full text-gray-600 px-7 py-8 border-2 bg-white rounded-3xl whitespace-pre-line">
|
||||||
|
Generate or edit the passage to add exercises!
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Passage
|
||||||
|
title={title}
|
||||||
|
content={content}
|
||||||
|
open={passageOpen}
|
||||||
|
setIsOpen={setPassageOpen}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionContext
|
||||||
|
sectionId={sectionId}
|
||||||
|
title="Reading Passage"
|
||||||
|
description="The reading passage that the exercises will refer to."
|
||||||
|
renderContent={renderContent}
|
||||||
|
editing={editing}
|
||||||
|
onSave={handleSave}
|
||||||
|
onEdit={modeHandle}
|
||||||
|
module={currentModule}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReadingContext;
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
|
import SortableSection from "../../Shared/SortableSection";
|
||||||
|
import getReadingQuestions from '../SectionExercises/reading';
|
||||||
|
import { Exercise, LevelPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
|
||||||
|
import { ReadingExercise } from "./types";
|
||||||
|
import Dropdown from "@/components/Dropdown";
|
||||||
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
import Writing from "../../Exercises/Writing";
|
||||||
|
import Speaking from "../../Exercises/Speaking";
|
||||||
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
DragEndEvent,
|
||||||
|
closestCenter,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import GenLoader from "../../Exercises/Shared/GenLoader";
|
||||||
|
import { ExamPart } from "@/stores/examEditor/types";
|
||||||
|
import getListeningItems from "./listening";
|
||||||
|
import getLevelQuestionItems from "./level";
|
||||||
|
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
sectionId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
||||||
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
|
const { sections, expandedSections } = useExamEditorStore(
|
||||||
|
(state) => state.modules[currentModule]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { genResult, generating, state } = useExamEditorStore(
|
||||||
|
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (genResult !== undefined && generating === "exercises") {
|
||||||
|
const newExercises = genResult[0].exercises;
|
||||||
|
const newState = state as ExamPart;
|
||||||
|
newState.exercises = [...newState.exercises, ...newExercises]
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_SECTION_STATE", payload: {
|
||||||
|
sectionId, update: {
|
||||||
|
exercises: newExercises
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: undefined } })
|
||||||
|
}
|
||||||
|
}, [genResult, dispatch, sectionId, currentModule]);
|
||||||
|
|
||||||
|
const currentSection = sections.find((s) => s.sectionId === sectionId)!;
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
);
|
||||||
|
|
||||||
|
const questionItems = () => {
|
||||||
|
let ids, items;
|
||||||
|
switch (currentModule) {
|
||||||
|
case "reading":
|
||||||
|
items = getReadingQuestions((currentSection.state as ReadingPart).exercises as ReadingExercise[], sectionId);
|
||||||
|
ids = items.map(q => q.id.toString());
|
||||||
|
break;
|
||||||
|
case "listening":
|
||||||
|
items = getListeningItems((currentSection.state as ReadingPart).exercises as ReadingExercise[], sectionId);
|
||||||
|
ids = items.map(q => q?.id.toString());
|
||||||
|
break;
|
||||||
|
case "level":
|
||||||
|
items = getLevelQuestionItems((currentSection.state as LevelPart).exercises as Exercise[], sectionId);
|
||||||
|
ids = items.map(q => q.id.toString());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ids, items }
|
||||||
|
}
|
||||||
|
|
||||||
|
const questions = questionItems();
|
||||||
|
|
||||||
|
const background = (component: ReactNode) => {
|
||||||
|
return (
|
||||||
|
<div className="p-8 shadow-inner border border-gray-200 bg-gray-50 rounded-xl">
|
||||||
|
{component}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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} />);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={(e) => dispatch({ type: "REORDER_EXERCISES", payload: { event: e, sectionId } })}
|
||||||
|
>
|
||||||
|
{(currentModule === "level" && questions.ids?.length === 0) ? (
|
||||||
|
background(<span className="flex justify-center">Generated exercises will appear here!</span>)
|
||||||
|
) : (
|
||||||
|
expandedSections.includes(sectionId) &&
|
||||||
|
questions.items &&
|
||||||
|
questions.items.length > 0 &&
|
||||||
|
questions.ids &&
|
||||||
|
questions.ids.length > 0 && (
|
||||||
|
<div className="mt-4 p-6 rounded-xl shadow-inner border bg-gray-50">
|
||||||
|
<SortableContext
|
||||||
|
items={questions.ids}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{questions.items.map(item => (
|
||||||
|
<SortableSection key={item.id.toString()} id={item.id.toString()}>
|
||||||
|
<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`}
|
||||||
|
customTitle={item.label}
|
||||||
|
contentWrapperClassName="rounded-xl"
|
||||||
|
>
|
||||||
|
<div className="p-4 shadow-inner border border-gray-200 bg-gray-50 rounded-xl">
|
||||||
|
{item.content}
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
</SortableSection>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{generating === "exercises" && <GenLoader module={currentModule} className="mt-4" />}
|
||||||
|
</DndContext >
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SectionExercises;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Exercise } from "@/interfaces/exam";
|
||||||
|
import ExerciseItem from "./types";
|
||||||
|
import ExerciseLabel from "../../Shared/ExerciseLabel";
|
||||||
|
import MultipleChoice from "../../Exercises/MultipleChoice";
|
||||||
|
import FillBlanksMC from "../../Exercises/Blanks/MultipleChoice";
|
||||||
|
|
||||||
|
const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): ExerciseItem[] => {
|
||||||
|
|
||||||
|
const previewLabel = (text: string) => {
|
||||||
|
return text !== undefined ? text.replaceAll('\\n', ' ').split(' ').slice(0, 15).join(' ') : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: ExerciseItem[] = exercises.map((exercise, index) => {
|
||||||
|
let firstWordId, lastWordId;
|
||||||
|
switch (exercise.type) {
|
||||||
|
case "multipleChoice":
|
||||||
|
firstWordId = exercise.questions[0].id;
|
||||||
|
lastWordId = exercise.questions[exercise.questions.length - 1].id;
|
||||||
|
return {
|
||||||
|
id: index,
|
||||||
|
sectionId,
|
||||||
|
label: (
|
||||||
|
<ExerciseLabel
|
||||||
|
label={`Multiple Choice Questions #${firstWordId} - #${lastWordId}`}
|
||||||
|
preview={
|
||||||
|
<>
|
||||||
|
"{previewLabel(exercise.prompt)}..."
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
content: <MultipleChoice exercise={exercise} sectionId={sectionId} />
|
||||||
|
};
|
||||||
|
case "fillBlanks":
|
||||||
|
firstWordId = exercise.solutions[0].id;
|
||||||
|
lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
|
||||||
|
return {
|
||||||
|
id: index,
|
||||||
|
sectionId,
|
||||||
|
label: (
|
||||||
|
<ExerciseLabel
|
||||||
|
label={`Fill Blanks Question #${firstWordId} - #${lastWordId}`}
|
||||||
|
preview={
|
||||||
|
<>
|
||||||
|
"{previewLabel(exercise.prompt)}..."
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
content: <FillBlanksMC exercise={exercise} sectionId={sectionId} />
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {} as unknown as ExerciseItem;
|
||||||
|
}
|
||||||
|
}).filter((item) => item !== undefined);
|
||||||
|
|
||||||
|
return items || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default getLevelQuestionItems;
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import ExerciseItem from './types';
|
||||||
|
import ExerciseLabel from '../../Shared/ExerciseLabel';
|
||||||
|
import FillBlanksLetters from '../../Exercises/Blanks/Letters';
|
||||||
|
import { Exercise, WriteBlanksExercise } from '@/interfaces/exam';
|
||||||
|
import MultipleChoice from '../../Exercises/MultipleChoice';
|
||||||
|
import WriteBlanksForm from '../../Exercises/WriteBlanksForm';
|
||||||
|
import WriteBlanksFill from '../../Exercises/Blanks/WriteBlankFill';
|
||||||
|
|
||||||
|
|
||||||
|
const writeBlanks = (exercise: WriteBlanksExercise, index: number, sectionId: number, previewLabel: (text: string) => string) => {
|
||||||
|
const firstWordId = exercise.solutions[0].id;
|
||||||
|
const lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
|
||||||
|
|
||||||
|
switch (exercise.variant) {
|
||||||
|
case 'form':
|
||||||
|
return {
|
||||||
|
id: index,
|
||||||
|
sectionId,
|
||||||
|
label: (
|
||||||
|
<ExerciseLabel
|
||||||
|
label={`Write Blanks: Form #${firstWordId} - #${lastWordId}`}
|
||||||
|
preview={
|
||||||
|
<>
|
||||||
|
"{previewLabel(exercise.prompt)}..."
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
content: <WriteBlanksForm exercise={exercise} sectionId={sectionId} />
|
||||||
|
};
|
||||||
|
case 'fill':
|
||||||
|
return {
|
||||||
|
id: index,
|
||||||
|
sectionId,
|
||||||
|
label: (
|
||||||
|
<ExerciseLabel
|
||||||
|
label={`Write Blanks: Fill #${firstWordId} - #${lastWordId}`}
|
||||||
|
preview={
|
||||||
|
<>
|
||||||
|
"{previewLabel(exercise.prompt)}..."
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
content: <WriteBlanksFill exercise={exercise} sectionId={sectionId} />
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const getListeningItems = (exercises: Exercise[], sectionId: number) => {
|
||||||
|
|
||||||
|
const previewLabel = (text: string) => {
|
||||||
|
return text !== undefined ? text.replaceAll('\\n', ' ').split(' ').slice(0, 15).join(' ') : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = exercises.map((exercise, index) => {
|
||||||
|
let firstWordId, lastWordId;
|
||||||
|
switch (exercise.type) {
|
||||||
|
case "fillBlanks":
|
||||||
|
firstWordId = exercise.solutions[0].id;
|
||||||
|
lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
|
||||||
|
return {
|
||||||
|
id: index,
|
||||||
|
sectionId,
|
||||||
|
label: (
|
||||||
|
<ExerciseLabel
|
||||||
|
label={`Fill Blanks Question #${firstWordId} - #${lastWordId}`}
|
||||||
|
preview={
|
||||||
|
<>
|
||||||
|
"{previewLabel(exercise.prompt)}..."
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
content: <FillBlanksLetters exercise={exercise} sectionId={sectionId} />
|
||||||
|
|
||||||
|
};
|
||||||
|
case "writeBlanks":
|
||||||
|
return writeBlanks(exercise, index, sectionId, previewLabel);
|
||||||
|
case "multipleChoice":
|
||||||
|
firstWordId = exercise.questions[0].id;
|
||||||
|
lastWordId = exercise.questions[exercise.questions.length - 1].id;
|
||||||
|
return {
|
||||||
|
id: index,
|
||||||
|
sectionId,
|
||||||
|
label: (
|
||||||
|
<ExerciseLabel
|
||||||
|
label={`Multiple Choice Questions #${firstWordId} - #${lastWordId}`}
|
||||||
|
preview={
|
||||||
|
<>
|
||||||
|
"{previewLabel(exercise.prompt)}..."
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
content: <MultipleChoice exercise={exercise} sectionId={sectionId} />
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}).filter((item) => item !== undefined);
|
||||||
|
|
||||||
|
return items || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default getListeningItems;
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import ExerciseItem, { ReadingExercise } from './types';
|
||||||
|
import WriteBlanks from "@/editor/Exercises/WriteBlanks";
|
||||||
|
import ExerciseLabel from '../../Shared/ExerciseLabel';
|
||||||
|
import MatchSentences from '../../Exercises/MatchSentences';
|
||||||
|
import TrueFalse from '../../Exercises/TrueFalse';
|
||||||
|
import FillBlanksLetters from '../../Exercises/Blanks/Letters';
|
||||||
|
|
||||||
|
const getExerciseItems = (exercises: ReadingExercise[], sectionId: number): ExerciseItem[] => {
|
||||||
|
|
||||||
|
const previewLabel = (text: string) => {
|
||||||
|
return text !== undefined ? text.replaceAll('\\n', ' ').split(' ').slice(0, 15).join(' ') : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: ExerciseItem[] = exercises.map((exercise, index) => {
|
||||||
|
let firstWordId, lastWordId;
|
||||||
|
|
||||||
|
switch (exercise.type) {
|
||||||
|
case "fillBlanks":
|
||||||
|
firstWordId = exercise.solutions[0].id;
|
||||||
|
lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
|
||||||
|
return {
|
||||||
|
id: index,
|
||||||
|
sectionId,
|
||||||
|
label: (
|
||||||
|
<ExerciseLabel
|
||||||
|
label={`Fill Blanks Question #${firstWordId} - #${lastWordId}`}
|
||||||
|
preview={
|
||||||
|
<>
|
||||||
|
"{previewLabel(exercise.prompt)}..."
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
content: <FillBlanksLetters exercise={exercise} sectionId={sectionId} />
|
||||||
|
};
|
||||||
|
case "writeBlanks":
|
||||||
|
firstWordId = exercise.solutions[0].id;
|
||||||
|
lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
|
||||||
|
return {
|
||||||
|
id: index,
|
||||||
|
sectionId,
|
||||||
|
label: (
|
||||||
|
<ExerciseLabel
|
||||||
|
label={`Write Blanks Question #${firstWordId} - #${lastWordId}`}
|
||||||
|
preview={
|
||||||
|
<>
|
||||||
|
"{previewLabel(exercise.prompt)}..."
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
content: <WriteBlanks exercise={exercise} sectionId={sectionId} />
|
||||||
|
};
|
||||||
|
case "matchSentences":
|
||||||
|
firstWordId = exercise.sentences[0].id;
|
||||||
|
lastWordId = exercise.sentences[exercise.sentences.length - 1].id;
|
||||||
|
return {
|
||||||
|
id: index,
|
||||||
|
sectionId,
|
||||||
|
label: (
|
||||||
|
<ExerciseLabel
|
||||||
|
label={`${exercise.variant == "ideaMatch" ? "Idea Match" : "Paragraph Match"} ${firstWordId == lastWordId ? `#${firstWordId}` : `#${firstWordId} - #${lastWordId}`}`}
|
||||||
|
preview={
|
||||||
|
<>
|
||||||
|
"{previewLabel(exercise.prompt)}..."
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
content: <MatchSentences exercise={exercise} sectionId={sectionId}/>
|
||||||
|
};
|
||||||
|
case "trueFalse":
|
||||||
|
firstWordId = exercise.questions[0].id
|
||||||
|
lastWordId = exercise.questions[exercise.questions.length - 1].id;
|
||||||
|
return {
|
||||||
|
id: index,
|
||||||
|
sectionId,
|
||||||
|
label: (
|
||||||
|
<ExerciseLabel
|
||||||
|
label={`True/False/Not Given #${firstWordId} - #${lastWordId}`}
|
||||||
|
preview={
|
||||||
|
<>
|
||||||
|
"{previewLabel(exercise.prompt)}..."
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
content: <TrueFalse exercise={exercise} sectionId={sectionId} />
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
}).filter((item) => item !== undefined);
|
||||||
|
|
||||||
|
return items || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default getExerciseItems;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { FillBlanksExercise, MatchSentencesExercise, TrueFalseExercise, WriteBlanksExercise } from "@/interfaces/exam";
|
||||||
|
|
||||||
|
export default interface ExerciseItem {
|
||||||
|
id: number;
|
||||||
|
sectionId: number;
|
||||||
|
label: React.ReactNode;
|
||||||
|
content: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReadingExercise = FillBlanksExercise | TrueFalseExercise | MatchSentencesExercise | WriteBlanksExercise;
|
||||||
99
src/components/ExamEditor/SectionRenderer/index.tsx
Executable file
99
src/components/ExamEditor/SectionRenderer/index.tsx
Executable file
@@ -0,0 +1,99 @@
|
|||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import ReadingContext from './SectionContext/reading';
|
||||||
|
import SectionExercises from './SectionExercises';
|
||||||
|
import useExamEditorStore from '@/stores/examEditor';
|
||||||
|
import { ModuleState } from '@/stores/examEditor/types';
|
||||||
|
import ListeningContext from './SectionContext/listening';
|
||||||
|
import SectionDropdown from '../Shared/SectionDropdown';
|
||||||
|
|
||||||
|
|
||||||
|
const SectionRenderer: React.FC = () => {
|
||||||
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
|
const {
|
||||||
|
focusedSection,
|
||||||
|
expandedSections,
|
||||||
|
sections,
|
||||||
|
sectionLabels,
|
||||||
|
edit,
|
||||||
|
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||||
|
|
||||||
|
const updateModule = useCallback((updates: Partial<ModuleState>) => {
|
||||||
|
dispatch({ type: 'UPDATE_MODULE', payload: { updates } });
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const toggleSection = (sectionId: number) => {
|
||||||
|
if (edit.includes(sectionId)) {
|
||||||
|
toast.info(`Save or discard your changes first!`);
|
||||||
|
} else {
|
||||||
|
if (!expandedSections.includes(sectionId)) {
|
||||||
|
updateModule({ focusedSection: sectionId });
|
||||||
|
}
|
||||||
|
updateModule({
|
||||||
|
expandedSections:
|
||||||
|
expandedSections.includes(sectionId) ?
|
||||||
|
expandedSections.filter(index => index !== sectionId) :
|
||||||
|
[...expandedSections, sectionId]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ContextMap: Record<string, React.ComponentType<{ sectionId: number; }>> = {
|
||||||
|
reading: ReadingContext,
|
||||||
|
listening: ListeningContext,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SectionContext = ContextMap[currentModule];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='flex flex-row'>
|
||||||
|
<div className={clsx(
|
||||||
|
"p-4 rounded-xl w-full",
|
||||||
|
currentModule && `bg-ielts-${currentModule}/20`
|
||||||
|
)}>
|
||||||
|
|
||||||
|
{sections.map((state, sectionIndex) => {
|
||||||
|
const id = state.sectionId;
|
||||||
|
const label = sectionLabels.find((sl) => sl.id == id)?.label;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={id}
|
||||||
|
className={
|
||||||
|
clsx("rounded-xl shadow",
|
||||||
|
sectionIndex !== sections.length - 1 && "mb-4"
|
||||||
|
)}>
|
||||||
|
<SectionDropdown
|
||||||
|
toggleOpen={() => toggleSection(id)}
|
||||||
|
open={expandedSections.includes(id)}
|
||||||
|
title={label}
|
||||||
|
className={clsx(
|
||||||
|
"w-full py-4 px-8 text-lg font-semibold leading-6 text-white",
|
||||||
|
"shadow-lg transform transition-all duration-300 hover:scale-102 hover:rounded-lg",
|
||||||
|
expandedSections.includes(id) ? "rounded-t-lg" : "rounded-lg",
|
||||||
|
focusedSection !== id ?
|
||||||
|
`bg-gradient-to-r from-ielts-${currentModule}/30 to-ielts-${currentModule}/60 hover:from-ielts-${currentModule}/60 hover:to-ielts-${currentModule}` :
|
||||||
|
`bg-ielts-${currentModule}`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{expandedSections.includes(id) && (
|
||||||
|
<div
|
||||||
|
className="p-6 bg-white rounded-b-xl shadow-inner border-b"
|
||||||
|
onFocus={() => updateModule({ focusedSection: id })}
|
||||||
|
tabIndex={id + 1}
|
||||||
|
>
|
||||||
|
{currentModule in ContextMap && <SectionContext sectionId={id} />}
|
||||||
|
<SectionExercises sectionId={id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SectionDropdown>
|
||||||
|
</div>);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SectionRenderer;
|
||||||
19
src/components/ExamEditor/SectionRenderer/types.ts
Normal file
19
src/components/ExamEditor/SectionRenderer/types.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import { GeneratedExercises, GeneratorState } from "../Shared/ExercisePicker/generatedExercises";
|
||||||
|
import { SectionState } from "@/stores/examEditor/types";
|
||||||
|
|
||||||
|
|
||||||
|
export interface SectionRendererProps {
|
||||||
|
module: Module;
|
||||||
|
sectionLabel: string;
|
||||||
|
states: SectionState[];
|
||||||
|
globalEdit: number[];
|
||||||
|
generatedExercises: GeneratedExercises | undefined;
|
||||||
|
generating: GeneratorState | undefined;
|
||||||
|
focusedSection: number;
|
||||||
|
setGeneratedExercises: React.Dispatch<React.SetStateAction<GeneratedExercises | undefined>>;
|
||||||
|
setGenerating: React.Dispatch<React.SetStateAction<GeneratorState | undefined>>;
|
||||||
|
setGlobalEdit: React.Dispatch<React.SetStateAction<number[]>>;
|
||||||
|
setSectionStates: React.Dispatch<React.SetStateAction<SectionState[]>>;
|
||||||
|
setFocusedSection: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
}
|
||||||
61
src/components/ExamEditor/SettingsEditor/Shared/Generate.ts
Normal file
61
src/components/ExamEditor/SettingsEditor/Shared/Generate.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { playSound } from "@/utils/sound";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { Generating } from "@/stores/examEditor/types";
|
||||||
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
|
||||||
|
interface GeneratorConfig {
|
||||||
|
method: 'GET' | 'POST';
|
||||||
|
queryParams?: Record<string, string>;
|
||||||
|
body?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generate(
|
||||||
|
sectionId: number,
|
||||||
|
module: Module,
|
||||||
|
type: "context" | "exercises",
|
||||||
|
config: GeneratorConfig,
|
||||||
|
mapData: (data: any) => Record<string, any>[]
|
||||||
|
) {
|
||||||
|
const dispatch = useExamEditorStore.getState().dispatch;
|
||||||
|
|
||||||
|
const setGenerating = (sectionId: number, generating: Generating) => {
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||||
|
payload: { sectionId, module, field: "generating", value: generating }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setGeneratedExercises = (sectionId: number, exercises: Record<string, any>[] | undefined) => {
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||||
|
payload: { sectionId, module, field: "genResult", value: exercises }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
setGenerating(sectionId, type);
|
||||||
|
|
||||||
|
const queryString = config.queryParams
|
||||||
|
? new URLSearchParams(config.queryParams).toString()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const url = `/api/exam/generate/${module}/${sectionId}${queryString ? `?${queryString}` : ''}`;
|
||||||
|
|
||||||
|
const request = config.method === 'POST'
|
||||||
|
? axios.post(url, config.body)
|
||||||
|
: axios.get(url);
|
||||||
|
|
||||||
|
request
|
||||||
|
.then((result) => {
|
||||||
|
playSound("check");
|
||||||
|
setGeneratedExercises(sectionId, mapData(result.data));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
playSound("error");
|
||||||
|
toast.error("Something went wrong! Try to generate again.");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setGenerating(sectionId, undefined);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
import { Generating } from "@/stores/examEditor/types";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { BsArrowRepeat } from "react-icons/bs";
|
||||||
|
import { GiBrain } from "react-icons/gi";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
module: Module;
|
||||||
|
sectionId: number;
|
||||||
|
genType: Generating;
|
||||||
|
generateFnc: (sectionId: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const GenerateBtn: React.FC<Props> = ({module, sectionId, genType, generateFnc}) => {
|
||||||
|
const {generating} = useExamEditorStore((store) => store.modules[module].sections.find((s)=> s.sectionId == sectionId))!;
|
||||||
|
const loading = generating && generating === genType;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`section-${sectionId}`}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center w-[140px] justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300 text-lg",
|
||||||
|
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module}`
|
||||||
|
)}
|
||||||
|
onClick={loading ? () => { } : () => generateFnc(sectionId)}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div key={`section-${sectionId}`} className="flex items-center justify-center">
|
||||||
|
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div key={`section-${sectionId}`} className="flex flex-row">
|
||||||
|
<GiBrain className="mr-2" size={24} />
|
||||||
|
<span>Generate</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GenerateBtn;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import Dropdown from "@/components/Dropdown";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
module: string;
|
||||||
|
title: string;
|
||||||
|
open: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingsDropdown: React.FC<Props> = ({ module, title, open, setIsOpen, children, disabled = false }) => {
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
title={title}
|
||||||
|
className={clsx(
|
||||||
|
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border text-white shadow-md transition-all duration-300 disabled:cursor-not-allowed",
|
||||||
|
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/30`,
|
||||||
|
open ? "rounded-t-lg" : "rounded-lg"
|
||||||
|
)}
|
||||||
|
contentWrapperClassName="pt-6 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"
|
||||||
|
open={open}
|
||||||
|
setIsOpen={setIsOpen}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SettingsDropdown;
|
||||||
140
src/components/ExamEditor/SettingsEditor/index.tsx
Normal file
140
src/components/ExamEditor/SettingsEditor/index.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import React, { ReactNode, useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||||
|
import { FaEye } from "react-icons/fa";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||||
|
import Option from '@/interfaces/option'
|
||||||
|
import Dropdown from "./Shared/SettingsDropdown";
|
||||||
|
import useSettingsState from "../Hooks/useSettingsState";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import { SectionSettings } from "@/stores/examEditor/types";
|
||||||
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
|
||||||
|
interface SettingsEditorProps {
|
||||||
|
sectionId: number,
|
||||||
|
sectionLabel: string;
|
||||||
|
module: Module,
|
||||||
|
introPresets: Option[];
|
||||||
|
children?: ReactNode;
|
||||||
|
canPreview: boolean;
|
||||||
|
preview: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingsEditor: React.FC<SettingsEditorProps> = ({
|
||||||
|
sectionId,
|
||||||
|
sectionLabel,
|
||||||
|
module,
|
||||||
|
introPresets,
|
||||||
|
children,
|
||||||
|
preview,
|
||||||
|
canPreview,
|
||||||
|
}) => {
|
||||||
|
const examLabel = useExamEditorStore((state) => state.modules[module].examLabel) || '';
|
||||||
|
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<SectionSettings>(
|
||||||
|
module,
|
||||||
|
sectionId
|
||||||
|
);
|
||||||
|
|
||||||
|
const options = useMemo(() => [
|
||||||
|
{ value: 'None', label: 'None' },
|
||||||
|
...introPresets,
|
||||||
|
{ value: 'Custom', label: 'Custom' }
|
||||||
|
], [introPresets]);
|
||||||
|
|
||||||
|
const onCategoryChange = useCallback((text: string) => {
|
||||||
|
updateLocalAndScheduleGlobal({ category: text });
|
||||||
|
}, [updateLocalAndScheduleGlobal]);
|
||||||
|
|
||||||
|
const onIntroOptionChange = useCallback((option: { value: string | null, label: string }) => {
|
||||||
|
let updates: Partial<SectionSettings> = { introOption: option };
|
||||||
|
|
||||||
|
switch (option.label) {
|
||||||
|
case 'None':
|
||||||
|
updates.currentIntro = undefined;
|
||||||
|
break;
|
||||||
|
case 'Custom':
|
||||||
|
updates.currentIntro = localSettings.customIntro;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
const selectedPreset = introPresets.find(preset => preset.label === option.label);
|
||||||
|
if (selectedPreset) {
|
||||||
|
updates.currentIntro = selectedPreset.value!
|
||||||
|
.replace('{part}', sectionLabel)
|
||||||
|
.replace('{label}', examLabel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLocalAndScheduleGlobal(updates);
|
||||||
|
}, [updateLocalAndScheduleGlobal, localSettings.customIntro, introPresets, sectionLabel, examLabel]);
|
||||||
|
|
||||||
|
const onCustomIntroChange = useCallback((text: string) => {
|
||||||
|
updateLocalAndScheduleGlobal({
|
||||||
|
introOption: { value: 'Custom', label: 'Custom' },
|
||||||
|
customIntro: text,
|
||||||
|
currentIntro: text
|
||||||
|
});
|
||||||
|
}, [updateLocalAndScheduleGlobal]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Dropdown
|
||||||
|
title="Category"
|
||||||
|
module={module}
|
||||||
|
open={localSettings.isCategoryDropdownOpen}
|
||||||
|
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isCategoryDropdownOpen: isOpen })}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
key={`section-${sectionId}`}
|
||||||
|
type="text"
|
||||||
|
placeholder="Category"
|
||||||
|
name="category"
|
||||||
|
onChange={onCategoryChange}
|
||||||
|
roundness="full"
|
||||||
|
value={localSettings.category || ''}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
<Dropdown
|
||||||
|
title="Divider"
|
||||||
|
module={module}
|
||||||
|
open={localSettings.isIntroDropdownOpen}
|
||||||
|
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isIntroDropdownOpen: isOpen })}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<Select
|
||||||
|
options={options}
|
||||||
|
onChange={(o) => onIntroOptionChange({ value: o!.value, label: o!.label })}
|
||||||
|
value={localSettings.introOption}
|
||||||
|
/>
|
||||||
|
{localSettings.introOption.value !== "None" && (
|
||||||
|
<AutoExpandingTextArea
|
||||||
|
key={`section-${sectionId}`}
|
||||||
|
value={localSettings.currentIntro || ''}
|
||||||
|
onChange={onCustomIntroChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
{children}
|
||||||
|
<div className="flex flex-row justify-center mt-4">
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300",
|
||||||
|
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/30`,
|
||||||
|
"disabled:cursor-not-allowed disabled:text-gray-200"
|
||||||
|
)}
|
||||||
|
onClick={preview}
|
||||||
|
disabled={!canPreview}
|
||||||
|
>
|
||||||
|
<FaEye className="mr-2" size={18} />
|
||||||
|
Preview Module
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsEditor;
|
||||||
88
src/components/ExamEditor/SettingsEditor/level.tsx
Normal file
88
src/components/ExamEditor/SettingsEditor/level.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { Exercise, LevelExam, LevelPart } from "@/interfaces/exam";
|
||||||
|
import SettingsEditor from ".";
|
||||||
|
import Option from "@/interfaces/option";
|
||||||
|
import Dropdown from "@/components/Dropdown";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import ExercisePicker from "../Shared/ExercisePicker";
|
||||||
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
import useSettingsState from "../Hooks/useSettingsState";
|
||||||
|
import { SectionSettings } from "@/stores/examEditor/types";
|
||||||
|
|
||||||
|
|
||||||
|
const LevelSettings: React.FC = () => {
|
||||||
|
|
||||||
|
const {currentModule } = useExamEditorStore();
|
||||||
|
const {
|
||||||
|
focusedSection,
|
||||||
|
difficulty,
|
||||||
|
sections,
|
||||||
|
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||||
|
|
||||||
|
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<SectionSettings>(
|
||||||
|
currentModule,
|
||||||
|
focusedSection
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentSection = sections.find((section) => section.sectionId == focusedSection)!.state as LevelPart;
|
||||||
|
|
||||||
|
const defaultPresets: Option[] = [
|
||||||
|
{
|
||||||
|
label: "Preset: Multiple Choice",
|
||||||
|
value: "Not available."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Preset: Multiple Choice - Blank Space",
|
||||||
|
value: "Welcome to {part} of the {label}. In this section, you'll be asked to select the correct word or group of words that best completes each sentence.\n\nFor each question, carefully read the sentence and click on the option (A, B, C, or D) that you believe is correct. After making your selection, you can proceed to the next question by clicking \"Next\". If you need to review or change your previous answers, you can go back at any time by clicking \"Back\".",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Preset: Multiple Choice - Underlined",
|
||||||
|
value: "Welcome to {part} of the {label}. In this section, you'll be asked to identify the underlined word or group of words that is not correct in each sentence.\n\nFor each question, carefully review the sentence and click on the option (A, B, C, or D) that you believe contains the incorrect word or group of words. After making your selection, you can proceed to the next question by clicking \"Next\". If needed, you can go back to previous questions by clicking \"Back\"."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Preset: Blank Space",
|
||||||
|
value: "Not available."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Preset: Reading Passage",
|
||||||
|
value: "Welcome to {part} of the {label}. In this section, you will read a text and answer the questions that follow.\n\nCarefully read the provided text, then select the correct answer (A, B, C, or D) for each question. After making your selection, you can proceed to the next question by clicking \"Next\". If you need to review or change your answers, you can go back at any time by clicking \"Back\"."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Preset: Multiple Choice - Fill Blanks",
|
||||||
|
value: "Welcome to {part} of the {label}. In this section, you will read a text and choose the correct word to fill in each blank space.\n\nFor each question, carefully read the text and click on the option that you believe best fits the context."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsEditor
|
||||||
|
sectionLabel={`Part ${focusedSection}`}
|
||||||
|
sectionId={focusedSection}
|
||||||
|
module="level"
|
||||||
|
introPresets={defaultPresets}
|
||||||
|
preview={()=>{}}
|
||||||
|
canPreview={false}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Dropdown title="Add Exercises" className={
|
||||||
|
clsx(
|
||||||
|
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
|
||||||
|
"bg-ielts-level/70 border-ielts-level hover:bg-ielts-level",
|
||||||
|
"text-white shadow-md transition-all duration-300",
|
||||||
|
localSettings.isExerciseDropdownOpen ? "rounded-t-lg" : "rounded-lg"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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 })}
|
||||||
|
>
|
||||||
|
<ExercisePicker
|
||||||
|
module="level"
|
||||||
|
sectionId={focusedSection}
|
||||||
|
difficulty={difficulty}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</SettingsEditor>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LevelSettings;
|
||||||
135
src/components/ExamEditor/SettingsEditor/listening.tsx
Normal file
135
src/components/ExamEditor/SettingsEditor/listening.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import Dropdown from "./Shared/SettingsDropdown";
|
||||||
|
import ExercisePicker from "../Shared/ExercisePicker";
|
||||||
|
import SettingsEditor from ".";
|
||||||
|
import GenerateBtn from "./Shared/GenerateBtn";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { generate } from "./Shared/Generate";
|
||||||
|
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 Input from "@/components/Low/Input";
|
||||||
|
|
||||||
|
const ListeningSettings: React.FC = () => {
|
||||||
|
const {currentModule } = useExamEditorStore();
|
||||||
|
const {
|
||||||
|
focusedSection,
|
||||||
|
difficulty,
|
||||||
|
sections,
|
||||||
|
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||||
|
|
||||||
|
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<ListeningSectionSettings>(
|
||||||
|
currentModule,
|
||||||
|
focusedSection
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentSection = sections.find((section) => section.sectionId == focusedSection)!.state as ListeningPart;
|
||||||
|
|
||||||
|
const defaultPresets: Option[] = [
|
||||||
|
{
|
||||||
|
label: "Preset: Writing Task 1",
|
||||||
|
value: "Welcome to {part} of the {label}. You will write a letter of at least 150 words in response to a given situation. Your letter may be personal, semi-formal, or formal. You have 20 minutes for this task."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Preset: Writing Task 2",
|
||||||
|
value: "Welcome to {part} of the {label}. You will write a semi-formal/neutral essay of at least 250 words on a topic of general interest. Discuss the given opinion, argument, or problem. Organize your ideas clearly and support them with relevant examples. You have 40 minutes for this task."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
const generateScript = useCallback(() => {
|
||||||
|
generate(
|
||||||
|
focusedSection,
|
||||||
|
currentModule,
|
||||||
|
"context",
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
queryParams: {
|
||||||
|
difficulty,
|
||||||
|
...(localSettings.topic && { topic: localSettings.topic })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(data: any) => [{
|
||||||
|
script: data.dialog
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [localSettings.topic, difficulty, focusedSection]);
|
||||||
|
|
||||||
|
const onTopicChange = useCallback((topic: string) => {
|
||||||
|
updateLocalAndScheduleGlobal({ topic });
|
||||||
|
}, [updateLocalAndScheduleGlobal]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsEditor
|
||||||
|
sectionLabel={`Section ${focusedSection}`}
|
||||||
|
sectionId={focusedSection}
|
||||||
|
module="listening"
|
||||||
|
introPresets={[defaultPresets[focusedSection - 1]]}
|
||||||
|
preview={()=> {}}
|
||||||
|
canPreview={false}
|
||||||
|
>
|
||||||
|
<Dropdown
|
||||||
|
title="Audio Context"
|
||||||
|
module={currentModule}
|
||||||
|
open={localSettings.isAudioContextOpen}
|
||||||
|
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen })}
|
||||||
|
>
|
||||||
|
<div className="flex flex-row gap-2 items-center px-2 pb-4">
|
||||||
|
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
|
||||||
|
<Input
|
||||||
|
key={`section-${focusedSection}`}
|
||||||
|
type="text"
|
||||||
|
placeholder="Topic"
|
||||||
|
name="category"
|
||||||
|
onChange={onTopicChange}
|
||||||
|
roundness="full"
|
||||||
|
value={localSettings.topic}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex self-end h-16 mb-1">
|
||||||
|
<GenerateBtn
|
||||||
|
module={currentModule}
|
||||||
|
genType="context"
|
||||||
|
sectionId={focusedSection}
|
||||||
|
generateFnc={generateScript}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
<Dropdown
|
||||||
|
title="Add Exercises"
|
||||||
|
module={currentModule}
|
||||||
|
open={localSettings.isExerciseDropdownOpen}
|
||||||
|
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen })}
|
||||||
|
disabled={currentSection.script === undefined && currentSection.audio === undefined}
|
||||||
|
>
|
||||||
|
<ExercisePicker
|
||||||
|
module="listening"
|
||||||
|
sectionId={focusedSection}
|
||||||
|
difficulty={difficulty}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
{/*
|
||||||
|
<Dropdown
|
||||||
|
title="Generate Audio"
|
||||||
|
module={currentModule}
|
||||||
|
open={localSettings.isExerciseDropdownOpen}
|
||||||
|
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen })}
|
||||||
|
disabled={currentSection.script === undefined && currentSection.audio === undefined}
|
||||||
|
>
|
||||||
|
<ExercisePicker
|
||||||
|
module="listening"
|
||||||
|
sectionId={focusedSection}
|
||||||
|
selectedExercises={selectedExercises}
|
||||||
|
setSelectedExercises={setSelectedExercises}
|
||||||
|
difficulty={difficulty}
|
||||||
|
/>
|
||||||
|
</Dropdown>*/}
|
||||||
|
</SettingsEditor>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListeningSettings;
|
||||||
131
src/components/ExamEditor/SettingsEditor/reading.tsx
Normal file
131
src/components/ExamEditor/SettingsEditor/reading.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
import SettingsEditor from ".";
|
||||||
|
import Option from "@/interfaces/option";
|
||||||
|
import Dropdown from "./Shared/SettingsDropdown";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import ExercisePicker from "../Shared/ExercisePicker";
|
||||||
|
import { generate } from "./Shared/Generate";
|
||||||
|
import GenerateBtn from "./Shared/GenerateBtn";
|
||||||
|
import useSettingsState from "../Hooks/useSettingsState";
|
||||||
|
import { ReadingPart } from "@/interfaces/exam";
|
||||||
|
import { ReadingSectionSettings } from "@/stores/examEditor/types";
|
||||||
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
import openDetachedTab from "@/utils/popout";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { usePersistentExamStore } from "@/stores/examStore";
|
||||||
|
|
||||||
|
const ReadingSettings: React.FC = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { currentModule } = useExamEditorStore();
|
||||||
|
const {
|
||||||
|
focusedSection,
|
||||||
|
difficulty,
|
||||||
|
sections,
|
||||||
|
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||||
|
|
||||||
|
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<ReadingSectionSettings>(
|
||||||
|
currentModule,
|
||||||
|
focusedSection
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentSection = sections.find((section) => section.sectionId == focusedSection)!.state as ReadingPart;
|
||||||
|
|
||||||
|
const defaultPresets: Option[] = [
|
||||||
|
{
|
||||||
|
label: "Preset: Writing Task 1",
|
||||||
|
value: "Welcome to {part} of the {label}. You will write a letter of at least 150 words in response to a given situation. Your letter may be personal, semi-formal, or formal. You have 20 minutes for this task."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Preset: Writing Task 2",
|
||||||
|
value: "Welcome to {part} of the {label}. You will write a semi-formal/neutral essay of at least 250 words on a topic of general interest. Discuss the given opinion, argument, or problem. Organize your ideas clearly and support them with relevant examples. You have 40 minutes for this task."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
const generatePassage = useCallback(() => {
|
||||||
|
generate(
|
||||||
|
focusedSection,
|
||||||
|
currentModule,
|
||||||
|
"context",
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
queryParams: {
|
||||||
|
difficulty,
|
||||||
|
...(localSettings.topic && { topic: localSettings.topic })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(data: any) => [{
|
||||||
|
title: data.title,
|
||||||
|
text: data.text
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [localSettings.topic, difficulty, focusedSection]);
|
||||||
|
|
||||||
|
const onTopicChange = useCallback((topic: string) => {
|
||||||
|
updateLocalAndScheduleGlobal({ topic });
|
||||||
|
}, [updateLocalAndScheduleGlobal]);
|
||||||
|
|
||||||
|
|
||||||
|
const canPreview = sections.some(
|
||||||
|
(s) => (s.state as ReadingPart).exercises && (s.state as ReadingPart).exercises.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsEditor
|
||||||
|
sectionLabel={`Passage ${focusedSection}`}
|
||||||
|
sectionId={focusedSection}
|
||||||
|
module="reading"
|
||||||
|
introPresets={[defaultPresets[focusedSection - 1]]}
|
||||||
|
preview={() => { }}
|
||||||
|
canPreview={canPreview}
|
||||||
|
>
|
||||||
|
<Dropdown
|
||||||
|
title="Generate Passage"
|
||||||
|
module={currentModule}
|
||||||
|
open={localSettings.isPassageOpen}
|
||||||
|
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isPassageOpen: isOpen })}
|
||||||
|
>
|
||||||
|
<div className="flex flex-row gap-2 items-center px-2 pb-4">
|
||||||
|
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
|
||||||
|
<Input
|
||||||
|
key={`section-${focusedSection}`}
|
||||||
|
type="text"
|
||||||
|
placeholder="Topic"
|
||||||
|
name="category"
|
||||||
|
onChange={onTopicChange}
|
||||||
|
roundness="full"
|
||||||
|
value={localSettings.topic}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex self-end h-16 mb-1">
|
||||||
|
<GenerateBtn
|
||||||
|
module={currentModule}
|
||||||
|
genType="context"
|
||||||
|
sectionId={focusedSection}
|
||||||
|
generateFnc={generatePassage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
<Dropdown
|
||||||
|
title="Add Exercises"
|
||||||
|
module={currentModule}
|
||||||
|
open={localSettings.isExerciseDropdownOpen}
|
||||||
|
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen })}
|
||||||
|
disabled={currentSection.text.content === "" || currentSection.text.title === ""}
|
||||||
|
>
|
||||||
|
<ExercisePicker
|
||||||
|
module="reading"
|
||||||
|
sectionId={focusedSection}
|
||||||
|
difficulty={difficulty}
|
||||||
|
extraArgs={{ text: currentSection.text.content }}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</SettingsEditor>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReadingSettings;
|
||||||
157
src/components/ExamEditor/SettingsEditor/speaking.tsx
Normal file
157
src/components/ExamEditor/SettingsEditor/speaking.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
import useSettingsState from "../Hooks/useSettingsState";
|
||||||
|
import { SpeakingSectionSettings } from "@/stores/examEditor/types";
|
||||||
|
import Option from "@/interfaces/option";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { generate } from "./Shared/Generate";
|
||||||
|
import SettingsEditor from ".";
|
||||||
|
import Dropdown from "./Shared/SettingsDropdown";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import GenerateBtn from "./Shared/GenerateBtn";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
|
||||||
|
const SpeakingSettings: React.FC = () => {
|
||||||
|
|
||||||
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
|
const { focusedSection, difficulty } = useExamEditorStore((store) => store.modules[currentModule])
|
||||||
|
|
||||||
|
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<SpeakingSectionSettings>(
|
||||||
|
currentModule,
|
||||||
|
focusedSection,
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultPresets: Option[] = [
|
||||||
|
{
|
||||||
|
label: "Preset: Writing Task 1",
|
||||||
|
value: "Welcome to {part} of the {label}. You will write a letter of at least 150 words in response to a given situation. Your letter may be personal, semi-formal, or formal. You have 20 minutes for this task."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Preset: Writing Task 2",
|
||||||
|
value: "Welcome to {part} of the {label}. You will write a semi-formal/neutral essay of at least 250 words on a topic of general interest. Discuss the given opinion, argument, or problem. Organize your ideas clearly and support them with relevant examples. You have 40 minutes for this task."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const generateScript = useCallback((sectionId: number) => {
|
||||||
|
|
||||||
|
const queryParams: {
|
||||||
|
difficulty: string;
|
||||||
|
first_topic?: string;
|
||||||
|
second_topic?: string;
|
||||||
|
topic?: string;
|
||||||
|
} = { difficulty };
|
||||||
|
|
||||||
|
if (sectionId === 1) {
|
||||||
|
if (localSettings.topic) {
|
||||||
|
queryParams['first_topic'] = localSettings.topic;
|
||||||
|
}
|
||||||
|
if (localSettings.secondTopic) {
|
||||||
|
queryParams['second_topic'] = localSettings.secondTopic;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (localSettings.topic) {
|
||||||
|
queryParams['topic'] = localSettings.topic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generate(
|
||||||
|
sectionId,
|
||||||
|
currentModule,
|
||||||
|
"context",
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
queryParams
|
||||||
|
},
|
||||||
|
(data: any) => {
|
||||||
|
switch (sectionId) {
|
||||||
|
case 1:
|
||||||
|
return [{
|
||||||
|
questions: data.questions,
|
||||||
|
firstTopic: data.first_topic,
|
||||||
|
secondTopic: data.second_topic
|
||||||
|
}];
|
||||||
|
case 2:
|
||||||
|
return [{
|
||||||
|
topic: data.topic,
|
||||||
|
question: data.question,
|
||||||
|
prompts: data.prompts,
|
||||||
|
suffix: data.suffix
|
||||||
|
}];
|
||||||
|
case 3:
|
||||||
|
return [{
|
||||||
|
topic: data.topic,
|
||||||
|
questions: data.questions
|
||||||
|
}];
|
||||||
|
default:
|
||||||
|
return [data];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [localSettings, difficulty]);
|
||||||
|
|
||||||
|
const onTopicChange = useCallback((topic: string) => {
|
||||||
|
updateLocalAndScheduleGlobal({ topic });
|
||||||
|
}, [updateLocalAndScheduleGlobal]);
|
||||||
|
|
||||||
|
const onSecondTopicChange = useCallback((topic: string) => {
|
||||||
|
updateLocalAndScheduleGlobal({ });
|
||||||
|
}, [updateLocalAndScheduleGlobal]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsEditor
|
||||||
|
sectionLabel={`Speaking ${focusedSection}`}
|
||||||
|
sectionId={focusedSection}
|
||||||
|
module="speaking"
|
||||||
|
introPresets={[defaultPresets[focusedSection - 1]]}
|
||||||
|
preview={() => { }}
|
||||||
|
canPreview={false}
|
||||||
|
>
|
||||||
|
<Dropdown
|
||||||
|
title="Generate Script"
|
||||||
|
module={currentModule}
|
||||||
|
open={localSettings.isExerciseDropdownOpen}
|
||||||
|
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen })}
|
||||||
|
>
|
||||||
|
|
||||||
|
<div className={clsx("gap-2 px-2 pb-4", focusedSection === 1 ? "flex flex-col w-full" : "flex flex-row items-center" )}>
|
||||||
|
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">{`${focusedSection === 1 ? "First Topic" : "Topic"}`} (Optional)</label>
|
||||||
|
<Input
|
||||||
|
key={`section-${focusedSection}`}
|
||||||
|
type="text"
|
||||||
|
placeholder="Topic"
|
||||||
|
name="category"
|
||||||
|
onChange={onTopicChange}
|
||||||
|
roundness="full"
|
||||||
|
value={localSettings.topic}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{focusedSection === 1 &&
|
||||||
|
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Second Topic (Optional)</label>
|
||||||
|
<Input
|
||||||
|
key={`section-${focusedSection}`}
|
||||||
|
type="text"
|
||||||
|
placeholder="Topic"
|
||||||
|
name="category"
|
||||||
|
onChange={onSecondTopicChange}
|
||||||
|
roundness="full"
|
||||||
|
value={localSettings.secondTopic}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div className={clsx("flex h-16 mb-1", focusedSection === 1 ? "justify-center mt-4" : "self-end")}>
|
||||||
|
<GenerateBtn
|
||||||
|
module={currentModule}
|
||||||
|
genType="context"
|
||||||
|
sectionId={focusedSection}
|
||||||
|
generateFnc={generateScript}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
</SettingsEditor>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SpeakingSettings;
|
||||||
139
src/components/ExamEditor/SettingsEditor/writing.tsx
Normal file
139
src/components/ExamEditor/SettingsEditor/writing.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import SettingsEditor from ".";
|
||||||
|
import Option from "@/interfaces/option";
|
||||||
|
import Dropdown from "./Shared/SettingsDropdown";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import { generate } from "./Shared/Generate";
|
||||||
|
import useSettingsState from "../Hooks/useSettingsState";
|
||||||
|
import GenerateBtn from "./Shared/GenerateBtn";
|
||||||
|
import { SectionSettings } from "@/stores/examEditor/types";
|
||||||
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { usePersistentExamStore } from "@/stores/examStore";
|
||||||
|
import openDetachedTab from "@/utils/popout";
|
||||||
|
import { WritingExercise } from "@/interfaces/exam";
|
||||||
|
import { v4 } from "uuid";
|
||||||
|
|
||||||
|
const WritingSettings: React.FC = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [preview, setPreview] = useState({canPreview: false, openTab: () => {}});
|
||||||
|
const { currentModule } = useExamEditorStore();
|
||||||
|
const {
|
||||||
|
minTimer,
|
||||||
|
difficulty,
|
||||||
|
isPrivate,
|
||||||
|
sections,
|
||||||
|
focusedSection,
|
||||||
|
} = useExamEditorStore((store) => store.modules["writing"]);
|
||||||
|
|
||||||
|
const states = sections.flatMap((s)=> s.state) as WritingExercise[];
|
||||||
|
|
||||||
|
const {
|
||||||
|
setExam,
|
||||||
|
setExerciseIndex,
|
||||||
|
} = usePersistentExamStore();
|
||||||
|
|
||||||
|
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<SectionSettings>(
|
||||||
|
currentModule,
|
||||||
|
focusedSection,
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultPresets: Option[] = [
|
||||||
|
{
|
||||||
|
label: "Preset: Writing Task 1",
|
||||||
|
value: "Welcome to {part} of the {label}. You will write a letter of at least 150 words in response to a given situation. Your letter may be personal, semi-formal, or formal. You have 20 minutes for this task."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Preset: Writing Task 2",
|
||||||
|
value: "Welcome to {part} of the {label}. You will write a semi-formal/neutral essay of at least 250 words on a topic of general interest. Discuss the given opinion, argument, or problem. Organize your ideas clearly and support them with relevant examples. You have 40 minutes for this task."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const generatePassage = useCallback((sectionId: number) => {
|
||||||
|
generate(
|
||||||
|
sectionId,
|
||||||
|
currentModule,
|
||||||
|
"context",
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
queryParams: {
|
||||||
|
difficulty,
|
||||||
|
...(localSettings.topic && { topic: localSettings.topic })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(data: any) => [{
|
||||||
|
prompt: data.question
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [localSettings.topic, difficulty]);
|
||||||
|
|
||||||
|
const onTopicChange = useCallback((topic: string) => {
|
||||||
|
updateLocalAndScheduleGlobal({ topic });
|
||||||
|
}, [updateLocalAndScheduleGlobal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const openTab = () => {
|
||||||
|
setExam({
|
||||||
|
isDiagnostic: false,
|
||||||
|
minTimer,
|
||||||
|
module: "writing",
|
||||||
|
exercises: states.filter((s) => s.prompt && s.prompt !== ""),
|
||||||
|
id: v4(),
|
||||||
|
variant: undefined,
|
||||||
|
difficulty,
|
||||||
|
private: isPrivate,
|
||||||
|
});
|
||||||
|
setExerciseIndex(0);
|
||||||
|
openDetachedTab("popout?type=Exam&module=writing", router)
|
||||||
|
}
|
||||||
|
setPreview({
|
||||||
|
canPreview: states.some((s) => s.prompt && s.prompt !== ""),
|
||||||
|
openTab
|
||||||
|
})
|
||||||
|
}, [states.some((s) => s.prompt !== "")])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsEditor
|
||||||
|
sectionLabel={`Task ${focusedSection}`}
|
||||||
|
sectionId={focusedSection}
|
||||||
|
module="writing"
|
||||||
|
introPresets={[defaultPresets[focusedSection - 1]]}
|
||||||
|
preview={preview.openTab}
|
||||||
|
canPreview={preview.canPreview}
|
||||||
|
>
|
||||||
|
<Dropdown
|
||||||
|
title="Generate Instructions"
|
||||||
|
module={currentModule}
|
||||||
|
open={localSettings.isExerciseDropdownOpen}
|
||||||
|
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen })}
|
||||||
|
>
|
||||||
|
|
||||||
|
<div className="flex flex-row gap-2 items-center px-2 pb-4">
|
||||||
|
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
|
||||||
|
<Input
|
||||||
|
key={`section-${focusedSection}`}
|
||||||
|
type="text"
|
||||||
|
placeholder="Topic"
|
||||||
|
name="category"
|
||||||
|
onChange={onTopicChange}
|
||||||
|
roundness="full"
|
||||||
|
value={localSettings.topic}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex self-end h-16 mb-1">
|
||||||
|
<GenerateBtn
|
||||||
|
genType="context"
|
||||||
|
module={currentModule}
|
||||||
|
sectionId={focusedSection}
|
||||||
|
generateFnc={generatePassage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
</SettingsEditor>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WritingSettings;
|
||||||
44
src/components/ExamEditor/Shared/AudioEdit.tsx
Normal file
44
src/components/ExamEditor/Shared/AudioEdit.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
interface AudioFile {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const Waveform = dynamic(() => import("@/components/Waveform"), {ssr: false});
|
||||||
|
|
||||||
|
const MultipleAudioUploader = () => {
|
||||||
|
const [audioFiles, setAudioFiles] = useState<AudioFile[]>([]);
|
||||||
|
|
||||||
|
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(event.target.files || []);
|
||||||
|
const newAudioFiles = files.map((file) => ({
|
||||||
|
id: URL.createObjectURL(file),
|
||||||
|
file,
|
||||||
|
}));
|
||||||
|
setAudioFiles((prev) => [...prev, ...newAudioFiles]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input type="file" multiple accept="audio/*" onChange={handleFileUpload} />
|
||||||
|
<div className="audio-list">
|
||||||
|
{audioFiles.map((audio) => (
|
||||||
|
<div key={audio.id} className="audio-item">
|
||||||
|
<h3>{audio.file.name}</h3>
|
||||||
|
<Waveform
|
||||||
|
variant='edit'
|
||||||
|
audio={audio.id}
|
||||||
|
waveColor="#ddd"
|
||||||
|
progressColor="#4a90e2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MultipleAudioUploader;
|
||||||
118
src/components/ExamEditor/Shared/ConfirmDeleteBtn.tsx
Normal file
118
src/components/ExamEditor/Shared/ConfirmDeleteBtn.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { MdClose, MdDelete } from 'react-icons/md';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
interface ConfirmDeleteBtnProps {
|
||||||
|
onDelete: () => void;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfirmDeleteBtn: React.FC<ConfirmDeleteBtnProps> = ({
|
||||||
|
onDelete,
|
||||||
|
size = 'sm',
|
||||||
|
position = 'top-right',
|
||||||
|
className
|
||||||
|
}) => {
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = () => setShowConfirm(false);
|
||||||
|
if (showConfirm) {
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('click', handleClickOutside);
|
||||||
|
}
|
||||||
|
}, [showConfirm]);
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowConfirm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
setShowConfirm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowConfirm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'p-0.5',
|
||||||
|
md: 'p-1',
|
||||||
|
lg: 'p-1.5'
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconSizes = {
|
||||||
|
sm: 14,
|
||||||
|
md: 16,
|
||||||
|
lg: 18
|
||||||
|
};
|
||||||
|
|
||||||
|
const positionClasses = {
|
||||||
|
'top-right': '-right-1 -top-1',
|
||||||
|
'top-left': '-left-1 -top-1',
|
||||||
|
'bottom-right': '-right-1 -bottom-1',
|
||||||
|
'bottom-left': '-left-1 -bottom-1'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"absolute",
|
||||||
|
positionClasses[position],
|
||||||
|
"z-10",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{!showConfirm && (
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className={clsx(
|
||||||
|
sizeClasses[size],
|
||||||
|
"rounded-full",
|
||||||
|
"bg-white/90 shadow-sm",
|
||||||
|
"text-gray-400 hover:text-red-600",
|
||||||
|
"transition-all duration-150",
|
||||||
|
"opacity-0 group-hover:opacity-100",
|
||||||
|
"hover:scale-110"
|
||||||
|
)}
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<MdClose size={iconSizes[size]} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showConfirm && (
|
||||||
|
<div className={clsx(
|
||||||
|
"flex items-center gap-1",
|
||||||
|
"bg-white rounded-lg shadow-lg",
|
||||||
|
sizeClasses[size]
|
||||||
|
)}>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className="p-1 rounded-md bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
|
||||||
|
title="Confirm remove"
|
||||||
|
>
|
||||||
|
<MdDelete size={iconSizes[size]} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="p-1 rounded-md bg-gray-50 text-gray-600 hover:bg-gray-100 transition-colors"
|
||||||
|
title="Cancel"
|
||||||
|
>
|
||||||
|
<MdClose size={iconSizes[size]} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfirmDeleteBtn;
|
||||||
|
|
||||||
15
src/components/ExamEditor/Shared/ExerciseLabel.tsx
Normal file
15
src/components/ExamEditor/Shared/ExerciseLabel.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
preview?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExerciseLabel: React.FC<Props> = ({label, preview}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full justify-between items-center mr-4">
|
||||||
|
<span className="font-semibold">{label}</span>
|
||||||
|
{preview && <div className="text-sm font-light italic">{preview}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExerciseLabel;
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Tooltip } from 'react-tooltip';
|
||||||
|
import { ExerciseGen } from './generatedExercises';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { GiBrain } from 'react-icons/gi';
|
||||||
|
import { IoTextOutline } from 'react-icons/io5';
|
||||||
|
import { Switch } from '@headlessui/react';
|
||||||
|
import useExamEditorStore from '@/stores/examEditor';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sectionId: number;
|
||||||
|
exercises: ExerciseGen[];
|
||||||
|
extraArgs?: Record<string, any>;
|
||||||
|
onSubmit: (configurations: ExerciseConfig[]) => void;
|
||||||
|
onDiscard: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExerciseConfig {
|
||||||
|
type: string;
|
||||||
|
params: {
|
||||||
|
[key: string]: string | number | boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExerciseWizard: React.FC<Props> = ({
|
||||||
|
exercises,
|
||||||
|
extraArgs,
|
||||||
|
sectionId,
|
||||||
|
onSubmit,
|
||||||
|
onDiscard,
|
||||||
|
}) => {
|
||||||
|
const {currentModule} = useExamEditorStore();
|
||||||
|
const { selectedExercises } = useExamEditorStore((store) => store.modules[currentModule].sections.find((s) => s.sectionId === sectionId))!;
|
||||||
|
|
||||||
|
const [configurations, setConfigurations] = useState<ExerciseConfig[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initialConfigs = selectedExercises.map(exerciseType => {
|
||||||
|
const exercise = exercises.find(ex => {
|
||||||
|
const fullType = ex.extra?.find(e => e.param === 'name')?.value
|
||||||
|
? `${ex.type}/?name=${ex.extra.find(e => e.param === 'name')?.value}`
|
||||||
|
: ex.type;
|
||||||
|
return fullType === exerciseType;
|
||||||
|
});
|
||||||
|
|
||||||
|
const params: { [key: string]: string | number | boolean } = {};
|
||||||
|
exercise?.extra?.forEach(param => {
|
||||||
|
if (param.param !== 'name') {
|
||||||
|
if (exerciseType.includes('paragraphMatch') && param.param === 'quantity') {
|
||||||
|
params[param.param] = extraArgs?.text.split("\n\n").length || 1;
|
||||||
|
} else {
|
||||||
|
params[param.param || ''] = param.value ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: exerciseType,
|
||||||
|
params
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setConfigurations(initialConfigs);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedExercises, exercises]);
|
||||||
|
|
||||||
|
const handleParameterChange = (
|
||||||
|
exerciseIndex: number,
|
||||||
|
paramName: string,
|
||||||
|
value: string | number | boolean
|
||||||
|
) => {
|
||||||
|
setConfigurations(prev => {
|
||||||
|
const newConfigs = [...prev];
|
||||||
|
newConfigs[exerciseIndex] = {
|
||||||
|
...newConfigs[exerciseIndex],
|
||||||
|
params: {
|
||||||
|
...newConfigs[exerciseIndex].params,
|
||||||
|
[paramName]: value
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return newConfigs;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderParameterInput = (
|
||||||
|
param: NonNullable<ExerciseGen['extra']>[0],
|
||||||
|
exerciseIndex: number,
|
||||||
|
config: ExerciseConfig
|
||||||
|
) => {
|
||||||
|
if (typeof param.value === 'boolean') {
|
||||||
|
const currentValue = Boolean(config.params[param.param || '']);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row items-center ml-auto">
|
||||||
|
<GiBrain
|
||||||
|
className="mx-4"
|
||||||
|
size={28}
|
||||||
|
color={currentValue ? `#F3F4F6` : `#1F2937`}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checked={currentValue}
|
||||||
|
onChange={(value) => handleParameterChange(
|
||||||
|
exerciseIndex,
|
||||||
|
param.param || '',
|
||||||
|
value
|
||||||
|
)}
|
||||||
|
className={clsx(
|
||||||
|
"relative inline-flex h-[30px] w-[58px] shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-white/75",
|
||||||
|
currentValue ? `bg-[#F3F4F6]` : `bg-[#1F2937]`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={clsx(
|
||||||
|
"pointer-events-none inline-block h-[26px] w-[26px] transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out",
|
||||||
|
currentValue ? 'translate-x-7' : 'translate-x-0'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Switch>
|
||||||
|
<IoTextOutline
|
||||||
|
className="mx-4"
|
||||||
|
size={28}
|
||||||
|
color={!currentValue ? `#F3F4F6` : `#1F2937`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
<Tooltip id={`${exerciseIndex}`} className="z-50 bg-white shadow-md rounded-sm" />
|
||||||
|
<a data-tooltip-id={`${exerciseIndex}`} data-tooltip-html="Generate or use placeholder?" className='ml-1 flex items-center justify-center'>
|
||||||
|
<Image src="/mat-icon-info.svg" width={24} height={24} alt={"AI Generated?"} />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputValue = Number(config.params[param.param || '1'].toString());
|
||||||
|
|
||||||
|
const isParagraphMatch = config.type.split("?name=")[1] === "paragraphMatch";
|
||||||
|
const maxParagraphs = isParagraphMatch ? extraArgs!.text.split("\n\n").length : 50;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm font-medium text-white">
|
||||||
|
{`${param.label}${isParagraphMatch ? ` (out of ${extraArgs!.text.split("\n\n").length} paragraphs)` : ""}`}
|
||||||
|
</label>
|
||||||
|
{param.tooltip && (
|
||||||
|
<>
|
||||||
|
<Tooltip id={config.type} className="z-50 bg-white shadow-md rounded-sm" />
|
||||||
|
<a data-tooltip-id={config.type} data-tooltip-html={param.tooltip} className='ml-1 flex items-center justify-center'>
|
||||||
|
<Image src="/mat-icon-info.svg" width={24} height={24} alt={param.tooltip} />
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => handleParameterChange(
|
||||||
|
exerciseIndex,
|
||||||
|
param.param || '',
|
||||||
|
e.target.value ? Number(e.target.value) : ''
|
||||||
|
)}
|
||||||
|
className="px-3 py-2 shadow-lg rounded-md text-mti-gray-dim w-full"
|
||||||
|
min={1}
|
||||||
|
max={maxParagraphs}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderExerciseHeader = (
|
||||||
|
exercise: ExerciseGen,
|
||||||
|
exerciseIndex: number,
|
||||||
|
config: ExerciseConfig,
|
||||||
|
extraParams: boolean,
|
||||||
|
) => {
|
||||||
|
const generateParam = exercise.extra?.find(param => param.param === 'generate');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx("flex items-center w-full", extraParams ? "mb-4" : "py-4")}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<exercise.icon className="h-5 w-5" />
|
||||||
|
<h3 className="font-medium text-lg">{exercise.label}</h3>
|
||||||
|
</div>
|
||||||
|
{generateParam && renderParameterInput(generateParam, exerciseIndex, config)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 px-4 py-6">
|
||||||
|
{configurations.map((config, exerciseIndex) => {
|
||||||
|
const exercise = exercises.find(ex => {
|
||||||
|
const fullType = ex.extra?.find(e => e.param === 'name')?.value
|
||||||
|
? `${ex.type}/?name=${ex.extra.find(e => e.param === 'name')?.value}`
|
||||||
|
: ex.type;
|
||||||
|
return fullType === config.type;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!exercise) return null;
|
||||||
|
|
||||||
|
const nonGenerateParams = exercise.extra?.filter(
|
||||||
|
param => param.param !== 'name' && param.param !== 'generate'
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={config.type}
|
||||||
|
className={`bg-ielts-${currentModule}/70 text-white rounded-lg p-4 shadow-xl`}
|
||||||
|
>
|
||||||
|
{renderExerciseHeader(exercise, exerciseIndex, config, (exercise.extra || []).length > 2)}
|
||||||
|
|
||||||
|
{nonGenerateParams && nonGenerateParams.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{nonGenerateParams.map(param => (
|
||||||
|
<div key={param.param}>
|
||||||
|
{renderParameterInput(param, exerciseIndex, config)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<button
|
||||||
|
onClick={onDiscard}
|
||||||
|
className={`px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-400 transition-colors`}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSubmit(configurations)}
|
||||||
|
className={`px-4 py-2 bg-ielts-${currentModule} text-white rounded-md hover:bg-ielts-${currentModule}/80 transition-colors`}
|
||||||
|
>
|
||||||
|
Add Exercises
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExerciseWizard;
|
||||||
356
src/components/ExamEditor/Shared/ExercisePicker/exercises.ts
Normal file
356
src/components/ExamEditor/Shared/ExercisePicker/exercises.ts
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
import {
|
||||||
|
FaListUl,
|
||||||
|
FaUnderline,
|
||||||
|
FaPen,
|
||||||
|
FaBookOpen,
|
||||||
|
FaEnvelope,
|
||||||
|
FaComments,
|
||||||
|
FaHandshake,
|
||||||
|
FaParagraph,
|
||||||
|
FaLightbulb,
|
||||||
|
FaHeadphones,
|
||||||
|
FaWpforms,
|
||||||
|
} from 'react-icons/fa6';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FaEdit,
|
||||||
|
FaFileAlt,
|
||||||
|
FaUserFriends,
|
||||||
|
FaCheckSquare,
|
||||||
|
FaQuestionCircle,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import { ExerciseGen } from './generatedExercises';
|
||||||
|
|
||||||
|
const quantity = (quantity: number, tooltip?: string) => {
|
||||||
|
return {
|
||||||
|
param: "quantity",
|
||||||
|
label: "Quantity",
|
||||||
|
tooltip: tooltip ? tooltip : "Exercise Quantity",
|
||||||
|
value: quantity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const generate = () => {
|
||||||
|
return {
|
||||||
|
param: "generate",
|
||||||
|
value: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reading = (passage: number) => {
|
||||||
|
const readingExercises = [
|
||||||
|
{
|
||||||
|
label: `Passage ${passage} - Fill Blanks`,
|
||||||
|
type: `reading_${passage}`,
|
||||||
|
icon: FaEdit,
|
||||||
|
sectionId: passage,
|
||||||
|
extra: [
|
||||||
|
{
|
||||||
|
param: "name",
|
||||||
|
value: "fillBlanks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
param: "num_random_words",
|
||||||
|
label: "Random Words",
|
||||||
|
tooltip: "Words that are not the solution",
|
||||||
|
value: 1
|
||||||
|
},
|
||||||
|
quantity(4, "Quantity of Blanks"),
|
||||||
|
generate()
|
||||||
|
],
|
||||||
|
module: "reading"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `Passage ${passage} - Write Blanks`,
|
||||||
|
type: `reading_${passage}`,
|
||||||
|
icon: FaPen,
|
||||||
|
sectionId: passage,
|
||||||
|
extra: [
|
||||||
|
{
|
||||||
|
param: "name",
|
||||||
|
value: "writeBlanks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
param: "max_words",
|
||||||
|
label: "Word Limit",
|
||||||
|
tooltip: "How many words a solution can have",
|
||||||
|
value: 3
|
||||||
|
},
|
||||||
|
quantity(4, "Quantity of Blanks"),
|
||||||
|
generate()
|
||||||
|
],
|
||||||
|
module: "reading"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `Passage ${passage} - True False`,
|
||||||
|
type: `reading_${passage}`,
|
||||||
|
icon: FaCheckSquare,
|
||||||
|
sectionId: passage,
|
||||||
|
extra: [
|
||||||
|
{
|
||||||
|
param: "name",
|
||||||
|
value: "trueFalse"
|
||||||
|
},
|
||||||
|
quantity(4, "Quantity of Statements"),
|
||||||
|
generate()
|
||||||
|
],
|
||||||
|
module: "reading"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `Passage ${passage} - Paragraph Match`,
|
||||||
|
type: `reading_${passage}`,
|
||||||
|
icon: FaParagraph,
|
||||||
|
sectionId: passage,
|
||||||
|
extra: [
|
||||||
|
{
|
||||||
|
param: "name",
|
||||||
|
value: "paragraphMatch"
|
||||||
|
},
|
||||||
|
quantity(5, "Quantity of Matches"),
|
||||||
|
generate()
|
||||||
|
],
|
||||||
|
module: "reading"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
if (passage === 3) {
|
||||||
|
readingExercises.push(
|
||||||
|
{
|
||||||
|
label: `Passage 3 - Idea Match`,
|
||||||
|
type: `reading_3`,
|
||||||
|
icon: FaLightbulb,
|
||||||
|
sectionId: passage,
|
||||||
|
extra: [
|
||||||
|
{
|
||||||
|
param: "name",
|
||||||
|
value: "ideaMatch"
|
||||||
|
},
|
||||||
|
quantity(5, "Quantity of Ideas"),
|
||||||
|
generate()
|
||||||
|
],
|
||||||
|
module: "reading"
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return readingExercises;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listening = (section: number) => {
|
||||||
|
const listeningExercises = [
|
||||||
|
{
|
||||||
|
label: `Section ${section} - Multiple Choice`,
|
||||||
|
type: `listening_${section}`,
|
||||||
|
icon: FaHeadphones,
|
||||||
|
sectionId: section,
|
||||||
|
extra: [
|
||||||
|
{
|
||||||
|
param: "name",
|
||||||
|
value: section == 3 ? "multipleChoice3Options" : "multipleChoice"
|
||||||
|
},
|
||||||
|
quantity(5, "Quantity of Multiple Choice Questions"),
|
||||||
|
generate()
|
||||||
|
],
|
||||||
|
module: "listening"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `Section ${section} - Write the Blanks: Questions`,
|
||||||
|
type: `listening_${section}`,
|
||||||
|
icon: FaQuestionCircle,
|
||||||
|
sectionId: section,
|
||||||
|
extra: [
|
||||||
|
{
|
||||||
|
param: "name",
|
||||||
|
value: "writeBlanksQuestions"
|
||||||
|
},
|
||||||
|
quantity(5, "Quantity of Blanks"),
|
||||||
|
generate()
|
||||||
|
],
|
||||||
|
module: "listening"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
if (section === 1 || section === 4) {
|
||||||
|
listeningExercises.push(
|
||||||
|
{
|
||||||
|
label: `Section ${section} - Write the Blanks: Fill`,
|
||||||
|
type: `listening_${section}`,
|
||||||
|
icon: FaEdit,
|
||||||
|
sectionId: section,
|
||||||
|
extra: [
|
||||||
|
{
|
||||||
|
param: "name",
|
||||||
|
value: "writeBlanksFill"
|
||||||
|
},
|
||||||
|
quantity(5, "Quantity of Blanks"),
|
||||||
|
generate()
|
||||||
|
],
|
||||||
|
module: "listening"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
listeningExercises.push(
|
||||||
|
{
|
||||||
|
label: `Section ${section} - Write the Blanks: Form`,
|
||||||
|
type: `listening_${section}`,
|
||||||
|
sectionId: section,
|
||||||
|
icon: FaWpforms,
|
||||||
|
extra: [
|
||||||
|
{
|
||||||
|
param: "name",
|
||||||
|
value: "writeBlanksForm"
|
||||||
|
},
|
||||||
|
quantity(5, "Quantity of Blanks"),
|
||||||
|
generate()
|
||||||
|
],
|
||||||
|
module: "listening"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return listeningExercises;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXERCISES: ExerciseGen[] = [
|
||||||
|
{
|
||||||
|
label: "Multiple Choice",
|
||||||
|
type: "multipleChoice",
|
||||||
|
icon: FaListUl,
|
||||||
|
extra: [
|
||||||
|
quantity(10, "Amount"),
|
||||||
|
generate()
|
||||||
|
],
|
||||||
|
module: "level"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Multiple Choice - Blank Space",
|
||||||
|
type: "mcBlank",
|
||||||
|
icon: FaEdit,
|
||||||
|
extra: [
|
||||||
|
quantity(10, "Amount"),
|
||||||
|
generate()
|
||||||
|
],
|
||||||
|
module: "level"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Multiple Choice - Underlined",
|
||||||
|
type: "mcUnderline",
|
||||||
|
icon: FaUnderline,
|
||||||
|
extra: [
|
||||||
|
quantity(10, "Amount"),
|
||||||
|
generate()
|
||||||
|
],
|
||||||
|
module: "level"
|
||||||
|
},
|
||||||
|
/*{
|
||||||
|
label: "Blank Space", <- Assuming this is FillBlanks aswell
|
||||||
|
type: "blankSpaceText",
|
||||||
|
icon: FaPen,
|
||||||
|
extra: [
|
||||||
|
quantity(10, "Nº of Blanks"),
|
||||||
|
{
|
||||||
|
label: "Passage Word Size",
|
||||||
|
param: "text_size",
|
||||||
|
value: "250"
|
||||||
|
},
|
||||||
|
generate()
|
||||||
|
],
|
||||||
|
module: "level"
|
||||||
|
},*/
|
||||||
|
{
|
||||||
|
label: "Fill Blanks: MC",
|
||||||
|
type: "fillBlanksMC",
|
||||||
|
icon: FaPen,
|
||||||
|
extra: [
|
||||||
|
quantity(10, "Nº of Blanks"),
|
||||||
|
{
|
||||||
|
label: "Passage Word Size",
|
||||||
|
param: "text_size",
|
||||||
|
value: "250"
|
||||||
|
},
|
||||||
|
generate()
|
||||||
|
],
|
||||||
|
module: "level"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Reading Passage",
|
||||||
|
type: "passageUtas",
|
||||||
|
icon: FaBookOpen,
|
||||||
|
extra: [
|
||||||
|
// in the utas exam there was only mc so I'm assuming short answers are deprecated
|
||||||
|
/*{
|
||||||
|
label: "Short Answers",
|
||||||
|
param: "sa_qty",
|
||||||
|
value: "10"
|
||||||
|
},*/
|
||||||
|
{
|
||||||
|
label: "Multiple Choice Quantity",
|
||||||
|
param: "mc_qty",
|
||||||
|
value: "10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Reading Passage Topic",
|
||||||
|
param: "topic",
|
||||||
|
value: ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Passage Word Size",
|
||||||
|
param: "text_size",
|
||||||
|
value: "700"
|
||||||
|
},
|
||||||
|
generate()
|
||||||
|
],
|
||||||
|
module: "level"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Task 1 - Letter",
|
||||||
|
type: "writing_letter",
|
||||||
|
icon: FaEnvelope,
|
||||||
|
extra: [
|
||||||
|
generate()
|
||||||
|
],
|
||||||
|
module: "writing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Task 2 - Essay",
|
||||||
|
type: "writing_2",
|
||||||
|
icon: FaFileAlt,
|
||||||
|
extra: [
|
||||||
|
generate()
|
||||||
|
],
|
||||||
|
module: "writing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Exercise 1",
|
||||||
|
type: "speaking_1",
|
||||||
|
icon: FaComments,
|
||||||
|
extra: [
|
||||||
|
generate()
|
||||||
|
],
|
||||||
|
module: "speaking"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Exercise 2",
|
||||||
|
type: "speaking_2",
|
||||||
|
icon: FaUserFriends,
|
||||||
|
extra: [
|
||||||
|
generate()
|
||||||
|
],
|
||||||
|
module: "speaking"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Interactive",
|
||||||
|
type: "speaking_3",
|
||||||
|
icon: FaHandshake,
|
||||||
|
extra: [
|
||||||
|
generate()
|
||||||
|
],
|
||||||
|
module: "speaking"
|
||||||
|
},
|
||||||
|
...reading(1),
|
||||||
|
...reading(2),
|
||||||
|
...reading(3),
|
||||||
|
...listening(1),
|
||||||
|
...listening(2),
|
||||||
|
...listening(3),
|
||||||
|
...listening(4),
|
||||||
|
]
|
||||||
|
|
||||||
|
export default EXERCISES;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { IconType } from "react-icons";
|
||||||
|
|
||||||
|
export interface GeneratedExercises {
|
||||||
|
exercises: Record<string, string>[];
|
||||||
|
sectionId: number;
|
||||||
|
module: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeneratorState {
|
||||||
|
loading: boolean;
|
||||||
|
sectionId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface ExerciseGen {
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
icon: IconType;
|
||||||
|
sectionId?: number;
|
||||||
|
extra?: { param?: string; value?: string | number | boolean; label?: string; tooltip?: string}[];
|
||||||
|
module: string
|
||||||
|
}
|
||||||
173
src/components/ExamEditor/Shared/ExercisePicker/index.tsx
Normal file
173
src/components/ExamEditor/Shared/ExercisePicker/index.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import EXERCISES from "./exercises";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { ExerciseGen, GeneratedExercises, GeneratorState } from "./generatedExercises";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import { useState } from "react";
|
||||||
|
import ExerciseWizard, { ExerciseConfig } from "./ExerciseWizard";
|
||||||
|
import { generate } from "../../SettingsEditor/Shared/Generate";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
import { Dialog, ListeningPart, ReadingPart } from "@/interfaces/exam";
|
||||||
|
|
||||||
|
interface ExercisePickerProps {
|
||||||
|
module: string;
|
||||||
|
sectionId: number;
|
||||||
|
difficulty: string;
|
||||||
|
extraArgs?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExercisePicker: React.FC<ExercisePickerProps> = ({
|
||||||
|
module,
|
||||||
|
sectionId,
|
||||||
|
extraArgs = undefined,
|
||||||
|
}) => {
|
||||||
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
|
const { difficulty, sections } = useExamEditorStore((store) => store.modules[currentModule]);
|
||||||
|
const section = sections.find((s) => s.sectionId == sectionId)!;
|
||||||
|
const { state, selectedExercises } = section;
|
||||||
|
|
||||||
|
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
|
||||||
|
const getFullExerciseType = (exercise: ExerciseGen): string => {
|
||||||
|
if (exercise.extra && exercise.extra.length > 0) {
|
||||||
|
const extraValue = exercise.extra.find(e => e.param === 'name')?.value;
|
||||||
|
return extraValue ? `${exercise.type}/?name=${extraValue}` : exercise.type;
|
||||||
|
}
|
||||||
|
return exercise.type;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (exercise: ExerciseGen) => {
|
||||||
|
const fullType = getFullExerciseType(exercise);
|
||||||
|
|
||||||
|
const newSelected = selectedExercises.includes(fullType)
|
||||||
|
? selectedExercises.filter(type => type !== fullType)
|
||||||
|
: [...selectedExercises, fullType];
|
||||||
|
|
||||||
|
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { module: currentModule, sectionId, field: "selectedExercises", value: newSelected } })
|
||||||
|
};
|
||||||
|
|
||||||
|
const moduleExercises = module === 'level' ? EXERCISES : (sectionId ? EXERCISES.filter((ex) => ex.module === module && ex.sectionId == sectionId) : EXERCISES.filter((ex) => ex.module === module));
|
||||||
|
|
||||||
|
const onModuleSpecific = (configurations: ExerciseConfig[]) => {
|
||||||
|
const exercises = configurations.map(config => {
|
||||||
|
const exerciseType = config.type.split('name=')[1];
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: exerciseType,
|
||||||
|
quantity: Number(config.params.quantity || 1),
|
||||||
|
...(config.params.num_random_words !== undefined && {
|
||||||
|
num_random_words: Number(config.params.num_random_words)
|
||||||
|
}),
|
||||||
|
...(config.params.max_words !== undefined && {
|
||||||
|
max_words: Number(config.params.max_words)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let context, moduleState;
|
||||||
|
|
||||||
|
switch (module) {
|
||||||
|
case 'reading':
|
||||||
|
moduleState = state as ReadingPart;
|
||||||
|
context = {
|
||||||
|
text: moduleState.text.content
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'listening':
|
||||||
|
moduleState = state as ListeningPart;
|
||||||
|
let script = moduleState.script;
|
||||||
|
let text, dialog;
|
||||||
|
switch (sectionId) {
|
||||||
|
case 1:
|
||||||
|
case 3:
|
||||||
|
dialog = script as Dialog[];
|
||||||
|
text = dialog.map((d) => `${d.name}: ${d.text}`).join("\n");
|
||||||
|
context = { text: text }
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
case 4:
|
||||||
|
text = script as string;
|
||||||
|
context = { text: text }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
context = {}
|
||||||
|
}
|
||||||
|
generate(
|
||||||
|
sectionId,
|
||||||
|
module as Module,
|
||||||
|
"exercises",
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
...context,
|
||||||
|
exercises: exercises,
|
||||||
|
difficulty: difficulty
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(data: any) => [{
|
||||||
|
exercises: data.exercises
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
|
||||||
|
setPickerOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal isOpen={pickerOpen} onClose={() => setPickerOpen(false)} title="Exercise Wizard">
|
||||||
|
<ExerciseWizard
|
||||||
|
sectionId={sectionId}
|
||||||
|
exercises={moduleExercises}
|
||||||
|
onSubmit={onModuleSpecific}
|
||||||
|
onDiscard={() => setPickerOpen(false)}
|
||||||
|
extraArgs={extraArgs}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
<div className="flex flex-col gap-4 px-4" key={sectionId}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{moduleExercises.map((exercise) => {
|
||||||
|
const fullType = getFullExerciseType(exercise);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={fullType}
|
||||||
|
className={`flex items-center space-x-3 text-white font-semibold cursor-pointer p-2 hover:bg-ielts-${exercise.module}/70 rounded bg-ielts-${exercise.module}/90`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="exercise"
|
||||||
|
value={fullType}
|
||||||
|
checked={selectedExercises.includes(fullType)}
|
||||||
|
onChange={() => handleChange(exercise)}
|
||||||
|
className="h-5 w-5"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<exercise.icon className="h-5 w-5 text-white" />
|
||||||
|
<span>{exercise.label}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row justify-center">
|
||||||
|
<button
|
||||||
|
className={
|
||||||
|
clsx("flex items-center justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300 disabled:cursor-not-allowed",
|
||||||
|
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/40 `,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={() => setPickerOpen(true)}
|
||||||
|
disabled={selectedExercises.length == 0}
|
||||||
|
>
|
||||||
|
Set Up Exercises ({selectedExercises.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExercisePicker;
|
||||||
71
src/components/ExamEditor/Shared/Header.tsx
Normal file
71
src/components/ExamEditor/Shared/Header.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { MdDelete, MdEdit, MdEditOff, MdRefresh, MdSave } from "react-icons/md";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
editing: boolean;
|
||||||
|
module?: Module;
|
||||||
|
handleSave: () => void;
|
||||||
|
handleDiscard: () => void;
|
||||||
|
modeHandle?: () => void;
|
||||||
|
mode?: "delete" | "edit";
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Header: React.FC<Props> = ({ title, description, editing, handleSave, handleDiscard, modeHandle, children, mode = "delete", module }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800">{title}</h1>
|
||||||
|
<p className="text-gray-600 mt-1">{description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{children}
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!editing}
|
||||||
|
className={
|
||||||
|
clsx("px-4 py-2 rounded-lg flex items-center gap-2 transition-all duration-200",
|
||||||
|
editing ? 'bg-green-500 text-white hover:bg-green-600' : 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MdSave size={18} />
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDiscard}
|
||||||
|
disabled={!editing}
|
||||||
|
className={clsx(
|
||||||
|
"px-4 py-2 rounded-lg flex items-center gap-2 transition-all duration-200",
|
||||||
|
editing ? 'bg-gray-500 text-white hover:bg-gray-600' : 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MdRefresh size={18} />
|
||||||
|
Discard
|
||||||
|
</button>
|
||||||
|
{mode === "delete" ? (
|
||||||
|
<button
|
||||||
|
onClick={modeHandle}
|
||||||
|
className="px-4 py-2 bg-white border border-red-500 text-red-500 hover:bg-red-50 rounded-lg transition-all duration-200 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<MdDelete size={18} />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
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} /> }
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FaPencilAlt } from 'react-icons/fa';
|
||||||
|
import { Module } from '@/interfaces';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import WordUploader from './WordUploader';
|
||||||
|
import GenLoader from '../../Exercises/Shared/GenLoader';
|
||||||
|
import useExamEditorStore from '@/stores/examEditor';
|
||||||
|
|
||||||
|
const ImportOrFromScratch: React.FC<{ module: Module; }> = ({ module }) => {
|
||||||
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
|
const { importing } = useExamEditorStore((store) => store.modules[currentModule])
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
dispatch({ type: "UPDATE_MODULE", payload: { updates: { importModule: false } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{importing ? (
|
||||||
|
<GenLoader module={module} custom={`Importing ${module} exam ...`} className='flex flex-grow justify-center bg-slate-200 ' />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 w-full flex-1 gap-6">
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className={clsx(
|
||||||
|
"flex flex-col items-center flex-1 gap-6 justify-center p-8",
|
||||||
|
"border-2 border-gray-200 rounded-xl",
|
||||||
|
`bg-ielts-${module}/20 hover:bg-ielts-${module}/30`,
|
||||||
|
"transition-all duration-300",
|
||||||
|
"shadow-sm hover:shadow-md group")}
|
||||||
|
>
|
||||||
|
<div className="transform group-hover:scale-105 transition-transform duration-300">
|
||||||
|
<FaPencilAlt className={clsx("w-20 h-20 transition-colors duration-300",
|
||||||
|
module === "reading" && "text-indigo-800 group-hover:text-indigo-950",
|
||||||
|
module === "listening" && "text-purple-800 group-hover:text-purple-950",
|
||||||
|
module === "level" && "text-teal-700 group-hover:text-teal-900"
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
<span className={clsx("text-lg font-bold transition-colors duration-300",
|
||||||
|
module === "reading" && "text-indigo-800 group-hover:text-indigo-950",
|
||||||
|
module === "listening" && "text-purple-800 group-hover:text-purple-950",
|
||||||
|
module === "level" && "text-teal-700 group-hover:text-teal-900"
|
||||||
|
)}>
|
||||||
|
Start from Scratch
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div className='h-full'>
|
||||||
|
<WordUploader module={module} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImportOrFromScratch;
|
||||||
272
src/components/ExamEditor/Shared/ImportExam/WordUploader.tsx
Normal file
272
src/components/ExamEditor/Shared/ImportExam/WordUploader.tsx
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import React, { useCallback, useRef, useState } from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { FaFileUpload, FaCheckCircle, FaLock, FaTimes } from 'react-icons/fa';
|
||||||
|
import { capitalize } from 'lodash';
|
||||||
|
import { Module } from '@/interfaces';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import useExamEditorStore from '@/stores/examEditor';
|
||||||
|
import { ReadingPart } from '@/interfaces/exam';
|
||||||
|
import { defaultSectionSettings } from '@/stores/examEditor/defaults';
|
||||||
|
|
||||||
|
const WordUploader: React.FC<{ module: Module }> = ({ module }) => {
|
||||||
|
const {currentModule, dispatch} = useExamEditorStore();
|
||||||
|
|
||||||
|
const examInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const solutionsInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [showUploaders, setShowUploaders] = useState(false);
|
||||||
|
const [examFile, setExamFile] = useState<File | null>(null);
|
||||||
|
const [solutionsFile, setSolutionsFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
const handleExamChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
if (file.type === 'application/msword' ||
|
||||||
|
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
|
||||||
|
setExamFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSolutionsChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
if (file.type === 'application/msword' ||
|
||||||
|
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
|
||||||
|
setSolutionsFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = useCallback( async () => {
|
||||||
|
try {
|
||||||
|
if (!examFile) {
|
||||||
|
toast.error('Exam file is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({type: "UPDATE_MODULE", payload: {updates: {importing: true}, module}})
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('exercises', examFile);
|
||||||
|
if (solutionsFile) {
|
||||||
|
formData.append('solutions', solutionsFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/exam/${module}/import/`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
toast.error(`An unknown error has occured while import ${module} exam!`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
toast.success(`${capitalize(module)} exam imported successfully!`);
|
||||||
|
|
||||||
|
setExamFile(null);
|
||||||
|
setSolutionsFile(null);
|
||||||
|
setShowUploaders(false);
|
||||||
|
|
||||||
|
switch (currentModule) {
|
||||||
|
case 'reading':
|
||||||
|
const newSectionsStates = data.parts.map(
|
||||||
|
(part: ReadingPart, index: number) => defaultSectionSettings(module, index + 1, part)
|
||||||
|
);
|
||||||
|
dispatch({type: "UPDATE_MODULE", payload: {
|
||||||
|
updates: {
|
||||||
|
sections: newSectionsStates,
|
||||||
|
minTimer: data.minTimer,
|
||||||
|
importModule: false,
|
||||||
|
importing: false,
|
||||||
|
},
|
||||||
|
module
|
||||||
|
}});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`An unknown error has occured while import ${module} exam!`);
|
||||||
|
} finally {
|
||||||
|
dispatch({type: "UPDATE_MODULE", payload: {updates: {importing: false}, module}})
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
examFile,
|
||||||
|
solutionsFile,
|
||||||
|
dispatch,
|
||||||
|
module
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!showUploaders ? (
|
||||||
|
<div
|
||||||
|
onClick={() => setShowUploaders(true)}
|
||||||
|
className="flex flex-col items-center gap-6 h-full justify-center p-8 border-2 border-blue-200 rounded-xl
|
||||||
|
bg-gradient-to-b from-blue-50 to-blue-100
|
||||||
|
hover:from-blue-100 hover:to-blue-200
|
||||||
|
cursor-pointer transition-all duration-300
|
||||||
|
shadow-sm hover:shadow-md group"
|
||||||
|
>
|
||||||
|
<div className="transform group-hover:scale-105 transition-transform duration-300">
|
||||||
|
<Image
|
||||||
|
src="/microsoft-word-icon.png"
|
||||||
|
width={200}
|
||||||
|
height={100}
|
||||||
|
alt="Upload Word"
|
||||||
|
className="drop-shadow-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-bold text-stone-600 group-hover:text-stone-800 transition-colors duration-300">
|
||||||
|
Upload {capitalize(module)} Exam
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col h-full gap-4 p-6 justify-between border-2 border-blue-200 rounded-xl bg-white shadow-md">
|
||||||
|
<div className='flex flex-col flex-1 justify-center gap-8'>
|
||||||
|
<div
|
||||||
|
onClick={() => examInputRef.current?.click()}
|
||||||
|
className={clsx(
|
||||||
|
"relative p-6 border-2 border-dashed rounded-lg cursor-pointer transition-all duration-300",
|
||||||
|
examFile ? "border-green-300 bg-green-50" : "border-gray-300 hover:border-blue-400 hover:bg-blue-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FaFileUpload className={clsx(
|
||||||
|
"w-8 h-8",
|
||||||
|
examFile ? "text-green-500" : "text-gray-400"
|
||||||
|
)} />
|
||||||
|
<div className="flex-grow">
|
||||||
|
<h3 className="font-semibold text-gray-700">Exam Document</h3>
|
||||||
|
<p className="text-sm text-gray-500">Required</p>
|
||||||
|
</div>
|
||||||
|
{examFile && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FaCheckCircle className="w-6 h-6 text-green-500" />
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setExamFile(null);
|
||||||
|
}}
|
||||||
|
className="p-1.5 hover:bg-green-100 rounded-full transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<FaTimes className="w-4 h-4 text-green-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{examFile && (
|
||||||
|
<div className="mt-2 text-sm text-green-600 font-medium">
|
||||||
|
{examFile.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={examInputRef}
|
||||||
|
onChange={handleExamChange}
|
||||||
|
accept=".doc,.docx"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onClick={() => solutionsInputRef.current?.click()}
|
||||||
|
className={clsx(
|
||||||
|
"relative p-6 border-2 border-dashed rounded-lg cursor-pointer transition-all duration-300",
|
||||||
|
solutionsFile ? "border-green-300 bg-green-50" : "border-gray-300 hover:border-blue-400 hover:bg-blue-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FaFileUpload className={clsx(
|
||||||
|
"w-8 h-8",
|
||||||
|
solutionsFile ? "text-green-500" : "text-gray-400"
|
||||||
|
)} />
|
||||||
|
<div className="flex-grow">
|
||||||
|
<h3 className="font-semibold text-gray-700">Solutions Document</h3>
|
||||||
|
<p className="text-sm text-gray-500">Optional</p>
|
||||||
|
</div>
|
||||||
|
{solutionsFile ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FaCheckCircle className="w-6 h-6 text-green-500" />
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSolutionsFile(null);
|
||||||
|
}}
|
||||||
|
className="p-1.5 hover:bg-green-100 rounded-full transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<FaTimes className="w-4 h-4 text-green-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400 font-medium px-2 py-1 bg-gray-100 rounded">
|
||||||
|
OPTIONAL
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{solutionsFile && (
|
||||||
|
<div className="mt-2 text-sm text-green-600 font-medium">
|
||||||
|
{solutionsFile.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={solutionsInputRef}
|
||||||
|
onChange={handleSolutionsChange}
|
||||||
|
accept=".doc,.docx"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUploaders(false)}
|
||||||
|
className={
|
||||||
|
clsx("px-6 py-2.5 text-sm font-semibold text-gray-700 bg-white border-2 border-gray-200",
|
||||||
|
"rounded-lg hover:bg-gray-50 hover:border-gray-300",
|
||||||
|
"transition-all duration-300 min-w-[120px]",
|
||||||
|
"focus:outline-none focus:ring-2 focus:ring-gray-200 focus:ring-offset-2",
|
||||||
|
"active:scale-95")}
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<FaTimes className="w-4 h-4" />
|
||||||
|
Cancel
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={!examFile}
|
||||||
|
className={clsx(
|
||||||
|
"flex-grow px-6 py-2.5 text-sm font-semibold rounded-lg",
|
||||||
|
"transition-all duration-300 min-w-[120px]",
|
||||||
|
"focus:outline-none focus:ring-2 focus:ring-offset-2",
|
||||||
|
"flex items-center justify-center gap-2",
|
||||||
|
examFile
|
||||||
|
? "bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 active:scale-95 focus:ring-blue-500"
|
||||||
|
: "bg-gradient-to-r from-gray-100 to-gray-200 text-gray-400 cursor-not-allowed border-2 border-gray-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{examFile ? (
|
||||||
|
<>
|
||||||
|
<FaFileUpload className="w-4 h-4" />
|
||||||
|
Import Files
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FaLock className="w-4 h-4" />
|
||||||
|
Upload Exam First
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WordUploader;
|
||||||
40
src/components/ExamEditor/Shared/Passage.tsx
Normal file
40
src/components/ExamEditor/Shared/Passage.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import Dropdown from "@/components/Dropdown";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
open: boolean;
|
||||||
|
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Passage: React.FC<Props> = ({ title, content, open, setIsOpen}) => {
|
||||||
|
const paragraphs = content.split('\n\n');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
title={title}
|
||||||
|
className={clsx(
|
||||||
|
"bg-white p-6 w-full items-center",
|
||||||
|
open ? "rounded-t-lg border-b border-gray-200" : "rounded-lg shadow-lg"
|
||||||
|
)}
|
||||||
|
titleClassName="text-2xl font-semibold text-gray-800"
|
||||||
|
contentWrapperClassName="p-6 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"
|
||||||
|
open={open}
|
||||||
|
setIsOpen={setIsOpen}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{paragraphs.map((paragraph, index) => (
|
||||||
|
<p
|
||||||
|
key={index}
|
||||||
|
className={clsx("text-justify", index < paragraphs.length - 1 ? 'mb-4' : 'mb-6')}
|
||||||
|
>
|
||||||
|
{paragraph.trim()}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Passage;
|
||||||
84
src/components/ExamEditor/Shared/SectionDropdown.tsx
Normal file
84
src/components/ExamEditor/Shared/SectionDropdown.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import React, { useState, ReactNode, useRef, useEffect } from 'react';
|
||||||
|
import { animated, useSpring } from '@react-spring/web';
|
||||||
|
|
||||||
|
interface DropdownProps {
|
||||||
|
title: ReactNode;
|
||||||
|
open: boolean;
|
||||||
|
toggleOpen: () => void;
|
||||||
|
className: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Would be way too messy to add the center the title in the other Dropdown
|
||||||
|
const SectionDropdown: React.FC<DropdownProps> = ({
|
||||||
|
title,
|
||||||
|
open,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
toggleOpen
|
||||||
|
}) => {
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [contentHeight, setContentHeight] = useState<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
if (contentRef.current) {
|
||||||
|
resizeObserver = new ResizeObserver(entries => {
|
||||||
|
for (let entry of entries) {
|
||||||
|
if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
|
||||||
|
const height = entry.borderBoxSize[0].blockSize;
|
||||||
|
setContentHeight(height + 0);
|
||||||
|
} else {
|
||||||
|
// Fallback for browsers that don't support borderBoxSize
|
||||||
|
const height = entry.contentRect.height;
|
||||||
|
setContentHeight(height + 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(contentRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const springProps = useSpring({
|
||||||
|
height: open ? contentHeight : 0,
|
||||||
|
opacity: open ? 1 : 0,
|
||||||
|
config: { tension: 300, friction: 30 }
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleOpen()}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<div className='flex flex-row w-full items-center'>
|
||||||
|
<p className='flex-grow'>{title}</p>
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 transform transition-transform ${open ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<animated.div style={springProps} className="overflow-hidden">
|
||||||
|
<div ref={contentRef}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</animated.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SectionDropdown;
|
||||||
36
src/components/ExamEditor/Shared/SortableSection.tsx
Normal file
36
src/components/ExamEditor/Shared/SortableSection.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
|
import { FaArrowsAlt } from "react-icons/fa";
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
|
||||||
|
const SortableSection: React.FC<{
|
||||||
|
id: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}> = ({ id, children }) => {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style} className="flex items-center mb-2 bg-white p-4 rounded shadow">
|
||||||
|
<div {...attributes} {...listeners} className="cursor-move mr-2">
|
||||||
|
<div className="cursor-move mr-3 p-2 rounded bg-gray-200 text-gray-500 hover:bg-gray-300">
|
||||||
|
<FaArrowsAlt size={16} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-grow">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SortableSection;
|
||||||
189
src/components/ExamEditor/index.tsx
Normal file
189
src/components/ExamEditor/index.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import SectionRenderer from "./SectionRenderer";
|
||||||
|
import Checkbox from "../Low/Checkbox";
|
||||||
|
import Input from "../Low/Input";
|
||||||
|
import Select from "../Low/Select";
|
||||||
|
import { capitalize } from "lodash";
|
||||||
|
import { Difficulty } from "@/interfaces/exam";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { ModuleState } from "@/stores/examEditor/types";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
import WritingSettings from "./SettingsEditor/writing";
|
||||||
|
import ReadingSettings from "./SettingsEditor/reading";
|
||||||
|
import LevelSettings from "./SettingsEditor/level";
|
||||||
|
import ListeningSettings from "./SettingsEditor/listening";
|
||||||
|
import SpeakingSettings from "./SettingsEditor/speaking";
|
||||||
|
import ImportOrStartFromScratch from "./Shared/ImportExam/ImportOrFromScratch";
|
||||||
|
import { defaultSectionSettings } from "@/stores/examEditor/defaults";
|
||||||
|
|
||||||
|
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
||||||
|
|
||||||
|
const ExamEditor: React.FC = () => {
|
||||||
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
|
const {
|
||||||
|
sections,
|
||||||
|
minTimer,
|
||||||
|
expandedSections,
|
||||||
|
examLabel,
|
||||||
|
isPrivate,
|
||||||
|
difficulty,
|
||||||
|
sectionLabels,
|
||||||
|
importModule
|
||||||
|
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||||
|
|
||||||
|
const [numberOfParts, setNumberOfParts] = useState(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentSections = sections;
|
||||||
|
const currentLabels = sectionLabels;
|
||||||
|
let updatedSections;
|
||||||
|
let updatedLabels;
|
||||||
|
|
||||||
|
if (numberOfParts > currentSections.length) {
|
||||||
|
const newSections = [...currentSections];
|
||||||
|
const newLabels = [...currentLabels];
|
||||||
|
|
||||||
|
for (let i = currentSections.length; i < numberOfParts; i++) {
|
||||||
|
newSections.push(defaultSectionSettings(currentModule, i + 1));
|
||||||
|
newLabels.push({
|
||||||
|
id: i + 1,
|
||||||
|
label: `Part ${i + 1}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedSections = newSections;
|
||||||
|
updatedLabels = newLabels;
|
||||||
|
} else if (numberOfParts < currentSections.length) {
|
||||||
|
updatedSections = currentSections.slice(0, numberOfParts);
|
||||||
|
updatedLabels = currentLabels.slice(0, numberOfParts);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedExpandedSections = expandedSections.filter(
|
||||||
|
sectionId => updatedSections.some(section => section.sectionId === sectionId)
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: 'UPDATE_MODULE',
|
||||||
|
payload: {
|
||||||
|
updates: {
|
||||||
|
sections: updatedSections,
|
||||||
|
sectionLabels: updatedLabels,
|
||||||
|
expandedSections: updatedExpandedSections
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [numberOfParts]);
|
||||||
|
|
||||||
|
|
||||||
|
const sectionIds = sections.map((section) => section.sectionId)
|
||||||
|
|
||||||
|
const updateModule = useCallback((updates: Partial<ModuleState>) => {
|
||||||
|
dispatch({ type: 'UPDATE_MODULE', payload: { updates } });
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const toggleSection = (sectionId: number) => {
|
||||||
|
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
|
||||||
|
toast.error("Include at least one section!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch({ type: 'TOGGLE_SECTION', payload: { sectionId } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const ModuleSettings: Record<Module, React.ComponentType> = {
|
||||||
|
reading: ReadingSettings,
|
||||||
|
writing: WritingSettings,
|
||||||
|
speaking: SpeakingSettings,
|
||||||
|
listening: ListeningSettings,
|
||||||
|
level: LevelSettings
|
||||||
|
};
|
||||||
|
|
||||||
|
const Settings = ModuleSettings[currentModule];
|
||||||
|
|
||||||
|
const showImport = importModule && (currentModule === "reading" || currentModule === "listening" || currentModule === "level");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showImport ? <ImportOrStartFromScratch module={currentModule} /> : (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 w-full items-center">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name="minTimer"
|
||||||
|
onChange={(e) => updateModule({ minTimer: parseInt(e) < 15 ? 15 : parseInt(e) })}
|
||||||
|
value={minTimer}
|
||||||
|
className="max-w-[300px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 flex-grow">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||||
|
<Select
|
||||||
|
options={DIFFICULTIES.map((x) => ({ value: x, label: capitalize(x) }))}
|
||||||
|
onChange={(value) => value && updateModule({ difficulty: value.value as Difficulty })}
|
||||||
|
value={{ value: difficulty, label: capitalize(difficulty) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{(sectionLabels.length != 0 && currentModule !== "level") ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">{sectionLabels[0].label.split(" ")[0]}</label>
|
||||||
|
<div className="flex flex-row gap-8">
|
||||||
|
{sectionLabels.map(({ id, label }) => (
|
||||||
|
<span
|
||||||
|
key={id}
|
||||||
|
className={clsx(
|
||||||
|
"px-6 py-4 w-48 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
sectionIds.includes(id)
|
||||||
|
? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white`
|
||||||
|
: "bg-white border-mti-gray-platinum"
|
||||||
|
)}
|
||||||
|
onClick={() => toggleSection(id)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3 w-1/3">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Number of Parts</label>
|
||||||
|
<Input type="number" name="Number of Parts" onChange={(v) => setNumberOfParts(parseInt(v))} value={numberOfParts} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-3 w-fit h-fit">
|
||||||
|
<div className="h-6" />
|
||||||
|
<Checkbox isChecked={isPrivate} onChange={(checked) => updateModule({ isPrivate: checked })}>
|
||||||
|
Privacy (Only available for Assignments)
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Exam Label *</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Exam Label"
|
||||||
|
name="label"
|
||||||
|
onChange={(text) => updateModule({ examLabel: text })}
|
||||||
|
roundness="xl"
|
||||||
|
defaultValue={examLabel}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row gap-8">
|
||||||
|
<Settings />
|
||||||
|
<div className="flex-grow max-w-[66%]">
|
||||||
|
<SectionRenderer />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExamEditor;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
|
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import reactStringReplace from "react-string-replace";
|
import reactStringReplace from "react-string-replace";
|
||||||
@@ -17,16 +17,28 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
|||||||
words,
|
words,
|
||||||
userSolutions,
|
userSolutions,
|
||||||
variant,
|
variant,
|
||||||
|
preview,
|
||||||
onNext,
|
onNext,
|
||||||
onBack,
|
onBack,
|
||||||
}) => {
|
}) => {
|
||||||
const { shuffles, exam, partIndex, questionIndex, exerciseIndex } = useExamStore((state) => state);
|
|
||||||
|
const examState = useExamStore((state) => state);
|
||||||
|
const persistentExamState = usePersistentExamStore((state) => state);
|
||||||
|
|
||||||
|
const {
|
||||||
|
hasExamEnded,
|
||||||
|
exerciseIndex,
|
||||||
|
partIndex,
|
||||||
|
questionIndex,
|
||||||
|
shuffles,
|
||||||
|
exam,
|
||||||
|
setCurrentSolution,
|
||||||
|
} = !preview ? examState : persistentExamState;
|
||||||
|
|
||||||
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
|
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
|
||||||
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
|
|
||||||
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
|
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const excludeWordMCType = (x: any) => {
|
const excludeWordMCType = (x: any) => {
|
||||||
return typeof x === "string" ? x : (x as { letter: string; word: string });
|
return typeof x === "string" ? x : (x as { letter: string; word: string });
|
||||||
};
|
};
|
||||||
@@ -88,30 +100,30 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
|||||||
const renderLines = useCallback(
|
const renderLines = useCallback(
|
||||||
(line: string) => {
|
(line: string) => {
|
||||||
return (
|
return (
|
||||||
<div className="text-xl leading-5" key={v4()} ref={dropdownRef}>
|
<div className="text-xl leading-relaxed" key={v4()} ref={dropdownRef}>
|
||||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
{reactStringReplace(line, /({{\d+}})/g, (match, i, original) => {
|
||||||
const id = match.replaceAll(/[\{\}]/g, "");
|
const id = match.replaceAll(/[\{\}]/g, "");
|
||||||
const userSolution = answers.find((x) => x.id === id);
|
const userSolution = answers.find((x) => x.id === id);
|
||||||
const styles = clsx(
|
const styles = clsx(
|
||||||
"rounded-full hover:text-white transition duration-300 ease-in-out my-1 px-5 py-2 text-center w-fit",
|
"rounded-full hover:text-white transition duration-300 ease-in-out my-1 px-5 py-2 text-center w-fit inline-block",
|
||||||
!userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight",
|
!userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight",
|
||||||
userSolution && "text-center text-mti-purple-dark bg-mti-purple-ultralight",
|
userSolution && "text-center text-mti-purple-dark bg-mti-purple-ultralight",
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentSelection = words.find((x) => {
|
const currentSelection = words.find((x) => {
|
||||||
if (typeof x !== "string" && "id" in x) {
|
if (typeof x !== "string" && "id" in x) {
|
||||||
return (x as FillBlanksMCOption).id.toString() == id.toString();
|
return (x as FillBlanksMCOption).id.toString() == id.toString();
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}) as FillBlanksMCOption;
|
}) as FillBlanksMCOption;
|
||||||
|
|
||||||
return variant === "mc" ? (
|
return variant === "mc" ? (
|
||||||
<MCDropdown
|
<MCDropdown
|
||||||
id={id}
|
id={id}
|
||||||
options={currentSelection.options}
|
options={currentSelection.options}
|
||||||
onSelect={(value) => onSelection(id, value)}
|
onSelect={(value) => onSelection(id, value)}
|
||||||
selectedValue={userSolution?.solution}
|
selectedValue={userSolution?.solution}
|
||||||
className="inline-block py-2 px-1"
|
className="inline-block py-2 px-1 align-middle"
|
||||||
width={220}
|
width={220}
|
||||||
isOpen={openDropdownId === id}
|
isOpen={openDropdownId === id}
|
||||||
onToggle={()=> setOpenDropdownId(prevId => prevId === id ? null : id)}
|
onToggle={()=> setOpenDropdownId(prevId => prevId === id ? null : id)}
|
||||||
@@ -123,14 +135,13 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
|||||||
value={userSolution?.solution}
|
value={userSolution?.solution}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
}
|
</div>
|
||||||
</div >
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[variant, words, answers, openDropdownId],
|
[variant, words, answers, openDropdownId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const memoizedLines = useMemo(() => {
|
const memoizedLines = useMemo(() => {
|
||||||
return text.split("\\n").map((line, index) => (
|
return text.split("\\n").map((line, index) => (
|
||||||
<p key={index} className={clsx(variant === "mc" && "whitespace-pre-wrap")}>
|
<p key={index} className={clsx(variant === "mc" && "whitespace-pre-wrap")}>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default function Writing({
|
|||||||
userSolutions,
|
userSolutions,
|
||||||
onNext,
|
onNext,
|
||||||
onBack,
|
onBack,
|
||||||
|
enableNavigation = false
|
||||||
}: WritingExercise & CommonProps) {
|
}: WritingExercise & CommonProps) {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
|
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
|
||||||
@@ -73,7 +74,7 @@ export default function Writing({
|
|||||||
const words = inputText.split(" ").filter((x) => x !== "");
|
const words = inputText.split(" ").filter((x) => x !== "");
|
||||||
|
|
||||||
if (wordCounter.type === "min") {
|
if (wordCounter.type === "min") {
|
||||||
setIsSubmitEnabled(wordCounter.limit <= words.length);
|
setIsSubmitEnabled(wordCounter.limit <= words.length || enableNavigation);
|
||||||
} else {
|
} else {
|
||||||
setIsSubmitEnabled(true);
|
setIsSubmitEnabled(true);
|
||||||
if (wordCounter.limit < words.length) {
|
if (wordCounter.limit < words.length) {
|
||||||
@@ -81,7 +82,7 @@ export default function Writing({
|
|||||||
setInputText(words.slice(0, words.length - 1).join(" "));
|
setInputText(words.slice(0, words.length - 1).join(" "));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [inputText, wordCounter]);
|
}, [enableNavigation, inputText, wordCounter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 mt-4">
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export interface CommonProps {
|
|||||||
examID?: string;
|
examID?: string;
|
||||||
onNext: (userSolutions: UserSolution) => void;
|
onNext: (userSolutions: UserSolution) => void;
|
||||||
onBack: (userSolutions: UserSolution) => void;
|
onBack: (userSolutions: UserSolution) => void;
|
||||||
|
enableNavigation?: boolean;
|
||||||
|
preview?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const renderExercise = (
|
export const renderExercise = (
|
||||||
@@ -32,22 +34,24 @@ export const renderExercise = (
|
|||||||
examID: string,
|
examID: string,
|
||||||
onNext: (userSolutions: UserSolution) => void,
|
onNext: (userSolutions: UserSolution) => void,
|
||||||
onBack: (userSolutions: UserSolution) => void,
|
onBack: (userSolutions: UserSolution) => void,
|
||||||
|
enableNavigation?: boolean,
|
||||||
|
preview?: boolean,
|
||||||
) => {
|
) => {
|
||||||
switch (exercise.type) {
|
switch (exercise.type) {
|
||||||
case "fillBlanks":
|
case "fillBlanks":
|
||||||
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
|
||||||
case "trueFalse":
|
case "trueFalse":
|
||||||
return <TrueFalse key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
return <TrueFalse key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
|
||||||
case "matchSentences":
|
case "matchSentences":
|
||||||
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview}/>;
|
||||||
case "multipleChoice":
|
case "multipleChoice":
|
||||||
return <MultipleChoice key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
return <MultipleChoice key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
|
||||||
case "writeBlanks":
|
case "writeBlanks":
|
||||||
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
|
||||||
case "writing":
|
case "writing":
|
||||||
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} examID={examID} enableNavigation={enableNavigation} preview={preview}/>;
|
||||||
case "speaking":
|
case "speaking":
|
||||||
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
|
||||||
case "interactiveSpeaking":
|
case "interactiveSpeaking":
|
||||||
return (
|
return (
|
||||||
<InteractiveSpeaking
|
<InteractiveSpeaking
|
||||||
@@ -56,6 +60,7 @@ export const renderExercise = (
|
|||||||
examID={examID}
|
examID={examID}
|
||||||
onNext={onNext}
|
onNext={onNext}
|
||||||
onBack={onBack}
|
onBack={onBack}
|
||||||
|
preview={preview}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
|
|
||||||
import React from "react";
|
|
||||||
import Input from "@/components/Low/Input";
|
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
exercise: FillBlanksExercise;
|
|
||||||
updateExercise: (data: any) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FillBlanksEdit = (props: Props) => {
|
|
||||||
const { exercise, updateExercise } = props;
|
|
||||||
|
|
||||||
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
|
|
||||||
return Array.isArray(words) && words.every((word) => word && typeof word === "object" && "id" in word && "options" in word);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
type={exercise?.variant && exercise.variant === "mc" ? "textarea" : "text"}
|
|
||||||
label="Prompt"
|
|
||||||
name="prompt"
|
|
||||||
required
|
|
||||||
value={exercise.prompt}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
prompt: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type={exercise?.variant && exercise.variant === "mc" ? "textarea" : "text"}
|
|
||||||
label="Text"
|
|
||||||
name="text"
|
|
||||||
required
|
|
||||||
value={exercise.text}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
text: exercise?.variant && exercise.variant === "mc" ? value : value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<h1 className="mt-4">Solutions</h1>
|
|
||||||
<div className="w-full flex flex-wrap -mx-2">
|
|
||||||
{exercise.solutions.map((solution, index) => (
|
|
||||||
<div key={solution.id} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label={`Solution ${index + 1}`}
|
|
||||||
name="solution"
|
|
||||||
required
|
|
||||||
value={solution.solution}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
solutions: exercise.solutions.map((sol) => (sol.id === solution.id ? { ...sol, solution: value } : sol)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<h1 className="mt-4">Words</h1>
|
|
||||||
<div className={clsx(exercise?.variant && exercise.variant === "mc" ? "w-full flex flex-row" : "w-full flex flex-wrap -mx-2")}>
|
|
||||||
{exercise?.variant && exercise.variant === "mc" && typeCheckWordsMC(exercise.words) ?
|
|
||||||
(
|
|
||||||
<div className="flex flex-col w-full">
|
|
||||||
{exercise.words.flatMap((mcOptions, wordIndex) =>
|
|
||||||
<>
|
|
||||||
<label className="font-semibold">{`Word ${wordIndex + 1}`}</label>
|
|
||||||
<div className="flex flex-row">
|
|
||||||
{Object.entries(mcOptions.options).map(([key, value], optionIndex) => (
|
|
||||||
<div key={`${wordIndex}-${optionIndex}-${key}`} className="flex sm:w-1/2 lg:w-1/4 px-2 mb-4">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label={`Option ${key}`}
|
|
||||||
name="word"
|
|
||||||
required
|
|
||||||
value={value}
|
|
||||||
onChange={(newValue) =>
|
|
||||||
updateExercise({
|
|
||||||
words: exercise.words.map((word, idx) =>
|
|
||||||
idx === wordIndex
|
|
||||||
? {
|
|
||||||
...(word as FillBlanksMCOption),
|
|
||||||
options: {
|
|
||||||
...(word as FillBlanksMCOption).options,
|
|
||||||
[key]: newValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: word
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
:
|
|
||||||
(
|
|
||||||
exercise.words.map((word, index) => (
|
|
||||||
<div key={index} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label={`Word ${index + 1}`}
|
|
||||||
name="word"
|
|
||||||
required
|
|
||||||
value={typeof word === "string" ? word : ("word" in word ? word.word : "")}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
words: exercise.words.map((sol, idx) =>
|
|
||||||
index === idx ? (typeof word === "string" ? value : { ...word, word: value }) : sol,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FillBlanksEdit;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
const InteractiveSpeakingEdit = () => {
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default InteractiveSpeakingEdit;
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { MatchSentencesExercise } from "@/interfaces/exam";
|
|
||||||
import Input from "@/components/Low/Input";
|
|
||||||
import Select from "@/components/Low/Select";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
exercise: MatchSentencesExercise;
|
|
||||||
updateExercise: (data: any) => void;
|
|
||||||
}
|
|
||||||
const MatchSentencesEdit = (props: Props) => {
|
|
||||||
const { exercise, updateExercise } = props;
|
|
||||||
|
|
||||||
const selectOptions = exercise.options.map((option) => ({
|
|
||||||
value: option.id,
|
|
||||||
label: option.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label="Prompt"
|
|
||||||
name="prompt"
|
|
||||||
required
|
|
||||||
value={exercise.prompt}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
prompt: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<h1>Solutions</h1>
|
|
||||||
<div className="w-full flex flex-wrap -mx-2">
|
|
||||||
{exercise.sentences.map((sentence, index) => (
|
|
||||||
<div key={sentence.id} className="flex flex-col w-full px-2">
|
|
||||||
<div className="flex w-full">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label={`Sentence ${index + 1}`}
|
|
||||||
name="sentence"
|
|
||||||
required
|
|
||||||
value={sentence.sentence}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
sentences: exercise.sentences.map((iSol) =>
|
|
||||||
iSol.id === sentence.id
|
|
||||||
? {
|
|
||||||
...iSol,
|
|
||||||
sentence: value,
|
|
||||||
}
|
|
||||||
: iSol
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="px-2"
|
|
||||||
/>
|
|
||||||
<div className="w-48 flex items-end px-2">
|
|
||||||
<Select
|
|
||||||
value={selectOptions.find(
|
|
||||||
(o) => o.value === sentence.solution
|
|
||||||
)}
|
|
||||||
options={selectOptions}
|
|
||||||
onChange={(value) => {
|
|
||||||
updateExercise({
|
|
||||||
sentences: exercise.sentences.map((iSol) =>
|
|
||||||
iSol.id === sentence.id
|
|
||||||
? {
|
|
||||||
...iSol,
|
|
||||||
solution: value?.value,
|
|
||||||
}
|
|
||||||
: iSol
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<h1>Options</h1>
|
|
||||||
<div className="w-full flex flex-wrap -mx-2">
|
|
||||||
{exercise.options.map((option, index) => (
|
|
||||||
<div key={option.id} className="flex flex-col w-full px-2">
|
|
||||||
<div className="flex w-full">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label={`Option ${index + 1}`}
|
|
||||||
name="option"
|
|
||||||
required
|
|
||||||
value={option.sentence}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
options: exercise.options.map((iSol) =>
|
|
||||||
iSol.id === option.id
|
|
||||||
? {
|
|
||||||
...iSol,
|
|
||||||
sentence: value,
|
|
||||||
}
|
|
||||||
: iSol
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="px-2"
|
|
||||||
/>
|
|
||||||
<div className="w-48 flex items-end px-2">
|
|
||||||
<Select
|
|
||||||
value={{
|
|
||||||
value: option.id,
|
|
||||||
label: option.id,
|
|
||||||
}}
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
value: option.id,
|
|
||||||
label: option.id,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
disabled
|
|
||||||
onChange={() => {}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MatchSentencesEdit;
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import Input from "@/components/Low/Input";
|
|
||||||
import {
|
|
||||||
MultipleChoiceExercise,
|
|
||||||
MultipleChoiceQuestion,
|
|
||||||
} from "@/interfaces/exam";
|
|
||||||
import Select from "@/components/Low/Select";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
exercise: MultipleChoiceExercise;
|
|
||||||
updateExercise: (data: any) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const variantOptions = [
|
|
||||||
{ value: "text", label: "Text", key: "text" },
|
|
||||||
{ value: "image", label: "Image", key: "src" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const MultipleChoiceEdit = (props: Props) => {
|
|
||||||
const { exercise, updateExercise } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h1>Questions</h1>
|
|
||||||
<div className="w-full flex-no-wrap -mx-2">
|
|
||||||
{exercise.questions.map((question: MultipleChoiceQuestion, index) => {
|
|
||||||
const variantValue = variantOptions.find(
|
|
||||||
(o) => o.value === question.variant
|
|
||||||
);
|
|
||||||
|
|
||||||
const solutionsOptions = question.options.map((option) => ({
|
|
||||||
value: option.id,
|
|
||||||
label: option.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const solutionValue = solutionsOptions.find(
|
|
||||||
(o) => o.value === question.solution
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div key={question.id} className="flex w-full px-2 flex-col">
|
|
||||||
<span>Question ID: {question.id}</span>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label="Prompt"
|
|
||||||
name="prompt"
|
|
||||||
required
|
|
||||||
value={question.prompt}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
questions: exercise.questions.map((sol) =>
|
|
||||||
sol.id === question.id ? { ...sol, prompt: value } : sol
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className="flex w-full">
|
|
||||||
<div className="w-48 flex items-end px-2">
|
|
||||||
<Select
|
|
||||||
value={solutionValue}
|
|
||||||
options={solutionsOptions}
|
|
||||||
onChange={(value) => {
|
|
||||||
updateExercise({
|
|
||||||
questions: exercise.questions.map((sol) =>
|
|
||||||
sol.id === question.id
|
|
||||||
? { ...sol, solution: value?.value }
|
|
||||||
: sol
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-48 flex items-end px-2">
|
|
||||||
<Select
|
|
||||||
value={variantValue}
|
|
||||||
options={variantOptions}
|
|
||||||
onChange={(value) => {
|
|
||||||
updateExercise({
|
|
||||||
questions: exercise.questions.map((sol) =>
|
|
||||||
sol.id === question.id
|
|
||||||
? { ...sol, variant: value?.value }
|
|
||||||
: sol
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full flex-wrap -mx-2">
|
|
||||||
{question.options.map((option) => (
|
|
||||||
<div
|
|
||||||
key={option.id}
|
|
||||||
className="flex sm:w-1/2 lg:w-1/4 px-2 px-2"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label={`Option ${option.id}`}
|
|
||||||
name="option"
|
|
||||||
required
|
|
||||||
value={option.text}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
questions: exercise.questions.map((sol) =>
|
|
||||||
sol.id === question.id
|
|
||||||
? {
|
|
||||||
...sol,
|
|
||||||
options: sol.options.map((opt) => {
|
|
||||||
if (
|
|
||||||
opt.id === option.id &&
|
|
||||||
variantValue?.key
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
...opt,
|
|
||||||
[variantValue.key]: value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return opt;
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
: sol
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MultipleChoiceEdit;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
const SpeakingEdit = () => {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SpeakingEdit;
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { TrueFalseExercise } from "@/interfaces/exam";
|
|
||||||
import Input from "@/components/Low/Input";
|
|
||||||
import Select from "@/components/Low/Select";
|
|
||||||
interface Props {
|
|
||||||
exercise: TrueFalseExercise;
|
|
||||||
updateExercise: (data: any) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = [
|
|
||||||
{ value: "true", label: "True" },
|
|
||||||
{ value: "false", label: "False" },
|
|
||||||
{ value: "not_given", label: "Not Given" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const TrueFalseEdit = (props: Props) => {
|
|
||||||
const { exercise, updateExercise } = props;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label="Prompt"
|
|
||||||
name="prompt"
|
|
||||||
required
|
|
||||||
value={exercise.prompt}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
prompt: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<h1>Questions</h1>
|
|
||||||
<div className="w-full flex-no-wrap -mx-2">
|
|
||||||
{exercise.questions.map((question, index) => (
|
|
||||||
<div key={question.id} className="flex w-full px-2">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label={`Question ${index + 1}`}
|
|
||||||
name="question"
|
|
||||||
required
|
|
||||||
value={question.prompt}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
questions: exercise.questions.map((sol) =>
|
|
||||||
sol.id === question.id ? { ...sol, prompt: value } : sol
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className="w-48 flex items-end px-2">
|
|
||||||
<Select
|
|
||||||
value={options.find((o) => o.value === question.solution)}
|
|
||||||
options={options}
|
|
||||||
onChange={(value) => {
|
|
||||||
updateExercise({
|
|
||||||
questions: exercise.questions.map((sol) =>
|
|
||||||
sol.id === question.id ? { ...sol, solution: value?.value } : sol
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="h-18"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TrueFalseEdit;
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import Input from "@/components/Low/Input";
|
|
||||||
import { WriteBlanksExercise } from "@/interfaces/exam";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
exercise: WriteBlanksExercise;
|
|
||||||
updateExercise: (data: any) => void;
|
|
||||||
}
|
|
||||||
const WriteBlankEdits = (props: Props) => {
|
|
||||||
const { exercise, updateExercise } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label="Prompt"
|
|
||||||
name="prompt"
|
|
||||||
required
|
|
||||||
value={exercise.prompt}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
prompt: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label="Text"
|
|
||||||
name="text"
|
|
||||||
required
|
|
||||||
value={exercise.text}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
text: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label="Max Words"
|
|
||||||
name="number"
|
|
||||||
required
|
|
||||||
value={exercise.maxWords}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
maxWords: Number(value),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<h1>Solutions</h1>
|
|
||||||
<div className="w-full flex flex-wrap -mx-2">
|
|
||||||
{exercise.solutions.map((solution) => (
|
|
||||||
<div key={solution.id} className="flex flex-col w-full px-2">
|
|
||||||
<span>Solution ID: {solution.id}</span>
|
|
||||||
{/* TODO: Consider adding an add and delete button */}
|
|
||||||
<div className="flex flex-wrap">
|
|
||||||
{solution.solution.map((sol, solIndex) => (
|
|
||||||
<Input
|
|
||||||
key={`${sol}-${solIndex}`}
|
|
||||||
type="text"
|
|
||||||
label={`Solution ${solIndex + 1}`}
|
|
||||||
name="solution"
|
|
||||||
required
|
|
||||||
value={sol}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
solutions: exercise.solutions.map((iSol) =>
|
|
||||||
iSol.id === solution.id
|
|
||||||
? {
|
|
||||||
...iSol,
|
|
||||||
solution: iSol.solution.map((iiSol, iiIndex) => {
|
|
||||||
if (iiIndex === solIndex) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return iiSol;
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
: iSol
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="sm:w-1/2 lg:w-1/4 px-2"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WriteBlankEdits;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
const WritingEdit = () => {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default WritingEdit;
|
|
||||||
62
src/components/Low/AutoExpandingTextInput.tsx
Normal file
62
src/components/Low/AutoExpandingTextInput.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import React, { useEffect, useRef, ChangeEvent } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
className?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AutoExpandingTextInput: React.FC<Props> = ({
|
||||||
|
value,
|
||||||
|
className,
|
||||||
|
onChange,
|
||||||
|
placeholder
|
||||||
|
}) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const measureRef = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
|
const adjustWidth = () => {
|
||||||
|
const input = inputRef.current;
|
||||||
|
const measure = measureRef.current;
|
||||||
|
if (input && measure) {
|
||||||
|
measure.textContent = input.value || placeholder || '';
|
||||||
|
input.style.width = `${measure.offsetWidth + 10}px`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adjustWidth();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [value, placeholder]);
|
||||||
|
|
||||||
|
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
adjustWidth();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={className}
|
||||||
|
placeholder={placeholder}
|
||||||
|
style={{ minWidth: '50px' }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
ref={measureRef}
|
||||||
|
className="absolute invisible whitespace-pre"
|
||||||
|
style={{
|
||||||
|
font: 'inherit',
|
||||||
|
padding: '0 4px',
|
||||||
|
border: '2px solid transparent',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AutoExpandingTextInput;
|
||||||
53
src/components/Low/AutoExpandingTextarea.tsx
Executable file
53
src/components/Low/AutoExpandingTextarea.tsx
Executable file
@@ -0,0 +1,53 @@
|
|||||||
|
import React, { useEffect, useRef, ChangeEvent } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
className?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
onBlur?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AutoExpandingTextArea: React.FC<Props> = ({
|
||||||
|
value,
|
||||||
|
className = 'w-full cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl',
|
||||||
|
placeholder = "Enter text here...",
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
}) => {
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
const adjustHeight = () => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (textarea) {
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adjustHeight();
|
||||||
|
const timer = setTimeout(adjustHeight, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
adjustHeight();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={className}
|
||||||
|
placeholder={placeholder}
|
||||||
|
style={{ overflow: 'hidden', resize: 'none' }}
|
||||||
|
onBlur={onBlur}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AutoExpandingTextArea;
|
||||||
@@ -1,12 +1,7 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {ComponentProps, useEffect, useState} from "react";
|
import {ComponentProps, useEffect, useState} from "react";
|
||||||
import ReactSelect, {GroupBase, StylesConfig} from "react-select";
|
import ReactSelect, {GroupBase, StylesConfig} from "react-select";
|
||||||
|
import Option from "@/interfaces/option";
|
||||||
interface Option {
|
|
||||||
[key: string]: any;
|
|
||||||
value: string | null;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
defaultValue?: Option;
|
defaultValue?: Option;
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
import { Module } from "@/interfaces";
|
|
||||||
import { moduleLabels } from "@/utils/moduleUtils";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { ReactNode, useState } from "react";
|
|
||||||
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch } from "react-icons/bs";
|
|
||||||
import ProgressBar from "../Low/ProgressBar";
|
|
||||||
import Timer from "./Timer";
|
|
||||||
import { Exercise, LevelExam, MultipleChoiceExercise, ShuffleMap, UserSolution } from "@/interfaces/exam";
|
|
||||||
import { BsFillGrid3X3GapFill } from "react-icons/bs";
|
|
||||||
import Button from "../Low/Button";
|
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import Modal from "../Modal";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
minTimer: number;
|
|
||||||
module: Module;
|
|
||||||
examLabel?: string;
|
|
||||||
label?: string;
|
|
||||||
exerciseIndex: number;
|
|
||||||
totalExercises: number;
|
|
||||||
disableTimer?: boolean;
|
|
||||||
partLabel?: string;
|
|
||||||
showTimer?: boolean;
|
|
||||||
showSolutions?: boolean;
|
|
||||||
currentExercise?: Exercise;
|
|
||||||
runOnClick?: ((questionIndex: number) => void) | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ModuleTitle({
|
|
||||||
minTimer,
|
|
||||||
module,
|
|
||||||
label,
|
|
||||||
examLabel,
|
|
||||||
exerciseIndex,
|
|
||||||
totalExercises,
|
|
||||||
disableTimer = false,
|
|
||||||
partLabel,
|
|
||||||
showTimer = true,
|
|
||||||
showSolutions = false,
|
|
||||||
runOnClick = undefined
|
|
||||||
}: Props) {
|
|
||||||
const {
|
|
||||||
userSolutions,
|
|
||||||
partIndex,
|
|
||||||
exam
|
|
||||||
} = useExamStore((state) => state);
|
|
||||||
const examExerciseIndex = useExamStore((state) => state.exerciseIndex)
|
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
const moduleIcon: { [key in Module]: ReactNode } = {
|
|
||||||
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
|
|
||||||
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
|
|
||||||
writing: <BsPen className="text-ielts-writing w-6 h-6" />,
|
|
||||||
speaking: <BsMegaphone className="text-ielts-speaking w-6 h-6" />,
|
|
||||||
level: <BsClipboard className="text-ielts-level w-6 h-6" />,
|
|
||||||
};
|
|
||||||
|
|
||||||
const isMultipleChoiceLevelExercise = () => {
|
|
||||||
if (exam?.module === 'level' && typeof partIndex === "number" && partIndex > -1) {
|
|
||||||
const currentExercise = (exam as LevelExam).parts[partIndex].exercises[examExerciseIndex];
|
|
||||||
return currentExercise && currentExercise.type === 'multipleChoice';
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderMCQuestionGrid = () => {
|
|
||||||
if (!isMultipleChoiceLevelExercise() && !userSolutions) return null;
|
|
||||||
|
|
||||||
const currentExercise = (exam as LevelExam).parts[partIndex!].exercises[examExerciseIndex] as MultipleChoiceExercise;
|
|
||||||
const userSolution = userSolutions!.find((x) => x.exercise.toString() == currentExercise.id.toString())!;
|
|
||||||
const answeredQuestions = new Set(userSolution.solutions.map(sol => sol.question.toString()));
|
|
||||||
const exerciseOffset = Number(currentExercise.questions[0].id);
|
|
||||||
const lastExercise = exerciseOffset + (currentExercise.questions.length - 1);
|
|
||||||
|
|
||||||
const getQuestionColor = (questionId: string, solution: string, userQuestionSolution: string | undefined) => {
|
|
||||||
const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => {
|
|
||||||
if (foundMap) return foundMap;
|
|
||||||
return userSolution.shuffleMaps?.find(map => map.questionID.toString() === questionId.toString()) || null;
|
|
||||||
}, null as ShuffleMap | null);
|
|
||||||
const newSolution = questionShuffleMap ? questionShuffleMap?.map[solution] : solution;
|
|
||||||
|
|
||||||
if (!userSolutions) return "";
|
|
||||||
|
|
||||||
if (!userQuestionSolution) {
|
|
||||||
return "!bg-mti-gray-davy !border--mti-gray-davy !text-mti-gray-davy !text-white hover:!bg-gray-700";
|
|
||||||
}
|
|
||||||
|
|
||||||
return userQuestionSolution === newSolution ?
|
|
||||||
"!bg-mti-purple-light !text-mti-purple-light !text-white hover:!bg-mti-purple-dark" :
|
|
||||||
"!bg-mti-rose-light !border-mti-rose-light !text-mti-rose-light !text-white hover:!bg-mti-rose-dark";
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h3 className="text-xl font-semibold mb-4 text-center">{`Part ${partIndex + 1} (Questions ${exerciseOffset} - ${lastExercise})`}</h3>
|
|
||||||
<div className="grid grid-cols-5 gap-3 px-4 py-2">
|
|
||||||
{currentExercise.questions.map((_, index) => {
|
|
||||||
const questionNumber = exerciseOffset + index;
|
|
||||||
const isAnswered = answeredQuestions.has(questionNumber.toString());
|
|
||||||
const solution = currentExercise.questions.find((x) => x.id.toString() == questionNumber.toString())!.solution;
|
|
||||||
|
|
||||||
const userQuestionSolution = currentExercise.userSolutions?.find((x) => x.question.toString() == questionNumber.toString())?.option;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant={showSolutions ? "solid" : (isAnswered ? "solid" : "outline")}
|
|
||||||
key={index}
|
|
||||||
className={clsx(
|
|
||||||
"w-12 h-12 flex items-center justify-center rounded-lg text-sm font-bold transition-all duration-200 ease-in-out",
|
|
||||||
(showSolutions ?
|
|
||||||
getQuestionColor(questionNumber.toString(), solution, userQuestionSolution) :
|
|
||||||
(isAnswered ?
|
|
||||||
"bg-mti-purple-light border-mti-purple-light text-white hover:bg-mti-purple-dark hover:border-mti-purple-dark" :
|
|
||||||
"bg-white border-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
onClick={() => { if (typeof runOnClick !== "undefined") { runOnClick(index); } setIsOpen(false); }}
|
|
||||||
>
|
|
||||||
{questionNumber}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<p className="mt-4 text-sm text-gray-600 text-center">
|
|
||||||
Click a question number to jump to that question
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{showTimer && <Timer minTimer={minTimer} disableTimer={disableTimer} />}
|
|
||||||
<div className="w-full">
|
|
||||||
{partLabel && (
|
|
||||||
<div className="text-3xl space-y-4">
|
|
||||||
{partLabel.split("\n\n").map((partInstructions, index) => {
|
|
||||||
if (index === 0)
|
|
||||||
return (
|
|
||||||
<p key={index} className="font-bold">
|
|
||||||
{partInstructions}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
else
|
|
||||||
return (
|
|
||||||
<div key={index} className="text-2xl font-semibold flex flex-col gap-2">
|
|
||||||
{partInstructions.split("\\n").map((line, lineIndex) => (
|
|
||||||
<span key={lineIndex} dangerouslySetInnerHTML={{__html: line.replace('that is not correct', 'that is <span class="font-bold"><u>not correct</u></span>')}}></span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={clsx("flex gap-6 w-full h-fit items-center", partLabel ? "mt-10" : "mt-5")}>
|
|
||||||
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
|
|
||||||
<div className="flex flex-col gap-3 w-full">
|
|
||||||
<div className="w-full flex justify-between">
|
|
||||||
<span className="text-base font-semibold">
|
|
||||||
{module === "level"
|
|
||||||
? (examLabel ? examLabel : "Placement Test")
|
|
||||||
: `${moduleLabels[module]} exam${label ? ` - ${label}` : ''}`
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold self-end">
|
|
||||||
Question {exerciseIndex}/{totalExercises}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
|
|
||||||
</div>
|
|
||||||
{isMultipleChoiceLevelExercise() && (
|
|
||||||
<>
|
|
||||||
<Button variant="outline" onClick={() => setIsOpen(true)} padding="p-2" className="rounded-lg">
|
|
||||||
<BsFillGrid3X3GapFill size={24} />
|
|
||||||
</Button>
|
|
||||||
<Modal
|
|
||||||
isOpen={isOpen}
|
|
||||||
onClose={() => setIsOpen(false)}
|
|
||||||
className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white shadow-xl transition-all"
|
|
||||||
>
|
|
||||||
<>
|
|
||||||
{renderMCQuestionGrid()}
|
|
||||||
</>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
107
src/components/Medium/ModuleTitle/MCQuestionGrid.tsx
Normal file
107
src/components/Medium/ModuleTitle/MCQuestionGrid.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import { Exam, LevelExam, MultipleChoiceExercise, ShuffleMap } from "@/interfaces/exam";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { BsFillGrid3X3GapFill } from "react-icons/bs";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
showSolutions: boolean;
|
||||||
|
runOnClick: ((index: number) => void) | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MCQuestionGrid: React.FC<Props> = ({showSolutions, runOnClick}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
userSolutions,
|
||||||
|
partIndex: sectionIndex,
|
||||||
|
exerciseIndex,
|
||||||
|
exam
|
||||||
|
} = useExamStore((state) => state);
|
||||||
|
|
||||||
|
const isMultipleChoiceLevelExercise = () => {
|
||||||
|
if (exam?.module === 'level' && typeof sectionIndex === "number" && sectionIndex > -1) {
|
||||||
|
const currentExercise = (exam as LevelExam).parts[sectionIndex].exercises[exerciseIndex];
|
||||||
|
return currentExercise && currentExercise.type === 'multipleChoice';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isMultipleChoiceLevelExercise() && !userSolutions) return null;
|
||||||
|
|
||||||
|
const currentExercise = (exam as LevelExam).parts[sectionIndex!].exercises[exerciseIndex] as MultipleChoiceExercise;
|
||||||
|
const userSolution = userSolutions!.find((x) => x.exercise.toString() == currentExercise.id.toString())!;
|
||||||
|
const answeredQuestions = new Set(userSolution.solutions.map(sol => sol.question.toString()));
|
||||||
|
const exerciseOffset = Number(currentExercise.questions[0].id);
|
||||||
|
const lastExercise = exerciseOffset + (currentExercise.questions.length - 1);
|
||||||
|
|
||||||
|
const getQuestionColor = (questionId: string, solution: string, userQuestionSolution: string | undefined) => {
|
||||||
|
const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => {
|
||||||
|
if (foundMap) return foundMap;
|
||||||
|
return userSolution.shuffleMaps?.find(map => map.questionID.toString() === questionId.toString()) || null;
|
||||||
|
}, null as ShuffleMap | null);
|
||||||
|
const newSolution = questionShuffleMap ? questionShuffleMap?.map[solution] : solution;
|
||||||
|
|
||||||
|
if (!userSolutions) return "";
|
||||||
|
|
||||||
|
if (!userQuestionSolution) {
|
||||||
|
return "!bg-mti-gray-davy !border--mti-gray-davy !text-mti-gray-davy !text-white hover:!bg-gray-700";
|
||||||
|
}
|
||||||
|
|
||||||
|
return userQuestionSolution === newSolution ?
|
||||||
|
"!bg-mti-purple-light !text-mti-purple-light !text-white hover:!bg-mti-purple-dark" :
|
||||||
|
"!bg-mti-rose-light !border-mti-rose-light !text-mti-rose-light !text-white hover:!bg-mti-rose-dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setIsOpen(true)} padding="p-2" className="rounded-lg">
|
||||||
|
<BsFillGrid3X3GapFill size={24} />
|
||||||
|
</Button>
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white shadow-xl transition-all"
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<h3 className="text-xl font-semibold mb-4 text-center">{`Part ${sectionIndex + 1} (Questions ${exerciseOffset} - ${lastExercise})`}</h3>
|
||||||
|
<div className="grid grid-cols-5 gap-3 px-4 py-2">
|
||||||
|
{currentExercise.questions.map((_, index) => {
|
||||||
|
const questionNumber = exerciseOffset + index;
|
||||||
|
const isAnswered = answeredQuestions.has(questionNumber.toString());
|
||||||
|
const solution = currentExercise.questions.find((x) => x.id.toString() == questionNumber.toString())!.solution;
|
||||||
|
|
||||||
|
const userQuestionSolution = currentExercise.userSolutions?.find((x) => x.question.toString() == questionNumber.toString())?.option;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant={showSolutions ? "solid" : (isAnswered ? "solid" : "outline")}
|
||||||
|
key={index}
|
||||||
|
className={clsx(
|
||||||
|
"w-12 h-12 flex items-center justify-center rounded-lg text-sm font-bold transition-all duration-200 ease-in-out",
|
||||||
|
(showSolutions ?
|
||||||
|
getQuestionColor(questionNumber.toString(), solution, userQuestionSolution) :
|
||||||
|
(isAnswered ?
|
||||||
|
"bg-mti-purple-light border-mti-purple-light text-white hover:bg-mti-purple-dark hover:border-mti-purple-dark" :
|
||||||
|
"bg-white border-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
onClick={() => { if (typeof runOnClick !== "undefined") { runOnClick(index); } setIsOpen(false); }}
|
||||||
|
>
|
||||||
|
{questionNumber}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm text-gray-600 text-center">
|
||||||
|
Click a question number to jump to that question
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MCQuestionGrid;
|
||||||
95
src/components/Medium/ModuleTitle/index.tsx
Normal file
95
src/components/Medium/ModuleTitle/index.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import { moduleLabels } from "@/utils/moduleUtils";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { ReactNode, useState } from "react";
|
||||||
|
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
|
||||||
|
import ProgressBar from "../../Low/ProgressBar";
|
||||||
|
import Timer from "../Timer";
|
||||||
|
import { Exercise } from "@/interfaces/exam";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import React from "react";
|
||||||
|
import MCQuestionGrid from "./MCQuestionGrid";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
minTimer: number;
|
||||||
|
module: Module;
|
||||||
|
examLabel?: string;
|
||||||
|
label?: string;
|
||||||
|
exerciseIndex: number;
|
||||||
|
totalExercises: number;
|
||||||
|
disableTimer?: boolean;
|
||||||
|
partLabel?: string;
|
||||||
|
showTimer?: boolean;
|
||||||
|
showSolutions?: boolean;
|
||||||
|
currentExercise?: Exercise;
|
||||||
|
runOnClick?: ((questionIndex: number) => void) | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ModuleTitle({
|
||||||
|
minTimer,
|
||||||
|
module,
|
||||||
|
label,
|
||||||
|
examLabel,
|
||||||
|
exerciseIndex,
|
||||||
|
totalExercises,
|
||||||
|
disableTimer = false,
|
||||||
|
partLabel,
|
||||||
|
showTimer = true,
|
||||||
|
showSolutions = false,
|
||||||
|
runOnClick = undefined
|
||||||
|
}: Props) {
|
||||||
|
const {
|
||||||
|
exam
|
||||||
|
} = useExamStore((state) => state);
|
||||||
|
|
||||||
|
const moduleIcon: { [key in Module]: ReactNode } = {
|
||||||
|
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
|
||||||
|
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
|
||||||
|
writing: <BsPen className="text-ielts-writing w-6 h-6" />,
|
||||||
|
speaking: <BsMegaphone className="text-ielts-speaking w-6 h-6" />,
|
||||||
|
level: <BsClipboard className="text-ielts-level w-6 h-6" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showTimer && <Timer minTimer={minTimer} disableTimer={disableTimer} />}
|
||||||
|
<div className="w-full">
|
||||||
|
{partLabel && (
|
||||||
|
<div className="text-3xl space-y-4">
|
||||||
|
{partLabel.split("\n\n").map((partInstructions, index) => {
|
||||||
|
if (index === 0)
|
||||||
|
return (
|
||||||
|
<p key={index} className="font-bold">
|
||||||
|
{partInstructions}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
else
|
||||||
|
return (
|
||||||
|
<div key={index} className="text-2xl font-semibold flex flex-col gap-2">
|
||||||
|
{partInstructions.split("\\n").map((line, lineIndex) => (
|
||||||
|
<span key={lineIndex} dangerouslySetInnerHTML={{__html: line.replace('that is not correct', 'that is <span class="font-bold"><u>not correct</u></span>')}}></span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={clsx("flex gap-6 w-full h-fit items-center", partLabel ? "mt-10" : "mt-5")}>
|
||||||
|
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<div className="w-full flex justify-between">
|
||||||
|
<span className="text-base font-semibold">
|
||||||
|
{examLabel ? examLabel : (module === "level" ? "Placement Test" : `${moduleLabels[module]} exam${label ? ` - ${label}` : ''}`)}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold self-end">
|
||||||
|
Question {exerciseIndex}/{totalExercises}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
|
||||||
|
</div>
|
||||||
|
{exam?.module === "level" && <MCQuestionGrid showSolutions={showSolutions} runOnClick={runOnClick}/>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
import {Dialog, Transition} from "@headlessui/react";
|
import {Dialog, DialogPanel, DialogTitle, Transition, TransitionChild} from "@headlessui/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {Fragment, ReactElement} from "react";
|
import {Fragment, ReactElement} from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
maxWidth?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
titleClassName?: string;
|
titleClassName?: string;
|
||||||
children?: ReactElement;
|
children?: ReactElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Modal({isOpen, title, className, titleClassName, onClose, children}: Props) {
|
export default function Modal({isOpen, maxWidth, title, className, titleClassName, onClose, children}: Props) {
|
||||||
return (
|
return (
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
<Dialog as="div" className="relative z-[200]" onClose={onClose}>
|
<Dialog as="div" className="relative z-[200]" onClose={onClose}>
|
||||||
<Transition.Child
|
<TransitionChild
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
@@ -24,11 +25,11 @@ export default function Modal({isOpen, title, className, titleClassName, onClose
|
|||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0">
|
leaveTo="opacity-0">
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||||
</Transition.Child>
|
</TransitionChild>
|
||||||
|
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||||
<Transition.Child
|
<TransitionChild
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
enterFrom="opacity-0 scale-95"
|
enterFrom="opacity-0 scale-95"
|
||||||
@@ -36,19 +37,20 @@ export default function Modal({isOpen, title, className, titleClassName, onClose
|
|||||||
leave="ease-in duration-200"
|
leave="ease-in duration-200"
|
||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="opacity-0 scale-95">
|
leaveTo="opacity-0 scale-95">
|
||||||
<Dialog.Panel
|
<DialogPanel
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-full max-w-6xl transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all",
|
"w-full transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all",
|
||||||
|
!!maxWidth ? maxWidth : 'max-w-6xl',
|
||||||
className,
|
className,
|
||||||
)}>
|
)}>
|
||||||
{title && (
|
{title && (
|
||||||
<Dialog.Title as="h3" className={clsx(titleClassName ? titleClassName : "text-lg font-medium leading-6 text-gray-900")}>
|
<DialogTitle as="h3" className={clsx(titleClassName ? titleClassName : "text-lg font-medium leading-6 text-gray-900")}>
|
||||||
{title}
|
{title}
|
||||||
</Dialog.Title>
|
</DialogTitle>
|
||||||
)}
|
)}
|
||||||
{children}
|
{children}
|
||||||
</Dialog.Panel>
|
</DialogPanel>
|
||||||
</Transition.Child>
|
</TransitionChild>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
41
src/components/Popouts/Exam.tsx
Normal file
41
src/components/Popouts/Exam.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import Level from "@/exams/Level";
|
||||||
|
import Reading from "@/exams/Reading";
|
||||||
|
import Writing from "@/exams/Writing";
|
||||||
|
import { usePersistentStorage } from "@/hooks/usePersistentStorage";
|
||||||
|
import { LevelExam, ReadingExam, WritingExam } from "@/interfaces/exam";
|
||||||
|
import { User } from "@/interfaces/user";
|
||||||
|
import { usePersistentExamStore } from "@/stores/examStore";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
// todo: perms
|
||||||
|
|
||||||
|
const Popout: React.FC<{ user: User }> = ({ user }) => {
|
||||||
|
const state = usePersistentExamStore((state) => state);
|
||||||
|
usePersistentStorage(usePersistentExamStore);
|
||||||
|
return (
|
||||||
|
<div className={`relative flex w-full min-h-screen p-4 shadow-md items-center rounded-2xl ${state.bgColor}`}>
|
||||||
|
<div className={clsx("relative flex p-20 justify-center flex-1")}>
|
||||||
|
{state.exam?.module == "level" && state.exam.parts && state.partIndex >= 0 &&
|
||||||
|
<Level exam={state.exam as LevelExam} onFinish={() => {
|
||||||
|
state.setPartIndex(0);
|
||||||
|
state.setExerciseIndex(0);
|
||||||
|
state.setQuestionIndex(0);
|
||||||
|
}} showSolutions={true} preview={true} />
|
||||||
|
}
|
||||||
|
{state.exam?.module == "writing" && state.exam.exercises && state.partIndex >= 0 &&
|
||||||
|
<Writing exam={state.exam as WritingExam} onFinish={() => {
|
||||||
|
state.setExerciseIndex(0);
|
||||||
|
}} preview={true} />
|
||||||
|
}
|
||||||
|
{state.exam?.module == "reading" && state.exam.parts.length > 0 &&
|
||||||
|
<Reading exam={state.exam as ReadingExam} onFinish={() => {
|
||||||
|
state.setPartIndex(0);
|
||||||
|
state.setExerciseIndex(-1);
|
||||||
|
state.setQuestionIndex(0);
|
||||||
|
}} preview={true} />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default Popout;
|
||||||
@@ -5,6 +5,7 @@ import {CommonProps} from ".";
|
|||||||
import {Fragment} from "react";
|
import {Fragment} from "react";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import { typeCheckWordsMC } from "@/utils/type.check";
|
||||||
|
|
||||||
export default function FillBlanksSolutions({id, type, prompt, solutions, words, text, onNext, onBack}: FillBlanksExercise & CommonProps) {
|
export default function FillBlanksSolutions({id, type, prompt, solutions, words, text, onNext, onBack}: FillBlanksExercise & CommonProps) {
|
||||||
const storeUserSolutions = useExamStore((state) => state.userSolutions);
|
const storeUserSolutions = useExamStore((state) => state.userSolutions);
|
||||||
@@ -44,10 +45,6 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, words,
|
|||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
|
|
||||||
return Array.isArray(words) && words.every((word) => word && typeof word === "object" && "id" in word && "options" in word);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderLines = (line: string) => {
|
const renderLines = (line: string) => {
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -1,72 +1,175 @@
|
|||||||
import React, {useEffect, useRef, useState} from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import {BsPauseFill, BsPlayFill} from "react-icons/bs";
|
import { BsPauseFill, BsPlayFill, BsScissors, BsTrash } from "react-icons/bs";
|
||||||
import WaveSurfer from "wavesurfer.js";
|
import WaveSurfer from "wavesurfer.js";
|
||||||
|
// @ts-ignore
|
||||||
|
import RegionsPlugin from 'wavesurfer.js/dist/plugin/wavesurfer.regions.min.js';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
audio: string;
|
audio: string;
|
||||||
waveColor: string;
|
waveColor: string;
|
||||||
progressColor: string;
|
progressColor: string;
|
||||||
|
variant?: 'exercise' | 'edit';
|
||||||
|
onCutsChange?: (cuts: AudioCut[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Waveform = ({audio, waveColor, progressColor}: Props) => {
|
interface AudioCut {
|
||||||
const containerRef = useRef(null);
|
id: string;
|
||||||
const waveSurferRef = useRef<WaveSurfer | null>();
|
start: number;
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
const Waveform = ({
|
||||||
const waveSurfer = WaveSurfer.create({
|
audio,
|
||||||
container: containerRef?.current || "",
|
waveColor,
|
||||||
responsive: true,
|
progressColor,
|
||||||
cursorWidth: 0,
|
variant = 'exercise',
|
||||||
height: 24,
|
onCutsChange
|
||||||
waveColor,
|
}: Props) => {
|
||||||
progressColor,
|
const containerRef = useRef(null);
|
||||||
barGap: 5,
|
const waveSurferRef = useRef<WaveSurfer | null>(null);
|
||||||
barWidth: 8,
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
barRadius: 4,
|
const [cuts, setCuts] = useState<AudioCut[]>([]);
|
||||||
fillParent: true,
|
const [currentRegion, setCurrentRegion] = useState<any | null>(null);
|
||||||
hideScrollbar: true,
|
const [duration, setDuration] = useState<number>(0);
|
||||||
normalize: true,
|
|
||||||
autoCenter: true,
|
|
||||||
ignoreSilenceMode: true,
|
|
||||||
barMinHeight: 4,
|
|
||||||
});
|
|
||||||
waveSurfer.load(audio);
|
|
||||||
|
|
||||||
waveSurfer.on("ready", () => {
|
useEffect(() => {
|
||||||
waveSurferRef.current = waveSurfer;
|
const waveSurfer = WaveSurfer.create({
|
||||||
});
|
container: containerRef?.current || "",
|
||||||
|
responsive: true,
|
||||||
|
cursorWidth: 0,
|
||||||
|
height: variant === 'edit' ? 96 : 24,
|
||||||
|
waveColor,
|
||||||
|
progressColor,
|
||||||
|
barGap: 5,
|
||||||
|
barWidth: 8,
|
||||||
|
barRadius: 4,
|
||||||
|
fillParent: true,
|
||||||
|
hideScrollbar: true,
|
||||||
|
normalize: true,
|
||||||
|
autoCenter: true,
|
||||||
|
ignoreSilenceMode: true,
|
||||||
|
barMinHeight: 4,
|
||||||
|
plugins: variant === 'edit' ? [
|
||||||
|
RegionsPlugin.create({
|
||||||
|
dragSelection: true,
|
||||||
|
slop: 5
|
||||||
|
})
|
||||||
|
] : []
|
||||||
|
});
|
||||||
|
|
||||||
waveSurfer.on("finish", () => setIsPlaying(false));
|
waveSurfer.load(audio);
|
||||||
|
|
||||||
return () => {
|
waveSurfer.on("ready", () => {
|
||||||
waveSurfer.destroy();
|
waveSurferRef.current = waveSurfer;
|
||||||
};
|
setDuration(waveSurfer.getDuration());
|
||||||
}, [audio, progressColor, waveColor]);
|
});
|
||||||
|
|
||||||
return (
|
waveSurfer.on("finish", () => setIsPlaying(false));
|
||||||
<>
|
|
||||||
{isPlaying && (
|
if (variant === 'edit') {
|
||||||
<BsPauseFill
|
|
||||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
waveSurfer.on('region-created', (region) => {
|
||||||
onClick={() => {
|
setCurrentRegion(region);
|
||||||
setIsPlaying((prev) => !prev);
|
const newCut: AudioCut = {
|
||||||
waveSurferRef.current?.playPause();
|
id: region.id,
|
||||||
}}
|
start: region.start,
|
||||||
/>
|
end: region.end
|
||||||
)}
|
};
|
||||||
{!isPlaying && (
|
setCuts(prev => [...prev, newCut]);
|
||||||
<BsPlayFill
|
onCutsChange?.([...cuts, newCut]);
|
||||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
});
|
||||||
onClick={() => {
|
|
||||||
setIsPlaying((prev) => !prev);
|
|
||||||
waveSurferRef.current?.playPause();
|
waveSurfer.on('region-updated', (region) => {
|
||||||
}}
|
setCuts(prev => prev.map(cut =>
|
||||||
/>
|
cut.id === region.id
|
||||||
)}
|
? { ...cut, start: region.start, end: region.end }
|
||||||
<div className="w-full max-w-4xl h-fit" ref={containerRef} />
|
: cut
|
||||||
</>
|
));
|
||||||
);
|
onCutsChange?.(cuts.map(cut =>
|
||||||
|
cut.id === region.id
|
||||||
|
? { ...cut, start: region.start, end: region.end }
|
||||||
|
: cut
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
waveSurfer.destroy();
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [audio, progressColor, waveColor, variant]);
|
||||||
|
|
||||||
|
const handlePlayPause = () => {
|
||||||
|
setIsPlaying(prev => !prev);
|
||||||
|
waveSurferRef.current?.playPause();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteRegion = (cutId: string) => {
|
||||||
|
const region = waveSurferRef.current?.regions?.list[cutId];
|
||||||
|
if (region) {
|
||||||
|
region.remove();
|
||||||
|
setCuts(prev => prev.filter(cut => cut.id !== cutId));
|
||||||
|
onCutsChange?.(cuts.filter(cut => cut.id !== cutId));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (time: number) => {
|
||||||
|
const minutes = Math.floor(time / 60);
|
||||||
|
const seconds = Math.floor(time % 60);
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{isPlaying ? (
|
||||||
|
<BsPauseFill
|
||||||
|
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||||
|
onClick={handlePlayPause}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<BsPlayFill
|
||||||
|
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||||
|
onClick={handlePlayPause}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{variant === 'edit' && duration > 0 && (
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
Total Duration: {formatTime(duration)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full max-w-4xl h-fit" ref={containerRef} />
|
||||||
|
|
||||||
|
{variant === 'edit' && cuts.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-medium text-gray-700">Audio Cuts</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{cuts.map((cut) => (
|
||||||
|
<div
|
||||||
|
key={cut.id}
|
||||||
|
className="flex items-center justify-between p-2 bg-gray-50 rounded-md"
|
||||||
|
>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
{formatTime(cut.start)} - {formatTime(cut.end)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteRegion(cut.id)}
|
||||||
|
className="p-1 text-red-500 hover:bg-red-50 rounded"
|
||||||
|
>
|
||||||
|
<BsTrash className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Waveform;
|
export default Waveform;
|
||||||
|
|||||||
76
src/components/ui/card.tsx
Normal file
76
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground rounded-xl border shadow bg-gray-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-neutral-500 dark:text-neutral-400", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import Button from "@/components/Low/Button";
|
|
||||||
import { Module } from "@/interfaces";
|
|
||||||
import { LevelPart, UserSolution } from "@/interfaces/exam";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { ReactNode } from "react";
|
|
||||||
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
partIndex: number;
|
|
||||||
part: LevelPart // for now
|
|
||||||
onNext: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PartDivider: React.FC<Props> = ({ partIndex, part, onNext }) => {
|
|
||||||
|
|
||||||
const moduleIcon: { [key in Module]: ReactNode } = {
|
|
||||||
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
|
|
||||||
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
|
|
||||||
writing: <BsPen className="text-ielts-writing w-6 h-6" />,
|
|
||||||
speaking: <BsMegaphone className="text-ielts-speaking w-6 h-6" />,
|
|
||||||
level: <BsClipboard className="text-white w-6 h-6" />,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={clsx("flex flex-col h-fit border bg-white rounded-3xl p-12 gap-8", part.intro ? "w-3/6" : "items-center my-auto")}>
|
|
||||||
{/** only level for now */}
|
|
||||||
<div className="flex flex-row gap-4 items-center"><div className="w-12 h-12 bg-ielts-level flex items-center justify-center rounded-lg">{moduleIcon["level"]}</div><p className="text-3xl">{part.intro ? `Part ${partIndex + 1}` : "Placement Test"}</p></div>
|
|
||||||
{part.intro && part.intro.split('\\n\\n').map((x, index) => <p key={`line-${index}`} className="text-2xl text-clip" dangerouslySetInnerHTML={{__html: x.replace('that is not correct', 'that is <span class="font-bold"><u>not correct</u></span>')}}></p>)}
|
|
||||||
<div className="flex items-center justify-center mt-4">
|
|
||||||
<Button color="purple" onClick={() => onNext()} className="max-w-[200px] self-end w-full text-2xl">
|
|
||||||
{partIndex === 0 ? `Start now`: `Start Part ${partIndex + 1}`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PartDivider;
|
|
||||||
@@ -147,9 +147,3 @@ function fisherYatesShuffle<T>(array: T[]): T[] {
|
|||||||
}
|
}
|
||||||
return shuffled;
|
return shuffled;
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
|
|
||||||
return Array.isArray(words) && words.every(
|
|
||||||
word => word && typeof word === 'object' && 'id' in word && 'options' in word
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user