Version 1.0
This commit is contained in:
@@ -0,0 +1,732 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Editor } from '@monaco-editor/react';
|
||||
import { registerMonacoCompletions } from '../utils/monacoCompletions';
|
||||
import {
|
||||
getChallenges,
|
||||
runCode,
|
||||
lintCode,
|
||||
getGuidance,
|
||||
getHandbookCatalog,
|
||||
getHandbookExample,
|
||||
Challenge,
|
||||
HandbookTopic,
|
||||
HandbookCatalog,
|
||||
} from '../api/client';
|
||||
import { MarkdownView } from '../components/MarkdownView';
|
||||
import {
|
||||
MessageSquare,
|
||||
ChevronLeft,
|
||||
Play,
|
||||
CheckCircle2,
|
||||
Sparkles,
|
||||
Code,
|
||||
Send,
|
||||
HelpCircle,
|
||||
BookMarked,
|
||||
X,
|
||||
Search,
|
||||
Zap,
|
||||
BookOpen,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Terminal,
|
||||
GripVertical,
|
||||
GripHorizontal,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface WorkspaceProps {
|
||||
challengeId: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
const [allChallenges, setAllChallenges] = useState<Challenge[]>([]);
|
||||
const [currentChallenge, setCurrentChallenge] = useState<Challenge | null>(null);
|
||||
|
||||
const [code, setCode] = useState('');
|
||||
const [stdinText, setStdinText] = useState('');
|
||||
|
||||
// Resizable Layout Dimensions
|
||||
const [leftWidth, setLeftWidth] = useState(320); // Left Challenge Panel width in px
|
||||
const [rightWidth, setRightWidth] = useState(350); // Right Sidebar width in px
|
||||
const [outputHeight, setOutputHeight] = useState(180); // Output Console height in px
|
||||
|
||||
const isDraggingLeft = useRef(false);
|
||||
const isDraggingRight = useRef(false);
|
||||
const isDraggingOutput = useRef(false);
|
||||
|
||||
const [output, setOutput] = useState<{ status: 'idle' | 'success' | 'error'; text: string }>({
|
||||
status: 'idle',
|
||||
text: 'Execution and lint output will appear here...',
|
||||
});
|
||||
|
||||
// Right sidebar state: 'mentor' | 'handbook' | 'closed'
|
||||
const [rightSidebarMode, setRightSidebarMode] = useState<'mentor' | 'handbook' | 'closed'>('mentor');
|
||||
|
||||
// Mentor state
|
||||
const [guidance, setGuidance] = useState<string>('Ask the mentor a question or click Check Answer below...');
|
||||
const [userQuestion, setUserQuestion] = useState('');
|
||||
const [isLoadingGuidance, setIsLoadingGuidance] = useState(false);
|
||||
|
||||
// Handbook W3Schools state
|
||||
const [handbookCatalog, setHandbookCatalog] = useState<HandbookCatalog>({ functions: [], subjects: [] });
|
||||
const [handbookTab, setHandbookTab] = useState<'functions' | 'subjects'>('functions');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedTopic, setSelectedTopic] = useState<HandbookTopic | null>(null);
|
||||
const [handbookExample, setHandbookExample] = useState<string>(
|
||||
'Select a built-in function or topic above to view W3Schools reference card...'
|
||||
);
|
||||
const [isLoadingExample, setIsLoadingExample] = useState(false);
|
||||
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isLinting, setIsLinting] = useState(false);
|
||||
|
||||
// Load all challenges
|
||||
useEffect(() => {
|
||||
getChallenges()
|
||||
.then((challenges) => {
|
||||
setAllChallenges(challenges);
|
||||
const match = challenges.find((c) => (c.id || c.challenge_id) === challengeId);
|
||||
if (match) {
|
||||
setCurrentChallenge(match);
|
||||
} else if (challenges.length > 0) {
|
||||
setCurrentChallenge(challenges[0]);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error('Failed to load challenges:', err));
|
||||
}, [challengeId]);
|
||||
|
||||
// Fetch handbook catalog when current challenge language changes
|
||||
useEffect(() => {
|
||||
if (currentChallenge) {
|
||||
getHandbookCatalog(currentChallenge.language)
|
||||
.then((catalog) => {
|
||||
setHandbookCatalog(catalog);
|
||||
if (catalog.functions.length > 0) {
|
||||
setSelectedTopic(catalog.functions[0]);
|
||||
} else if (catalog.subjects.length > 0) {
|
||||
setSelectedTopic(catalog.subjects[0]);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error('Failed to load handbook catalog:', err));
|
||||
}
|
||||
}, [currentChallenge]);
|
||||
|
||||
// Global mousemove and mouseup listeners for drag-to-resize splitters
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isDraggingLeft.current) {
|
||||
const newWidth = Math.max(180, Math.min(600, e.clientX));
|
||||
setLeftWidth(newWidth);
|
||||
} else if (isDraggingRight.current) {
|
||||
const newWidth = Math.max(220, Math.min(700, window.innerWidth - e.clientX));
|
||||
setRightWidth(newWidth);
|
||||
} else if (isDraggingOutput.current) {
|
||||
const newHeight = Math.max(80, Math.min(650, window.innerHeight - e.clientY));
|
||||
setOutputHeight(newHeight);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDraggingLeft.current = false;
|
||||
isDraggingRight.current = false;
|
||||
isDraggingOutput.current = false;
|
||||
document.body.style.cursor = 'default';
|
||||
document.body.style.userSelect = 'auto';
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startDraggingLeft = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingLeft.current = true;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const startDraggingRight = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingRight.current = true;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const startDraggingOutput = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingOutput.current = true;
|
||||
document.body.style.cursor = 'row-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const handleSelectChallenge = (cid: string) => {
|
||||
const match = allChallenges.find((c) => (c.id || c.challenge_id) === cid);
|
||||
if (match) {
|
||||
setCurrentChallenge(match);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunCode = async (overrideStdin?: string) => {
|
||||
if (!code.trim() || !currentChallenge) return;
|
||||
setIsRunning(true);
|
||||
setOutput({ status: 'idle', text: 'Executing code...' });
|
||||
const inputToSend = overrideStdin !== undefined ? overrideStdin : stdinText;
|
||||
|
||||
try {
|
||||
const res = await runCode(currentChallenge.language.toLowerCase() || 'python', code, inputToSend);
|
||||
if (res.exit_code === 0) {
|
||||
setOutput({
|
||||
status: 'success',
|
||||
text: res.stdout?.trim() ? `Output:\n${res.stdout}` : '✓ Code executed successfully (exit code 0, no stdout).',
|
||||
});
|
||||
} else {
|
||||
const err = res.stderr?.trim() || res.stdout?.trim() || 'Unknown runtime error';
|
||||
setOutput({ status: 'error', text: `✗ Runtime Error (exit code ${res.exit_code}):\n${err}` });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setOutput({ status: 'error', text: `✗ Error running code: ${err.message}` });
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendStdin = () => {
|
||||
handleRunCode(stdinText);
|
||||
};
|
||||
|
||||
const handleLintCode = async () => {
|
||||
if (!code.trim() || !currentChallenge) return;
|
||||
setIsLinting(true);
|
||||
setOutput({ status: 'idle', text: 'Running syntax & style lint...' });
|
||||
try {
|
||||
const res = await lintCode(currentChallenge.language.toLowerCase() || 'python', code);
|
||||
if (res.exit_code === 0) {
|
||||
setOutput({ status: 'success', text: '✓ Syntax & Style clean! No linting errors detected.' });
|
||||
} else {
|
||||
const raw = (res.stdout || '') + '\n' + (res.stderr || '');
|
||||
setOutput({ status: 'error', text: `✗ Lint Error(s):\n${raw.trim()}` });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setOutput({ status: 'error', text: `✗ Error linting code: ${err.message}` });
|
||||
} finally {
|
||||
setIsLinting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAskMentor = async (questionToAsk?: string) => {
|
||||
if (!currentChallenge) return;
|
||||
const q = questionToAsk !== undefined ? questionToAsk : userQuestion.trim();
|
||||
const cid = currentChallenge.id || currentChallenge.challenge_id;
|
||||
|
||||
setIsLoadingGuidance(true);
|
||||
setRightSidebarMode('mentor');
|
||||
setGuidance('⏳ **Mentor is thinking and generating guidance...**\n\n*Analyzing your code, task requirements, and question...*');
|
||||
setUserQuestion('');
|
||||
|
||||
try {
|
||||
const res = await getGuidance(
|
||||
cid,
|
||||
currentChallenge.language.toLowerCase() || 'python',
|
||||
code,
|
||||
q
|
||||
);
|
||||
const qHeader = q ? `### Question / Evaluation:\n> ${q}\n\n---\n\n` : '';
|
||||
setGuidance(`${qHeader}${res.mentor_response || 'No guidance received.'}`);
|
||||
} catch (err: any) {
|
||||
setGuidance(`⚠️ Error consulting Mentor: ${err.message}`);
|
||||
} finally {
|
||||
setIsLoadingGuidance(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectHandbookTopic = async (topic: HandbookTopic) => {
|
||||
if (!currentChallenge) return;
|
||||
setSelectedTopic(topic);
|
||||
setIsLoadingExample(true);
|
||||
setHandbookExample(`⏳ **Generating W3Schools reference card for '${topic.title}' in ${currentChallenge.language}...**\n\n*Consulting LLM backend...*`);
|
||||
|
||||
try {
|
||||
const res = await getHandbookExample(currentChallenge.language, topic.id, topic.title);
|
||||
if (res.status === 'success') {
|
||||
setHandbookExample(res.example_markdown);
|
||||
} else {
|
||||
setHandbookExample(`⚠️ ${res.message}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setHandbookExample(`⚠️ Error generating reference card: ${err.message}`);
|
||||
} finally {
|
||||
setIsLoadingExample(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckAnswer = () => {
|
||||
const checkPrompt =
|
||||
'Please evaluate my code implementation against all the requirements of this challenge. Check if my solution is complete and correct, point out any missing requirements or edge cases, and give me feedback on my answer.';
|
||||
handleAskMentor(checkPrompt);
|
||||
};
|
||||
|
||||
const handleKeyDownQuestion = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleAskMentor();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDownStdin = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSendStdin();
|
||||
}
|
||||
};
|
||||
|
||||
const activeCId = currentChallenge?.id || currentChallenge?.challenge_id || '';
|
||||
|
||||
// Filter handbook catalog based on active tab and search query
|
||||
const rawList = handbookTab === 'functions' ? handbookCatalog.functions : handbookCatalog.subjects;
|
||||
const filteredList = rawList.filter((item) =>
|
||||
searchQuery.trim() === ''
|
||||
? true
|
||||
: item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.desc.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-darkBg text-gray-100 font-sans overflow-hidden">
|
||||
{/* Header Bar */}
|
||||
<header className="h-14 border-b border-darkBorder bg-darkSurface px-4 flex justify-between items-center select-none shadow-md z-10 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-1.5 hover:bg-gray-800 text-gray-400 hover:text-white rounded-lg transition-colors flex items-center gap-1 text-xs"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
<span>Dashboard</span>
|
||||
</button>
|
||||
<div className="h-4 w-px bg-darkBorder" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="text-blue-500" size={20} />
|
||||
<span className="font-bold text-sm text-white">TactiTerm</span>
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-px bg-darkBorder" />
|
||||
|
||||
{/* Challenge Selector */}
|
||||
<select
|
||||
value={activeCId}
|
||||
onChange={(e) => handleSelectChallenge(e.target.value)}
|
||||
className="bg-darkBg border border-darkBorder text-gray-200 text-xs rounded-lg px-3 py-1.5 focus:outline-none focus:border-blue-500 font-medium"
|
||||
>
|
||||
{allChallenges.map((c) => {
|
||||
const cid = c.id || c.challenge_id;
|
||||
return (
|
||||
<option key={cid} value={cid}>
|
||||
{c.name} ({c.difficulty})
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Action Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleRunCode()}
|
||||
disabled={isRunning || !code.trim()}
|
||||
className="px-3.5 py-1.5 bg-green-600 hover:bg-green-500 disabled:opacity-50 text-white font-semibold text-xs rounded-lg transition-all flex items-center gap-1.5 shadow-sm"
|
||||
>
|
||||
{isRunning ? (
|
||||
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Play size={14} fill="currentColor" />
|
||||
)}
|
||||
<span>Run</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleLintCode}
|
||||
disabled={isLinting || !code.trim()}
|
||||
className="px-3.5 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white font-semibold text-xs rounded-lg transition-all flex items-center gap-1.5 shadow-sm"
|
||||
>
|
||||
{isLinting ? (
|
||||
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 size={14} />
|
||||
)}
|
||||
<span>Lint</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleCheckAnswer}
|
||||
disabled={isLoadingGuidance || !code.trim()}
|
||||
className="px-3.5 py-1.5 bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 text-white font-semibold text-xs rounded-lg transition-all flex items-center gap-1.5 shadow-sm"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span>Check Answer</span>
|
||||
</button>
|
||||
|
||||
{/* Dual Sidebar Toggles */}
|
||||
<button
|
||||
onClick={() => setRightSidebarMode(rightSidebarMode === 'mentor' ? 'closed' : 'mentor')}
|
||||
className={`px-3.5 py-1.5 font-semibold text-xs rounded-lg transition-all flex items-center gap-1.5 shadow-sm border ${
|
||||
rightSidebarMode === 'mentor'
|
||||
? 'bg-amber-500/20 text-amber-300 border-amber-500/40'
|
||||
: 'bg-gray-800 text-gray-300 hover:bg-gray-700 border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
<span>Mentor</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setRightSidebarMode(rightSidebarMode === 'handbook' ? 'closed' : 'handbook')}
|
||||
className={`px-3.5 py-1.5 font-semibold text-xs rounded-lg transition-all flex items-center gap-1.5 shadow-sm border ${
|
||||
rightSidebarMode === 'handbook'
|
||||
? 'bg-indigo-500/20 text-indigo-300 border-indigo-500/40'
|
||||
: 'bg-gray-800 text-gray-300 hover:bg-gray-700 border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<BookMarked size={14} />
|
||||
<span>W3Schools Handbook</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Container */}
|
||||
<div className="flex flex-1 overflow-hidden relative">
|
||||
{/* Left Column: Challenge Specifications (Scalable Width) */}
|
||||
<div
|
||||
style={{ width: `${leftWidth}px` }}
|
||||
className="border-r border-darkBorder bg-darkBg flex flex-col overflow-hidden shrink-0"
|
||||
>
|
||||
<div className="p-3 bg-darkSurface border-b border-darkBorder flex justify-between items-center">
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-gray-400">Challenge Details</h2>
|
||||
{currentChallenge && (
|
||||
<span className="px-2 py-0.5 bg-blue-500/10 border border-blue-500/20 text-blue-400 text-[10px] uppercase font-bold rounded">
|
||||
{currentChallenge.language}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-5 overflow-y-auto space-y-6">
|
||||
{currentChallenge ? (
|
||||
<>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white mb-2">{currentChallenge.name}</h1>
|
||||
<span
|
||||
className={`inline-block px-2.5 py-0.5 text-[10px] uppercase font-bold rounded border ${
|
||||
currentChallenge.difficulty.toLowerCase() === 'easy'
|
||||
? 'bg-green-500/10 text-green-400 border-green-500/20'
|
||||
: currentChallenge.difficulty.toLowerCase() === 'medium'
|
||||
? 'bg-amber-500/10 text-amber-400 border-amber-500/20'
|
||||
: 'bg-red-500/10 text-red-400 border-red-500/20'
|
||||
}`}
|
||||
>
|
||||
{currentChallenge.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-blue-400 mb-2">Description</h3>
|
||||
<p className="text-xs text-gray-300 leading-relaxed bg-darkSurface p-3 rounded-xl border border-darkBorder/50">
|
||||
{currentChallenge.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{currentChallenge.requirements && currentChallenge.requirements.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-blue-400 mb-2">Requirements</h3>
|
||||
<ul className="space-y-1.5">
|
||||
{currentChallenge.requirements.map((req, idx) => (
|
||||
<li key={idx} className="text-xs text-gray-300 flex items-start gap-2">
|
||||
<span className="text-blue-500 font-bold">•</span>
|
||||
<span>{req}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentChallenge.hints && currentChallenge.hints.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-amber-400 mb-2">Hints</h3>
|
||||
<ul className="space-y-1.5">
|
||||
{currentChallenge.hints.map((hint, idx) => (
|
||||
<li key={idx} className="text-xs text-gray-400 flex items-start gap-2 bg-amber-500/5 p-2 rounded-lg border border-amber-500/10">
|
||||
<HelpCircle size={14} className="text-amber-400 shrink-0 mt-0.5" />
|
||||
<span>{hint}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">Loading details...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resizable Splitter Handle: Left Column */}
|
||||
<div
|
||||
onMouseDown={startDraggingLeft}
|
||||
className="w-1.5 bg-darkBorder hover:bg-blue-500 active:bg-blue-600 cursor-col-resize flex items-center justify-center shrink-0 group transition-colors"
|
||||
title="Drag to resize Challenge Panel"
|
||||
>
|
||||
<GripVertical size={10} className="text-gray-600 group-hover:text-white" />
|
||||
</div>
|
||||
|
||||
{/* Middle Column: Monaco Code Editor & Resizable Output Console */}
|
||||
<div className="flex-1 flex flex-col bg-[#1e1e1e] overflow-hidden">
|
||||
<div className="bg-darkBg text-gray-400 px-4 py-2 text-xs flex justify-between items-center border-b border-darkBorder shrink-0">
|
||||
<span className="font-mono text-blue-400">main.py</span>
|
||||
<span className="text-[11px] text-gray-500">TactiTerm IDE</span>
|
||||
</div>
|
||||
|
||||
{/* Code Editor */}
|
||||
<div className="flex-1 relative">
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage={currentChallenge?.language.toLowerCase() || 'python'}
|
||||
language={currentChallenge?.language.toLowerCase() || 'python'}
|
||||
defaultValue=""
|
||||
value={code}
|
||||
theme="vs-dark"
|
||||
onMount={(_, monaco) => registerMonacoCompletions(monaco)}
|
||||
onChange={(val) => setCode(val || '')}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
automaticLayout: true,
|
||||
scrollBeyondLastLine: false,
|
||||
padding: { top: 12 },
|
||||
quickSuggestions: true,
|
||||
suggestOnTriggerCharacters: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resizable Splitter Handle: Output Console */}
|
||||
<div
|
||||
onMouseDown={startDraggingOutput}
|
||||
className="h-1.5 bg-darkBorder hover:bg-blue-500 active:bg-blue-600 cursor-row-resize flex items-center justify-center shrink-0 group transition-colors"
|
||||
title="Drag up/down to resize Output Console"
|
||||
>
|
||||
<GripHorizontal size={10} className="text-gray-600 group-hover:text-white" />
|
||||
</div>
|
||||
|
||||
{/* Scalable Output & Stdin Input Terminal Area */}
|
||||
<div
|
||||
style={{ height: `${outputHeight}px` }}
|
||||
className="border-t border-darkBorder bg-black/90 p-4 font-mono text-xs flex flex-col overflow-hidden shrink-0"
|
||||
>
|
||||
<div className="flex justify-between items-center text-gray-400 pb-2 mb-2 border-b border-gray-800 text-[11px] uppercase font-bold shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal size={14} className="text-blue-400" />
|
||||
<span>Output Console</span>
|
||||
{output.status === 'success' && <span className="text-green-400 lowercase font-normal">(clean)</span>}
|
||||
{output.status === 'error' && <span className="text-red-400 lowercase font-normal">(error)</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre className={`flex-1 overflow-y-auto whitespace-pre-wrap leading-relaxed ${
|
||||
output.status === 'error' ? 'text-red-400' : 'text-emerald-400'
|
||||
}`}>
|
||||
{output.text}
|
||||
</pre>
|
||||
|
||||
{/* Interactive Stdin Input Bar */}
|
||||
<div className="mt-3 pt-3 border-t border-gray-800 flex gap-2 font-sans shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
value={stdinText}
|
||||
onChange={(e) => setStdinText(e.target.value)}
|
||||
onKeyDown={handleKeyDownStdin}
|
||||
placeholder="Type program input (stdin) and hit Enter to run with input..."
|
||||
className="flex-1 bg-darkSurface border border-darkBorder rounded-lg px-3 py-1.5 text-xs text-gray-200 placeholder-gray-500 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendStdin}
|
||||
disabled={isRunning}
|
||||
className="px-3.5 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white font-semibold text-xs rounded-lg transition-all flex items-center gap-1.5 shadow-sm"
|
||||
>
|
||||
<Send size={13} />
|
||||
<span>Send Input</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resizable Splitter Handle: Right Column */}
|
||||
{rightSidebarMode !== 'closed' && (
|
||||
<div
|
||||
onMouseDown={startDraggingRight}
|
||||
className="w-1.5 bg-darkBorder hover:bg-blue-500 active:bg-blue-600 cursor-col-resize flex items-center justify-center shrink-0 group transition-colors"
|
||||
title="Drag to resize Right Sidebar"
|
||||
>
|
||||
<GripVertical size={10} className="text-gray-600 group-hover:text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right Column Mode 1: Mentor Sidebar (Scalable Width) */}
|
||||
{rightSidebarMode === 'mentor' && (
|
||||
<div
|
||||
style={{ width: `${rightWidth}px` }}
|
||||
className="border-l border-darkBorder bg-darkSurface flex flex-col overflow-hidden shrink-0"
|
||||
>
|
||||
<div className="p-3 border-b border-darkBorder bg-darkBg flex justify-between items-center">
|
||||
<h2 className="flex items-center gap-2 font-bold text-amber-400 text-xs uppercase tracking-wider">
|
||||
<MessageSquare size={16} /> Mentor
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setRightSidebarMode('closed')}
|
||||
className="text-gray-400 hover:text-white text-xs px-2 py-0.5 rounded hover:bg-gray-800"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mentor Guidance View */}
|
||||
<div className="flex-1 p-4 overflow-y-auto space-y-4">
|
||||
<div className="p-4 bg-darkBg border border-darkBorder rounded-xl shadow-inner">
|
||||
<MarkdownView content={guidance} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Question Input Area */}
|
||||
<div className="p-4 border-t border-darkBorder bg-darkBg flex flex-col gap-2">
|
||||
<label className="text-[11px] font-bold text-gray-400 uppercase tracking-wider">
|
||||
Ask Question (Enter to Send):
|
||||
</label>
|
||||
<textarea
|
||||
value={userQuestion}
|
||||
onChange={(e) => setUserQuestion(e.target.value)}
|
||||
onKeyDown={handleKeyDownQuestion}
|
||||
placeholder="e.g. How should I structure my loop? Why is my variable returning None?"
|
||||
className="w-full h-20 bg-darkSurface border border-darkBorder rounded-lg p-2.5 text-xs text-gray-200 placeholder-gray-500 focus:outline-none focus:border-amber-500 resize-none font-sans"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleAskMentor()}
|
||||
disabled={isLoadingGuidance}
|
||||
className="flex-1 py-2 bg-amber-600 hover:bg-amber-500 disabled:opacity-50 text-white font-semibold text-xs rounded-lg transition-all flex items-center justify-center gap-1.5 shadow-sm"
|
||||
>
|
||||
<Send size={14} />
|
||||
<span>Ask Mentor</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleCheckAnswer}
|
||||
disabled={isLoadingGuidance || !code.trim()}
|
||||
className="py-2 px-3 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-50 text-white font-semibold text-xs rounded-lg transition-all flex items-center justify-center gap-1"
|
||||
title="Check Answer"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span>Check</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right Column Mode 2: W3Schools-Style Handbook Sidebar (Scalable Width) */}
|
||||
{rightSidebarMode === 'handbook' && (
|
||||
<div
|
||||
style={{ width: `${rightWidth}px` }}
|
||||
className="border-l border-darkBorder bg-darkSurface flex flex-col overflow-hidden shrink-0"
|
||||
>
|
||||
<div className="p-3 border-b border-darkBorder bg-darkBg flex justify-between items-center">
|
||||
<h2 className="flex items-center gap-2 font-bold text-indigo-400 text-xs uppercase tracking-wider">
|
||||
<BookMarked size={16} /> W3Schools Reference ({currentChallenge?.language || 'Python'})
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setRightSidebarMode('closed')}
|
||||
className="text-gray-400 hover:text-white text-xs px-2 py-0.5 rounded hover:bg-gray-800"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Category Tabs & Search Bar */}
|
||||
<div className="p-3 border-b border-darkBorder bg-darkBg flex flex-col gap-2.5">
|
||||
<div className="flex bg-darkSurface p-1 rounded-xl border border-darkBorder">
|
||||
<button
|
||||
onClick={() => setHandbookTab('functions')}
|
||||
className={`flex-1 py-1.5 text-[11px] font-bold rounded-lg transition-all flex items-center justify-center gap-1.5 ${
|
||||
handbookTab === 'functions'
|
||||
? 'bg-indigo-600 text-white shadow-sm'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Zap size={13} />
|
||||
<span>Built-ins</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setHandbookTab('subjects')}
|
||||
className={`flex-1 py-1.5 text-[11px] font-bold rounded-lg transition-all flex items-center justify-center gap-1.5 ${
|
||||
handbookTab === 'subjects'
|
||||
? 'bg-indigo-600 text-white shadow-sm'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<BookOpen size={13} />
|
||||
<span>Subjects</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-2.5 top-2.5 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Filter functions or topics..."
|
||||
className="w-full bg-darkSurface border border-darkBorder rounded-lg pl-8 pr-3 py-1.5 text-xs text-gray-200 focus:outline-none focus:border-indigo-500 font-sans"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Topic Catalog List */}
|
||||
<div className="p-3 border-b border-darkBorder bg-darkBg flex flex-col gap-1.5 max-h-48 overflow-y-auto">
|
||||
{filteredList.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 italic p-2">No matching functions or topics found.</p>
|
||||
) : (
|
||||
filteredList.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleSelectHandbookTopic(item)}
|
||||
className={`text-left p-2 rounded-lg text-xs transition-all border ${
|
||||
selectedTopic?.id === item.id
|
||||
? 'bg-indigo-600/20 border-indigo-500/50 text-indigo-300 font-bold'
|
||||
: 'bg-darkSurface border-darkBorder/50 text-gray-300 hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold flex items-center gap-1.5">
|
||||
{handbookTab === 'functions' ? <Zap size={12} className="text-amber-400" /> : <BookOpen size={12} className="text-blue-400" />}
|
||||
<span>{item.title}</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-400 font-normal line-clamp-1 mt-0.5">{item.desc}</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Generated LLM W3Schools Reference Card View */}
|
||||
<div className="flex-1 p-4 overflow-y-auto">
|
||||
<div className="p-4 bg-darkBg border border-darkBorder rounded-xl shadow-inner">
|
||||
<MarkdownView content={handbookExample} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Workspace;
|
||||
Reference in New Issue
Block a user