diff --git a/src/common/badges-dashboard/BadgeDetails.jsx b/src/common/badges-dashboard/BadgeDetails.jsx index ff78258d9f..b20d38c65d 100644 --- a/src/common/badges-dashboard/BadgeDetails.jsx +++ b/src/common/badges-dashboard/BadgeDetails.jsx @@ -1,6 +1,6 @@ import Badge from './Badge'; -import sanitizeHTML from 'common/utils/sanitizeHTML'; import './badge.css'; +import sanitizeHTML from 'common/utils/sanitizeHTML'; const BadgeDetails = ({ badge, onClose }) => { const makeClickableLinks = (badge) => { diff --git a/src/common/playlists/PlayErrorBoundary.css b/src/common/playlists/PlayErrorBoundary.css new file mode 100644 index 0000000000..13cd05b837 --- /dev/null +++ b/src/common/playlists/PlayErrorBoundary.css @@ -0,0 +1,60 @@ +.play-error-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem 1.5rem; + text-align: center; + min-height: 50vh; +} + +.play-error-image { + width: 200px; + height: auto; + margin-bottom: 1.5rem; + opacity: 0.8; +} + +.play-error-title { + font-size: 1.5rem; + font-weight: 600; + color: #333; + margin: 0 0 0.75rem; +} + +.play-error-message { + font-size: 1rem; + color: #666; + max-width: 500px; + line-height: 1.5; + margin: 0 0 1.5rem; +} + +.play-error-actions { + display: flex; + gap: 1rem; +} + +.play-error-retry-button { + padding: 0.6rem 1.5rem; + font-size: 0.95rem; + font-weight: 600; + border: none; + border-radius: 6px; + cursor: pointer; + background: #00f2fe; + color: #fff; + transition: opacity 0.2s; +} + +.play-error-back-button { + padding: 0.6rem 1.5rem; + font-size: 0.95rem; + font-weight: 600; + border: 2px solid #00f2fe; + border-radius: 6px; + cursor: pointer; + background: transparent; + color: #00f2fe; + transition: opacity 0.2s; +} \ No newline at end of file diff --git a/src/common/playlists/PlayErrorBoundary.jsx b/src/common/playlists/PlayErrorBoundary.jsx new file mode 100644 index 0000000000..76015834f8 --- /dev/null +++ b/src/common/playlists/PlayErrorBoundary.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { ReactComponent as ImageOops } from 'images/img-oops.svg'; +import './PlayErrorBoundary.css'; + +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; + } +} + +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/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..3d4f3391b1 100644 --- a/src/plays/devblog/Pages/Article.jsx +++ b/src/plays/devblog/Pages/Article.jsx @@ -1,8 +1,8 @@ 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'; +import sanitizeHTML from 'common/utils/sanitizeHTML'; const Article = () => { const [article, setArticle] = useState({}); diff --git a/src/plays/fun-quiz/EndScreen.jsx b/src/plays/fun-quiz/EndScreen.jsx index 5398009a8b..3c30b637dc 100644 --- a/src/plays/fun-quiz/EndScreen.jsx +++ b/src/plays/fun-quiz/EndScreen.jsx @@ -1,5 +1,6 @@ // vendors import { Fragment, useState } from 'react'; + import sanitizeHTML from 'common/utils/sanitizeHTML'; // css diff --git a/src/plays/fun-quiz/QuizScreen.jsx b/src/plays/fun-quiz/QuizScreen.jsx index 571946985a..c0f7dd2da2 100644 --- a/src/plays/fun-quiz/QuizScreen.jsx +++ b/src/plays/fun-quiz/QuizScreen.jsx @@ -1,6 +1,6 @@ import { useEffect, useState, useCallback, useRef } from 'react'; -import sanitizeHTML from 'common/utils/sanitizeHTML'; +import sanitizeHTML from 'common/utils/sanitizeHTML'; import './QuizScreen.scss'; // assets 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..26c13c6327 100644 --- a/src/plays/text-to-speech/TextToSpeech.jsx +++ b/src/plays/text-to-speech/TextToSpeech.jsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { FaVolumeUp, FaStop } from 'react-icons/fa'; import PlayHeader from 'common/playlists/PlayHeader'; +import sanitizeHTML from 'common/utils/sanitizeHTML'; import './styles.css'; function TextToSpeech(props) { @@ -158,7 +159,10 @@ function TextToSpeech(props) {
{convertedText ? ( <> -

{convertedText}

+