Training update, most of the styles in the old tips were standardized, before all the styles were hardcoded into the tip, the new tips may still have some hardcoded styles but the vast majority only uses standard html or custom ones that are picked up in FormatTip to attribute styles

This commit is contained in:
Carlos Mesquita
2024-09-07 11:37:04 +01:00
parent 4865b47393
commit 9c41ddee60
8 changed files with 598 additions and 213 deletions

View File

@@ -1,39 +1,47 @@
import { useCallback } from "react";
import React, { useCallback } from "react";
import { HighlightConfig, HighlightTarget } from "@/training/TrainingInterfaces";
const HighlightContent: React.FC<{
html: string;
highlightPhrases: string[],
firstOccurence?: boolean
}> = ({
interface HighlightedContentProps {
html: string;
highlightConfigs: HighlightConfig[];
contentType: HighlightTarget;
currentSegmentIndex?: number;
}
const HighlightedContent: React.FC<HighlightedContentProps> = ({
html,
highlightPhrases,
firstOccurence = false
highlightConfigs,
contentType,
currentSegmentIndex
}) => {
const createHighlightedContent = useCallback(() => {
if (highlightPhrases.length === 0) {
return { __html: html };
}
const escapeRegExp = (string: string) => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
const regex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'i');
const globalRegex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'gi');
let highlightedHtml = html;
highlightConfigs.forEach(config => {
if (config.targets.includes(contentType) || config.targets.includes('all')) {
const escapeRegExp = (string: string) => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
if (firstOccurence) {
highlightedHtml = html.replace(regex, (match) => `<span style="background-color: yellow;">${match}</span>`);
} else {
highlightedHtml = html.replace(globalRegex, (match) => `<span style="background-color: yellow;">${match}</span>`);
}
const regex = new RegExp(config.phrases.map(escapeRegExp).join('|'), 'g');
if (contentType === 'segment' && currentSegmentIndex !== undefined) {
const segments = highlightedHtml.split('</div>');
segments[currentSegmentIndex] = segments[currentSegmentIndex].replace(regex, (match) => {
return `<span style="background-color: #FFFACD;">${match}</span>`;
});
highlightedHtml = segments.join('</div>');
} else {
highlightedHtml = highlightedHtml.replace(regex, (match) => {
return `<span style="background-color: #FFFACD;">${match}</span>`;
});
}
}
});
return { __html: highlightedHtml };
}, [html, highlightPhrases, firstOccurence]);
}, [html, highlightConfigs, contentType, currentSegmentIndex]);
return <div dangerouslySetInnerHTML={createHighlightedContent()} />;
};
export default HighlightContent;
export default HighlightedContent;

View File

