Initial commit — AI-powered coding tutor (Professor)
Next.js 16, React 19, Monaco editor, Anthropic SDK, multi-provider AI, Wandbox Python execution, iframe HTML preview, SQLite auth + session persistence. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
46
components/editor/CodeEditor.tsx
Normal file
46
components/editor/CodeEditor.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import type { SupportedLanguage } from '@/types';
|
||||
|
||||
const MonacoEditor = dynamic(() => import('@monaco-editor/react'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-full w-full items-center justify-center bg-[#1e1e1e]">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-zinc-600 border-t-blue-400" />
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
interface Props {
|
||||
language: SupportedLanguage;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
export default function CodeEditor({ language, value, onChange, height = '100%' }: Props) {
|
||||
const monacoLanguage = language === 'html' ? 'html' : 'python';
|
||||
|
||||
return (
|
||||
<MonacoEditor
|
||||
height={height}
|
||||
language={monacoLanguage}
|
||||
value={value}
|
||||
theme="vs-dark"
|
||||
onChange={(val) => onChange(val ?? '')}
|
||||
options={{
|
||||
fontSize: 14,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace",
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
padding: { top: 16, bottom: 16 },
|
||||
lineNumbersMinChars: 3,
|
||||
tabSize: 4,
|
||||
wordWrap: 'on',
|
||||
smoothScrolling: true,
|
||||
cursorSmoothCaretAnimation: 'on',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
73
components/editor/EditorPane.tsx
Normal file
73
components/editor/EditorPane.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import type { Dispatch } from 'react';
|
||||
import type { AppState, AppAction } from '@/types';
|
||||
import { useCodeExecution } from '@/hooks/useCodeExecution';
|
||||
import CodeEditor from './CodeEditor';
|
||||
import EditorToolbar from './EditorToolbar';
|
||||
import OutputPanel from './OutputPanel';
|
||||
import HtmlPreview from './HtmlPreview';
|
||||
|
||||
interface Props {
|
||||
state: AppState;
|
||||
dispatch: Dispatch<AppAction>;
|
||||
providerLabel: string;
|
||||
onSubmit: () => void;
|
||||
onReset: () => void;
|
||||
onOpenSettings: () => void;
|
||||
savedIndicator: boolean;
|
||||
authUser: { id: string; email: string } | null;
|
||||
onShowAuth: () => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export default function EditorPane({ state, dispatch, providerLabel, onSubmit, onReset, onOpenSettings, savedIndicator, authUser, onShowAuth, onLogout }: Props) {
|
||||
const { execute } = useCodeExecution(dispatch);
|
||||
const language = state.topic!.language;
|
||||
|
||||
function handleRun() {
|
||||
execute(language, state.code);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-[#1e1e1e]">
|
||||
<EditorToolbar
|
||||
language={language}
|
||||
providerLabel={providerLabel}
|
||||
isExecuting={state.phase === 'executing'}
|
||||
isStreaming={state.isStreaming}
|
||||
onRun={handleRun}
|
||||
onSubmit={onSubmit}
|
||||
onReset={onReset}
|
||||
onOpenSettings={onOpenSettings}
|
||||
savedIndicator={savedIndicator}
|
||||
authUser={authUser}
|
||||
onShowAuth={onShowAuth}
|
||||
onLogout={onLogout}
|
||||
responseMode={state.responseMode}
|
||||
onToggleHintMode={() =>
|
||||
dispatch({ type: 'SET_RESPONSE_MODE', payload: { hintMode: !state.responseMode.hintMode } })
|
||||
}
|
||||
onToggleStrict={() =>
|
||||
dispatch({ type: 'SET_RESPONSE_MODE', payload: { strict: !state.responseMode.strict } })
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<CodeEditor
|
||||
language={language}
|
||||
value={state.code}
|
||||
onChange={(val) => dispatch({ type: 'CODE_CHANGE', payload: val })}
|
||||
height="100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{language === 'html' && <HtmlPreview code={state.code} />}
|
||||
|
||||
<OutputPanel
|
||||
result={state.executionResult}
|
||||
isLoading={state.phase === 'executing'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
components/editor/EditorToolbar.tsx
Normal file
163
components/editor/EditorToolbar.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import type { ResponseMode } from '@/types';
|
||||
|
||||
interface Props {
|
||||
language: string;
|
||||
providerLabel: string;
|
||||
isExecuting: boolean;
|
||||
isStreaming: boolean;
|
||||
onRun: () => void;
|
||||
onSubmit: () => void;
|
||||
onReset: () => void;
|
||||
onOpenSettings: () => void;
|
||||
savedIndicator: boolean;
|
||||
authUser: { id: string; email: string } | null;
|
||||
onShowAuth: () => void;
|
||||
onLogout: () => void;
|
||||
responseMode: ResponseMode;
|
||||
onToggleHintMode: () => void;
|
||||
onToggleStrict: () => void;
|
||||
}
|
||||
|
||||
export default function EditorToolbar({
|
||||
language,
|
||||
providerLabel,
|
||||
isExecuting,
|
||||
isStreaming,
|
||||
onRun,
|
||||
onSubmit,
|
||||
onReset,
|
||||
onOpenSettings,
|
||||
savedIndicator,
|
||||
authUser,
|
||||
onShowAuth,
|
||||
onLogout,
|
||||
responseMode,
|
||||
onToggleHintMode,
|
||||
onToggleStrict,
|
||||
}: Props) {
|
||||
const busy = isExecuting || isStreaming;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-b border-zinc-700 bg-zinc-900 px-4 py-2">
|
||||
{/* Language badge */}
|
||||
<span className="rounded bg-zinc-700 px-2 py-0.5 text-xs font-mono text-zinc-300">
|
||||
{language}
|
||||
</span>
|
||||
|
||||
{/* Hint mode toggle */}
|
||||
<button
|
||||
onClick={onToggleHintMode}
|
||||
title={responseMode.hintMode ? 'Hints on — click to disable' : 'Hints off — click to enable'}
|
||||
className={`flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors ${
|
||||
responseMode.hintMode
|
||||
? 'bg-yellow-500/20 text-yellow-300 hover:bg-yellow-500/30'
|
||||
: 'text-zinc-500 hover:bg-zinc-700 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2a7 7 0 0 1 7 7c0 2.73-1.56 5.1-3.85 6.33L15 17H9l-.15-1.67C6.56 14.1 5 11.73 5 9a7 7 0 0 1 7-7zm-1 16h2v1a1 1 0 0 1-2 0v-1zm0 3h2v.5a1 1 0 0 1-2 0V21z" />
|
||||
</svg>
|
||||
Hints
|
||||
</button>
|
||||
|
||||
{/* Strict / Lenient pill */}
|
||||
<button
|
||||
onClick={onToggleStrict}
|
||||
title={responseMode.strict ? 'Strict — exact approach required. Click for Lenient.' : 'Lenient — equivalent solutions accepted. Click for Strict.'}
|
||||
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||||
responseMode.strict
|
||||
? 'bg-red-500/20 text-red-300 hover:bg-red-500/30'
|
||||
: 'bg-zinc-700 text-zinc-400 hover:bg-zinc-600 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
{responseMode.strict ? 'Strict' : 'Lenient'}
|
||||
</button>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Saved indicator */}
|
||||
<span
|
||||
className={`text-xs text-zinc-500 transition-opacity duration-300 ${savedIndicator ? 'opacity-100' : 'opacity-0'}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
Saved
|
||||
</span>
|
||||
|
||||
{/* Auth area */}
|
||||
{authUser ? (
|
||||
<>
|
||||
<span className="max-w-[120px] truncate text-xs text-zinc-500" title={authUser.email}>
|
||||
{authUser.email}
|
||||
</span>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="rounded px-2 py-1 text-xs text-zinc-500 hover:bg-zinc-700 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={onShowAuth}
|
||||
className="rounded px-2 py-1 text-xs text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Provider settings */}
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
title="AI provider settings"
|
||||
className="flex items-center gap-1.5 rounded px-2 py-1.5 text-xs text-zinc-500 hover:bg-zinc-700 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14" />
|
||||
<path d="M12 2v2M12 20v2M2 12h2M20 12h2" />
|
||||
</svg>
|
||||
<span className="max-w-[80px] truncate">{providerLabel}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onReset}
|
||||
title="Pick a new topic"
|
||||
className="rounded px-3 py-1.5 text-xs text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
Change topic
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onRun}
|
||||
disabled={busy}
|
||||
className="flex items-center gap-1.5 rounded bg-zinc-700 px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-600 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isExecuting ? (
|
||||
<>
|
||||
<div className="h-3 w-3 animate-spin rounded-full border border-zinc-500 border-t-blue-400" />
|
||||
Running…
|
||||
</>
|
||||
) : (
|
||||
<>▶ Run</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={busy}
|
||||
className="flex items-center gap-1.5 rounded bg-blue-600 px-3 py-1.5 text-xs text-white hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isStreaming ? (
|
||||
<>
|
||||
<div className="h-3 w-3 animate-spin rounded-full border border-blue-300 border-t-white" />
|
||||
Reviewing…
|
||||
</>
|
||||
) : (
|
||||
'Submit for Review'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
components/editor/HtmlPreview.tsx
Normal file
36
components/editor/HtmlPreview.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export default function HtmlPreview({ code }: Props) {
|
||||
const [previewCode, setPreviewCode] = useState(code);
|
||||
|
||||
// Debounce the preview update to avoid iframe thrashing
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setPreviewCode(code), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [code]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col border-t border-zinc-700">
|
||||
<div className="flex items-center gap-2 border-b border-zinc-700 bg-zinc-900 px-4 py-2">
|
||||
<span className="text-xs font-sans font-medium uppercase tracking-wider text-zinc-400">Preview</span>
|
||||
<div className="ml-auto flex gap-1">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-red-500/60" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-yellow-500/60" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-green-500/60" />
|
||||
</div>
|
||||
</div>
|
||||
<iframe
|
||||
srcDoc={previewCode}
|
||||
sandbox="allow-scripts"
|
||||
className="h-48 w-full bg-white"
|
||||
title="HTML Preview"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
components/editor/OutputPanel.tsx
Normal file
78
components/editor/OutputPanel.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { ExecutionResult } from '@/types';
|
||||
|
||||
interface Props {
|
||||
result: ExecutionResult | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export default function OutputPanel({ result, isLoading }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
if (!isLoading && !result) return null;
|
||||
|
||||
const hasError = result?.error || (result?.stderr && result.stderr.trim());
|
||||
const hasOutput = result?.stdout && result.stdout.trim();
|
||||
|
||||
return (
|
||||
<div className="border-t border-zinc-700 bg-zinc-900 text-sm font-mono">
|
||||
{/* Header bar */}
|
||||
<button
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-xs text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
<span>{collapsed ? '▶' : '▼'}</span>
|
||||
<span className="font-sans font-medium uppercase tracking-wider">Output</span>
|
||||
{result && !isLoading && (
|
||||
<span
|
||||
className={`ml-auto rounded px-1.5 py-0.5 text-xs font-sans ${
|
||||
result.exitCode === 0 && !result.error
|
||||
? 'bg-green-900/50 text-green-400'
|
||||
: 'bg-red-900/50 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{result.timedOut
|
||||
? 'timed out'
|
||||
: result.error
|
||||
? 'error'
|
||||
: `exit ${result.exitCode}`}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
{!collapsed && (
|
||||
<div className="max-h-48 overflow-y-auto px-4 pb-4 space-y-2">
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 text-zinc-500">
|
||||
<div className="h-3 w-3 animate-spin rounded-full border border-zinc-600 border-t-blue-400" />
|
||||
<span>Running…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result?.error && (
|
||||
<pre className="whitespace-pre-wrap text-red-400">{result.error}</pre>
|
||||
)}
|
||||
|
||||
{result?.timedOut && !result.error && (
|
||||
<p className="text-yellow-400">Execution timed out after 15 seconds.</p>
|
||||
)}
|
||||
|
||||
{hasOutput && (
|
||||
<pre className="whitespace-pre-wrap text-zinc-200">{result!.stdout}</pre>
|
||||
)}
|
||||
|
||||
{hasError && !result?.error && (
|
||||
<pre className="whitespace-pre-wrap text-red-400">{result!.stderr}</pre>
|
||||
)}
|
||||
|
||||
{result && !isLoading && !result.error && !hasOutput && !hasError && (
|
||||
<span className="text-zinc-500 italic">No output</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user