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)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user