@@ -1,91 +1,43 @@
import React, { useState, useCallback } from "react";
import React from "react";
import ExerciseWalkthrough from "@/training/ExerciseWalkthrough";
import { ITrainingTip, WalkthroughConfigs } from "./TrainingInterfaces";
import formatTip from "./FormatTip";
// This wrapper is just to test new exercises from the handbook, will be removed when all the tips and exercises are in firestore
const TrainingExercise: React.FC<ITrainingTip> = (trainingTip: ITrainingTip) => {
const leftText = "<div class=\"container mx-auto px-4 overflow-x-auto\"><table class=\"min-w-full bg-white border border-gray-300\"><thead><tr class=\"bg-gray-100\"><th class=\"py-2 px-4 border-b font-semibold text-left\">Category</th><th class=\"py-2 px-4 border-b font-semibold text-left\">Option A</th><th class=\"py-2 px-4 border-b font-semibold text-left\">Option B</th></tr></thead><tbody><tr><td class=\"py-2 px-4 border-b font-medium\">Self</td><td class=\"py-2 px-4 border-b\">You need to take care of yourself and connect with the people around you.</td><td class=\"py-2 px-4 border-b\">Focus on your interests and talents and meet people who are like you.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Home</td><td class=\"py-2 px-4 border-b\">It's a good idea to paint your living room yellow.</td><td class=\"py-2 px-4 border-b\">You should arrange your home so that it makes you feel happy.</td></tr><tr><td class=\"py-2 px-4 border-b font-medium\">Financial Life</td><td class=\"py-2 px-4 border-b\">You can be happy if you have enough money, but don't want money too much.</td><td class=\"py-2 px-4 border-b\">If you waste money on things you don't need, you won't have enough money for things that you do need.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Social Life</td><td class=\"py-2 px-4 border-b\">A good group of friends can increase your happiness.</td><td class=\"py-2 px-4 border-b\">Researchers say that a happy friend can increase our mood by nine percent.</td></tr><tr><td class=\"py-2 px-4 border-b font-medium\">Workplace</td><td class=\"py-2 px-4 border-b\">You spend a lot of time at work, so you should like your workplace.</td><td class=\"py-2 px-4 border-b\">Your boss needs to be someone you enjoy working for.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Community</td><td class=\"py-2 px-4 border-b\">The place where you live is more important for happiness than anything else.</td><td class=\"py-2 px-4 border-b\">Live around people who have the same amount of money as you do.</td></tr></tbody></table></div>";
const tip = {
category: "Strategy",
body: "<p>Look for <b>clues to the main idea</b> in the first (and sometimes second) sentence of a paragraph.</p>"
}
const question = "<div class=\"container mx-auto px-4 py-8\"><h2 class=\"text-2xl font-bold mb-4\">Identifying Main Ideas</h2><p class=\"text-lg leading-relaxed mb-6\">Read the statements below. Circle the main idea in each pair of statements (a or b).</p></div>";
const rightTextData: WalkthroughConfigs[] = [
{
"html": "<div class='bg-blue-100 p-4 rounded-lg mb-4'><h2 class='text-xl font-bold mb-2'>Identifying Main Ideas</h2><p class='text-gray-700 leading-relaxed'>Let's analyze each pair of statements to determine which one represents the main idea. We'll focus on which statement is more general and encompasses the overall concept.</p></div>",
"wordDelay": 200,
"holdDelay": 5000,
"highlight": []
},
{
"html": "<div class='bg-green-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>1. Self</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You need to take care of yourself and connect with the people around you.</b></p><p class='mt-2'>This statement is more comprehensive, covering both self-care and social connections. Option B is more specific and could be considered a subset of A.</p></div>",
"wordDelay": 200,
"holdDelay": 8000,
"highlight": ["You need to take care of yourself and connect with the people around you."]
},
{
"html": "<div class='bg-yellow-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>2. Home</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>B. You should arrange your home so that it makes you feel happy.</b></p><p class='mt-2'>This statement is more general and applies to the entire home. Option A is a specific example that could fall under this broader concept.</p></div>",
"wordDelay": 200,
"holdDelay": 8000,
"highlight": ["You should arrange your home so that it makes you feel happy."]
},
{
"html": "<div class='bg-pink-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>3. Financial Life</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You can be happy if you have enough money, but don't want money too much.</b></p><p class='mt-2'>This statement provides a balanced view of money's role in happiness. Option B is more specific and could be seen as a consequence of wanting money too much.</p></div>",
"wordDelay": 200,
"holdDelay": 8000,
"highlight": ["You can be happy if you have enough money, but don't want money too much."]
},
{
"html": "<div class='bg-purple-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>4. Social Life</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. A good group of friends can increase your happiness.</b></p><p class='mt-2'>This statement is more general about the impact of friendships. Option B provides a specific statistic that supports this main idea.</p></div>",
"wordDelay": 200,
"holdDelay": 8000,
"highlight": ["A good group of friends can increase your happiness."]
},
{
"html": "<div class='bg-indigo-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>5. Workplace</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You spend a lot of time at work, so you should like your workplace.</b></p><p class='mt-2'>This statement covers the overall importance of workplace satisfaction. Option B focuses on one specific aspect (the boss) and is less comprehensive.</p></div>",
"wordDelay": 200,
"holdDelay": 8000,
"highlight": ["You spend a lot of time at work, so you should like your workplace."]
},
{
"html": "<div class='bg-red-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>6. Community</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. The place where you live is more important for happiness than anything else.</b></p><p class='mt-2'>While this statement might be debatable, it's more general and encompasses the overall importance of community. Option B is a specific suggestion about community demographics.</p></div>",
"wordDelay": 200,
"holdDelay": 8000,
"highlight": ["The place where you live is more important for happiness than anything else."]
},
{
"html": "<div class='bg-orange-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>Key Strategy</h3><p class='text-gray-700 leading-relaxed'>When identifying main ideas:</p><ul class='list-disc pl-5 space-y-2'><li>Look for broader, more encompassing statements</li><li>Consider which statement other ideas could fall under</li><li>Identify which statement provides a general principle rather than a specific example</li></ul></div>",
"wordDelay": 200,
"holdDelay": 8000,
"highlight": []
},
{
"html": "<div class='bg-teal-50 p-4 rounded-lg'><h3 class='text-lg font-semibold mb-2'>Helpful Tip</h3><p class='text-gray-700 leading-relaxed'>Remember to look for clues to the main idea in the first (and sometimes second) sentence of a paragraph. In this exercise, we applied this concept to pairs of statements. This approach can help you quickly identify the central theme or main point in various types of text.</p></div>",
"wordDelay": 200,
"holdDelay": 5000,
"highlight": []
const tip = {
"category": "",
"embedding": "",
"text": "",
"html": "",
"id": "",
"verified": true,
"standalone": false,
"exercise": {
"question": "",
"additional": "",
"segments": []
}
}
]
const mockTip: ITrainingTip = {
id: "some random id",
tipCategory: tip.category,
tipHtml: tip.body,
standalone: false,
exercise: {
question: question,
highlightable: leftText,
segments: rightTextData
}
}
const mockTip: ITrainingTip = {
id: "some random id",
tipCategory: tip.category,
tipHtml: tip.html,
standalone: tip.standalone,
exercise: {
question: tip.exercise.question,
additional: tip.exercise.additional,
segments: tip.exercise.segments as WalkthroughConfigs[]
}
}
return (
<div className="flex flex-col p-10">
<ExerciseWalkthrough {...trainingTip}
/>
</div>
);
const formattedTip = formatTip(mockTip);
return (
<ExerciseWalkthrough {...formatTip(trainingTip)}
/>
);
}
export default TrainingExercise;

View File

@@ -1,19 +1,32 @@
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, HighlightConfig, InsertHtmlConfig } from './TrainingInterfaces';
import Tip from './Tip';
interface HtmlState {
question: string;
additional: string;
walkthrough: string;
}
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 [currentHighlightConfigs, setCurrentHighlightConfigs] = useState<HighlightConfig[]>([]);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [currentSegmentIndex, setCurrentSegmentIndex] = useState<number>(0);
const timelineRef = useRef<TimelineEvent[]>([]);
const animationRef = useRef<number | null>(null);
const segmentsRef = useRef<SegmentRef[]>([]);
const [questionHtml, setQuestionHtml] = useState(tip.exercise?.question || '');
const [additionalHtml, setAdditionalHtml] = useState(tip.exercise?.additional || '');
const [walkthroughHtml, setWalkthroughHtml] = useState<string>('');
const [htmlStates, setHtmlStates] = useState<HtmlState[]>([]);
const lastProcessedInsertTime = useRef<number>(-1);
const toggleAutoPlay = useCallback(() => {
setIsAutoPlaying((prev) => {
if (!prev && currentTime === getMaxTime()) {
@@ -21,7 +34,6 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
}
return !prev;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTime]);
const handleAnimationComplete = useCallback(() => {
@@ -33,23 +45,24 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
}, []);
const getMaxTime = (): number => {
return (
tip.exercise?.segments.reduce((sum, segment) => sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0) ?? 0
);
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 = [];
const newHtmlStates: HtmlState[] = [];
tip.exercise?.segments.forEach((segment, index) => {
const parser = new DOMParser();
const doc = parser.parseFromString(segment.html, "text/html");
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) || []));
words.push(...(node.textContent?.split(/\s+/).filter(word => word.length > 0) || []));
} else if (node.nodeType === Node.ELEMENT_NODE) {
Array.from(node.childNodes).forEach(walkTree);
}
@@ -62,69 +75,116 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
...segment,
words: words,
startTime: currentTimePosition,
endTime: currentTimePosition + textDuration,
endTime: currentTimePosition + textDuration
});
timeline.push({
type: "text",
type: 'text',
start: currentTimePosition,
end: currentTimePosition + textDuration,
segmentIndex: index,
segmentIndex: index
});
currentTimePosition += textDuration;
timeline.push({
type: "highlight",
type: 'highlight',
start: currentTimePosition,
end: currentTimePosition + segment.holdDelay,
content: segment.highlight,
segmentIndex: index,
segmentIndex: index
});
if (segment.insertHTML && segment.insertHTML.length > 0) {
newHtmlStates.push({
question: questionHtml,
additional: additionalHtml,
walkthrough: walkthroughHtml
});
timeline.push({
type: 'insert',
start: currentTimePosition,
end: currentTimePosition + segment.holdDelay,
segmentIndex: index,
content: segment.insertHTML
});
}
currentTimePosition += segment.holdDelay;
});
for (let i = 0; i < timeline.length; i++) {
if (timeline[i].type === 'insert') {
const nextInsertIndex = timeline.findIndex((event, index) => index > i && event.type === 'insert');
if (nextInsertIndex !== -1) {
timeline[i].end = timeline[nextInsertIndex].start;
} else {
timeline[i].end = currentTimePosition;
}
}
}
timelineRef.current = timeline;
}, [tip.exercise?.segments]);
setHtmlStates(newHtmlStates);
}, [tip.exercise?.segments, questionHtml, additionalHtml, walkthroughHtml]);
const updateText = useCallback(() => {
const currentEvent = timelineRef.current.find((event) => currentTime >= event.start && currentTime < event.end);
const currentEvents = timelineRef.current.filter(
event => currentTime >= event.start && currentTime <= event.end
);
if (currentEvent) {
if (currentEvent.type === "text") {
if (currentTime < lastProcessedInsertTime.current) {
const lastInsertEvent = timelineRef.current
.filter(event => event.type === 'insert' && event.start <= currentTime)
.pop();
if (lastInsertEvent) {
const stateIndex = timelineRef.current.indexOf(lastInsertEvent);
if (stateIndex >= 0 && stateIndex < htmlStates.length) {
const previousState = htmlStates[stateIndex];
setQuestionHtml(previousState.question);
setAdditionalHtml(previousState.additional);
setWalkthroughHtml(previousState.walkthrough);
}
} else {
// If no previous insert event, revert to initial state
setQuestionHtml(tip.exercise?.question || '');
setAdditionalHtml(tip.exercise?.additional || '');
setWalkthroughHtml('');
}
}
currentEvents.forEach(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("");
.map(seg => seg.html)
.join('');
const parser = new DOMParser();
const doc = parser.parseFromString(segment.html, "text/html");
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) {
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;
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 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);
@@ -133,38 +193,79 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
} 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));
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));
walkTree(doc.body, node => fragment.appendChild(node));
const serializer = new XMLSerializer();
const currentSegmentHtml = Array.from(fragment.childNodes)
.map((node) => serializer.serializeToString(node))
.join("");
.map(node => serializer.serializeToString(node))
.join('');
const newHtml = previousSegmentsHtml + currentSegmentHtml;
setWalkthroughHtml(newHtml);
setHighlightedPhrases([]);
} else if (currentEvent.type === "highlight") {
setCurrentSegmentIndex(currentEvent.segmentIndex);
setCurrentHighlightConfigs([]);
} else if (currentEvent.type === 'highlight') {
const newHtml = segmentsRef.current
.slice(0, currentEvent.segmentIndex + 1)
.map((seg) => seg.html)
.join("");
.map(seg => seg.html)
.join('');
setWalkthroughHtml(newHtml);
setHighlightedPhrases(currentEvent.content || []);
setCurrentSegmentIndex(currentEvent.segmentIndex);
setCurrentHighlightConfigs(currentEvent.content as HighlightConfig[] || []);
} else if (currentEvent.type === 'insert') {
const insertConfigs = currentEvent.content as InsertHtmlConfig[];
insertConfigs.forEach(config => {
switch (config.target) {
case 'question':
setQuestionHtml(prevHtml => insertHtmlContent(prevHtml, config));
break;
case 'additional':
setAdditionalHtml(prevHtml => insertHtmlContent(prevHtml, config));
break;
case 'segment':
setWalkthroughHtml(prevHtml => insertHtmlContent(prevHtml, config));
break;
}
});
lastProcessedInsertTime.current = currentTime;
}
}
}, [currentTime]);
});
}, [currentTime, htmlStates, tip.exercise?.question, tip.exercise?.additional]);
useEffect(() => {
updateText();
}, [currentTime, updateText]);
const insertHtmlContent = (prevHtml: string, config: InsertHtmlConfig): string => {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = prevHtml;
const targetElement = tempDiv.querySelector(`#${config.targetId}`);
if (targetElement) {
switch (config.position) {
case 'append':
targetElement.insertAdjacentHTML('beforeend', config.html);
break;
case 'prepend':
targetElement.insertAdjacentHTML('afterbegin', config.html);
break;
case 'replace':
targetElement.innerHTML = config.html;
break;
}
}
return tempDiv.innerHTML;
};
useEffect(() => {
if (isAutoPlaying) {
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
@@ -219,62 +320,81 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
}
};
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 className="container mx-auto py-6">
<Tip category={tip.tipCategory} html={tip.tipHtml} />
{!tip.standalone && (
<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-1">
<div className="bg-gray-50 rounded-lg shadow">
<div className="p-6 space-y-4">
<animated.div dangerouslySetInnerHTML={{__html: walkthroughHtml}} />
<div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 w-full'>
<div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 w-full'>
<div className='flex-1 bg-white p-6 rounded-lg shadow space-y-6'>
<div className="container mx-auto px-4">
<div id="question-container" className="border p-6 rounded-lg shadow-md">
<HighlightContent
html={questionHtml}
highlightConfigs={currentHighlightConfigs}
contentType="question"
/>
</div>
</div>
{tip.exercise?.additional && (<div className="container mx-auto px-4">
<div id="additional-container" className="border p-6 rounded-lg shadow-md">
<HighlightContent
html={additionalHtml}
highlightConfigs={currentHighlightConfigs}
contentType="additional"
/>
</div>
</div>
)}
</div>
<div className='flex-1'>
<div className='bg-gray-50 rounded-lg shadow'>
<div id="segment-container" className='p-6 space-y-4'>
<animated.div>
<HighlightContent
html={walkthroughHtml}
highlightConfigs={currentHighlightConfigs.filter(config =>
config.targets.includes('segment') || config.targets.includes('all')
)}
contentType="segment"
currentSegmentIndex={currentSegmentIndex}
/>
</animated.div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default ExerciseWalkthrough;
export default ExerciseWalkthrough;

View File

@@ -0,0 +1,201 @@
import { ITrainingTip, WalkthroughConfigs } from "./TrainingInterfaces";
const colorOptions = [
'red', 'blue', 'green', 'purple', 'pink', 'indigo', 'teal', 'orange', 'cyan', 'emerald', 'sky', 'violet', 'fuchsia', 'rose', 'lime', 'slate'
]
const getRandomColors = (count: number) => {
const shuffled = [...colorOptions].sort(() => 0.5 - Math.random());
return shuffled.slice(0, count);
};
const classMap = {
"mainDiv": {
"tip": "flex-col gap-2",
"question": "flex-col gap-2",
"additional": "flex-col gap-8",
"segment": "p-4 rounded-lg mb-4 flex flex-col gap-2"
},
"h2": {
"tip": "mb-4 font-semibold text-lg",
"question": "text-lg font-semibold mb-4",
"additional": "text-2xl font-semibold mb-4",
"segment": "text-xl font-semibold"
}
}
const setClass = (element: Element, style: string) => {
element.setAttribute('class', style)
}
// DON'T OVERRIDE DIV AND SPAN STYLES
const processHtml = (section: string, html: string, color: string) => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const mainDiv = doc.body.firstElementChild;
if (mainDiv && mainDiv.tagName === 'DIV') {
if (section === "segment") {
setClass(mainDiv, `bg-${color}-100 ${classMap["mainDiv"][section]}`);
} else {
setClass(mainDiv, classMap["mainDiv"][section as keyof typeof classMap["mainDiv"]]);
}
}
doc.querySelectorAll('h1').forEach(e => {
if (section === "additional") {
setClass(e, 'text-4xl font-bold mb-6')
} else {
setClass(e, 'text-xl font-semibold mb-4');
}
});
doc.querySelectorAll('h2').forEach(e => {
setClass(e, classMap["h2"][section as keyof typeof classMap["h2"]])
});
doc.querySelectorAll('h3').forEach(e => {
e.setAttribute('class', 'text-lg font-semibold mb-4')
})
doc.querySelectorAll('p').forEach(e => {
if (section === "segment") {
setClass(e, 'text-gray-700 leading-relaxed')
} else {
setClass(e, 'mb-4');
}
});
doc.querySelectorAll('label').forEach(e => {
if (section === "additional") {
setClass(e, 'font-semibold');
} else {
setClass(e, 'min-w-[16px] mr-1 font-semibold');
}
});
doc.querySelectorAll('ul').forEach(e => {
const hasLabel = Array.from(e.querySelectorAll('li')).some(li => li.querySelector('label'));
if (hasLabel) {
e.setAttribute('class', 'list-none space-y-2');
} else {
e.setAttribute('class', `list-disc pl-5 space-y-2`);
}
});
doc.querySelectorAll('ol').forEach(e => {
e.setAttribute('class', 'list-decimal pl-5 space-y-2');
})
doc.querySelectorAll('hz-row').forEach(e => {
e.setAttribute('class', `flex flex-row items-center mb-4 gap-2`);
})
if (section === "segment") {
doc.querySelectorAll('b').forEach(e => {
e.setAttribute('class', `text-${color}-700`);
});
}
doc.querySelectorAll('section').forEach(e => {
e.setAttribute('class', `mb-8`);
});
doc.querySelectorAll('option-box').forEach(e => {
e.setAttribute('class', `flex justify-center min-w-[32px] min-h-6 bg-gray-200 rounded`);
});
doc.querySelectorAll('option-box-grow').forEach(e => {
e.setAttribute('class', 'flex flex-grow ml-2 w-10 min-h-6 bg-gray-200 rounded px-4 py-2');
})
doc.querySelectorAll('option-box-blank').forEach(e => {
e.setAttribute('class', 'min-w-[32px] min-h-[32px] border border-gray-300 text-center mr-3 flex justify-center items-center');
})
doc.querySelectorAll('option-card').forEach(e => {
e.setAttribute('class', 'bg-gray-100 rounded-lg flex flex-col p-4')
})
doc.querySelectorAll('footer').forEach(e => {
e.setAttribute('class', `flex flex-col gap-2 text-sm`);
});
doc.querySelectorAll('single-line').forEach(e => {
e.setAttribute('class', `border-b border-black w-full h-4 inline-block`);
})
doc.querySelectorAll('padded-line').forEach(e => {
e.setAttribute('class', `my-2 inline-block w-full`);
})
doc.querySelectorAll('table').forEach(table => {
table.setAttribute('class', 'min-w-full bg-white border border-gray-300')
table.querySelectorAll('thead tr').forEach(tr => {
tr.setAttribute('class', 'bg-gray-100');
});
table.querySelectorAll('th').forEach(th => {
th.setAttribute('class', 'py-2 px-4 border-b font-semibold text-left');
});
table.querySelectorAll('tbody tr').forEach((tr, index) => {
if (index % 2 === 1) {
tr.setAttribute('class', 'bg-gray-50');
}
});
table.querySelectorAll('td').forEach(td => {
if (td === td.parentElement?.firstElementChild) {
td.setAttribute('class', 'py-2 px-4 border-b font-medium');
} else {
td.setAttribute('class', 'py-2 px-4 border-b');
}
});
});
doc.querySelectorAll('blockquote').forEach(e => {
setClass(e, `flex w-full justify-center ${section === "segment" ? "" : "mb-4"}`)
})
doc.querySelectorAll('items-between').forEach(e => {
setClass(e, 'flex flex-row justify-between mb-4')
})
return doc.body.innerHTML;
}
const formatTip = (tip: ITrainingTip): ITrainingTip => {
if (tip.exercise && tip.exercise.segments) {
const colors = getRandomColors(tip.exercise.segments.length);
const processedSegments: WalkthroughConfigs[] = tip.exercise.segments.map((segment, index) => ({
...segment,
html: processHtml("segment", segment.html, colors[index])
}));
return {
id: tip.id,
tipCategory: tip.tipCategory,
tipHtml: processHtml("tip", tip.tipHtml, ""),
standalone: tip.standalone,
exercise: {
question: processHtml("question", tip.exercise.question, ""),
additional: tip.exercise.additional ? processHtml("additional", tip.exercise.additional, "") : undefined,
segments: processedSegments
}
};
}
return {
id: tip.id,
tipCategory: tip.tipCategory,
tipHtml: processHtml("tip", tip.tipHtml, ""),
standalone: tip.standalone,
exercise: undefined
};
};
export default formatTip;

View File

@@ -0,0 +1,83 @@
import React, { useMemo } from 'react';
import { FaChessKnight, FaLink, FaPen } from 'react-icons/fa';
import { IoLanguage } from 'react-icons/io5';
import { MdOutlineCategory } from 'react-icons/md';
import { GiSkills } from 'react-icons/gi';
import { BiBookReader } from 'react-icons/bi';
type CategoryConfig = {
[key: string]: {
headerColor: string;
bodyColor: string;
textColor: string;
icon: any;
}
};
const categoryConfig : CategoryConfig = {
'Strategy': {
headerColor: 'bg-yellow-400',
bodyColor: 'bg-yellow-100',
textColor: 'text-yellow-900',
icon: FaChessKnight
},
'Word Partners': {
headerColor: 'bg-purple-700',
bodyColor: 'bg-purple-200',
textColor: 'text-purple-900',
icon: MdOutlineCategory
},
'Word Link': {
headerColor: 'bg-green-600',
bodyColor: 'bg-green-100',
textColor: 'text-green-900',
icon: FaLink
},
'CT Focus': {
headerColor: 'bg-purple-700',
bodyColor: 'bg-purple-200',
textColor: 'text-purple-900',
icon: GiSkills
},
'Reading Skill': {
headerColor: 'bg-orange-200',
bodyColor: 'bg-orange-100',
textColor: 'text-orange-900',
icon: BiBookReader
},
'Language for Writing': {
headerColor: 'bg-orange-200',
bodyColor: 'bg-orange-100',
textColor: 'text-orange-900',
icon: IoLanguage
},
'Writing Skill': {
headerColor: 'bg-orange-200',
bodyColor: 'bg-orange-100',
textColor: 'text-orange-900',
icon: FaPen
}
};
const Tip: React.FC<{ category: string; html: string }> = ({ category, html }) => {
const { headerColor, bodyColor, textColor, icon: Icon } = useMemo(() =>
categoryConfig[category] || categoryConfig['Strategy'], [category]
);
return (
<div className="rounded-lg overflow-hidden shadow-md mb-4">
<div className={`px-4 py-3 ${headerColor}`}>
<h2 className="font-bold text-white text-xl flex items-center">
<Icon className="ml-2 mr-2" size={24} />
{category === "CT Focus" ? "Critical Thinking" : category}
</h2>
</div>
<div className={`p-6 ${bodyColor}`}>
<p className={`text-lg ${textColor}`} dangerouslySetInnerHTML={{ __html: html }} />
</div>
</div>
);
};
export default Tip;

View File

@@ -29,7 +29,7 @@ export interface ITrainingTip {
standalone: boolean;
exercise?: {
question: string;
highlightable: string;
additional?: string;
segments: WalkthroughConfigs[]
}
}
@@ -38,16 +38,31 @@ export interface WalkthroughConfigs {
html: string;
wordDelay: number;
holdDelay: number;
highlight: string[];
highlight?: HighlightConfig[];
insertHTML?: InsertHtmlConfig[];
}
export type HighlightTarget = 'question' | 'additional' | 'segment' | 'all';
export interface HighlightConfig {
targets: HighlightTarget[];
phrases: string[];
}
export interface InsertHtmlConfig {
target: 'question' | 'additional' | 'segment';
targetId: string;
html: string;
position: 'append' | 'prepend' | 'replace';
}
export interface TimelineEvent {
type: 'text' | 'highlight';
type: 'text' | 'highlight' | 'insert';
start: number;
end: number;
segmentIndex: number;
content?: string[];
content?: HighlightConfig[] | InsertHtmlConfig[];
}
export interface SegmentRef extends WalkthroughConfigs {

View File

@@ -10,6 +10,7 @@ import clsx from "clsx";
import Exercise from "@/training/Exercise";
import TrainingScore from "@/training/TrainingScore";
import {ITrainingContent, ITrainingTip} from "@/training/TrainingInterfaces";
import formatTip from "@/training/FormatTip";
import {Stat, User} from "@/interfaces/user";
import Head from "next/head";
import Layout from "@/components/High/Layout";
@@ -105,7 +106,9 @@ const TrainingContent: React.FC<{user: User}> = ({user}) => {
params: {ids: trainingContent.tip_ids},
paramsSerializer: (params) => qs.stringify(params, {arrayFormat: "repeat"}),
});
setTrainingTips(tips.data);
const processedTips = tips.data.map(formatTip);
setTrainingTips(processedTips);
setTrainingContent(withExamsStats);
} catch (error) {
router.push("/training");
@@ -338,7 +341,7 @@ const TrainingContent: React.FC<{user: User}> = ({user}) => {
</div>
<div className="flex -md:hidden">
<div className="rounded-3xl p-6 shadow-training-inset w-full">
<div className="flex flex-col p-10">
<div className="flex flex-col">
<Exercise key={currentTipIndex} {...trainingTips[currentTipIndex]} />
</div>
<div className="self-end flex justify-between w-full gap-8 bottom-8 left-0 px-8">

View File

@@ -5,6 +5,9 @@ module.exports = {
{
pattern: /bg-ai-detection-result-(ai|mixed|human)/,
},
{
pattern: /(bg|text)-(red|blue|green|purple|pink|indigo|teal|orange|cyan|emerald|sky|violet|fuchsia|rose|lime|slate)-(50|100|200|700)/,
}
],
theme: {
container: {