diff --git a/frontend/src/components/editor/code-editor.tsx b/frontend/src/components/editor/code-editor.tsx new file mode 100644 index 0000000..b375ce6 --- /dev/null +++ b/frontend/src/components/editor/code-editor.tsx @@ -0,0 +1,48 @@ +"use client"; + +import Editor, { type OnMount } from "@monaco-editor/react"; +import { useCallback, useRef } from "react"; + +interface CodeEditorProps { + value: string; + onChange: (value: string) => void; +} + +export function CodeEditor({ value, onChange }: CodeEditorProps) { + const editorRef = useRef[0] | null>(null); + + const handleEditorMount: OnMount = useCallback((editor) => { + editorRef.current = editor; + }, []); + + const handleChange = useCallback( + (newValue: string | undefined) => { + onChange(newValue ?? ""); + }, + [onChange] + ); + + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/editor/index.ts b/frontend/src/components/editor/index.ts new file mode 100644 index 0000000..64e6f13 --- /dev/null +++ b/frontend/src/components/editor/index.ts @@ -0,0 +1,4 @@ +export { CodeEditor } from "./code-editor"; +export { TestRunner } from "./test-runner"; +export { TestResults } from "./test-results"; +export { ProblemWorkspace } from "./problem-workspace"; diff --git a/frontend/src/components/editor/problem-workspace.tsx b/frontend/src/components/editor/problem-workspace.tsx new file mode 100644 index 0000000..486b0dd --- /dev/null +++ b/frontend/src/components/editor/problem-workspace.tsx @@ -0,0 +1,627 @@ +"use client"; + +import { useState, useCallback, useMemo } from "react"; +import { CodeEditor } from "./code-editor"; +import { TestResults } from "./test-results"; +import { usePyodide } from "@/hooks/use-pyodide"; +import { submitSolution } from "@/lib/api"; +import type { QuestionDetail, TestResult, HiddenTestOutput } from "@/types"; +import { Badge } from "@/components/ui/badge"; +import { Markdown } from "@/components/ui/markdown"; +import { CodeBlock } from "@/components/ui/code-block"; +import { Callout, ApproachBox } from "@/components/ui/callout"; +import { Collapsible } from "@/components/ui/collapsible"; +import { getDifficultyVariant, capitalize } from "@/lib/utils"; +import { + FileText, + BookOpen, + AlertCircle, + Brain, + ClipboardList, + AlertTriangle, + Lightbulb, + Code, + Play, + Send, + Loader2, +} from "lucide-react"; +import Link from "next/link"; + +interface ProblemWorkspaceProps { + question: QuestionDetail; +} + +interface ExecutionResult { + test_id: number; + output: unknown; + error?: string; +} + +function extractFunctionName(signature: string): string { + const match = signature.match(/def\s+(\w+)\s*\(/); + return match ? match[1] : "solution"; +} + +function generateStarterCode(signature: string): string { + return `${signature} + # Write your solution here + pass +`; +} + +type LeftTab = "problem" | "explanation"; +type RightTab = "code" | "results"; + +export function ProblemWorkspace({ question }: ProblemWorkspaceProps) { + const starterCode = useMemo( + () => generateStarterCode(question.function_signature!), + [question.function_signature] + ); + const [code, setCode] = useState(starterCode); + const [leftTab, setLeftTab] = useState("problem"); + const [rightTab, setRightTab] = useState("code"); + const [hasRunTests, setHasRunTests] = useState(false); + + // Test execution state + const { pyodide, loading: pyodideLoading, runPython } = usePyodide(); + const [isRunning, setIsRunning] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [visibleResults, setVisibleResults] = useState([]); + const [hiddenPassedCount, setHiddenPassedCount] = useState(0); + const [hiddenTotalCount, setHiddenTotalCount] = useState(0); + + const functionName = useMemo( + () => extractFunctionName(question.function_signature!), + [question.function_signature] + ); + + const visibleTestCases = useMemo( + () => question.visible_test_cases ?? [], + [question.visible_test_cases] + ); + const hiddenTestInputs = useMemo( + () => question.hidden_test_inputs ?? [], + [question.hidden_test_inputs] + ); + + const handleReset = useCallback(() => { + setCode(starterCode); + }, [starterCode]); + + const generateTestHarness = useCallback( + (tests: Array<{ id: number; input: Record }>) => { + const testsJson = JSON.stringify(tests); + return ` +import json + +${code} + +__tests__ = json.loads('${testsJson.replace(/'/g, "\\'")}') +__results__ = [] + +for test in __tests__: + try: + result = ${functionName}(**test["input"]) + __results__.append({"test_id": test["id"], "output": result}) + except Exception as e: + __results__.append({"test_id": test["id"], "output": None, "error": str(e)}) + +json.dumps(__results__) +`; + }, + [code, functionName] + ); + + const runTests = useCallback( + async (tests: Array<{ id: number; input: Record }>) => { + const harness = generateTestHarness(tests); + const { output, error } = await runPython(harness); + + if (error) { + return tests.map((t) => ({ + test_id: t.id, + output: null, + error: error, + })); + } + + try { + return JSON.parse(output as string) as ExecutionResult[]; + } catch { + return tests.map((t) => ({ + test_id: t.id, + output: null, + error: "Failed to parse output", + })); + } + }, + [generateTestHarness, runPython] + ); + + const handleRunTests = useCallback(async () => { + if (!pyodide || isRunning) return; + + setIsRunning(true); + setHiddenPassedCount(0); + setHiddenTotalCount(0); + + try { + const tests = visibleTestCases.map((tc) => ({ + id: tc.id, + input: tc.input, + })); + const results = await runTests(tests); + + const testResults: TestResult[] = results.map((r, idx) => { + const testCase = visibleTestCases[idx]; + const passed = + !r.error && + JSON.stringify(r.output) === JSON.stringify(testCase.expected); + + return { + test_id: r.test_id, + passed, + input: testCase.input, + expected: testCase.expected, + actual: r.output, + error: r.error, + }; + }); + + setVisibleResults(testResults); + setHasRunTests(true); + setRightTab("results"); + } finally { + setIsRunning(false); + } + }, [pyodide, isRunning, visibleTestCases, runTests]); + + const handleSubmit = useCallback(async () => { + if (!pyodide || isSubmitting) return; + + setIsSubmitting(true); + + try { + // Run visible tests first + const visibleTests = visibleTestCases.map((tc) => ({ + id: tc.id, + input: tc.input, + })); + const visibleExecutionResults = await runTests(visibleTests); + + const visibleTestResults: TestResult[] = visibleExecutionResults.map( + (r, idx) => { + const testCase = visibleTestCases[idx]; + const passed = + !r.error && + JSON.stringify(r.output) === JSON.stringify(testCase.expected); + + return { + test_id: r.test_id, + passed, + input: testCase.input, + expected: testCase.expected, + actual: r.output, + error: r.error, + }; + } + ); + + setVisibleResults(visibleTestResults); + + // Run hidden tests + const hiddenTests = hiddenTestInputs.map((tc) => ({ + id: tc.id, + input: tc.input, + })); + const hiddenExecutionResults = await runTests(hiddenTests); + + // Submit hidden outputs to server for validation + const hiddenOutputs: HiddenTestOutput[] = hiddenExecutionResults.map( + (r) => ({ + test_id: r.test_id, + output: r.output, + }) + ); + + const response = await submitSolution(question.slug, { + code, + hidden_outputs: hiddenOutputs, + }); + + setHiddenPassedCount(response.total_passed); + setHiddenTotalCount(response.total_tests); + setHasRunTests(true); + setRightTab("results"); + } catch (error) { + console.error("Submission error:", error); + } finally { + setIsSubmitting(false); + } + }, [ + pyodide, + isSubmitting, + visibleTestCases, + hiddenTestInputs, + runTests, + question.slug, + code, + ]); + + const isLoading = isRunning || isSubmitting; + + return ( +
+ {/* Left panel - Problem description / Explanation */} +
+ {/* Header - compact */} +
+
+
+ + ← Back + +

{question.title}

+ + {capitalize(question.difficulty)} + +
+ {question.leetcode_url && ( + + LeetCode ↗ + + )} +
+ +
+ {question.categories.map((cat) => ( + + + {cat.name} + + + ))} + {question.patterns.map((pat) => ( + + + {pat.name} + + + ))} +
+ + {/* Tabs */} +
+ + +
+
+ + {/* Tab content - scrollable */} +
+ {leftTab === "problem" ? ( +
+ {/* Problem */} +
+

+ + Problem +

+
+ {question.description} +
+
+ + {/* Constraints */} + {question.constraints && ( +
+

+ + Constraints +

+ {question.constraints} +
+ )} + + {/* Examples */} + {question.examples && question.examples.length > 0 && ( +
+

+ + Examples +

+
+ {question.examples.map((example, i) => ( +
+
+ Input: + {example.input} +
+
+ Output: + {example.output} +
+ {example.explanation && ( +
+ {example.explanation} +
+ )} +
+ ))} +
+
+ )} +
+ ) : ( +
+ {/* Intuition */} + {question.explanation?.intuition && ( + + {question.explanation.intuition} + + )} + + {/* Approach */} + {question.explanation?.approach && ( + + {question.explanation.approach} + + )} + + {/* Common Pitfalls */} + {question.explanation?.common_pitfalls && + question.explanation.common_pitfalls.length > 0 && ( + +
+ {question.explanation.common_pitfalls.map((pitfall, i) => ( +
+

{pitfall.title}

+ {pitfall.description} + {pitfall.wrong_approach && pitfall.correct_approach && ( +
+ + {pitfall.wrong_approach} + + + {pitfall.correct_approach} + +
+ )} +
+ ))} +
+
+ )} + + {/* Key Takeaways */} + {question.explanation?.key_takeaways && + question.explanation.key_takeaways.length > 0 && ( + +
    + {question.explanation.key_takeaways.map((takeaway, i) => ( +
  • + {takeaway} +
  • + ))} +
+
+ )} + + {/* Complexity Analysis */} + {(question.explanation?.time_complexity || + question.explanation?.space_complexity) && ( +
+

+ Complexity Analysis +

+
+ {question.explanation?.time_complexity && ( +

+ Time:{" "} + + {question.explanation.time_complexity} + +

+ )} + {question.explanation?.space_complexity && ( +

+ Space:{" "} + + {question.explanation.space_complexity} + +

+ )} +
+
+ )} + + {/* Solutions */} + {question.solutions && question.solutions.length > 0 && ( +
+

+ + Solutions +

+
+ {question.solutions.map((solution, i) => { + const content = ( +
+
+

+ {solution.approach_name} +

+ {solution.is_optimal && ( + Optimal + )} +
+ + {solution.explanation && ( +
+ {solution.explanation} +
+ )} +
+ ); + + return solution.is_optimal ? ( +
{content}
+ ) : ( + + {content} + + ); + })} +
+
+ )} +
+ )} +
+
+ + {/* Right panel - Editor and test results */} +
+ {/* Tabs header */} +
+
+ + {hasRunTests && ( + + )} +
+ +
+ + {/* Tab content */} +
+ {rightTab === "code" ? ( + <> + {/* Editor - full height */} +
+ +
+ + {/* Action buttons */} +
+
+ {pyodideLoading ? ( + + + Loading Python... + + ) : ( + "Python ready" + )} +
+
+ + +
+
+ + ) : ( + /* Results tab */ +
+ +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/editor/test-results.tsx b/frontend/src/components/editor/test-results.tsx new file mode 100644 index 0000000..9519025 --- /dev/null +++ b/frontend/src/components/editor/test-results.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { CheckCircle, XCircle, Loader2 } from "lucide-react"; +import type { TestResult, VisibleTestCase } from "@/types"; + +interface TestResultsProps { + visibleResults: TestResult[]; + hiddenPassedCount: number; + hiddenTotalCount: number; + isRunning?: boolean; + visibleTestCases?: VisibleTestCase[]; +} + +export function TestResults({ + visibleResults, + hiddenPassedCount, + hiddenTotalCount, + isRunning, + visibleTestCases, +}: TestResultsProps) { + if (isRunning) { + return ( +
+ + Running tests... +
+ ); + } + + if (visibleResults.length === 0 && hiddenTotalCount === 0) { + return ( +
+ Click "Run Tests" to test your solution with visible test + cases, or "Submit" to run all tests. +
+ ); + } + + const formatValue = (value: unknown): string => { + if (value === null || value === undefined) return "null"; + if (typeof value === "string") return `"${value}"`; + return JSON.stringify(value); + }; + + const formatInput = (input: Record): string => { + return Object.entries(input) + .map(([key, val]) => `${key} = ${formatValue(val)}`) + .join(", "); + }; + + return ( +
+ {visibleResults.map((result, index) => { + const testCase = visibleTestCases?.[index]; + return ( +
+
+ {result.passed ? ( + + ) : ( + + )} + + Test {result.test_id + 1}: {result.passed ? "Passed" : "Failed"} + +
+ + {!result.passed && ( +
+ {result.input && ( +
+ + Input:{" "} + + + {formatInput(result.input)} + +
+ )} + {testCase && ( +
+ + Expected:{" "} + + + {formatValue(testCase.expected)} + +
+ )} +
+ Got: + + {result.error ? `Error: ${result.error}` : formatValue(result.actual)} + +
+
+ )} +
+ ); + })} + + {hiddenTotalCount > 0 && ( +
+
+ {hiddenPassedCount === hiddenTotalCount ? ( + + ) : ( + + )} + + Hidden Tests: {hiddenPassedCount}/{hiddenTotalCount} passed + +
+

+ Hidden test details are not shown to prevent cheating. +

+
+ )} +
+ ); +} diff --git a/frontend/src/components/editor/test-runner.tsx b/frontend/src/components/editor/test-runner.tsx new file mode 100644 index 0000000..c64c2b3 --- /dev/null +++ b/frontend/src/components/editor/test-runner.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { Play, Send, Loader2 } from "lucide-react"; +import { usePyodide } from "@/hooks/use-pyodide"; +import { submitSolution } from "@/lib/api"; +import { TestResults } from "./test-results"; +import type { + VisibleTestCase, + HiddenTestInput, + TestResult, + HiddenTestOutput, +} from "@/types"; + +interface TestRunnerProps { + code: string; + functionName: string; + slug: string; + visibleTestCases: VisibleTestCase[]; + hiddenTestInputs: HiddenTestInput[]; +} + +interface ExecutionResult { + test_id: number; + output: unknown; + error?: string; +} + +export function TestRunner({ + code, + functionName, + slug, + visibleTestCases, + hiddenTestInputs, +}: TestRunnerProps) { + const { pyodide, loading: pyodideLoading, runPython } = usePyodide(); + const [isRunning, setIsRunning] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [visibleResults, setVisibleResults] = useState([]); + const [hiddenPassedCount, setHiddenPassedCount] = useState(0); + const [hiddenTotalCount, setHiddenTotalCount] = useState(0); + + const generateTestHarness = useCallback( + (tests: Array<{ id: number; input: Record }>) => { + const testsJson = JSON.stringify(tests); + return ` +import json + +${code} + +__tests__ = json.loads('${testsJson.replace(/'/g, "\\'")}') +__results__ = [] + +for test in __tests__: + try: + result = ${functionName}(**test["input"]) + __results__.append({"test_id": test["id"], "output": result}) + except Exception as e: + __results__.append({"test_id": test["id"], "output": None, "error": str(e)}) + +json.dumps(__results__) +`; + }, + [code, functionName] + ); + + const runTests = useCallback( + async (tests: Array<{ id: number; input: Record }>) => { + const harness = generateTestHarness(tests); + const { output, error } = await runPython(harness); + + if (error) { + return tests.map((t) => ({ + test_id: t.id, + output: null, + error: error, + })); + } + + try { + return JSON.parse(output as string) as ExecutionResult[]; + } catch { + return tests.map((t) => ({ + test_id: t.id, + output: null, + error: "Failed to parse output", + })); + } + }, + [generateTestHarness, runPython] + ); + + const handleRunTests = useCallback(async () => { + if (!pyodide || isRunning) return; + + setIsRunning(true); + setHiddenPassedCount(0); + setHiddenTotalCount(0); + + try { + const tests = visibleTestCases.map((tc) => ({ + id: tc.id, + input: tc.input, + })); + const results = await runTests(tests); + + const testResults: TestResult[] = results.map((r, idx) => { + const testCase = visibleTestCases[idx]; + const passed = + !r.error && + JSON.stringify(r.output) === JSON.stringify(testCase.expected); + + return { + test_id: r.test_id, + passed, + input: testCase.input, + expected: testCase.expected, + actual: r.output, + error: r.error, + }; + }); + + setVisibleResults(testResults); + } finally { + setIsRunning(false); + } + }, [pyodide, isRunning, visibleTestCases, runTests]); + + const handleSubmit = useCallback(async () => { + if (!pyodide || isSubmitting) return; + + setIsSubmitting(true); + + try { + // Run visible tests first + const visibleTests = visibleTestCases.map((tc) => ({ + id: tc.id, + input: tc.input, + })); + const visibleExecutionResults = await runTests(visibleTests); + + const visibleTestResults: TestResult[] = visibleExecutionResults.map( + (r, idx) => { + const testCase = visibleTestCases[idx]; + const passed = + !r.error && + JSON.stringify(r.output) === JSON.stringify(testCase.expected); + + return { + test_id: r.test_id, + passed, + input: testCase.input, + expected: testCase.expected, + actual: r.output, + error: r.error, + }; + } + ); + + setVisibleResults(visibleTestResults); + + // Run hidden tests + const hiddenTests = hiddenTestInputs.map((tc) => ({ + id: tc.id, + input: tc.input, + })); + const hiddenExecutionResults = await runTests(hiddenTests); + + // Submit hidden outputs to server for validation + const hiddenOutputs: HiddenTestOutput[] = hiddenExecutionResults.map( + (r) => ({ + test_id: r.test_id, + output: r.output, + }) + ); + + const response = await submitSolution(slug, { + code, + hidden_outputs: hiddenOutputs, + }); + + setHiddenPassedCount(response.total_passed); + setHiddenTotalCount(response.total_tests); + } catch (error) { + console.error("Submission error:", error); + } finally { + setIsSubmitting(false); + } + }, [ + pyodide, + isSubmitting, + visibleTestCases, + hiddenTestInputs, + runTests, + slug, + code, + ]); + + const isLoading = isRunning || isSubmitting; + + return ( +
+
+ + + {pyodideLoading && ( + + Loading Python runtime... + + )} +
+
+ +
+
+ ); +}