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>
311 lines
10 KiB
TypeScript
311 lines
10 KiB
TypeScript
'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)} />
|
|
)}
|
|
</>
|
|
);
|
|
}
|