Merged in feature/training-content (pull request #95)
ENCOA-69: Training update, most of the styles in the old tips were standardized, before all the styles were hardcoded into the tip
This commit is contained in:
@@ -1,39 +1,47 @@
|
|||||||
import { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
|
import { HighlightConfig, HighlightTarget } from "@/training/TrainingInterfaces";
|
||||||
|
|
||||||
const HighlightContent: React.FC<{
|
interface HighlightedContentProps {
|
||||||
html: string;
|
html: string;
|
||||||
highlightPhrases: string[],
|
highlightConfigs: HighlightConfig[];
|
||||||
firstOccurence?: boolean
|
contentType: HighlightTarget;
|
||||||
}> = ({
|
currentSegmentIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HighlightedContent: React.FC<HighlightedContentProps> = ({
|
||||||
html,
|
html,
|
||||||
highlightPhrases,
|
highlightConfigs,
|
||||||
firstOccurence = false
|
contentType,
|
||||||
|
currentSegmentIndex
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
const createHighlightedContent = useCallback(() => {
|
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;
|
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) {
|
const regex = new RegExp(config.phrases.map(escapeRegExp).join('|'), 'g');
|
||||||
highlightedHtml = html.replace(regex, (match) => `<span style="background-color: yellow;">${match}</span>`);
|
|
||||||
} else {
|
if (contentType === 'segment' && currentSegmentIndex !== undefined) {
|
||||||
highlightedHtml = html.replace(globalRegex, (match) => `<span style="background-color: yellow;">${match}</span>`);
|
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 };
|
return { __html: highlightedHtml };
|
||||||
}, [html, highlightPhrases, firstOccurence]);
|
}, [html, highlightConfigs, contentType, currentSegmentIndex]);
|
||||||
|
|
||||||
return <div dangerouslySetInnerHTML={createHighlightedContent()} />;
|
return <div dangerouslySetInnerHTML={createHighlightedContent()} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default HighlightContent;
|
export default HighlightedContent;
|
||||||
@@ -1,91 +1,43 @@
|
|||||||
import React, { useState, useCallback } from "react";
|
import React from "react";
|
||||||
import ExerciseWalkthrough from "@/training/ExerciseWalkthrough";
|
import ExerciseWalkthrough from "@/training/ExerciseWalkthrough";
|
||||||
import { ITrainingTip, WalkthroughConfigs } from "./TrainingInterfaces";
|
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
|
// 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 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 = {
|
||||||
const tip = {
|
"category": "",
|
||||||
category: "Strategy",
|
"embedding": "",
|
||||||
body: "<p>Look for <b>clues to the main idea</b> in the first (and sometimes second) sentence of a paragraph.</p>"
|
"text": "",
|
||||||
}
|
"html": "",
|
||||||
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>";
|
"id": "",
|
||||||
const rightTextData: WalkthroughConfigs[] = [
|
"verified": true,
|
||||||
{
|
"standalone": false,
|
||||||
"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>",
|
"exercise": {
|
||||||
"wordDelay": 200,
|
"question": "",
|
||||||
"holdDelay": 5000,
|
"additional": "",
|
||||||
"highlight": []
|
"segments": []
|
||||||
},
|
}
|
||||||
{
|
|
||||||
"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 mockTip: ITrainingTip = {
|
const mockTip: ITrainingTip = {
|
||||||
id: "some random id",
|
id: "some random id",
|
||||||
tipCategory: tip.category,
|
tipCategory: tip.category,
|
||||||
tipHtml: tip.body,
|
tipHtml: tip.html,
|
||||||
standalone: false,
|
standalone: tip.standalone,
|
||||||
exercise: {
|
exercise: {
|
||||||
question: question,
|
question: tip.exercise.question,
|
||||||
highlightable: leftText,
|
additional: tip.exercise.additional,
|
||||||
segments: rightTextData
|
segments: tip.exercise.segments as WalkthroughConfigs[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const formattedTip = formatTip(mockTip);
|
||||||
<div className="flex flex-col p-10">
|
return (
|
||||||
<ExerciseWalkthrough {...trainingTip}
|
<ExerciseWalkthrough {...formatTip(trainingTip)}
|
||||||
/>
|
/>
|
||||||
</div>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TrainingExercise;
|
export default TrainingExercise;
|
||||||
@@ -1,19 +1,32 @@
|
|||||||
import React, {useState, useEffect, useRef, useCallback} from "react";
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import {animated} from "@react-spring/web";
|
import { animated } from '@react-spring/web';
|
||||||
import {FaRegCirclePlay, FaRegCircleStop} from "react-icons/fa6";
|
import { FaRegCirclePlay, FaRegCircleStop } from "react-icons/fa6";
|
||||||
import HighlightContent from "../HighlightContent";
|
import HighlightContent from '../HighlightContent';
|
||||||
import {ITrainingTip, SegmentRef, TimelineEvent} from "./TrainingInterfaces";
|
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 ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
||||||
const [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false);
|
const [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false);
|
||||||
const [currentTime, setCurrentTime] = useState<number>(0);
|
const [currentTime, setCurrentTime] = useState<number>(0);
|
||||||
const [walkthroughHtml, setWalkthroughHtml] = useState<string>("");
|
const [currentHighlightConfigs, setCurrentHighlightConfigs] = useState<HighlightConfig[]>([]);
|
||||||
const [highlightedPhrases, setHighlightedPhrases] = useState<string[]>([]);
|
|
||||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
|
const [currentSegmentIndex, setCurrentSegmentIndex] = useState<number>(0);
|
||||||
const timelineRef = useRef<TimelineEvent[]>([]);
|
const timelineRef = useRef<TimelineEvent[]>([]);
|
||||||
const animationRef = useRef<number | null>(null);
|
const animationRef = useRef<number | null>(null);
|
||||||
const segmentsRef = useRef<SegmentRef[]>([]);
|
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(() => {
|
const toggleAutoPlay = useCallback(() => {
|
||||||
setIsAutoPlaying((prev) => {
|
setIsAutoPlaying((prev) => {
|
||||||
if (!prev && currentTime === getMaxTime()) {
|
if (!prev && currentTime === getMaxTime()) {
|
||||||
@@ -21,7 +34,6 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
|||||||
}
|
}
|
||||||
return !prev;
|
return !prev;
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [currentTime]);
|
}, [currentTime]);
|
||||||
|
|
||||||
const handleAnimationComplete = useCallback(() => {
|
const handleAnimationComplete = useCallback(() => {
|
||||||
@@ -33,23 +45,24 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getMaxTime = (): number => {
|
const getMaxTime = (): number => {
|
||||||
return (
|
return tip.exercise?.segments.reduce((sum, segment) =>
|
||||||
tip.exercise?.segments.reduce((sum, segment) => sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0) ?? 0
|
sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0
|
||||||
);
|
) ?? 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeline: TimelineEvent[] = [];
|
const timeline: TimelineEvent[] = [];
|
||||||
let currentTimePosition = 0;
|
let currentTimePosition = 0;
|
||||||
segmentsRef.current = [];
|
segmentsRef.current = [];
|
||||||
|
const newHtmlStates: HtmlState[] = [];
|
||||||
|
|
||||||
tip.exercise?.segments.forEach((segment, index) => {
|
tip.exercise?.segments.forEach((segment, index) => {
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const doc = parser.parseFromString(segment.html, "text/html");
|
const doc = parser.parseFromString(segment.html, 'text/html');
|
||||||
const words: string[] = [];
|
const words: string[] = [];
|
||||||
const walkTree = (node: Node) => {
|
const walkTree = (node: Node) => {
|
||||||
if (node.nodeType === Node.TEXT_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) {
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
Array.from(node.childNodes).forEach(walkTree);
|
Array.from(node.childNodes).forEach(walkTree);
|
||||||
}
|
}
|
||||||
@@ -62,69 +75,116 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
|||||||
...segment,
|
...segment,
|
||||||
words: words,
|
words: words,
|
||||||
startTime: currentTimePosition,
|
startTime: currentTimePosition,
|
||||||
endTime: currentTimePosition + textDuration,
|
endTime: currentTimePosition + textDuration
|
||||||
});
|
});
|
||||||
|
|
||||||
timeline.push({
|
timeline.push({
|
||||||
type: "text",
|
type: 'text',
|
||||||
start: currentTimePosition,
|
start: currentTimePosition,
|
||||||
end: currentTimePosition + textDuration,
|
end: currentTimePosition + textDuration,
|
||||||
segmentIndex: index,
|
segmentIndex: index
|
||||||
});
|
});
|
||||||
|
|
||||||
currentTimePosition += textDuration;
|
currentTimePosition += textDuration;
|
||||||
|
|
||||||
timeline.push({
|
timeline.push({
|
||||||
type: "highlight",
|
type: 'highlight',
|
||||||
start: currentTimePosition,
|
start: currentTimePosition,
|
||||||
end: currentTimePosition + segment.holdDelay,
|
end: currentTimePosition + segment.holdDelay,
|
||||||
content: segment.highlight,
|
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;
|
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;
|
timelineRef.current = timeline;
|
||||||
}, [tip.exercise?.segments]);
|
setHtmlStates(newHtmlStates);
|
||||||
|
}, [tip.exercise?.segments, questionHtml, additionalHtml, walkthroughHtml]);
|
||||||
|
|
||||||
const updateText = useCallback(() => {
|
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 (currentTime < lastProcessedInsertTime.current) {
|
||||||
if (currentEvent.type === "text") {
|
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 segment = segmentsRef.current[currentEvent.segmentIndex];
|
||||||
const elapsedTime = currentTime - currentEvent.start;
|
const elapsedTime = currentTime - currentEvent.start;
|
||||||
const wordsToShow = Math.min(Math.floor(elapsedTime / segment.wordDelay), segment.words.length);
|
const wordsToShow = Math.min(Math.floor(elapsedTime / segment.wordDelay), segment.words.length);
|
||||||
|
|
||||||
const previousSegmentsHtml = segmentsRef.current
|
const previousSegmentsHtml = segmentsRef.current
|
||||||
.slice(0, currentEvent.segmentIndex)
|
.slice(0, currentEvent.segmentIndex)
|
||||||
.map((seg) => seg.html)
|
.map(seg => seg.html)
|
||||||
.join("");
|
.join('');
|
||||||
|
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const doc = parser.parseFromString(segment.html, "text/html");
|
const doc = parser.parseFromString(segment.html, 'text/html');
|
||||||
let wordCount = 0;
|
let wordCount = 0;
|
||||||
const walkTree = (node: Node, action: (node: Node) => void): boolean => {
|
const walkTree = (node: Node, action: (node: Node) => void): boolean => {
|
||||||
if (node.nodeType === Node.TEXT_NODE && node.textContent) {
|
if (node.nodeType === Node.TEXT_NODE && node.textContent) {
|
||||||
const words = node.textContent.split(/(\s+)/).filter((word) => word.length > 0);
|
const words = node.textContent.split(/(\s+)/).filter(word => word.length > 0);
|
||||||
if (wordCount + words.filter((w) => !/\s+/.test(w)).length <= wordsToShow) {
|
if (wordCount + words.filter(w => !/\s+/.test(w)).length <= wordsToShow) {
|
||||||
action(node.cloneNode(true));
|
action(node.cloneNode(true));
|
||||||
wordCount += words.filter((w) => !/\s+/.test(w)).length;
|
wordCount += words.filter(w => !/\s+/.test(w)).length;
|
||||||
} else {
|
} else {
|
||||||
const remainingWords = wordsToShow - wordCount;
|
const remainingWords = wordsToShow - wordCount;
|
||||||
const newTextContent = words.reduce(
|
const newTextContent = words.reduce((acc, word) => {
|
||||||
(acc, word) => {
|
if (!/\s+/.test(word) && acc.nonSpaceWords < remainingWords) {
|
||||||
if (!/\s+/.test(word) && acc.nonSpaceWords < remainingWords) {
|
acc.text += word;
|
||||||
acc.text += word;
|
acc.nonSpaceWords++;
|
||||||
acc.nonSpaceWords++;
|
} else if (/\s+/.test(word) || acc.nonSpaceWords < remainingWords) {
|
||||||
} else if (/\s+/.test(word) || acc.nonSpaceWords < remainingWords) {
|
acc.text += word;
|
||||||
acc.text += word;
|
}
|
||||||
}
|
return acc;
|
||||||
return acc;
|
}, { text: '', nonSpaceWords: 0 }).text;
|
||||||
},
|
|
||||||
{text: "", nonSpaceWords: 0},
|
|
||||||
).text;
|
|
||||||
const newNode = node.cloneNode(false);
|
const newNode = node.cloneNode(false);
|
||||||
newNode.textContent = newTextContent;
|
newNode.textContent = newTextContent;
|
||||||
action(newNode);
|
action(newNode);
|
||||||
@@ -133,38 +193,79 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
|||||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
const clone = node.cloneNode(false);
|
const clone = node.cloneNode(false);
|
||||||
action(clone);
|
action(clone);
|
||||||
Array.from(node.childNodes).some((child) => {
|
Array.from(node.childNodes).some(child => {
|
||||||
return walkTree(child, (childNode) => (clone as Node).appendChild(childNode));
|
return walkTree(child, childNode => (clone as Node).appendChild(childNode));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return wordCount >= wordsToShow;
|
return wordCount >= wordsToShow;
|
||||||
};
|
};
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
walkTree(doc.body, (node) => fragment.appendChild(node));
|
walkTree(doc.body, node => fragment.appendChild(node));
|
||||||
|
|
||||||
const serializer = new XMLSerializer();
|
const serializer = new XMLSerializer();
|
||||||
const currentSegmentHtml = Array.from(fragment.childNodes)
|
const currentSegmentHtml = Array.from(fragment.childNodes)
|
||||||
.map((node) => serializer.serializeToString(node))
|
.map(node => serializer.serializeToString(node))
|
||||||
.join("");
|
.join('');
|
||||||
const newHtml = previousSegmentsHtml + currentSegmentHtml;
|
const newHtml = previousSegmentsHtml + currentSegmentHtml;
|
||||||
|
|
||||||
setWalkthroughHtml(newHtml);
|
setWalkthroughHtml(newHtml);
|
||||||
setHighlightedPhrases([]);
|
setCurrentSegmentIndex(currentEvent.segmentIndex);
|
||||||
} else if (currentEvent.type === "highlight") {
|
setCurrentHighlightConfigs([]);
|
||||||
|
} else if (currentEvent.type === 'highlight') {
|
||||||
const newHtml = segmentsRef.current
|
const newHtml = segmentsRef.current
|
||||||
.slice(0, currentEvent.segmentIndex + 1)
|
.slice(0, currentEvent.segmentIndex + 1)
|
||||||
.map((seg) => seg.html)
|
.map(seg => seg.html)
|
||||||
.join("");
|
.join('');
|
||||||
setWalkthroughHtml(newHtml);
|
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(() => {
|
useEffect(() => {
|
||||||
updateText();
|
updateText();
|
||||||
}, [currentTime, 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(() => {
|
useEffect(() => {
|
||||||
if (isAutoPlaying) {
|
if (isAutoPlaying) {
|
||||||
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
|
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
|
||||||
@@ -219,60 +320,79 @@ 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 (
|
return (
|
||||||
<div className="container mx-auto">
|
<div className="container mx-auto py-6">
|
||||||
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4">
|
<Tip category={tip.tipCategory} html={tip.tipHtml} />
|
||||||
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
|
{!tip.standalone && (
|
||||||
<div className="text-gray-700" dangerouslySetInnerHTML={{__html: tip.tipHtml}} />
|
<div className='flex flex-col space-y-4'>
|
||||||
</div>
|
<div className='flex flex-row items-center space-x-4 py-4'>
|
||||||
<div className="flex flex-col space-y-4">
|
<button
|
||||||
<div className="flex flex-row items-center space-x-4 py-4">
|
onClick={toggleAutoPlay}
|
||||||
<button
|
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"
|
||||||
onClick={toggleAutoPlay}
|
aria-label={isAutoPlaying ? 'Pause' : 'Play'}
|
||||||
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 ? (
|
||||||
{isAutoPlaying ? <FaRegCircleStop className="w-6 h-6" /> : <FaRegCirclePlay className="w-6 h-6" />}
|
<FaRegCircleStop className="w-6 h-6" />
|
||||||
</button>
|
) : (
|
||||||
<input
|
<FaRegCirclePlay className="w-6 h-6" />
|
||||||
type="range"
|
)}
|
||||||
min="0"
|
</button>
|
||||||
max={timelineRef.current.length > 0 ? timelineRef.current[timelineRef.current.length - 1].end : 0}
|
<input
|
||||||
value={currentTime}
|
type="range"
|
||||||
onChange={handleSliderChange}
|
min="0"
|
||||||
onMouseDown={handleSliderMouseDown}
|
max={timelineRef.current.length > 0 ? timelineRef.current[timelineRef.current.length - 1].end : 0}
|
||||||
onMouseUp={handleSliderMouseUp}
|
value={currentTime}
|
||||||
onTouchStart={handleSliderMouseDown}
|
onChange={handleSliderChange}
|
||||||
onTouchEnd={handleSliderMouseUp}
|
onMouseDown={handleSliderMouseDown}
|
||||||
className="flex-grow"
|
onMouseUp={handleSliderMouseUp}
|
||||||
/>
|
onTouchStart={handleSliderMouseDown}
|
||||||
</div>
|
onTouchEnd={handleSliderMouseUp}
|
||||||
<div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4">
|
className='flex-grow'
|
||||||
<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>
|
||||||
<div className="flex-1">
|
<div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 w-full'>
|
||||||
<div className="bg-gray-50 rounded-lg shadow">
|
<div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 w-full'>
|
||||||
<div className="p-6 space-y-4">
|
<div className='flex-1 bg-white p-6 rounded-lg shadow space-y-6'>
|
||||||
<animated.div dangerouslySetInnerHTML={{__html: walkthroughHtml}} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
201
src/components/TrainingContent/FormatTip.ts
Normal file
201
src/components/TrainingContent/FormatTip.ts
Normal 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;
|
||||||
83
src/components/TrainingContent/Tip.tsx
Normal file
83
src/components/TrainingContent/Tip.tsx
Normal 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;
|
||||||
@@ -29,7 +29,7 @@ export interface ITrainingTip {
|
|||||||
standalone: boolean;
|
standalone: boolean;
|
||||||
exercise?: {
|
exercise?: {
|
||||||
question: string;
|
question: string;
|
||||||
highlightable: string;
|
additional?: string;
|
||||||
segments: WalkthroughConfigs[]
|
segments: WalkthroughConfigs[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,16 +38,31 @@ export interface WalkthroughConfigs {
|
|||||||
html: string;
|
html: string;
|
||||||
wordDelay: number;
|
wordDelay: number;
|
||||||
holdDelay: 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 {
|
export interface TimelineEvent {
|
||||||
type: 'text' | 'highlight';
|
type: 'text' | 'highlight' | 'insert';
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
segmentIndex: number;
|
segmentIndex: number;
|
||||||
content?: string[];
|
content?: HighlightConfig[] | InsertHtmlConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SegmentRef extends WalkthroughConfigs {
|
export interface SegmentRef extends WalkthroughConfigs {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import clsx from "clsx";
|
|||||||
import Exercise from "@/training/Exercise";
|
import Exercise from "@/training/Exercise";
|
||||||
import TrainingScore from "@/training/TrainingScore";
|
import TrainingScore from "@/training/TrainingScore";
|
||||||
import {ITrainingContent, ITrainingTip} from "@/training/TrainingInterfaces";
|
import {ITrainingContent, ITrainingTip} from "@/training/TrainingInterfaces";
|
||||||
|
import formatTip from "@/training/FormatTip";
|
||||||
import {Stat, User} from "@/interfaces/user";
|
import {Stat, User} from "@/interfaces/user";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import Layout from "@/components/High/Layout";
|
import Layout from "@/components/High/Layout";
|
||||||
@@ -105,7 +106,9 @@ const TrainingContent: React.FC<{user: User}> = ({user}) => {
|
|||||||
params: {ids: trainingContent.tip_ids},
|
params: {ids: trainingContent.tip_ids},
|
||||||
paramsSerializer: (params) => qs.stringify(params, {arrayFormat: "repeat"}),
|
paramsSerializer: (params) => qs.stringify(params, {arrayFormat: "repeat"}),
|
||||||
});
|
});
|
||||||
setTrainingTips(tips.data);
|
|
||||||
|
const processedTips = tips.data.map(formatTip);
|
||||||
|
setTrainingTips(processedTips);
|
||||||
setTrainingContent(withExamsStats);
|
setTrainingContent(withExamsStats);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
router.push("/training");
|
router.push("/training");
|
||||||
@@ -338,7 +341,7 @@ const TrainingContent: React.FC<{user: User}> = ({user}) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex -md:hidden">
|
<div className="flex -md:hidden">
|
||||||
<div className="rounded-3xl p-6 shadow-training-inset w-full">
|
<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]} />
|
<Exercise key={currentTipIndex} {...trainingTips[currentTipIndex]} />
|
||||||
</div>
|
</div>
|
||||||
<div className="self-end flex justify-between w-full gap-8 bottom-8 left-0 px-8">
|
<div className="self-end flex justify-between w-full gap-8 bottom-8 left-0 px-8">
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ module.exports = {
|
|||||||
{
|
{
|
||||||
pattern: /bg-ai-detection-result-(ai|mixed|human)/,
|
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: {
|
theme: {
|
||||||
container: {
|
container: {
|
||||||
|
|||||||
Reference in New Issue
Block a user