| import { useState, useRef, useEffect } from "react"; | |
| const API_URL = import.meta.env.VITE_API_URL || "https://happy4040-mock-technical-interviewer.hf.space"; | |
| function Markdown({ text }) { | |
| const html = (text || "") | |
| .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>") | |
| .replace(/\*(.+?)\*/g, "<em>$1</em>") | |
| .replace(/`{3}[\w]*\n?([\s\S]*?)`{3}/g, "<pre><code>$1</code></pre>") | |
| .replace(/`([^`]+)`/g, "<code>$1</code>") | |
| .replace(/^### (.+)$/gm, "<h3>$1</h3>") | |
| .replace(/^## (.+)$/gm, "<h2>$1</h2>") | |
| .replace(/^# (.+)$/gm, "<h1>$1</h1>") | |
| .replace(/^---$/gm, "<hr/>") | |
| .replace(/^\* (.+)$/gm, "<li>$1</li>") | |
| .replace(/\n\n/g, "</p><p>") | |
| .replace(/\n/g, "<br/>"); | |
| return <div className="md-body" dangerouslySetInnerHTML={{ __html: `<p>${html}</p>` }} />; | |
| } | |
| function Whiteboard({ onCapture }) { | |
| const canvasRef = useRef(null); | |
| const drawing = useRef(false); | |
| const lastPos = useRef(null); | |
| const [color, setColor] = useState("#f0f4ff"); | |
| const [lineWidth, setLineWidth] = useState(3); | |
| const [eraser, setEraser] = useState(false); | |
| const getPos = (e, canvas) => { | |
| const rect = canvas.getBoundingClientRect(); | |
| const scaleX = canvas.width / rect.width; | |
| const scaleY = canvas.height / rect.height; | |
| if (e.touches) return { x: (e.touches[0].clientX - rect.left) * scaleX, y: (e.touches[0].clientY - rect.top) * scaleY }; | |
| return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY }; | |
| }; | |
| const startDraw = (e) => { e.preventDefault(); drawing.current = true; lastPos.current = getPos(e, canvasRef.current); }; | |
| const draw = (e) => { | |
| e.preventDefault(); | |
| if (!drawing.current) return; | |
| const canvas = canvasRef.current; | |
| const ctx = canvas.getContext("2d"); | |
| const pos = getPos(e, canvas); | |
| ctx.beginPath(); ctx.moveTo(lastPos.current.x, lastPos.current.y); ctx.lineTo(pos.x, pos.y); | |
| ctx.strokeStyle = eraser ? "#0e1117" : color; ctx.lineWidth = eraser ? 20 : lineWidth; | |
| ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.stroke(); | |
| lastPos.current = pos; | |
| }; | |
| const stopDraw = () => { drawing.current = false; }; | |
| const clear = () => { | |
| const canvas = canvasRef.current; const ctx = canvas.getContext("2d"); | |
| ctx.fillStyle = "#0e1117"; ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| }; | |
| useEffect(() => { clear(); }, []); | |
| const capture = () => { const b64 = canvasRef.current.toDataURL("image/png").split(",")[1]; onCapture(b64); }; | |
| return ( | |
| <div className="whiteboard-wrap"> | |
| <div className="wb-toolbar"> | |
| <span className="wb-label">Whiteboard</span> | |
| <div className="color-swatches"> | |
| {["#f0f4ff","#7ee8a2","#ffd166","#ef476f","#06d6a0","#ffffff"].map(c => ( | |
| <button key={c} className={`swatch${color === c && !eraser ? " active" : ""}`} | |
| style={{ background: c }} onClick={() => { setColor(c); setEraser(false); }} /> | |
| ))} | |
| </div> | |
| <input type="range" min="1" max="12" value={lineWidth} onChange={e => setLineWidth(+e.target.value)} className="size-slider" title="Brush size" /> | |
| <button className={`wb-btn${eraser ? " active" : ""}`} onClick={() => setEraser(!eraser)}>Erase</button> | |
| <button className="wb-btn" onClick={clear}>Clear</button> | |
| <button className="wb-btn send-wb" onClick={capture}>Send to AI</button> | |
| </div> | |
| <canvas ref={canvasRef} width={900} height={340} className="wb-canvas" | |
| onMouseDown={startDraw} onMouseMove={draw} onMouseUp={stopDraw} onMouseLeave={stopDraw} | |
| onTouchStart={startDraw} onTouchMove={draw} onTouchEnd={stopDraw} /> | |
| </div> | |
| ); | |
| } | |
| function Message({ role, text }) { | |
| return ( | |
| <div className={`msg ${role}`}> | |
| <div className="msg-avatar">{role === "ai" ? "AI" : "You"}</div> | |
| <div className="msg-bubble">{role === "ai" ? <Markdown text={text} /> : <p>{text}</p>}</div> | |
| </div> | |
| ); | |
| } | |
| function CodeEditor({ value, onChange }) { | |
| return ( | |
| <div className="code-editor-wrap"> | |
| <div className="code-editor-header"> | |
| <span className="dot red"/><span className="dot yellow"/><span className="dot green"/> | |
| <span className="code-lang">Python</span> | |
| </div> | |
| <textarea className="code-editor" value={value} onChange={e => onChange(e.target.value)} | |
| spellCheck={false} placeholder="# Write your solution here..." /> | |
| </div> | |
| ); | |
| } | |
| export default function App() { | |
| const [screen, setScreen] = useState("home"); | |
| const [apiKey, setApiKey] = useState(""); | |
| const [sessionId, setSessionId] = useState(null); | |
| const [messages, setMessages] = useState([]); | |
| const [problem, setProblem] = useState(""); | |
| const [code, setCode] = useState("# Your solution here\n"); | |
| const [codeChanged, setCodeChanged] = useState(false); | |
| const [input, setInput] = useState(""); | |
| const [loading, setLoading] = useState(false); | |
| const [starting, setStarting] = useState(false); | |
| const [finished, setFinished] = useState(false); | |
| const [report, setReport] = useState(""); | |
| const [showWb, setShowWb] = useState(false); | |
| const [error, setError] = useState(""); | |
| const chatEndRef = useRef(null); | |
| useEffect(() => { chatEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]); | |
| const apiFetch = (path, opts = {}) => | |
| fetch(`${API_URL}${path}`, { | |
| ...opts, | |
| headers: { "Content-Type": "application/json", ...(opts.headers || {}) }, | |
| }); | |
| const startSession = async () => { | |
| if (!apiKey.trim()) { setError("Please enter your Google Gemini API key."); return; } | |
| setError(""); setStarting(true); | |
| try { | |
| const res = await apiFetch("/api/session/start", { method: "POST", body: JSON.stringify({ api_key: apiKey.trim() }) }); | |
| if (!res.ok) throw new Error(await res.text()); | |
| const data = await res.json(); | |
| setSessionId(data.session_id); | |
| setMessages([{ role: "ai", text: data.message }]); | |
| setScreen("interview"); | |
| } catch (e) { | |
| setError("Could not connect to the server. Please try again."); | |
| } finally { setStarting(false); } | |
| }; | |
| const sendMessage = async (extraImageBase64 = null) => { | |
| if (!input.trim() && !codeChanged && !extraImageBase64) return; | |
| const userText = input.trim(); | |
| setInput(""); | |
| const userMsg = userText || (extraImageBase64 ? "[Whiteboard sent]" : "[Code updated]"); | |
| setMessages(m => [...m, { role: "user", text: userMsg }]); | |
| setLoading(true); | |
| try { | |
| const body = { session_id: sessionId, message: userText, code, code_changed: codeChanged, image_base64: extraImageBase64 || null, api_key: apiKey.trim() }; | |
| setCodeChanged(false); | |
| const res = await apiFetch("/api/chat", { method: "POST", body: JSON.stringify(body) }); | |
| if (!res.ok) throw new Error(await res.text()); | |
| const data = await res.json(); | |
| setMessages(m => [...m, { role: "ai", text: data.message }]); | |
| if (data.problem && !data.problem.includes("not been selected")) setProblem(data.problem); | |
| if (data.code && data.code !== "# Your code here") setCode(data.code); | |
| if (data.finished) { setFinished(true); if (data.report) setReport(data.report); } | |
| } catch (e) { | |
| setMessages(m => [...m, { role: "ai", text: `Something went wrong. Please try again in a moment.` }]); | |
| } finally { setLoading(false); } | |
| }; | |
| const handleWbCapture = (b64) => { setShowWb(false); sendMessage(b64); }; | |
| const handleKeyDown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); } }; | |
| const downloadReport = () => { | |
| const blob = new Blob([report], { type: "text/markdown" }); | |
| const a = document.createElement("a"); a.href = URL.createObjectURL(blob); | |
| a.download = "interview_report.md"; a.click(); | |
| }; | |
| if (screen === "home") return ( | |
| <div className="home-screen"> | |
| <div className="home-card"> | |
| <div className="logo-mark">⬡</div> | |
| <h1 className="home-title">Mock Technologie<br/><span>Interview Suite</span></h1> | |
| <p className="home-sub">Practice real technical interviews with an AI interviewer powered by Google Gemini. Get hints, use the whiteboard, write code, and receive a full evaluation report.</p> | |
| <div className="features-row"> | |
| <div className="feat"><span>🧠</span><p>Gemini AI</p></div> | |
| <div className="feat"><span>🖊</span><p>Whiteboard</p></div> | |
| <div className="feat"><span>💻</span><p>Code Editor</p></div> | |
| <div className="feat"><span>📊</span><p>Report</p></div> | |
| </div> | |
| <div className="config-section"> | |
| <label className="input-label">Google Gemini API Key</label> | |
| <input type="password" className="config-input" placeholder="AIza..." | |
| value={apiKey} onChange={e => setApiKey(e.target.value)} | |
| onKeyDown={e => e.key === "Enter" && startSession()} /> | |
| <p className="key-hint">Free key at <a href="https://aistudio.google.com/apikey" target="_blank" rel="noreferrer">aistudio.google.com/apikey</a> — your quota, your usage</p> | |
| </div> | |
| {error && <p className="err-msg">{error}</p>} | |
| <button className="start-btn" onClick={startSession} disabled={starting || !apiKey.trim()}> | |
| {starting ? <span className="spinner"/> : "Start Interview →"} | |
| </button> | |
| </div> | |
| <div className="home-bg"> | |
| {Array.from({length:24}).map((_,i)=>( | |
| <div key={i} className="bg-orb" style={{animationDelay:`${i*0.3}s`, left:`${(i*41)%100}%`, top:`${(i*31)%100}%`}}/> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| return ( | |
| <div className="app"> | |
| <header className="app-header"> | |
| <div className="header-brand"><span className="header-logo">⬡</span><span>Mock Technologie Inc.</span></div> | |
| <div className="header-status"> | |
| {finished | |
| ? <span className="badge done">✓ Complete</span> | |
| : <span className="badge live"><span className="pulse"/>Live</span>} | |
| </div> | |
| {finished && ( | |
| <div className="header-actions"> | |
| <button className="hdr-btn" onClick={downloadReport}>⬇ Download Report</button> | |
| <button className="hdr-btn accent" onClick={() => setScreen("report")}>View Report</button> | |
| </div> | |
| )} | |
| </header> | |
| <div className="layout"> | |
| <div className="left-panel"> | |
| <div className="problem-box"> | |
| <div className="problem-header"><span>📋</span><span>Problem Statement</span></div> | |
| <div className="problem-body"> | |
| {!problem || problem === "No problem selected yet." || problem === "Problem has not been selected yet" | |
| ? <p className="no-problem">A question will appear here once selected.</p> | |
| : <Markdown text={problem} />} | |
| </div> | |
| </div> | |
| <div className="chat-box"> | |
| <div className="chat-messages"> | |
| {messages.map((m, i) => <Message key={i} role={m.role} text={m.text} />)} | |
| {loading && ( | |
| <div className="msg ai"> | |
| <div className="msg-avatar">AI</div> | |
| <div className="msg-bubble typing"><span/><span/><span/></div> | |
| </div> | |
| )} | |
| <div ref={chatEndRef}/> | |
| </div> | |
| <div className="chat-input-row"> | |
| <textarea className="chat-input" rows={3} | |
| placeholder="Type your message… (Enter to send, Shift+Enter for newline)" | |
| value={input} onChange={e => setInput(e.target.value)} | |
| onKeyDown={handleKeyDown} disabled={loading || finished} /> | |
| <div className="chat-actions"> | |
| <button className="icon-btn" title="Whiteboard" onClick={() => setShowWb(v => !v)} disabled={finished}>🖊</button> | |
| <button className="send-btn" onClick={() => sendMessage()} | |
| disabled={loading || finished || (!input.trim() && !codeChanged)}> | |
| {loading ? <span className="spinner sm"/> : "Send →"} | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="right-panel"> | |
| <div className="panel-header"> | |
| <span>Code Editor</span> | |
| {codeChanged && <span className="changed-badge">● unsent changes</span>} | |
| </div> | |
| <CodeEditor value={code} onChange={v => { setCode(v); setCodeChanged(true); }}/> | |
| <button className="submit-code-btn" onClick={() => sendMessage()} disabled={loading || finished || !codeChanged}> | |
| Submit Code Update | |
| </button> | |
| </div> | |
| </div> | |
| {showWb && ( | |
| <div className="wb-overlay"> | |
| <div className="wb-modal"> | |
| <div className="wb-modal-header"> | |
| <span>Whiteboard — Draw your approach</span> | |
| <button className="close-btn" onClick={() => setShowWb(false)}>✕</button> | |
| </div> | |
| <Whiteboard onCapture={handleWbCapture}/> | |
| </div> | |
| </div> | |
| )} | |
| {screen === "report" && ( | |
| <div className="report-overlay"> | |
| <div className="report-modal"> | |
| <div className="report-modal-header"> | |
| <span>📊 Interview Evaluation Report</span> | |
| <div style={{display:"flex",gap:"8px"}}> | |
| <button className="hdr-btn" onClick={downloadReport}>⬇ Download</button> | |
| <button className="close-btn" onClick={() => setScreen("interview")}>✕</button> | |
| </div> | |
| </div> | |
| <div className="report-body"><Markdown text={report || "Generating report…"} /></div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } |