feat: Update login navigation and authentication flow

- Changed navigation from '/editor' to '/tests' after successful login.
- Introduced token state management in AuthContext for better handling of authentication.
- Updated login function to store JWT instead of Google token.
- Added error handling for login and test fetching processes.

style: Enhance UI with new footer and test list styles

- Added a footer component with copyright information.
- Created a new TestList component with improved styling and animations.
- Implemented responsive design for test cards and filter tabs.
- Added loading and error states for better user experience.

fix: Improve API interaction for test fetching and password verification

- Refactored API calls to use a centralized studentApi utility.
- Enhanced error handling for API responses, including unauthorized access.
- Implemented password verification for protected tests before starting them.
This commit is contained in:
2025-10-29 11:37:19 +05:30
parent 304761e258
commit 47f73681af
11 changed files with 1416 additions and 98 deletions

View File

@@ -1 +1,2 @@
VITE_API_URL="http://localhost:8080"
VITE_FACULTY_API_URL="http://localhost:5000/api"

View File

@@ -2677,9 +2677,9 @@
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
"integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
@@ -2697,7 +2697,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.8",
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},

View File

@@ -2,8 +2,10 @@ import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-d
import { GoogleOAuthProvider } from '@react-oauth/google';
import { AuthProvider } from './contexts/AuthContext';
import Login from './components/Login';
import TestList from './components/TestList';
import CodeChallenge from "./components/CodeChallenge.jsx";
import Header from './components/Header';
import Footer from './components/Footer';
import ProtectedRoute from './components/ProtectedRoute';
import "./index.css";
@@ -19,23 +21,24 @@ function App() {
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/editor"
path="/tests"
element={
<ProtectedRoute>
{/* <Header /> */}
<CodeChallenge />
<footer className="footer-bar fixed bottom-0 left-0 right-0 border-t border-slate-200/40 dark:border-gray-800/20 bg-black">
<div className="flex items-center justify-center h-7">
<span className="text-xs text-slate-400 dark:text-gray-400 flex items-center">
Copyright © 2025. Made with
by Ishika and Arnab.
</span>
</div>
</footer>
<TestList />
<Footer />
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/editor" replace />} />
<Route
path="/editor"
element={
<ProtectedRoute>
<CodeChallenge />
<Footer />
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/tests" replace />} />
</Routes>
</div>
</Router>

View File

@@ -1,8 +1,12 @@
import React, { useState, useEffect, useRef } from 'react';
import Editor from "@monaco-editor/react";
import { Play, Send } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { useNavigate } from 'react-router-dom';
const CodeChallenge = () => {
const [test, setTest] = useState(null);
const [questions, setQuestions] = useState([]);
const [activeQuestion, setActiveQuestion] = useState("Q.1");
const [language, setLanguage] = useState("JavaScript");
const [code, setCode] = useState("");
@@ -11,7 +15,85 @@ const CodeChallenge = () => {
const [autoSelected, setAutoSelected] = useState(true);
const [activeSocket, setActiveSocket] = useState(null);
const [submissionId, setSubmissionId] = useState(null);
const [timeRemaining, setTimeRemaining] = useState(null);
const socketRef = useRef(null);
const { token } = useAuth();
const navigate = useNavigate();
// Load test data from localStorage
useEffect(() => {
const testData = localStorage.getItem('currentTest');
if (testData) {
try {
const parsedData = JSON.parse(testData);
setTest(parsedData);
if (parsedData.questions && parsedData.questions.length > 0) {
setQuestions(parsedData.questions);
// Set initial code from first question
const firstQuestion = parsedData.questions[0];
setLanguage(firstQuestion.programming_language || 'JavaScript');
setCode(firstQuestion.code_template || getDefaultTemplate(firstQuestion.programming_language || 'JavaScript'));
}
} catch (error) {
console.error('Error loading test data:', error);
}
} else {
// No test data, redirect back to tests
navigate('/tests');
}
}, [navigate]);
// Timer countdown
useEffect(() => {
if (!test || !test.end_time) return;
const updateTimer = () => {
const now = new Date();
const endTime = new Date(test.end_time);
const diff = endTime - now;
if (diff <= 0) {
setTimeRemaining('Time Up!');
// Optionally auto-submit or redirect
return;
}
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
setTimeRemaining(`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`);
};
updateTimer(); // Initial call
const timerId = setInterval(updateTimer, 1000);
return () => clearInterval(timerId);
}, [test]);
// Get default code template for a language
const getDefaultTemplate = (lang) => {
const templates = {
'JavaScript': '// Write your code here\n',
'Python': '# Write your code here\n',
'Java': 'public class Solution {\n public static void main(String[] args) {\n // Write your code here\n }\n}',
'C++': '#include <iostream>\nusing namespace std;\n\nint main() {\n // Write your code here\n return 0;\n}',
'C': '#include <stdio.h>\n\nint main() {\n // Write your code here\n return 0;\n}'
};
return templates[lang] || '// Write your code here\n';
};
// Map question index to Q.1, Q.2, Q.3 format
const getQuestionIndex = (questionKey) => {
const index = parseInt(questionKey.replace('Q.', '')) - 1;
return index;
};
// Get current question based on activeQuestion
const getCurrentQuestion = () => {
const index = getQuestionIndex(activeQuestion);
return questions[index] || null;
};
// Map frontend language names to backend language identifiers
const getLanguageIdentifier = (uiLanguage) => {
@@ -179,10 +261,34 @@ int main() {
// Set initial code based on active problem
useEffect(() => {
if (problems[activeQuestion]) {
const currentQuestion = getCurrentQuestion();
if (currentQuestion) {
// Check if there's a saved submission for this question
const savedSubmission = localStorage.getItem(`submission_${test?.id}_${currentQuestion.id}`);
if (savedSubmission) {
try {
const submission = JSON.parse(savedSubmission);
setCode(submission.code);
setLanguage(currentQuestion.programming_language || 'JavaScript');
setTerminalOutput([
{ type: 'system', content: `Loaded your previous submission from ${new Date(submission.timestamp).toLocaleString()}` }
]);
} catch (error) {
console.error('Error loading saved submission:', error);
setLanguage(currentQuestion.programming_language || 'JavaScript');
setCode(currentQuestion.code_template || getDefaultTemplate(currentQuestion.programming_language || 'JavaScript'));
}
} else {
setLanguage(currentQuestion.programming_language || 'JavaScript');
setCode(currentQuestion.code_template || getDefaultTemplate(currentQuestion.programming_language || 'JavaScript'));
setTerminalOutput([]);
}
} else if (problems[activeQuestion]) {
// Fallback to example problems if no real test data
setCode(getStarterCode(problems[activeQuestion], language));
setTerminalOutput([]);
}
}, [activeQuestion, language]);
}, [activeQuestion]);
// Cleanup WebSocket connection on unmount
useEffect(() => {
@@ -402,8 +508,9 @@ int main() {
}
setIsRunning(true);
const currentQuestion = getCurrentQuestion();
setTerminalOutput([
{ type: 'system', content: `Running ${problems[activeQuestion].id}...` }
{ type: 'system', content: `Running ${currentQuestion?.title || problems[activeQuestion]?.id || 'code'}...` }
]);
try {
@@ -445,34 +552,61 @@ int main() {
// Handle code submission
const submitCode = async () => {
setIsRunning(true);
const currentQuestion = getCurrentQuestion();
setTerminalOutput([
{ type: 'system', content: `Submitting solution for ${problems[activeQuestion].id}...` }
{ type: 'system', content: `Submitting solution for ${currentQuestion?.title || problems[activeQuestion]?.id || 'problem'}...` }
]);
try {
// Submit code to the backend
const response = await fetch('http://localhost:8080/api/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
code: code,
language: getLanguageIdentifier(language),
input: '',
problemId: problems[activeQuestion].id
}),
});
// If we have real test data, submit to faculty backend
if (currentQuestion && test) {
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:5000';
const response = await fetch(`${apiUrl}/api/students/submissions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
testId: test.id,
answers: [{
questionId: currentQuestion.id,
submittedAnswer: code
}]
}),
});
if (!response.ok) {
throw new Error(`Error: ${response.statusText}`);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
setTerminalOutput(prev => [
...prev,
{ type: 'system', content: '✓ Submission successful!' },
{ type: 'output', content: `Your answer has been submitted for Question ${getQuestionIndex(activeQuestion) + 1}` },
{ type: 'output', content: `Test: ${test.title}` },
{ type: 'system', content: 'You can modify and resubmit your answer anytime before the test ends.' }
]);
// Store submission locally
localStorage.setItem(`submission_${test.id}_${currentQuestion.id}`, JSON.stringify({
code,
timestamp: new Date().toISOString()
}));
} else {
throw new Error(data.message || 'Submission failed');
}
setIsRunning(false);
return;
}
const data = await response.json();
setSubmissionId(data.id);
// Connect to WebSocket for real-time updates
connectToWebSocket(data.id);
// If no test data, show error
throw new Error('No test data available. Please start a test from the test list.');
} catch (error) {
console.error('Error submitting solution:', error);
@@ -486,6 +620,36 @@ int main() {
// Render the current problem
const renderProblem = () => {
const currentQuestion = getCurrentQuestion();
// If we have real test question, use it
if (currentQuestion) {
return (
<div className="problem-container">
<h1>{currentQuestion.title || `Question ${getQuestionIndex(activeQuestion) + 1}`}</h1>
<div className="problem-description">
<p>{currentQuestion.question_text || currentQuestion.description}</p>
{currentQuestion.constraints && <p><strong>Constraints:</strong> {currentQuestion.constraints}</p>}
{currentQuestion.marks && <p><strong>Points:</strong> {currentQuestion.marks}</p>}
</div>
{currentQuestion.test_cases && currentQuestion.test_cases.length > 0 && (
<div className="test-cases">
<h3>Example Test Cases:</h3>
{currentQuestion.test_cases.slice(0, 2).map((testCase, idx) => (
<div key={idx} className="test-case">
<p><strong>Input:</strong> {testCase.input}</p>
<p><strong>Expected Output:</strong> {testCase.expected_output}</p>
</div>
))}
</div>
)}
</div>
);
}
// Fallback to example problems
const problem = problems[activeQuestion];
if (!problem) return null;
@@ -518,8 +682,23 @@ int main() {
return (
<div className="code-challenge-container">
<header className="code-challenge-header">
<h1>OnScreen Test</h1>
<button className="sign-in-btn">Sign In</button>
<h1>{test?.title || 'OnScreen Test'}</h1>
{timeRemaining && (
<div className="timer-display" style={{
fontSize: '1.2rem',
fontWeight: 'bold',
color: timeRemaining === 'Time Up!' ? '#ef4444' : '#10b981',
display: 'flex',
alignItems: 'center',
gap: '0.5rem'
}}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
{timeRemaining}
</div>
)}
</header>
{/* <div className="code-challenge-problem-nav">
@@ -528,24 +707,19 @@ int main() {
<div className="code-challenge-main">
<div className="problem-tabs">
<button
className={activeQuestion === "Q.1" ? "tab-active" : ""}
onClick={() => setActiveQuestion("Q.1")}
>
Q.1
</button>
<button
className={activeQuestion === "Q.2" ? "tab-active" : ""}
onClick={() => setActiveQuestion("Q.2")}
>
Q.2
</button>
<button
className={activeQuestion === "Q.3" ? "tab-active" : ""}
onClick={() => setActiveQuestion("Q.3")}
>
Q.3
</button>
{(questions.length > 0 ? questions : [1, 2, 3]).map((q, idx) => {
const questionKey = `Q.${idx + 1}`;
return (
<button
key={questionKey}
className={activeQuestion === questionKey ? "tab-active" : ""}
onClick={() => setActiveQuestion(questionKey)}
disabled={questions.length > 0 && idx >= questions.length}
>
{questionKey}
</button>
);
})}
</div>
<div className="problem-content">

View File

@@ -0,0 +1,14 @@
const Footer = () => {
return (
<footer className="footer-bar fixed bottom-0 left-0 right-0 border-t border-slate-200/40 dark:border-gray-800/20 bg-black">
<div className="flex items-center justify-center h-7">
<span className="text-xs text-slate-400 dark:text-gray-400 flex items-center">
Copyright © 2025. Made with
by Ishika and Arnab.
</span>
</div>
</footer>
);
};
export default Footer;

View File

@@ -14,7 +14,7 @@ const Login = () => {
useEffect(() => {
const token = localStorage.getItem('monaco_token');
if (token) {
navigate('/editor');
navigate('/tests');
}
}, [navigate]);
@@ -39,7 +39,7 @@ const Login = () => {
const success = await login(userInfo.email, credentialResponse.credential, userInfo);
if (success) {
navigate('/editor');
navigate('/tests');
} else {
throw new Error('Authentication failed');
}

View File

@@ -0,0 +1,688 @@
/* TestList.css */
.test-list-container {
min-height: 100vh;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
.dark .test-list-container {
background: linear-gradient(135deg, #1a1a1a 0%, #2d3748 100%);
}
.test-list-header {
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border-bottom: 1px solid #e5e7eb;
padding: 1.5rem 0;
}
.dark .test-list-header {
background: #1f2937;
border-bottom-color: #374151;
}
.header-content {
max-width: 1280px;
margin: 0 auto;
padding: 0 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
@media (min-width: 768px) {
.header-content {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
.header-title {
font-size: 2rem;
font-weight: 700;
color: #111827;
margin: 0 0 0.5rem 0;
}
.dark .header-title {
color: white;
}
.header-subtitle {
color: #6b7280;
margin: 0;
}
.dark .header-subtitle {
color: #9ca3af;
}
.filter-tabs {
display: flex;
gap: 0.5rem;
background: #f3f4f6;
padding: 0.25rem;
border-radius: 0.5rem;
}
.dark .filter-tabs {
background: #374151;
}
.filter-tab {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
border: none;
background: transparent;
color: #6b7280;
cursor: pointer;
transition: all 0.2s;
}
.filter-tab:hover {
color: #111827;
}
.dark .filter-tab {
color: #9ca3af;
}
.dark .filter-tab:hover {
color: white;
}
.filter-tab.active {
background: white;
color: #2563eb;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.dark .filter-tab.active {
background: #4b5563;
color: #60a5fa;
}
.test-list-content {
max-width: 1280px;
margin: 0 auto;
padding: 2rem 1rem;
}
.tests-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
}
@media (min-width: 768px) {
.tests-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.tests-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.test-card {
background: white;
border-radius: 0.75rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
border: 1px solid #e5e7eb;
transition: all 0.3s;
}
.test-card:hover {
box-shadow: 0 10px 15px rgba(0, 0, 0, 0.1);
border-color: #93c5fd;
transform: translateY(-2px);
}
.dark .test-card {
background: #1f2937;
border-color: #374151;
}
.dark .test-card:hover {
border-color: #3b82f6;
}
.test-card-stripe {
height: 0.5rem;
background: linear-gradient(90deg, #3b82f6 0%, #8b5cf6 100%);
}
.test-card-content {
padding: 1.5rem;
}
.test-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 1rem;
}
.test-title {
font-size: 1.25rem;
font-weight: 700;
color: #111827;
margin: 0;
transition: color 0.2s;
}
.test-card:hover .test-title {
color: #2563eb;
}
.dark .test-title {
color: white;
}
.dark .test-card:hover .test-title {
color: #60a5fa;
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.25rem;
white-space: nowrap;
}
.status-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
}
.status-active .status-dot {
background: #10b981;
animation: pulse 2s infinite;
}
.status-active {
background: #d1fae5;
color: #065f46;
}
.dark .status-active {
background: rgba(16, 185, 129, 0.2);
color: #6ee7b7;
}
.status-upcoming {
background: #dbeafe;
color: #1e40af;
}
.status-upcoming .status-dot {
background: #3b82f6;
}
.dark .status-upcoming {
background: rgba(59, 130, 246, 0.2);
color: #93c5fd;
}
.status-closed {
background: #f3f4f6;
color: #6b7280;
}
.status-closed .status-dot {
background: #9ca3af;
}
.dark .status-closed {
background: #374151;
color: #9ca3af;
}
.test-description {
color: #6b7280;
margin-bottom: 1.5rem;
min-height: 3rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.dark .test-description {
color: #9ca3af;
}
.test-details {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.test-detail {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: #374151;
}
.dark .test-detail {
color: #d1d5db;
}
.test-detail-icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
}
.icon-blue {
color: #3b82f6;
}
.icon-purple {
color: #8b5cf6;
}
.icon-amber {
color: #f59e0b;
}
.test-button {
width: 100%;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
font-weight: 600;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: all 0.2s;
}
.test-button-active {
background: linear-gradient(90deg, #3b82f6 0%, #8b5cf6 100%);
color: white;
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.3);
}
.test-button-active:hover {
transform: translateY(-1px);
box-shadow: 0 6px 8px rgba(59, 130, 246, 0.4);
}
.test-button-disabled {
background: #e5e7eb;
color: #9ca3af;
cursor: not-allowed;
}
.dark .test-button-disabled {
background: #374151;
color: #6b7280;
}
.loading-container,
.error-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
.dark .loading-container,
.dark .error-container {
background: linear-gradient(135deg, #1a1a1a 0%, #2d3748 100%);
}
.loading-content {
text-align: center;
}
.spinner {
display: inline-block;
width: 3rem;
height: 3rem;
border: 0.25rem solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
.loading-text {
color: #6b7280;
font-size: 1.125rem;
}
.dark .loading-text {
color: #9ca3af;
}
.error-box {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 0.5rem;
padding: 1.5rem;
max-width: 28rem;
}
.dark .error-box {
background: rgba(220, 38, 38, 0.1);
border-color: #991b1b;
}
.error-content {
display: flex;
align-items: center;
gap: 0.75rem;
}
.error-icon {
width: 1.5rem;
height: 1.5rem;
color: #dc2626;
flex-shrink: 0;
}
.error-title {
font-weight: 600;
color: #991b1b;
margin: 0 0 0.25rem 0;
}
.dark .error-title {
color: #fca5a5;
}
.error-message {
color: #dc2626;
margin: 0;
}
.dark .error-message {
color: #fca5a5;
}
.empty-state {
text-align: center;
padding: 4rem 1rem;
}
.empty-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 4rem;
height: 4rem;
border-radius: 50%;
background: #e5e7eb;
margin-bottom: 1rem;
}
.dark .empty-icon {
background: #374151;
}
.empty-icon svg {
width: 2rem;
height: 2rem;
color: #9ca3af;
}
.empty-title {
font-size: 1.125rem;
font-weight: 600;
color: #111827;
margin: 0 0 0.5rem 0;
}
.dark .empty-title {
color: white;
}
.empty-message {
color: #6b7280;
margin: 0;
}
.dark .empty-message {
color: #9ca3af;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 1000;
animation: fadeIn 0.3s;
}
.modal-content {
background: white;
border-radius: 1rem;
box-shadow: 0 20px 25px rgba(0, 0, 0, 0.3);
width: 100%;
max-width: 28rem;
animation: slideUp 0.3s;
}
.dark .modal-content {
background: #1f2937;
}
.modal-header {
border-bottom: 1px solid #e5e7eb;
padding: 1.5rem;
}
.dark .modal-header {
border-bottom-color: #374151;
}
.modal-header-content {
display: flex;
align-items: center;
gap: 0.75rem;
}
.modal-icon {
width: 3rem;
height: 3rem;
border-radius: 50%;
background: #dbeafe;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.dark .modal-icon {
background: rgba(59, 130, 246, 0.2);
}
.modal-icon svg {
width: 1.5rem;
height: 1.5rem;
color: #3b82f6;
}
.dark .modal-icon svg {
color: #60a5fa;
}
.modal-title {
font-size: 1.25rem;
font-weight: 700;
color: #111827;
margin: 0;
}
.dark .modal-title {
color: white;
}
.modal-subtitle {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
}
.dark .modal-subtitle {
color: #9ca3af;
}
.modal-body {
padding: 1.5rem;
}
.modal-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
margin-bottom: 0.5rem;
}
.dark .modal-label {
color: #d1d5db;
}
.modal-input {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 1rem;
transition: all 0.2s;
box-sizing: border-box;
}
.modal-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.dark .modal-input {
background: #374151;
border-color: #4b5563;
color: white;
}
.dark .modal-input:focus {
border-color: #3b82f6;
}
.modal-footer {
border-top: 1px solid #e5e7eb;
padding: 1.5rem;
display: flex;
gap: 0.75rem;
}
.dark .modal-footer {
border-top-color: #374151;
}
.modal-button {
flex: 1;
padding: 0.625rem 1rem;
border-radius: 0.5rem;
font-weight: 500;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.modal-button-cancel {
background: #f3f4f6;
color: #374151;
}
.modal-button-cancel:hover {
background: #e5e7eb;
}
.dark .modal-button-cancel {
background: #374151;
color: #d1d5db;
}
.dark .modal-button-cancel:hover {
background: #4b5563;
}
.modal-button-submit {
background: linear-gradient(90deg, #3b82f6 0%, #8b5cf6 100%);
color: white;
}
.modal-button-submit:hover:not(:disabled) {
opacity: 0.9;
}
.modal-button-submit:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.dark .modal-button-submit:disabled {
background: #4b5563;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(1.25rem);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}

View File

@@ -0,0 +1,346 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import './TestList.css';
const TestList = () => {
const [tests, setTests] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [selectedTest, setSelectedTest] = useState(null);
const [password, setPassword] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
const navigate = useNavigate();
const { token } = useAuth();
useEffect(() => {
fetchTests();
}, []);
const fetchTests = async () => {
try {
console.log('Fetching tests with token:', token?.substring(0, 50) + '...');
const response = await fetch('http://localhost:5000/api/students/tests', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
if (response.status === 401) {
localStorage.removeItem('monaco_user');
localStorage.removeItem('monaco_token');
window.location.href = '/login';
return;
}
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
console.log('Tests received:', data.tests);
data.tests.forEach(test => {
console.log(`Test: ${test.title}, Status: ${test.status}, Start: ${test.start_time}, End: ${test.end_time}`);
});
setTests(data.tests);
} else {
setError(data.message || 'Failed to fetch tests');
}
} catch (error) {
setError('Failed to fetch tests');
console.error('Error:', error);
} finally {
setLoading(false);
}
};
const handleStartTest = async (test) => {
try {
const response = await fetch(`http://localhost:5000/api/students/tests/${test.id}/questions`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
const data = await response.json();
if (data.success) {
localStorage.setItem('currentTest', JSON.stringify({
id: test.id,
questions: data.questions,
currentQuestionIndex: 0
}));
navigate('/editor');
} else {
setError(data.message);
}
} catch (error) {
setError('Failed to start test');
console.error('Error:', error);
}
};
const handlePasswordSubmit = async () => {
if (!selectedTest || !password) return;
try {
const response = await fetch(`http://localhost:5000/api/students/tests/${selectedTest.id}/verify-password`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ password })
});
const data = await response.json();
if (data.success) {
setShowPasswordModal(false);
setPassword('');
handleStartTest(selectedTest);
} else {
setError('Invalid password');
}
} catch (error) {
setError('Failed to verify password');
console.error('Error:', error);
}
};
const handleTestClick = (test) => {
if (test.password_required) {
setSelectedTest(test);
setShowPasswordModal(true);
} else {
handleStartTest(test);
}
};
const filteredTests = tests.filter(test => {
if (filterStatus === 'all') return true;
return test.status.toLowerCase() === filterStatus.toLowerCase();
});
if (loading) {
return (
<div className="loading-container">
<div className="loading-content">
<div className="spinner"></div>
<p className="loading-text">Loading tests...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="error-container">
<div className="error-box">
<div className="error-content">
<svg className="error-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h3 className="error-title">Error</h3>
<p className="error-message">{error}</p>
</div>
</div>
</div>
</div>
);
}
return (
<div className="test-list-container">
{/* Header Section */}
<div className="test-list-header">
<div className="header-content">
<div>
<h1 className="header-title">
📝 Available Tests
</h1>
<p className="header-subtitle">
Select a test to start your coding challenge
</p>
</div>
{/* Filter Tabs */}
<div className="filter-tabs">
<button
onClick={() => setFilterStatus('all')}
className={`filter-tab ${filterStatus === 'all' ? 'active' : ''}`}
>
All Tests
</button>
<button
onClick={() => setFilterStatus('active')}
className={`filter-tab ${filterStatus === 'active' ? 'active' : ''}`}
>
Active
</button>
<button
onClick={() => setFilterStatus('upcoming')}
className={`filter-tab ${filterStatus === 'upcoming' ? 'active' : ''}`}
>
Upcoming
</button>
</div>
</div>
</div>
{/* Tests Grid */}
<div className="test-list-content">
{filteredTests.length === 0 ? (
<div className="empty-state">
<div className="empty-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 className="empty-title">No tests available</h3>
<p className="empty-message">Check back later for new tests</p>
</div>
) : (
<div className="tests-grid">
{filteredTests.map(test => (
<div key={test.id} className="test-card">
{/* Status Badge */}
<div className="test-card-stripe"></div>
<div className="test-card-content">
{/* Header */}
<div className="test-card-header">
<h2 className="test-title">
{test.title}
</h2>
<span className={`status-badge status-${test.status?.toLowerCase() || 'closed'}`}>
<span className="status-dot"></span>
{test.status}
</span>
</div>
{/* Description */}
<p className="test-description">
{test.description || 'No description available'}
</p>
{/* Test Details */}
<div className="test-details">
<div className="test-detail">
<svg className="test-detail-icon icon-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span><strong>{test.duration_minutes}</strong> minutes</span>
</div>
{test.total_questions && (
<div className="test-detail">
<svg className="test-detail-icon icon-purple" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<span><strong>{test.total_questions}</strong> questions</span>
</div>
)}
{test.password_required && (
<div className="test-detail icon-amber">
<svg className="test-detail-icon icon-amber" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span><strong>Password required</strong></span>
</div>
)}
</div>
{/* Action Button */}
<button
onClick={() => handleTestClick(test)}
disabled={test.status !== 'Active'}
className={`test-button ${
test.status === 'Active' ? 'test-button-active' : 'test-button-disabled'
}`}
>
{test.status === 'Active' ? (
<>
<span>Start Test</span>
<svg style={{width: '1.25rem', height: '1.25rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</>
) : (
<>
<svg style={{width: '1.25rem', height: '1.25rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span>Not Available</span>
</>
)}
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Password Modal */}
{showPasswordModal && (
<div className="modal-overlay">
<div className="modal-content">
{/* Modal Header */}
<div className="modal-header">
<div className="modal-header-content">
<div className="modal-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<h2 className="modal-title">Protected Test</h2>
<p className="modal-subtitle">Enter password to continue</p>
</div>
</div>
</div>
{/* Modal Body */}
<div className="modal-body">
<label className="modal-label">
Test Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handlePasswordSubmit()}
className="modal-input"
placeholder="Enter password"
autoFocus
/>
</div>
{/* Modal Footer */}
<div className="modal-footer">
<button
onClick={() => {
setShowPasswordModal(false);
setPassword('');
setError(null);
}}
className="modal-button modal-button-cancel"
>
Cancel
</button>
<button
onClick={handlePasswordSubmit}
disabled={!password}
className="modal-button modal-button-submit"
>
Submit
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default TestList;

View File

@@ -12,6 +12,7 @@ export const useAuth = () => {
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [token, setToken] = useState(null);
const [isLoading, setIsLoading] = useState(true);
// Check for existing session on app load
@@ -22,6 +23,7 @@ export const AuthProvider = ({ children }) => {
const savedToken = localStorage.getItem('monaco_token');
if (savedUser && savedToken) {
setUser(JSON.parse(savedUser));
setToken(savedToken);
}
} catch (error) {
console.error('Error checking auth status:', error);
@@ -36,54 +38,74 @@ export const AuthProvider = ({ children }) => {
}, []);
const login = async (email, googleToken, userInfo = null) => {
try {
// For Google OAuth login
if (googleToken && userInfo) {
const userData = {
id: userInfo.sub || Date.now(),
// For Google OAuth login
if (googleToken && userInfo) {
// Exchange Google token for our JWT
const response = await fetch('http://localhost:5000/api/students/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${googleToken}`
},
body: JSON.stringify({
email: email,
name: userInfo.name || email.split('@')[0],
picture: userInfo.picture || null,
loginTime: new Date().toISOString(),
googleToken: googleToken
};
name: userInfo.name || email.split('@')[0]
})
});
setUser(userData);
localStorage.setItem('monaco_user', JSON.stringify(userData));
localStorage.setItem('monaco_token', googleToken);
return true;
if (!response.ok) {
throw new Error(`Server error: ${response.status} ${response.statusText}`);
}
// Fallback for demo purposes (though we're moving to Google-only)
if (email && email.includes('@')) {
const userData = {
id: Date.now(),
email: email,
name: email.split('@')[0],
loginTime: new Date().toISOString()
};
setUser(userData);
localStorage.setItem('monaco_user', JSON.stringify(userData));
localStorage.setItem('monaco_token', 'demo_token');
return true;
const data = await response.json();
if (!data.success) {
throw new Error(data.message || 'Login failed');
}
return false;
} catch (error) {
console.error('Login error:', error);
return false;
const userData = {
id: userInfo.sub || Date.now(),
email: email,
name: userInfo.name || email.split('@')[0],
picture: userInfo.picture || null,
loginTime: new Date().toISOString(),
token: data.token // Store the JWT instead of Google token
};
setUser(userData);
localStorage.setItem('monaco_user', JSON.stringify(userData));
localStorage.setItem('monaco_token', userData.token);
setToken(userData.token);
return true;
}
// Fallback for demo purposes (though we're moving to Google-only)
if (email && email.includes('@')) {
const userData = {
id: Date.now(),
email: email,
name: email.split('@')[0],
loginTime: new Date().toISOString()
};
setUser(userData);
localStorage.setItem('monaco_user', JSON.stringify(userData));
localStorage.setItem('monaco_token', 'demo_token');
return true;
}
return false;
};
const logout = () => {
setUser(null);
setToken(null);
localStorage.removeItem('monaco_user');
localStorage.removeItem('monaco_token');
};
const value = {
user,
token,
login,
logout,
isAuthenticated: !!user,

View File

@@ -1225,6 +1225,8 @@ body {
padding: 8px 12px;
background-color: #252526;
border-bottom: 1px solid rgba(128, 128, 128, 0.35);
position: relative;
z-index: 200;
}
.editor-controls {
@@ -1259,6 +1261,8 @@ body {
.editor-actions {
display: flex;
gap: 8px;
position: relative;
z-index: 150;
}
.run-btn {
@@ -1673,3 +1677,18 @@ body {
footer {
z-index: 1000;
}
/* Test List Animations */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.animate-fadeIn { animation: fadeIn 0.3s ease-out; }
.animate-slideUp { animation: slideUp 0.3s ease-out; }
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }

View File

@@ -0,0 +1,51 @@
const API_URL = import.meta.env.VITE_FACULTY_API_URL || 'http://localhost:5000/api';
export const studentApi = {
async getTests() {
const response = await fetch(`${API_URL}/students/tests`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('monaco_token')}`
}
});
return await response.json();
},
async getTestQuestions(testId) {
const response = await fetch(`${API_URL}/students/tests/${testId}/questions`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('monaco_token')}`
}
});
return await response.json();
},
async verifyTestPassword(testId, password) {
const response = await fetch(`${API_URL}/students/tests/${testId}/verify-password`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('monaco_token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ password })
});
return await response.json();
},
async submitAnswer(testId, questionId, code) {
const response = await fetch(`${API_URL}/students/submissions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('monaco_token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
testId,
answers: [{
questionId,
submittedAnswer: code
}]
})
});
return await response.json();
}
};