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:
@@ -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) {
|
let highlightedHtml = html;
|
||||||
return { __html: html };
|
highlightConfigs.forEach(config => {
|
||||||
}
|
if (config.targets.includes(contentType) || config.targets.includes('all')) {
|
||||||
|
|
||||||
const escapeRegExp = (string: string) => {
|
const escapeRegExp = (string: string) => {
|
||||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
};
|
};
|
||||||
|
|
||||||
const regex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'i');
|
const regex = new RegExp(config.phrases.map(escapeRegExp).join('|'), 'g');
|
||||||
const globalRegex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'gi');
|
|
||||||
|
|
||||||
let highlightedHtml = html;
|
if (contentType === 'segment' && currentSegmentIndex !== undefined) {
|
||||||
|
const segments = highlightedHtml.split('</div>');
|
||||||
if (firstOccurence) {
|
segments[currentSegmentIndex] = segments[currentSegmentIndex].replace(regex, (match) => {
|
||||||
highlightedHtml = html.replace(regex, (match) => `<span style="background-color: yellow;">${match}</span>`);
|
return `<span style="background-color: #FFFACD;">${match}</span>`;
|
||||||
|
});
|
||||||
|
highlightedHtml = segments.join('</div>');
|
||||||
} else {
|
} else {
|
||||||
highlightedHtml = html.replace(globalRegex, (match) => `<span style="background-color: yellow;">${match}</span>`);
|
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,90 +1,42 @@
|
|||||||
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: "Strategy",
|
"category": "",
|
||||||
body: "<p>Look for <b>clues to the main idea</b> in the first (and sometimes second) sentence of a paragraph.</p>"
|
"embedding": "",
|
||||||
|
"text": "",
|
||||||
|
"html": "",
|
||||||
|
"id": "",
|
||||||
|
"verified": true,
|
||||||
|
"standalone": false,
|
||||||
|
"exercise": {
|
||||||
|
"question": "",
|
||||||
|
"additional": "",
|
||||||
|
"segments": []
|
||||||
}
|
}
|
||||||
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 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[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formattedTip = formatTip(mockTip);
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col p-10">
|
<ExerciseWalkthrough {...formatTip(trainingTip)}
|
||||||
<ExerciseWalkthrough {...trainingTip}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,59 +75,108 @@ 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++;
|
||||||
@@ -122,9 +184,7 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
|||||||
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,31 +320,22 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (tip.standalone || !tip.exercise) {
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto">
|
<div className="container mx-auto py-6">
|
||||||
<h1 className="text-xl font-bold text-red-600">The exercise for this tip is not available yet!</h1>
|
<Tip category={tip.tipCategory} html={tip.tipHtml} />
|
||||||
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4 mt-10">
|
{!tip.standalone && (
|
||||||
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
|
<div className='flex flex-col space-y-4'>
|
||||||
<div className="text-gray-700" dangerouslySetInnerHTML={{__html: tip.tipHtml}} />
|
<div className='flex flex-row items-center space-x-4 py-4'>
|
||||||
</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
|
<button
|
||||||
onClick={toggleAutoPlay}
|
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"
|
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"}>
|
aria-label={isAutoPlaying ? 'Pause' : 'Play'}
|
||||||
{isAutoPlaying ? <FaRegCircleStop className="w-6 h-6" /> : <FaRegCirclePlay className="w-6 h-6" />}
|
>
|
||||||
|
{isAutoPlaying ? (
|
||||||
|
<FaRegCircleStop className="w-6 h-6" />
|
||||||
|
) : (
|
||||||
|
<FaRegCirclePlay className="w-6 h-6" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -255,25 +347,53 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
|||||||
onMouseUp={handleSliderMouseUp}
|
onMouseUp={handleSliderMouseUp}
|
||||||
onTouchStart={handleSliderMouseDown}
|
onTouchStart={handleSliderMouseDown}
|
||||||
onTouchEnd={handleSliderMouseUp}
|
onTouchEnd={handleSliderMouseUp}
|
||||||
className="flex-grow"
|
className='flex-grow'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4">
|
<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">
|
<div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 w-full'>
|
||||||
{/*<h2 className="text-xl font-bold mb-4">Question</h2>*/}
|
<div className='flex-1 bg-white p-6 rounded-lg shadow space-y-6'>
|
||||||
<div className="mb-4" dangerouslySetInnerHTML={{__html: tip.exercise.question}} />
|
<div className="container mx-auto px-4">
|
||||||
<HighlightContent html={tip.exercise.highlightable} highlightPhrases={highlightedPhrases} />
|
<div id="question-container" className="border p-6 rounded-lg shadow-md">
|
||||||
|
<HighlightContent
|
||||||
|
html={questionHtml}
|
||||||
|
highlightConfigs={currentHighlightConfigs}
|
||||||
|
contentType="question"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
</div>
|
||||||
<div className="bg-gray-50 rounded-lg shadow">
|
{tip.exercise?.additional && (<div className="container mx-auto px-4">
|
||||||
<div className="p-6 space-y-4">
|
<div id="additional-container" className="border p-6 rounded-lg shadow-md">
|
||||||
<animated.div dangerouslySetInnerHTML={{__html: walkthroughHtml}} />
|
<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