diff --git a/.husky/pre-commit b/.husky/pre-commit index 0312b76025..7e2936624c 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -npx lint-staged \ No newline at end of file +yarn pre-commit \ No newline at end of file diff --git a/src/common/Testimonial/TestimonialCard.jsx b/src/common/Testimonial/TestimonialCard.jsx index 9bf94226a0..c2e0c29ce0 100644 --- a/src/common/Testimonial/TestimonialCard.jsx +++ b/src/common/Testimonial/TestimonialCard.jsx @@ -2,7 +2,6 @@ import React, { useState } from 'react'; import { format } from 'date-fns'; import * as allLocales from 'date-fns/locale'; import { email2Slug } from 'common/services/string'; -import sanitizeHTML from 'common/utils/sanitizeHTML'; const TestimonialCard = ({ home, quote, name, avatarUrl, category, created_at, email }) => { const [formattedDate] = useState(() => { @@ -60,7 +59,7 @@ const TestimonialCard = ({ home, quote, name, avatarUrl, category, created_at, e >

diff --git a/src/common/badges-dashboard/BadgeDetails.jsx b/src/common/badges-dashboard/BadgeDetails.jsx index ff78258d9f..893bf60b19 100644 --- a/src/common/badges-dashboard/BadgeDetails.jsx +++ b/src/common/badges-dashboard/BadgeDetails.jsx @@ -1,5 +1,4 @@ import Badge from './Badge'; -import sanitizeHTML from 'common/utils/sanitizeHTML'; import './badge.css'; const BadgeDetails = ({ badge, onClose }) => { @@ -10,7 +9,7 @@ const BadgeDetails = ({ badge, onClose }) => { return `${name}`; }); - return ; + return ; }; return ( diff --git a/src/common/playlists/PlayErrorBoundary.jsx b/src/common/playlists/PlayErrorBoundary.jsx new file mode 100644 index 0000000000..f73e6f5bba --- /dev/null +++ b/src/common/playlists/PlayErrorBoundary.jsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { ReactComponent as ImageOops } from 'images/img-oops.svg'; + +class PlayErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null, isChunkError: false }; + } + + static getDerivedStateFromError(error) { + // Detect chunk load failures (network errors loading lazy chunks) + const isChunkError = + error?.name === 'ChunkLoadError' || + /loading chunk/i.test(error?.message) || + /failed to fetch dynamically imported module/i.test(error?.message); + + return { hasError: true, error, isChunkError }; + } + + componentDidCatch(error, errorInfo) { + console.error(`Error loading play "${this.props.playName}":`, error, errorInfo); + } + + handleRetry = () => { + this.setState({ hasError: false, error: null, isChunkError: false }); + }; + + handleGoBack = () => { + window.location.href = '/plays'; + }; + + render() { + if (this.state.hasError) { + return ( +

+ +

+ {this.state.isChunkError ? 'Failed to load this play' : 'Something went wrong'} +

+

+ {this.state.isChunkError + ? 'There was a network error loading this play. Please check your connection and try again.' + : `An error occurred while rendering "${this.props.playName || 'this play'}".`} +

+
+ {this.state.isChunkError && ( + + )} + +
+
+ ); + } + + return this.props.children; + } +} + +const styles = { + container: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + padding: '3rem 1.5rem', + textAlign: 'center', + minHeight: '50vh' + }, + image: { + width: '200px', + height: 'auto', + marginBottom: '1.5rem', + opacity: 0.8 + }, + title: { + fontSize: '1.5rem', + fontWeight: 600, + color: '#333', + margin: '0 0 0.75rem' + }, + message: { + fontSize: '1rem', + color: '#666', + maxWidth: '500px', + lineHeight: 1.5, + margin: '0 0 1.5rem' + }, + actions: { + display: 'flex', + gap: '1rem' + }, + retryButton: { + padding: '0.6rem 1.5rem', + fontSize: '0.95rem', + fontWeight: 600, + border: 'none', + borderRadius: '6px', + cursor: 'pointer', + background: '#00f2fe', + color: '#fff', + transition: 'opacity 0.2s' + }, + backButton: { + padding: '0.6rem 1.5rem', + fontSize: '0.95rem', + fontWeight: 600, + border: '2px solid #00f2fe', + borderRadius: '6px', + cursor: 'pointer', + background: 'transparent', + color: '#00f2fe', + transition: 'opacity 0.2s' + } +}; + +export default PlayErrorBoundary; diff --git a/src/common/playlists/PlayMeta.jsx b/src/common/playlists/PlayMeta.jsx index d5dbc2f17b..33158038d9 100644 --- a/src/common/playlists/PlayMeta.jsx +++ b/src/common/playlists/PlayMeta.jsx @@ -10,6 +10,7 @@ import { PageNotFound } from 'common'; import thumbPlay from 'images/thumb-play.png'; import { getProdUrl } from 'common/utils/commonUtils'; import { loadCoverImage } from 'common/utils/coverImageUtil'; +import PlayErrorBoundary from 'common/playlists/PlayErrorBoundary'; function PlayMeta() { const [loading, setLoading] = useState(true); @@ -87,6 +88,10 @@ function PlayMeta() { const renderPlayComponent = () => { const Comp = plays[play.component || toSanitized(play.title_name)]; + if (!Comp) { + return ; + } + return ; }; @@ -103,7 +108,11 @@ function PlayMeta() { - }>{renderPlayComponent()} + } + > + {renderPlayComponent()} + ); } diff --git a/src/common/utils/sanitizeHTML.js b/src/common/utils/sanitizeHTML.js deleted file mode 100644 index b07766de01..0000000000 --- a/src/common/utils/sanitizeHTML.js +++ /dev/null @@ -1,12 +0,0 @@ -import DOMPurify from 'dompurify'; - -/** - * Sanitizes an HTML string using DOMPurify to prevent XSS attacks. - * Use this utility whenever you need to render dynamic HTML via dangerouslySetInnerHTML. - * - * @param {string} html - The raw HTML string to sanitize. - * @returns {string} - A sanitized HTML string safe to use with dangerouslySetInnerHTML. - */ -const sanitizeHTML = (html) => DOMPurify.sanitize(html ?? ''); - -export default sanitizeHTML; diff --git a/src/plays/Selection-Sort-Visualizer/SelectionSortVisualizer.js b/src/plays/Selection-Sort-Visualizer/SelectionSortVisualizer.js index 1eb8241875..951f4380cb 100644 --- a/src/plays/Selection-Sort-Visualizer/SelectionSortVisualizer.js +++ b/src/plays/Selection-Sort-Visualizer/SelectionSortVisualizer.js @@ -23,8 +23,6 @@ function SelectionSortVisualizer() { const handleSort = async () => { const arrCopy = [...arr]; const outputElements = document.getElementById('output-visualizer'); - // Safe: clears the container to empty string (no user data injected). - // All subsequent DOM mutations use createElement/createTextNode (XSS-safe). outputElements.innerHTML = ''; for (let i = 0; i < arrCopy.length - 1; i++) { diff --git a/src/plays/devblog/Pages/Article.jsx b/src/plays/devblog/Pages/Article.jsx index ef2ea2d2b0..8f7c70e7da 100644 --- a/src/plays/devblog/Pages/Article.jsx +++ b/src/plays/devblog/Pages/Article.jsx @@ -1,7 +1,6 @@ import axios from 'axios'; import { useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; -import sanitizeHTML from 'common/utils/sanitizeHTML'; import Loading from '../components/Loading'; const Article = () => { @@ -51,7 +50,7 @@ const Article = () => {
) : ( diff --git a/src/plays/fun-quiz/EndScreen.jsx b/src/plays/fun-quiz/EndScreen.jsx index 5398009a8b..a8f337760f 100644 --- a/src/plays/fun-quiz/EndScreen.jsx +++ b/src/plays/fun-quiz/EndScreen.jsx @@ -1,6 +1,5 @@ // vendors import { Fragment, useState } from 'react'; -import sanitizeHTML from 'common/utils/sanitizeHTML'; // css import './FrontScreen.scss'; @@ -17,17 +16,17 @@ const EndScreen = ({ quizSummary, redirectHome }) => {
Question: {currentQuestion?.qNo}
  • Ans: ${currentQuestion?.correct_answer}
    `) + __html: `
    Ans: ${currentQuestion?.correct_answer}
    ` }} /> Your Answer: ${currentQuestion?.your_answer}`) + __html: `Your Answer: ${currentQuestion?.your_answer}` }} /> diff --git a/src/plays/fun-quiz/QuizScreen.jsx b/src/plays/fun-quiz/QuizScreen.jsx index 571946985a..cc150234f8 100644 --- a/src/plays/fun-quiz/QuizScreen.jsx +++ b/src/plays/fun-quiz/QuizScreen.jsx @@ -1,5 +1,4 @@ import { useEffect, useState, useCallback, useRef } from 'react'; -import sanitizeHTML from 'common/utils/sanitizeHTML'; import './QuizScreen.scss'; @@ -150,7 +149,7 @@ function QuizScreen({ category, getQuizSummary }) {
    {timer}
    Question: {questionNumber + 1}
    -

    +

    {currentQuestion?.options?.map((option, index) => { @@ -158,7 +157,7 @@ function QuizScreen({ category, getQuizSummary }) {
    diff --git a/src/plays/markdown-editor/Output.jsx b/src/plays/markdown-editor/Output.jsx index 8f7ca65314..9228271430 100644 --- a/src/plays/markdown-editor/Output.jsx +++ b/src/plays/markdown-editor/Output.jsx @@ -1,11 +1,10 @@ import React from 'react'; -import sanitizeHTML from 'common/utils/sanitizeHTML'; const Output = ({ md, text, mdPreviewBox }) => { return (
    ); diff --git a/src/plays/text-to-speech/TextToSpeech.jsx b/src/plays/text-to-speech/TextToSpeech.jsx index e400bc4fde..3286a6d0f9 100644 --- a/src/plays/text-to-speech/TextToSpeech.jsx +++ b/src/plays/text-to-speech/TextToSpeech.jsx @@ -158,7 +158,10 @@ function TextToSpeech(props) {
    {convertedText ? ( <> -

    {convertedText}

    +