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:
310
components/AppShell.tsx
Normal file
310
components/AppShell.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import type { Topic, ProviderConfig, AuthUser, AppMode } from '@/types';
|
||||
import { useAppState } from '@/hooks/useAppState';
|
||||
import { useAI } from '@/hooks/useAI';
|
||||
import { loadProviderConfig, saveProviderConfig, PROVIDER_MAP } from '@/lib/providers';
|
||||
import { loadLocalSession, saveLocalSession, clearLocalSession } from '@/lib/localSession';
|
||||
import { TOPICS } from '@/lib/topics';
|
||||
import TopicSelector from './TopicSelector';
|
||||
import EditorPane from './editor/EditorPane';
|
||||
import LessonPane from './classroom/LessonPane';
|
||||
import ChatPane from './chat/ChatPane';
|
||||
import ProviderSettings from './ProviderSettings';
|
||||
import AuthModal from './AuthModal';
|
||||
|
||||
export default function AppShell() {
|
||||
const [state, dispatch] = useAppState();
|
||||
const [providerConfig, setProviderConfig] = useState<ProviderConfig>(loadProviderConfig);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
const [authUser, setAuthUser] = useState<AuthUser | null>(null);
|
||||
const [showAuth, setShowAuth] = useState(false);
|
||||
const [savedIndicator, setSavedIndicator] = useState(false);
|
||||
const [splitPercent, setSplitPercent] = useState(50);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dbSaveRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Keep localStorage in sync whenever providerConfig changes
|
||||
useEffect(() => {
|
||||
saveProviderConfig(providerConfig);
|
||||
}, [providerConfig]);
|
||||
|
||||
// Load persisted responseMode on mount
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const raw = localStorage.getItem('professor_response_mode');
|
||||
if (raw) dispatch({ type: 'SET_RESPONSE_MODE', payload: JSON.parse(raw) });
|
||||
} catch { /* ignore */ }
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Persist responseMode whenever it changes
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem('professor_response_mode', JSON.stringify(state.responseMode));
|
||||
}, [state.responseMode]);
|
||||
|
||||
// Check auth status on mount
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.user) setAuthUser(data.user as AuthUser);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Restore session from localStorage on mount (immediate, works without an account)
|
||||
useEffect(() => {
|
||||
const local = loadLocalSession();
|
||||
if (!local) return;
|
||||
const topic = TOPICS.find((t) => t.id === local.topicId);
|
||||
if (topic && local.task) {
|
||||
dispatch({
|
||||
type: 'RESTORE_SESSION',
|
||||
payload: {
|
||||
topic,
|
||||
task: local.task,
|
||||
code: local.code,
|
||||
messages: local.messages,
|
||||
executionResult: local.executionResult,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// When user logs in, fetch their DB session and prefer it over the local one
|
||||
useEffect(() => {
|
||||
if (!authUser) return;
|
||||
fetch('/api/session')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (!data.session) return;
|
||||
const { topicId, task, code, messages, executionResult } = data.session;
|
||||
const topic = TOPICS.find((t) => t.id === topicId);
|
||||
if (topic && task) {
|
||||
dispatch({
|
||||
type: 'RESTORE_SESSION',
|
||||
payload: { topic, task, code: code ?? '', messages: messages ?? [], executionResult },
|
||||
});
|
||||
// Sync DB session down to local so they're consistent
|
||||
saveLocalSession({ topicId, task, code: code ?? '', messages: messages ?? [], executionResult });
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [authUser, dispatch]);
|
||||
|
||||
// Auto-save session whenever relevant state changes
|
||||
useEffect(() => {
|
||||
if (state.phase === 'selecting' || !state.topic || !state.task) return;
|
||||
|
||||
const sessionData = {
|
||||
topicId: state.topic.id,
|
||||
task: state.task,
|
||||
code: state.code,
|
||||
messages: state.messages,
|
||||
executionResult: state.executionResult,
|
||||
};
|
||||
|
||||
// Always save to localStorage immediately
|
||||
saveLocalSession(sessionData);
|
||||
|
||||
// Flash "Saved" indicator
|
||||
if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
|
||||
setSavedIndicator(true);
|
||||
savedTimerRef.current = setTimeout(() => setSavedIndicator(false), 1500);
|
||||
|
||||
// Debounced DB sync when logged in
|
||||
if (authUser) {
|
||||
if (dbSaveRef.current) clearTimeout(dbSaveRef.current);
|
||||
dbSaveRef.current = setTimeout(() => {
|
||||
fetch('/api/session', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(sessionData),
|
||||
}).catch(() => {});
|
||||
}, 1000);
|
||||
}
|
||||
}, [state.topic, state.task, state.code, state.messages, state.executionResult, authUser]);
|
||||
|
||||
const { generateTask, reviewCode, sendMessage, generateLesson, sendClassroomMessage } = useAI(dispatch, providerConfig);
|
||||
|
||||
const handleTopicSelect = useCallback(
|
||||
(topic: Topic) => {
|
||||
clearLocalSession();
|
||||
dispatch({ type: 'SELECT_TOPIC', payload: topic });
|
||||
generateTask(topic);
|
||||
},
|
||||
[dispatch, generateTask]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
reviewCode(state);
|
||||
}, [reviewCode, state]);
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
(text: string) => {
|
||||
sendMessage(state, text);
|
||||
},
|
||||
[sendMessage, state]
|
||||
);
|
||||
|
||||
const handleSendClassroomMessage = useCallback(
|
||||
(text: string) => {
|
||||
sendClassroomMessage(state, text);
|
||||
},
|
||||
[sendClassroomMessage, state]
|
||||
);
|
||||
|
||||
const handleSetAppMode = useCallback(
|
||||
(mode: AppMode) => {
|
||||
dispatch({ type: 'SET_APP_MODE', payload: mode });
|
||||
// Lazily generate the lesson the first time the user enters Classroom mode
|
||||
if (mode === 'classroom' && !state.lessonContent && !state.isStreaming && state.topic) {
|
||||
generateLesson(state.topic);
|
||||
}
|
||||
},
|
||||
[dispatch, state.lessonContent, state.isStreaming, state.topic, generateLesson]
|
||||
);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
dispatch({ type: 'RESET' });
|
||||
clearLocalSession();
|
||||
// Clear DB session if logged in
|
||||
if (authUser) {
|
||||
fetch('/api/session', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topicId: null, task: null, code: null, messages: [], executionResult: null }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, [dispatch, authUser]);
|
||||
|
||||
const handleSaveProvider = useCallback((config: ProviderConfig) => {
|
||||
setProviderConfig(config);
|
||||
}, []);
|
||||
|
||||
const handleAuthSuccess = useCallback((user: AuthUser) => {
|
||||
setAuthUser(user);
|
||||
setShowAuth(false);
|
||||
}, []);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
setAuthUser(null);
|
||||
// Local session stays intact — user can continue without an account
|
||||
}, []);
|
||||
|
||||
const handlePaneDragStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const isHorizontal = rect.width >= 1024; // lg breakpoint
|
||||
const pct = isHorizontal
|
||||
? ((e.clientX - rect.left) / rect.width) * 100
|
||||
: ((e.clientY - rect.top) / rect.height) * 100;
|
||||
setSplitPercent(Math.min(Math.max(pct, 20), 80));
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}, []);
|
||||
|
||||
const providerDef = PROVIDER_MAP[providerConfig.provider];
|
||||
|
||||
if (state.phase === 'selecting') {
|
||||
return (
|
||||
<>
|
||||
<TopicSelector onSelect={handleTopicSelect} onOpenSettings={() => setShowSettings(true)} />
|
||||
{showSettings && (
|
||||
<ProviderSettings
|
||||
config={providerConfig}
|
||||
onSave={handleSaveProvider}
|
||||
onClose={() => setShowSettings(false)}
|
||||
/>
|
||||
)}
|
||||
{showAuth && (
|
||||
<AuthModal onSuccess={handleAuthSuccess} onClose={() => setShowAuth(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={containerRef} className="flex h-screen w-full overflow-hidden bg-zinc-950 flex-col lg:flex-row">
|
||||
{/* Left pane — editor (Homework) or lesson viewer (Classroom) */}
|
||||
<div
|
||||
className="flex flex-col min-h-0 min-w-0 overflow-hidden"
|
||||
style={{ flex: `0 0 ${splitPercent}%` }}
|
||||
>
|
||||
{state.appMode === 'classroom' ? (
|
||||
<LessonPane
|
||||
topic={state.topic!}
|
||||
lessonContent={state.lessonContent}
|
||||
isGenerating={state.isStreaming && state.lessonContent === null}
|
||||
streamingContent={state.streamingContent}
|
||||
/>
|
||||
) : (
|
||||
<EditorPane
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
providerLabel={`${providerDef.label} · ${providerConfig.model}`}
|
||||
onSubmit={handleSubmit}
|
||||
onReset={handleReset}
|
||||
onOpenSettings={() => setShowSettings(true)}
|
||||
savedIndicator={savedIndicator}
|
||||
authUser={authUser}
|
||||
onShowAuth={() => setShowAuth(true)}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
onMouseDown={handlePaneDragStart}
|
||||
className="group relative flex-shrink-0 select-none
|
||||
h-1 w-full cursor-row-resize
|
||||
lg:h-full lg:w-1 lg:cursor-col-resize
|
||||
bg-zinc-700 hover:bg-blue-500 active:bg-blue-400 transition-colors duration-100"
|
||||
/>
|
||||
|
||||
{/* Right pane — chat */}
|
||||
<div className="flex flex-col flex-1 min-h-0 min-w-0 overflow-hidden">
|
||||
<ChatPane
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
onSendMessage={handleSendMessage}
|
||||
onSendClassroomMessage={handleSendClassroomMessage}
|
||||
onSubmit={handleSubmit}
|
||||
onSetAppMode={handleSetAppMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSettings && (
|
||||
<ProviderSettings
|
||||
config={providerConfig}
|
||||
onSave={handleSaveProvider}
|
||||
onClose={() => setShowSettings(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showAuth && (
|
||||
<AuthModal onSuccess={handleAuthSuccess} onClose={() => setShowAuth(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
154
components/AuthModal.tsx
Normal file
154
components/AuthModal.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { AuthUser } from '@/types';
|
||||
|
||||
type Mode = 'login' | 'register';
|
||||
|
||||
interface Props {
|
||||
onSuccess: (user: AuthUser) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AuthModal({ onSuccess, onClose }: Props) {
|
||||
const [mode, setMode] = useState<Mode>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const endpoint = mode === 'login' ? '/api/auth/login' : '/api/auth/register';
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
onSuccess(data.user as AuthUser);
|
||||
} catch {
|
||||
setError('Network error. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div className="w-full max-w-sm rounded-2xl border border-zinc-700 bg-zinc-900 shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-zinc-700 px-6 py-4">
|
||||
<h2 className="text-sm font-semibold text-zinc-100">Save your progress</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200 transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mode tabs */}
|
||||
<div className="flex border-b border-zinc-700">
|
||||
{(['login', 'register'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => { setMode(m); setError(''); }}
|
||||
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
|
||||
mode === m
|
||||
? 'border-b-2 border-blue-500 text-blue-400'
|
||||
: 'text-zinc-500 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{m === 'login' ? 'Sign In' : 'Create Account'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-5">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-zinc-400">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-200 placeholder-zinc-600 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-zinc-400">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
||||
placeholder={mode === 'register' ? 'At least 8 characters' : '••••••••'}
|
||||
className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-200 placeholder-zinc-600 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-800 bg-red-900/20 px-3 py-2 text-xs text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-blue-600 py-2.5 text-xs font-semibold text-white hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-60 transition-colors"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span className="h-3 w-3 animate-spin rounded-full border border-blue-300 border-t-white" />
|
||||
{mode === 'login' ? 'Signing in…' : 'Creating account…'}
|
||||
</span>
|
||||
) : mode === 'login' ? (
|
||||
'Sign In'
|
||||
) : (
|
||||
'Create Account'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-[11px] text-zinc-600">
|
||||
{mode === 'login' ? (
|
||||
<>No account?{' '}
|
||||
<button type="button" onClick={() => { setMode('register'); setError(''); }} className="text-zinc-400 hover:text-zinc-200 underline">
|
||||
Create one
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>Already have an account?{' '}
|
||||
<button type="button" onClick={() => { setMode('login'); setError(''); }} className="text-zinc-400 hover:text-zinc-200 underline">
|
||||
Sign in
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
348
components/ProviderSettings.tsx
Normal file
348
components/ProviderSettings.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import type { ProviderConfig, ProviderId } from '@/types';
|
||||
import { PROVIDERS, PROVIDER_MAP } from '@/lib/providers';
|
||||
|
||||
type ConnectStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
interface Props {
|
||||
config: ProviderConfig;
|
||||
onSave: (config: ProviderConfig) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ProviderSettings({ config, onSave, onClose }: Props) {
|
||||
const [provider, setProvider] = useState<ProviderId>(config.provider);
|
||||
const [model, setModel] = useState(config.model);
|
||||
const [apiKey, setApiKey] = useState(config.apiKey ?? '');
|
||||
const [baseUrl, setBaseUrl] = useState(config.baseUrl ?? '');
|
||||
|
||||
const [connectStatus, setConnectStatus] = useState<ConnectStatus>('idle');
|
||||
const [connectError, setConnectError] = useState('');
|
||||
const [fetchedModels, setFetchedModels] = useState<string[]>([]);
|
||||
const [modelFilter, setModelFilter] = useState('');
|
||||
const filterRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const def = PROVIDER_MAP[provider];
|
||||
|
||||
// Reset when provider changes
|
||||
useEffect(() => {
|
||||
const newDef = PROVIDER_MAP[provider];
|
||||
setModel(newDef.defaultModel);
|
||||
setBaseUrl(newDef.hasCustomBaseUrl ? newDef.defaultBaseUrl : '');
|
||||
if (!newDef.requiresApiKey) setApiKey('');
|
||||
setConnectStatus('idle');
|
||||
setFetchedModels([]);
|
||||
setModelFilter('');
|
||||
}, [provider]);
|
||||
|
||||
// Focus filter when models load
|
||||
useEffect(() => {
|
||||
if (connectStatus === 'success') {
|
||||
setTimeout(() => filterRef.current?.focus(), 50);
|
||||
}
|
||||
}, [connectStatus]);
|
||||
|
||||
async function handleConnect() {
|
||||
setConnectStatus('loading');
|
||||
setConnectError('');
|
||||
setFetchedModels([]);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/models', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider,
|
||||
apiKey: apiKey.trim() || undefined,
|
||||
baseUrl: baseUrl.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok || data.error) {
|
||||
setConnectError(data.error ?? `Error ${res.status}`);
|
||||
setConnectStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
const models: string[] = data.models ?? [];
|
||||
setFetchedModels(models);
|
||||
setConnectStatus('success');
|
||||
// Auto-select first model if current isn't in the fetched list
|
||||
if (models.length && !models.includes(model)) {
|
||||
setModel(models[0]);
|
||||
}
|
||||
} catch (err) {
|
||||
setConnectError(err instanceof Error ? err.message : 'Connection failed');
|
||||
setConnectStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const saved: ProviderConfig = {
|
||||
provider,
|
||||
model: model.trim() || def.defaultModel,
|
||||
...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}),
|
||||
...(baseUrl.trim() && def.hasCustomBaseUrl ? { baseUrl: baseUrl.trim() } : {}),
|
||||
};
|
||||
onSave(saved);
|
||||
onClose();
|
||||
}
|
||||
|
||||
const filteredModels = fetchedModels.filter((m) =>
|
||||
m.toLowerCase().includes(modelFilter.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div className="w-full max-w-md rounded-2xl border border-zinc-700 bg-zinc-900 shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-zinc-700 px-6 py-4">
|
||||
<h2 className="text-sm font-semibold text-zinc-100">AI Provider Settings</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200 transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="space-y-5 px-6 py-5">
|
||||
|
||||
{/* Provider selector */}
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-zinc-400">Provider</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{PROVIDERS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => setProvider(p.id)}
|
||||
className={`rounded-lg border px-3 py-2.5 text-left text-xs transition-all ${
|
||||
provider === p.id
|
||||
? 'border-blue-500 bg-blue-600/20 text-blue-300'
|
||||
: 'border-zinc-700 bg-zinc-800 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
<span className="block font-semibold">{p.label}</span>
|
||||
<span className="block text-[10px] opacity-70 mt-0.5">
|
||||
{p.requiresApiKey ? 'Requires API key' : 'Local — no key needed'}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key — shown before model so user fills it first */}
|
||||
{def.requiresApiKey && (
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-zinc-400">
|
||||
API Key
|
||||
{provider === 'anthropic' && (
|
||||
<span className="ml-1 text-zinc-600">(leave blank to use ANTHROPIC_API_KEY env var)</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => {
|
||||
setApiKey(e.target.value);
|
||||
setConnectStatus('idle');
|
||||
setFetchedModels([]);
|
||||
}}
|
||||
placeholder={provider === 'anthropic' ? 'sk-ant-… (optional)' : 'sk-or-…'}
|
||||
autoComplete="off"
|
||||
className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-200 placeholder-zinc-600 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Base URL */}
|
||||
{def.hasCustomBaseUrl && (
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-zinc-400">Base URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={baseUrl}
|
||||
onChange={(e) => {
|
||||
setBaseUrl(e.target.value);
|
||||
setConnectStatus('idle');
|
||||
setFetchedModels([]);
|
||||
}}
|
||||
placeholder={def.defaultBaseUrl}
|
||||
className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-200 placeholder-zinc-600 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-zinc-600">
|
||||
{provider === 'ollama'
|
||||
? 'Ollama must be running with OLLAMA_ORIGINS=* for CORS.'
|
||||
: 'LM Studio server must be running on this address.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-zinc-400">Model</label>
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={connectStatus === 'loading'}
|
||||
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed ${
|
||||
connectStatus === 'success'
|
||||
? 'bg-green-900/40 text-green-400 hover:bg-green-900/60'
|
||||
: 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600 disabled:opacity-60'
|
||||
}`}
|
||||
>
|
||||
{connectStatus === 'loading' ? (
|
||||
<>
|
||||
<div className="h-3 w-3 animate-spin rounded-full border border-zinc-500 border-t-zinc-200" />
|
||||
Connecting…
|
||||
</>
|
||||
) : connectStatus === 'success' ? (
|
||||
<>
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
{fetchedModels.length} model{fetchedModels.length !== 1 ? 's' : ''} · Reconnect
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{connectStatus === 'error' && (
|
||||
<div className="mb-2 rounded-lg border border-red-800 bg-red-900/20 px-3 py-2 text-xs text-red-400">
|
||||
{connectError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live model list */}
|
||||
{connectStatus === 'success' && fetchedModels.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{fetchedModels.length > 8 && (
|
||||
<input
|
||||
ref={filterRef}
|
||||
type="text"
|
||||
value={modelFilter}
|
||||
onChange={(e) => setModelFilter(e.target.value)}
|
||||
placeholder={`Filter ${fetchedModels.length} models…`}
|
||||
className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-200 placeholder-zinc-600 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
<div className="max-h-52 overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-800">
|
||||
{filteredModels.length === 0 ? (
|
||||
<p className="px-3 py-3 text-xs italic text-zinc-500">No models match “{modelFilter}”</p>
|
||||
) : (
|
||||
filteredModels.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setModel(m)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors ${
|
||||
m === model
|
||||
? 'bg-blue-600/20 text-blue-300'
|
||||
: 'text-zinc-300 hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{m === model ? (
|
||||
<svg className="h-3 w-3 flex-shrink-0 text-blue-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
) : (
|
||||
<span className="h-3 w-3 flex-shrink-0" />
|
||||
)}
|
||||
<span className="font-mono">{m}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{modelFilter && filteredModels.length < fetchedModels.length && (
|
||||
<p className="text-right text-[11px] text-zinc-600">
|
||||
{filteredModels.length} of {fetchedModels.length} shown
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Static fallback — shown before connecting or on error */
|
||||
<div className="space-y-1.5">
|
||||
{def.modelSuggestions.length > 0 ? (
|
||||
<>
|
||||
<select
|
||||
value={def.modelSuggestions.includes(model) ? model : '__custom__'}
|
||||
onChange={(e) => {
|
||||
if (e.target.value !== '__custom__') setModel(e.target.value);
|
||||
}}
|
||||
className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-200 focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
{def.modelSuggestions.map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
<option value="__custom__">Custom…</option>
|
||||
</select>
|
||||
{!def.modelSuggestions.includes(model) && (
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder={def.defaultModel}
|
||||
className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-200 placeholder-zinc-600 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder={def.defaultModel}
|
||||
className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-200 placeholder-zinc-600 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
<p className="text-[11px] text-zinc-600">
|
||||
Hit Connect to load available models from {def.label}.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connectStatus === 'success' && (
|
||||
<p className="mt-1.5 text-[11px] text-zinc-500">
|
||||
Selected: <span className="font-mono text-zinc-400">{model}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 border-t border-zinc-700 px-6 py-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg px-4 py-2 text-xs text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-xs font-semibold text-white hover:bg-blue-500 transition-colors"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
components/TopicSelector.tsx
Normal file
157
components/TopicSelector.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Topic, SupportedLanguage } from '@/types';
|
||||
import { TOPICS } from '@/lib/topics';
|
||||
|
||||
const PYTHON_ICON = '🐍';
|
||||
const HTML_ICON = '🌐';
|
||||
|
||||
const CUSTOM_STARTER: Record<SupportedLanguage, string> = {
|
||||
python: '# Your code here\n',
|
||||
html: '<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta charset="UTF-8">\n <title>Page</title>\n</head>\n<body>\n <!-- Your code here -->\n</body>\n</html>\n',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
onSelect: (topic: Topic) => void;
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
export default function TopicSelector({ onSelect, onOpenSettings }: Props) {
|
||||
const pythonTopics = TOPICS.filter((t) => t.language === 'python');
|
||||
const htmlTopics = TOPICS.filter((t) => t.language === 'html');
|
||||
|
||||
const [customLabel, setCustomLabel] = useState('');
|
||||
const [customLang, setCustomLang] = useState<SupportedLanguage>('python');
|
||||
|
||||
function handleCustomSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const label = customLabel.trim();
|
||||
if (!label) return;
|
||||
onSelect({
|
||||
id: `custom-${Date.now()}`,
|
||||
label,
|
||||
language: customLang,
|
||||
description: 'Custom topic',
|
||||
starterCode: CUSTOM_STARTER[customLang],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center bg-zinc-950 p-8">
|
||||
<div className="w-full max-w-2xl">
|
||||
{/* Header */}
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="mb-2 text-3xl font-bold tracking-tight text-zinc-100">Professor</h1>
|
||||
<p className="text-sm text-zinc-400">Choose a topic to start learning with your AI tutor</p>
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="mt-3 inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs text-zinc-500 hover:bg-zinc-800 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} suppressHydrationWarning>
|
||||
<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>
|
||||
AI Provider Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Python section */}
|
||||
<Section icon={PYTHON_ICON} label="Python" topics={pythonTopics} onSelect={onSelect} />
|
||||
|
||||
<div className="my-6 border-t border-zinc-800" />
|
||||
|
||||
{/* HTML / CSS section */}
|
||||
<Section icon={HTML_ICON} label="HTML & CSS" topics={htmlTopics} onSelect={onSelect} />
|
||||
|
||||
<div className="my-6 border-t border-zinc-800" />
|
||||
|
||||
{/* Custom topic */}
|
||||
<div>
|
||||
<h2 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-zinc-500">
|
||||
<span>✦</span>
|
||||
Custom
|
||||
</h2>
|
||||
<form onSubmit={handleCustomSubmit} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customLabel}
|
||||
onChange={(e) => setCustomLabel(e.target.value)}
|
||||
placeholder="e.g. Python Classes, CSS Grid, JavaScript Promises…"
|
||||
className="min-w-0 flex-1 rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2.5 text-sm text-zinc-200 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none transition-colors"
|
||||
/>
|
||||
<div className="flex overflow-hidden rounded-lg border border-zinc-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomLang('python')}
|
||||
className={`px-3 py-2 text-xs transition-colors ${
|
||||
customLang === 'python'
|
||||
? 'bg-zinc-600 text-zinc-100'
|
||||
: 'text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{PYTHON_ICON} Python
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomLang('html')}
|
||||
className={`border-l border-zinc-700 px-3 py-2 text-xs transition-colors ${
|
||||
customLang === 'html'
|
||||
? 'bg-zinc-600 text-zinc-100'
|
||||
: 'text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{HTML_ICON} HTML
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!customLabel.trim()}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
icon,
|
||||
label,
|
||||
topics,
|
||||
onSelect,
|
||||
}: {
|
||||
icon: string;
|
||||
label: string;
|
||||
topics: Topic[];
|
||||
onSelect: (topic: Topic) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-zinc-500">
|
||||
<span>{icon}</span>
|
||||
{label}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{topics.map((topic) => (
|
||||
<button
|
||||
key={topic.id}
|
||||
onClick={() => onSelect(topic)}
|
||||
className="group flex flex-col gap-1 rounded-xl border border-zinc-800 bg-zinc-900 p-4 text-left transition-all hover:border-zinc-600 hover:bg-zinc-800 hover:shadow-lg hover:shadow-black/20 active:scale-[0.98]"
|
||||
>
|
||||
<span className="text-sm font-semibold text-zinc-200 group-hover:text-white transition-colors">
|
||||
{topic.label}
|
||||
</span>
|
||||
<span className="text-xs leading-relaxed text-zinc-500 group-hover:text-zinc-400 transition-colors">
|
||||
{topic.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
components/chat/ChatInput.tsx
Normal file
69
components/chat/ChatInput.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, type KeyboardEvent } from 'react';
|
||||
|
||||
interface Props {
|
||||
isDisabled: boolean;
|
||||
onSend: (text: string) => void;
|
||||
}
|
||||
|
||||
export default function ChatInput({ isDisabled, onSend }: Props) {
|
||||
const [text, setText] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
function handleSend() {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || isDisabled) return;
|
||||
onSend(trimmed);
|
||||
setText('');
|
||||
// Reset height
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.min(el.scrollHeight, 160)}px`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-zinc-700 bg-zinc-900 p-3">
|
||||
<div className="flex items-end gap-2 rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 focus-within:border-zinc-500 transition-colors">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
disabled={isDisabled}
|
||||
placeholder="Ask a question or chat with Professor…"
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-transparent text-sm text-zinc-200 placeholder-zinc-500 outline-none disabled:opacity-50"
|
||||
style={{ maxHeight: '160px' }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={isDisabled || !text.trim()}
|
||||
className="flex-shrink-0 rounded-lg bg-blue-600 p-1.5 text-white hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M22 2L11 13" />
|
||||
<path d="M22 2L15 22 11 13 2 9l20-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-center text-xs text-zinc-600">Enter to send · Shift+Enter for newline</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
components/chat/ChatPane.tsx
Normal file
102
components/chat/ChatPane.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import type { Dispatch } from 'react';
|
||||
import type { AppState, AppAction, AppMode } from '@/types';
|
||||
import TaskCard from './TaskCard';
|
||||
import MessageList from './MessageList';
|
||||
import ChatInput from './ChatInput';
|
||||
|
||||
interface Props {
|
||||
state: AppState;
|
||||
dispatch: Dispatch<AppAction>;
|
||||
onSendMessage: (text: string) => void;
|
||||
onSendClassroomMessage: (text: string) => void;
|
||||
onSubmit: () => void;
|
||||
onSetAppMode: (mode: AppMode) => void;
|
||||
}
|
||||
|
||||
export default function ChatPane({ state, onSendMessage, onSendClassroomMessage, onSetAppMode }: Props) {
|
||||
const isClassroom = state.appMode === 'classroom';
|
||||
const isTaskLoading = state.phase === 'loading_task';
|
||||
|
||||
// In classroom mode "busy" only covers classroom chat replies, not lesson generation
|
||||
const isBusy = isClassroom
|
||||
? state.isStreaming && state.lessonContent !== null
|
||||
: state.isStreaming || state.phase === 'executing';
|
||||
|
||||
const messages = isClassroom ? state.classroomMessages : state.messages;
|
||||
const handleSend = isClassroom ? onSendClassroomMessage : onSendMessage;
|
||||
|
||||
const showChatStreaming = isClassroom
|
||||
? state.isStreaming && state.lessonContent !== null
|
||||
: state.isStreaming && state.phase !== 'loading_task';
|
||||
const chatStreamingContent = showChatStreaming ? state.streamingContent : '';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-zinc-900">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 border-b border-zinc-700 px-4 py-2">
|
||||
<div className="h-2 w-2 rounded-full bg-blue-400" />
|
||||
<span className="text-sm font-semibold text-zinc-200">Professor</span>
|
||||
|
||||
{isBusy && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-zinc-500">
|
||||
<div className="h-2 w-2 animate-pulse rounded-full bg-blue-400" />
|
||||
Thinking…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div className="ml-auto flex overflow-hidden rounded-lg border border-zinc-700">
|
||||
<button
|
||||
onClick={() => onSetAppMode('classroom')}
|
||||
className={`px-3 py-1 text-xs transition-colors ${
|
||||
isClassroom ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
Classroom
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSetAppMode('homework')}
|
||||
className={`border-l border-zinc-700 px-3 py-1 text-xs transition-colors ${
|
||||
!isClassroom ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
Homework
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task card — homework only */}
|
||||
{!isClassroom && (
|
||||
<TaskCard
|
||||
task={state.task}
|
||||
isLoading={isTaskLoading}
|
||||
streamingContent={isTaskLoading ? state.streamingContent : ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error banner */}
|
||||
{state.error && (
|
||||
<div className="mx-3 mt-3 rounded-lg border border-red-800 bg-red-900/30 px-4 py-2.5 text-sm text-red-300">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<MessageList
|
||||
messages={messages}
|
||||
isStreaming={showChatStreaming}
|
||||
streamingContent={chatStreamingContent}
|
||||
emptyText={
|
||||
isClassroom
|
||||
? 'Ask me anything about this topic.'
|
||||
: 'Run your code or submit it for review — or ask me anything about the task.'
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Input */}
|
||||
<ChatInput isDisabled={state.isStreaming} onSend={handleSend} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
components/chat/MessageBubble.tsx
Normal file
68
components/chat/MessageBubble.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import type { Message } from '@/types';
|
||||
|
||||
interface Props {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export default function MessageBubble({ message }: Props) {
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[85%] rounded-2xl rounded-tr-sm bg-blue-600 px-4 py-3 text-sm text-white">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[92%] rounded-2xl rounded-tl-sm bg-zinc-800 px-4 py-3 text-sm text-zinc-200">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ className, children, ...rest }) {
|
||||
const match = /language-(\w+)/.exec(className ?? '');
|
||||
const isBlock = Boolean(match);
|
||||
return isBlock ? (
|
||||
<SyntaxHighlighter
|
||||
style={vscDarkPlus as Record<string, React.CSSProperties>}
|
||||
language={match![1]}
|
||||
PreTag="div"
|
||||
customStyle={{ margin: '0.5rem 0', borderRadius: '0.375rem', fontSize: '0.8rem' }}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code className="rounded bg-zinc-700 px-1 py-0.5 text-xs font-mono text-zinc-200" {...rest}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
p: ({ children }) => <p className="mb-2 last:mb-0 leading-relaxed">{children}</p>,
|
||||
ul: ({ children }) => <ul className="mb-2 list-disc pl-5 space-y-1">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="mb-2 list-decimal pl-5 space-y-1">{children}</ol>,
|
||||
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
|
||||
h1: ({ children }) => <h1 className="mb-2 text-base font-bold text-zinc-100">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="mb-2 text-sm font-bold text-zinc-100">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="mb-1 text-sm font-semibold text-zinc-200">{children}</h3>,
|
||||
strong: ({ children }) => <strong className="font-semibold text-zinc-100">{children}</strong>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="my-2 border-l-2 border-blue-500 pl-3 text-zinc-400 italic">{children}</blockquote>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
components/chat/MessageList.tsx
Normal file
90
components/chat/MessageList.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import type { Message } from '@/types';
|
||||
import MessageBubble from './MessageBubble';
|
||||
|
||||
interface Props {
|
||||
messages: Message[];
|
||||
isStreaming: boolean;
|
||||
streamingContent: string;
|
||||
emptyText?: string;
|
||||
}
|
||||
|
||||
export default function MessageList({ messages, isStreaming, streamingContent, emptyText }: Props) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, streamingContent]);
|
||||
|
||||
if (messages.length === 0 && !isStreaming) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-6 text-center">
|
||||
<p className="text-sm text-zinc-500">
|
||||
{emptyText ?? 'Run your code or submit it for review — or ask me anything about the task.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
|
||||
{/* Live streaming bubble */}
|
||||
{isStreaming && streamingContent && (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[92%] rounded-2xl rounded-tl-sm bg-zinc-800 px-4 py-3 text-sm text-zinc-200">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ className, children, ...rest }) {
|
||||
const match = /language-(\w+)/.exec(className ?? '');
|
||||
return match ? (
|
||||
<SyntaxHighlighter
|
||||
style={vscDarkPlus as Record<string, React.CSSProperties>}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
customStyle={{ margin: '0.5rem 0', borderRadius: '0.375rem', fontSize: '0.8rem' }}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code className="rounded bg-zinc-700 px-1 py-0.5 text-xs font-mono text-zinc-200" {...rest}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
p: ({ children }) => <p className="mb-2 last:mb-0 leading-relaxed">{children}</p>,
|
||||
}}
|
||||
>
|
||||
{streamingContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Typing indicator when streaming but no content yet */}
|
||||
{isStreaming && !streamingContent && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-2xl rounded-tl-sm bg-zinc-800 px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-zinc-500 [animation-delay:-0.3s]" />
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-zinc-500 [animation-delay:-0.15s]" />
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-zinc-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
components/chat/TaskCard.tsx
Normal file
81
components/chat/TaskCard.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import type { Task } from '@/types';
|
||||
|
||||
interface Props {
|
||||
task: Task | null;
|
||||
isLoading: boolean;
|
||||
streamingContent: string;
|
||||
}
|
||||
|
||||
export default function TaskCard({ task, isLoading, streamingContent }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
if (!isLoading && !task) return null;
|
||||
|
||||
return (
|
||||
<div className="border-b border-zinc-700 bg-zinc-800/50">
|
||||
<button
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
className="flex w-full items-center gap-2 px-4 py-3 text-left hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<span className="text-xs text-zinc-400">{collapsed ? '▶' : '▼'}</span>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-blue-400">
|
||||
{isLoading ? 'Generating task…' : task?.title ?? 'Task'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="px-4 pb-4 text-sm text-zinc-300">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{/* Skeleton shimmer while task streams in */}
|
||||
{streamingContent ? (
|
||||
<p className="whitespace-pre-wrap text-xs text-zinc-400 font-mono">{streamingContent}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-3 w-3/4 animate-pulse rounded bg-zinc-700" />
|
||||
<div className="h-3 w-full animate-pulse rounded bg-zinc-700" />
|
||||
<div className="h-3 w-2/3 animate-pulse rounded bg-zinc-700" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : task ? (
|
||||
<div className="space-y-3">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ children }) => <p className="leading-relaxed text-zinc-300">{children}</p>,
|
||||
ul: ({ children }) => <ul className="list-disc pl-5 space-y-1">{children}</ul>,
|
||||
li: ({ children }) => <li className="text-zinc-400">{children}</li>,
|
||||
strong: ({ children }) => <strong className="font-semibold text-zinc-200">{children}</strong>,
|
||||
code: ({ children }) => (
|
||||
<code className="rounded bg-zinc-700 px-1 py-0.5 text-xs font-mono text-zinc-300">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{task.description}
|
||||
</ReactMarkdown>
|
||||
|
||||
{task.hints.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold text-zinc-500 uppercase tracking-wider">Hints</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{task.hints.map((hint, i) => (
|
||||
<li key={i} className="text-xs text-zinc-500">{hint}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
components/classroom/LessonPane.tsx
Normal file
96
components/classroom/LessonPane.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import type { Topic } from '@/types';
|
||||
|
||||
interface Props {
|
||||
topic: Topic;
|
||||
lessonContent: string | null;
|
||||
isGenerating: boolean;
|
||||
streamingContent: string;
|
||||
}
|
||||
|
||||
export default function LessonPane({ topic, lessonContent, isGenerating, streamingContent }: Props) {
|
||||
const content = lessonContent ?? (isGenerating ? streamingContent : null);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-zinc-950">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 border-b border-zinc-700 bg-zinc-900 px-4 py-2 flex-shrink-0">
|
||||
<svg className="h-3.5 w-3.5 text-zinc-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
||||
</svg>
|
||||
<span className="text-sm font-semibold text-zinc-200">{topic.label}</span>
|
||||
{isGenerating && (
|
||||
<div className="ml-auto flex items-center gap-1.5 text-xs text-zinc-500">
|
||||
<div className="h-2 w-2 animate-pulse rounded-full bg-blue-400" />
|
||||
Writing lesson…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!content ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-500">
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-zinc-600 [animation-delay:-0.3s]" />
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-zinc-600 [animation-delay:-0.15s]" />
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-zinc-600" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-6 py-5 text-sm">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ className, children, ...rest }) {
|
||||
const match = /language-(\w+)/.exec(className ?? '');
|
||||
return match ? (
|
||||
<SyntaxHighlighter
|
||||
style={vscDarkPlus as Record<string, React.CSSProperties>}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
customStyle={{ margin: '0.75rem 0', borderRadius: '0.375rem', fontSize: '0.8rem' }}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code className="rounded bg-zinc-800 px-1 py-0.5 text-xs font-mono text-zinc-300" {...rest}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
h1: ({ children }) => <h1 className="mb-4 mt-6 text-xl font-bold text-zinc-100 first:mt-0">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="mb-3 mt-6 text-base font-bold text-zinc-100 border-b border-zinc-800 pb-1">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="mb-2 mt-4 text-sm font-semibold text-zinc-200">{children}</h3>,
|
||||
p: ({ children }) => <p className="mb-3 leading-relaxed text-zinc-300">{children}</p>,
|
||||
ul: ({ children }) => <ul className="mb-3 list-disc pl-5 space-y-1.5 text-zinc-300">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="mb-3 list-decimal pl-5 space-y-1.5 text-zinc-300">{children}</ol>,
|
||||
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
|
||||
strong: ({ children }) => <strong className="font-semibold text-zinc-100">{children}</strong>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="my-3 border-l-2 border-blue-500 pl-4 italic text-zinc-400">{children}</blockquote>
|
||||
),
|
||||
hr: () => <hr className="my-5 border-zinc-800" />,
|
||||
table: ({ children }) => (
|
||||
<div className="mb-3 overflow-x-auto">
|
||||
<table className="w-full border-collapse text-xs">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => <th className="border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-left font-semibold text-zinc-200">{children}</th>,
|
||||
td: ({ children }) => <td className="border border-zinc-700 px-3 py-1.5 text-zinc-300">{children}</td>,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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