diff --git a/client/index.html b/client/index.html index c880bd3..df9711e 100644 --- a/client/index.html +++ b/client/index.html @@ -1,12 +1,16 @@ - - - - PyBe - - -
- - - + + + + + PyBe + + + + +
+ + + + \ No newline at end of file diff --git a/client/package-lock.json b/client/package-lock.json index 15e5231..b7f5809 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -17,6 +17,7 @@ } }, "..": { + "name": "pybe-mern-app", "version": "1.0.0", "dependencies": { "concurrently": "^9.1.2" diff --git a/client/src/components/ChallengeWorkspace.jsx b/client/src/components/ChallengeWorkspace.jsx new file mode 100644 index 0000000..f3073bf --- /dev/null +++ b/client/src/components/ChallengeWorkspace.jsx @@ -0,0 +1,511 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + Timer, + Code2, + HelpCircle, + Play, + Sparkles, + AlertCircle, + Terminal, + CheckCircle2, + RefreshCw +} from 'lucide-react'; +import { verifyScenario } from '../utils/verification'; +import { getScenarioHint } from '../utils/hints'; +import CodeFlowChart from './CodeFlowChart'; + +// Shared global Pyodide loaders inside the workspace module context +let pyodideInstance = null; +let pyodideLoadingPromise = null; + +async function getPyodide() { + if (pyodideInstance) return pyodideInstance; + if (pyodideLoadingPromise) return pyodideLoadingPromise; + + pyodideLoadingPromise = (async () => { + if (!window.loadPyodide) { + throw new Error("Pyodide execution environment could not be found. Check connection or index.html."); + } + const py = await window.loadPyodide({ + indexURL: "https://cdn.jsdelivr.net/pyodide/v0.26.2/full/" + }); + pyodideInstance = py; + return py; + })(); + + return pyodideLoadingPromise; +} + +export default function ChallengeWorkspace({ result, scenario }) { + const [challengeState, setChallengeState] = useState('locked'); // 'locked', 'active', 'revealed' + const [setupStep, setSetupStep] = useState('choose_path'); // 'choose_path', 'config_timer' + const [customMinutes, setCustomMinutes] = useState(10); + const [isZen, setIsZen] = useState(false); + const [timeLeft, setTimeLeft] = useState(600); + const [timerActive, setTimerActive] = useState(false); + const [userCode, setUserCode] = useState(''); + const [consoleOutput, setConsoleOutput] = useState(''); + const [consoleError, setConsoleError] = useState(null); + const [isRunning, setIsRunning] = useState(false); + const [isVerifying, setIsVerifying] = useState(false); + const [verifyStatus, setVerifyStatus] = useState(null); + const [solvedBeforeReveal, setSolvedBeforeReveal] = useState(false); + + const [hintIndex, setHintIndex] = useState(0); + const [currentHint, setCurrentHint] = useState(''); + const [pyodideState, setPyodideState] = useState('idle'); + const [hoveredLine, setHoveredLine] = useState(null); + + const [leftWidth, setLeftWidth] = useState(55); + const splitContainerRef = useRef(null); + + const startResize = (e) => { + e.preventDefault(); + document.addEventListener('mousemove', handleResizeMove); + document.addEventListener('mouseup', handleResizeEnd); + document.addEventListener('touchmove', handleTouchResizeMove); + document.addEventListener('touchend', handleResizeEnd); + }; + + const handleResizeMove = (e) => { + if (!splitContainerRef.current) return; + const rect = splitContainerRef.current.getBoundingClientRect(); + let percentage = ((e.clientX - rect.left) / rect.width) * 100; + if (percentage < 30) percentage = 30; + if (percentage > 80) percentage = 80; + setLeftWidth(percentage); + }; + + const handleTouchResizeMove = (e) => { + if (!splitContainerRef.current || !e.touches[0]) return; + const rect = splitContainerRef.current.getBoundingClientRect(); + let percentage = ((e.touches[0].clientX - rect.left) / rect.width) * 100; + if (percentage < 30) percentage = 30; + if (percentage > 80) percentage = 80; + setLeftWidth(percentage); + }; + + const handleResizeEnd = () => { + document.removeEventListener('mousemove', handleResizeMove); + document.removeEventListener('mouseup', handleResizeEnd); + document.removeEventListener('touchmove', handleTouchResizeMove); + document.removeEventListener('touchend', handleResizeEnd); + }; + + const timerRef = useRef(null); + + useEffect(() => { + setChallengeState('locked'); + setSetupStep('choose_path'); + setCustomMinutes(10); + setIsZen(false); + setTimerActive(false); + setTimeLeft(600); + setUserCode(''); + setConsoleOutput(''); + setConsoleError(null); + setVerifyStatus(null); + setSolvedBeforeReveal(false); + setHintIndex(0); + setCurrentHint(''); + if (timerRef.current) clearInterval(timerRef.current); + }, [result, scenario?._id]); + + useEffect(() => { + if (timerActive && challengeState === 'active') { + timerRef.current = setInterval(() => { + setTimeLeft((prev) => { + if (prev <= 1) { + clearInterval(timerRef.current); + setTimerActive(false); + setChallengeState('revealed'); + setSolvedBeforeReveal(false); + return 0; + } + return prev - 1; + }); + }, 1000); + } + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, [timerActive, challengeState]); + + const startChallenge = () => { + const seconds = isZen ? 0 : customMinutes * 60; + setTimeLeft(seconds); + setTimerActive(!isZen); + setChallengeState('active'); + + const objectivesList = scenario?.objectives?.map(obj => `# - ${obj}`).join('\n') || ''; + const template = `# Scenario: ${scenario?.title || 'Challenge'}\n# Objectives:\n${objectivesList}\n\n# Write your code to solve this scenario below:\n\n`; + setUserCode(template); + }; + + const handleGetHint = () => { + const nextHint = getScenarioHint(scenario?.title, hintIndex); + setCurrentHint(nextHint); + setHintIndex((prev) => prev + 1); + }; + + const handleRunCode = async () => { + if (isRunning) return; + setIsRunning(true); + setConsoleError(null); + setPyodideState('loading'); + try { + const py = await getPyodide(); + setPyodideState('loaded'); + + let logs = ''; + const decodeBuffer = (buf) => { + if (typeof buf === 'string') return buf; + try { return new TextDecoder().decode(buf); } catch { return String(buf); } + }; + + py.setStdout({ + write: (buf) => { + logs += decodeBuffer(buf); + return buf.length; + } + }); + py.setStderr({ + write: (buf) => { + logs += decodeBuffer(buf); + return buf.length; + } + }); + + py.runPython(` +import sys +for name in list(globals().keys()): + if not name.startswith('__') and name not in ['sys', 'io', 'pyodide']: + del globals()[name] + `); + + await py.runPythonAsync(userCode); + setConsoleOutput(logs || '(Success: Program ran with no output)'); + setConsoleError(null); + } catch (err) { + setConsoleError(err.message); + } finally { + setIsRunning(false); + } + }; + + const handleVerify = async () => { + if (isVerifying) return; + setIsVerifying(true); + setConsoleError(null); + setPyodideState('loading'); + try { + const py = await getPyodide(); + setPyodideState('loaded'); + + let logs = ''; + const decodeBuffer = (buf) => { + if (typeof buf === 'string') return buf; + try { return new TextDecoder().decode(buf); } catch { return String(buf); } + }; + + py.setStdout({ + write: (buf) => { + logs += decodeBuffer(buf); + return buf.length; + } + }); + py.setStderr({ + write: (buf) => { + logs += decodeBuffer(buf); + return buf.length; + } + }); + + py.runPython(` +import sys +for name in list(globals().keys()): + if not name.startswith('__') and name not in ['sys', 'io', 'pyodide']: + del globals()[name] + `); + + await py.runPythonAsync(userCode); + + const status = await verifyScenario(scenario?.title, userCode, py); + setVerifyStatus(status); + setConsoleOutput(logs); + setConsoleError(null); + + if (status.success) { + setSolvedBeforeReveal(true); + setChallengeState('revealed'); + setTimerActive(false); + } + } catch (err) { + setConsoleError(err.message); + setVerifyStatus({ + success: false, + errors: ["Execution error: " + err.message], + warnings: [] + }); + } finally { + setIsVerifying(false); + } + }; + + const revealEarly = () => { + setChallengeState('revealed'); + setSolvedBeforeReveal(false); + setTimerActive(false); + }; + + const formatTime = (seconds) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + }; + + const lineCount = userCode.split('\n').length; + const lines = Array.from({ length: Math.max(lineCount, 6) }, (_, i) => i + 1); + + return ( +
+
{result.promptScore}Prompt maturity
+
+ {result.abstractionMap.map((item) => ( +
+ {item.pattern} + {item.pythonConcept} +

{item.explanation}

+
+ ))} +
+ + {challengeState === 'locked' && ( +
+ {setupStep === 'choose_path' ? ( + <> +
+ +

How would you like to review the code?

+
+

+ You can try writing the Python code solution yourself inside our interactive sandbox, or display the AI Mentor's recommended solution immediately. +

+
+ + +
+ + ) : ( + <> +
+ +

Configure Challenge Timer

+
+

+ Set a time limit for your practice run. The solution will reveal when the countdown hits zero or when you successfully solve the scenario checks. +

+ +
+
+ + setCustomMinutes(Math.max(1, parseInt(e.target.value) || 1))} + /> +
+ + +
+ +
+ + +
+ + )} +
+ )} + + {challengeState === 'active' && ( +
+
+
+ Challenge Sandbox +
+ {isZen ? ( + 🐢 Zen Mode + ) : ( + + ⏱️ {formatTime(timeLeft)} + + )} +
+
+ +
+
+ {lines.map((num) => ( + + {num} + + ))} +
+