mirror of
https://github.com/Kizuren/QuizConnect.git
synced 2025-12-21 21:16:14 +01:00
Initial commit
This commit is contained in:
commit
ea82926500
58 changed files with 9323 additions and 0 deletions
30
src/App.tsx
Normal file
30
src/App.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Login } from './pages/Login';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { AdminDashboard } from './pages/AdminDashboard';
|
||||
import { Quiz } from './pages/Quiz';
|
||||
import { useAuthStore } from './store/auth';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function App() {
|
||||
const { token, isAdmin } = useAuthStore();
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={!token ? <Login /> : <Navigate to={isAdmin ? "/admin" : "/dashboard"} />} />
|
||||
<Route path="/dashboard" element={token && !isAdmin ? <Dashboard /> : <Navigate to={isAdmin ? "/admin" : "/login"} />} />
|
||||
<Route path="/admin" element={token && isAdmin ? <AdminDashboard /> : <Navigate to="/login" />} />
|
||||
<Route path="/quiz/:questionSetId" element={token && !isAdmin ? <Quiz /> : <Navigate to="/login" />} />
|
||||
<Route path="*" element={<Navigate to={token ? (isAdmin ? "/admin" : "/dashboard") : "/login"} />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
36
src/components/Button.tsx
Normal file
36
src/components/Button.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
'rounded-lg font-medium transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900',
|
||||
{
|
||||
'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500 text-white': variant === 'primary',
|
||||
'bg-gray-700 hover:bg-gray-600 focus:ring-gray-500 text-white': variant === 'secondary',
|
||||
'bg-red-600 hover:bg-red-700 focus:ring-red-500 text-white': variant === 'danger',
|
||||
'px-3 py-1.5 text-sm': size === 'sm',
|
||||
'px-4 py-2 text-base': size === 'md',
|
||||
'px-6 py-3 text-lg': size === 'lg',
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
56
src/components/CodeInput.tsx
Normal file
56
src/components/CodeInput.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import React, { useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface CodeInputProps {
|
||||
length?: number;
|
||||
onComplete: (code: string) => void;
|
||||
}
|
||||
|
||||
export const CodeInput: React.FC<CodeInputProps> = ({ length = 4, onComplete }) => {
|
||||
const [code, setCode] = useState<string[]>(Array(length).fill(''));
|
||||
const inputRefs = React.useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
const handleChange = (index: number, value: string) => {
|
||||
if (value.length > 1) return;
|
||||
|
||||
const newCode = [...code];
|
||||
newCode[index] = value;
|
||||
setCode(newCode);
|
||||
|
||||
if (value && index < length - 1) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
|
||||
if (newCode.every(digit => digit !== '')) {
|
||||
onComplete(newCode.join(''));
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Backspace' && !code[index] && index > 0) {
|
||||
inputRefs.current[index - 1]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{Array.from({ length }).map((_, index) => (
|
||||
<input
|
||||
key={index}
|
||||
ref={el => inputRefs.current[index] = el}
|
||||
type="text"
|
||||
maxLength={1}
|
||||
className={clsx(
|
||||
"w-14 h-14 text-center text-2xl rounded-lg",
|
||||
"bg-gray-800 border-2 border-gray-700",
|
||||
"focus:border-blue-500 focus:outline-none",
|
||||
"text-white placeholder-gray-500"
|
||||
)}
|
||||
value={code[index]}
|
||||
onChange={e => handleChange(index, e.target.value)}
|
||||
onKeyDown={e => handleKeyDown(index, e)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
76
src/components/Layout.tsx
Normal file
76
src/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { LogOut } from 'lucide-react';
|
||||
import { Button } from './Button';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { checkUserResetState } from '../lib/api';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
const navigate = useNavigate();
|
||||
const { logout, isAdmin } = useAuthStore();
|
||||
const pollingIntervalRef = useRef<number | null>(null);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
// Check if the user has been reset by an admin
|
||||
useEffect(() => {
|
||||
if (!isAdmin) {
|
||||
const checkResetState = async () => {
|
||||
try {
|
||||
const resetState = await checkUserResetState();
|
||||
if (resetState === true) {
|
||||
console.log('User has been reset by admin. Logging out...');
|
||||
logout();
|
||||
navigate('/login', {
|
||||
state: {
|
||||
message: 'Your account has been reset by an administrator. Please log in again.'
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking reset state:', error);
|
||||
}
|
||||
};
|
||||
|
||||
checkResetState();
|
||||
pollingIntervalRef.current = window.setInterval(checkResetState, 5000);
|
||||
|
||||
return () => {
|
||||
if (pollingIntervalRef.current !== null) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [isAdmin, logout, navigate]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900">
|
||||
<header className="bg-gray-800 border-b border-gray-700">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
QuizConnect {isAdmin && <span className="text-blue-500">(Admin)</span>}
|
||||
</h1>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
99
src/components/QuestionCard.tsx
Normal file
99
src/components/QuestionCard.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { Button } from './Button';
|
||||
import { Question } from '../types/api';
|
||||
|
||||
interface QuestionCardProps {
|
||||
question: Question;
|
||||
onSubmit: (response: string) => Promise<void>;
|
||||
currentNumber: number;
|
||||
totalQuestions: number;
|
||||
isAnswered?: boolean;
|
||||
previousResponse?: string;
|
||||
}
|
||||
|
||||
export const QuestionCard: React.FC<QuestionCardProps> = ({
|
||||
question,
|
||||
onSubmit,
|
||||
currentNumber,
|
||||
totalQuestions,
|
||||
isAnswered = false,
|
||||
previousResponse = '',
|
||||
}) => {
|
||||
const [response, setResponse] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Set response field to the previous response whenever the question changes or when the previous response or isAnswered status changes
|
||||
useEffect(() => {
|
||||
if (isAnswered && previousResponse) {
|
||||
setResponse(previousResponse);
|
||||
} else if (!isAnswered) {
|
||||
setResponse('');
|
||||
}
|
||||
}, [question.questionId, isAnswered, previousResponse]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!response.trim()) {
|
||||
setError('Please enter your response');
|
||||
return;
|
||||
}
|
||||
|
||||
const wordCount = response.trim().split(/\s+/).length;
|
||||
if (wordCount < question.minWordLength || wordCount > question.maxWordLength) {
|
||||
setError(`Response must be between ${question.minWordLength} and ${question.maxWordLength} words`);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onSubmit(response);
|
||||
} catch (err) {
|
||||
setError('Failed to submit response. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-800 rounded-lg p-6 shadow-xl">
|
||||
<h2 className="text-xl font-semibold text-white mb-4">{question.questionText}</h2>
|
||||
|
||||
<div className="mb-6">
|
||||
<textarea
|
||||
value={response}
|
||||
onChange={(e) => setResponse(e.target.value)}
|
||||
disabled={isAnswered}
|
||||
className="w-full h-32 bg-gray-700 border-2 border-gray-600 rounded-lg p-3 text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
placeholder={isAnswered ? 'Question already answered' : 'Type your response here...'}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">
|
||||
Word count: {response.trim().split(/\s+/).filter(Boolean).length}
|
||||
</span>
|
||||
<span className="text-gray-400">
|
||||
Required: {question.minWordLength}-{question.maxWordLength} words
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 flex items-center gap-2 text-red-500">
|
||||
<X className="w-4 h-4" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || isAnswered}
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
{isAnswered ? 'Already Submitted' : 'Submit Response'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
165
src/components/QuestionForm.tsx
Normal file
165
src/components/QuestionForm.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Button } from './Button';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface QuestionFormProps {
|
||||
onSubmit: (data: {
|
||||
questionText: string;
|
||||
expectedResultText: string;
|
||||
minWordLength: number;
|
||||
maxWordLength: number;
|
||||
}) => void;
|
||||
onCancel: () => void;
|
||||
initialData?: {
|
||||
questionText: string;
|
||||
expectedResultText: string;
|
||||
minWordLength: number;
|
||||
maxWordLength: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const QuestionForm: React.FC<QuestionFormProps> = ({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
initialData,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
questionText: initialData?.questionText || '',
|
||||
expectedResultText: initialData?.expectedResultText || '',
|
||||
minWordLength: initialData?.minWordLength || 50,
|
||||
maxWordLength: initialData?.maxWordLength || 500,
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.questionText.trim()) {
|
||||
newErrors.questionText = 'Question text is required';
|
||||
}
|
||||
|
||||
if (!formData.expectedResultText.trim()) {
|
||||
newErrors.expectedResultText = 'Expected result is required';
|
||||
}
|
||||
|
||||
if (formData.minWordLength < 1) {
|
||||
newErrors.minWordLength = 'Minimum word length must be at least 1';
|
||||
}
|
||||
|
||||
if (formData.maxWordLength <= formData.minWordLength) {
|
||||
newErrors.maxWordLength = 'Maximum word length must be greater than minimum';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (validateForm()) {
|
||||
onSubmit(formData);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50">
|
||||
<div className="absolute inset-0 bg-black bg-opacity-50" onClick={onCancel} />
|
||||
<div className="bg-gray-800 rounded-lg p-6 w-full max-w-2xl relative z-10">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
{initialData ? 'Edit Question' : 'Add New Question'}
|
||||
</h2>
|
||||
<Button variant="secondary" size="sm" onClick={onCancel}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Question Text
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.questionText}
|
||||
onChange={(e) => setFormData({ ...formData, questionText: e.target.value })}
|
||||
className={`w-full bg-gray-700 border ${
|
||||
errors.questionText ? 'border-red-500' : 'border-gray-600'
|
||||
} rounded-lg p-3 text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 transition-colors`}
|
||||
rows={4}
|
||||
placeholder="Enter the question text..."
|
||||
/>
|
||||
{errors.questionText && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.questionText}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Expected Result
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.expectedResultText}
|
||||
onChange={(e) => setFormData({ ...formData, expectedResultText: e.target.value })}
|
||||
className={`w-full bg-gray-700 border ${
|
||||
errors.expectedResultText ? 'border-red-500' : 'border-gray-600'
|
||||
} rounded-lg p-3 text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 transition-colors`}
|
||||
rows={4}
|
||||
placeholder="Enter the expected result..."
|
||||
/>
|
||||
{errors.expectedResultText && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.expectedResultText}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Minimum Word Length
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.minWordLength}
|
||||
onChange={(e) => setFormData({ ...formData, minWordLength: parseInt(e.target.value) || 0 })}
|
||||
className={`w-full bg-gray-700 border ${
|
||||
errors.minWordLength ? 'border-red-500' : 'border-gray-600'
|
||||
} rounded-lg px-4 py-2 text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 transition-colors`}
|
||||
min="1"
|
||||
/>
|
||||
{errors.minWordLength && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.minWordLength}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Maximum Word Length
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.maxWordLength}
|
||||
onChange={(e) => setFormData({ ...formData, maxWordLength: parseInt(e.target.value) || 0 })}
|
||||
className={`w-full bg-gray-700 border ${
|
||||
errors.maxWordLength ? 'border-red-500' : 'border-gray-600'
|
||||
} rounded-lg px-4 py-2 text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 transition-colors`}
|
||||
min={formData.minWordLength + 1}
|
||||
/>
|
||||
{errors.maxWordLength && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.maxWordLength}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4 mt-6">
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
{initialData ? 'Save Changes' : 'Add Question'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
32
src/components/QuestionSetCard.tsx
Normal file
32
src/components/QuestionSetCard.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import React from 'react';
|
||||
import { ChevronRight, Lock } from 'lucide-react';
|
||||
import { QuestionSet } from '../types/api';
|
||||
|
||||
interface QuestionSetCardProps {
|
||||
questionSet: QuestionSet;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const QuestionSetCard: React.FC<QuestionSetCardProps> = ({ questionSet, onClick }) => {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={questionSet.locked}
|
||||
className="w-full bg-gray-800 rounded-lg p-6 text-left transition-colors hover:bg-gray-750 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-1">
|
||||
{questionSet.questionSetName}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400">Question Set #{questionSet.questionSetOrder}</p>
|
||||
</div>
|
||||
{questionSet.locked ? (
|
||||
<Lock className="w-5 h-5 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-blue-500" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
15
src/index.css
Normal file
15
src/index.css
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gray-900 text-white;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-gray-700;
|
||||
}
|
||||
172
src/lib/api.ts
Normal file
172
src/lib/api.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import axios from 'axios';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: 'https://quizconnect.marcus7i.net/api',
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().token;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
useAuthStore.getState().logout();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Auth endpoints
|
||||
export const login = async (pin: string) => {
|
||||
const response = await api.post('/user/login', { pin });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const adminLogin = async (loginId: string) => {
|
||||
const response = await api.post('/admin/login', { loginId });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Admin endpoints
|
||||
export const getAdminQuestionSets = async () => {
|
||||
const response = await api.get('/admin/questionsets');
|
||||
return response.data.questionSets.questionSets;
|
||||
};
|
||||
|
||||
export const getAdminUsers = async () => {
|
||||
const response = await api.get('/admin/users');
|
||||
return response.data.users.users;
|
||||
};
|
||||
|
||||
export const getAdminQuestions = async (questionSetId: string) => {
|
||||
const response = await api.get(`/admin/questions?questionSetId=${questionSetId}`);
|
||||
return response.data.questions.questions;
|
||||
};
|
||||
|
||||
export const getAdminResponses = async (questionId: string) => {
|
||||
const response = await api.get(`/admin/responses?questionId=${questionId}`);
|
||||
return response.data.responses.response || [];
|
||||
};
|
||||
|
||||
export const createUser = async (userName: string) => {
|
||||
const response = await api.post('/admin/users', { userName });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateUser = async (userName: string, newUserName: string) => {
|
||||
const response = await api.put('/admin/users', { userName, newUserName });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const resetUser = async (userName: string) => {
|
||||
const response = await api.put('/admin/users/reset', { userName });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteUser = async (userName: string) => {
|
||||
const response = await api.delete('/admin/users', { data: { userName } });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createQuestionSet = async (questionSetName: string) => {
|
||||
const response = await api.post('/admin/questionsets', { questionSetName });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateQuestionSetName = async (questionSetId: string, newQuestionSetName: string) => {
|
||||
const response = await api.put('/admin/questionsets/name', { questionSetId, newQuestionSetName });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateQuestionSetOrder = async (questionSetId: string, questionSetOrder: number) => {
|
||||
const response = await api.put('/admin/questionsets/order', { questionSetId, questionSetOrder });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const toggleQuestionSetLock = async (questionSetId: string, locked: boolean) => {
|
||||
const response = await api.put('/admin/questionsets/lock', { questionSetId, locked });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteQuestionSet = async (questionSetId: string) => {
|
||||
const response = await api.delete('/admin/questionsets', { data: { questionSetId } });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createQuestion = async (
|
||||
questionSetId: string,
|
||||
questionText: string,
|
||||
expectedResultText: string,
|
||||
questionOrder: number,
|
||||
minWordLength: number,
|
||||
maxWordLength: number
|
||||
) => {
|
||||
const response = await api.post('/admin/questions', {
|
||||
questionSetId,
|
||||
questionText,
|
||||
expectedResultText,
|
||||
questionOrder,
|
||||
minWordLength,
|
||||
maxWordLength,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateQuestion = async (
|
||||
questionId: string,
|
||||
questionText: string,
|
||||
expectedResultText: string,
|
||||
questionOrder: number,
|
||||
minWordLength: number,
|
||||
maxWordLength: number
|
||||
) => {
|
||||
const response = await api.put('/admin/questions', {
|
||||
questionId,
|
||||
questionText,
|
||||
expectedResultText,
|
||||
questionOrder,
|
||||
minWordLength,
|
||||
maxWordLength,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteQuestion = async (questionId: string) => {
|
||||
const response = await api.delete('/admin/questions', { data: { questionId } });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// User endpoints
|
||||
export const getQuestionSets = async () => {
|
||||
const response = await api.get('/user/questionsets');
|
||||
return response.data.questionSets.questionSets;
|
||||
};
|
||||
|
||||
export const getQuestions = async (questionSetId: string) => {
|
||||
const response = await api.get(`/user/questions?questionSetId=${questionSetId}`);
|
||||
return response.data.questions.questions;
|
||||
};
|
||||
|
||||
export const submitResponse = async (questionId: string, responseText: string) => {
|
||||
const response = await api.post('/user/responses', { questionId, responseText });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getUserResponses = async (questionId: string) => {
|
||||
const response = await api.get(`/user/responses?questionId=${questionId}`);
|
||||
return response.data || [];
|
||||
};
|
||||
|
||||
export const checkUserResetState = async () => {
|
||||
const response = await api.get('/user/state');
|
||||
return response.data.resetState;
|
||||
};
|
||||
|
||||
export default api;
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
649
src/pages/AdminDashboard.tsx
Normal file
649
src/pages/AdminDashboard.tsx
Normal file
|
|
@ -0,0 +1,649 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { Button } from '../components/Button';
|
||||
import { Plus, Edit2, Trash2, Lock, Unlock, Users, ChevronDown, ChevronUp, Eye, X } from 'lucide-react';
|
||||
import { QuestionForm } from '../components/QuestionForm';
|
||||
import {
|
||||
getAdminQuestionSets,
|
||||
getAdminUsers,
|
||||
getAdminQuestions,
|
||||
getAdminResponses,
|
||||
createQuestionSet,
|
||||
updateQuestionSetName,
|
||||
updateQuestionSetOrder,
|
||||
toggleQuestionSetLock,
|
||||
deleteQuestionSet,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
resetUser,
|
||||
createQuestion,
|
||||
updateQuestion,
|
||||
deleteQuestion,
|
||||
} from '../lib/api';
|
||||
|
||||
export const AdminDashboard: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [newQuestionSetName, setNewQuestionSetName] = useState('');
|
||||
const [newUserName, setNewUserName] = useState('');
|
||||
const [showUserModal, setShowUserModal] = useState(false);
|
||||
const [selectedQuestionSet, setSelectedQuestionSet] = useState<string | null>(null);
|
||||
const [showResponsesModal, setShowResponsesModal] = useState(false);
|
||||
const [selectedQuestion, setSelectedQuestion] = useState<string | null>(null);
|
||||
|
||||
const [showQuestionModal, setShowQuestionModal] = useState(false);
|
||||
const [currentQuestionSetId, setCurrentQuestionSetId] = useState<string | null>(null);
|
||||
const [editingQuestion, setEditingQuestion] = useState<any | null>(null);
|
||||
const [showQuestionSetModal, setShowQuestionSetModal] = useState(false);
|
||||
const [editingQuestionSet, setEditingQuestionSet] = useState<any | null>(null);
|
||||
|
||||
const [editingUserId, setEditingUserId] = useState<string | null>(null);
|
||||
const [editedUserName, setEditedUserName] = useState('');
|
||||
|
||||
const { data: questionSets, isLoading: loadingQuestionSets } = useQuery({
|
||||
queryKey: ['adminQuestionSets'],
|
||||
queryFn: getAdminQuestionSets,
|
||||
});
|
||||
|
||||
const { data: users, isLoading: loadingUsers } = useQuery({
|
||||
queryKey: ['adminUsers'],
|
||||
queryFn: getAdminUsers,
|
||||
});
|
||||
|
||||
const { data: questions } = useQuery({
|
||||
queryKey: ['adminQuestions', selectedQuestionSet],
|
||||
queryFn: () => selectedQuestionSet ? getAdminQuestions(selectedQuestionSet) : Promise.resolve(null),
|
||||
enabled: !!selectedQuestionSet,
|
||||
});
|
||||
|
||||
const { data: responses } = useQuery({
|
||||
queryKey: ['adminResponses', selectedQuestion],
|
||||
queryFn: () => selectedQuestion ? getAdminResponses(selectedQuestion) : Promise.resolve(null),
|
||||
enabled: !!selectedQuestion,
|
||||
});
|
||||
|
||||
const createQuestionSetMutation = useMutation({
|
||||
mutationFn: createQuestionSet,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['adminQuestionSets'] });
|
||||
setNewQuestionSetName('');
|
||||
},
|
||||
});
|
||||
|
||||
const updateQuestionSetNameMutation = useMutation({
|
||||
mutationFn: ({ questionSetId, newName }: { questionSetId: string; newName: string }) =>
|
||||
updateQuestionSetName(questionSetId, newName),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['adminQuestionSets'] });
|
||||
},
|
||||
});
|
||||
|
||||
const updateQuestionSetOrderMutation = useMutation({
|
||||
mutationFn: ({ questionSetId, questionSetOrder }: { questionSetId: string; questionSetOrder: number }) =>
|
||||
updateQuestionSetOrder(questionSetId, questionSetOrder),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['adminQuestionSets'] });
|
||||
},
|
||||
});
|
||||
|
||||
const toggleLockMutation = useMutation({
|
||||
mutationFn: ({ id, locked }: { id: string; locked: boolean }) =>
|
||||
toggleQuestionSetLock(id, locked),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['adminQuestionSets'] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteQuestionSetMutation = useMutation({
|
||||
mutationFn: deleteQuestionSet,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['adminQuestionSets'] });
|
||||
},
|
||||
});
|
||||
|
||||
const createQuestionMutation = useMutation({
|
||||
mutationFn: (data: {
|
||||
questionSetId: string;
|
||||
questionText: string;
|
||||
expectedResultText: string;
|
||||
questionOrder: number;
|
||||
minWordLength: number;
|
||||
maxWordLength: number;
|
||||
}) => createQuestion(
|
||||
data.questionSetId,
|
||||
data.questionText,
|
||||
data.expectedResultText,
|
||||
data.questionOrder,
|
||||
data.minWordLength,
|
||||
data.maxWordLength
|
||||
),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['adminQuestions', selectedQuestionSet] });
|
||||
},
|
||||
});
|
||||
|
||||
const updateQuestionMutation = useMutation({
|
||||
mutationFn: (data: {
|
||||
questionId: string;
|
||||
questionText: string;
|
||||
expectedResultText: string;
|
||||
questionOrder: number;
|
||||
minWordLength: number;
|
||||
maxWordLength: number;
|
||||
}) => updateQuestion(
|
||||
data.questionId,
|
||||
data.questionText,
|
||||
data.expectedResultText,
|
||||
data.questionOrder,
|
||||
data.minWordLength,
|
||||
data.maxWordLength
|
||||
),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['adminQuestions', selectedQuestionSet] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteQuestionMutation = useMutation({
|
||||
mutationFn: deleteQuestion,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['adminQuestions', selectedQuestionSet] });
|
||||
},
|
||||
});
|
||||
|
||||
const createUserMutation = useMutation({
|
||||
mutationFn: createUser,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['adminUsers'] });
|
||||
setNewUserName('');
|
||||
},
|
||||
});
|
||||
|
||||
const updateUserMutation = useMutation({
|
||||
mutationFn: ({ userName, newUserName }: { userName: string; newUserName: string }) =>
|
||||
updateUser(userName, newUserName),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['adminUsers'] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteUserMutation = useMutation({
|
||||
mutationFn: deleteUser,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['adminUsers'] });
|
||||
},
|
||||
});
|
||||
|
||||
const resetUserMutation = useMutation({
|
||||
mutationFn: resetUser,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['adminUsers'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddQuestion = (questionSetId: string) => {
|
||||
setCurrentQuestionSetId(questionSetId);
|
||||
setEditingQuestion(null);
|
||||
setShowQuestionModal(true);
|
||||
};
|
||||
|
||||
const handleEditQuestion = (question: any) => {
|
||||
setEditingQuestion(question);
|
||||
setCurrentQuestionSetId(question.questionSetId);
|
||||
setShowQuestionModal(true);
|
||||
};
|
||||
|
||||
const handleQuestionFormSubmit = (data: {
|
||||
questionText: string;
|
||||
expectedResultText: string;
|
||||
minWordLength: number;
|
||||
maxWordLength: number;
|
||||
}) => {
|
||||
if (editingQuestion) {
|
||||
updateQuestionMutation.mutate({
|
||||
questionId: editingQuestion.questionId,
|
||||
questionText: data.questionText,
|
||||
expectedResultText: data.expectedResultText,
|
||||
questionOrder: editingQuestion.questionOrder,
|
||||
minWordLength: data.minWordLength,
|
||||
maxWordLength: data.maxWordLength,
|
||||
});
|
||||
} else if (currentQuestionSetId) {
|
||||
createQuestionMutation.mutate({
|
||||
questionSetId: currentQuestionSetId,
|
||||
questionText: data.questionText,
|
||||
expectedResultText: data.expectedResultText,
|
||||
questionOrder: questions?.length || 0,
|
||||
minWordLength: data.minWordLength,
|
||||
maxWordLength: data.maxWordLength,
|
||||
});
|
||||
}
|
||||
|
||||
setShowQuestionModal(false);
|
||||
};
|
||||
|
||||
const handleEditQuestionSet = (set: any) => {
|
||||
setEditingQuestionSet(set);
|
||||
setShowQuestionSetModal(true);
|
||||
};
|
||||
|
||||
const handleQuestionSetFormSubmit = (data: {
|
||||
questionSetName: string;
|
||||
questionSetOrder: number;
|
||||
}) => {
|
||||
if (editingQuestionSet) {
|
||||
if (data.questionSetName !== editingQuestionSet.questionSetName) {
|
||||
updateQuestionSetNameMutation.mutate({
|
||||
questionSetId: editingQuestionSet.questionSetId,
|
||||
newName: data.questionSetName,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.questionSetOrder !== editingQuestionSet.questionSetOrder) {
|
||||
updateQuestionSetOrderMutation.mutate({
|
||||
questionSetId: editingQuestionSet.questionSetId,
|
||||
questionSetOrder: data.questionSetOrder,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setShowQuestionSetModal(false);
|
||||
};
|
||||
|
||||
if (loadingQuestionSets || loadingUsers) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold text-white">Question Sets</h2>
|
||||
<Button
|
||||
onClick={() => setShowUserModal(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
Manage Users
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mb-6">
|
||||
<input
|
||||
type="text"
|
||||
value={newQuestionSetName}
|
||||
onChange={(e) => setNewQuestionSetName(e.target.value)}
|
||||
placeholder="New question set name"
|
||||
className="flex-1 bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white placeholder-gray-400"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => createQuestionSetMutation.mutate(newQuestionSetName)}
|
||||
disabled={!newQuestionSetName.trim()}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Set
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{questionSets?.map((set) => (
|
||||
<div
|
||||
key={set.questionSetId}
|
||||
className="bg-gray-800 rounded-lg overflow-hidden"
|
||||
>
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{set.questionSetName}
|
||||
</h3>
|
||||
<div className="flex items-center gap-4 mt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleEditQuestionSet(set)}
|
||||
>
|
||||
Order: {set.questionSetOrder}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
toggleLockMutation.mutate({
|
||||
id: set.questionSetId,
|
||||
locked: !set.locked,
|
||||
})
|
||||
}
|
||||
>
|
||||
{set.locked ? (
|
||||
<Lock className="w-4 h-4" />
|
||||
) : (
|
||||
<Unlock className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleEditQuestionSet(set)}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
deleteQuestionSetMutation.mutate(set.questionSetId)
|
||||
}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setSelectedQuestionSet(selectedQuestionSet === set.questionSetId ? null : set.questionSetId)}
|
||||
>
|
||||
{selectedQuestionSet === set.questionSetId ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedQuestionSet === set.questionSetId && (
|
||||
<div className="border-t border-gray-700 p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h4 className="text-lg font-semibold text-white">Questions</h4>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleAddQuestion(set.questionSetId)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Question
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{questions?.map((question) => (
|
||||
<div
|
||||
key={question.questionId}
|
||||
className="bg-gray-700 p-4 rounded-lg"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="text-white font-medium mb-2">{question.questionText}</p>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Word limit: {question.minWordLength}-{question.maxWordLength}
|
||||
</p>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
Expected result: {question.expectedResultText}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedQuestion(question.questionId);
|
||||
setShowResponsesModal(true);
|
||||
}}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleEditQuestion(question)}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => deleteQuestionMutation.mutate(question.questionId)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showUserModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-gray-800 rounded-lg p-6 w-full max-w-2xl">
|
||||
<h2 className="text-xl font-bold text-white mb-4">User Management</h2>
|
||||
|
||||
<div className="flex gap-4 mb-6">
|
||||
<input
|
||||
type="text"
|
||||
value={newUserName}
|
||||
onChange={(e) => setNewUserName(e.target.value)}
|
||||
placeholder="New username"
|
||||
className="flex-1 bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white placeholder-gray-400"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => createUserMutation.mutate(newUserName)}
|
||||
disabled={!newUserName.trim()}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add User
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 max-h-96 overflow-y-auto">
|
||||
{users?.map((user) => (
|
||||
<div
|
||||
key={user.username}
|
||||
className="bg-gray-700 p-4 rounded-lg flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
{editingUserId === user.username ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editedUserName}
|
||||
onChange={(e) => setEditedUserName(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (editedUserName.trim() !== '' && editedUserName !== user.username) {
|
||||
updateUserMutation.mutate({
|
||||
userName: user.username,
|
||||
newUserName: editedUserName.trim()
|
||||
});
|
||||
}
|
||||
setEditingUserId(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingUserId(null);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
className="bg-transparent border-b border-blue-500 font-semibold text-white focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
className="font-semibold text-white cursor-pointer hover:text-blue-400"
|
||||
onClick={() => {
|
||||
setEditingUserId(user.username);
|
||||
setEditedUserName(user.username);
|
||||
}}
|
||||
>
|
||||
{user.username}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-400">PIN: {user.pin}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => resetUserMutation.mutate(user.username)}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => deleteUserMutation.mutate(user.username)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowUserModal(false)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showResponsesModal && selectedQuestion && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-gray-800 rounded-lg p-6 w-full max-w-4xl">
|
||||
<h2 className="text-xl font-bold text-white mb-4">Student Responses</h2>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left p-2 text-gray-400">Student</th>
|
||||
<th className="text-left p-2 text-gray-400">Response</th>
|
||||
<th className="text-left p-2 text-gray-400">Timestamp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{responses?.map((response) => (
|
||||
<tr key={response.responseId} className="border-t border-gray-700">
|
||||
<td className="p-2 text-white">{response.userName}</td>
|
||||
<td className="p-2 text-white">{response.responseText}</td>
|
||||
<td className="p-2 text-gray-400">
|
||||
{new Date(response.responseTime).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowResponsesModal(false);
|
||||
setSelectedQuestion(null);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showQuestionModal && (
|
||||
<QuestionForm
|
||||
onSubmit={handleQuestionFormSubmit}
|
||||
onCancel={() => setShowQuestionModal(false)}
|
||||
initialData={editingQuestion ? {
|
||||
questionText: editingQuestion.questionText,
|
||||
expectedResultText: editingQuestion.expectedResultText,
|
||||
minWordLength: editingQuestion.minWordLength,
|
||||
maxWordLength: editingQuestion.maxWordLength
|
||||
} : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showQuestionSetModal && editingQuestionSet && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-gray-800 rounded-lg p-6 w-full max-w-md relative z-10">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
Edit Question Set
|
||||
</h2>
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowQuestionSetModal(false)}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
handleQuestionSetFormSubmit({
|
||||
questionSetName: formData.get('questionSetName') as string,
|
||||
questionSetOrder: parseInt(formData.get('questionSetOrder') as string)
|
||||
});
|
||||
}} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Question Set Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="questionSetName"
|
||||
defaultValue={editingQuestionSet.questionSetName}
|
||||
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 transition-colors"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Order
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="questionSetOrder"
|
||||
defaultValue={editingQuestionSet.questionSetOrder}
|
||||
min="1"
|
||||
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 transition-colors"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4 mt-6">
|
||||
<Button variant="secondary" onClick={() => setShowQuestionSetModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
49
src/pages/Dashboard.tsx
Normal file
49
src/pages/Dashboard.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { QuestionSetCard } from '../components/QuestionSetCard';
|
||||
import { getQuestionSets } from '../lib/api';
|
||||
|
||||
export const Dashboard: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { data: questionSets, isLoading, error } = useQuery({
|
||||
queryKey: ['questionSets'],
|
||||
queryFn: getQuestionSets,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="text-center text-red-500">
|
||||
Failed to load question sets. Please try again later.
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<h2 className="text-2xl font-bold text-white mb-6">Available Question Sets</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{questionSets?.map((questionSet) => (
|
||||
<QuestionSetCard
|
||||
key={questionSet.questionSetId}
|
||||
questionSet={questionSet}
|
||||
onClick={() => navigate(`/quiz/${questionSet.questionSetId}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
66
src/pages/Login.tsx
Normal file
66
src/pages/Login.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Brain } from 'lucide-react';
|
||||
import { CodeInput } from '../components/CodeInput';
|
||||
import { Button } from '../components/Button';
|
||||
import { login, adminLogin } from '../lib/api';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
|
||||
export const Login: React.FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const setToken = useAuthStore(state => state.setToken);
|
||||
|
||||
const handleLogin = async (code: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
const response = isAdmin
|
||||
? await adminLogin(code)
|
||||
: await login(code);
|
||||
|
||||
if (response.success) {
|
||||
setToken(response.accessToken, isAdmin);
|
||||
navigate(isAdmin ? '/admin' : '/dashboard');
|
||||
} else {
|
||||
setError(response.errorMessage || 'Login failed');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An error occurred. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex flex-col items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Brain className="w-16 h-16 text-blue-500 mx-auto mb-4" />
|
||||
<h1 className="text-4xl font-bold text-white mb-2">QuizConnect</h1>
|
||||
<p className="text-gray-400">Enter your {isAdmin ? 'admin' : 'user'} code to continue</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg p-8 shadow-xl">
|
||||
<div className="flex justify-center mb-8">
|
||||
<CodeInput onComplete={handleLogin} />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-500 text-center mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setIsAdmin(!isAdmin)}
|
||||
className="mt-4"
|
||||
>
|
||||
Switch to {isAdmin ? 'User' : 'Admin'} Login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
190
src/pages/Quiz.tsx
Normal file
190
src/pages/Quiz.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { QuestionCard } from '../components/QuestionCard';
|
||||
import { Button } from '../components/Button';
|
||||
import { Home, ChevronLeft, ChevronRight, Check } from 'lucide-react';
|
||||
import { getQuestions, submitResponse, getQuestionSets, getUserResponses } from '../lib/api';
|
||||
|
||||
export const Quiz: React.FC = () => {
|
||||
const { questionSetId } = useParams<{ questionSetId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [answeredQuestions, setAnsweredQuestions] = useState<Set<number>>(new Set());
|
||||
|
||||
const { data: questions, isLoading: loadingQuestions } = useQuery({
|
||||
queryKey: ['questions', questionSetId],
|
||||
queryFn: () => getQuestions(questionSetId!),
|
||||
});
|
||||
|
||||
const { data: questionSets } = useQuery({
|
||||
queryKey: ['questionSets'],
|
||||
queryFn: getQuestionSets,
|
||||
});
|
||||
|
||||
// Get user responses for current question
|
||||
const { data: userResponseData } = useQuery({
|
||||
queryKey: ['userResponses', questions?.[currentQuestionIndex]?.questionId],
|
||||
queryFn: () => questions?.[currentQuestionIndex]
|
||||
? getUserResponses(questions[currentQuestionIndex].questionId)
|
||||
: Promise.resolve(null),
|
||||
enabled: !!questions?.[currentQuestionIndex],
|
||||
});
|
||||
|
||||
// Extract actual response from the data structure
|
||||
const userResponse = userResponseData?.responses?.response?.[0]?.responseText || '';
|
||||
const hasResponse = !!userResponseData?.responses?.response?.length;
|
||||
|
||||
// Reset current question index when questionset changes
|
||||
useEffect(() => {
|
||||
setCurrentQuestionIndex(0);
|
||||
setAnsweredQuestions(new Set());
|
||||
}, [questionSetId]);
|
||||
|
||||
// Check if questions are already answered
|
||||
useEffect(() => {
|
||||
if (hasResponse) {
|
||||
setAnsweredQuestions(prev => new Set([...prev, currentQuestionIndex]));
|
||||
}
|
||||
}, [hasResponse, currentQuestionIndex]);
|
||||
|
||||
// Check if all questions are answered
|
||||
const allQuestionsAnswered = questions && questions.length > 0 &&
|
||||
answeredQuestions.size === questions.length;
|
||||
|
||||
const handleSubmit = async (response: string) => {
|
||||
if (!questions) return;
|
||||
|
||||
await submitResponse(questions[currentQuestionIndex].questionId, response);
|
||||
setAnsweredQuestions(prev => new Set([...prev, currentQuestionIndex]));
|
||||
|
||||
// Automatically navigate to next question after answering
|
||||
if (currentQuestionIndex < questions.length - 1) {
|
||||
handleNavigate('next');
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigate = (direction: 'prev' | 'next') => {
|
||||
if (!questions) return;
|
||||
|
||||
if (direction === 'prev' && currentQuestionIndex > 0) {
|
||||
setCurrentQuestionIndex(currentQuestionIndex - 1);
|
||||
} else if (direction === 'next' && currentQuestionIndex < questions.length - 1) {
|
||||
setCurrentQuestionIndex(currentQuestionIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDone = () => {
|
||||
// Check for next set or navigate to dashboard
|
||||
if (!questionSets) return;
|
||||
|
||||
const currentSetIndex = questionSets.findIndex((set: { questionSetId: string | undefined; }) => set.questionSetId === questionSetId);
|
||||
const nextSet = questionSets
|
||||
.slice(currentSetIndex + 1)
|
||||
.find((set: { locked: any; }) => !set.locked);
|
||||
|
||||
if (nextSet) {
|
||||
navigate(`/quiz/${nextSet.questionSetId}`);
|
||||
} else {
|
||||
navigate('/dashboard', {
|
||||
state: {
|
||||
message: 'Congratulations! You have completed all available question sets.'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingQuestions) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!questions || questions.length === 0) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="text-center text-red-500">
|
||||
Failed to load questions. Please try again later.
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Home className="w-4 h-4" />
|
||||
Home
|
||||
</Button>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleNavigate('prev')}
|
||||
disabled={currentQuestionIndex === 0}
|
||||
className={`flex items-center gap-2 ${currentQuestionIndex === 0 ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
{allQuestionsAnswered ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleDone}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
Done
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleNavigate('next')}
|
||||
disabled={currentQuestionIndex === questions.length - 1}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between text-sm text-gray-400 mb-2">
|
||||
<span>Progress: {answeredQuestions.size} of {questions.length} questions completed</span>
|
||||
<span>Question {currentQuestionIndex + 1} of {questions.length}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-700 rounded-full">
|
||||
<div
|
||||
className="h-2 bg-blue-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${(answeredQuestions.size / questions.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{questions[currentQuestionIndex] && (
|
||||
<QuestionCard
|
||||
question={questions[currentQuestionIndex]}
|
||||
onSubmit={handleSubmit}
|
||||
currentNumber={currentQuestionIndex + 1}
|
||||
totalQuestions={questions.length}
|
||||
isAnswered={answeredQuestions.has(currentQuestionIndex)}
|
||||
previousResponse={userResponse}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
29
src/store/auth.ts
Normal file
29
src/store/auth.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
isAdmin: boolean;
|
||||
setToken: (token: string, isAdmin: boolean) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
isAdmin: false,
|
||||
setToken: (token, isAdmin) => {
|
||||
localStorage.setItem('token', token);
|
||||
set({ token, isAdmin });
|
||||
},
|
||||
logout: () => {
|
||||
localStorage.removeItem('token');
|
||||
set({ token: null, isAdmin: false });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
}
|
||||
)
|
||||
);
|
||||
36
src/types/api.ts
Normal file
36
src/types/api.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
export interface LoginResponse {
|
||||
success: boolean;
|
||||
accessToken: string;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
export interface QuestionSet {
|
||||
questionSetId: string;
|
||||
questionSetName: string;
|
||||
questionSetOrder: number;
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
questionId: string;
|
||||
questionSetId: string;
|
||||
questionText: string;
|
||||
expectedResultText: string;
|
||||
questionOrder: number;
|
||||
minWordLength: number;
|
||||
maxWordLength: number;
|
||||
}
|
||||
|
||||
export interface Response {
|
||||
responseId: string;
|
||||
questionId: string;
|
||||
userName: string;
|
||||
responseText: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
username: string;
|
||||
pin: string;
|
||||
resetState: boolean;
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
Loading…
Add table
Add a link
Reference in a new issue