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:
@@ -1 +1,2 @@
|
||||
VITE_API_URL="http://localhost:8080"
|
||||
VITE_API_URL="http://localhost:8080"
|
||||
VITE_FACULTY_API_URL="http://localhost:5000/api"
|
||||
8
Frontend/package-lock.json
generated
8
Frontend/package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (!response.ok) {
|
||||
throw new Error(`Error: ${response.statusText}`);
|
||||
// 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) {
|
||||
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">
|
||||
|
||||
14
Frontend/src/components/Footer.jsx
Normal file
14
Frontend/src/components/Footer.jsx
Normal 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;
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
688
Frontend/src/components/TestList.css
Normal file
688
Frontend/src/components/TestList.css
Normal 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;
|
||||
}
|
||||
}
|
||||
346
Frontend/src/components/TestList.jsx
Normal file
346
Frontend/src/components/TestList.jsx
Normal 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;
|
||||
@@ -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
|
||||
};
|
||||
|
||||
setUser(userData);
|
||||
localStorage.setItem('monaco_user', JSON.stringify(userData));
|
||||
localStorage.setItem('monaco_token', googleToken);
|
||||
return true;
|
||||
name: userInfo.name || email.split('@')[0]
|
||||
})
|
||||
});
|
||||
|
||||
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,
|
||||
|
||||
@@ -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 {
|
||||
@@ -1672,4 +1676,19 @@ body {
|
||||
/* Make sure the footer appears on top of other elements */
|
||||
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; }
|
||||
|
||||
51
Frontend/src/utils/studentApi.js
Normal file
51
Frontend/src/utils/studentApi.js
Normal 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();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user