diff --git a/src/components/HighlightContent.tsx b/src/components/HighlightContent.tsx index 32401523..d1f9d30e 100644 --- a/src/components/HighlightContent.tsx +++ b/src/components/HighlightContent.tsx @@ -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 = ({ 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) => `${match}`); - } else { - highlightedHtml = html.replace(globalRegex, (match) => `${match}`); - } + const regex = new RegExp(config.phrases.map(escapeRegExp).join('|'), 'g'); + + if (contentType === 'segment' && currentSegmentIndex !== undefined) { + const segments = highlightedHtml.split(''); + segments[currentSegmentIndex] = segments[currentSegmentIndex].replace(regex, (match) => { + return `${match}`; + }); + highlightedHtml = segments.join(''); + } else { + highlightedHtml = highlightedHtml.replace(regex, (match) => { + return `${match}`; + }); + } + } + }); return { __html: highlightedHtml }; - }, [html, highlightPhrases, firstOccurence]); + }, [html, highlightConfigs, contentType, currentSegmentIndex]); return
; }; -export default HighlightContent; +export default HighlightedContent; \ No newline at end of file diff --git a/src/components/TrainingContent/Exercise.tsx b/src/components/TrainingContent/Exercise.tsx index 984a30be..7f68c8f2 100644 --- a/src/components/TrainingContent/Exercise.tsx +++ b/src/components/TrainingContent/Exercise.tsx @@ -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 = (trainingTip: ITrainingTip) => { - const leftText = "
CategoryOption AOption B
SelfYou need to take care of yourself and connect with the people around you.Focus on your interests and talents and meet people who are like you.
HomeIt's a good idea to paint your living room yellow.You should arrange your home so that it makes you feel happy.
Financial LifeYou can be happy if you have enough money, but don't want money too much.If you waste money on things you don't need, you won't have enough money for things that you do need.
Social LifeA good group of friends can increase your happiness.Researchers say that a happy friend can increase our mood by nine percent.
WorkplaceYou spend a lot of time at work, so you should like your workplace.Your boss needs to be someone you enjoy working for.
CommunityThe place where you live is more important for happiness than anything else.Live around people who have the same amount of money as you do.
"; - const tip = { - category: "Strategy", - body: "

Look for clues to the main idea in the first (and sometimes second) sentence of a paragraph.

" - } - const question = "

Identifying Main Ideas

Read the statements below. Circle the main idea in each pair of statements (a or b).

"; - const rightTextData: WalkthroughConfigs[] = [ - { - "html": "

Identifying Main Ideas

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.

", - "wordDelay": 200, - "holdDelay": 5000, - "highlight": [] - }, - { - "html": "

1. Self

Main idea: A. You need to take care of yourself and connect with the people around you.

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.

", - "wordDelay": 200, - "holdDelay": 8000, - "highlight": ["You need to take care of yourself and connect with the people around you."] - }, - { - "html": "

2. Home

Main idea: B. You should arrange your home so that it makes you feel happy.

This statement is more general and applies to the entire home. Option A is a specific example that could fall under this broader concept.

", - "wordDelay": 200, - "holdDelay": 8000, - "highlight": ["You should arrange your home so that it makes you feel happy."] - }, - { - "html": "

3. Financial Life

Main idea: A. You can be happy if you have enough money, but don't want money too much.

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.

", - "wordDelay": 200, - "holdDelay": 8000, - "highlight": ["You can be happy if you have enough money, but don't want money too much."] - }, - { - "html": "

4. Social Life

Main idea: A. A good group of friends can increase your happiness.

This statement is more general about the impact of friendships. Option B provides a specific statistic that supports this main idea.

", - "wordDelay": 200, - "holdDelay": 8000, - "highlight": ["A good group of friends can increase your happiness."] - }, - { - "html": "

5. Workplace

Main idea: A. You spend a lot of time at work, so you should like your workplace.

This statement covers the overall importance of workplace satisfaction. Option B focuses on one specific aspect (the boss) and is less comprehensive.

", - "wordDelay": 200, - "holdDelay": 8000, - "highlight": ["You spend a lot of time at work, so you should like your workplace."] - }, - { - "html": "

6. Community

Main idea: A. The place where you live is more important for happiness than anything else.

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.

", - "wordDelay": 200, - "holdDelay": 8000, - "highlight": ["The place where you live is more important for happiness than anything else."] - }, - { - "html": "

Key Strategy

When identifying main ideas:

  • Look for broader, more encompassing statements
  • Consider which statement other ideas could fall under
  • Identify which statement provides a general principle rather than a specific example
", - "wordDelay": 200, - "holdDelay": 8000, - "highlight": [] - }, - { - "html": "

Helpful Tip

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.

", - "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 ( -
- -
- ); + const formattedTip = formatTip(mockTip); + return ( + + ); } export default TrainingExercise; \ No newline at end of file diff --git a/src/components/TrainingContent/ExerciseWalkthrough.tsx b/src/components/TrainingContent/ExerciseWalkthrough.tsx index 75a2dbbd..16026f91 100644 --- a/src/components/TrainingContent/ExerciseWalkthrough.tsx +++ b/src/components/TrainingContent/ExerciseWalkthrough.tsx @@ -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 = (tip: ITrainingTip) => { const [isAutoPlaying, setIsAutoPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); - const [walkthroughHtml, setWalkthroughHtml] = useState(""); - const [highlightedPhrases, setHighlightedPhrases] = useState([]); + const [currentHighlightConfigs, setCurrentHighlightConfigs] = useState([]); const [isPlaying, setIsPlaying] = useState(false); + const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0); const timelineRef = useRef([]); const animationRef = useRef(null); const segmentsRef = useRef([]); + const [questionHtml, setQuestionHtml] = useState(tip.exercise?.question || ''); + const [additionalHtml, setAdditionalHtml] = useState(tip.exercise?.additional || ''); + const [walkthroughHtml, setWalkthroughHtml] = useState(''); + const [htmlStates, setHtmlStates] = useState([]); + const lastProcessedInsertTime = useRef(-1); + const toggleAutoPlay = useCallback(() => { setIsAutoPlaying((prev) => { if (!prev && currentTime === getMaxTime()) { @@ -21,7 +34,6 @@ const ExerciseWalkthrough: React.FC = (tip: ITrainingTip) => { } return !prev; }); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentTime]); const handleAnimationComplete = useCallback(() => { @@ -33,23 +45,24 @@ const ExerciseWalkthrough: React.FC = (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 = (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 = (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 = (tip: ITrainingTip) => { } }; - if (tip.standalone || !tip.exercise) { - return ( -
-

The exercise for this tip is not available yet!

-
-

{tip.tipCategory}

-
-
-
- ); - } - return ( -
-
-

{tip.tipCategory}

-
-
-
-
- - 0 ? timelineRef.current[timelineRef.current.length - 1].end : 0} - value={currentTime} - onChange={handleSliderChange} - onMouseDown={handleSliderMouseDown} - onMouseUp={handleSliderMouseUp} - onTouchStart={handleSliderMouseDown} - onTouchEnd={handleSliderMouseUp} - className="flex-grow" - /> -
-
-
- {/*

Question

*/} -
- +
+ + {!tip.standalone && ( +
+
+ + 0 ? timelineRef.current[timelineRef.current.length - 1].end : 0} + value={currentTime} + onChange={handleSliderChange} + onMouseDown={handleSliderMouseDown} + onMouseUp={handleSliderMouseUp} + onTouchStart={handleSliderMouseDown} + onTouchEnd={handleSliderMouseUp} + className='flex-grow' + />
-
-
-
- +
+
+
+
+
+ +
+
+ {tip.exercise?.additional && (
+
+ +
+
+ )} +
+
+
+
+ + + config.targets.includes('segment') || config.targets.includes('all') + )} + contentType="segment" + currentSegmentIndex={currentSegmentIndex} + /> + +
+
-
+ )}
); }; -export default ExerciseWalkthrough; +export default ExerciseWalkthrough; \ No newline at end of file diff --git a/src/components/TrainingContent/FormatTip.ts b/src/components/TrainingContent/FormatTip.ts new file mode 100644 index 00000000..1c96ec71 --- /dev/null +++ b/src/components/TrainingContent/FormatTip.ts @@ -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; \ No newline at end of file diff --git a/src/components/TrainingContent/Tip.tsx b/src/components/TrainingContent/Tip.tsx new file mode 100644 index 00000000..57ad00cc --- /dev/null +++ b/src/components/TrainingContent/Tip.tsx @@ -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 ( +
+
+

+ + {category === "CT Focus" ? "Critical Thinking" : category} +

+
+
+

+

+
+ ); +}; + +export default Tip; \ No newline at end of file diff --git a/src/components/TrainingContent/TrainingInterfaces.ts b/src/components/TrainingContent/TrainingInterfaces.ts index 0d95559f..537be5d5 100644 --- a/src/components/TrainingContent/TrainingInterfaces.ts +++ b/src/components/TrainingContent/TrainingInterfaces.ts @@ -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 { diff --git a/src/pages/training/[id]/index.tsx b/src/pages/training/[id]/index.tsx index a322b63f..2dab7719 100644 --- a/src/pages/training/[id]/index.tsx +++ b/src/pages/training/[id]/index.tsx @@ -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}) => {
-
+
diff --git a/tailwind.config.js b/tailwind.config.js index 2cdf314d..d9c7360c 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -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: {