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"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.3",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
"integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
|
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2697,7 +2697,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.8",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.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 { GoogleOAuthProvider } from '@react-oauth/google';
|
||||||
import { AuthProvider } from './contexts/AuthContext';
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
import Login from './components/Login';
|
import Login from './components/Login';
|
||||||
|
import TestList from './components/TestList';
|
||||||
import CodeChallenge from "./components/CodeChallenge.jsx";
|
import CodeChallenge from "./components/CodeChallenge.jsx";
|
||||||
import Header from './components/Header';
|
import Header from './components/Header';
|
||||||
|
import Footer from './components/Footer';
|
||||||
import ProtectedRoute from './components/ProtectedRoute';
|
import ProtectedRoute from './components/ProtectedRoute';
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
@@ -19,23 +21,24 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route
|
<Route
|
||||||
path="/editor"
|
path="/tests"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
{/* <Header /> */}
|
<TestList />
|
||||||
<CodeChallenge />
|
<Footer />
|
||||||
<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>
|
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/" element={<Navigate to="/editor" replace />} />
|
<Route
|
||||||
|
path="/editor"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<CodeChallenge />
|
||||||
|
<Footer />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/" element={<Navigate to="/tests" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import Editor from "@monaco-editor/react";
|
import Editor from "@monaco-editor/react";
|
||||||
import { Play, Send } from 'lucide-react';
|
import { Play, Send } from 'lucide-react';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
const CodeChallenge = () => {
|
const CodeChallenge = () => {
|
||||||
|
const [test, setTest] = useState(null);
|
||||||
|
const [questions, setQuestions] = useState([]);
|
||||||
const [activeQuestion, setActiveQuestion] = useState("Q.1");
|
const [activeQuestion, setActiveQuestion] = useState("Q.1");
|
||||||
const [language, setLanguage] = useState("JavaScript");
|
const [language, setLanguage] = useState("JavaScript");
|
||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
@@ -11,7 +15,85 @@ const CodeChallenge = () => {
|
|||||||
const [autoSelected, setAutoSelected] = useState(true);
|
const [autoSelected, setAutoSelected] = useState(true);
|
||||||
const [activeSocket, setActiveSocket] = useState(null);
|
const [activeSocket, setActiveSocket] = useState(null);
|
||||||
const [submissionId, setSubmissionId] = useState(null);
|
const [submissionId, setSubmissionId] = useState(null);
|
||||||
|
const [timeRemaining, setTimeRemaining] = useState(null);
|
||||||
const socketRef = useRef(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
|
// Map frontend language names to backend language identifiers
|
||||||
const getLanguageIdentifier = (uiLanguage) => {
|
const getLanguageIdentifier = (uiLanguage) => {
|
||||||
@@ -179,10 +261,34 @@ int main() {
|
|||||||
|
|
||||||
// Set initial code based on active problem
|
// Set initial code based on active problem
|
||||||
useEffect(() => {
|
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));
|
setCode(getStarterCode(problems[activeQuestion], language));
|
||||||
|
setTerminalOutput([]);
|
||||||
}
|
}
|
||||||
}, [activeQuestion, language]);
|
}, [activeQuestion]);
|
||||||
|
|
||||||
// Cleanup WebSocket connection on unmount
|
// Cleanup WebSocket connection on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -402,8 +508,9 @@ int main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsRunning(true);
|
setIsRunning(true);
|
||||||
|
const currentQuestion = getCurrentQuestion();
|
||||||
setTerminalOutput([
|
setTerminalOutput([
|
||||||
{ type: 'system', content: `Running ${problems[activeQuestion].id}...` }
|
{ type: 'system', content: `Running ${currentQuestion?.title || problems[activeQuestion]?.id || 'code'}...` }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -445,34 +552,61 @@ int main() {
|
|||||||
// Handle code submission
|
// Handle code submission
|
||||||
const submitCode = async () => {
|
const submitCode = async () => {
|
||||||
setIsRunning(true);
|
setIsRunning(true);
|
||||||
|
const currentQuestion = getCurrentQuestion();
|
||||||
setTerminalOutput([
|
setTerminalOutput([
|
||||||
{ type: 'system', content: `Submitting solution for ${problems[activeQuestion].id}...` }
|
{ type: 'system', content: `Submitting solution for ${currentQuestion?.title || problems[activeQuestion]?.id || 'problem'}...` }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Submit code to the backend
|
// If we have real test data, submit to faculty backend
|
||||||
const response = await fetch('http://localhost:8080/api/submit', {
|
if (currentQuestion && test) {
|
||||||
method: 'POST',
|
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:5000';
|
||||||
headers: {
|
const response = await fetch(`${apiUrl}/api/students/submissions`, {
|
||||||
'Content-Type': 'application/json',
|
method: 'POST',
|
||||||
},
|
headers: {
|
||||||
body: JSON.stringify({
|
'Authorization': `Bearer ${token}`,
|
||||||
code: code,
|
'Content-Type': 'application/json',
|
||||||
language: getLanguageIdentifier(language),
|
},
|
||||||
input: '',
|
body: JSON.stringify({
|
||||||
problemId: problems[activeQuestion].id
|
testId: test.id,
|
||||||
}),
|
answers: [{
|
||||||
});
|
questionId: currentQuestion.id,
|
||||||
|
submittedAnswer: code
|
||||||
|
}]
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Error: ${response.statusText}`);
|
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();
|
// If no test data, show error
|
||||||
setSubmissionId(data.id);
|
throw new Error('No test data available. Please start a test from the test list.');
|
||||||
|
|
||||||
// Connect to WebSocket for real-time updates
|
|
||||||
connectToWebSocket(data.id);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error submitting solution:', error);
|
console.error('Error submitting solution:', error);
|
||||||
@@ -486,6 +620,36 @@ int main() {
|
|||||||
|
|
||||||
// Render the current problem
|
// Render the current problem
|
||||||
const renderProblem = () => {
|
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];
|
const problem = problems[activeQuestion];
|
||||||
if (!problem) return null;
|
if (!problem) return null;
|
||||||
|
|
||||||
@@ -518,8 +682,23 @@ int main() {
|
|||||||
return (
|
return (
|
||||||
<div className="code-challenge-container">
|
<div className="code-challenge-container">
|
||||||
<header className="code-challenge-header">
|
<header className="code-challenge-header">
|
||||||
<h1>OnScreen Test</h1>
|
<h1>{test?.title || 'OnScreen Test'}</h1>
|
||||||
<button className="sign-in-btn">Sign In</button>
|
{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>
|
</header>
|
||||||
|
|
||||||
{/* <div className="code-challenge-problem-nav">
|
{/* <div className="code-challenge-problem-nav">
|
||||||
@@ -528,24 +707,19 @@ int main() {
|
|||||||
|
|
||||||
<div className="code-challenge-main">
|
<div className="code-challenge-main">
|
||||||
<div className="problem-tabs">
|
<div className="problem-tabs">
|
||||||
<button
|
{(questions.length > 0 ? questions : [1, 2, 3]).map((q, idx) => {
|
||||||
className={activeQuestion === "Q.1" ? "tab-active" : ""}
|
const questionKey = `Q.${idx + 1}`;
|
||||||
onClick={() => setActiveQuestion("Q.1")}
|
return (
|
||||||
>
|
<button
|
||||||
Q.1
|
key={questionKey}
|
||||||
</button>
|
className={activeQuestion === questionKey ? "tab-active" : ""}
|
||||||
<button
|
onClick={() => setActiveQuestion(questionKey)}
|
||||||
className={activeQuestion === "Q.2" ? "tab-active" : ""}
|
disabled={questions.length > 0 && idx >= questions.length}
|
||||||
onClick={() => setActiveQuestion("Q.2")}
|
>
|
||||||
>
|
{questionKey}
|
||||||
Q.2
|
</button>
|
||||||
</button>
|
);
|
||||||
<button
|
})}
|
||||||
className={activeQuestion === "Q.3" ? "tab-active" : ""}
|
|
||||||
onClick={() => setActiveQuestion("Q.3")}
|
|
||||||
>
|
|
||||||
Q.3
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="problem-content">
|
<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(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem('monaco_token');
|
const token = localStorage.getItem('monaco_token');
|
||||||
if (token) {
|
if (token) {
|
||||||
navigate('/editor');
|
navigate('/tests');
|
||||||
}
|
}
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ const Login = () => {
|
|||||||
const success = await login(userInfo.email, credentialResponse.credential, userInfo);
|
const success = await login(userInfo.email, credentialResponse.credential, userInfo);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
navigate('/editor');
|
navigate('/tests');
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Authentication failed');
|
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 }) => {
|
export const AuthProvider = ({ children }) => {
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
|
const [token, setToken] = useState(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// Check for existing session on app load
|
// Check for existing session on app load
|
||||||
@@ -22,6 +23,7 @@ export const AuthProvider = ({ children }) => {
|
|||||||
const savedToken = localStorage.getItem('monaco_token');
|
const savedToken = localStorage.getItem('monaco_token');
|
||||||
if (savedUser && savedToken) {
|
if (savedUser && savedToken) {
|
||||||
setUser(JSON.parse(savedUser));
|
setUser(JSON.parse(savedUser));
|
||||||
|
setToken(savedToken);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking auth status:', error);
|
console.error('Error checking auth status:', error);
|
||||||
@@ -36,54 +38,74 @@ export const AuthProvider = ({ children }) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = async (email, googleToken, userInfo = null) => {
|
const login = async (email, googleToken, userInfo = null) => {
|
||||||
try {
|
// For Google OAuth login
|
||||||
// For Google OAuth login
|
if (googleToken && userInfo) {
|
||||||
if (googleToken && userInfo) {
|
// Exchange Google token for our JWT
|
||||||
const userData = {
|
const response = await fetch('http://localhost:5000/api/students/login', {
|
||||||
id: userInfo.sub || Date.now(),
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${googleToken}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
email: email,
|
email: email,
|
||||||
name: userInfo.name || email.split('@')[0],
|
name: userInfo.name || email.split('@')[0]
|
||||||
picture: userInfo.picture || null,
|
})
|
||||||
loginTime: new Date().toISOString(),
|
});
|
||||||
googleToken: googleToken
|
|
||||||
};
|
|
||||||
|
|
||||||
setUser(userData);
|
if (!response.ok) {
|
||||||
localStorage.setItem('monaco_user', JSON.stringify(userData));
|
throw new Error(`Server error: ${response.status} ${response.statusText}`);
|
||||||
localStorage.setItem('monaco_token', googleToken);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback for demo purposes (though we're moving to Google-only)
|
const data = await response.json();
|
||||||
if (email && email.includes('@')) {
|
if (!data.success) {
|
||||||
const userData = {
|
throw new Error(data.message || 'Login failed');
|
||||||
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 userData = {
|
||||||
} catch (error) {
|
id: userInfo.sub || Date.now(),
|
||||||
console.error('Login error:', error);
|
email: email,
|
||||||
return false;
|
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 = () => {
|
const logout = () => {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
setToken(null);
|
||||||
localStorage.removeItem('monaco_user');
|
localStorage.removeItem('monaco_user');
|
||||||
localStorage.removeItem('monaco_token');
|
localStorage.removeItem('monaco_token');
|
||||||
};
|
};
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
user,
|
user,
|
||||||
|
token,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
isAuthenticated: !!user,
|
isAuthenticated: !!user,
|
||||||
|
|||||||
@@ -1225,6 +1225,8 @@ body {
|
|||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
background-color: #252526;
|
background-color: #252526;
|
||||||
border-bottom: 1px solid rgba(128, 128, 128, 0.35);
|
border-bottom: 1px solid rgba(128, 128, 128, 0.35);
|
||||||
|
position: relative;
|
||||||
|
z-index: 200;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-controls {
|
.editor-controls {
|
||||||
@@ -1259,6 +1261,8 @@ body {
|
|||||||
.editor-actions {
|
.editor-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 150;
|
||||||
}
|
}
|
||||||
|
|
||||||
.run-btn {
|
.run-btn {
|
||||||
@@ -1673,3 +1677,18 @@ body {
|
|||||||
footer {
|
footer {
|
||||||
z-index: 1000;
|
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