1116 lines
40 KiB
TypeScript
1116 lines
40 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback, useMemo, useEffect } from "react";
|
|
import { CodeEditor } from "./code-editor";
|
|
import { TestResults } from "./test-results";
|
|
import { usePyodide } from "@/hooks/use-pyodide";
|
|
import { useTimeTracker, getTimeTrackerElapsed } from "@/hooks/use-time-tracker";
|
|
import { submitSolution } from "@/lib/api";
|
|
import { markQuestionCompleted, getSavedSolution, isQuestionCompleted } from "@/lib/progress";
|
|
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 {
|
|
detectProblemType,
|
|
TREE_NODE_CLASS,
|
|
LIST_NODE_CLASS,
|
|
BUILD_TREE,
|
|
TREE_TO_ARRAY,
|
|
BUILD_LIST,
|
|
LIST_TO_ARRAY,
|
|
} from "@/lib/python-helpers";
|
|
import {
|
|
COMPLEXITY_ANALYZER,
|
|
type ComplexityResult,
|
|
} from "@/lib/complexity-analyzer";
|
|
import {
|
|
FileText,
|
|
BookOpen,
|
|
AlertCircle,
|
|
Brain,
|
|
ClipboardList,
|
|
AlertTriangle,
|
|
Lightbulb,
|
|
Code,
|
|
Play,
|
|
Send,
|
|
Loader2,
|
|
PanelLeftClose,
|
|
PanelRightClose,
|
|
Columns2,
|
|
Gauge,
|
|
ChevronRight,
|
|
CheckCircle,
|
|
XCircle,
|
|
GitCompare,
|
|
} 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 {
|
|
// Check for class-based signature: "class MinStack" -> "MinStack"
|
|
const classMatch = signature.match(/class\s+(\w+)/);
|
|
if (classMatch) return classMatch[1];
|
|
|
|
// Function: "def two_sum(" -> "two_sum"
|
|
const funcMatch = signature.match(/def\s+(\w+)\s*\(/);
|
|
return funcMatch ? funcMatch[1] : "solution";
|
|
}
|
|
|
|
function generateStarterCode(signature: string): string {
|
|
// Class-based problems need a different starter template
|
|
if (signature.startsWith("class ")) {
|
|
return `${signature}:
|
|
def __init__(self):
|
|
# Initialize your data structure here
|
|
pass
|
|
`;
|
|
}
|
|
|
|
return `${signature}
|
|
# Write your solution here
|
|
pass
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Compare actual and expected outputs with flexible matching.
|
|
* Handles unordered array comparison for problems like subsets.
|
|
*/
|
|
function compareOutputs(actual: unknown, expected: unknown): boolean {
|
|
// Direct equality check
|
|
if (JSON.stringify(actual) === JSON.stringify(expected)) {
|
|
return true;
|
|
}
|
|
|
|
// Handle unordered array comparison (e.g., subsets, word-break-ii)
|
|
if (Array.isArray(actual) && Array.isArray(expected)) {
|
|
if (actual.length !== expected.length) {
|
|
return false;
|
|
}
|
|
// Sort both arrays by their JSON string representation for comparison
|
|
const sortFn = (a: unknown, b: unknown) =>
|
|
JSON.stringify(a).localeCompare(JSON.stringify(b));
|
|
const sortedActual = [...actual].sort(sortFn);
|
|
const sortedExpected = [...expected].sort(sortFn);
|
|
return JSON.stringify(sortedActual) === JSON.stringify(sortedExpected);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
type LeftTab = "problem" | "explanation" | "solutions";
|
|
type RightTab = "code" | "results";
|
|
type ViewMode = "split" | "left" | "right";
|
|
|
|
export function ProblemWorkspace({ question }: ProblemWorkspaceProps) {
|
|
// Start time tracking for this question
|
|
useTimeTracker(question.slug);
|
|
|
|
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 [viewMode, setViewMode] = useState<ViewMode>("split");
|
|
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 [complexityResult, setComplexityResult] = useState<ComplexityResult | null>(null);
|
|
const [isComplexityExpanded, setIsComplexityExpanded] = useState(false);
|
|
const [isCompleted, setIsCompleted] = useState(false);
|
|
|
|
// Load saved solution and completion status on mount
|
|
useEffect(() => {
|
|
const saved = getSavedSolution(question.slug);
|
|
if (saved) {
|
|
setCode(saved);
|
|
}
|
|
setIsCompleted(isQuestionCompleted(question.slug));
|
|
}, [question.slug]);
|
|
|
|
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 toggleViewMode = useCallback((panel: "left" | "right") => {
|
|
setViewMode((current) => {
|
|
if (current === "split") {
|
|
return panel; // Maximize the clicked panel
|
|
}
|
|
return "split"; // Return to split view
|
|
});
|
|
}, []);
|
|
|
|
const signature = question.function_signature ?? "";
|
|
|
|
const generateTestHarness = useCallback(
|
|
(tests: Array<{ id: number; input: Record<string, unknown> }>) => {
|
|
// Use first test to detect problem type
|
|
const firstInput = tests[0]?.input ?? {};
|
|
const problemType = detectProblemType(signature, firstInput);
|
|
const testsJson = JSON.stringify(tests);
|
|
|
|
if (problemType === "class-based") {
|
|
// Class-based harness: operations/args format
|
|
return `
|
|
import json
|
|
|
|
${code}
|
|
|
|
__tests__ = json.loads('${testsJson.replace(/'/g, "\\'")}')
|
|
__results__ = []
|
|
|
|
for test in __tests__:
|
|
try:
|
|
operations = test["input"]["operations"]
|
|
arguments = test["input"]["args"]
|
|
outputs = []
|
|
instance = None
|
|
for op, args in zip(operations, arguments):
|
|
if instance is None:
|
|
# First operation is always the constructor
|
|
instance = globals()[op](*args)
|
|
outputs.append(None)
|
|
else:
|
|
method_result = getattr(instance, op)(*args)
|
|
outputs.append(method_result)
|
|
__results__.append({"test_id": test["id"], "output": outputs})
|
|
except Exception as e:
|
|
__results__.append({"test_id": test["id"], "output": None, "error": str(e)})
|
|
|
|
json.dumps(__results__)
|
|
`;
|
|
}
|
|
|
|
if (problemType === "tree") {
|
|
// Tree-based harness: convert arrays to TreeNode, convert result back
|
|
return `
|
|
import json
|
|
|
|
${TREE_NODE_CLASS}
|
|
|
|
${BUILD_TREE}
|
|
|
|
${TREE_TO_ARRAY}
|
|
|
|
${code}
|
|
|
|
__tests__ = json.loads('${testsJson.replace(/'/g, "\\'")}')
|
|
__results__ = []
|
|
|
|
for test in __tests__:
|
|
try:
|
|
processed = dict(test["input"])
|
|
# Convert tree parameters from array to TreeNode
|
|
for key in ["root", "tree", "p", "q"]:
|
|
if key in processed and isinstance(processed[key], list):
|
|
processed[key] = __build_tree(processed[key])
|
|
result = ${functionName}(**processed)
|
|
# Convert TreeNode result back to array
|
|
if isinstance(result, TreeNode):
|
|
result = __tree_to_array(result)
|
|
__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__)
|
|
`;
|
|
}
|
|
|
|
if (problemType === "linkedlist") {
|
|
// Linked list harness: convert arrays to ListNode, convert result back
|
|
return `
|
|
import json
|
|
|
|
${LIST_NODE_CLASS}
|
|
|
|
${BUILD_LIST}
|
|
|
|
${LIST_TO_ARRAY}
|
|
|
|
${code}
|
|
|
|
__tests__ = json.loads('${testsJson.replace(/'/g, "\\'")}')
|
|
__results__ = []
|
|
|
|
for test in __tests__:
|
|
try:
|
|
processed = dict(test["input"])
|
|
# Convert list parameters from array to ListNode
|
|
for key in ["head", "l1", "l2", "list1", "list2", "node"]:
|
|
if key in processed and isinstance(processed[key], list):
|
|
processed[key] = __build_list(processed[key])
|
|
result = ${functionName}(**processed)
|
|
# Convert ListNode result back to array
|
|
if isinstance(result, ListNode):
|
|
result = __list_to_array(result)
|
|
__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__)
|
|
`;
|
|
}
|
|
|
|
// Simple function harness (default)
|
|
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, signature]
|
|
);
|
|
|
|
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 analyzeComplexity = useCallback(
|
|
async (userCode: string) => {
|
|
const analyzerCode = `
|
|
${COMPLEXITY_ANALYZER}
|
|
|
|
import json
|
|
__user_code = '''${userCode.replace(/'/g, "\\'")}'''
|
|
__result = analyze_complexity(__user_code)
|
|
json.dumps(__result)
|
|
`;
|
|
const { output, error } = await runPython(analyzerCode);
|
|
|
|
if (error) {
|
|
return { success: false, error } as ComplexityResult;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(output as string) as ComplexityResult;
|
|
} catch {
|
|
return { success: false, error: "Failed to parse analysis" } as ComplexityResult;
|
|
}
|
|
},
|
|
[runPython]
|
|
);
|
|
|
|
const handleRunTests = useCallback(async () => {
|
|
if (!pyodide || isRunning) return;
|
|
|
|
setIsRunning(true);
|
|
setHiddenPassedCount(0);
|
|
setHiddenTotalCount(0);
|
|
setComplexityResult(null);
|
|
|
|
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 && compareOutputs(r.output, 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);
|
|
|
|
// Run complexity analysis in the background
|
|
const complexity = await analyzeComplexity(code);
|
|
setComplexityResult(complexity);
|
|
} finally {
|
|
setIsRunning(false);
|
|
}
|
|
}, [pyodide, isRunning, visibleTestCases, runTests, analyzeComplexity, code]);
|
|
|
|
const handleSubmit = useCallback(async () => {
|
|
if (!pyodide || isSubmitting) return;
|
|
|
|
setIsSubmitting(true);
|
|
setComplexityResult(null);
|
|
|
|
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 && compareOutputs(r.output, 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);
|
|
|
|
// Run complexity analysis in the background
|
|
const complexity = await analyzeComplexity(code);
|
|
setComplexityResult(complexity);
|
|
|
|
// Check if all tests passed
|
|
const allVisiblePassed = visibleTestResults.every((r) => r.passed);
|
|
const allHiddenPassed = response.total_passed === response.total_tests;
|
|
if (allVisiblePassed && allHiddenPassed) {
|
|
const timeSpentMs = getTimeTrackerElapsed();
|
|
const primaryPattern = question.patterns[0]?.slug || "";
|
|
markQuestionCompleted(question.slug, {
|
|
primaryPattern,
|
|
difficulty: question.difficulty,
|
|
code,
|
|
timeSpentMs,
|
|
});
|
|
setIsCompleted(true);
|
|
}
|
|
|
|
// Auto-switch to Results tab after submit
|
|
setRightTab("results");
|
|
} catch (error) {
|
|
console.error("Submission error:", error);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
}, [
|
|
pyodide,
|
|
isSubmitting,
|
|
visibleTestCases,
|
|
hiddenTestInputs,
|
|
runTests,
|
|
question.slug,
|
|
question.difficulty,
|
|
question.patterns,
|
|
code,
|
|
analyzeComplexity,
|
|
]);
|
|
|
|
const isLoading = isRunning || isSubmitting;
|
|
|
|
return (
|
|
<div className="fixed inset-0 top-[65px] flex bg-[var(--background)]">
|
|
{/* Left panel - Problem description / Explanation */}
|
|
{viewMode !== "right" && (
|
|
<div
|
|
className={`${
|
|
viewMode === "left" ? "w-full" : "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>
|
|
{isCompleted && (
|
|
<span className="flex items-center gap-1 text-xs text-green-500">
|
|
<CheckCircle className="h-3.5 w-3.5" />
|
|
Completed
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{question.leetcode_url && (
|
|
<a
|
|
href={question.leetcode_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-[var(--primary)] hover:underline text-xs"
|
|
>
|
|
LeetCode ↗
|
|
</a>
|
|
)}
|
|
<button
|
|
onClick={() => toggleViewMode("left")}
|
|
className="p-1.5 rounded hover:bg-[var(--accent)] text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
|
title={viewMode === "left" ? "Split view" : "Maximize problem"}
|
|
>
|
|
{viewMode === "left" ? (
|
|
<Columns2 className="h-4 w-4" />
|
|
) : (
|
|
<PanelRightClose className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</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>
|
|
<button
|
|
onClick={() => setLeftTab("solutions")}
|
|
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
|
leftTab === "solutions"
|
|
? "border-[var(--primary)] text-[var(--foreground)]"
|
|
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
|
}`}
|
|
>
|
|
Solutions
|
|
</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>
|
|
)}
|
|
|
|
{leftTab === "explanation" && (
|
|
<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>
|
|
)}
|
|
|
|
{/* Why This Pattern? */}
|
|
{question.explanation?.pattern_comparison && (
|
|
<Callout
|
|
variant="pattern"
|
|
icon={GitCompare}
|
|
title="Why This Pattern?"
|
|
>
|
|
<Markdown>{question.explanation.pattern_comparison}</Markdown>
|
|
</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>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{leftTab === "solutions" && (
|
|
<div className="space-y-6">
|
|
{question.solutions && question.solutions.length > 0 ? (
|
|
<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>
|
|
) : (
|
|
<p className="text-[var(--muted-foreground)]">
|
|
No solutions available for this problem yet.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Right panel - Editor and test results */}
|
|
{viewMode !== "left" && (
|
|
<div
|
|
className={`${viewMode === "right" ? "w-full" : "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 items-center gap-2">
|
|
<button
|
|
onClick={() => toggleViewMode("right")}
|
|
className="p-1.5 rounded hover:bg-[var(--accent)] text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
|
title={viewMode === "right" ? "Split view" : "Maximize editor"}
|
|
>
|
|
{viewMode === "right" ? (
|
|
<Columns2 className="h-4 w-4" />
|
|
) : (
|
|
<PanelLeftClose className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
<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 && (() => {
|
|
const visiblePassed = visibleResults.filter(r => r.passed).length;
|
|
const visibleTotal = visibleResults.length;
|
|
const allVisiblePassed = visibleTotal > 0 && visiblePassed === visibleTotal;
|
|
const allHiddenPassed = hiddenTotalCount === 0 || hiddenPassedCount === hiddenTotalCount;
|
|
const allPassed = allVisiblePassed && allHiddenPassed;
|
|
|
|
return (
|
|
<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)]"
|
|
}`}
|
|
>
|
|
{allPassed ? (
|
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
|
) : (
|
|
<XCircle className="h-4 w-4 text-red-500" />
|
|
)}
|
|
Results
|
|
</button>
|
|
);
|
|
})()}
|
|
</div>
|
|
</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 */}
|
|
<div className="flex-1 min-h-0">
|
|
<CodeEditor value={code} onChange={setCode} />
|
|
</div>
|
|
|
|
{/* Complexity Analysis - shown after running tests */}
|
|
{complexityResult?.success && (
|
|
<div className="border-t border-[var(--border)] bg-[var(--secondary)]/50 px-4 py-2">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Gauge className="h-4 w-4 text-[var(--primary)]" />
|
|
<span className="font-medium">
|
|
Estimated: {complexityResult.complexity}
|
|
</span>
|
|
{(complexityResult.details?.findings?.length ||
|
|
complexityResult.details?.recursive_calls?.length) && (
|
|
<button
|
|
onClick={() => setIsComplexityExpanded(!isComplexityExpanded)}
|
|
className="flex items-center gap-1 text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors ml-2"
|
|
>
|
|
<ChevronRight
|
|
className={`h-3 w-3 transition-transform ${
|
|
isComplexityExpanded ? "rotate-90" : ""
|
|
}`}
|
|
/>
|
|
Details
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{isComplexityExpanded && complexityResult.details && (
|
|
<div className="mt-2 space-y-2 text-xs">
|
|
{/* Loop findings */}
|
|
{complexityResult.details.findings
|
|
?.filter((f) => f.type === "loop")
|
|
.map((finding, i) => (
|
|
<div key={`loop-${i}`} className="space-y-0.5">
|
|
<div className="text-[var(--muted-foreground)]">
|
|
{finding.kind === "for" ? (
|
|
<>
|
|
Loop over {finding.iterates_over || "iterable"}
|
|
{finding.depth && finding.depth > 1
|
|
? ` (nested, depth ${finding.depth})`
|
|
: ""}{" "}
|
|
→ O(n)
|
|
</>
|
|
) : (
|
|
<>
|
|
While loop
|
|
{finding.depth && finding.depth > 1
|
|
? ` (nested, depth ${finding.depth})`
|
|
: ""}
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="ml-3 flex items-center gap-2">
|
|
<span className="text-[var(--muted-foreground)]/60 text-[10px]">
|
|
L{finding.line}
|
|
</span>
|
|
<code className="bg-[var(--secondary)] px-1 rounded text-[var(--foreground)]/80">
|
|
{finding.snippet}
|
|
</code>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Sort operations */}
|
|
{complexityResult.details.findings
|
|
?.filter((f) => f.kind === "sort")
|
|
.map((finding, i) => (
|
|
<div key={`sort-${i}`} className="space-y-0.5">
|
|
<div className="text-[var(--muted-foreground)]">
|
|
Sorting operation → O(n log n)
|
|
</div>
|
|
<div className="ml-3 flex items-center gap-2">
|
|
<span className="text-[var(--muted-foreground)]/60 text-[10px]">
|
|
L{finding.line}
|
|
</span>
|
|
<code className="bg-[var(--secondary)] px-1 rounded text-[var(--foreground)]/80">
|
|
{finding.snippet}
|
|
</code>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Linear search warnings */}
|
|
{complexityResult.details.findings
|
|
?.filter((f) => f.kind === "linear_search")
|
|
.map((finding, i) => (
|
|
<div key={`search-${i}`} className="space-y-0.5">
|
|
<div className="text-yellow-500">
|
|
{finding.note || "Linear search in loop"}
|
|
</div>
|
|
<div className="ml-3 flex items-center gap-2">
|
|
<span className="text-[var(--muted-foreground)]/60 text-[10px]">
|
|
L{finding.line}
|
|
</span>
|
|
<code className="bg-[var(--secondary)] px-1 rounded text-[var(--foreground)]/80">
|
|
{finding.snippet}
|
|
</code>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Recursive calls */}
|
|
{complexityResult.details.recursive_calls?.map((call, i) => (
|
|
<div key={`rec-${i}`} className="space-y-0.5">
|
|
<div className="text-[var(--muted-foreground)]">
|
|
Recursive call
|
|
</div>
|
|
<div className="ml-3 flex items-center gap-2">
|
|
<span className="text-[var(--muted-foreground)]/60 text-[10px]">
|
|
L{call.line}
|
|
</span>
|
|
<code className="bg-[var(--secondary)] px-1 rounded text-[var(--foreground)]/80">
|
|
{call.snippet}
|
|
</code>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</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 flex flex-col">
|
|
<TestResults
|
|
visibleResults={visibleResults}
|
|
hiddenPassedCount={hiddenPassedCount}
|
|
hiddenTotalCount={hiddenTotalCount}
|
|
visibleTestCases={visibleTestCases}
|
|
/>
|
|
|
|
{/* Complexity Analysis in Results tab */}
|
|
{complexityResult?.success && (
|
|
<div className="mt-4 p-3 rounded bg-[var(--secondary)]/50 border border-[var(--border)]">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Gauge className="h-4 w-4 text-[var(--primary)]" />
|
|
<span className="font-medium">
|
|
Estimated Complexity: {complexityResult.complexity}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Success state with navigation */}
|
|
{(() => {
|
|
const visiblePassed = visibleResults.filter((r) => r.passed).length;
|
|
const visibleTotal = visibleResults.length;
|
|
const allVisiblePassed = visibleTotal > 0 && visiblePassed === visibleTotal;
|
|
const allHiddenPassed =
|
|
hiddenTotalCount === 0 || hiddenPassedCount === hiddenTotalCount;
|
|
const allPassed = allVisiblePassed && allHiddenPassed && hiddenTotalCount > 0;
|
|
|
|
if (!allPassed) return null;
|
|
|
|
return (
|
|
<div className="mt-6 p-4 rounded-lg bg-green-500/10 border border-green-500/30">
|
|
<div className="flex items-center gap-2 text-green-500 font-semibold mb-3">
|
|
<CheckCircle className="h-5 w-5" />
|
|
All tests passed!
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<Link
|
|
href="/questions"
|
|
className="px-4 py-2 text-sm font-medium rounded bg-[var(--secondary)] border border-[var(--border)] hover:bg-[var(--accent)] transition-colors"
|
|
>
|
|
Back to Questions
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|