Files
professor/components/AppShell.tsx
Aodhan Collins f644937604 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>
2026-03-04 21:48:34 +00:00

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)} />
)}
</>
);
}