Initial commit

This commit is contained in:
MarcUs7i 2025-04-18 19:13:47 +02:00
commit ea82926500
58 changed files with 9323 additions and 0 deletions

30
src/App.tsx Normal file
View 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
View 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>
);
};

View 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
View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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
View 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
View 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
View 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>
);

View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />