Merge branch 'develop' of https://bitbucket.org/ecropdev/ielts-ui into feature/level-file-upload

This commit is contained in:
Carlos Mesquita
2024-08-20 19:37:06 +01:00
37 changed files with 5344 additions and 1817 deletions

View File

@@ -1,10 +1,10 @@
import { Module } from "@/interfaces";
import {Module} from "@/interfaces";
import useExamStore from "@/stores/examStore";
import { moduleLabels } from "@/utils/moduleUtils";
import {moduleLabels} from "@/utils/moduleUtils";
import clsx from "clsx";
import { motion } from "framer-motion";
import { ReactNode, useEffect, useState } from "react";
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch } from "react-icons/bs";
import {motion} from "framer-motion";
import {ReactNode, useEffect, useState} from "react";
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
import ProgressBar from "../Low/ProgressBar";
import TimerEndedModal from "../TimerEndedModal";
import Timer from "./Timer";
@@ -24,7 +24,7 @@ export default function ModuleTitle({
minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false, partLabel, showTimer = true
}: Props) {
const moduleIcon: { [key in Module]: ReactNode } = {
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" />,
@@ -36,11 +36,25 @@ export default function ModuleTitle({
<>
{showTimer && <Timer minTimer={minTimer} disableTimer={disableTimer} />}
<div className="w-full">
{partLabel && <div className="text-3xl space-y-4">{partLabel.split('\n\n').map((line, index) => {
if(index == 0) return <p className="font-bold">{line}</p>
else return <p className="text-2xl font-semibold">{line}</p>
})}</div>}
<div className={clsx("flex gap-6 w-full h-fit items-center", partLabel ? "mt-10" : "mt-5" )}>
{partLabel && (
<div className="text-3xl space-y-4">
{partLabel.split("\n\n").map((line, index) => {
if (index == 0)
return (
<p key={index} className="font-bold">
{line}
</p>
);
else
return (
<p key={index} className="text-2xl font-semibold">
{line}
</p>
);
})}
</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">

View File

@@ -1,105 +1,82 @@
import React from "react";
import { Permission } from "@/interfaces/permissions";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
Row,
} from "@tanstack/react-table";
import {Permission} from "@/interfaces/permissions";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable, Row} from "@tanstack/react-table";
import Link from "next/link";
import { convertCamelCaseToReadable } from "@/utils/string";
import {convertCamelCaseToReadable} from "@/utils/string";
interface Props {
permissions: Permission[];
permissions: Permission[];
}
const columnHelper = createColumnHelper<Permission>();
const defaultColumns = [
columnHelper.accessor("type", {
header: () => <span>Type</span>,
cell: ({ row, getValue }) => (
<Link
href={`/permissions/${row.original.id}`}
key={row.id}
className="underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer"
>
{convertCamelCaseToReadable(getValue() as string)}
</Link>
),
}),
columnHelper.accessor("type", {
header: () => <span>Type</span>,
cell: ({row, getValue}) => (
<Link
href={`/permissions/${row.original.id}`}
key={row.id}
className="underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer">
{convertCamelCaseToReadable(getValue() as string)}
</Link>
),
}),
];
export default function PermissionList({ permissions }: Props) {
const table = useReactTable({
data: permissions,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
export default function PermissionList({permissions}: Props) {
const table = useReactTable({
data: permissions,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
const groupedData: { [key: string]: Row<Permission>[] } = table
.getRowModel()
.rows.reduce((groups: { [key: string]: Row<Permission>[] }, row) => {
const parent = row.original.topic;
if (!groups[parent]) {
groups[parent] = [];
}
groups[parent].push(row);
return groups;
}, {});
const groupedData: {[key: string]: Row<Permission>[]} = table.getRowModel().rows.reduce((groups: {[key: string]: Row<Permission>[]}, row) => {
const parent = row.original.topic;
if (!groups[parent]) {
groups[parent] = [];
}
groups[parent].push(row);
return groups;
}, {});
return (
<div className="w-full">
<div className="w-full flex flex-col gap-2">
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="py-4 px-4 text-left" key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{Object.keys(groupedData).map((parent) => (
<React.Fragment key={parent}>
<tr>
<td className="px-2 py-2 items-center w-fit">
<strong>{parent}</strong>
</td>
</tr>
{groupedData[parent].map((row, i) => (
<tr
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
key={row.id}
>
{row.getVisibleCells().map((cell) => (
<td
className="px-4 py-2 items-center w-fit"
key={cell.id}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
</div>
</div>
);
return (
<div className="w-full h-full">
<div className="w-full flex flex-col gap-2">
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="py-4 px-4 text-left" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{Object.keys(groupedData).map((parent) => (
<React.Fragment key={parent}>
<tr>
<td className="px-2 py-2 items-center w-fit">
<strong>{parent}</strong>
</td>
</tr>
{groupedData[parent].map((row, i) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -28,6 +28,7 @@ export default function FillBlanksSolutions({
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = correctUserSolutions!.filter((x) => {
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
console.log(solution);
if (!solution) return false;
const option = words.find((w) => {
@@ -36,7 +37,7 @@ export default function FillBlanksSolutions({
} else if ('letter' in w) {
return w.word.toLowerCase() === x.solution.toLowerCase();
} else {
return w.id === x.id;
return w.id.toString() === x.id.toString();
}
});
if (!option) return false;

View File

@@ -49,15 +49,11 @@ function Blank({
{userSolution && !isUserSolutionCorrect() && (
<div
className="py-2 px-3 rounded-2xl w-fit focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light"
placeholder={id}
contentEditable={disabled}>
{userSolution}
</div>
)}
<div
className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())}
placeholder={id}
contentEditable={disabled}>
<div className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())} contentEditable={disabled}>
{!solutions ? userInput : solutions.join(" / ")}
</div>
</span>

View File

@@ -1,287 +1,280 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { animated } from '@react-spring/web';
import { FaRegCirclePlay, FaRegCircleStop } from "react-icons/fa6";
import HighlightContent from '../HighlightContent';
import { ITrainingTip, SegmentRef, TimelineEvent } from './TrainingInterfaces';
import React, {useState, useEffect, useRef, useCallback} from "react";
import {animated} from "@react-spring/web";
import {FaRegCirclePlay, FaRegCircleStop} from "react-icons/fa6";
import HighlightContent from "../HighlightContent";
import {ITrainingTip, SegmentRef, TimelineEvent} from "./TrainingInterfaces";
const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
const [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false);
const [currentTime, setCurrentTime] = useState<number>(0);
const [walkthroughHtml, setWalkthroughHtml] = useState<string>('');
const [highlightedPhrases, setHighlightedPhrases] = useState<string[]>([]);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const timelineRef = useRef<TimelineEvent[]>([]);
const animationRef = useRef<number | null>(null);
const segmentsRef = useRef<SegmentRef[]>([]);
const [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false);
const [currentTime, setCurrentTime] = useState<number>(0);
const [walkthroughHtml, setWalkthroughHtml] = useState<string>("");
const [highlightedPhrases, setHighlightedPhrases] = useState<string[]>([]);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const timelineRef = useRef<TimelineEvent[]>([]);
const animationRef = useRef<number | null>(null);
const segmentsRef = useRef<SegmentRef[]>([]);
const toggleAutoPlay = useCallback(() => {
setIsAutoPlaying((prev) => {
if (!prev && currentTime === getMaxTime()) {
setCurrentTime(0);
}
return !prev;
});
}, [currentTime]);
const toggleAutoPlay = useCallback(() => {
setIsAutoPlaying((prev) => {
if (!prev && currentTime === getMaxTime()) {
setCurrentTime(0);
}
return !prev;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTime]);
const handleAnimationComplete = useCallback(() => {
setIsAutoPlaying(false);
}, []);
const handleAnimationComplete = useCallback(() => {
setIsAutoPlaying(false);
}, []);
const handleResetAnimation = useCallback((newTime: number) => {
setCurrentTime(newTime);
}, []);
const handleResetAnimation = useCallback((newTime: number) => {
setCurrentTime(newTime);
}, []);
const getMaxTime = (): number => {
return tip.exercise?.segments.reduce((sum, segment) =>
sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0
) ?? 0;
};
const getMaxTime = (): number => {
return (
tip.exercise?.segments.reduce((sum, segment) => sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0) ?? 0
);
};
useEffect(() => {
const timeline: TimelineEvent[] = [];
let currentTimePosition = 0;
segmentsRef.current = [];
useEffect(() => {
const timeline: TimelineEvent[] = [];
let currentTimePosition = 0;
segmentsRef.current = [];
tip.exercise?.segments.forEach((segment, index) => {
const parser = new DOMParser();
const doc = parser.parseFromString(segment.html, 'text/html');
const words: string[] = [];
const walkTree = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
words.push(...(node.textContent?.split(/\s+/).filter(word => word.length > 0) || []));
} else if (node.nodeType === Node.ELEMENT_NODE) {
Array.from(node.childNodes).forEach(walkTree);
}
};
walkTree(doc.body);
tip.exercise?.segments.forEach((segment, index) => {
const parser = new DOMParser();
const doc = parser.parseFromString(segment.html, "text/html");
const words: string[] = [];
const walkTree = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
words.push(...(node.textContent?.split(/\s+/).filter((word) => word.length > 0) || []));
} else if (node.nodeType === Node.ELEMENT_NODE) {
Array.from(node.childNodes).forEach(walkTree);
}
};
walkTree(doc.body);
const textDuration = words.length * segment.wordDelay;
const textDuration = words.length * segment.wordDelay;
segmentsRef.current.push({
...segment,
words: words,
startTime: currentTimePosition,
endTime: currentTimePosition + textDuration
});
segmentsRef.current.push({
...segment,
words: words,
startTime: currentTimePosition,
endTime: currentTimePosition + textDuration,
});
timeline.push({
type: 'text',
start: currentTimePosition,
end: currentTimePosition + textDuration,
segmentIndex: index
});
timeline.push({
type: "text",
start: currentTimePosition,
end: currentTimePosition + textDuration,
segmentIndex: index,
});
currentTimePosition += textDuration;
currentTimePosition += textDuration;
timeline.push({
type: 'highlight',
start: currentTimePosition,
end: currentTimePosition + segment.holdDelay,
content: segment.highlight,
segmentIndex: index
});
timeline.push({
type: "highlight",
start: currentTimePosition,
end: currentTimePosition + segment.holdDelay,
content: segment.highlight,
segmentIndex: index,
});
currentTimePosition += segment.holdDelay;
});
currentTimePosition += segment.holdDelay;
});
timelineRef.current = timeline;
}, [tip.exercise?.segments]);
timelineRef.current = timeline;
}, [tip.exercise?.segments]);
const updateText = useCallback(() => {
const currentEvent = timelineRef.current.find(
event => currentTime >= event.start && currentTime < event.end
);
const updateText = useCallback(() => {
const currentEvent = timelineRef.current.find((event) => currentTime >= event.start && currentTime < event.end);
if (currentEvent) {
if (currentEvent.type === 'text') {
const segment = segmentsRef.current[currentEvent.segmentIndex];
const elapsedTime = currentTime - currentEvent.start;
const wordsToShow = Math.min(Math.floor(elapsedTime / segment.wordDelay), segment.words.length);
if (currentEvent) {
if (currentEvent.type === "text") {
const segment = segmentsRef.current[currentEvent.segmentIndex];
const elapsedTime = currentTime - currentEvent.start;
const wordsToShow = Math.min(Math.floor(elapsedTime / segment.wordDelay), segment.words.length);
const previousSegmentsHtml = segmentsRef.current
.slice(0, currentEvent.segmentIndex)
.map(seg => seg.html)
.join('');
const previousSegmentsHtml = segmentsRef.current
.slice(0, currentEvent.segmentIndex)
.map((seg) => seg.html)
.join("");
const parser = new DOMParser();
const doc = parser.parseFromString(segment.html, 'text/html');
let wordCount = 0;
const walkTree = (node: Node, action: (node: Node) => void): boolean => {
if (node.nodeType === Node.TEXT_NODE && node.textContent) {
const words = node.textContent.split(/(\s+)/).filter(word => word.length > 0);
if (wordCount + words.filter(w => !/\s+/.test(w)).length <= wordsToShow) {
action(node.cloneNode(true));
wordCount += words.filter(w => !/\s+/.test(w)).length;
} else {
const remainingWords = wordsToShow - wordCount;
const newTextContent = words.reduce((acc, word) => {
if (!/\s+/.test(word) && acc.nonSpaceWords < remainingWords) {
acc.text += word;
acc.nonSpaceWords++;
} else if (/\s+/.test(word) || acc.nonSpaceWords < remainingWords) {
acc.text += word;
}
return acc;
}, { text: '', nonSpaceWords: 0 }).text;
const newNode = node.cloneNode(false);
newNode.textContent = newTextContent;
action(newNode);
wordCount = wordsToShow;
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
const clone = node.cloneNode(false);
action(clone);
Array.from(node.childNodes).some(child => {
return walkTree(child, childNode => (clone as Node).appendChild(childNode));
});
}
return wordCount >= wordsToShow;
};
const fragment = document.createDocumentFragment();
walkTree(doc.body, node => fragment.appendChild(node));
const parser = new DOMParser();
const doc = parser.parseFromString(segment.html, "text/html");
let wordCount = 0;
const walkTree = (node: Node, action: (node: Node) => void): boolean => {
if (node.nodeType === Node.TEXT_NODE && node.textContent) {
const words = node.textContent.split(/(\s+)/).filter((word) => word.length > 0);
if (wordCount + words.filter((w) => !/\s+/.test(w)).length <= wordsToShow) {
action(node.cloneNode(true));
wordCount += words.filter((w) => !/\s+/.test(w)).length;
} else {
const remainingWords = wordsToShow - wordCount;
const newTextContent = words.reduce(
(acc, word) => {
if (!/\s+/.test(word) && acc.nonSpaceWords < remainingWords) {
acc.text += word;
acc.nonSpaceWords++;
} else if (/\s+/.test(word) || acc.nonSpaceWords < remainingWords) {
acc.text += word;
}
return acc;
},
{text: "", nonSpaceWords: 0},
).text;
const newNode = node.cloneNode(false);
newNode.textContent = newTextContent;
action(newNode);
wordCount = wordsToShow;
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
const clone = node.cloneNode(false);
action(clone);
Array.from(node.childNodes).some((child) => {
return walkTree(child, (childNode) => (clone as Node).appendChild(childNode));
});
}
return wordCount >= wordsToShow;
};
const fragment = document.createDocumentFragment();
walkTree(doc.body, (node) => fragment.appendChild(node));
const serializer = new XMLSerializer();
const currentSegmentHtml = Array.from(fragment.childNodes)
.map(node => serializer.serializeToString(node))
.join('');
const newHtml = previousSegmentsHtml + currentSegmentHtml;
const serializer = new XMLSerializer();
const currentSegmentHtml = Array.from(fragment.childNodes)
.map((node) => serializer.serializeToString(node))
.join("");
const newHtml = previousSegmentsHtml + currentSegmentHtml;
setWalkthroughHtml(newHtml);
setHighlightedPhrases([]);
} else if (currentEvent.type === 'highlight') {
const newHtml = segmentsRef.current
.slice(0, currentEvent.segmentIndex + 1)
.map(seg => seg.html)
.join('');
setWalkthroughHtml(newHtml);
setHighlightedPhrases(currentEvent.content || []);
}
}
}, [currentTime]);
setWalkthroughHtml(newHtml);
setHighlightedPhrases([]);
} else if (currentEvent.type === "highlight") {
const newHtml = segmentsRef.current
.slice(0, currentEvent.segmentIndex + 1)
.map((seg) => seg.html)
.join("");
setWalkthroughHtml(newHtml);
setHighlightedPhrases(currentEvent.content || []);
}
}
}, [currentTime]);
useEffect(() => {
updateText();
}, [currentTime, updateText]);
useEffect(() => {
updateText();
}, [currentTime, updateText]);
useEffect(() => {
if (isAutoPlaying) {
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
if (lastEvent && currentTime >= lastEvent.end) {
setCurrentTime(0);
}
setIsPlaying(true);
} else {
setIsPlaying(false);
}
}, [isAutoPlaying, currentTime]);
useEffect(() => {
if (isAutoPlaying) {
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
if (lastEvent && currentTime >= lastEvent.end) {
setCurrentTime(0);
}
setIsPlaying(true);
} else {
setIsPlaying(false);
}
}, [isAutoPlaying, currentTime]);
useEffect(() => {
const animate = () => {
if (isPlaying) {
setCurrentTime((prevTime) => {
const newTime = prevTime + 50;
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
if (lastEvent && newTime >= lastEvent.end) {
setIsPlaying(false);
handleAnimationComplete();
return lastEvent.end;
}
return newTime;
});
}
animationRef.current = requestAnimationFrame(animate);
};
useEffect(() => {
const animate = () => {
if (isPlaying) {
setCurrentTime((prevTime) => {
const newTime = prevTime + 50;
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
if (lastEvent && newTime >= lastEvent.end) {
setIsPlaying(false);
handleAnimationComplete();
return lastEvent.end;
}
return newTime;
});
}
animationRef.current = requestAnimationFrame(animate);
};
animationRef.current = requestAnimationFrame(animate);
animationRef.current = requestAnimationFrame(animate);
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [isPlaying, handleAnimationComplete]);
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [isPlaying, handleAnimationComplete]);
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = parseInt(e.target.value, 10);
setCurrentTime(newTime);
handleResetAnimation(newTime);
};
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = parseInt(e.target.value, 10);
setCurrentTime(newTime);
handleResetAnimation(newTime);
};
const handleSliderMouseDown = () => {
setIsPlaying(false);
};
const handleSliderMouseDown = () => {
setIsPlaying(false);
};
const handleSliderMouseUp = () => {
if (isAutoPlaying) {
setIsPlaying(true);
}
};
const handleSliderMouseUp = () => {
if (isAutoPlaying) {
setIsPlaying(true);
}
};
if (tip.standalone || !tip.exercise) {
return (
<div className="container mx-auto">
<h1 className='text-xl font-bold text-red-600'>The exercise for this tip is not available yet!</h1>
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4 mt-10">
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
<div className="text-gray-700" dangerouslySetInnerHTML={{ __html: tip.tipHtml }} />
</div>
</div>
);
}
if (tip.standalone || !tip.exercise) {
return (
<div className="container mx-auto">
<h1 className="text-xl font-bold text-red-600">The exercise for this tip is not available yet!</h1>
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4 mt-10">
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
<div className="text-gray-700" dangerouslySetInnerHTML={{__html: tip.tipHtml}} />
</div>
</div>
);
}
return (
<div className="container mx-auto">
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4">
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
<div className="text-gray-700" dangerouslySetInnerHTML={{ __html: tip.tipHtml }} />
</div>
<div className='flex flex-col space-y-4'>
<div className='flex flex-row items-center space-x-4 py-4'>
<button
onClick={toggleAutoPlay}
className="p-2 bg-blue-500 text-white rounded-full transition-colors duration-200 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
aria-label={isAutoPlaying ? 'Pause' : 'Play'}
>
{isAutoPlaying ? (
<FaRegCircleStop className="w-6 h-6" />
) : (
<FaRegCirclePlay className="w-6 h-6" />
)}
</button>
<input
type="range"
min="0"
max={timelineRef.current.length > 0 ? timelineRef.current[timelineRef.current.length - 1].end : 0}
value={currentTime}
onChange={handleSliderChange}
onMouseDown={handleSliderMouseDown}
onMouseUp={handleSliderMouseUp}
onTouchStart={handleSliderMouseDown}
onTouchEnd={handleSliderMouseUp}
className='flex-grow'
/>
</div>
<div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4'>
<div className='flex-1 bg-white p-6 rounded-lg shadow'>
{/*<h2 className="text-xl font-bold mb-4">Question</h2>*/}
<div className="mb-4" dangerouslySetInnerHTML={{ __html: tip.exercise.question }} />
<HighlightContent html={tip.exercise.highlightable} highlightPhrases={highlightedPhrases} />
</div>
<div className='flex-1'>
<div className='bg-gray-50 rounded-lg shadow'>
<div className='p-6 space-y-4'>
<animated.div
dangerouslySetInnerHTML={{ __html: walkthroughHtml }}
/>
</div>
</div>
</div>
</div>
</div>
</div>
);
return (
<div className="container mx-auto">
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4">
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
<div className="text-gray-700" dangerouslySetInnerHTML={{__html: tip.tipHtml}} />
</div>
<div className="flex flex-col space-y-4">
<div className="flex flex-row items-center space-x-4 py-4">
<button
onClick={toggleAutoPlay}
className="p-2 bg-blue-500 text-white rounded-full transition-colors duration-200 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
aria-label={isAutoPlaying ? "Pause" : "Play"}>
{isAutoPlaying ? <FaRegCircleStop className="w-6 h-6" /> : <FaRegCirclePlay className="w-6 h-6" />}
</button>
<input
type="range"
min="0"
max={timelineRef.current.length > 0 ? timelineRef.current[timelineRef.current.length - 1].end : 0}
value={currentTime}
onChange={handleSliderChange}
onMouseDown={handleSliderMouseDown}
onMouseUp={handleSliderMouseUp}
onTouchStart={handleSliderMouseDown}
onTouchEnd={handleSliderMouseUp}
className="flex-grow"
/>
</div>
<div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4">
<div className="flex-1 bg-white p-6 rounded-lg shadow">
{/*<h2 className="text-xl font-bold mb-4">Question</h2>*/}
<div className="mb-4" dangerouslySetInnerHTML={{__html: tip.exercise.question}} />
<HighlightContent html={tip.exercise.highlightable} highlightPhrases={highlightedPhrases} />
</div>
<div className="flex-1">
<div className="bg-gray-50 rounded-lg shadow">
<div className="p-6 space-y-4">
<animated.div dangerouslySetInnerHTML={{__html: walkthroughHtml}} />
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default ExerciseWalkthrough;