feat(frontend): add code editor
This commit is contained in:
627
frontend/src/components/editor/problem-workspace.tsx
Normal file
627
frontend/src/components/editor/problem-workspace.tsx
Normal file
@@ -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<LeftTab>("problem");
|
||||
const [rightTab, setRightTab] = useState<RightTab>("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<TestResult[]>([]);
|
||||
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<string, unknown> }>) => {
|
||||
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<string, unknown> }>) => {
|
||||
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 (
|
||||
<div className="fixed inset-0 top-[65px] flex bg-[var(--background)]">
|
||||
{/* Left panel - Problem description / Explanation */}
|
||||
<div className="w-1/2 border-r border-[var(--border)] flex flex-col">
|
||||
{/* Header - compact */}
|
||||
<div className="px-4 pt-3 pb-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/questions"
|
||||
className="text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
>
|
||||
← Back
|
||||
</Link>
|
||||
<h1 className="text-lg font-bold">{question.title}</h1>
|
||||
<Badge variant={getDifficultyVariant(question.difficulty)} className="text-xs">
|
||||
{capitalize(question.difficulty)}
|
||||
</Badge>
|
||||
</div>
|
||||
{question.leetcode_url && (
|
||||
<a
|
||||
href={question.leetcode_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--primary)] hover:underline text-xs"
|
||||
>
|
||||
LeetCode ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{question.categories.map((cat) => (
|
||||
<Link key={cat.id} href={`/questions?category=${cat.slug}`}>
|
||||
<Badge variant="category" className="text-xs py-0">
|
||||
{cat.name}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
{question.patterns.map((pat) => (
|
||||
<Link key={pat.id} href={`/patterns/${pat.slug}`}>
|
||||
<Badge variant="pattern" className="text-xs py-0">
|
||||
{pat.name}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-[var(--border)]">
|
||||
<button
|
||||
onClick={() => setLeftTab("problem")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
leftTab === "problem"
|
||||
? "border-[var(--primary)] text-[var(--foreground)]"
|
||||
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
Problem
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLeftTab("explanation")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
leftTab === "explanation"
|
||||
? "border-[var(--primary)] text-[var(--foreground)]"
|
||||
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
Explanation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab content - scrollable */}
|
||||
<div className="flex-1 overflow-auto px-4 py-3">
|
||||
{leftTab === "problem" ? (
|
||||
<div className="space-y-6">
|
||||
{/* Problem */}
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3">
|
||||
<FileText className="h-5 w-5" />
|
||||
Problem
|
||||
</h2>
|
||||
<div className="prose-content">
|
||||
<Markdown>{question.description}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Constraints */}
|
||||
{question.constraints && (
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
Constraints
|
||||
</h2>
|
||||
<Markdown>{question.constraints}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Examples */}
|
||||
{question.examples && question.examples.length > 0 && (
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3">
|
||||
<BookOpen className="h-5 w-5" />
|
||||
Examples
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{question.examples.map((example, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="p-3 rounded bg-[var(--secondary)] space-y-1"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium">Input: </span>
|
||||
<code>{example.input}</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Output: </span>
|
||||
<code>{example.output}</code>
|
||||
</div>
|
||||
{example.explanation && (
|
||||
<div className="text-sm text-[var(--muted-foreground)]">
|
||||
{example.explanation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Intuition */}
|
||||
{question.explanation?.intuition && (
|
||||
<Callout variant="insight" icon={Brain} title="Intuition">
|
||||
<Markdown>{question.explanation.intuition}</Markdown>
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{/* Approach */}
|
||||
{question.explanation?.approach && (
|
||||
<Callout variant="info" icon={ClipboardList} title="Approach">
|
||||
<Markdown>{question.explanation.approach}</Markdown>
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{/* Common Pitfalls */}
|
||||
{question.explanation?.common_pitfalls &&
|
||||
question.explanation.common_pitfalls.length > 0 && (
|
||||
<Callout
|
||||
variant="warning"
|
||||
icon={AlertTriangle}
|
||||
title="Common Pitfalls"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{question.explanation.common_pitfalls.map((pitfall, i) => (
|
||||
<div key={i}>
|
||||
<h4 className="font-medium mb-2">{pitfall.title}</h4>
|
||||
<Markdown>{pitfall.description}</Markdown>
|
||||
{pitfall.wrong_approach && pitfall.correct_approach && (
|
||||
<div className="mt-2 flex gap-2 flex-wrap">
|
||||
<ApproachBox variant="wrong">
|
||||
{pitfall.wrong_approach}
|
||||
</ApproachBox>
|
||||
<ApproachBox variant="correct">
|
||||
{pitfall.correct_approach}
|
||||
</ApproachBox>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{/* Key Takeaways */}
|
||||
{question.explanation?.key_takeaways &&
|
||||
question.explanation.key_takeaways.length > 0 && (
|
||||
<Callout
|
||||
variant="success"
|
||||
icon={Lightbulb}
|
||||
title="Key Takeaways"
|
||||
>
|
||||
<ul className="space-y-2">
|
||||
{question.explanation.key_takeaways.map((takeaway, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="[&>div]:inline [&>div>p]:inline"
|
||||
>
|
||||
<Markdown>{takeaway}</Markdown>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{/* Complexity Analysis */}
|
||||
{(question.explanation?.time_complexity ||
|
||||
question.explanation?.space_complexity) && (
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3">
|
||||
Complexity Analysis
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{question.explanation?.time_complexity && (
|
||||
<p>
|
||||
<strong>Time:</strong>{" "}
|
||||
<Markdown>
|
||||
{question.explanation.time_complexity}
|
||||
</Markdown>
|
||||
</p>
|
||||
)}
|
||||
{question.explanation?.space_complexity && (
|
||||
<p>
|
||||
<strong>Space:</strong>{" "}
|
||||
<Markdown>
|
||||
{question.explanation.space_complexity}
|
||||
</Markdown>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Solutions */}
|
||||
{question.solutions && question.solutions.length > 0 && (
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3">
|
||||
<Code className="h-5 w-5" />
|
||||
Solutions
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{question.solutions.map((solution, i) => {
|
||||
const content = (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">
|
||||
{solution.approach_name}
|
||||
</h3>
|
||||
{solution.is_optimal && (
|
||||
<Badge variant="optimal">Optimal</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CodeBlock language="python" code={solution.code} />
|
||||
{solution.explanation && (
|
||||
<div className="text-sm text-[var(--muted-foreground)]">
|
||||
<Markdown>{solution.explanation}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return solution.is_optimal ? (
|
||||
<div key={i}>{content}</div>
|
||||
) : (
|
||||
<Collapsible
|
||||
key={i}
|
||||
title={`${solution.approach_name} (Non-optimal)`}
|
||||
>
|
||||
{content}
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel - Editor and test results */}
|
||||
<div className="w-1/2 flex flex-col">
|
||||
{/* Tabs header */}
|
||||
<div className="px-4 pt-2 border-b border-[var(--border)] flex items-center justify-between">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setRightTab("code")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
|
||||
rightTab === "code"
|
||||
? "border-[var(--primary)] text-[var(--foreground)]"
|
||||
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
<Code className="h-4 w-4" />
|
||||
Code
|
||||
</button>
|
||||
{hasRunTests && (
|
||||
<button
|
||||
onClick={() => setRightTab("results")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
|
||||
rightTab === "results"
|
||||
? "border-[var(--primary)] text-[var(--foreground)]"
|
||||
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
Results
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)] px-2 py-1"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{rightTab === "code" ? (
|
||||
<>
|
||||
{/* Editor - full height */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<CodeEditor value={code} onChange={setCode} />
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="p-3 border-t border-[var(--border)] flex items-center justify-between bg-[var(--secondary)]">
|
||||
<div className="text-sm text-[var(--muted-foreground)]">
|
||||
{pyodideLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading Python...
|
||||
</span>
|
||||
) : (
|
||||
"Python ready"
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleRunTests}
|
||||
disabled={pyodideLoading || isLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded bg-[var(--secondary)] border border-[var(--border)] hover:bg-[var(--accent)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isRunning ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
Run Tests
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={pyodideLoading || isLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Results tab */
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<TestResults
|
||||
visibleResults={visibleResults}
|
||||
hiddenPassedCount={hiddenPassedCount}
|
||||
hiddenTotalCount={hiddenTotalCount}
|
||||
visibleTestCases={visibleTestCases}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user